Files
PacketStation/frontend/src/lib/buildWatch.ts
T
spencermandClaude Opus 5.5 6e820ba0a5 Version banner, File locations section, final CSP (no unsafe-inline styles, frame-src none, object-src none)
The MTR viewer draws PDFs with pdf.js instead of an iframe the server's frame-ancestors refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:50:05 -06:00

91 lines
3.1 KiB
TypeScript

// Notices a new server build while the page is open, so the user can reload (phase 10).
// The page remembers the build at load and asks again on window focus, when the event stream reconnects
// (a deploy restarts the server and drops the stream) and every few minutes. It never reloads by itself:
// a form may be half filled.
import { useEffect, useState } from "react";
import { request } from "../api/client";
export const BUILD_CHECK_MS = 5 * 60_000;
/** Focus and reconnects can come in bursts. One check per this many ms is enough. */
export const BUILD_MIN_GAP_MS = 10_000;
/** The build id from /api/version: `build`, else `version`. Null when neither is there. */
export function buildOf(raw: unknown): string | null {
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
for (const v of [o.build, o.version]) if (typeof v === "string" && v.trim() !== "") return v.trim();
return null;
}
const reconnectListeners = new Set<() => void>();
/** The event stream calls this when it opens again after a drop. */
export function streamReconnected(): void {
for (const fn of reconnectListeners) fn();
}
export function onStreamReconnect(fn: () => void): () => void {
reconnectListeners.add(fn);
return () => {
reconnectListeners.delete(fn);
};
}
export interface BuildWatchOptions {
intervalMs?: number;
minGapMs?: number;
}
/** True once /api/version answers with a build other than the one the page loaded with. Stays true. */
export function useBuildChanged({ intervalMs = BUILD_CHECK_MS, minGapMs = BUILD_MIN_GAP_MS }: BuildWatchOptions = {}): boolean {
const [changed, setChanged] = useState(false);
useEffect(() => {
const ctl = new AbortController();
let base: string | null = null;
let busy = false;
let done = false;
let last = Number.NEGATIVE_INFINITY;
const check = async () => {
const now = Date.now();
if (busy || done || now - last < minGapMs) return;
busy = true;
last = now;
try {
const b = buildOf(await request<unknown>("/api/version", { signal: ctl.signal }));
if (!b || ctl.signal.aborted) return;
if (base === null) base = b;
else if (b !== base) {
done = true;
setChanged(true);
}
} catch {
// Offline or restarting. The next focus, reconnect or tick asks again, with no wait.
last = Number.NEGATIVE_INFINITY;
} finally {
busy = false;
}
};
const kick = () => void check();
const onVisible = () => {
if (document.visibilityState === "visible") kick();
};
kick();
window.addEventListener("focus", kick);
document.addEventListener("visibilitychange", onVisible);
const offReconnect = onStreamReconnect(kick);
const timer = window.setInterval(kick, intervalMs);
return () => {
ctl.abort();
window.removeEventListener("focus", kick);
document.removeEventListener("visibilitychange", onVisible);
offReconnect();
window.clearInterval(timer);
};
}, [intervalMs, minGapMs]);
return changed;
}