Skip to content

feat: implement JWT-authenticated WebSocket event system with Redis Pub/Sub scaling#261

Open
onajidavid87-web wants to merge 1 commit into
nexoraorg:mainfrom
onajidavid87-web:main
Open

feat: implement JWT-authenticated WebSocket event system with Redis Pub/Sub scaling#261
onajidavid87-web wants to merge 1 commit into
nexoraorg:mainfrom
onajidavid87-web:main

Conversation

@onajidavid87-web

@onajidavid87-web onajidavid87-web commented Jul 20, 2026

Copy link
Copy Markdown

Implemented a scalable real-time WebSocket event system using Express and Socket.IO with JWT authentication, Redis Pub/Sub horizontal scaling, and namespace-based routing (/alerts, /scores, /transactions). Included a resilient React WebSocketProvider with offline message queuing, custom hooks (useWebSocket, useWebSocketEvent), event deduplication, and integration stress testing for 100+ concurrent connections under 100ms latency.

Closes #259

Summary by CodeRabbit

  • New Features

    • Added real-time WebSocket updates for alerts, credit scores, transactions, and system metrics.
    • Added authenticated connections with automatic reconnection, offline message queuing, and event subscriptions.
    • Added support for scaling real-time connections across multiple backend instances.
  • Documentation

    • Added WebSocket architecture and deployment guidance.
  • Tests

    • Added coverage for authentication, event delivery, heartbeat handling, concurrency, and latency.

…th Redis Pub/Sub scaling

Implemented a scalable real-time WebSocket event system using Express and Socket.IO with JWT authentication, Redis Pub/Sub horizontal scaling, and namespace-based routing (/alerts, /scores, /transactions). Included a resilient React WebSocketProvider with offline message queuing, custom hooks (useWebSocket, useWebSocketEvent), event deduplication, and integration stress testing for 100+ concurrent connections under 100ms latency.

Closes nexoraorg#259
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

WebSocket infrastructure

Layer / File(s) Summary
Backend Socket.IO server and authentication
backend/package.json, backend/src/websocket/index.ts
Adds JWT-authenticated namespaces, room subscriptions, heartbeat handling, Redis adapter support, and lifecycle cleanup.
Backend broadcasting and deployment wiring
backend/src/services/websocketService.ts, backend/src/index.ts, backend/docker-compose.ws-scale.yml, docs/websocket-architecture.md
Adds typed event broadcasters, HTTP server integration, Redis-backed scaling configuration, and architecture documentation.
Frontend provider and hooks
frontend/src/components/WebSocketProvider.tsx, frontend/src/types/websocket.ts, frontend/src/hooks/*, frontend/package.json
Adds multi-namespace Socket.IO connections, queued messages, deduplication, subscriptions, typed state, and reconnect backoff.
WebSocket validation
backend/src/__tests__/websocket.test.ts, frontend/src/components/__tests__/WebSocketProvider.test.tsx, tests/integration/websocket.integration.test.ts, tests/integration/package.json
Tests authentication, heartbeat, broadcasting, frontend event handling, and delivery latency across 105 concurrent clients.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: authenticated WebSocket event system with Redis scaling.
Linked Issues check ✅ Passed The changes satisfy the linked issue's backend, auth, scaling, frontend, testing, documentation, and Docker Compose requirements.
Out of Scope Changes check ✅ Passed The added files and refactors all support the WebSocket system and its tests/docs, with no unrelated changes apparent.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (4)
backend/package.json (1)

63-63: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove @types/socket.io dependency.

Socket.IO v3 and v4 include their own TypeScript definitions out of the box. The @types/socket.io package is deprecated for these versions and may cause type conflicts or masking of the official types.

♻️ Proposed fix
-    "`@types/socket.io`": "^3.0.2",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/package.json` at line 63, Remove the deprecated `@types/socket.io`
entry from backend/package.json dependencies, leaving the official Socket.IO
package to provide its bundled TypeScript definitions.
frontend/src/components/WebSocketProvider.tsx (2)

196-206: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

reconnectTimerRef appears to be dead code. It is declared (Line 76) and only ever cleared here; it is never assigned a timer. Socket.IO's built-in reconnection is already enabled, so either wire this into a manual backoff path or remove it to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/WebSocketProvider.tsx` around lines 196 - 206, Remove
the unused reconnectTimerRef declaration and its clearTimeout cleanup from
disconnect, since no manual reconnect timer is assigned and Socket.IO handles
reconnection. Keep the socket cleanup, status update, and reconnect-attempt
reset in disconnect unchanged.

217-217: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

String.prototype.substr is deprecated. Use slice(2, 11) (or crypto.randomUUID() where available) instead.

♻️ Suggested change
-        id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+        id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/WebSocketProvider.tsx` at line 217, Update the
message ID generation expression near the WebSocket provider’s message
construction to replace deprecated String.prototype.substr with slice(2, 11),
preserving the existing random suffix length and ID format.
frontend/src/components/__tests__/WebSocketProvider.test.tsx (1)

16-33: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mock's single-slot handler capture makes the useWebSocketEvent test order-dependent, not a real isolation test.

The mock's on() tracks only the most-recently-registered handler per event name (connectHandler, alertHandler) across all mocked socket instances. Since WebSocketProvider itself registers an internal 'alert:new' listener for /alerts (per its source) in addition to whatever useWebSocketEvent's subscribe() registers, alertHandler in this test is bound to whichever registration happens to run last — an internal implementation detail, not something the test explicitly controls. The test's intent ("receives events via useWebSocketEvent hook") isn't reliably isolated from the provider's own built-in handling.

Consider tracking registrations by call index/socket instance (e.g., store all on calls in an array and invoke handlers by matching event name explicitly per assertion) rather than relying on a single mutable capture variable per event name.

Also applies to: 94-108

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/__tests__/WebSocketProvider.test.tsx` around lines 16
- 33, Make the socket.io mock in WebSocketProvider tests capture every on
registration per socket instance or call rather than overwriting shared
connectHandler and alertHandler variables. Update the useWebSocketEvent
assertions to select and invoke the intended alert:new registration explicitly,
keeping the provider’s internal listener separate so the test does not depend on
registration order.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/src/websocket/index.ts`:
- Around line 126-132: Restrict the subscribe handler so clients cannot join
arbitrary rooms. Validate the requested room against the authenticated user’s
identity and roles using the existing authorization context, and call
socket.join only for permitted rooms; otherwise reject or ignore the request
without emitting subscribed. Update the socket.on('subscribe') flow and preserve
server-controlled room membership.

In `@frontend/src/components/WebSocketProvider.tsx`:
- Around line 79-90: The shared seenEventIdsRef incorrectly suppresses identical
events across independent consumers; scope deduplication to each
subscription/handler or restrict it to the provider’s internal recent* state
updates. Update frontend/src/components/WebSocketProvider.tsx lines 79-90
accordingly; frontend/src/hooks/useWebSocketEvent.ts lines 23-34 requires no
direct change and should receive events once the provider no longer shares
global dedup state.
- Around line 113-193: Prevent connect’s identity from changing when consumers
provide inline callbacks by storing onConnectionChange, onError, onAlert,
onScoreUpdate, and onTransaction in refs and invoking the current ref values
inside the socket handlers. Update the connect dependency list to use those
stable refs while preserving callback behavior, so the auto-connect effect does
not repeatedly disconnect and recreate sockets on parent renders.
- Around line 229-247: Update subscribe and the socket lifecycle to maintain a
ref-backed registry of namespace/event/callback subscriptions instead of binding
only to the socket returned at call time. Ensure subscriptions registered before
a socket exists are bound when that namespace socket is created, and existing
subscriptions are detached from replaced sockets and reattached to the new
instances during connect/reconnect, while preserving duplicate-event filtering
and unsubscribe behavior.
- Around line 132-160: Update the WebSocketProvider connection handlers to track
status, reconnect attempts, and errors independently for each namespace/socket
rather than overwriting shared state. Derive the exposed aggregate status and
onConnectionChange notifications from all required namespaces, so one namespace
disconnecting or reconnecting does not report the whole connection as
disconnected while others remain connected. Keep connect events scoped to their
namespace and only reset aggregate reconnect state when no namespace is still
reconnecting.

In `@tests/integration/websocket.integration.test.ts`:
- Line 83: Update the alert client filtering around connectedSockets to avoid
the private socket.nsp field. Preserve each socket’s namespace string when
creating the clients and filter using that stored namespace, selecting entries
whose namespace ends with “/alerts”.

---

Nitpick comments:
In `@backend/package.json`:
- Line 63: Remove the deprecated `@types/socket.io` entry from
backend/package.json dependencies, leaving the official Socket.IO package to
provide its bundled TypeScript definitions.

In `@frontend/src/components/__tests__/WebSocketProvider.test.tsx`:
- Around line 16-33: Make the socket.io mock in WebSocketProvider tests capture
every on registration per socket instance or call rather than overwriting shared
connectHandler and alertHandler variables. Update the useWebSocketEvent
assertions to select and invoke the intended alert:new registration explicitly,
keeping the provider’s internal listener separate so the test does not depend on
registration order.

In `@frontend/src/components/WebSocketProvider.tsx`:
- Around line 196-206: Remove the unused reconnectTimerRef declaration and its
clearTimeout cleanup from disconnect, since no manual reconnect timer is
assigned and Socket.IO handles reconnection. Keep the socket cleanup, status
update, and reconnect-attempt reset in disconnect unchanged.
- Line 217: Update the message ID generation expression near the WebSocket
provider’s message construction to replace deprecated String.prototype.substr
with slice(2, 11), preserving the existing random suffix length and ID format.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 119863b2-0d7b-4e23-83d7-c62e3aedd364

📥 Commits

Reviewing files that changed from the base of the PR and between 44988d2 and 7d30163.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (16)
  • backend/docker-compose.ws-scale.yml
  • backend/package.json
  • backend/src/__tests__/websocket.test.ts
  • backend/src/index.ts
  • backend/src/services/websocketService.ts
  • backend/src/websocket/index.ts
  • docs/websocket-architecture.md
  • frontend/package.json
  • frontend/src/components/WebSocketProvider.tsx
  • frontend/src/components/__tests__/WebSocketProvider.test.tsx
  • frontend/src/hooks/index.ts
  • frontend/src/hooks/useWebSocket.ts
  • frontend/src/hooks/useWebSocketEvent.ts
  • frontend/src/types/websocket.ts
  • tests/integration/package.json
  • tests/integration/websocket.integration.test.ts

Comment on lines +126 to +132
// Handle explicit room join requests
socket.on('subscribe', (room: string) => {
if (typeof room === 'string' && room.trim()) {
socket.join(room);
socket.emit('subscribed', { room });
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Insecure room joining allows clients to intercept private messages.

Allowing clients to join arbitrary rooms via the subscribe event enables any user to join sensitive rooms (e.g., user:<other_user_id> or role:admin) and intercept private broadcasts.

Room membership should be controlled strictly by the server based on the user's authenticated identity and roles, or the requested room must be validated against the user's permissions before calling socket.join(room).

🔒️ Proposed fix to restrict room subscriptions
       // Handle explicit room join requests
       socket.on('subscribe', (room: string) => {
         if (typeof room === 'string' && room.trim()) {
-          socket.join(room);
-          socket.emit('subscribed', { room });
+          // Example validation: only allow joining public rooms or explicitly authorized rooms
+          if (room.startsWith('public:')) {
+            socket.join(room);
+            socket.emit('subscribed', { room });
+          } else {
+            socket.emit('error', { message: 'Unauthorized room subscription' });
+          }
         }
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Handle explicit room join requests
socket.on('subscribe', (room: string) => {
if (typeof room === 'string' && room.trim()) {
socket.join(room);
socket.emit('subscribed', { room });
}
});
// Handle explicit room join requests
socket.on('subscribe', (room: string) => {
if (typeof room === 'string' && room.trim()) {
// Example validation: only allow joining public rooms or explicitly authorized rooms
if (room.startsWith('public:')) {
socket.join(room);
socket.emit('subscribed', { room });
} else {
socket.emit('error', { message: 'Unauthorized room subscription' });
}
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/websocket/index.ts` around lines 126 - 132, Restrict the
subscribe handler so clients cannot join arbitrary rooms. Validate the requested
room against the authenticated user’s identity and roles using the existing
authorization context, and call socket.join only for permitted rooms; otherwise
reject or ignore the request without emitting subscribed. Update the
socket.on('subscribe') flow and preserve server-controlled room membership.

Comment on lines +79 to +90
const isDuplicateEvent = useCallback((eventId?: string, data?: any): boolean => {
const id = eventId || (data?.id ? String(data.id) : `${data?.timestamp}-${JSON.stringify(data).slice(0, 30)}`);
if (seenEventIdsRef.current.has(id)) {
return true;
}
seenEventIdsRef.current.add(id);
if (seenEventIdsRef.current.size > 200) {
const firstKey = seenEventIdsRef.current.values().next().value;
if (firstKey) seenEventIdsRef.current.delete(firstKey);
}
return false;
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Shared dedup set suppresses events across independent consumers. A single seenEventIdsRef is consulted by both the provider's internal namespace handlers and every subscribe/useWebSocketEvent wrapper, so once any consumer marks an event id as seen, all other consumers of the same event drop it.

  • frontend/src/components/WebSocketProvider.tsx#L79-L90: scope dedup per subscription/handler (or dedup only for the internal recent* state updates), not a single global set consulted by every consumer.
  • frontend/src/hooks/useWebSocketEvent.ts#L23-L34: once the provider stops sharing dedup state across consumers, this hook's callback will actually receive events instead of being treated as a duplicate of the provider's internal handler.
📍 Affects 2 files
  • frontend/src/components/WebSocketProvider.tsx#L79-L90 (this comment)
  • frontend/src/hooks/useWebSocketEvent.ts#L23-L34
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/WebSocketProvider.tsx` around lines 79 - 90, The
shared seenEventIdsRef incorrectly suppresses identical events across
independent consumers; scope deduplication to each subscription/handler or
restrict it to the provider’s internal recent* state updates. Update
frontend/src/components/WebSocketProvider.tsx lines 79-90 accordingly;
frontend/src/hooks/useWebSocketEvent.ts lines 23-34 requires no direct change
and should receive events once the provider no longer shares global dedup state.

Comment on lines +113 to +193
const connect = useCallback(async (authToken?: string) => {
const jwtToken = authToken || token || (typeof window !== 'undefined' ? localStorage.getItem('token') : null) || '';

setStatus('connecting');

// Connect to monitoring system
const connect = useCallback(async () => {
if (monitorRef.current) {
return;
}
NAMESPACES.forEach(ns => {
if (socketsRef.current.has(ns)) {
socketsRef.current.get(ns)?.disconnect();
}

try {
const monitor = initializeMonitor();
await monitor.start();

// Get initial metrics
const dashboardData = await monitor.getDashboardData();
setMetrics(dashboardData.overview.realTimeMetrics);

} catch (error) {
setState(prev => ({
...prev,
lastError: error instanceof Error ? error.message : t('errors.connectionError')
}));
throw error;
}
}, [initializeMonitor]);
const socket = SocketIOClient(`${url}${ns}`, {
auth: { token: jwtToken },
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: maxReconnectAttempts,
reconnectionDelay: reconnectInterval,
autoConnect: true
});

// Disconnect from monitoring system
const disconnect = useCallback(async () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
socket.on('connect', () => {
setStatus('connected');
setLastConnected(new Date());
setLastError(undefined);
setReconnectAttempts(0);
onConnectionChange?.('connected');
flushQueue();
});

if (monitorRef.current) {
await monitorRef.current.stop();
monitorRef.current = null;
socket.on('connect_error', (err: Error) => {
setLastError(err.message);
setStatus('error');
onError?.(err);
});

socket.on('reconnect_attempt', (attempt: number) => {
setStatus('reconnecting');
setReconnectAttempts(attempt);
onConnectionChange?.('reconnecting');
});

socket.on('disconnect', (reason: string) => {
if (reason === 'io server disconnect') {
// Severed by server, try manual reconnect
socket.connect();
}
setStatus('disconnected');
onConnectionChange?.('disconnected');
});

// Register Namespace Specific Real-Time Event Handlers
if (ns === '/alerts') {
socket.on('alert:new', (alert: FraudAlertEvent) => {
if (isDuplicateEvent(alert.id, alert)) return;
setRecentAlerts(prev => [alert, ...prev.slice(0, 19)]);
onAlert?.(alert);
});
}

if (ns === '/scores') {
socket.on('score:updated', (score: CreditScoreEvent) => {
if (isDuplicateEvent(undefined, score)) return;
setRecentScoreUpdates(prev => [score, ...prev.slice(0, 19)]);
onScoreUpdate?.(score);
});
}

if (ns === '/transactions') {
socket.on('transaction:new', (tx: TransactionEventPayload) => {
if (isDuplicateEvent(tx.transactionId, tx)) return;
setRecentTransactions(prev => [tx, ...prev.slice(0, 49)]);
onTransaction?.(tx);
});

socket.on('metrics:update', (sysMetrics: SystemMetricsEvent) => {
setMetrics(sysMetrics);
});
}

socketsRef.current.set(ns, socket);
});
}, [url, token, maxReconnectAttempts, reconnectInterval, onConnectionChange, onError, onAlert, onScoreUpdate, onTransaction, isDuplicateEvent, flushQueue]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

connect identity churn can cause disconnect/reconnect loops.

connect depends on onConnectionChange, onError, onAlert, onScoreUpdate, onTransaction (Line 193). If a consumer passes inline callbacks (not memoized), connect is a new function each render, so the auto-connect effect at Lines 250-260 re-runs its cleanup (disconnect()) then connect() on every parent render, tearing down and rebuilding all sockets. Consider storing the callbacks in refs so connect stays stable, or narrow the effect dependencies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/WebSocketProvider.tsx` around lines 113 - 193,
Prevent connect’s identity from changing when consumers provide inline callbacks
by storing onConnectionChange, onError, onAlert, onScoreUpdate, and
onTransaction in refs and invoking the current ref values inside the socket
handlers. Update the connect dependency list to use those stable refs while
preserving callback behavior, so the auto-connect effect does not repeatedly
disconnect and recreate sockets on parent renders.

Comment on lines +132 to +160
socket.on('connect', () => {
setStatus('connected');
setLastConnected(new Date());
setLastError(undefined);
setReconnectAttempts(0);
onConnectionChange?.('connected');
flushQueue();
});

if (monitorRef.current) {
await monitorRef.current.stop();
monitorRef.current = null;
socket.on('connect_error', (err: Error) => {
setLastError(err.message);
setStatus('error');
onError?.(err);
});

socket.on('reconnect_attempt', (attempt: number) => {
setStatus('reconnecting');
setReconnectAttempts(attempt);
onConnectionChange?.('reconnecting');
});

socket.on('disconnect', (reason: string) => {
if (reason === 'io server disconnect') {
// Severed by server, try manual reconnect
socket.connect();
}
setStatus('disconnected');
onConnectionChange?.('disconnected');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Aggregate connection state is incorrect for multi-namespace sockets.

A single status (plus reconnectAttempts/lastError) is shared across three independent sockets. Each socket's connect/disconnect/connect_error/reconnect_attempt handler overwrites the global state, so whichever socket fires last wins. A single namespace disconnecting flips status to 'disconnected' and fires onConnectionChange('disconnected') even while /alerts and /scores remain connected, and setReconnectAttempts(0) on any one connect masks ongoing reconnects on the others. Track state per namespace and derive an aggregate (e.g., connected only when all/required namespaces are connected).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/WebSocketProvider.tsx` around lines 132 - 160, Update
the WebSocketProvider connection handlers to track status, reconnect attempts,
and errors independently for each namespace/socket rather than overwriting
shared state. Derive the exposed aggregate status and onConnectionChange
notifications from all required namespaces, so one namespace disconnecting or
reconnecting does not report the whole connection as disconnected while others
remain connected. Keep connect events scoped to their namespace and only reset
aggregate reconnect state when no namespace is still reconnecting.

Comment on lines +229 to +247
const subscribe = useCallback(<T = any>(
namespace: WebSocketNamespace | string,
eventName: string,
callback: (data: T) => void
): (() => void) => {
const socket = socketsRef.current.get(namespace);
if (socket) {
const wrappedHandler = (data: T) => {
if (!isDuplicateEvent(undefined, data)) {
callback(data);
}
};
socket.on(eventName, wrappedHandler);
return () => {
socket.off(eventName, wrappedHandler);
};
}
return () => {};
}, [isDuplicateEvent]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

subscribe attaches to a socket captured at call time and never re-attaches.

subscribe reads socketsRef.current.get(namespace) once. Two problems make consumer subscriptions unreliable:

  1. Child effects run before the parent's auto-connect effect, so on mount the sockets map is usually empty and subscribe returns the no-op () => {} (Line 246). Because subscribe is stable (deps [isDuplicateEvent]), useWebSocketEvent's effect never re-runs to retry once sockets exist.
  2. When connect() recreates sockets (reconnect/prop change), existing subscriptions still point at the old, disconnected socket instance.

Consider maintaining a registry of subscriptions in a ref and (re)binding them whenever a namespace socket is created, so handlers survive socket recreation and late connects.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/WebSocketProvider.tsx` around lines 229 - 247, Update
subscribe and the socket lifecycle to maintain a ref-backed registry of
namespace/event/callback subscriptions instead of binding only to the socket
returned at call time. Ensure subscriptions registered before a socket exists
are bound when that namespace socket is created, and existing subscriptions are
detached from replaced sockets and reattached to the new instances during
connect/reconnect, while preserving duplicate-event filtering and unsubscribe
behavior.

console.log(`Connected ${CONCURRENT_CLIENT_COUNT} clients concurrently in ${connectionDuration}ms`);

// 2. Measure Broadcast Message Delivery Latency (< 100ms requirement)
const alertClients = connectedSockets.filter(s => s.nsp.endsWith('/alerts'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the shipped type definitions for socket.io-client's Socket class
fd -HI 'socket.d.ts' node_modules/socket.io-client 2>/dev/null
rg -n 'nsp' node_modules/socket.io-client/build/**/socket.d.ts 2>/dev/null

Repository: nexoraorg/chenaikit

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## package.json dependency hints\n'
rg -n '"socket\.io-client"|socket\.io-client' package.json package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null || true

printf '\n## relevant test context\n'
sed -n '1,140p' tests/integration/websocket.integration.test.ts

Repository: nexoraorg/chenaikit

Length of output: 4617


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## search for socket.io client API usage in repo\n'
rg -n '\.nsp\b|socket\.nsp|namespace' tests src . 2>/dev/null | sed -n '1,120p'

Repository: nexoraorg/chenaikit

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json, os, re
from pathlib import Path

for path in ["package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"]:
    p = Path(path)
    if p.exists():
        print(f"## {path}")
        if path == "package.json":
            data = json.loads(p.read_text())
            deps = {}
            deps.update(data.get("dependencies", {}))
            deps.update(data.get("devDependencies", {}))
            for k,v in deps.items():
                if "socket.io-client" in k or "socket.io" in k:
                    print(k, v)
        else:
            text = p.read_text(errors="ignore")
            m = re.search(r'socket\.io-client[^\n]*', text)
            if m:
                print(m.group(0)[:300])
PY

Repository: nexoraorg/chenaikit

Length of output: 209


🌐 Web query:

socket.io-client v4 Socket nsp public property docs TypeScript definition

💡 Result:

In socket.io-client v4, the nsp property on the Socket class is a private property of type string [1][2][3]. It represents the namespace to which the socket instance belongs [4][5]. Because it is marked as private within the Socket class definition [1][2][3], it is not intended for public access in TypeScript and will not be accessible via type-checked code on a Socket instance. On the server-side version of Socket.IO, socket.nsp refers to the Namespace object [6][7], which is a public-facing property that allows interaction with the namespace (e.g., broadcasting events). However, this distinction is important because the client-side Socket instance does not expose this same Namespace object as a public property [8][1][2]. The client-side Socket's nsp property is used internally to track the socket's namespace assignment [1][2].

Citations:


🌐 Web query:

socket.io-client 4.8.3 Socket nsp private TypeScript declaration

💡 Result:

In socket.io-client version 4.8.3, the nsp property on the Socket class is marked as private in the TypeScript declarations [1][2][3]. Consequently, TypeScript will flag an error if you attempt to access it directly (e.g., socket.nsp) [4]. This property is intended for internal use by the library to manage namespace routing for packets [2]. While it is public in the server-side Socket.IO implementation (allowing access to the Namespace instance) [4][5], the client-side Socket class uses it as a private string to identify which namespace the specific socket instance is connected to [2][3]. If you need to identify the namespace a client-side socket is connected to, consider storing the namespace string explicitly when you initialize the connection: const namespace = "/my-namespace"; const socket = io(namespace); // Instead of accessing socket.nsp, use your stored variable console.log(namespace); If you absolutely must access this private property in a TypeScript environment, you can bypass the type check by casting the socket instance to any: const nsp = (socket as any).nsp; However, this is not recommended as it relies on internal, undocumented behavior that is subject to change without notice.

Citations:


🌐 Web query:

site:socket.io/docs/v4/client-socket-instance nsp socket.io-client

💡 Result:

In the context of the socket.io-client library, nsp generally refers to a namespace. Namespaces allow you to split the application logic over a single shared connection [1]. While the provided documentation highlights the Socket instance properties such as socket.io (a reference to the Manager), it does not explicitly document an nsp property on the client-side Socket instance [1]. To connect to a specific namespace from the client, you define the namespace in the connection URL string [1]. Example of connecting to a specific namespace: const socket = io("http://localhost:3000/my-namespace"); In this example, /my-namespace is the namespace (nsp) being targeted [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## tsconfig files\n'
git ls-files '*tsconfig*.json' '*jest*.js' '*jest*.ts' 'package.json' | sed -n '1,80p'

printf '\n## package.json scripts and test tooling\n'
python3 - <<'PY'
import json
from pathlib import Path
p = Path('package.json')
if p.exists():
    data = json.loads(p.read_text())
    for k,v in data.get('scripts', {}).items():
        if 'test' in k.lower() or 'type' in k.lower() or 'lint' in k.lower():
            print(f'{k}: {v}')
    for sec in ('devDependencies','dependencies'):
        for name in ('typescript','ts-jest','jest','vitest','babel-jest'):
            if name in data.get(sec, {}):
                print(f'{sec}:{name}={data[sec][name]}')
PY

printf '\n## relevant tsconfig excerpt\n'
for f in $(git ls-files '*tsconfig*.json'); do
  echo "--- $f"
  sed -n '1,220p' "$f" | sed -n '1,160p'
done

Repository: nexoraorg/chenaikit

Length of output: 6391


🌐 Web query:

socket.io-client 4.8.3 lib/socket.ts nsp private Socket class

💡 Result:

In socket.io-client version 4.8.3, the Socket class defines the nsp property as a private member [1][2]. Within the lib/socket.ts source file, it is declared as: private readonly nsp: string; This property is intended for internal use by the Socket instance to track which namespace it belongs to and is not exposed as a public API for external access [1][3]. While some other implementations of Socket.IO (such as the Deno port) have historically encountered issues where this property was incorrectly marked as private when external access was expected [4], in the official socket.io-client package, the namespace is managed internally by the class and generally does not require direct access from the application layer [5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json
from pathlib import Path
p = Path('package.json')
if p.exists():
    data = json.loads(p.read_text())
    print(data.get('scripts', {}).get('test', ''))
    print(data.get('scripts', {}).get('typecheck', ''))
    print(data.get('scripts', {}).get('lint', ''))
PY

for f in $(git ls-files '*tsconfig*.json'); do
  echo "--- $f"
  rg -n '"strict"|noEmitOnError|skipLibCheck|types|include|exclude' "$f" || true
done

Repository: nexoraorg/chenaikit

Length of output: 1351


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## tests/integration/package.json\n'
if [ -f tests/integration/package.json ]; then
  cat -n tests/integration/package.json
else
  echo 'missing'
fi

printf '\n## root package.json scripts mentioning type-check/test:integration\n'
python3 - <<'PY'
import json
from pathlib import Path
data = json.loads(Path('package.json').read_text())
for k,v in data.get('scripts', {}).items():
    if 'type-check' in k.lower() or 'integration' in k.lower():
        print(f'{k}: {v}')
PY

printf '\n## tests/integration jest config\n'
cat -n tests/integration/jest.config.js

Repository: nexoraorg/chenaikit

Length of output: 1517


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## root workspace package manifests\n'
for f in $(git ls-files '*/package.json' 'package.json'); do
  case "$f" in
    tests/integration/package.json|package.json)
      echo "--- $f"
      sed -n '1,220p' "$f"
      ;;
  esac
done

Repository: nexoraorg/chenaikit

Length of output: 4379


Avoid relying on socket.nsp here. socket.io-client v4 treats nsp as a private client field, so filter on the namespace string you already have when creating each socket instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/websocket.integration.test.ts` at line 83, Update the alert
client filtering around connectedSockets to avoid the private socket.nsp field.
Preserve each socket’s namespace string when creating the clients and filter
using that stored namespace, selecting entries whose namespace ends with
“/alerts”.

@gelluisaac

Copy link
Copy Markdown
Collaborator

@onajidavid87-web resolve conflict

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Real-time WebSocket Infrastructure with Event Broadcasting

2 participants