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

View file

@ -14,12 +14,14 @@
"@lila/shared": "workspace:*",
"better-auth": "^1.6.2",
"cors": "^2.8.6",
"express": "^5.2.1"
"express": "^5.2.1",
"ws": "^8.20.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/supertest": "^7.2.0",
"@types/ws": "^8.18.1",
"supertest": "^7.2.2",
"tsx": "^4.21.0"
}

View file

@ -0,0 +1,24 @@
import type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js";
export class InMemoryLobbyGameStore implements LobbyGameStore {
private games = new Map<string, LobbyGameData>();
async create(lobbyId: string, data: LobbyGameData): Promise<void> {
if (this.games.has(lobbyId)) {
throw new Error(`Game already exists for lobby: ${lobbyId}`);
}
this.games.set(lobbyId, data);
}
async get(lobbyId: string): Promise<LobbyGameData | null> {
return this.games.get(lobbyId) ?? null;
}
async set(lobbyId: string, data: LobbyGameData): Promise<void> {
this.games.set(lobbyId, data);
}
async delete(lobbyId: string): Promise<void> {
this.games.delete(lobbyId);
}
}

View file

@ -0,0 +1,17 @@
import type { MultiplayerQuestion } from "../services/multiplayerGameService.js";
export type LobbyGameData = {
questions: MultiplayerQuestion[];
currentIndex: number;
// NOTE: Map types are used here for O(1) lookups in-process.
// When migrating to Valkey, convert to plain objects for JSON serialization.
playerAnswers: Map<string, number | null>; // userId → selectedOptionId, null = timed out
scores: Map<string, number>; // userId → running total
};
export interface LobbyGameStore {
create(lobbyId: string, data: LobbyGameData): Promise<void>;
get(lobbyId: string): Promise<LobbyGameData | null>;
set(lobbyId: string, data: LobbyGameData): Promise<void>;
delete(lobbyId: string): Promise<void>;
}

View file

@ -0,0 +1,2 @@
export type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js";
export { InMemoryLobbyGameStore } from "./InMemoryLobbyGameStore.js";

View file

@ -1,8 +1,13 @@
import { createServer } from "http";
import { createApp } from "./app.js";
import { setupWebSocket } from "./ws/index.js";
const PORT = Number(process.env["PORT"] ?? 3000);
const app = createApp();
const server = createServer(app);
setupWebSocket(server);
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);

View file

@ -0,0 +1,75 @@
import { randomUUID } from "crypto";
import { getGameTerms, getDistractors } from "@lila/db";
import type {
GameQuestion,
AnswerOption,
SupportedLanguageCode,
SupportedPos,
DifficultyLevel,
} from "@lila/shared";
// TODO(game-mode-slice): replace with lobby settings when mode selection lands
const MULTIPLAYER_DEFAULTS = {
sourceLanguage: "en" as SupportedLanguageCode,
targetLanguage: "it" as SupportedLanguageCode,
pos: "noun" as SupportedPos,
difficulty: "easy" as DifficultyLevel,
rounds: 3,
};
const shuffle = <T>(array: T[]): T[] => {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const temp = result[i]!;
result[i] = result[j]!;
result[j] = temp;
}
return result;
};
export type MultiplayerQuestion = GameQuestion & { correctOptionId: number };
export const generateMultiplayerQuestions = async (): Promise<
MultiplayerQuestion[]
> => {
const correctAnswers = await getGameTerms(
MULTIPLAYER_DEFAULTS.sourceLanguage,
MULTIPLAYER_DEFAULTS.targetLanguage,
MULTIPLAYER_DEFAULTS.pos,
MULTIPLAYER_DEFAULTS.difficulty,
MULTIPLAYER_DEFAULTS.rounds,
);
const questions: MultiplayerQuestion[] = await Promise.all(
correctAnswers.map(async (correctAnswer) => {
const distractorTexts = await getDistractors(
correctAnswer.termId,
correctAnswer.targetText,
MULTIPLAYER_DEFAULTS.targetLanguage,
MULTIPLAYER_DEFAULTS.pos,
MULTIPLAYER_DEFAULTS.difficulty,
3,
);
const optionTexts = [correctAnswer.targetText, ...distractorTexts];
const shuffledTexts = shuffle(optionTexts);
const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText);
const options: AnswerOption[] = shuffledTexts.map((text, index) => ({
optionId: index,
text,
}));
return {
questionId: randomUUID(),
prompt: correctAnswer.sourceText,
gloss: correctAnswer.sourceGloss,
options,
correctOptionId,
};
}),
);
return questions;
};

View file

@ -1,4 +1,5 @@
import type { Session, User } from "better-auth";
import type { WebSocket } from "ws";
declare global {
namespace Express {
@ -8,4 +9,10 @@ declare global {
}
}
declare module "ws" {
interface WebSocket {
lobbyId?: string | undefined;
}
}
export {};

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

View file

@ -112,11 +112,19 @@ export const WsGameFinishedSchema = z.object({
export type WsGameFinished = z.infer<typeof WsGameFinishedSchema>;
export const WsErrorSchema = z.object({
type: z.literal("error"),
code: z.string(),
message: z.string(),
});
export type WsError = z.infer<typeof WsErrorSchema>;
export const WsServerMessageSchema = z.discriminatedUnion("type", [
WsLobbyStateSchema,
WsGameQuestionSchema,
WsGameAnswerResultSchema,
WsGameFinishedSchema,
WsErrorSchema,
]);
export type WsServerMessage = z.infer<typeof WsServerMessageSchema>;

27
pnpm-lock.yaml generated
View file

@ -62,6 +62,9 @@ importers:
express:
specifier: ^5.2.1
version: 5.2.1
ws:
specifier: ^8.20.0
version: 8.20.0
devDependencies:
'@types/cors':
specifier: ^2.8.19
@ -72,6 +75,9 @@ importers:
'@types/supertest':
specifier: ^7.2.0
version: 7.2.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
supertest:
specifier: ^7.2.2
version: 7.2.2
@ -1311,6 +1317,9 @@ packages:
'@types/supertest@7.2.0':
resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.57.1':
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -2983,6 +2992,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.20.0:
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
@ -3915,6 +3936,10 @@ snapshots:
'@types/methods': 1.1.4
'@types/superagent': 8.1.9
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.12.0
'@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@ -5586,6 +5611,8 @@ snapshots:
wrappy@1.0.2: {}
ws@8.20.0: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1