diff --git a/ui/e2e/maintenance.spec.ts b/ui/e2e/maintenance.spec.ts
new file mode 100644
index 0000000..110b8d4
--- /dev/null
+++ b/ui/e2e/maintenance.spec.ts
@@ -0,0 +1,38 @@
+import { expect, test } from './fixtures';
+
+// The maintenance overlay is raised in prod by the edge 503 marker (X-Scrabble-Maintenance);
+// the mock transport never produces one, so — like the offline indicator — the e2e drives it
+// through the window.__maint hook (gateway.ts, mock-only). It is app-global (mounted outside
+// the route blocks in App.svelte), so it shows without a session.
+test('maintenance overlay covers the app and lifts on recovery', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByRole('alertdialog')).toHaveCount(0);
+
+ // A planned deploy window begins: a non-dismissable dimmed overlay covers the app.
+ await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on());
+ const overlay = page.getByRole('alertdialog');
+ await expect(overlay).toBeVisible();
+ // The retry button is present (EN "Try again" / RU "Повторить" depending on locale).
+ await expect(overlay.getByRole('button', { name: /again|Повторить/i })).toBeVisible();
+
+ // The window ends — in prod the store's poll lifts it on the first successful read; the mock
+ // has no probe, so it clears explicitly. The overlay disappears with no page reload.
+ await page.evaluate(() => (window as unknown as { __maint: { off(): void } }).__maint.off());
+ await expect(page.getByRole('alertdialog')).toHaveCount(0);
+});
+
+test('recovery reloads the SPA to pick up the fresh client', async ({ page }) => {
+ await page.goto('/');
+ await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on());
+ await expect(page.getByRole('alertdialog')).toBeVisible();
+
+ // Real recovery reloads the page (the deploy may have shipped an incompatible client, and the
+ // running bundle is the old one). Wait for the forced navigation, then confirm the app
+ // re-bootstrapped: the overlay is gone (the store reset by the reload) and the login is back.
+ await Promise.all([
+ page.waitForEvent('load'),
+ page.evaluate(() => (window as unknown as { __maint: { recover(): void } }).__maint.recover()),
+ ]);
+ await expect(page.getByRole('alertdialog')).toHaveCount(0);
+ await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
+});
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index a1b8824..ad3f36d 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -9,6 +9,7 @@
import StaleInviteModal from './components/StaleInviteModal.svelte';
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
import Coachmark from './components/Coachmark.svelte';
+ import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
import DebugPanel from './components/DebugPanel.svelte';
import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte';
@@ -128,6 +129,7 @@
+
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
diff --git a/ui/src/components/MaintenanceOverlay.svelte b/ui/src/components/MaintenanceOverlay.svelte
new file mode 100644
index 0000000..e002597
--- /dev/null
+++ b/ui/src/components/MaintenanceOverlay.svelte
@@ -0,0 +1,83 @@
+
+
+{#if maintenance.active}
+
+
+
Э
+
{t('maintenance.title')}
+
{t('maintenance.body')}
+
+
+
+{/if}
+
+
diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts
index 6e2d805..a8bef98 100644
--- a/ui/src/lib/gateway.ts
+++ b/ui/src/lib/gateway.ts
@@ -6,6 +6,7 @@ import type { GatewayClient } from './client';
import { MockGateway } from './mock/client';
import { createTransport } from './transport';
import { reportOffline, reportOnline } from './connection.svelte';
+import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
const isMock = import.meta.env.MODE === 'mock';
@@ -20,6 +21,15 @@ if (isMock && typeof window !== 'undefined') {
offline: reportOffline,
online: reportOnline,
};
+ // The mock never produces a real 503, so the e2e drives the maintenance overlay directly
+ // (the store's poll is inert in mock — no probe is registered — so `off` clears it).
+ (
+ window as unknown as { __maint?: { on(retryAfterMs?: number): void; off(): void; recover(): void } }
+ ).__maint = {
+ on: (retryAfterMs = 15000) => reportMaintenance(retryAfterMs),
+ off: clearMaintenance,
+ recover: maintenanceRecovered,
+ };
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer).
(
diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts
index 1739be6..e19daca 100644
--- a/ui/src/lib/i18n/en.ts
+++ b/ui/src/lib/i18n/en.ts
@@ -7,6 +7,9 @@ export const en = {
'app.brand': 'Erudit',
'dict.loading': 'Loading…',
'connection.connecting': 'Connecting…',
+ 'maintenance.title': 'Under maintenance',
+ 'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
+ 'maintenance.retry': 'Try again',
'blocked.title': 'Account blocked',
'blocked.permanent': 'Your account is blocked.',
diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts
index 4ee0347..81e6966 100644
--- a/ui/src/lib/i18n/ru.ts
+++ b/ui/src/lib/i18n/ru.ts
@@ -8,6 +8,9 @@ export const ru: Record = {
'app.brand': 'Эрудит',
'dict.loading': 'Загрузка…',
'connection.connecting': 'Подключение…',
+ 'maintenance.title': 'Технические работы',
+ 'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
+ 'maintenance.retry': 'Повторить',
'blocked.title': 'Учётная запись заблокирована',
'blocked.permanent': 'Ваша учётная запись заблокирована.',
diff --git a/ui/src/lib/maintenance.svelte.ts b/ui/src/lib/maintenance.svelte.ts
new file mode 100644
index 0000000..2745b9d
--- /dev/null
+++ b/ui/src/lib/maintenance.svelte.ts
@@ -0,0 +1,99 @@
+// 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 | null = null;
+let attempt = 0;
+let probing = false;
+let probe: (() => Promise) | 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 {
+ 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();
+ },
+ );
+}
diff --git a/ui/src/lib/maintenance.test.ts b/ui/src/lib/maintenance.test.ts
new file mode 100644
index 0000000..24f0092
--- /dev/null
+++ b/ui/src/lib/maintenance.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+import { Code, ConnectError } from '@connectrpc/connect';
+import { maintenanceRetryMs, parseRetryAfterMs } from './maintenance';
+
+// A raw ConnectError as connect-web builds it from a non-Connect HTTP 503: the response
+// headers are preserved as `.metadata` (verified against @connectrpc/connect-web).
+const err503 = (headers: Record): ConnectError =>
+ new ConnectError('HTTP 503', Code.Unavailable, new Headers(headers));
+
+describe('maintenanceRetryMs', () => {
+ it('detects the marker and parses Retry-After', () => {
+ expect(maintenanceRetryMs(err503({ 'x-scrabble-maintenance': '1', 'retry-after': '120' }))).toBe(120_000);
+ });
+
+ it('matches the header case-insensitively, defaulting the delay when Retry-After is absent', () => {
+ expect(maintenanceRetryMs(err503({ 'X-Scrabble-Maintenance': '1' }))).toBe(15_000);
+ });
+
+ it('returns null for a 503 without the marker (a plain transient failure)', () => {
+ expect(maintenanceRetryMs(err503({ 'retry-after': '30' }))).toBeNull();
+ });
+
+ it('returns null for non-Connect errors', () => {
+ expect(maintenanceRetryMs(new Error('boom'))).toBeNull();
+ expect(maintenanceRetryMs(undefined)).toBeNull();
+ expect(maintenanceRetryMs('nope')).toBeNull();
+ });
+});
+
+describe('parseRetryAfterMs', () => {
+ it('clamps into the 3s–120s band', () => {
+ expect(parseRetryAfterMs('120')).toBe(120_000);
+ expect(parseRetryAfterMs('1')).toBe(3_000); // floor
+ expect(parseRetryAfterMs('9999')).toBe(120_000); // ceiling
+ });
+
+ it('falls back to the default on absent / garbage / non-positive', () => {
+ expect(parseRetryAfterMs(null)).toBe(15_000);
+ expect(parseRetryAfterMs(undefined)).toBe(15_000);
+ expect(parseRetryAfterMs('soon')).toBe(15_000);
+ expect(parseRetryAfterMs('0')).toBe(15_000);
+ expect(parseRetryAfterMs('-5')).toBe(15_000);
+ });
+});
diff --git a/ui/src/lib/maintenance.ts b/ui/src/lib/maintenance.ts
new file mode 100644
index 0000000..63dbcf4
--- /dev/null
+++ b/ui/src/lib/maintenance.ts
@@ -0,0 +1,37 @@
+// 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'));
+}
diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts
index 1ec3551..dd89956 100644
--- a/ui/src/lib/transport.ts
+++ b/ui/src/lib/transport.ts
@@ -12,6 +12,8 @@ import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec';
import { browserOffset } from './profileValidation';
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
+import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
+import { maintenanceRetryMs } from './maintenance';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
const MAX_RETRIES = 6;
@@ -28,10 +30,14 @@ export function createTransport(baseUrl: string): GatewayClient {
// The reachability probe the connection watcher fires while offline: a cheap authenticated read
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
- registerProbe(async () => {
+ const reachabilityProbe = async (): Promise => {
if (!token) throw new Error('no session');
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
- });
+ };
+ registerProbe(reachabilityProbe);
+ // The maintenance overlay reuses the same cheap read to poll for the end of a deploy
+ // window; the first success lifts the overlay (maintenance.svelte.ts).
+ registerMaintenanceProbe(reachabilityProbe);
// exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
@@ -43,6 +49,14 @@ export function createTransport(baseUrl: string): GatewayClient {
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal });
} catch (e) {
if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry
+ const maintMs = maintenanceRetryMs(e);
+ if (maintMs !== null) {
+ // A planned deploy window (the edge 503 carried X-Scrabble-Maintenance): raise the
+ // overlay and fail fast — its own poll drives recovery — instead of burning the
+ // retry budget on every read for the length of the window.
+ reportMaintenance(maintMs);
+ throw toGatewayError(e);
+ }
const err = toGatewayError(e);
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
reportOffline();
@@ -53,6 +67,9 @@ export function createTransport(baseUrl: string): GatewayClient {
throw err;
}
reportOnline();
+ // A read got through: if the maintenance overlay was up, the deploy window has ended —
+ // reload to pick up the (possibly incompatible) fresh client (maintenance.svelte.ts).
+ maintenanceRecovered();
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
return res.payload;
}
@@ -310,7 +327,11 @@ export function createTransport(baseUrl: string): GatewayClient {
if (pe) onEvent(pe);
}
} catch (e) {
- if (!ctrl.signal.aborted) onError?.(toGatewayError(e));
+ if (!ctrl.signal.aborted) {
+ const maintMs = maintenanceRetryMs(e);
+ if (maintMs !== null) reportMaintenance(maintMs);
+ onError?.(toGatewayError(e));
+ }
}
})();
return () => ctrl.abort();