import { useEffect } from 'react'; import { WsFrameSchema } from '@boocode/contracts/ws-frames'; import { sessionEvents } from './sessionEvents'; import { createWsReconnectToast } from './wsReconnectToast'; const RECONNECT_INITIAL_MS = 1000; const RECONNECT_MAX_MS = 30000; export function useUserEvents(): void { useEffect(() => { let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let reconnectDelay = RECONNECT_INITIAL_MS; let unmounted = false; // v1.8.1: silent on the first disconnect; gray "reconnecting…" after 3 // fails / 15 s; red "connection lost" with a Retry Now action after 60 s. const reconnectToast = createWsReconnectToast({ label: 'Live updates', onRetryNow: () => { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; reconnectDelay = RECONNECT_INITIAL_MS; connect(); } }, }); const connect = () => { if (unmounted) return; const url = new URL('/api/ws/user', window.location.href); url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(url.toString()); ws.onopen = () => { reconnectDelay = RECONNECT_INITIAL_MS; reconnectToast.onConnected(); }; ws.onmessage = (ev) => { // v1.13.11-a: Zod-validate every inbound frame. Fail-closed — invalid // frames are logged and dropped instead of dispatched onto the // sessionEvents bus where a stale or wrong shape would silently // corrupt sidebar / chat state. let raw: unknown; try { raw = JSON.parse(ev.data); } catch (err) { console.warn('useUserEvents: failed to parse frame', err); return; } const validated = WsFrameSchema.safeParse(raw); if (!validated.success) { console.error('ws-frame-validation-failed (user channel)', { frame_type: (raw as { type?: unknown })?.type, errors: validated.error.flatten(), }); return; } // Bridge cast: Zod's union is broader than SessionEvent (it includes // per-session-channel frames too, which never arrive on the user // channel). sessionEvents.emit only dispatches frames whose type // appears in SessionEvent; the narrowing happens via the existing // useSidebar.ts applyEvent switch. sessionEvents.emit( validated.data as unknown as import('./sessionEvents').SessionEvent, ); }; ws.onclose = () => { if (unmounted) return; reconnectToast.onFailure(); const delay = reconnectDelay; reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS); reconnectTimer = setTimeout(connect, delay); }; ws.onerror = () => { // close handler will trigger reconnect; best-effort, ignore failure // because the socket may already be closing try { ws?.close(); } catch {} }; }; connect(); return () => { unmounted = true; reconnectToast.dispose(); if (reconnectTimer) clearTimeout(reconnectTimer); if (ws) try { ws.close(); } catch {} }; }, []); }