The sidebar footer gains a status dot beside the spawn button: green while the daemon answers, amber while reconnecting, red while it is gone. Hovering it shows the release the daemon is running — commit hash and commit date. When the events socket drops, `DaemonLink` starts polling `GET /api/info`. Past a 1200 ms grace window an overlay blocks the UI (and swallows the app's shortcuts) until the daemon answers again. If it comes back on a different commit the page reloads, so a tab never drives new server code from the bundle that was just replaced; a plain restart just unlocks. docs/RESTART.md is the contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
219 lines
7.4 KiB
TypeScript
219 lines
7.4 KiB
TypeScript
// 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<DaemonInfo>;
|
|
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<LinkListener>();
|
|
private readonly fetchInfo: () => Promise<DaemonInfo>;
|
|
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<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|