91 lines
3.1 KiB
TypeScript
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;
|
||
|
|
}
|