lila/apps/web/src/lib/ws-client.ts
lila 8aaafea3fc feat: multiplayer slice — end to end working
WebSocket server:
- WS auth via Better Auth session on upgrade request
- Router with discriminated union dispatch and two-layer error handling
- In-memory connections map with broadcastToLobby
- Lobby handlers: join, leave, start
- Game handlers: answer, resolve round, end game, game:ready for state sync
- Shared game state store (LobbyGameStore interface + InMemory impl)
- Timer map separate from store for Valkey-readiness

REST API:
- POST /api/v1/lobbies — create lobby + add host as first player
- POST /api/v1/lobbies/:code/join — atomic join with capacity/status checks
- getLobbyWithPlayers added to model for id-based lookup

Frontend:
- WsClient class with typed on/off, connect/disconnect, isConnected
- WsProvider owns connection lifecycle (connect/disconnect/isConnected state)
- WsConnector component triggers connection at multiplayer layout mount
- Lobby waiting room: live player list, copyable code, host Start button
- Game view: reuses QuestionCard, game:ready on mount, round results
- MultiplayerScoreScreen: sorted scores, winner highlight, tie handling
- Vite proxy: /ws and /api proxied to localhost:3000 for dev cookie fix

Tests:
- lobbyService.test.ts: create, join, retry, idempotency, full lobby
- auth.test.ts: 401 reject, upgrade success, 500 on error
- router.test.ts: dispatch all message types, error handling
- vitest.config.ts: exclude dist folder

Fixes:
- server.ts: server.listen() instead of app.listen() for WS support
- StrictMode removed from main.tsx (incompatible with WS lifecycle)
- getLobbyWithPlayers(id) added for handleLobbyStart lookup
2026-04-18 23:32:21 +02:00

135 lines
3.5 KiB
TypeScript

import { WsServerMessageSchema } from "@lila/shared";
import type { WsClientMessage, WsServerMessage } from "@lila/shared";
/**
* Minimal WebSocket client for multiplayer communication.
*
* NOTE: Callbacks registered via `on()` are stored by reference.
* When using in React components, wrap callbacks in `useCallback`
* to ensure the same reference is passed to both `on()` and `off()`.
*/
export class WsClient {
private ws: WebSocket | null = null;
private callbacks = new Map<string, Set<(msg: WsServerMessage) => void>>();
/**
* Called when the WebSocket connection closes.
* Set by WsProvider — do not set directly in components.
*/
public onError: ((event: Event) => void) | null = null;
/**
* Called when the WebSocket connection encounters an error.
* Set by WsProvider — do not set directly in components.
*/
public onClose: ((event: CloseEvent) => void) | null = null;
connect(apiUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
if (
this.ws &&
(this.ws.readyState === WebSocket.OPEN ||
this.ws.readyState === WebSocket.CONNECTING)
) {
resolve();
return;
}
let wsUrl: string;
if (!apiUrl) {
wsUrl = "/ws";
} else {
wsUrl =
apiUrl
.replace(/^https:\/\//, "wss://")
.replace(/^http:\/\//, "ws://") + "/ws";
}
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
resolve();
};
this.ws.onmessage = (event: MessageEvent) => {
let parsed: unknown;
try {
parsed = JSON.parse(event.data as string);
} catch {
console.error("WsClient: received invalid JSON", event.data);
return;
}
const result = WsServerMessageSchema.safeParse(parsed);
if (!result.success) {
console.error("WsClient: received unknown message shape", parsed);
return;
}
const msg = result.data;
const handlers = this.callbacks.get(msg.type);
if (!handlers) return;
for (const handler of handlers) {
handler(msg);
}
};
this.ws.onerror = (event: Event) => {
this.onError?.(event);
reject(new Error("WebSocket connection failed"));
};
this.ws.onclose = (event: CloseEvent) => {
this.ws = null;
this.onClose?.(event);
};
});
}
disconnect(): void {
if (!this.ws) return;
this.ws.close();
this.ws = null;
}
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
send(message: WsClientMessage): void {
if (!this.isConnected()) {
console.warn(
"WsClient: attempted to send message while disconnected",
message,
);
return;
}
this.ws!.send(JSON.stringify(message));
}
on<T extends WsServerMessage["type"]>(
type: T,
callback: (msg: Extract<WsServerMessage, { type: T }>) => void,
): void {
if (!this.callbacks.has(type)) {
this.callbacks.set(type, new Set());
}
this.callbacks.get(type)!.add(callback as (msg: WsServerMessage) => void);
}
off<T extends WsServerMessage["type"]>(
type: T,
callback: (msg: Extract<WsServerMessage, { type: T }>) => void,
): void {
const handlers = this.callbacks.get(type);
if (!handlers) return;
handlers.delete(callback as (msg: WsServerMessage) => void);
if (handlers.size === 0) {
this.callbacks.delete(type);
}
}
clearCallbacks(): void {
this.callbacks.clear();
}
}