// The link to the daemon behind this page: is it up, and which release is it? // The sidebar's status indicator and the restart overlay both read this one // controller. See docs/RESTART.md for the restart contract it implements. import { getDaemonInfo } from "./api.js"; import type { Scheduler } from "./events.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"; export interface LinkStatus { 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; } /** 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; const browserScheduler: Scheduler = { setTimer: (fn, ms) => window.setTimeout(fn, ms), clearTimer: (h) => window.clearTimeout(h), }; /** * 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(" "); } 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; } export type LinkListener = (status: LinkStatus) => void; export class DaemonLink { private state: LinkState = "connecting"; private identity: DaemonInfo | null = null; private attempts = 0; private graceTimer: number | null = null; private pollTimer: number | null = null; private readonly listeners = new Set(); private readonly fetchInfo: () => Promise; private readonly scheduler: Scheduler; private readonly reload: () => void; private readonly graceMs: number; private readonly pollMs: number; constructor(opts: DaemonLinkOptions = {}) { this.fetchInfo = opts.fetchInfo ?? getDaemonInfo; this.scheduler = opts.scheduler ?? browserScheduler; this.reload = opts.reload ?? (() => window.location.reload()); this.graceMs = opts.graceMs ?? GRACE_MS; this.pollMs = opts.pollMs ?? POLL_MS; } /** Record the release we started against. Everything later is a diff on it. */ async start(): Promise { await this.probe(); } status(): LinkStatus { return { state: this.state, build: this.identity?.build, hostname: this.identity?.hostname, attempts: this.attempts, }; } subscribe(listener: LinkListener): () => void { this.listeners.add(listener); listener(this.status()); return () => this.listeners.delete(listener); } /** The events socket dropped. Starts the grace timer and the poll. */ noteDisconnected(): void { if (this.state === "down" || this.state === "updating") return; this.attempts = 0; this.setState("reconnecting"); if (this.graceTimer === null) { this.graceTimer = this.scheduler.setTimer(() => { this.graceTimer = null; if (this.state === "reconnecting") this.setState("down"); void this.poll(); }, this.graceMs); } } /** 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 { if (this.state === "online" || this.state === "updating") return; void this.probe(); } /** * One `/api/info` round-trip. Resolves `true` when the daemon answered. * * A first answer becomes the baseline. A later one that differs on commit * reloads the page; anything else (plain restart, or the same process) puts * the UI back online. */ async probe(): Promise { let info: DaemonInfo; try { info = await this.fetchInfo(); } catch { this.attempts += 1; this.emit(); return false; } const before = this.identity; this.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. this.setState("updating"); this.reload(); return true; } this.attempts = 0; this.clearTimers(); this.setState("online"); return true; } /** Stop polling. The page's own teardown; not used in normal operation. */ stop(): void { this.clearTimers(); } private async poll(): Promise { if (this.state === "online" || this.state === "updating") return; const ok = await this.probe(); if (ok || this.pollTimer !== null) return; this.pollTimer = this.scheduler.setTimer(() => { this.pollTimer = null; void this.poll(); }, this.pollMs); } private clearTimers(): void { if (this.graceTimer !== null) { this.scheduler.clearTimer(this.graceTimer); this.graceTimer = null; } if (this.pollTimer !== null) { this.scheduler.clearTimer(this.pollTimer); this.pollTimer = null; } } private setState(state: LinkState): void { if (this.state === state) return; this.state = state; this.emit(); } private emit(): void { const s = this.status(); for (const fn of this.listeners) fn(s); } }