import type { Server } from "node:http"; import { WebSocketServer, type WebSocket } from "ws"; import type { PeerInfo, SignalMessage, SignalPayload } from "@psx/shared"; import { validateSignalMessage } from "@psx/shared"; import { verifyToken } from "../auth.js"; import type { LobbyRecord, Store } from "../db/store.js"; /** * SDP/ICE exchange — GDD §8. * * The server is a relay and nothing more. It never sees gameplay: once peers * have exchanged descriptors they talk over data channels directly, and this * socket carries only membership changes for the rest of the session. * * Two rules make the relay safe to expose: * 1. `from` on a relayed signal is stamped from the authenticated socket, * never read from the payload, so peers cannot impersonate one another. * 2. A socket may only address peers inside the lobby it authenticated into. */ interface Connection { socket: WebSocket; peerId: number; userId: string; lobbyCode: string; isHost: boolean; alive: boolean; } const HEARTBEAT_INTERVAL_MS = 30_000; export class SignalingServer { private readonly wss: WebSocketServer; /** lobby code → peer id → connection */ private readonly rooms = new Map>(); private readonly heartbeat: NodeJS.Timeout; constructor( server: Server, private readonly store: Store, path = "/ws", ) { this.wss = new WebSocketServer({ server, path }); this.wss.on("connection", (socket) => this.onConnection(socket)); // Half-open TCP connections would otherwise linger as phantom lobby members. this.heartbeat = setInterval(() => { for (const room of this.rooms.values()) { for (const connection of room.values()) { if (!connection.alive) { connection.socket.terminate(); continue; } connection.alive = false; connection.socket.ping(); } } }, HEARTBEAT_INTERVAL_MS); } private onConnection(socket: WebSocket): void { let connection: Connection | null = null; socket.on("pong", () => { if (connection) connection.alive = true; }); socket.on("message", async (raw) => { let parsed: unknown; try { parsed = JSON.parse(raw.toString()); } catch { return send(socket, { t: "error", message: "malformed JSON" }); } const validation = validateSignalMessage(parsed); if (!validation.ok) { return send(socket, { t: "error", message: validation.errors.join("; ") }); } const message = validation.value; // Everything except `hello` requires an authenticated socket. if (!connection && message.t !== "hello") { return send(socket, { t: "error", message: "expected hello first" }); } switch (message.t) { case "hello": { if (connection) return send(socket, { t: "error", message: "already authenticated" }); connection = await this.authenticate(socket, message.token, message.lobbyCode); break; } case "signal": this.relaySignal(connection!, message.to, message.payload); break; case "ready": await this.setReady(connection!, message.isReady); break; default: send(socket, { t: "error", message: `unsupported message: ${message.t}` }); } }); socket.on("close", () => { if (connection) void this.disconnect(connection); }); } private async authenticate( socket: WebSocket, token: string, lobbyCode: string, ): Promise { const verified = verifyToken(token); if (!verified) { send(socket, { t: "error", message: "invalid or expired token" }); socket.close(); return null; } const lobby = await this.store.findLobbyByCode(lobbyCode); if (!lobby || lobby.status === "closed") { send(socket, { t: "error", message: "lobby not found or closed" }); socket.close(); return null; } // Membership is established over REST (`POST /lobbies/:code/join`) so that // capacity and duplicate-join rules live in exactly one place. const member = lobby.players.find((player) => player.userId === verified.userId); if (!member) { send(socket, { t: "error", message: "join the lobby before connecting" }); socket.close(); return null; } const room = this.rooms.get(lobby.code) ?? new Map(); this.rooms.set(lobby.code, room); // A reconnect from the same peer supersedes the stale socket. room.get(member.peerId)?.socket.terminate(); const connection: Connection = { socket, peerId: member.peerId, userId: verified.userId, lobbyCode: lobby.code, isHost: verified.userId === lobby.hostId, alive: true, }; room.set(member.peerId, connection); send(socket, { t: "welcome", peerId: connection.peerId, isHost: connection.isHost, peers: this.peerList(lobby, room), }); this.broadcast( lobby.code, { t: "peer-joined", peer: this.peerInfo(lobby, member.peerId) }, connection.peerId, ); return connection; } private relaySignal(from: Connection, to: number, payload: SignalPayload): void { const target = this.rooms.get(from.lobbyCode)?.get(to); if (!target) return; send(target.socket, { t: "signal", // Stamped from the authenticated socket — never trusted from the payload. from: from.peerId, to, payload, }); } private async setReady(connection: Connection, isReady: boolean): Promise { const lobby = await this.store.setReady(connection.lobbyCode, connection.userId, isReady); if (!lobby) return; this.broadcast(connection.lobbyCode, { t: "peer-updated", peer: this.peerInfo(lobby, connection.peerId), }); } private async disconnect(connection: Connection): Promise { const room = this.rooms.get(connection.lobbyCode); // Guard against a superseded socket removing its replacement. if (room?.get(connection.peerId)?.socket === connection.socket) { room.delete(connection.peerId); } if (room && room.size === 0) this.rooms.delete(connection.lobbyCode); await this.store.leaveLobby(connection.lobbyCode, connection.userId); this.broadcast(connection.lobbyCode, { t: "peer-left", peerId: connection.peerId }); // The authoritative simulation lived in the host's browser, so its departure // ends the session for everyone rather than silently freezing the world. if (connection.isHost) { this.broadcast(connection.lobbyCode, { t: "error", message: "the host left the session" }); for (const remaining of room?.values() ?? []) remaining.socket.close(); this.rooms.delete(connection.lobbyCode); } } private peerInfo(lobby: LobbyRecord, peerId: number): PeerInfo { const player = lobby.players.find((entry) => entry.peerId === peerId); return { peerId, displayName: player?.displayName ?? `Player ${peerId}`, isHost: player ? player.userId === lobby.hostId : false, isReady: player?.isReady ?? false, }; } private peerList(lobby: LobbyRecord, room: Map): PeerInfo[] { return [...room.keys()].map((peerId) => this.peerInfo(lobby, peerId)); } private broadcast(lobbyCode: string, message: SignalMessage, exceptPeerId?: number): void { for (const [peerId, connection] of this.rooms.get(lobbyCode) ?? []) { if (peerId === exceptPeerId) continue; send(connection.socket, message); } } close(): void { clearInterval(this.heartbeat); this.wss.close(); } } function send(socket: WebSocket, message: SignalMessage): void { if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message)); }