Rooms and Broadcasting
The room model
Not every connected client should receive every event. A user viewing board-1 does not need events from board-2. Rooms group clients by context: all users viewing the same board are in the same room.
Board 1 room: [Alice, Bob]
Board 2 room: [Carol] When a task is created on board 1, only Alice and Bob receive the event. Carol is in a different room.
Implementing rooms
// src/rooms.ts
import { WebSocket } from "ws";
const rooms = new Map<string, Set<WebSocket>>();
export function joinRoom(roomId: string, ws: WebSocket): void {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId)!.add(ws);
}
export function leaveRoom(roomId: string, ws: WebSocket): void {
const room = rooms.get(roomId);
if (room) {
room.delete(ws);
if (room.size === 0) rooms.delete(roomId);
}
}
export function leaveAllRooms(ws: WebSocket): void {
for (const [roomId, room] of rooms) {
room.delete(ws);
if (room.size === 0) rooms.delete(roomId);
}
}
export function broadcastToRoom(roomId: string, data: any, exclude?: WebSocket): void {
const room = rooms.get(roomId);
if (!room) return;
const message = JSON.stringify(data);
for (const ws of room) {
if (ws !== exclude && ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
}
}
export function getRoomSize(roomId: string): number {
return rooms.get(roomId)?.size ?? 0;
} Key details:
exclude parameter. When Alice moves a task, broadcast the update to Bob but not back to Alice — she already knows about her own action. The exclude parameter skips the sender.
readyState check. Only send to sockets that are open. A socket might be in the process of closing.
Cleanup. leaveAllRooms removes a socket from every room it joined. Called on disconnect.
Handling join and leave messages
Update handleMessage and handleDisconnect:
import { joinRoom, leaveRoom, leaveAllRooms, broadcastToRoom, getRoomSize } from "./rooms.js";
function handleMessage(ws: WebSocket, message: any): void {
switch (message.type) {
case "join": {
if (!message.boardId) {
ws.send(JSON.stringify({ error: "boardId is required" }));
return;
}
joinRoom(message.boardId, ws);
ws.send(
JSON.stringify({
type: "joined",
boardId: message.boardId,
viewers: getRoomSize(message.boardId),
}),
);
// Notify others
broadcastToRoom(
message.boardId,
{
type: "user_joined",
viewers: getRoomSize(message.boardId),
},
ws,
);
break;
}
case "leave": {
if (message.boardId) {
leaveRoom(message.boardId, ws);
broadcastToRoom(message.boardId, {
type: "user_left",
viewers: getRoomSize(message.boardId),
});
}
break;
}
case "ping":
ws.send(JSON.stringify({ type: "pong" }));
break;
default:
ws.send(JSON.stringify({ error: `Unknown type: ${message.type}` }));
}
}
function handleDisconnect(ws: WebSocket): void {
// Leave all rooms this socket was in
leaveAllRooms(ws);
} Broadcasting task events through rooms
Update the task routes to broadcast through rooms (in addition to SSE):
import { broadcastToRoom } from "../rooms.js";
// After creating a task:
broadcastToRoom(boardId, { type: "task_created", task });
// After moving a task:
broadcastToRoom(boardId, { type: "task_moved", task });
// After deleting a task:
broadcastToRoom(boardId, { type: "task_deleted", taskId: c.params.id }); Now both SSE clients and WebSocket clients receive updates. SSE for one-way subscriptions, WebSocket for interactive features.
Try it
# Terminal 1: Connect and join board-1
wscat -c ws://localhost:3000
> {"type":"join","boardId":"board-1"}
# < {"type":"joined","boardId":"board-1","viewers":1}
# Terminal 2: Connect and join board-1
wscat -c ws://localhost:3000
> {"type":"join","boardId":"board-1"}
# < {"type":"joined","boardId":"board-1","viewers":2}
# Terminal 1 also receives: {"type":"user_joined","viewers":2}
# Terminal 3: Create a task (REST)
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Live update!","listId":"list-todo"}'
# Terminals 1 and 2 both receive: {"type":"task_created","task":{...}} Exercises
Exercise 1: Implement rooms. Join two clients to the same board, one to a different board. Create a task. Only the matching room should receive the event.
Exercise 2: Test the exclude parameter. When a client creates a task via WebSocket (if you implement that), it should not receive its own broadcast.
Exercise 3: Disconnect a client. Verify leaveAllRooms cleans up and the room size decreases.
Why does broadcastToRoom have an exclude parameter?