From d67e582c03a9de45c622a3f951be439af5c95b9b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 22:40:49 +0200 Subject: [PATCH 1/2] feat(ui): in-session maintenance overlay on the edge 503 marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ui/e2e/maintenance.spec.ts | 22 ++++++ ui/src/App.svelte | 2 + ui/src/components/MaintenanceOverlay.svelte | 83 +++++++++++++++++++++ ui/src/lib/gateway.ts | 7 ++ ui/src/lib/i18n/en.ts | 3 + ui/src/lib/i18n/ru.ts | 3 + ui/src/lib/maintenance.svelte.ts | 83 +++++++++++++++++++++ ui/src/lib/maintenance.test.ts | 44 +++++++++++ ui/src/lib/maintenance.ts | 37 +++++++++ ui/src/lib/transport.ts | 25 ++++++- 10 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 ui/e2e/maintenance.spec.ts create mode 100644 ui/src/components/MaintenanceOverlay.svelte create mode 100644 ui/src/lib/maintenance.svelte.ts create mode 100644 ui/src/lib/maintenance.test.ts create mode 100644 ui/src/lib/maintenance.ts diff --git a/ui/e2e/maintenance.spec.ts b/ui/e2e/maintenance.spec.ts new file mode 100644 index 0000000..4d913dd --- /dev/null +++ b/ui/e2e/maintenance.spec.ts @@ -0,0 +1,22 @@ +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); +}); 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..7cbe567 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, reportMaintenance } from './maintenance.svelte'; const isMock = import.meta.env.MODE === 'mock'; @@ -20,6 +21,12 @@ 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 } }).__maint = { + on: (retryAfterMs = 15000) => reportMaintenance(retryAfterMs), + off: clearMaintenance, + }; // 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..65133e8 --- /dev/null +++ b/ui/src/lib/maintenance.svelte.ts @@ -0,0 +1,83 @@ +// 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 (a successful read, or reset). */ +export function clearMaintenance(): void { + active = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } +} + +/** 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; + clearMaintenance(); + }, + () => { + 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..2230466 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 { clearMaintenance, 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,7 @@ export function createTransport(baseUrl: string): GatewayClient { throw err; } reportOnline(); + clearMaintenance(); if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode); return res.payload; } @@ -310,7 +325,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(); From 7dcd62fdd7826f6174d68daaa40aa4e519dc854e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 22:46:55 +0200 Subject: [PATCH 2/2] feat(ui): reload the SPA on maintenance recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/e2e/maintenance.spec.ts | 16 ++++++++++++++++ ui/src/lib/gateway.ts | 7 +++++-- ui/src/lib/maintenance.svelte.ts | 20 ++++++++++++++++++-- ui/src/lib/transport.ts | 6 ++++-- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/ui/e2e/maintenance.spec.ts b/ui/e2e/maintenance.spec.ts index 4d913dd..110b8d4 100644 --- a/ui/e2e/maintenance.spec.ts +++ b/ui/e2e/maintenance.spec.ts @@ -20,3 +20,19 @@ test('maintenance overlay covers the app and lifts on recovery', async ({ page } 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/lib/gateway.ts b/ui/src/lib/gateway.ts index 7cbe567..a8bef98 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -6,7 +6,7 @@ import type { GatewayClient } from './client'; import { MockGateway } from './mock/client'; import { createTransport } from './transport'; import { reportOffline, reportOnline } from './connection.svelte'; -import { clearMaintenance, reportMaintenance } from './maintenance.svelte'; +import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte'; const isMock = import.meta.env.MODE === 'mock'; @@ -23,9 +23,12 @@ if (isMock && typeof window !== 'undefined') { }; // 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 } }).__maint = { + ( + 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/maintenance.svelte.ts b/ui/src/lib/maintenance.svelte.ts index 65133e8..2745b9d 100644 --- a/ui/src/lib/maintenance.svelte.ts +++ b/ui/src/lib/maintenance.svelte.ts @@ -41,7 +41,8 @@ export function reportMaintenance(retryAfterMs: number): void { if (probe) schedulePoll(); } -/** clearMaintenance lowers the overlay and stops the poll (a successful read, or reset). */ +/** 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) { @@ -50,6 +51,21 @@ export function clearMaintenance(): void { } } +/** 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 { @@ -73,7 +89,7 @@ function runProbe(): void { probe().then( () => { probing = false; - clearMaintenance(); + maintenanceRecovered(); }, () => { probing = false; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 2230466..dd89956 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -12,7 +12,7 @@ import { GatewayError, type GatewayClient } from './client'; import * as codec from './codec'; import { browserOffset } from './profileValidation'; import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; -import { clearMaintenance, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte'; +import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte'; import { maintenanceRetryMs } from './maintenance'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'; @@ -67,7 +67,9 @@ export function createTransport(baseUrl: string): GatewayClient { throw err; } reportOnline(); - clearMaintenance(); + // 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; }