import { WebSocketServer } from 'ws';
import type WebSocket from 'ws';
type ClientState = { topics: Set<string> };
const state = new WeakMap<WebSocket, ClientState>();
export function startWsServer(server: import('node:http').Server) {
const wss = new WebSocketServer({ server, maxPayload: 1024 * 64 });
wss.on('connection', (socket) => {
state.set(socket, { topics: new Set() });
socket.on('message', (buf) => {
const msg = JSON.parse(buf.toString('utf8')) as { type: string; topic?: string };
const s = state.get(socket)!;
if (msg.type === 'subscribe' && msg.topic) s.topics.add(msg.topic);
if (msg.type === 'unsubscribe' && msg.topic) s.topics.delete(msg.topic);
});
});
return {
publish(topic: string, data: unknown) {
const payload = JSON.stringify({ type: 'event', topic, data });
for (const client of wss.clients) {
const s = state.get(client);
if (s?.topics.has(topic) && client.readyState === client.OPEN) {
client.send(payload);
}
}
}
};
}
Raw WebSockets can turn into an unmaintainable mess unless you define a tiny protocol up front. I keep messages typed (even if it’s ‘JSON with a type field’) and I implement topic subscriptions so clients opt into exactly what they need. I track subscriptions per connection and clean them up on close so state doesn’t leak. Another practical detail is defensive limits: set maxPayload and validate incoming payloads so one bad client can’t chew CPU and memory. For many apps I’ll start with SSE, but when I truly need bidirectional real-time interactions, a topic-based approach keeps the system sane as features grow.