Compare commits
27 Commits
75fe07865a
...
v1.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 45957bdcd6 | |||
| fcedadcb5b | |||
| 2feb638329 | |||
| 4dfedd02a3 | |||
| 829e29a726 | |||
| 44117e906c | |||
| c90331b189 | |||
| 399508f2f0 | |||
| 0dfecc2af7 | |||
| 446ea2ac45 | |||
| 01a9249002 | |||
| 7dcd62fdd7 | |||
| d67e582c03 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -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: |
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -86,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
|
||||||
|
|||||||
@@ -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}}" }]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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). -->
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+8
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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).
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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': 'Ваша учётная запись заблокирована.',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user