Files
scrabble-game/ui/src/lib/maintenance.ts
T
Ilia Denisov d67e582c03
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 1m4s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
feat(ui): in-session maintenance overlay on the edge 503 marker
The edge caddy 503 carries X-Scrabble-Maintenance during a deploy window, but a user
already in the running SPA only sees their calls start failing — the static caddy page
catches only a fresh load. Detect that marker (strictly — not any transient
'unavailable', which the Connecting indicator already covers) on the raw ConnectError at
the two transport catch sites (unary exec + the live subscribe stream), before
toGatewayError discards the response headers, and raise a non-dismissable dimmed overlay
that mirrors the static caddy page. It self-clears: a capped-backoff poll (a cheap read,
mirroring connection.svelte.ts) lifts it on the first success, and a manual "retry"
button forces an immediate re-check — so it can never get stuck. On detection the read
retry loop fails fast, so a window doesn't burn the retry budget on every call.

- pure detector maintenance.ts (maintenanceRetryMs / parseRetryAfterMs) + unit tests;
  the store + self-clearing poll in maintenance.svelte.ts (mirrors connection.svelte.ts)
- MaintenanceOverlay.svelte (clones Splash's fixed/inset/dimmed shell, non-dismissable),
  mounted app-global in App.svelte after Coachmark
- transport.ts detects + reports at both catch sites, clears on any successful read
- i18n RU "Технические работы" / EN "Under maintenance"; a window.__maint mock hook
  (the mock can't emit a real 503) + a Playwright spec

Prod is same-origin (VITE_GATEWAY_URL empty) so the marker header is readable without a
CORS expose-header. Verified: pnpm check (0), unit (402), build, e2e (186, Chromium+WebKit).
2026-07-03 22:40:49 +02:00

38 lines
1.8 KiB
TypeScript

// Detection of a planned edge maintenance window. During a prod deploy roll the edge
// caddy returns HTTP 503 with an `X-Scrabble-Maintenance: 1` header (and a `Retry-After`)
// for every gated route. The SPA keys STRICTLY on that marker — not on any transport
// `unavailable`, which the "Connecting…" indicator already covers — so a planned window
// raises the maintenance overlay while an ordinary blip stays a soft reconnect.
//
// These helpers are pure (no DOM, no runes) so they unit-test in the node vitest env. The
// maintenance marker + Retry-After ride on the raw ConnectError's `metadata`, which
// toGatewayError (retry.ts) discards — so detection must run on the raw error.
import { ConnectError } from '@connectrpc/connect';
const DEFAULT_RETRY_MS = 15_000;
const MIN_RETRY_MS = 3_000;
const MAX_RETRY_MS = 120_000;
/**
* parseRetryAfterMs converts a `Retry-After` header (delta-seconds — the edge emits
* seconds, never an HTTP-date) into a millisecond hint clamped to a sane band. An absent
* or unparseable value falls back to a default.
*/
export function parseRetryAfterMs(header: string | null | undefined): number {
const secs = header == null ? NaN : Number(header);
if (!Number.isFinite(secs) || secs <= 0) return DEFAULT_RETRY_MS;
return Math.min(MAX_RETRY_MS, Math.max(MIN_RETRY_MS, Math.round(secs * 1000)));
}
/**
* maintenanceRetryMs returns the maintenance `Retry-After` hint in milliseconds when the
* thrown transport error is the edge maintenance 503 (carries `X-Scrabble-Maintenance: 1`),
* or null for any other error. Header lookup is case-insensitive (Headers.get).
*/
export function maintenanceRetryMs(e: unknown): number | null {
if (!(e instanceof ConnectError)) return null;
if (e.metadata?.get('x-scrabble-maintenance') !== '1') return null;
return parseRetryAfterMs(e.metadata.get('retry-after'));
}