From d60b0da9df0deb37ee31dd69c74e8bc4f9d21e9d Mon Sep 17 00:00:00 2001 From: lila Date: Fri, 17 Apr 2026 20:44:33 +0200 Subject: [PATCH] feat(web): add WsClient class for multiplayer WebSocket communication - connect(apiUrl) derives wss:// from https:// automatically, returns Promise resolving on open, rejecting on error - disconnect() closes connection, no-op if already closed - isConnected() checks readyState === OPEN - send(message) typed to WsClientMessage discriminated union - on/off typed with Extract for precise callback narrowing per message type - callbacks stored as Map> supporting multiple listeners per message type - clearCallbacks() for explicit cleanup on provider unmount - onError/onClose as separate lifecycle properties distinct from message handlers --- apps/web/src/lib/ws-client.ts | 116 ++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 apps/web/src/lib/ws-client.ts diff --git a/apps/web/src/lib/ws-client.ts b/apps/web/src/lib/ws-client.ts new file mode 100644 index 0000000..1edb3b7 --- /dev/null +++ b/apps/web/src/lib/ws-client.ts @@ -0,0 +1,116 @@ +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 void>>(); + + public onError: ((event: Event) => void) | null = null; + public onClose: ((event: CloseEvent) => void) | null = null; + + connect(apiUrl: string): Promise { + return new Promise((resolve, reject) => { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + const wsUrl = apiUrl + .replace(/^https:\/\//, "wss://") + .replace(/^http:\/\//, "ws://"); + + this.ws = new WebSocket(`${wsUrl}/ws`); + + 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( + type: T, + callback: (msg: Extract) => void, + ): void { + if (!this.callbacks.has(type)) { + this.callbacks.set(type, new Set()); + } + this.callbacks.get(type)!.add(callback as (msg: WsServerMessage) => void); + } + + off( + type: T, + callback: (msg: Extract) => 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(); + } +}