feat(backend): add WS connection lifecycle and player identity registry

This commit is contained in:
Gabriel Franco 2026-09-10 11:54:48 -03:00
parent 0047da5aab
commit 66db5b1931
2 changed files with 163 additions and 5 deletions

View file

@ -1,8 +1,106 @@
// Placeholder connection handler. Matchmaking, room assignment, and // WebSocket connection lifecycle: parses each incoming text frame as a
// game-state sync are not implemented yet — this only proves the // flat `{ type, ...payload }` envelope (see PROTOCOL.md "Transport") and
// WebSocket transport itself works end to end. // 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) { export function handleConnection(socket) {
console.log('client connected'); socket.on('message', (raw) => handleMessage(socket, raw));
socket.on('close', () => console.log('client disconnected'));
socket.on('close', () => {
const playerId = unregisterSocket(socket);
if (playerId === undefined) return;
for (const cb of disconnectSubscribers) {
cb(playerId);
}
});
} }

View file

@ -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;
}