"use client"; import { useEffect, useRef, useState } from "react"; import type { LiveEvent } from "./api"; export type LiveStatus = "connecting" | "live" | "reconnecting" | "offline"; export function wsUrl(): string { if (typeof window === "undefined") return ""; const base = process.env.NEXT_PUBLIC_API_URL; if (base) return base.replace(/^http/, "ws") + "/api/v1/live"; const proto = window.location.protocol === "https:" ? "wss" : "ws"; return `${proto}://${window.location.host}/api/v1/live`; } /** * Shared WebSocket to /api/v1/live (protocol 2). Subscribes to the given channels and calls * `onEvent` for every matching event. Reconnects with exponential backoff (1 s → 30 s) and, on * reconnection, asks the gateway to replay everything after the last stream id seen (spec §60), * so a laptop waking from sleep catches up without duplicates (ids are de-duplicated by callers). */ export function useLive(channels: string[], onEvent: (ev: LiveEvent, channels: string[]) => void): LiveStatus { const [status, setStatus] = useState("connecting"); const handler = useRef(onEvent); const lastSid = useRef(null); useEffect(() => { handler.current = onEvent; }); const key = channels.join("|"); useEffect(() => { let ws: WebSocket | null = null; let closed = false; let attempt = 0; let timer: ReturnType | null = null; let watchdog: ReturnType | null = null; let lastFrame = Date.now(); const subs = key.split("|").filter(Boolean); const connect = (): void => { if (closed) return; try { ws = new WebSocket(wsUrl()); } catch { schedule(); return; } ws.onopen = () => { const wasReconnect = attempt > 0; attempt = 0; lastFrame = Date.now(); setStatus("live"); ws?.send(JSON.stringify({ subscribe: subs, unsubscribe: subs.includes("events:global") ? [] : ["events:global"], ...(wasReconnect && lastSid.current ? { since: lastSid.current } : {}) })); }; ws.onmessage = (m) => { lastFrame = Date.now(); try { const msg = JSON.parse(String(m.data)) as { type: string; sid?: string; event?: LiveEvent; channels?: string[] }; if (msg.type === "event" && msg.event) { if (msg.sid) lastSid.current = msg.sid; handler.current({ ...msg.event, sid: msg.sid }, msg.channels ?? []); } } catch { // ignore malformed frames } }; ws.onclose = () => { if (closed) return; setStatus(attempt > 3 ? "offline" : "reconnecting"); schedule(); }; ws.onerror = () => { ws?.close(); }; }; const schedule = (): void => { if (closed) return; const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5)) * (0.8 + Math.random() * 0.4); attempt++; timer = setTimeout(connect, delay); }; // Heartbeats arrive every 25 s; if nothing for 70 s the socket is half-dead → reconnect. watchdog = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN && Date.now() - lastFrame > 70_000) ws.close(); }, 10_000); const onVisible = (): void => { if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN && ws.readyState !== WebSocket.CONNECTING) { if (timer) clearTimeout(timer); connect(); } }; document.addEventListener("visibilitychange", onVisible); connect(); return () => { closed = true; if (timer) clearTimeout(timer); if (watchdog) clearInterval(watchdog); document.removeEventListener("visibilitychange", onVisible); ws?.close(); }; }, [key]); return status; }