From 9affe339c6fe6512699e349e227637f26a14a04b Mon Sep 17 00:00:00 2001 From: lila Date: Fri, 17 Apr 2026 21:12:15 +0200 Subject: [PATCH] feat(web): add WebSocket client and context infrastructure - WsClient class: connect/disconnect/send/on/off/isConnected/clearCallbacks - connect() derives wss:// from https:// automatically, returns Promise - on/off typed with Extract for precise callback narrowing, callbacks stored as Map> - ws-context.ts: WsContextValue type + WsContext definition - ws-provider.tsx: WsProvider with module-level wsClient singleton, owns connection lifecycle (connect/disconnect/isConnected state) - ws-hooks.ts: useWsClient, useWsConnected, useWsConnect, useWsDisconnect --- apps/web/src/lib/ws-client.ts | 9 +++++++ apps/web/src/lib/ws-context.ts | 11 ++++++++ apps/web/src/lib/ws-hooks.ts | 35 +++++++++++++++++++++++++ apps/web/src/lib/ws-provider.tsx | 44 ++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 apps/web/src/lib/ws-context.ts create mode 100644 apps/web/src/lib/ws-hooks.ts create mode 100644 apps/web/src/lib/ws-provider.tsx diff --git a/apps/web/src/lib/ws-client.ts b/apps/web/src/lib/ws-client.ts index 1edb3b7..f1da6cc 100644 --- a/apps/web/src/lib/ws-client.ts +++ b/apps/web/src/lib/ws-client.ts @@ -12,7 +12,16 @@ export class WsClient { private ws: WebSocket | null = null; private callbacks = new Map 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 { diff --git a/apps/web/src/lib/ws-context.ts b/apps/web/src/lib/ws-context.ts new file mode 100644 index 0000000..d6c230c --- /dev/null +++ b/apps/web/src/lib/ws-context.ts @@ -0,0 +1,11 @@ +import { createContext } from "react"; +import type { WsClient } from "./ws-client.js"; + +export type WsContextValue = { + client: WsClient; + isConnected: boolean; + connect: (url: string) => Promise; + disconnect: () => void; +}; + +export const WsContext = createContext(null); diff --git a/apps/web/src/lib/ws-hooks.ts b/apps/web/src/lib/ws-hooks.ts new file mode 100644 index 0000000..08417c0 --- /dev/null +++ b/apps/web/src/lib/ws-hooks.ts @@ -0,0 +1,35 @@ +import { useContext } from "react"; +import { WsContext } from "./ws-context.js"; +import type { WsClient } from "./ws-client.js"; + +export const useWsClient = (): WsClient => { + const ctx = useContext(WsContext); + if (!ctx) { + throw new Error("useWsClient must be used within a WsProvider"); + } + return ctx.client; +}; + +export const useWsConnected = (): boolean => { + const ctx = useContext(WsContext); + if (!ctx) { + throw new Error("useWsConnected must be used within a WsProvider"); + } + return ctx.isConnected; +}; + +export const useWsConnect = (): ((url: string) => Promise) => { + const ctx = useContext(WsContext); + if (!ctx) { + throw new Error("useWsConnect must be used within a WsProvider"); + } + return ctx.connect; +}; + +export const useWsDisconnect = (): (() => void) => { + const ctx = useContext(WsContext); + if (!ctx) { + throw new Error("useWsDisconnect must be used within a WsProvider"); + } + return ctx.disconnect; +}; diff --git a/apps/web/src/lib/ws-provider.tsx b/apps/web/src/lib/ws-provider.tsx new file mode 100644 index 0000000..1b34bd6 --- /dev/null +++ b/apps/web/src/lib/ws-provider.tsx @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import { WsClient } from "./ws-client.js"; +import { WsContext } from "./ws-context.js"; + +const wsClient = new WsClient(); + +export const WsProvider = ({ children }: { children: ReactNode }) => { + const [isConnected, setIsConnected] = useState(false); + + const connect = useCallback(async (url: string): Promise => { + wsClient.onClose = () => setIsConnected(false); + wsClient.onError = () => setIsConnected(false); + try { + await wsClient.connect(url); + setIsConnected(true); + } catch (err) { + setIsConnected(false); + throw err; + } + }, []); + + const disconnect = useCallback((): void => { + wsClient.disconnect(); + setIsConnected(false); + }, []); + + useEffect(() => { + return () => { + wsClient.disconnect(); + wsClient.clearCallbacks(); + wsClient.onClose = null; + wsClient.onError = null; + }; + }, []); + + return ( + + {children} + + ); +};