feat(web): add WsClient class for multiplayer WebSocket communication
- connect(apiUrl) derives wss:// from https:// automatically, returns
Promise<void> 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<WsServerMessage, { type: T }> for
precise callback narrowing per message type
- callbacks stored as Map<string, Set<fn>> supporting multiple
listeners per message type
- clearCallbacks() for explicit cleanup on provider unmount
- onError/onClose as separate lifecycle properties distinct
from message handlers
This commit is contained in:
parent
ce19740cc8
commit
d60b0da9df
1 changed files with 116 additions and 0 deletions
116
apps/web/src/lib/ws-client.ts
Normal file
116
apps/web/src/lib/ws-client.ts
Normal file
|
|
@ -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<string, Set<(msg: WsServerMessage) => void>>();
|
||||||
|
|
||||||
|
public onError: ((event: Event) => void) | null = null;
|
||||||
|
public onClose: ((event: CloseEvent) => void) | null = null;
|
||||||
|
|
||||||
|
connect(apiUrl: string): Promise<void> {
|
||||||
|
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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue