Files
scrabble-game/ui/src/lib/maintenance.svelte.ts
T
Ilia Denisov 7dcd62fdd7
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
feat(ui): reload the SPA on maintenance recovery
The deploy that ends a maintenance window may ship a client-incompatible change
(wire/schema bump), and the in-session bundle is the old one. On recovery, reload to
pick up the fresh client instead of just hiding the overlay. Ordering is safe: the edge
maintenance flag (deploy/prod-deploy.sh) spans the WHOLE roll and clears only at script
exit — after the gateway (which serves the embedded SPA) has rolled — so the edge 503s
everything until the entire deploy is live; recovery therefore always serves the new SPA.
A window.__maint.recover() hook + e2e cover the reload.
2026-07-03 22:46:55 +02:00

100 lines
3.3 KiB
TypeScript

// Global maintenance signal + self-clearing poll. `active` is true while the edge is in a
// planned deploy window (a caddy 503 carried `X-Scrabble-Maintenance`; see maintenance.ts).
// A non-dismissable overlay (MaintenanceOverlay.svelte) covers the app while active, and a
// cheap read is retried on a capped-backoff cadence (mirroring connection.svelte.ts) — the
// first success lifts the overlay, so it can never get stuck even with no other traffic.
// Mock mode never reports maintenance, so it simply stays inactive.
import { backoffMs } from './retry';
let active = $state(false);
let retryHintMs = $state(0);
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let attempt = 0;
let probing = false;
let probe: (() => Promise<void>) | null = null;
export const maintenance = {
/** active is true while the edge is in a planned maintenance window. */
get active(): boolean {
return active;
},
/** retryHintMs is the last Retry-After hint in milliseconds (0 when unknown). */
get retryHintMs(): number {
return retryHintMs;
},
};
/** registerMaintenanceProbe installs the reachability read the poll fires while active
* (the transport wires a cheap authenticated read). */
export function registerMaintenanceProbe(fn: () => Promise<void>): void {
probe = fn;
}
/** reportMaintenance raises the overlay and starts the self-clearing poll. Idempotent: a
* repeat hit only refreshes the hint, it never restarts a running poll. */
export function reportMaintenance(retryAfterMs: number): void {
retryHintMs = retryAfterMs;
if (active) return;
active = true;
attempt = 0;
if (probe) schedulePoll();
}
/** clearMaintenance lowers the overlay and stops the poll without reloading (a reset, or the
* e2e hook). Prod recovery goes through maintenanceRecovered instead. */
export function clearMaintenance(): void {
active = false;
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
}
/** maintenanceRecovered runs when the edge answers again after a window we were showing.
* The deploy that ended the window may have shipped a client-incompatible change (a wire /
* schema bump), and the running bundle is the OLD one — so reload to pick up the fresh
* client instead of merely hiding the overlay. A no-op unless the overlay was up; the cover
* stays over the brief reload flash. In prod recovery is the only way the overlay clears. */
export function maintenanceRecovered(): void {
if (!active) return;
active = false;
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
if (typeof location !== 'undefined') location.reload();
}
/** retryNow forces an immediate re-check (the overlay's button). It never closes the
* overlay itself — only a successful probe does. A no-op while a probe is in flight. */
export function retryNow(): void {
if (!active || !probe || probing) return;
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
attempt = 0;
runProbe();
}
function schedulePoll(): void {
pollTimer = setTimeout(runProbe, backoffMs(++attempt));
}
function runProbe(): void {
pollTimer = null;
if (!active || !probe || probing) return;
probing = true;
probe().then(
() => {
probing = false;
maintenanceRecovered();
},
() => {
probing = false;
if (active) schedulePoll();
},
);
}