feat(api): add WebSocket foundation and multiplayer game store

- Add ws/ directory: server setup, auth, router, connections map
- WebSocket auth rejects upgrade with 401 if no Better Auth session
- Router parses WsClientMessageSchema, dispatches to handlers,
  two-layer error handling (AppError -> WsErrorSchema, unknown -> 500)
- connections.ts: in-memory Map<lobbyId, Map<userId, WebSocket>>
  with addConnection, removeConnection, broadcastToLobby
- LobbyGameStore interface + InMemoryLobbyGameStore implementation
  following existing GameSessionStore pattern
- multiplayerGameService: generateMultiplayerQuestions() decoupled
  from single-player flow, hardcoded defaults en->it nouns easy 3 rounds
- handleLobbyJoin and handleLobbyLeave implemented
- WsErrorSchema added to shared schemas
- server.ts switched to createServer + setupWebSocket
This commit is contained in:
lila 2026-04-17 09:36:16 +02:00
parent b0aef8cc16
commit 745c5c4e3a
14 changed files with 443 additions and 1 deletions

52
apps/api/src/ws/index.ts Normal file
View file

@ -0,0 +1,52 @@
import { WebSocketServer } from "ws";
import type { WebSocket } from "ws";
import type { Server } from "http";
import type { IncomingMessage } from "http";
import { handleUpgrade } from "./auth.js";
import { handleMessage, type AuthenticatedUser } from "./router.js";
import { removeConnection } from "./connections.js";
import { handleLobbyLeave } from "./handlers/lobbyHandlers.js";
export const setupWebSocket = (server: Server): WebSocketServer => {
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (request, socket, head) => {
if (request.url !== "/ws") {
socket.destroy();
return;
}
handleUpgrade(request, socket, head, wss);
});
wss.on(
"connection",
(ws: WebSocket, _request: IncomingMessage, auth: AuthenticatedUser) => {
ws.on("message", (rawData) => {
handleMessage(ws, rawData, auth);
});
ws.on("close", () => {
handleDisconnect(ws, auth);
});
ws.on("error", (err) => {
console.error(`WebSocket error for user ${auth.user.id}:`, err);
});
},
);
return wss;
};
const handleDisconnect = async (
ws: WebSocket,
auth: AuthenticatedUser,
): Promise<void> => {
if (!ws.lobbyId) return; // user connected but never joined a lobby
removeConnection(ws.lobbyId, auth.user.id);
await handleLobbyLeave(
ws,
{ type: "lobby:leave", lobbyId: ws.lobbyId },
auth.user,
);
};