Co-authored-by: h <57948770+h@users.noreply.github.com> Agent-Logs-Url: https://github.com/h/CarReservation/sessions/a42d4e36-a3cf-4ff7-b1cb-f076e601b1b8
104 lines
3.4 KiB
JavaScript
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 };
|
|
}
|