// Coder user-channel WS — mirrors useUserEvents but connects to the BooCoder // host service's /api/coder/ws/user endpoint. Forwards flow_run_started and // flow_run_step_updated frames onto the sessionEvents bus so OrchestratorPane // can subscribe to run-level lifecycle updates without a per-session WS. // // Event-dedup discipline: do NOT additionally emit these frames locally after // a POST /api/coder/runs call — this hook forwards the authoritative WS frame. import { useEffect } from 'react'; import { WsFrameSchema } from '@boocode/contracts/ws-frames'; import { sessionEvents } from './sessionEvents'; import type { BattleStartedEvent, BattleUpdatedEvent, ContestantUpdatedEvent, FlowRunStartedEvent, FlowRunStepUpdatedEvent, } from './sessionEvents'; const RECONNECT_INITIAL_MS = 1000; const RECONNECT_MAX_MS = 30_000; export function useCoderUserEvents(): void { useEffect(() => { let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let reconnectDelay = RECONNECT_INITIAL_MS; let unmounted = false; const connect = () => { if (unmounted) return; const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${proto}//${window.location.host}/api/coder/ws/user`); ws.onopen = () => { reconnectDelay = RECONNECT_INITIAL_MS; }; ws.onmessage = (ev) => { let raw: unknown; try { raw = JSON.parse(ev.data as string); } catch { return; } const validated = WsFrameSchema.safeParse(raw); if (!validated.success) { console.error('ws-frame-validation-failed (coder user channel)', { frame_type: (raw as { type?: unknown })?.type, errors: validated.error.flatten(), }); return; } const frame = validated.data; if (frame.type === 'flow_run_started') { sessionEvents.emit(frame as unknown as FlowRunStartedEvent); } else if (frame.type === 'flow_run_step_updated') { sessionEvents.emit(frame as unknown as FlowRunStepUpdatedEvent); } else if (frame.type === 'battle_started') { sessionEvents.emit(frame as unknown as BattleStartedEvent); } else if (frame.type === 'contestant_updated') { sessionEvents.emit(frame as unknown as ContestantUpdatedEvent); } else if (frame.type === 'battle_updated') { sessionEvents.emit(frame as unknown as BattleUpdatedEvent); } }; ws.onclose = () => { if (unmounted) return; const delay = reconnectDelay; reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS); reconnectTimer = setTimeout(connect, delay); }; ws.onerror = () => { try { ws?.close(); } catch {} }; }; connect(); return () => { unmounted = true; if (reconnectTimer) clearTimeout(reconnectTimer); if (ws) try { ws.close(); } catch {} }; }, []); }