onsole.log(Hub listening on port ${port}));
}
private configureListeners(): void {
this.hub.on('connection', (socket: WebSocket, request: IncomingMessage) => {
const params = new URL(request.url!, http://${request.headers.host}).searchParams;
const channelName = params.get('channel') || 'general';
const operatorId = op_${Date.now()}_${Math.random().toString(36).substring(7)};
const meta: SessionMetadata = { operatorId, alias: null, channel: channelName };
this.activeSessions.set(socket, meta);
this.sendTo(socket, { type: 'handshake', payload: { operatorId } });
socket.on('message', (raw: Buffer) => this.routePayload(socket, raw));
socket.on('close', () => this.teardown(socket));
socket.on('error', (err: Error) => console.error(`Link failure: ${err.message}`));
});
}
private teardown(socket: WebSocket): void {
const meta = this.activeSessions.get(socket);
if (meta) {
this.distributeToChannel(meta.channel, {
type: 'presence',
payload: { id: meta.operatorId, alias: meta.alias, status: 'offline' }
});
this.activeSessions.delete(socket);
}
}
}
**2. Message Routing and Payload Processing**
```typescript
private routePayload(source: WebSocket, raw: Buffer): void {
try {
const packet = JSON.parse(raw.toString());
const meta = this.activeSessions.get(source);
if (!meta) return;
switch (packet.action) {
case 'register':
meta.alias = packet.payload.name || 'Guest';
this.distributeToChannel(meta.channel, {
type: 'presence',
payload: { id: meta.operatorId, alias: meta.alias, status: 'online' }
}, source);
break;
case 'broadcast':
if (!meta.alias) {
return this.sendTo(source, { type: 'error', payload: 'Register alias first' });
}
this.distributeToChannel(meta.channel, {
type: 'chat',
payload: {
id: meta.operatorId,
alias: meta.alias,
content: packet.payload.text,
ts: new Date().toISOString()
}
});
break;
case 'typing':
this.distributeToChannel(meta.channel, {
type: 'indicator',
payload: { id: meta.operatorId, alias: meta.alias }
}, source);
break;
case 'private':
this.routePrivate(source, packet.payload.targetId, packet.payload.text);
break;
case 'roster':
const users = this.getRoster(meta.channel);
this.sendTo(source, { type: 'roster', payload: users });
break;
default:
this.sendTo(source, { type: 'error', payload: `Unknown action: ${packet.action}` });
}
} catch {
this.sendTo(source, { type: 'error', payload: 'Malformed packet' });
}
}
3. Distribution and Private Messaging
private distributeToChannel(channel: string, packet: object, exclude?: WebSocket): void {
const payload = JSON.stringify(packet);
for (const [socket, meta] of this.activeSessions) {
if (meta.channel === channel && socket !== exclude && socket.readyState === WebSocket.OPEN) {
socket.send(payload);
}
}
}
private routePrivate(source: WebSocket, targetId: string, content: string): void {
const senderMeta = this.activeSessions.get(source);
if (!senderMeta) return;
for (const [socket, meta] of this.activeSessions) {
if (meta.operatorId === targetId) {
this.sendTo(socket, {
type: 'direct',
payload: {
from: senderMeta.operatorId,
fromAlias: senderMeta.alias,
content,
ts: new Date().toISOString()
}
});
this.sendTo(source, {
type: 'direct',
payload: { to: targetId, content, ts: new Date().toISOString() }
});
return;
}
}
this.sendTo(source, { type: 'error', payload: 'Target unreachable' });
}
private getRoster(channel: string): Array<{ id: string; alias: string }> {
const users: Array<{ id: string; alias: string }> = [];
for (const [, meta] of this.activeSessions) {
if (meta.channel === channel && meta.alias) {
users.push({ id: meta.operatorId, alias: meta.alias });
}
}
return users;
}
private sendTo(socket: WebSocket, packet: object): void {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(packet));
}
}
4. Client-Side Controller
export class ChatClient {
private link: WebSocket | null = null;
private alias: string = '';
private reconnectDelay: number = 3000;
constructor(private endpoint: string) {}
establish(): void {
this.link = new WebSocket(`${this.endpoint}?channel=lobby`);
this.link.onopen = () => console.log('Link established');
this.link.onmessage = (evt: MessageEvent) => {
const packet = JSON.parse(evt.data);
this.process(packet);
};
this.link.onclose = () => {
console.log('Link severed. Reconnecting...');
setTimeout(() => this.establish(), this.reconnectDelay);
};
this.link.onerror = () => console.error('Link error');
}
private process(packet: any): void {
switch (packet.type) {
case 'chat':
this.renderBubble(packet.payload);
break;
case 'presence':
this.updateRoster(packet.payload);
break;
case 'indicator':
this.showTyping(packet.payload.alias);
break;
case 'direct':
this.renderDirect(packet.payload);
break;
case 'error':
this.notify(packet.payload);
break;
}
}
register(name: string): void {
this.alias = name;
this.send({ action: 'register', payload: { name } });
}
broadcast(text: string): void {
this.send({ action: 'broadcast', payload: { text } });
}
signalTyping(): void {
this.send({ action: 'typing', payload: {} });
}
private send(packet: object): void {
if (this.link?.readyState === WebSocket.OPEN) {
this.link.send(JSON.stringify(packet));
}
}
// UI rendering methods omitted for brevity
private renderBubble(data: any) { /* ... */ }
private updateRoster(data: any) { /* ... */ }
private showTyping(name: string) { /* ... */ }
private renderDirect(data: any) { /* ... */ }
private notify(msg: string) { /* ... */ }
}
Pitfall Guide
Real-world WebSocket implementations often fail due to subtle architectural oversights. The following pitfalls highlight common mistakes and their remedies.
-
Memory Leaks via Stale Connections
- Explanation: Failing to remove connections from the state map when a client disconnects unexpectedly (e.g., network drop) causes the server to retain references to dead sockets. Over time, this exhausts memory.
- Fix: Implement robust
close and error event handlers that invoke a teardown routine to delete the session from the map and broadcast presence updates.
-
Blocking the Event Loop
- Explanation: Performing CPU-intensive operations (e.g., image processing, heavy database queries) inside the message handler blocks the Node.js event loop, delaying responses for all connected clients.
- Fix: Keep message handlers lightweight. Offload heavy work to worker threads or message queues. Acknowledge receipt immediately and process asynchronously.
-
Absence of Heartbeat Mechanism
- Explanation: Firewalls and load balancers often terminate idle TCP connections. Without activity, the server may hold "zombie" connections that appear open but cannot transmit data.
- Fix: Implement a ping/pong heartbeat. The server sends a ping at regular intervals; if no pong is received within a timeout, the connection is closed.
-
Cross-Site Scripting (XSS) via Unescaped Content
- Explanation: Rendering user-generated content directly into the DOM without sanitization allows attackers to inject malicious scripts.
- Fix: Always escape HTML entities on the client side before rendering. Use
textContent or a sanitization library rather than innerHTML for user input.
-
Race Conditions on Join
- Explanation: A client may send a chat message before the server has processed the registration/join request, resulting in the message being dropped or attributed to an anonymous user.
- Fix: Queue outgoing messages on the client until a registration acknowledgment is received. Alternatively, validate state on the server and reject messages from unregistered sessions.
-
Unbounded Room Growth
- Explanation: Defaulting all users to a single room creates a broadcast storm as the user count grows, degrading performance.
- Fix: Enforce channel isolation. Require clients to specify a channel on connection. Implement logic to clean up empty channels if necessary.
-
Ignoring Reconnection Logic
- Explanation: Clients that disconnect due to transient network issues remain offline until manually refreshed, leading to poor user experience.
- Fix: Implement automatic reconnection with exponential backoff on the client side. Ensure the server can handle reconnections gracefully, perhaps by reusing session IDs or issuing new ones.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small Internal Tool (<100 users) | In-memory Map with single Node.js instance | Simplicity; low latency; no external dependencies. | Minimal infrastructure cost. |
| Medium App (100β10k users) | In-memory Map with sticky sessions behind a load balancer | Horizontal scaling requires session affinity to maintain state consistency. | Moderate cost for load balancer and multiple instances. |
| High-Scale Public App (>10k users) | Redis Pub/Sub with stateless WebSocket servers | Decouples state from compute; enables seamless horizontal scaling; Redis handles distribution. | Higher cost for Redis cluster and complex architecture. |
| Low-Bandwidth Environments | Binary protocols (e.g., Protobuf) over WebSockets | Reduces payload size significantly compared to JSON. | Increased development complexity; requires serialization libraries. |
Configuration Template
package.json Dependencies
{
"dependencies": {
"ws": "^8.16.0",
"typescript": "^5.3.0"
},
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "ts-node src/server.ts"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
Quick Start Guide
- Initialize Project: Run
npm init -y and install dependencies: npm install ws typescript.
- Create Server: Add
src/server.ts with the CommunicationHub implementation from the Core Solution.
- Create Client: Add
src/client.ts with the ChatClient implementation.
- Compile and Run: Execute
npm run build followed by npm start.
- Test: Open multiple browser tabs pointing to the client interface. Register aliases and exchange messages to verify real-time delivery, presence updates, and channel isolation.