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