diff --git a/panel/src/lib/useWebSocket.ts b/panel/src/lib/useWebSocket.ts new file mode 100644 index 0000000..831d86d --- /dev/null +++ b/panel/src/lib/useWebSocket.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef, useCallback, useState } from "react"; + +type MessageHandler = (data: any) => void; + +let globalWs: WebSocket | null = null; +let globalHandlers = new Set(); +let globalConnected = false; +let reconnectTimer: ReturnType | null = null; +let reconnectAttempt = 0; + +function getWsUrl(): string { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}/ws`; +} + +function connect(): void { + if (globalWs?.readyState === WebSocket.OPEN || globalWs?.readyState === WebSocket.CONNECTING) return; + + const ws = new WebSocket(getWsUrl()); + + ws.onopen = () => { + globalConnected = true; + reconnectAttempt = 0; + globalHandlers.forEach(h => h({ type: "__WS_CONNECTED" })); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + globalHandlers.forEach(h => h(data)); + } catch { + // ignore parse errors + } + }; + + ws.onclose = () => { + globalConnected = false; + globalWs = null; + globalHandlers.forEach(h => h({ type: "__WS_DISCONNECTED" })); + + const delay = Math.min(1000 * 2 ** reconnectAttempt, 30000); + reconnectAttempt++; + reconnectTimer = setTimeout(connect, delay); + }; + + ws.onerror = () => { + ws.close(); + }; + + globalWs = ws; +} + +function disconnect(): void { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + globalWs?.close(); + globalWs = null; + globalConnected = false; +} + +export function useWebSocket() { + const [connected, setConnected] = useState(globalConnected); + const refCount = useRef(0); + + useEffect(() => { + refCount.current++; + if (refCount.current === 1 && !globalWs) { + connect(); + } + + const handler: MessageHandler = (data) => { + if (data.type === "__WS_CONNECTED") setConnected(true); + if (data.type === "__WS_DISCONNECTED") setConnected(false); + }; + globalHandlers.add(handler); + + return () => { + globalHandlers.delete(handler); + refCount.current--; + if (refCount.current === 0) { + disconnect(); + } + }; + }, []); + + const send = useCallback((data: unknown) => { + if (globalWs?.readyState === WebSocket.OPEN) { + globalWs.send(JSON.stringify(data)); + } + }, []); + + const subscribe = useCallback((handler: MessageHandler) => { + globalHandlers.add(handler); + return () => { globalHandlers.delete(handler); }; + }, []); + + return { connected, send, subscribe }; +}