/** * useRealtimeEvents — WebSocket connection to /ws/events for live sidebar updates. * * Connects once on mount, automatically reconnects with exponential backoff, * and dispatches invalidation signals to the parent callbacks. * * Events from backend: * { kind: "projects", action: "created"|"updated"|"deleted", id: "..." } * { kind: "profiles", action: "created"|"updated"|"locked"|"unlocked"|"deleted", id: "..." } * { kind: "dub_history", action: "saved"|"deleted", id: "..." } * { kind: "export_history", action: "exported"|"recorded", id: "..." } * { kind: "ping" } // keepalive, ignored */ import { useEffect, useRef, useCallback } from 'react'; import { API } from '../api/client'; const WS_EVENTS_URL = API.replace(/^http/, 'ws') + '/ws/events'; /** * @param {Object} handlers - Map of event kind → callback * @param {Function} handlers.projects - Called when projects list changes * @param {Function} handlers.profiles - Called when profiles list changes * @param {Function} handlers.dub_history - Called when dub history changes * @param {Function} handlers.export_history - Called when export history changes */ export default function useRealtimeEvents(handlers) { const wsRef = useRef(null); const handlersRef = useRef(handlers); const reconnectTimerRef = useRef(null); const retryCountRef = useRef(0); const mountedRef = useRef(true); // Keep handlers ref current without causing reconnects useEffect(() => { handlersRef.current = handlers; }); const connect = useCallback(() => { if (!mountedRef.current) return; // Don't double-connect if (wsRef.current && wsRef.current.readyState <= 1) return; try { const ws = new WebSocket(WS_EVENTS_URL); wsRef.current = ws; ws.onopen = () => { retryCountRef.current = 0; console.debug('[ws/events] connected'); }; ws.onmessage = (e) => { try { const event = JSON.parse(e.data); const kind = event.kind; if (kind === 'ping') return; // keepalive, ignore const handler = handlersRef.current?.[kind]; if (handler) { handler(event); } } catch (err) { console.warn('[ws/events] bad message:', e.data, err); } }; ws.onclose = (e) => { wsRef.current = null; if (!mountedRef.current) return; // Exponential backoff: 2s, 4s, 8s, 16s, max 60s const delay = Math.min(2000 * Math.pow(2, retryCountRef.current), 60_000); retryCountRef.current++; if (retryCountRef.current <= 5) { console.debug(`[ws/events] closed (code=${e.code}), reconnecting in ${delay}ms`); } reconnectTimerRef.current = setTimeout(connect, delay); }; ws.onerror = () => { // onerror is always followed by onclose, so we just let onclose handle reconnect ws.close(); }; } catch (err) { console.warn('[ws/events] connection failed:', err); const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30_000); retryCountRef.current++; reconnectTimerRef.current = setTimeout(connect, delay); } }, []); useEffect(() => { mountedRef.current = true; connect(); return () => { mountedRef.current = false; if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } if (wsRef.current) { wsRef.current.onclose = null; // prevent reconnect on unmount wsRef.current.close(); wsRef.current = null; } }; }, [connect]); }