OmniVoice-Studio / frontend /src /hooks /useRealtimeEvents.js
Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
3.68 kB
/**
* 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]);
}