SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
3.7 KB · 105 lines typescript
Raw Blame History
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