import crypto from 'node:crypto'; import { WebSocketServer, WebSocket } from 'ws'; import type { IncomingMessage, Server as HttpServer } from 'node:http'; // --------------------------------------------------------------------------- // Signaling protocol // client -> server: {type: offer|answer|candidate, to} {type: pos|state} // server -> client: {type: hello, id, peers} {type: joined|left, ...} // relayed messages get "from" stamped // --------------------------------------------------------------------------- export interface PeerState { muted: boolean; deafened: boolean; sharing: boolean; } export interface PeerSnapshot { id: string; name: string; x: number; y: number; state: PeerState; } interface ClientMessageBase { type: string; to?: unknown; x?: unknown; y?: unknown; state?: Partial; sdp?: unknown; candidate?: unknown; name?: unknown; } export class Peer { readonly socket: WebSocket; readonly id: string; name: string; x = 50; y = 50; state: PeerState = { muted: false, deafened: false, sharing: false }; constructor(socket: WebSocket, id: string, name: string) { this.socket = socket; this.id = id; this.name = name; } get connected(): boolean { return this.socket.readyState === WebSocket.OPEN; } send(payload: unknown): void { if (this.connected) this.socket.send(JSON.stringify(payload)); } snapshot(): PeerSnapshot { return { id: this.id, name: this.name, x: this.x, y: this.y, state: this.state }; } } export class Room { readonly id: string; private readonly peers = new Map(); constructor(id: string) { this.id = id; } get size(): number { return this.peers.size; } values(): IterableIterator { return this.peers.values(); } get(id: string): Peer | undefined { return this.peers.get(id); } has(id: string): boolean { return this.peers.has(id); } add(peer: Peer): void { this.peers.set(peer.id, peer); } delete(id: string): boolean { return this.peers.delete(id); } broadcast(msg: unknown, exceptId?: string): void { for (const [id, peer] of this.peers) { if (id !== exceptId && peer.connected) peer.socket.send(JSON.stringify(msg)); } } snapshot(): PeerSnapshot[] { return [...this.peers.values()].map((p) => p.snapshot()); } } export class RoomRegistry { private readonly rooms = new Map(); room(roomId: string): Room { let room = this.rooms.get(roomId); if (!room) { room = new Room(roomId); this.rooms.set(roomId, room); } return room; } dropIfEmpty(roomId: string): void { const room = this.rooms.get(roomId); if (room && room.size === 0) this.rooms.delete(roomId); } allRooms(): IterableIterator { return this.rooms.values(); } allPeers(): Peer[] { const out: Peer[] = []; for (const room of this.rooms.values()) out.push(...room.values()); return out; } snapshot(): Record { const out: Record = {}; for (const [roomId, room] of this.rooms) out[roomId] = room.snapshot(); return out; } } /** WebSocket signaling: room rosters, position/state fan-out and WebRTC relay. */ export class SignalingServer { readonly wss: WebSocketServer; private readonly registry: RoomRegistry; private heartbeat: ReturnType; constructor(registry: RoomRegistry) { this.registry = registry; this.wss = new WebSocketServer({ noServer: true }); // heartbeat: drop dead sockets this.heartbeat = setInterval(() => this.pingAll(), 30_000); this.wss.on('close', () => clearInterval(this.heartbeat)); this.wss.on('connection', (socket, req) => this.handleConnection(socket, req)); } /** Routes /ws upgrades from the HTTP server into the signaling socket. */ attach(server: HttpServer): void { server.on('upgrade', (req, socket, head) => { const { pathname } = new URL(req.url || '/', 'http://x'); if (pathname === '/ws') { this.wss.handleUpgrade(req, socket, head, (ws) => this.wss.emit('connection', ws, req)); } else { socket.destroy(); } }); } stop(): void { clearInterval(this.heartbeat); } private pingAll(): void { for (const peer of this.registry.allPeers()) { if (peer.connected) peer.socket.ping(); } } broadcastRosters(): void { const data = JSON.stringify({ type: 'rosters', rooms: this.registry.snapshot() }); for (const client of this.wss.clients) { if (client.readyState === WebSocket.OPEN) client.send(data); } } private handleConnection(socket: WebSocket, req: IncomingMessage): void { const q = new URL(req.url || '/', 'http://x').searchParams; const roomId = (q.get('room') || 'general').slice(0, 64); const name = (q.get('name') || 'anon').slice(0, 32); const id = crypto.randomBytes(4).toString('hex'); const room = this.registry.room(roomId); const client = new Peer(socket, id, name); room.add(client); client.send({ type: 'hello', id, room: roomId, peers: [...room.values()].filter((p) => p.id !== id).map((p) => p.snapshot()), }); room.broadcast({ type: 'joined', peer: client.snapshot() }, id); this.broadcastRosters(); console.log(`[signaling] ${name} (${id}) joined #${roomId} — ${room.size} in room`); socket.on('message', (raw) => { const text = Array.isArray(raw) ? Buffer.concat(raw).toString() : raw.toString(); let msg: ClientMessageBase; try { msg = JSON.parse(text) as ClientMessageBase; } catch { return; } if (!msg || typeof msg.type !== 'string') return; this.handleMessage(room, client, msg); }); socket.on('close', () => { room.delete(id); this.registry.dropIfEmpty(roomId); if (room.size > 0) room.broadcast({ type: 'left', id }); this.broadcastRosters(); console.log(`[signaling] ${name} (${id}) left #${roomId} — ${room.size} in room`); }); } private handleMessage(room: Room, client: Peer, msg: ClientMessageBase): void { const send = (payload: unknown): void => client.send(payload); switch (msg.type) { case 'ping': { send({ type: 'pong' }); break; } case 'pos': { const x = Number(msg.x); const y = Number(msg.y); if (Number.isFinite(x) && Number.isFinite(y)) { client.x = Math.min(100, Math.max(0, x)); client.y = Math.min(100, Math.max(0, y)); room.broadcast({ type: 'pos', from: client.id, x: client.x, y: client.y }, client.id); } break; } case 'state': { client.state = { muted: !!msg.state?.muted, deafened: !!msg.state?.deafened, sharing: !!msg.state?.sharing, }; room.broadcast({ type: 'state', from: client.id, state: client.state }, client.id); this.broadcastRosters(); break; } case 'offer': case 'answer': case 'candidate': case 'reset': { if (typeof msg.to !== 'string' || !room.has(msg.to)) return; const target = room.get(msg.to); if (target && target.connected) { const { to: _to, ...rest } = msg; target.socket.send(JSON.stringify({ ...rest, from: client.id })); } break; } default: break; // ignore unknown types — forward compat } } }