From 66db5b1931dd2b7f11c32dfaf8da628b69a46463 Mon Sep 17 00:00:00 2001 From: Gabriel Franco Date: Thu, 10 Sep 2026 11:54:48 -0300 Subject: [PATCH] feat(backend): add WS connection lifecycle and player identity registry --- backend/src/ws/connectionHandler.js | 108 ++++++++++++++++++++++++++-- backend/src/ws/registry.js | 60 ++++++++++++++++ 2 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 backend/src/ws/registry.js diff --git a/backend/src/ws/connectionHandler.js b/backend/src/ws/connectionHandler.js index 6e1e485..f0768f4 100644 --- a/backend/src/ws/connectionHandler.js +++ b/backend/src/ws/connectionHandler.js @@ -1,8 +1,106 @@ -// Placeholder connection handler. Matchmaking, room assignment, and -// game-state sync are not implemented yet — this only proves the -// WebSocket transport itself works end to end. +// WebSocket connection lifecycle: parses each incoming text frame as a +// flat `{ type, ...payload }` envelope (see PROTOCOL.md "Transport") and +// dispatches it to whichever handler has been registered for that +// `type` via registerHandler(). Matchmaking, rooms, and the session +// engine own their own message types and register into this dispatcher +// from their own files (backend/src/matchmaking/, backend/src/rooms/) +// instead of this file growing a god-switch. +// +// The registry (registry.js) is the source of truth for which socket +// belongs to which playerId. This file owns wiring a socket into that +// registry on `identify` and tearing it down on close; it does not know +// anything about matches, rooms, or queues beyond that. + +import { WebSocket } from 'ws'; +import { registerSocket, unregisterSocket, getPlayerId } from './registry.js'; + +const handlers = new Map(); +const disconnectSubscribers = new Set(); + +/** + * Registers a handler function for a given message `type`. Called by + * this file (for `identify`) and by other modules that want to handle + * their own message types without editing this file's dispatch loop. + * `fn(socket, message)` is invoked with the parsed message envelope. + */ +export function registerHandler(type, fn) { + handlers.set(type, fn); +} + +/** + * Subscribes to socket disconnects. `cb(playerId)` fires once a socket + * that had identified as `playerId` closes, after it has already been + * removed from the registry. Lets matchmaking/rooms react (ban table, + * `room:playerLeft`, ...) without this file knowing about matches or + * rooms. Returns an unsubscribe function. + */ +export function onDisconnect(cb) { + disconnectSubscribers.add(cb); + return () => disconnectSubscribers.delete(cb); +} + +function sendError(socket, message) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'error', message })); + } +} + +registerHandler('identify', (socket, message) => { + const { playerId, name } = message; + if (typeof playerId !== 'string' || playerId.length === 0) { + sendError(socket, 'identify requires a string playerId'); + return; + } + if (typeof name !== 'string' || name.length === 0) { + sendError(socket, 'identify requires a string name'); + return; + } + registerSocket(playerId, socket); + socket.send(JSON.stringify({ type: 'identify:ack' })); +}); + +function handleMessage(socket, raw) { + let message; + try { + message = JSON.parse(raw.toString()); + } catch { + sendError(socket, 'Malformed JSON'); + return; + } + + if (!message || typeof message !== 'object' || Array.isArray(message) || typeof message.type !== 'string') { + sendError(socket, 'Message must be an object with a string "type" field'); + return; + } + + // Per PROTOCOL.md "Identity": no message is valid before `identify`. + if (getPlayerId(socket) === undefined && message.type !== 'identify') { + socket.close(1008, 'identify required'); + return; + } + + const handler = handlers.get(message.type); + if (!handler) { + sendError(socket, `Unknown message type: ${message.type}`); + return; + } + + try { + handler(socket, message); + } catch (err) { + console.error(`handler for "${message.type}" threw:`, err); + sendError(socket, `Internal error handling ${message.type}`); + } +} export function handleConnection(socket) { - console.log('client connected'); - socket.on('close', () => console.log('client disconnected')); + socket.on('message', (raw) => handleMessage(socket, raw)); + + socket.on('close', () => { + const playerId = unregisterSocket(socket); + if (playerId === undefined) return; + for (const cb of disconnectSubscribers) { + cb(playerId); + } + }); } diff --git a/backend/src/ws/registry.js b/backend/src/ws/registry.js new file mode 100644 index 0000000..0bd0671 --- /dev/null +++ b/backend/src/ws/registry.js @@ -0,0 +1,60 @@ +// In-memory bidirectional registry mapping the client-generated +// `playerId` (see PROTOCOL.md "Identity") to the live WebSocket for that +// player, and back. Other backend modules (matchmaking, rooms, ...) use +// this to look up "the socket for player X" to send targeted messages, +// and connectionHandler.js uses it to look up "the playerId for this +// socket" on disconnect for cleanup. + +import { WebSocket } from 'ws'; + +const socketsByPlayerId = new Map(); +const playerIdsBySocket = new Map(); + +/** + * Registers a socket under a playerId. If that playerId was already + * bound to a different (stale) socket, the stale binding is dropped + * first so the registry never points a playerId at a dead socket. + */ +export function registerSocket(playerId, socket) { + const existingSocket = socketsByPlayerId.get(playerId); + if (existingSocket && existingSocket !== socket) { + playerIdsBySocket.delete(existingSocket); + } + socketsByPlayerId.set(playerId, socket); + playerIdsBySocket.set(socket, playerId); +} + +/** + * Removes a socket's registration. Returns the playerId that was bound + * to it, or undefined if the socket was never registered (e.g. it + * disconnected before sending `identify`). + */ +export function unregisterSocket(socket) { + const playerId = playerIdsBySocket.get(socket); + if (playerId === undefined) return undefined; + playerIdsBySocket.delete(socket); + if (socketsByPlayerId.get(playerId) === socket) { + socketsByPlayerId.delete(playerId); + } + return playerId; +} + +export function getSocket(playerId) { + return socketsByPlayerId.get(playerId); +} + +export function getPlayerId(socket) { + return playerIdsBySocket.get(socket); +} + +/** + * Sends a typed JSON message to the socket registered for playerId. + * Returns true if the message was sent, false if there was no open + * socket for that player (already disconnected, or never identified). + */ +export function sendTo(playerId, message) { + const socket = socketsByPlayerId.get(playerId); + if (!socket || socket.readyState !== WebSocket.OPEN) return false; + socket.send(JSON.stringify(message)); + return true; +}