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

32
apps/api/src/ws/auth.ts Normal file
View file

@ -0,0 +1,32 @@
import type { IncomingMessage } from "http";
import type { Duplex } from "stream";
import type { WebSocketServer, WebSocket } from "ws";
import { fromNodeHeaders } from "better-auth/node";
import { auth } from "../lib/auth.js";
export const handleUpgrade = async (
request: IncomingMessage,
socket: Duplex,
head: Buffer,
wss: WebSocketServer,
): Promise<void> => {
try {
const session = await auth.api.getSession({
headers: fromNodeHeaders(request.headers),
});
if (!session) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {
wss.emit("connection", ws, request, session);
});
} catch (err) {
console.error("WebSocket auth error:", err);
socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n");
socket.destroy();
}
};

View file

@ -0,0 +1,44 @@
import type { WebSocket } from "ws";
// Map<lobbyId, Map<userId, WebSocket>>
const connections = new Map<string, Map<string, WebSocket>>();
export const addConnection = (
lobbyId: string,
userId: string,
ws: WebSocket,
): void => {
if (!connections.has(lobbyId)) {
connections.set(lobbyId, new Map());
}
connections.get(lobbyId)!.set(userId, ws);
};
export const removeConnection = (lobbyId: string, userId: string): void => {
const lobby = connections.get(lobbyId);
if (!lobby) return;
lobby.delete(userId);
if (lobby.size === 0) {
connections.delete(lobbyId);
}
};
export const getConnections = (lobbyId: string): Map<string, WebSocket> => {
return connections.get(lobbyId) ?? new Map();
};
export const broadcastToLobby = (
lobbyId: string,
message: unknown,
excludeUserId?: string,
): void => {
const lobby = connections.get(lobbyId);
if (!lobby) return;
const payload = JSON.stringify(message);
for (const [userId, ws] of lobby) {
if (excludeUserId && userId === excludeUserId) continue;
if (ws.readyState === ws.OPEN) {
ws.send(payload);
}
}
};

View file

@ -0,0 +1,73 @@
import type { WebSocket } from "ws";
import type { User } from "better-auth";
import type { WsLobbyJoin, WsLobbyLeave } from "@lila/shared";
import { getLobbyByCodeWithPlayers, deleteLobby, removePlayer } from "@lila/db";
import {
addConnection,
removeConnection,
broadcastToLobby,
} from "../connections.js";
import { NotFoundError, ConflictError } from "../../errors/AppError.js";
export const handleLobbyJoin = async (
ws: WebSocket,
msg: WsLobbyJoin,
user: User,
): Promise<void> => {
// Load lobby and validate membership
const lobby = await getLobbyByCodeWithPlayers(msg.code);
if (!lobby) {
throw new NotFoundError("Lobby not found");
}
if (lobby.status !== "waiting") {
throw new ConflictError("Lobby is not in waiting state");
}
if (!lobby.players.some((p) => p.userId === user.id)) {
throw new ConflictError("You are not a member of this lobby");
}
// Register connection and tag the socket with lobbyId
addConnection(lobby.id, user.id, ws);
ws.lobbyId = lobby.id;
// Broadcast updated lobby state to all players
broadcastToLobby(lobby.id, { type: "lobby:state", lobby });
};
export const handleLobbyLeave = async (
ws: WebSocket,
msg: WsLobbyLeave,
user: User,
): Promise<void> => {
const lobby = await getLobbyByCodeWithPlayers(msg.lobbyId);
if (!lobby) return;
removeConnection(msg.lobbyId, user.id);
ws.lobbyId = undefined;
if (lobby.hostUserId === user.id) {
await deleteLobby(msg.lobbyId);
broadcastToLobby(msg.lobbyId, {
type: "error",
code: "LOBBY_CLOSED",
message: "Host left the lobby",
});
for (const player of lobby.players) {
removeConnection(msg.lobbyId, player.userId);
}
} else {
await removePlayer(msg.lobbyId, user.id);
const updated = await getLobbyByCodeWithPlayers(lobby.code);
if (!updated) return;
broadcastToLobby(msg.lobbyId, { type: "lobby:state", lobby: updated });
// TODO(reconnection-slice): if lobby.status === 'in_progress', the game
// continues with remaining players. If only one player remains after this
// leave, end the game immediately and declare them winner. Currently we
// broadcast updated lobby state and let the game resolve naturally via
// timeouts — the disconnected player's answers will be null each round.
// When reconnection handling is added, this is the place to change.
}
};

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,
);
};

74
apps/api/src/ws/router.ts Normal file
View file

@ -0,0 +1,74 @@
import type { WebSocket } from "ws";
import type { Session, User } from "better-auth";
import { WsClientMessageSchema } from "@lila/shared";
import {
handleLobbyJoin,
handleLobbyLeave,
handleLobbyStart,
} from "./handlers/lobbyHandlers.js";
import { handleGameAnswer } from "./handlers/gameHandlers.js";
import { AppError } from "../errors/AppError.js";
export type AuthenticatedUser = { session: Session; user: User };
const sendError = (ws: WebSocket, code: string, message: string): void => {
ws.send(JSON.stringify({ type: "error", code, message }));
};
const assertExhaustive = (_: never): never => {
throw new Error("Unhandled message type");
};
export const handleMessage = async (
ws: WebSocket,
rawData: unknown,
auth: AuthenticatedUser,
): Promise<void> => {
// Layer 1: parse and validate incoming message
let parsed: unknown;
try {
parsed = JSON.parse(
typeof rawData === "string" ? rawData : (rawData as Buffer).toString(),
);
} catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
return;
}
const result = WsClientMessageSchema.safeParse(parsed);
if (!result.success) {
ws.send(
JSON.stringify({ type: "error", message: "Invalid message format" }),
);
return;
}
const msg = result.data;
// Layer 2: dispatch to handler, catch and translate errors
try {
switch (msg.type) {
case "lobby:join":
await handleLobbyJoin(ws, msg, auth.user);
break;
case "lobby:leave":
await handleLobbyLeave(ws, msg, auth.user);
break;
case "lobby:start":
await handleLobbyStart(ws, msg, auth.user);
break;
case "game:answer":
await handleGameAnswer(ws, msg, auth.user);
break;
default:
assertExhaustive(msg);
}
} catch (err) {
if (err instanceof AppError) {
sendError(ws, err.name, err.message);
} else {
console.error("Unhandled WS error:", err);
sendError(ws, "INTERNAL_ERROR", "An unexpected error occurred");
}
}
};