// React port of ../../daemonLink.ts. The link to the daemon behind this page: // is it up, and which release is it? `RestartOverlay` (./RestartOverlay.tsx) // renders what this reports. See docs/RESTART.md for the restart contract. import { useEffect, useRef } from "react"; import { create } from "zustand"; import { getDaemonInfo } from "../../api.js"; import type { BuildInfo, DaemonInfo } from "../../types.js"; /** * - `connecting` — first probe hasn't landed yet. * - `online` — the daemon answered and is the same process, or a resolved new one. * - `reconnecting` — the socket dropped, but within the grace window; a blip * shouldn't throw a modal in the user's face. * - `down` — unreachable past the grace window: the overlay blocks the UI and * the poll runs until it answers. * - `updating` — it answered on a different commit; the page is reloading and * stays blocked until it does. */ export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating"; /** What happened between two `/api/info` replies. */ export type RestartKind = "same" | "restarted" | "upgraded"; /** How long a dropped socket may stay dropped before the UI blocks. Long enough * to ride out a wifi hiccup, short enough that a real restart is visible. */ export const GRACE_MS = 1200; /** Cadence of the `/api/info` poll while the daemon is unreachable. */ export const POLL_MS = 700; /** * Compare two daemon replies. * * The commit decides first: a different one means new code, which for a browser * means new assets. The per-process `instance` catches the rest — a restart of * the same build, which needs no reload. A daemon built outside a git checkout * reports an empty commit, so an empty-vs-empty pair never counts as an * upgrade; the instance still reports the restart. */ export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind { const a = before.build?.commit ?? ""; const b = after.build?.commit ?? ""; if (a && b && a !== b) return "upgraded"; if (before.instance && after.instance && before.instance !== after.instance) return "restarted"; return "same"; } /** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when * the build carried uncommitted changes. Empty builds say so rather than * rendering a blank tooltip. */ export function formatRelease(build: BuildInfo | undefined): string { if (!build?.commit) return "unknown release"; const day = build.commit_date ? build.commit_date.slice(0, 10) : ""; const parts = [build.commit]; if (day) parts.push(`· ${day}`); if (build.dirty) parts.push("(dirty)"); return parts.join(" "); } /** Schedules a probe/grace timer; returns a handle `clearTimer` understands. */ export interface Scheduler { setTimer(fn: () => void, ms: number): number; clearTimer(handle: number): void; } const browserScheduler: Scheduler = { setTimer: (fn, ms) => window.setTimeout(fn, ms), clearTimer: (h) => window.clearTimeout(h), }; export interface DaemonLinkState { state: LinkState; /** The release the daemon last reported. Undefined until the first probe, * and on a daemon too old to carry one. */ build?: BuildInfo; /** Hostname the daemon reports, for the indicator's tooltip. */ hostname?: string; /** How many probes have failed in the current outage — the overlay counts * attempts so a long restart doesn't look frozen. */ attempts: number; } export interface DaemonLinkActions { /** One `/api/info` round-trip. Resolves `true` when the daemon answered. */ probe(): Promise; /** The events socket dropped. Starts the grace timer and the poll. */ noteDisconnected(): void; /** The events socket came back. It carries no identity of its own, so the * state only clears once a probe confirms *which* daemon answered — a * reconnect to an upgraded daemon must reload, not resume. */ noteConnected(): void; /** Stop polling. */ stop(): void; } export type DaemonLinkStore = DaemonLinkState & DaemonLinkActions; export interface DaemonLinkOptions { /** Injectable for tests; defaults to `GET /api/info`. */ fetchInfo?: () => Promise; scheduler?: Scheduler; /** What an upgrade does. Injectable so tests don't navigate. */ reload?: () => void; graceMs?: number; pollMs?: number; } /** Builds an isolated daemon-link store — same shape as * {@link ../../stores/catalogStore.js!createCatalogStore}: both a React hook * and a plain object (`getState`/`subscribe`), so it drives the overlay * whether or not React is the one calling `noteConnected`/`noteDisconnected`. */ export function createDaemonLinkStore(opts: DaemonLinkOptions = {}) { const fetchInfo = opts.fetchInfo ?? getDaemonInfo; const scheduler = opts.scheduler ?? browserScheduler; const reload = opts.reload ?? (() => window.location.reload()); const graceMs = opts.graceMs ?? GRACE_MS; const pollMs = opts.pollMs ?? POLL_MS; let identity: DaemonInfo | null = null; let graceTimer: number | null = null; let pollTimer: number | null = null; return create((set, get) => { function clearTimers(): void { if (graceTimer !== null) { scheduler.clearTimer(graceTimer); graceTimer = null; } if (pollTimer !== null) { scheduler.clearTimer(pollTimer); pollTimer = null; } } async function probe(): Promise { let info: DaemonInfo; try { info = await fetchInfo(); } catch { set((s) => ({ attempts: s.attempts + 1 })); return false; } const before = identity; identity = info; if (before && compareIdentity(before, info) === "upgraded") { // Stay blocked through the reload: an unlocked frame here would let the // user drive new server code from the bundle it just replaced. set({ state: "updating", build: info.build, hostname: info.hostname }); reload(); return true; } clearTimers(); set({ state: "online", build: info.build, hostname: info.hostname, attempts: 0 }); return true; } async function poll(): Promise { const state = get().state; if (state === "online" || state === "updating") return; const ok = await probe(); if (ok || pollTimer !== null) return; pollTimer = scheduler.setTimer(() => { pollTimer = null; void poll(); }, pollMs); } return { state: "connecting", build: undefined, hostname: undefined, attempts: 0, probe, noteDisconnected() { const state = get().state; if (state === "down" || state === "updating") return; set({ state: "reconnecting", attempts: 0 }); if (graceTimer === null) { graceTimer = scheduler.setTimer(() => { graceTimer = null; if (get().state === "reconnecting") set({ state: "down" }); void poll(); }, graceMs); } }, noteConnected() { const state = get().state; if (state === "online" || state === "updating") return; void probe(); }, stop: clearTimers, }; }); } export type DaemonLinkStoreApi = ReturnType; export interface DaemonLinkProps { /** The link store this instance drives — `RestartOverlay` (and, later, the * sidebar's status indicator) read the same one. */ store: DaemonLinkStoreApi; /** The catalog store's `connected` field (docs/RESTART.md): the events * socket dying is the earliest signal a restart happened, ahead of any * poll. Pass `useStore(catalogStore, (s) => s.connected)`. */ connected: boolean; } /** * Renders nothing — the controller that wires a `DaemonLinkStore` to the * catalog's socket state. Mount once near the app root; `RestartOverlay` * reads the store it drives. */ export function DaemonLink({ store, connected }: DaemonLinkProps): null { const mounted = useRef(false); useEffect(() => { if (!mounted.current) { mounted.current = true; void store.getState().probe(); return; } if (connected) store.getState().noteConnected(); else store.getState().noteDisconnected(); }, [store, connected]); useEffect(() => () => store.getState().stop(), [store]); return null; }