import { useEffect, useRef, useState, useCallback } from 'react'; const WS_BASE = (() => { if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL; const loc = window.location; const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; // Use the same origin as the page; in development the Vite dev server // proxies /api/ws to the backend, and in production nginx does the same // via the existing location /api/ block (no extra nginx config needed). return `${proto}//${loc.host}/api/ws`; })(); const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 15000, 30000]; // ms /** * Maintains a WebSocket connection with automatic reconnection. * * @param {(message: object) => void} onMessage - Called for each parsed JSON message. * @returns {{ status: 'connecting'|'connected'|'disconnected'|'error' }} */ export default function useWebSocket(onMessage) { const [status, setStatus] = useState('connecting'); const wsRef = useRef(null); const retryCountRef = useRef(0); const unmountedRef = useRef(false); const retryTimerRef = useRef(null); const onMessageRef = useRef(onMessage); // Keep onMessage ref up-to-date without re-running the effect useEffect(() => { onMessageRef.current = onMessage; }, [onMessage]); const connect = useCallback(() => { if (unmountedRef.current) return; setStatus('connecting'); const ws = new WebSocket(WS_BASE); wsRef.current = ws; ws.onopen = () => { if (unmountedRef.current) { ws.close(); return; } retryCountRef.current = 0; setStatus('connected'); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); onMessageRef.current(msg); } catch { // ignore malformed messages } }; ws.onclose = () => { if (unmountedRef.current) return; const delay = RECONNECT_DELAYS[Math.min(retryCountRef.current, RECONNECT_DELAYS.length - 1)]; retryCountRef.current += 1; // After exhausting all backoff levels, keep status as 'error' if (retryCountRef.current > RECONNECT_DELAYS.length) { setStatus('error'); } else { setStatus('disconnected'); } retryTimerRef.current = setTimeout(connect, delay); }; ws.onerror = () => { // onclose will fire right after, which handles reconnect }; }, []); useEffect(() => { // Reset on each mount so that React StrictMode's simulated unmount/remount // does not leave unmountedRef permanently true and block reconnection. unmountedRef.current = false; connect(); return () => { unmountedRef.current = true; clearTimeout(retryTimerRef.current); const ws = wsRef.current; if (ws) { // Null out all handlers first to prevent any reconnect attempts. ws.onopen = null; ws.onclose = null; ws.onerror = null; ws.onmessage = null; if (ws.readyState === WebSocket.CONNECTING) { // Closing a CONNECTING socket triggers the browser warning // "WebSocket is closed before the connection is established". // Instead, schedule a close as soon as it opens so the server // isn't left with a permanently idle connection. ws.onopen = () => ws.close(); } else if (ws.readyState === WebSocket.OPEN) { ws.close(); } } }; }, [connect]); return { status }; }