Files
car/frontend/src/hooks/useWebSocket.js

104 lines
3.4 KiB
JavaScript

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