Skip to content
58 linesCodeBlameRaw
1/**
2 * A project's live tail in the browser: the socket the site forwards to
3 * the project's store (routes/repo/observability-live.ts), opened while
4 * a page follows it, and opened again after it drops.
5 */
6import { useEffect, useRef, useState } from "react";
7
8import type { LiveItem } from "@g1t/contracts";
9
10export type LiveState = "off" | "connecting" | "live" | "dropped";
11
12export function useTelemetryLive(path: string, on: boolean, onItems: (items: LiveItem[], dropped: number) => void): LiveState {
13 const [state, setState] = useState<LiveState>("off");
14 const handler = useRef(onItems);
15 handler.current = onItems;
16 useEffect(() => {
17 if (!on) {
18 setState("off");
19 return;
20 }
21 let ws: WebSocket | null = null;
22 let closed = false;
23 let retry: ReturnType<typeof setTimeout> | undefined;
24 let ping: ReturnType<typeof setInterval> | undefined;
25 let wait = 1000;
26 const open = () => {
27 setState("connecting");
28 ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${path}`);
29 ws.onopen = () => {
30 wait = 1000;
31 setState("live");
32 ping = setInterval(() => ws?.readyState === WebSocket.OPEN && ws.send("ping"), 30_000);
33 };
34 ws.onmessage = (event) => {
35 if (typeof event.data !== "string" || event.data === "pong") return;
36 try {
37 const message = JSON.parse(event.data) as { type: string; items?: LiveItem[]; dropped?: number };
38 if (message.type === "items" && message.items) handler.current(message.items, message.dropped ?? 0);
39 } catch {}
40 };
41 ws.onclose = () => {
42 clearInterval(ping);
43 if (closed) return;
44 setState("dropped");
45 retry = setTimeout(open, wait);
46 wait = Math.min(wait * 2, 30_000);
47 };
48 };
49 open();
50 return () => {
51 closed = true;
52 clearTimeout(retry);
53 clearInterval(ping);
54 ws?.close();
55 };
56 }, [path, on]);
57 return state;
58}