Compare commits

...

30 Commits

Author SHA1 Message Date
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer fcedadcb5b Merge pull request 'feat(gateway): unsupported-engine telemetry beacon + Grafana counter' (#177) from feature/unsupported-engine-telemetry into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / deploy (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
2026-07-04 21:16:23 +00:00
Ilia Denisov 2feb638329 feat(gateway): unsupported-engine telemetry beacon + Grafana counter
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
The index.html boot guard now fires a fire-and-forget beacon (POST /telemetry/unsupported)
when it turns a client away on the unsupported-engine screen, so the owner can see — on the
Users dashboard beside "app opens" — how many real clients hit it and on which engines.

- Client: navigator.sendBeacon (fetch fallback) with a localStorage dedup keyed by app version
  + reason + Chromium, so a user reopening the app is one report, not ten.
- Gateway: a new unauthenticated POST /telemetry/unsupported handler (the client never booted,
  so it carries no session), per-IP public-limited and body-capped, mirroring the export-download
  route. It folds the beacon into the OTel counter unsupported_engine_total {reason =
  no_bigint/no_proxy/boot_error/other, chromium}, with reason allow-listed and the Chromium major
  reduced to a bounded range (normalizeUnsupported) so a spoofed beacon cannot inflate the metric
  cardinality; the full user agent is logged, not labelled.
- Caddy: /telemetry/* added to the @gateway matcher (else it falls to the landing catch-all).
- Grafana: two panels on the Scrabble — Users dashboard (by reason, by Chromium major).
- Docs: ARCHITECTURE.md §11.

Tests: recordUnsupportedEngine (metric split), normalizeUnsupported (cardinality bounding), and
the handler route end to end (204 / 405 / counter). go build+vet+test and gofmt clean; ui
check/build/e2e green; the client beacon + dedup verified against a forced hard-gate (BigInt
removed) — one POST, deduped on reopen, correct reason/chromium/version labels.
2026-07-04 23:03:47 +02:00
developer 4dfedd02a3 feat(ui): support old Android in-app WebViews (es2019 + core-js + engine screen)
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s
Old Android System WebViews (the Telegram/VK in-app browser; a real device with Google
Play auto-updates the WebView, but emulators / no-Play / restricted devices stay frozen)
showed a white screen. Two causes, fixed in order:

- build.target es2022 shipped ?./?? verbatim, which the old engine cannot parse. Lowered
  to es2019 so esbuild down-levels the syntax.
- The es2020+ runtime globals the bundle and its deps call (globalThis, structuredClone,
  Array.at, ...) are missing on those engines. A conditional core-js loader (emitPolyfills
  writes dist/polyfills.js, document.write'd by an index.html gate only when needed) covers
  them; modern engines download nothing, so the bundle-size budget is untouched.

BigInt (the 64-bit FlatBuffers timestamp decode) and Proxy (Svelte 5 runes) cannot be
polyfilled, so the effective floor is Chrome 67. Below it, a permanent ES5 boot guard in
index.html shows a friendly "this device's OS or browser can't run the app" screen (with
the web-version link inside a Mini App) and a "Diagnostic information" view with a Copy
button, instead of a white screen. A reactive net raises the same screen on an uncaught
boot error that never signals window.__booted.

Board tile glyphs used container-query units (cqw, Chrome 105+) under .cell font-size:0,
so they collapsed to 0px on Chrome 74 (invisible letters); added vmin fallbacks.

The unsupported-engine telemetry beacon is a separate follow-up PR.
2026-07-04 20:36:05 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 44117e906c Merge pull request 'fix(ci): prod build needs EXPORT_SIGN_KEY for compose interpolation' (#174) from fix/prod-build-export-sign-key into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 18s
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 9s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-03 21:42:05 +00:00
Ilia Denisov c90331b189 fix(ci): prod build needs EXPORT_SIGN_KEY for compose interpolation
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
The first v1.8.0 prod-deploy build job failed: `docker compose build` interpolates the
WHOLE compose file, and the backend's :?-guarded EXPORT_SIGN_KEY — added with the
finished-game export after the last prod release (v1.7.0), so the prod build never
exercised it — was absent from the build job's env, tripping the guard before any image
built. Prod was untouched (build-only failure: no image pushed, no deploy, no migration).

Add EXPORT_SIGN_KEY (audited every :?-guarded compose var against the build job env — it
was the only genuinely-missing one; TELEGRAM_MINIAPP_URL is derived in the run step).
Verified by reproducing the build-job env + `docker compose config`.
2026-07-03 23:37:48 +02:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 0dfecc2af7 Merge pull request 'fix(ci): arm the maintenance flag on the test-contour deploy' (#172) from fix/maintenance-flag-test-contour into development
CI / deploy (push) Successful in 1m44s
CI / changes (pull_request) Successful in 1s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 8s
2026-07-03 21:10:14 +00:00
Ilia Denisov 446ea2ac45 fix(ci): arm the maintenance flag on the test-contour deploy
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The maintenance overlay never showed on the test contour: only prod-deploy.sh raised
the caddy maintenance flag, so a test redeploy was a bare gateway/backend recreate — the
SPA saw a transient 502 ("Reconnecting…"), never the 503 + X-Scrabble-Maintenance marker
the overlay keys on. So the feature (PR #171) was unverifiable off prod.

Raise the flag around the recreate in ci.yaml's deploy job too, mirroring prod. Ordering
matters because of the config reseed: ci does `rm -rf $conf` + recreate, so the running
caddy sits on the stale pre-reseed bind mount and cannot see a flag written to the new
dir until it is recreated. So: raise the flag, force-recreate caddy FIRST (onto the fresh
mount, where it carries the flag and 503s), then recreate gateway/backend (the window),
then the other config services, then lower it. A trap clears the flag if the step fails,
and the next deploy's reseed wipes a stale one — so the contour can't stick in maintenance.
The caddy-routed probes run in the next step, after the flag is lowered.

Prod was already correct (prod-deploy.sh); this only makes the test contour faithful so the
overlay + reload can be seen there. An open SPA must already be on the PR-#171 bundle (which
carries the overlay code) — reload once to bootstrap onto it.
2026-07-03 23:04:19 +02:00
developer 01a9249002 Merge pull request 'feat(ui): in-session maintenance overlay on the edge 503 marker' (#171) from feature/maintenance-overlay into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m6s
2026-07-03 20:52:05 +00:00
Ilia Denisov 7dcd62fdd7 feat(ui): reload the SPA on maintenance recovery
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
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
Ilia Denisov d67e582c03 feat(ui): in-session maintenance overlay on the edge 503 marker
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
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
developer 75fe07865a Merge pull request 'feat(deploy): prod OOM swap cushion + edge maintenance page' (#170) from feature/prod-hardening into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m43s
2026-07-03 20:28:29 +00:00
Ilia Denisov 597e200f37 Merge remote-tracking branch 'origin/development' into feature/prod-hardening
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m40s
2026-07-03 22:17:05 +02:00
Ilia Denisov c8601c0115 feat(deploy): prod OOM swap cushion + edge maintenance page
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Two prod-deploy hardening measures (owner-requested):

- Swap file (Ansible common role, swap_size=1G, vm.swappiness=10). The per-container
  memory caps enforce that one service can't eat all RAM, but they overcommit the
  1.9 GiB main host (~2.8 GiB of caps), so a simultaneous spike could hit the kernel
  OOM-killer (and it might pick postgres). A small swap absorbs the overshoot.
  Idempotent, builtin-only (no ansible.posix).

- Edge maintenance page. prod-deploy.sh raises a flag around the rolling swap /
  migration window that the caddy edge serves a static 503 "технические работы" page
  from (deploy/caddy/maintenance.html), for the user-facing routes only — /_gm
  (Grafana) stays reachable. Cleared on any exit (success, health failure + rollback,
  or error) by a shell trap so it can never stick on. The 503 carries Retry-After +
  an X-Scrabble-Maintenance marker so a follow-up SPA overlay can tell a planned
  window apart from a transient error (the static page only catches a fresh load; an
  in-session user needs the app-side overlay). Not zero-downtime — the single
  stateful backend still blips — but the window is graceful instead of raw 502s.

Verified: caddy validate; the gate 503s every non-/_gm path incl. the Connect/gRPC
edge and serves the page + markers; /_gm bypasses; toggling needs no reload
(per-request stat). Ansible --syntax-check + compose config (base+prod) pass.
2026-07-03 22:09:37 +02:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
31 changed files with 1117 additions and 12 deletions
+21 -3
View File
@@ -399,6 +399,13 @@ jobs:
mkdir -p "$conf" mkdir -p "$conf"
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/ cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
export SCRABBLE_CONFIG_DIR="$conf" export SCRABBLE_CONFIG_DIR="$conf"
# Maintenance page for the redeploy window, mirroring prod-deploy.sh so the SPA
# overlay is exercised on the test contour too (not only prod). Raised just before
# the recreate and lowered once caddy is back (below); the trap clears it if the
# step fails so the contour never sticks in maintenance (and the reseed above wipes a
# stale flag anyway). The caddy-routed probes run in the NEXT step, after it is lowered.
maint_flag="$conf/caddy/on"
trap 'rm -f "$maint_flag"' EXIT
# Derive the public URLs from the one canonical origin instead of storing each as # Derive the public URLs from the one canonical origin instead of storing each as
# its own variable (paths are structural SPA routes / the Caddy /_gm sub-path). # its own variable (paths are structural SPA routes / the Caddy /_gm sub-path).
# Exported before build so the VK ID redirect is baked into the SPA. # Exported before build so the VK ID redirect is baked into the SPA.
@@ -430,12 +437,23 @@ jobs:
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod # bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
# main host omits both. Without the profile they would not start here. # main host omits both. Without the profile they would not start here.
docker compose --ansi never --profile telegram-local build --progress plain docker compose --ansi never --profile telegram-local build --progress plain
# Raise the maintenance page, THEN bring caddy onto the reseeded config mount so it
# actually carries the flag: the running caddy sits on the stale pre-reseed mount (the
# dir was rm'd + recreated — see the force-recreate note below), so a flag written to
# the new dir is invisible until caddy is recreated. With the fresh caddy up, an open
# SPA sees the 503 marker + overlay for the whole recreate window, not a bare reconnect.
: > "$maint_flag"
docker compose --ansi never up -d --force-recreate --no-deps caddy
docker compose --ansi never --profile telegram-local up -d --remove-orphans docker compose --ansi never --profile telegram-local up -d --remove-orphans
# The config-only services bind-mount the reseeded config dir. A plain `up -d` # The config-only services bind-mount the reseeded config dir. A plain `up -d`
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a # leaves them on the previous bind mount (the dir was rm'd + recreated), so a
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to # changed Grafana dashboard is ignored — force-recreate them to pick up the fresh
# pick up the fresh config. # config. (Caddy was already recreated above so it would carry the maintenance flag.)
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana docker compose --ansi never up -d --force-recreate --no-deps otelcol prometheus tempo grafana
# Lower the maintenance page: services are back. An open SPA's poll now gets through
# (once the gateway finishes booting) and reloads into the fresh client; the caddy
# probes in the next step see 200. The EXIT trap is a backstop if we failed earlier.
rm -f "$maint_flag"
- name: Probe the landing, gateway and backend - name: Probe the landing, gateway and backend
run: | run: |
+6
View File
@@ -46,6 +46,12 @@ jobs:
# VITE_GATEWAY_URL omitted: the SPA is served same-origin (compose ":-" default). # VITE_GATEWAY_URL omitted: the SPA is served same-origin (compose ":-" default).
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }} POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
# `docker compose build` interpolates the WHOLE compose file, so every :?-guarded
# runtime var must be present at build even though it is not a build-arg — incl. the
# backend's EXPORT_SIGN_KEY (added with the finished-game export after v1.7.0, which is
# why the first v1.8.0 build tripped on it). POSTGRES_PASSWORD/GM_BASICAUTH_HASH above
# are here for the same reason; DICT_VERSION + the derived Mini App URL cover the rest.
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# PUBLIC_BASE_URL drives the derived VK ID redirect (baked into the SPA) and the # PUBLIC_BASE_URL drives the derived VK ID redirect (baked into the SPA) and the
# Mini App URL; both are computed in the build step, not stored variables. # Mini App URL; both are computed in the build step, not stored variables.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }} PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
+15 -1
View File
@@ -190,6 +190,16 @@ public site. After `master` is green this workflow is the **only** thing that to
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main → prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
deploy-bot → verify** (the per-service rolling shows in the deploy-main log). deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
**Maintenance page.** During the roll (and any migration window) `prod-deploy.sh` raises a
flag the edge caddy serves a static 503 "технические работы" page from
(`deploy/caddy/maintenance.html`), for the user-facing routes only — `/_gm` (Grafana) stays
reachable. The flag is cleared on any exit (success, a health failure + rollback, or an
error) by a shell trap, so it can never stick on. It arms from this feature's own deploy
onward (the caddy carrying the gate must be live first). This is **not** a zero-downtime
deploy — the backend is a single stateful instance (in-memory game state + push hub, no
Redis), so its swap still blips (clients auto-reconnect the live stream); the page just
makes the window graceful instead of raw 502s.
**Versioning.** Each release is a git tag `vX.Y.Z` on `master`; the deploy stamps **Versioning.** Each release is a git tag `vX.Y.Z` on `master`; the deploy stamps
`git describe --tags` into every image tag, every binary (`-ldflags``pkg/version` `git describe --tags` into every image tag, every binary (`-ldflags``pkg/version`
the `service.version` telemetry attribute) and the SPA About screen. Tag the release the `service.version` telemetry attribute) and the SPA About screen. Tag the release
@@ -222,7 +232,11 @@ together with the fresh CA.
**Sizing / monitoring:** the main host launches undersized (2 vCPU / 1.9 GiB); the prod **Sizing / monitoring:** the main host launches undersized (2 vCPU / 1.9 GiB); the prod
overlay trims limits + `GOMAXPROCS=2` + 7d Prometheus retention, and `node_exporter` feeds overlay trims limits + `GOMAXPROCS=2` + 7d Prometheus retention, and `node_exporter` feeds
host memory to Grafana (`/_gm/grafana/`). Watch host memory and resize at Selectel when host memory to Grafana (`/_gm/grafana/`). Watch host memory and resize at Selectel when
players arrive. players arrive. The per-container memory caps are enforced (Compose v2 → cgroup), so no one
service can eat all RAM, but they **overcommit** the host (~2.8 GiB of caps vs 1.9 GiB) — a
**1 GiB swap file** (Ansible `common` role, `swap_size`, `vm.swappiness=10`) cushions a
simultaneous spike into swap instead of the kernel OOM-killer. The `host_mem_low` Grafana
alert fires under 10% available.
**Shared Gitea set** (one unprefixed entry each, used by every contour) — secrets: **Shared Gitea set** (one unprefixed entry each, used by every contour) — secrets:
`GATEWAY_VK_APP_SECRET, GATEWAY_VK_ID_CLIENT_SECRET, SMTP_RELAY_USER, SMTP_RELAY_PASS`; `GATEWAY_VK_APP_SECRET, GATEWAY_VK_ID_CLIENT_SECRET, SMTP_RELAY_USER, SMTP_RELAY_PASS`;
+8
View File
@@ -19,3 +19,11 @@ scrabble_base_dir: /opt/scrabble
# the host's own containers (and any ad-hoc runs) rotate identically. # the host's own containers (and any ad-hoc runs) rotate identically.
docker_log_max_size: "10m" docker_log_max_size: "10m"
docker_log_max_file: "3" docker_log_max_file: "3"
# Swap file as an OOM cushion. The per-container memory caps (docker-compose.prod.yml)
# sum to more than the tight main host's RAM (2 vCPU / 1.9 GiB), so a simultaneous
# spike could otherwise hit the kernel OOM-killer and it might pick postgres. A small
# swap absorbs the overshoot; swappiness stays low so swap is a cushion, not a hot
# path (a slow service beats a killed one). Set swap_size to "0" to skip provisioning.
swap_size: "1G"
swap_swappiness: 10
@@ -150,6 +150,57 @@
enabled: true enabled: true
state: started state: started
# --- Swap file (OOM cushion) ---------------------------------------------------
# The prod main host is tight (1.9 GiB) and the per-container memory caps
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
# the kernel OOM-killer (which might pick postgres). A small swap absorbs the
# overshoot. Idempotent; skipped when swap_size == "0". Builtin-only (no ansible.posix).
- name: Check whether the swap file is already active
ansible.builtin.command: swapon --show=NAME --noheadings
register: swap_active
changed_when: false
when: swap_size != "0"
- name: Allocate the swap file
ansible.builtin.command:
cmd: "fallocate -l {{ swap_size }} /swapfile"
creates: /swapfile
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
- name: Secure the swap file (0600)
ansible.builtin.file:
path: /swapfile
owner: root
group: root
mode: "0600"
when: swap_size != "0"
- name: Format and enable the swap file
ansible.builtin.shell: "mkswap /swapfile && swapon /swapfile"
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
- name: Persist the swap file in /etc/fstab
ansible.builtin.lineinfile:
path: /etc/fstab
line: "/swapfile none swap sw 0 0"
regexp: '^/swapfile\s'
state: present
when: swap_size != "0"
- name: Keep swap a cushion, not a hot path (low swappiness)
ansible.builtin.copy:
dest: /etc/sysctl.d/60-scrabble-swap.conf
mode: "0644"
content: "vm.swappiness = {{ swap_swappiness }}\n"
register: swappiness_conf
when: swap_size != "0"
- name: Apply swappiness now
ansible.builtin.command: sysctl --system
changed_when: false
when: swap_size != "0" and swappiness_conf is changed
# --- Deploy directories -------------------------------------------------------- # --- Deploy directories --------------------------------------------------------
- name: Create the scrabble base directories - name: Create the scrabble base directories
+34 -1
View File
@@ -33,6 +33,39 @@
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md. # is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
header Alt-Svc clear header Alt-Svc clear
# Maintenance gate. While the deploy holds the flag file /srv/maint/on (touched by
# prod-deploy.sh around a rolling swap, removed on the script's exit), serve a static
# 503 "works in progress" page instead of proxying to a mid-recreate upstream —
# a graceful signal rather than raw 502s. Operator surfaces (/_gm) are excluded so
# Grafana stays reachable during the window. Caddy stat()s the flag per request, so
# toggling it needs no reload; the page + flag live in the caddy config dir bind-mounted
# read-only at /srv/maint (docker-compose.yml). The test contour never sets the flag
# (only prod-deploy.sh does), so this is inert there.
@maintenance {
not path /_gm /_gm/*
file {
root /srv/maint
try_files on
}
}
handle @maintenance {
error 503
}
handle_errors {
@maint503 expression {err.status_code} == 503
handle @maint503 {
root * /srv/maint
rewrite * /maintenance.html
header Retry-After 120
# Distinctive maintenance marker so the SPA can tell a planned window apart from
# a transient upstream 503 and show its own dimmed overlay (an in-session user
# never sees this static page — it only renders on a fresh load). The header
# rides every gated response, incl. the Connect/gRPC edge the app polls.
header X-Scrabble-Maintenance 1
file_server
}
}
# Operator surfaces under /_gm: a single shared Basic-Auth, then route. # Operator surfaces under /_gm: a single shared Basic-Auth, then route.
@gm path /_gm /_gm/* @gm path /_gm /_gm/*
handle @gm { handle @gm {
@@ -53,7 +86,7 @@
# The game SPA and the Connect edge are served by the gateway. Strip any # The game SPA and the Connect edge are served by the gateway. Strip any
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
# tag the honeypot block sets below (a client cannot self-tag a real request). # tag the honeypot block sets below (a client cannot self-tag a real request).
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /scrabble.edge.v1.Gateway/* @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
handle @gateway { handle @gateway {
reverse_proxy gateway:8081 { reverse_proxy gateway:8081 {
header_up -X-Scrabble-Honeypot header_up -X-Scrabble-Honeypot
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<!--
Served with 503 by the edge caddy while the deploy holds the maintenance flag
(/srv/maint/on, toggled by deploy/prod-deploy.sh around a rolling swap). Static and
self-contained (no upstream, no external assets) so it renders while the backend /
gateway are mid-recreate.
-->
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Эрудит — технические работы</title>
<style>
:root { color-scheme: light dark; }
html, body { height: 100%; margin: 0; }
body {
display: flex; align-items: center; justify-content: center;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #f4f1ea; color: #2b2b2b;
padding: 24px; box-sizing: border-box;
}
@media (prefers-color-scheme: dark) { body { background: #1d1b17; color: #e8e4da; } }
.card { max-width: 30rem; text-align: center; line-height: 1.5; }
.tile {
display: inline-block; width: 3.5rem; height: 3.5rem; line-height: 3.5rem;
font-size: 2rem; font-weight: 700; border-radius: 10px;
background: #d9b451; color: #2b2b2b; box-shadow: 0 2px 4px rgba(0,0,0,.25);
margin-bottom: 1.25rem;
}
h1 { font-size: 1.4rem; margin: 0 0 .5rem; }
p { margin: .35rem 0; }
.en { opacity: .7; font-size: .95rem; }
</style>
</head>
<body>
<main class="card">
<div class="tile">Э</div>
<h1>Технические работы</h1>
<p>Идёт короткое обновление игры. Мы скоро вернёмся — обновите страницу через пару минут.</p>
<p class="en">Scrabble is briefly down for an update. Please refresh in a couple of minutes.</p>
</main>
</body>
</html>
+5
View File
@@ -428,6 +428,11 @@ services:
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH} GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
volumes: volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
# Maintenance page + toggle flag: the caddy config dir holds maintenance.html and the
# `on` flag prod-deploy.sh touches around a rolling swap; the Caddyfile serves a 503
# from here while the flag exists (read-only mount — the deploy writes the flag on the
# host side). See deploy/caddy/Caddyfile and deploy/prod-deploy.sh.
- ${SCRABBLE_CONFIG_DIR:-.}/caddy:/srv/maint:ro
- caddy-data:/data - caddy-data:/data
deploy: deploy:
resources: resources:
+16
View File
@@ -56,6 +56,22 @@
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 }, "gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }] "targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
},
{
"type": "timeseries",
"title": "Unsupported-engine screens (cumulative by reason)",
"description": "Clients turned away by the index.html boot guard because the engine cannot run the app (an old Android System WebView), by reason: no_bigint / no_proxy (a missing unpolyfillable primitive) or boot_error (an uncaught startup failure). Deduped per device/version, so this counts distinct blocked installs, not launches. The effective floor is Chrome 67 (BigInt).",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(unsupported_engine_total) by (reason)", "legendFormat": "{{reason}}" }]
},
{
"type": "timeseries",
"title": "Unsupported engines by Chromium (rate)",
"description": "The same blocked clients by reported Chromium major version — which old in-app WebViews are still in the wild. \"other\" is an unparseable or out-of-range version.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 32 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(unsupported_engine_total[1h])) by (chromium)", "legendFormat": "Chromium {{chromium}}" }]
} }
] ]
} }
+16
View File
@@ -42,6 +42,15 @@ PREV_STATE_FILE="${PREV_STATE_FILE:-/opt/scrabble/PREVIOUS_TAG}"
PG_USER="${POSTGRES_USER:-scrabble}" PG_USER="${POSTGRES_USER:-scrabble}"
PG_DB="${POSTGRES_DB:-scrabble}" PG_DB="${POSTGRES_DB:-scrabble}"
# Edge maintenance page. caddy serves a static 503 "works in progress" page for the
# user-facing routes while this flag file exists (deploy/caddy/Caddyfile). We hold it
# across the roll / migration window and clear it on ANY exit — success, a health
# failure + rollback, or an unexpected error — via the trap, so users get a graceful
# page instead of raw mid-swap 502s and the flag can never get stuck on.
MAINT_FLAG="${MAINT_FLAG:-${SCRABBLE_CONFIG_DIR:-/opt/scrabble}/caddy/on}"
maint_off() { rm -f "$MAINT_FLAG" 2>/dev/null || true; }
trap maint_off EXIT
cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; } cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; }
export REGISTRY export REGISTRY
# otelcol joins the host docker group to read the socket; the GID varies per host. # otelcol joins the host docker group to read the socket; the GID varies per host.
@@ -119,6 +128,13 @@ if [ -z "$(docker ps -aq -f name=scrabble-backend)" ]; then
exit 0 exit 0
fi fi
# Existing stack: raise the maintenance page for the whole roll (and any migration
# window). It shows once caddy carries the gate (from this feature's own deploy
# onward); the EXIT trap lowers it however this run ends.
mkdir -p "$(dirname "$MAINT_FLAG")"
: > "$MAINT_FLAG"
echo "maintenance page raised ($MAINT_FLAG)"
# Migration deploy: freeze writes and snapshot a consistent dump before migrating. # Migration deploy: freeze writes and snapshot a consistent dump before migrating.
if [ "$MIGRATION" = 1 ]; then if [ "$MIGRATION" = 1 ]; then
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)" echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
+11
View File
@@ -1070,6 +1070,17 @@ browser), so an open in-app session reflects it at once.
it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by
design (a dropped beacon just retries its batch on the next flush); it is telemetry, never design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
a game input. a game input.
- **Unsupported-engine screens (client-reported):** the ES5 boot guard in `index.html` (§13)
shows a full-screen "your device's OS or browser can't run the app" screen — instead of a white
screen — when the engine lacks an unpolyfillable essential (`BigInt`, the 64-bit FlatBuffers
decode; or `Proxy`, Svelte 5 runes) or an uncaught error aborts boot; the effective floor is
Chrome 67. It then fires one fire-and-forget beacon (`POST /telemetry/unsupported`
unauthenticated, since the client never booted, but per-IP public-limited and body-capped),
deduped in `localStorage` by app version + reason + Chromium so a user reopening the app is one
report. The gateway folds it into `unsupported_engine_total` (`reason` =
no_bigint/no_proxy/boot_error/other; `chromium` = the major version reduced to a bounded range so
a spoofed beacon cannot inflate cardinality) and logs the full user agent (not a label). Surfaced
on the **Scrabble — Users** dashboard beside app opens.
- **Rate-limit observability:** every limiter rejection increments the gateway - **Rate-limit observability:** every limiter rejection increments the gateway
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
only, honouring the no-per-user-label discipline above) and logs one **Debug** line; only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
+14
View File
@@ -32,6 +32,8 @@ type serverMetrics struct {
localColdStart metric.Int64Counter localColdStart metric.Int64Counter
localDictLoad metric.Int64Counter localDictLoad metric.Int64Counter
localPreview metric.Int64Counter localPreview metric.Int64Counter
// Clients turned away by the unsupported-engine boot screen (see unsupportedEngineHandler).
unsupportedEngine metric.Int64Counter
} }
// newServerMetrics builds the instruments on meter (nil selects a no-op meter), // newServerMetrics builds the instruments on meter (nil selects a no-op meter),
@@ -63,6 +65,8 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."), localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."), localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."), localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
unsupportedEngine: counterOf(meter, "unsupported_engine_total",
"Clients that hit the unsupported-engine boot screen (the app cannot run), by reason (no_bigint, no_proxy, boot_error, other) and Chromium major — a deduped beacon from the index.html boot guard; the full user agent is logged, not labelled."),
} }
gauge, err := meter.Int64ObservableGauge("active_users", gauge, err := meter.Int64ObservableGauge("active_users",
@@ -108,6 +112,16 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason))) m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
} }
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
func (m *serverMetrics) recordUnsupportedEngine(ctx context.Context, reason, chromium string) {
m.unsupportedEngine.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
attribute.String("chromium", chromium),
))
}
// localEvalReport is the client-reported local move-preview telemetry batch — deltas since // localEvalReport is the client-reported local move-preview telemetry batch — deltas since
// the client's previous report. It backs the adoption dashboard: app cold starts vs cached // the client's previous report. It backs the adoption dashboard: app cold starts vs cached
// dictionaries vs on-device previews. // dictionaries vs on-device previews.
@@ -128,3 +128,70 @@ func TestBannedMetric(t *testing.T) {
t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts) t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts)
} }
} }
// TestUnsupportedEngineMetric records unsupported-engine beacons through a manual reader and asserts
// unsupported_engine_total splits by reason and Chromium major.
func TestUnsupportedEngineMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
m.recordUnsupportedEngine(ctx, "boot_error", "74")
var rm metricdata.ResourceMetrics
if err := reader.Collect(ctx, &rm); err != nil {
t.Fatalf("collect: %v", err)
}
type key struct{ reason, chromium string }
counts := map[key]int64{}
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != "unsupported_engine_total" {
continue
}
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("unsupported_engine_total is not an int64 sum")
}
for _, dp := range sum.DataPoints {
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
counts[key{reason.AsString(), chromium.AsString()}] += dp.Value
}
}
}
if got := counts[key{"no_bigint", "66"}]; got != 2 {
t.Errorf("unsupported no_bigint/66 = %d, want 2", got)
}
if got := counts[key{"boot_error", "74"}]; got != 1 {
t.Errorf("unsupported boot_error/74 = %d, want 1", got)
}
}
// TestNormalizeUnsupported checks that a beacon's reason and Chromium are reduced to bounded label
// values, so a spoofed beacon cannot explode the metric cardinality.
func TestNormalizeUnsupported(t *testing.T) {
cases := []struct {
reason, chromium string
wantReason, wantChromium string
}{
{"no_bigint", "66", "no_bigint", "66"},
{"no_proxy", " 74 ", "no_proxy", "74"},
{"boot_error", "105", "boot_error", "105"},
{"garbage", "66", "other", "66"},
{"", "", "other", "other"},
{"no_bigint", "not-a-number", "no_bigint", "other"},
{"no_bigint", "9999", "no_bigint", "other"},
{"no_bigint", "0", "no_bigint", "other"},
{"no_bigint", "-5", "no_bigint", "other"},
}
for _, c := range cases {
gotR, gotC := normalizeUnsupported(c.reason, c.chromium)
if gotR != c.wantReason || gotC != c.wantChromium {
t.Errorf("normalizeUnsupported(%q,%q) = %q,%q; want %q,%q", c.reason, c.chromium, gotR, gotC, c.wantReason, c.wantChromium)
}
}
}
+74
View File
@@ -15,6 +15,7 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -197,6 +198,9 @@ func (s *Server) HTTPHandler() http.Handler {
mux.Handle("/dl/", s.exportDownloadHandler()) mux.Handle("/dl/", s.exportDownloadHandler())
// The client posts its local-move-preview adoption telemetry here (session-gated). // The client posts its local-move-preview adoption telemetry here (session-gated).
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
// screen (the app cannot run). Unauthenticated — the client never booted — but rate-limited.
mux.Handle("/telemetry/unsupported", s.unsupportedEngineHandler())
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini // The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md // App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps // §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
@@ -582,6 +586,76 @@ func clampReport(r *localEvalReport) {
clamp(&r.PreviewNetwork) clamp(&r.PreviewNetwork)
} }
// unsupportedEngineBeacon is the small fire-and-forget report the index.html boot guard sends when
// it turns a client away on the unsupported-engine screen (no BigInt/Proxy, or an uncaught boot
// error). It is deduped client-side (one per device / app version / reason).
type unsupportedEngineBeacon struct {
Reason string `json:"reason"`
Chromium string `json:"chromium"`
Version string `json:"version"`
UA string `json:"ua"`
}
// unsupportedEngineHandler folds one unsupported-engine beacon into the edge counter. It is
// unauthenticated (the client never booted, so it carries no session) but per-IP rate-limited with
// the public limiter and body-capped. reason and the Chromium major are reduced to bounded label
// sets (normalizeUnsupported) so a spoofed beacon cannot inflate the metric cardinality; the full
// user agent is logged, not labelled. Only POST; the reply is always 204.
func (s *Server) unsupportedEngineHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "unsupported")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
var b unsupportedEngineBeacon
if err := json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&b); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
reason, chromium := normalizeUnsupported(b.Reason, b.Chromium)
s.metrics.recordUnsupportedEngine(r.Context(), reason, chromium)
s.log.Info("unsupported engine",
zap.String("reason", reason),
zap.String("chromium", chromium),
zap.String("app_version", truncate(b.Version, 40)),
zap.String("user_agent", truncate(b.UA, 400)),
)
w.WriteHeader(http.StatusNoContent)
})
}
// normalizeUnsupported reduces a beacon's reason and Chromium fields to bounded label values, so a
// spoofed beacon cannot explode the metric cardinality: reason is allow-listed, and Chromium is
// parsed as a major version kept only within a plausible range, otherwise "other".
func normalizeUnsupported(reason, chromium string) (string, string) {
switch reason {
case "no_bigint", "no_proxy", "boot_error":
// a recognised reason — keep as-is
default:
reason = "other"
}
major := "other"
if n, err := strconv.Atoi(strings.TrimSpace(chromium)); err == nil && n >= 1 && n <= 199 {
major = strconv.Itoa(n)
}
return reason, major
}
// truncate bounds a logged, client-supplied string to n bytes (a spoofed beacon field is not
// trusted to be small).
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// resolve extracts and resolves the Authorization bearer token to an account id // resolve extracts and resolves the Authorization bearer token to an account id
// and its guest flag, returning a Connect Unauthenticated error when it is missing // and its guest flag, returning a Connect Unauthenticated error when it is missing
// or unknown. // or unknown.
@@ -0,0 +1,81 @@
package connectsrv_test
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/connectsrv"
"scrabble/gateway/internal/ratelimit"
)
// TestUnsupportedEngineHandler drives the /telemetry/unsupported beacon route end to end: a POST
// increments unsupported_engine_total with the normalised labels and replies 204; a GET is 405.
func TestUnsupportedEngineHandler(t *testing.T) {
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
edge := connectsrv.NewServer(connectsrv.Deps{
Limiter: ratelimit.New(),
RateLimit: config.DefaultRateLimit(),
Meter: meter,
})
srv := httptest.NewServer(edge.HTTPHandler())
defer srv.Close()
url := srv.URL + "/telemetry/unsupported"
// A GET is rejected.
getResp, err := http.Get(url)
if err != nil {
t.Fatalf("get: %v", err)
}
getResp.Body.Close()
if getResp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET status = %d, want 405", getResp.StatusCode)
}
// A POST is accepted (204) and folded into the counter with normalised labels.
body := `{"reason":"no_bigint","chromium":"66","version":"v1.2.3","ua":"Mozilla/5.0 ... Chrome/66"}`
resp, err := http.Post(url, "application/json", bytes.NewBufferString(body))
if err != nil {
t.Fatalf("post: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("POST status = %d, want 204", resp.StatusCode)
}
var rm metricdata.ResourceMetrics
if err := reader.Collect(context.Background(), &rm); err != nil {
t.Fatalf("collect: %v", err)
}
var total int64
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != "unsupported_engine_total" {
continue
}
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("unsupported_engine_total is not an int64 sum")
}
for _, dp := range sum.DataPoints {
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
if reason.AsString() != "no_bigint" || chromium.AsString() != "66" {
t.Errorf("labels = %s/%s, want no_bigint/66", reason.AsString(), chromium.AsString())
}
total += dp.Value
}
}
}
if total != 1 {
t.Errorf("unsupported_engine_total = %d, want 1", total)
}
}
+38
View File
@@ -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();
});
+234
View File
@@ -2,6 +2,240 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- Boot capability guard. Runs before the deferred ES module, in plain ES5 so it survives even
on an engine too old to parse the bundle. Three jobs, as early as possible:
1. Hard gate — if an UNPOLYFILLABLE essential is missing (BigInt for the 64-bit
FlatBuffers wire decode, Proxy for Svelte 5 runes) the app cannot run at all: show the
unsupported-engine screen (an old Android System WebView, e.g. Chromium 66, is the case
this guards) instead of a white screen.
2. Soft gate — if only polyfillable es2020+ globals are missing (globalThis,
structuredClone, Array.at, …) pull core-js (emitted as polyfills.js) BEFORE the module
runs. document.write is deliberate: the only way to inject a parser-blocking <script>
guaranteed to run ahead of a deferred module; its argument is a static literal, so no
injection surface.
3. Reactive net — record any uncaught error/rejection during boot; if the app has not
signalled window.__booted within a grace period AND an error fired, show the same
screen with the captured cause (covers a bundle that fails to parse, or an unforeseen
incompatibility). __booted is set in App.svelte once bootstrap resolves.
The screen has a "Diagnostic information" view (engine + feature table + reason + version)
with a Copy button. On a capable engine nothing here renders, so neither the bundle-size
budget nor the mock e2e is affected. -->
<script>
(function () {
'use strict';
var nav = navigator;
var ua = nav.userAgent || '';
var VERSION = '__BOOT_VERSION__'; // replaced at build (vite.config injectBootVersion)
var RU = (nav.language || '').toLowerCase().indexOf('ru') === 0;
var MINIAPP = /\/(?:telegram|vk)\//.test(location.pathname);
var WEBURL = location.protocol + '//' + location.host + '/app/';
// [label, test, Chrome version it landed in, hard?] — hard = required and unpolyfillable.
function has(fn) {
try {
return !!fn();
} catch (e) {
return false;
}
}
var probes = [
['BigInt', function () { return typeof BigInt !== 'undefined'; }, 67, true],
['Proxy', function () { return typeof Proxy !== 'undefined'; }, 49, true],
['globalThis', function () { return typeof globalThis !== 'undefined'; }, 71, false],
['structuredClone', function () { return typeof structuredClone === 'function'; }, 98, false],
['Array.prototype.at', function () { return typeof [].at === 'function'; }, 92, false],
['Array.prototype.findLast', function () { return typeof [].findLast === 'function'; }, 97, false],
['Object.hasOwn', function () { return typeof Object.hasOwn === 'function'; }, 93, false],
['Object.fromEntries', function () { return typeof Object.fromEntries === 'function'; }, 73, false],
['Promise.allSettled', function () { return !!Promise.allSettled; }, 76, false],
['Promise.any', function () { return !!Promise.any; }, 85, false],
['WeakRef', function () { return typeof WeakRef !== 'undefined'; }, 84, false],
['queueMicrotask', function () { return typeof queueMicrotask === 'function'; }, 71, false]
];
var results = [];
var hardMissing = [];
var softMissing = false;
for (var i = 0; i < probes.length; i++) {
var ok = has(probes[i][1]);
results.push({ name: probes[i][0], ok: ok, chrome: probes[i][2], hard: probes[i][3] });
if (!ok) {
if (probes[i][3]) hardMissing.push(probes[i][0]);
else softMissing = true;
}
}
// The diagnostic report (built on demand, behind the button).
function diag(reason) {
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
var wv = /;\s*wv\)/.test(ua) || /\bwv\b/.test(ua);
var out = [];
out.push('reason : ' + reason);
out.push('app : ' + VERSION);
out.push('chromium : ' + (cm ? cm[1] : 'n/a') + (wv ? ' (Android WebView)' : ''));
out.push('userAgent : ' + ua);
out.push('url : ' + location.href);
out.push('viewport : ' + window.innerWidth + 'x' + window.innerHeight + ' @' + (window.devicePixelRatio || 1));
out.push('lang : ' + (nav.language || '?') + ' online: ' + nav.onLine);
out.push('');
out.push('features:');
for (var k = 0; k < results.length; k++) {
var r = results[k];
out.push(' ' + (r.ok ? 'OK' : 'NO') + ' ' + r.name + ' (' + (r.hard ? 'required' : 'polyfilled') + ', Chrome ' + r.chrome + ')');
}
return out.join('\n');
}
function el(tag, style, text) {
var e = document.createElement(tag);
if (style) e.setAttribute('style', style);
if (text != null) e.appendChild(document.createTextNode(text));
return e;
}
function btn(bg, fg) {
return 'display:inline-block;margin:0 10px 10px 0;padding:11px 18px;border:0;border-radius:8px;' +
'font:600 15px/1 inherit;cursor:pointer;color:' + fg + ';background:' + bg + ';';
}
var shown = false;
// Fire-and-forget beacon so the gateway can count who hits this screen (Grafana). Deduped
// in localStorage by app version + reason + Chromium, so a user reopening the app ten times
// is one report. sendBeacon (Chrome 39+, present on the engines that reach here) with a
// fetch fallback; both are best-effort and never block or throw.
function beacon(code) {
try {
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
var chromium = cm ? cm[1] : '';
var sig = VERSION + '|' + code + '|' + chromium;
try {
if (localStorage.getItem('scrabble_unsupp') === sig) return;
} catch (e) {}
var payload = JSON.stringify({ reason: code, chromium: chromium, version: VERSION, ua: ua });
var sent = false;
try {
if (nav.sendBeacon) sent = nav.sendBeacon('/telemetry/unsupported', new Blob([payload], { type: 'application/json' }));
} catch (e) {}
if (!sent && typeof fetch === 'function') {
try {
fetch('/telemetry/unsupported', { method: 'POST', body: payload, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(function () {});
sent = true;
} catch (e) {}
}
if (sent) {
try {
localStorage.setItem('scrabble_unsupp', sig);
} catch (e) {}
}
} catch (e) {}
}
function show(reason, code) {
if (shown) return;
shown = true;
beacon(code);
var root = el('div', 'position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;overflow:auto;' +
'-webkit-overflow-scrolling:touch;background:#0f1115;color:#e8eaed;box-sizing:border-box;padding:24px;' +
'font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;');
var wrap = el('div', 'max-width:520px;margin:0 auto;');
root.appendChild(wrap);
var msg = el('div', null);
msg.appendChild(el('div', 'font-size:22px;font-weight:700;margin-bottom:14px;', 'Эрудит'));
msg.appendChild(el('p', 'margin:0 0 14px;', RU
? 'На вашей версии операционной системы или браузера приложение не сможет работать.'
: "This app can't run on your device's operating system or browser version."));
if (MINIAPP) {
msg.appendChild(el('p', 'margin:0 0 6px;', RU
? 'Откройте веб-версию в обычном браузере (Chrome, Firefox):'
: 'Open the web version in a regular browser (Chrome, Firefox):'));
var a = el('a', 'color:#8ab4ff;word-break:break-all;', WEBURL);
a.setAttribute('href', WEBURL);
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener');
var lp = el('p', 'margin:0 0 18px;');
lp.appendChild(a);
msg.appendChild(lp);
}
var infoBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Диагностическая информация' : 'Diagnostic information');
msg.appendChild(infoBtn);
wrap.appendChild(msg);
var dv = el('div', 'display:none;');
var pre = el('pre', 'white-space:pre-wrap;word-break:break-word;background:#0b0d11;border-radius:8px;padding:12px;' +
'font:12.5px/1.45 ui-monospace,Menlo,Consolas,monospace;overflow:auto;', diag(reason));
dv.appendChild(pre);
var copyBtn = el('button', btn('#8ab4ff', '#0b0d11'), RU ? 'Копировать' : 'Copy');
var backBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Назад' : 'Back');
var row = el('div', 'margin-top:12px;');
row.appendChild(copyBtn);
row.appendChild(backBtn);
dv.appendChild(row);
wrap.appendChild(dv);
infoBtn.onclick = function () {
msg.style.display = 'none';
dv.style.display = 'block';
};
backBtn.onclick = function () {
dv.style.display = 'none';
msg.style.display = 'block';
};
copyBtn.onclick = function () {
var txt = diag(reason);
try {
if (nav.clipboard && nav.clipboard.writeText) {
nav.clipboard.writeText(txt);
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
return;
}
} catch (e) {}
try {
var ta = document.createElement('textarea');
ta.value = txt;
ta.setAttribute('style', 'position:fixed;left:-9999px;top:0;');
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
} catch (e2) {
copyBtn.firstChild.nodeValue = RU ? 'Выделите и скопируйте текст' : 'Select and copy the text';
}
};
function mount() {
document.body.insertBefore(root, document.body.firstChild);
}
if (document.body) mount();
else document.addEventListener('DOMContentLoaded', mount);
}
// 1 + 2: the gate.
if (hardMissing.length) {
show((RU ? 'нет: ' : 'missing: ') + hardMissing.join(', '), hardMissing.indexOf('BigInt') >= 0 ? 'no_bigint' : 'no_proxy');
} else if (softMissing) {
document.write('<script src="polyfills.js"><\/script>');
}
// 3: the reactive net for an unforeseen boot failure.
var bootErr = null;
function note(m) {
if (!bootErr) bootErr = m;
}
window.onerror = function (m, s, l, c, e) {
note(String(m) + (e && e.stack ? '\n' + e.stack : s ? ' @ ' + s + ':' + l : ''));
return false;
};
window.addEventListener('error', function (e) {
if (e && e.message) note(e.message + (e.filename ? ' @ ' + e.filename + ':' + e.lineno : ''));
}, true);
window.addEventListener('unhandledrejection', function (e) {
var r = e && e.reason;
note(r && (r.stack || r.message) ? r.stack || r.message : String(r));
});
setTimeout(function () {
if (!window.__booted && bootErr && !shown) show((RU ? 'ошибка запуска: ' : 'boot error: ') + bootErr, 'boot_error');
}, 8000);
})();
</script>
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it <!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
reach this tag). --> reach this tag). -->
+1
View File
@@ -27,6 +27,7 @@
"@playwright/test": "^1.49.0", "@playwright/test": "^1.49.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"core-js-bundle": "^3.49.0",
"svelte": "^5.15.0", "svelte": "^5.15.0",
"svelte-check": "^4.1.0", "svelte-check": "^4.1.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
+8
View File
@@ -36,6 +36,9 @@ importers:
'@types/node': '@types/node':
specifier: ^22.10.0 specifier: ^22.10.0
version: 22.19.19 version: 22.19.19
core-js-bundle:
specifier: ^3.49.0
version: 3.49.0
svelte: svelte:
specifier: ^5.15.0 specifier: ^5.15.0
version: 5.56.0 version: 5.56.0
@@ -508,6 +511,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
core-js-bundle@3.49.0:
resolution: {integrity: sha512-WXc7oOsePN3aKFOJVG5zQdi+h/Jm2W0WIPYvRc4IG3vkNcbC2w6LlSzTmnhOl6N1xmOJEzCSNieX3mwF+3zBGw==}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -1132,6 +1138,8 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
core-js-bundle@3.49.0: {}
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
+4
View File
@@ -1,4 +1,8 @@
# pnpm 11 records build-script approval here. esbuild's postinstall materialises # pnpm 11 records build-script approval here. esbuild's postinstall materialises
# its CLI shim; the platform binary itself ships as an optional dependency. # its CLI shim; the platform binary itself ships as an optional dependency.
# core-js-bundle ships a prebuilt minified.js (we only read that file at build time,
# via vite.config emitPolyfills) and its install script is just a funding banner, so
# it is denied — nothing to build.
allowBuilds: allowBuilds:
core-js-bundle: false
esbuild: true esbuild: true
+8 -1
View File
@@ -9,6 +9,7 @@
import StaleInviteModal from './components/StaleInviteModal.svelte'; import StaleInviteModal from './components/StaleInviteModal.svelte';
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte'; import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
import Coachmark from './components/Coachmark.svelte'; import Coachmark from './components/Coachmark.svelte';
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
import DebugPanel from './components/DebugPanel.svelte'; import DebugPanel from './components/DebugPanel.svelte';
import Login from './screens/Login.svelte'; import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte'; import Lobby from './screens/Lobby.svelte';
@@ -25,7 +26,12 @@
import TelegramLaunchError from './screens/TelegramLaunchError.svelte'; import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
onMount(() => { onMount(() => {
void bootstrap(); // Tell the index.html boot guard that startup completed, so its reactive net does not raise the
// unsupported-engine screen on a healthy boot — it only fires when an uncaught error occurred
// AND this flag never sets (a bundle that failed to parse, or an unforeseen incompatibility).
void bootstrap().then(() => {
(window as unknown as { __booted?: boolean }).__booted = true;
});
}); });
// The lobby is the cold-start landing (an empty hash and Telegram launch params both parse // The lobby is the cold-start landing (an empty hash and Telegram launch params both parse
@@ -128,6 +134,7 @@
<StaleInviteModal /> <StaleInviteModal />
<WelcomeRedeemModal /> <WelcomeRedeemModal />
<Coachmark /> <Coachmark />
<MaintenanceOverlay />
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError} {#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
<Splash /> <Splash />
@@ -0,0 +1,83 @@
<script lang="ts">
// Non-dismissable maintenance cover. Shown while the edge is in a planned deploy window
// (maintenance.svelte.ts, raised from a caddy 503 carrying X-Scrabble-Maintenance). It has
// no close affordance — an in-session user cannot dismiss it — but the store's poll lifts
// it automatically the moment the gateway answers again; the "retry" button just forces an
// immediate re-check. Mirrors the static caddy maintenance page (deploy/caddy/maintenance.html)
// so a fresh load and an in-session user see the same thing.
import { maintenance, retryNow } from '../lib/maintenance.svelte';
import { t } from '../lib/i18n/index.svelte';
</script>
{#if maintenance.active}
<div class="scrim" role="alertdialog" aria-modal="true" aria-labelledby="maint-title" aria-describedby="maint-body">
<div class="card">
<div class="tile" aria-hidden="true">Э</div>
<h1 id="maint-title">{t('maintenance.title')}</h1>
<p id="maint-body">{t('maintenance.body')}</p>
<button type="button" onclick={retryNow}>{t('maintenance.retry')}</button>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
/* Above the game (z 60) and toasts (z 50) — a planned window covers everything the user
could otherwise interact with; below the dev DebugPanel (z 10000). */
z-index: 100;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
.card {
max-width: 22rem;
text-align: center;
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2rem 1.5rem;
}
/* Mirrors a placed board tile (as Splash.svelte / the caddy page do). */
.tile {
display: inline-grid;
place-items: center;
width: 3.5rem;
height: 3.5rem;
font-size: 2rem;
font-weight: 700;
border-radius: 4px;
background: var(--tile-bg);
color: var(--tile-text);
box-shadow:
inset 0 -2px 0 var(--tile-edge),
2px 0 3px -1px rgba(0, 0, 0, 0.4);
margin-bottom: 1.25rem;
}
h1 {
font-size: 1.25rem;
margin: 0 0 0.5rem;
}
p {
margin: 0 0 1.5rem;
color: var(--text-muted);
line-height: 1.5;
}
button {
font: inherit;
padding: 0.55rem 1.4rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: transparent;
color: var(--text);
cursor: pointer;
}
button:hover {
border-color: var(--accent);
color: var(--accent);
}
</style>
+11
View File
@@ -479,6 +479,11 @@
position: absolute; position: absolute;
top: 5%; top: 5%;
left: 8%; left: 8%;
/* Chrome < 105 has no container-query units: a dropped `cqw` would inherit the .cell
`font-size: 0` and the glyph would vanish. Fall back to a viewport-relative size that tracks
the board (≈ the viewport-fitted query container) and the zoom (--z), so old Android System
WebViews still draw the tile glyphs. Chrome 105+ takes the exact `cqw` line below. */
font-size: calc(4.2vmin * var(--z, 1));
font-size: 4.2cqw; font-size: 4.2cqw;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
@@ -487,12 +492,14 @@
position: absolute; position: absolute;
right: 5%; right: 5%;
bottom: 3%; bottom: 3%;
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.4cqw; font-size: 2.4cqw;
font-weight: 600; font-weight: 600;
} }
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits, /* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink centred on the same line as a neighbouring tile's value digit. */ its ink centred on the same line as a neighbouring tile's value digit. */
.blankmark { .blankmark {
font-size: calc(2.8vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.8cqw; font-size: 2.8cqw;
bottom: 0; bottom: 0;
} }
@@ -501,6 +508,7 @@
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: calc(3.6vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 3.6cqw; font-size: 3.6cqw;
opacity: 0.7; opacity: 0.7;
} }
@@ -509,6 +517,7 @@
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: calc(2.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.7cqw; font-size: 2.7cqw;
font-weight: 600; font-weight: 600;
opacity: 0.9; opacity: 0.9;
@@ -526,10 +535,12 @@
padding: 0 1px; padding: 0 1px;
} }
.bt { .bt {
font-size: calc(1.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 1.7cqw; font-size: 1.7cqw;
font-weight: 600; font-weight: 600;
} }
.bb { .bb {
font-size: calc(1.9vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 1.9cqw; font-size: 1.9cqw;
font-weight: 700; font-weight: 700;
white-space: nowrap; white-space: nowrap;
+10
View File
@@ -6,6 +6,7 @@ import type { GatewayClient } from './client';
import { MockGateway } from './mock/client'; import { MockGateway } from './mock/client';
import { createTransport } from './transport'; import { createTransport } from './transport';
import { reportOffline, reportOnline } from './connection.svelte'; import { reportOffline, reportOnline } from './connection.svelte';
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
const isMock = import.meta.env.MODE === 'mock'; const isMock = import.meta.env.MODE === 'mock';
@@ -20,6 +21,15 @@ if (isMock && typeof window !== 'undefined') {
offline: reportOffline, offline: reportOffline,
online: reportOnline, 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 // Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer). // attaches a robot on a timer).
( (
+3
View File
@@ -7,6 +7,9 @@ export const en = {
'app.brand': 'Erudit', 'app.brand': 'Erudit',
'dict.loading': 'Loading…', 'dict.loading': 'Loading…',
'connection.connecting': 'Connecting…', '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.title': 'Account blocked',
'blocked.permanent': 'Your account is blocked.', 'blocked.permanent': 'Your account is blocked.',
+3
View File
@@ -8,6 +8,9 @@ export const ru: Record<MessageKey, string> = {
'app.brand': 'Эрудит', 'app.brand': 'Эрудит',
'dict.loading': 'Загрузка…', 'dict.loading': 'Загрузка…',
'connection.connecting': 'Подключение…', 'connection.connecting': 'Подключение…',
'maintenance.title': 'Технические работы',
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
'maintenance.retry': 'Повторить',
'blocked.title': 'Учётная запись заблокирована', 'blocked.title': 'Учётная запись заблокирована',
'blocked.permanent': 'Ваша учётная запись заблокирована.', 'blocked.permanent': 'Ваша учётная запись заблокирована.',
+99
View File
@@ -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<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();
},
);
}
+44
View File
@@ -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<string, string>): 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 3s120s 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);
});
});
+37
View File
@@ -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'));
}
+24 -3
View File
@@ -12,6 +12,8 @@ import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec'; import * as codec from './codec';
import { browserOffset } from './profileValidation'; import { browserOffset } from './profileValidation';
import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; 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'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
const MAX_RETRIES = 6; 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 // 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). // (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
registerProbe(async () => { const reachabilityProbe = async (): Promise<void> => {
if (!token) throw new Error('no session'); if (!token) throw new Error('no session');
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() }); 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 // 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 // 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 }); res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal });
} catch (e) { } catch (e) {
if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry 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); const err = toGatewayError(e);
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) { if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
reportOffline(); reportOffline();
@@ -53,6 +67,9 @@ export function createTransport(baseUrl: string): GatewayClient {
throw err; throw err;
} }
reportOnline(); 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); if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
return res.payload; return res.payload;
} }
@@ -310,7 +327,11 @@ export function createTransport(baseUrl: string): GatewayClient {
if (pe) onEvent(pe); if (pe) onEvent(pe);
} }
} catch (e) { } 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(); return () => ctrl.abort();
+47 -3
View File
@@ -1,7 +1,42 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { defineConfig } from 'vite'; import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelte } from '@sveltejs/vite-plugin-svelte';
/**
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
* __BOOT_VERSION__ placeholder with the same VITE_APP_VERSION build-arg that feeds __APP_VERSION__.
* The guard runs before the bundle, so it cannot read the bundle's version; this lets its on-demand
* diagnostic report name the client version anyway. Falls back to "dev" for a local build.
*/
function injectBootVersion(): Plugin {
const version = process.env.VITE_APP_VERSION || 'dev';
return {
name: 'inject-boot-version',
transformIndexHtml(html) {
return html.replace(/__BOOT_VERSION__/g, version);
},
};
}
/**
* emitPolyfills writes the prebuilt core-js bundle to `dist/polyfills.js`. The index.html gate
* script loads it via document.write only on an old engine that lacks the es2020+ runtime APIs the
* app uses (globalThis, structuredClone, Array.at, …) — e.g. the Chromium 66 Android System WebView
* behind the Telegram/VK in-app browser — before the deferred module runs. A modern engine never
* requests it, so it adds nothing to that payload and stays out of the module graph the bundle-size
* gate measures.
*/
function emitPolyfills(): Plugin {
return {
name: 'emit-polyfills',
generateBundle() {
const src = readFileSync(resolve(import.meta.dirname, 'node_modules/core-js-bundle/minified.js'), 'utf8');
this.emitFile({ type: 'asset', fileName: 'polyfills.js', source: src });
},
};
}
// The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over // The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over
// h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can // h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can
// not speak h2c directly) talks to the dev server on the same origin. In `mock` // not speak h2c directly) talks to the dev server on the same origin. In `mock`
@@ -19,7 +54,9 @@ export default defineConfig(({ mode }) => ({
// so a missing build-arg never breaks the build. // so a missing build-arg never breaks the build.
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'), __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'),
}, },
plugins: [svelte()], // emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the
// index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic.
plugins: [svelte(), emitPolyfills(), injectBootVersion()],
server: { server: {
port: 5173, port: 5173,
proxy: proxy:
@@ -33,7 +70,14 @@ export default defineConfig(({ mode }) => ({
}, },
}, },
build: { build: {
target: 'es2022', // Down-level to es2019 so an old Android System WebView (seen: Telegram/VK in-app WebView on
// Chromium 66, Android 9) can parse the bundle — esbuild lowers es2020+ syntax (?., ??, private
// fields, static blocks, …) that Chrome 66 rejects with "Unexpected token ?", which left the SPA
// on a white screen. esbuild lowers syntax only, not runtime APIs — the es2020+ globals the
// bundle/deps call at runtime (globalThis, structuredClone, Array.at, …) are covered separately
// by the conditional core-js loader (emitPolyfills + the index.html gate), loaded only on an
// engine that actually lacks them.
target: 'es2019',
// Emit sourcemaps everywhere except the production build. A shipped `.map` // Emit sourcemaps everywhere except the production build. A shipped `.map`
// carries full `sourcesContent` — the entire TypeScript/Svelte source — and the // carries full `sourcesContent` — the entire TypeScript/Svelte source — and the
// gateway/landing images serve `dist/` verbatim, so production maps would expose // gateway/landing images serve `dist/` verbatim, so production maps would expose