TypeScript 55.4%
Python 43.2%
SQL 1.2%
1"use client";23import { useEffect, useRef, useState } from "react";4import type { LiveEvent } from "./api";56export type LiveStatus = "connecting" | "live" | "reconnecting" | "offline";78export function wsUrl(): string {9 if (typeof window === "undefined") return "";10 const base = process.env.NEXT_PUBLIC_API_URL;11 if (base) return base.replace(/^http/, "ws") + "/api/v1/live";12 const proto = window.location.protocol === "https:" ? "wss" : "ws";13 return `${proto}://${window.location.host}/api/v1/live`;14}1516/**17 * Shared WebSocket to /api/v1/live (protocol 2). Subscribes to the given channels and calls18 * `onEvent` for every matching event. Reconnects with exponential backoff (1 s → 30 s) and, on19 * reconnection, asks the gateway to replay everything after the last stream id seen (spec §60),20 * so a laptop waking from sleep catches up without duplicates (ids are de-duplicated by callers).21 */22export function useLive(channels: string[], onEvent: (ev: LiveEvent, channels: string[]) => void): LiveStatus {23 const [status, setStatus] = useState<LiveStatus>("connecting");24 const handler = useRef(onEvent);25 const lastSid = useRef<string | null>(null);26 useEffect(() => {27 handler.current = onEvent;28 });29 const key = channels.join("|");3031 useEffect(() => {32 let ws: WebSocket | null = null;33 let closed = false;34 let attempt = 0;35 let timer: ReturnType<typeof setTimeout> | null = null;36 let watchdog: ReturnType<typeof setInterval> | null = null;37 let lastFrame = Date.now();38 const subs = key.split("|").filter(Boolean);3940 const connect = (): void => {41 if (closed) return;42 try {43 ws = new WebSocket(wsUrl());44 } catch {45 schedule();46 return;47 }48 ws.onopen = () => {49 const wasReconnect = attempt > 0;50 attempt = 0;51 lastFrame = Date.now();52 setStatus("live");53 ws?.send(JSON.stringify({ subscribe: subs, unsubscribe: subs.includes("events:global") ? [] : ["events:global"], ...(wasReconnect && lastSid.current ? { since: lastSid.current } : {}) }));54 };55 ws.onmessage = (m) => {56 lastFrame = Date.now();57 try {58 const msg = JSON.parse(String(m.data)) as { type: string; sid?: string; event?: LiveEvent; channels?: string[] };59 if (msg.type === "event" && msg.event) {60 if (msg.sid) lastSid.current = msg.sid;61 handler.current({ ...msg.event, sid: msg.sid }, msg.channels ?? []);62 }63 } catch {64 // ignore malformed frames65 }66 };67 ws.onclose = () => {68 if (closed) return;69 setStatus(attempt > 3 ? "offline" : "reconnecting");70 schedule();71 };72 ws.onerror = () => {73 ws?.close();74 };75 };76 const schedule = (): void => {77 if (closed) return;78 const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5)) * (0.8 + Math.random() * 0.4);79 attempt++;80 timer = setTimeout(connect, delay);81 };82 // Heartbeats arrive every 25 s; if nothing for 70 s the socket is half-dead → reconnect.83 watchdog = setInterval(() => {84 if (ws && ws.readyState === WebSocket.OPEN && Date.now() - lastFrame > 70_000) ws.close();85 }, 10_000);86 const onVisible = (): void => {87 if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN && ws.readyState !== WebSocket.CONNECTING) {88 if (timer) clearTimeout(timer);89 connect();90 }91 };92 document.addEventListener("visibilitychange", onVisible);93 connect();94 return () => {95 closed = true;96 if (timer) clearTimeout(timer);97 if (watchdog) clearInterval(watchdog);98 document.removeEventListener("visibilitychange", onVisible);99 ws?.close();100 };101 }, [key]);102103 return status;104}105