summaryrefslogtreecommitdiff
path: root/gui/src/hooks/useWs.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'gui/src/hooks/useWs.tsx')
-rw-r--r--gui/src/hooks/useWs.tsx326
1 files changed, 326 insertions, 0 deletions
diff --git a/gui/src/hooks/useWs.tsx b/gui/src/hooks/useWs.tsx
new file mode 100644
index 0000000..b11c0aa
--- /dev/null
+++ b/gui/src/hooks/useWs.tsx
@@ -0,0 +1,326 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+// --- Hook: useWebSocket ------------------------------------------------------
+// Handles: connect, open, message, error, close, reconnect (exp backoff + jitter),
+// heartbeat, offline/online, tab visibility, send queue, clean teardown.
+
+export type WsStatus = "idle" | "connecting" | "open" | "closing" | "closed";
+
+export interface UseWebSocketOptions {
+ url: string;
+ protocols?: string | string[];
+ autoReconnect?: boolean; // default: true
+ maxRetries?: number; // default: Infinity
+ backoffInitialMs?: number; // default: 500
+ backoffMaxMs?: number; // default: 10_000
+ heartbeatIntervalMs?: number; // default: 25_000 (typical ALB/NGINX timeouts ~60s)
+ heartbeatMessage?:
+ | string
+ | ArrayBuffer
+ | Blob
+ | (() => string | ArrayBuffer | Blob);
+ // If provided, decides whether to reconnect on close (e.g., avoid on 1000 normal close)
+ shouldReconnectOnClose?: (ev: CloseEvent) => boolean;
+ // Optional passive listeners
+ onOpen?: (ev: Event) => void;
+ onMessage?: (ev: MessageEvent) => void;
+ onError?: (ev: Event) => void;
+ onClose?: (ev: CloseEvent) => void;
+}
+
+export interface UseWebSocketApi {
+ status: WsStatus;
+ retryCount: number;
+ error: Event | CloseEvent | null;
+ bufferedAmount: number; // bytes currently queued in the socket buffer
+ lastMessage: MessageEvent | null;
+ // Sends immediately if OPEN, otherwise enqueues to flush on open
+ send: (data: string | ArrayBuffer | Blob) => boolean; // returns true if sent now
+ // attempt an immediate reconnect (resets backoff)
+ reconnectNow: () => void;
+ // graceful close (optionally with code & reason)
+ close: (code?: number, reason?: string) => void;
+}
+
+function jitter(ms: number) {
+ const spread = ms * 0.2; // ±20%
+ return ms + (Math.random() * 2 - 1) * spread;
+}
+
+export function useWebSocket(opts: UseWebSocketOptions): UseWebSocketApi {
+ const {
+ url,
+ protocols,
+ autoReconnect = true,
+ maxRetries = Number.POSITIVE_INFINITY,
+ backoffInitialMs = 500,
+ backoffMaxMs = 10_000,
+ heartbeatIntervalMs = 25_000,
+ heartbeatMessage = () => (typeof window !== "undefined" ? "ping" : "ping"),
+ shouldReconnectOnClose = (ev) =>
+ ev.code !== 1000 && ev.code !== 1001 && ev.code !== 1005,
+ onOpen,
+ onMessage,
+ onError,
+ onClose,
+ } = opts;
+
+ const wsRef = useRef<WebSocket | null>(null);
+ const heartbeatTimer = useRef<number | null>(null);
+ const reconnectTimer = useRef<number | null>(null);
+ const pendingQueueRef = useRef<(string | ArrayBuffer | Blob)[]>([]);
+ const retryCountRef = useRef(0);
+ const manualCloseRef = useRef(false); // track if close() was user-intended
+
+ const [status, setStatus] = useState<WsStatus>("idle");
+ const [retryCount, setRetryCount] = useState(0);
+ const [error, setError] = useState<Event | CloseEvent | null>(null);
+ const [lastMessage, setLastMessage] = useState<MessageEvent | null>(null);
+ const [bufferedAmount, setBufferedAmount] = useState(0);
+
+ // --- Internal helpers ------------------------------------------------------
+ const clearHeartbeat = () => {
+ if (heartbeatTimer.current) {
+ window.clearInterval(heartbeatTimer.current);
+ heartbeatTimer.current = null;
+ }
+ };
+
+ const clearReconnect = () => {
+ if (reconnectTimer.current) {
+ window.clearTimeout(reconnectTimer.current);
+ reconnectTimer.current = null;
+ }
+ };
+
+ const flushQueue = () => {
+ const ws = wsRef.current;
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ const q = pendingQueueRef.current;
+ while (q.length) {
+ const item = q.shift()!;
+ ws.send(item);
+ }
+ setBufferedAmount(ws.bufferedAmount);
+ };
+
+ const scheduleReconnect = (_dueTo: "close" | "error") => {
+ if (!autoReconnect) return;
+ if (manualCloseRef.current) return; // user requested close -> do not reconnect
+ if (retryCountRef.current >= maxRetries) return;
+
+ const attempt = retryCountRef.current + 1;
+ const backoff = Math.min(
+ backoffMaxMs,
+ backoffInitialMs * Math.pow(2, attempt - 1),
+ );
+ const delay = Math.max(250, jitter(backoff));
+
+ clearReconnect();
+ reconnectTimer.current = window.setTimeout(() => {
+ connect();
+ }, delay);
+ };
+
+ const startHeartbeat = () => {
+ clearHeartbeat();
+ if (!heartbeatIntervalMs) return;
+ heartbeatTimer.current = window.setInterval(() => {
+ const ws = wsRef.current;
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ try {
+ const msg =
+ typeof heartbeatMessage === "function"
+ ? heartbeatMessage()
+ : heartbeatMessage;
+ ws.send(msg);
+ } catch {}
+ }, heartbeatIntervalMs);
+ };
+
+ const bindSocketEvents = (ws: WebSocket) => {
+ ws.addEventListener("open", (ev) => {
+ setStatus("open");
+ setError(null);
+ retryCountRef.current = 0;
+ setRetryCount(0);
+ startHeartbeat();
+ flushQueue();
+ onOpen?.(ev);
+ });
+
+ ws.addEventListener("message", (ev) => {
+ setLastMessage(ev);
+ setBufferedAmount(ws.bufferedAmount);
+ onMessage?.(ev);
+ });
+
+ ws.addEventListener("error", (ev) => {
+ setError(ev);
+ setStatus(ws.readyState === WebSocket.CLOSED ? "closed" : "connecting");
+ onError?.(ev);
+ scheduleReconnect("error");
+ });
+
+ ws.addEventListener("close", (ev) => {
+ setStatus("closed");
+ setError(ev);
+ clearHeartbeat();
+ onClose?.(ev);
+ if (!manualCloseRef.current && shouldReconnectOnClose(ev)) {
+ retryCountRef.current += 1;
+ setRetryCount(retryCountRef.current);
+ scheduleReconnect("close");
+ }
+ });
+ };
+
+ const connect = useCallback(() => {
+ try {
+ if (
+ wsRef.current &&
+ (wsRef.current.readyState === WebSocket.OPEN ||
+ wsRef.current.readyState === WebSocket.CONNECTING)
+ ) {
+ return; // already connected/connecting
+ }
+ manualCloseRef.current = false;
+ setStatus("connecting");
+ const ws = new WebSocket(url, protocols);
+ wsRef.current = ws;
+ bindSocketEvents(ws);
+ } catch (e) {
+ setError(e as Event);
+ scheduleReconnect("error");
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [url, JSON.stringify(protocols)]);
+
+ // Maintain connection on mount & url changes
+ useEffect(() => {
+ connect();
+ return () => {
+ // teardown
+ clearReconnect();
+ clearHeartbeat();
+ const ws = wsRef.current;
+ if (
+ ws &&
+ (ws.readyState === WebSocket.OPEN ||
+ ws.readyState === WebSocket.CONNECTING)
+ ) {
+ try {
+ ws.close(1000, "component unmount");
+ } catch {}
+ }
+ wsRef.current = null;
+ };
+ }, [connect]);
+
+ // Reconnect when browser regains connectivity
+ useEffect(() => {
+ const onOnline = () => {
+ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN)
+ connect();
+ };
+ const onOffline = () => {
+ // proactively close to reset state; will reconnect when back online
+ const ws = wsRef.current;
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ try {
+ ws.close(1011, "offline");
+ } catch {}
+ }
+ };
+ window.addEventListener("online", onOnline);
+ window.addEventListener("offline", onOffline);
+ return () => {
+ window.removeEventListener("online", onOnline);
+ window.removeEventListener("offline", onOffline);
+ };
+ }, [connect]);
+
+ // Reconnect when tab becomes visible (helps with long-sleeped mobile tabs)
+ useEffect(() => {
+ const handler = () => {
+ if (!document.hidden) {
+ const ws = wsRef.current;
+ if (!ws || ws.readyState !== WebSocket.OPEN) connect();
+ }
+ };
+ document.addEventListener("visibilitychange", handler);
+ return () => document.removeEventListener("visibilitychange", handler);
+ }, [connect]);
+
+ const send: UseWebSocketApi["send"] = useCallback((data) => {
+ const ws = wsRef.current;
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(data);
+ setBufferedAmount(ws.bufferedAmount);
+ return true;
+ }
+ pendingQueueRef.current.push(data);
+ return false;
+ }, []);
+
+ const reconnectNow = useCallback(() => {
+ retryCountRef.current = 0;
+ setRetryCount(0);
+ clearReconnect();
+ const ws = wsRef.current;
+ if (
+ ws &&
+ (ws.readyState === WebSocket.OPEN ||
+ ws.readyState === WebSocket.CONNECTING)
+ ) {
+ try {
+ ws.close(1012, "manual reconnect");
+ } catch {}
+ } else {
+ connect();
+ }
+ }, [connect]);
+
+ const close: UseWebSocketApi["close"] = useCallback(
+ (code = 1000, reason = "client close") => {
+ manualCloseRef.current = true;
+ clearReconnect();
+ clearHeartbeat();
+ const ws = wsRef.current;
+ if (
+ ws &&
+ (ws.readyState === WebSocket.OPEN ||
+ ws.readyState === WebSocket.CONNECTING)
+ ) {
+ try {
+ ws.close(code, reason);
+ } catch {}
+ setStatus("closing");
+ }
+ },
+ [],
+ );
+
+ return useMemo(
+ () => ({
+ status,
+ retryCount,
+ error,
+ bufferedAmount,
+ lastMessage,
+ send,
+ reconnectNow,
+ close,
+ }),
+ [
+ status,
+ retryCount,
+ error,
+ bufferedAmount,
+ lastMessage,
+ send,
+ reconnectNow,
+ close,
+ ],
+ );
+}