diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 7a2b9f7..578bd81 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -86,7 +86,7 @@ # 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 # 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 { reverse_proxy gateway:8081 { header_up -X-Scrabble-Honeypot diff --git a/deploy/grafana/dashboards/users.json b/deploy/grafana/dashboards/users.json index e7eb072..6504813 100644 --- a/deploy/grafana/dashboards/users.json +++ b/deploy/grafana/dashboards/users.json @@ -56,6 +56,22 @@ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 }, "datasource": { "type": "prometheus", "uid": "prometheus" }, "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}}" }] } ] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 030165e..f053c97 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 design (a dropped beacon just retries its batch on the next flush); it is telemetry, never 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 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; diff --git a/gateway/internal/connectsrv/metrics.go b/gateway/internal/connectsrv/metrics.go index 9c619e7..c531e83 100644 --- a/gateway/internal/connectsrv/metrics.go +++ b/gateway/internal/connectsrv/metrics.go @@ -32,6 +32,8 @@ type serverMetrics struct { localColdStart metric.Int64Counter localDictLoad 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), @@ -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."), 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)."), + 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", @@ -108,6 +112,16 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) { 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 // the client's previous report. It backs the adoption dashboard: app cold starts vs cached // dictionaries vs on-device previews. diff --git a/gateway/internal/connectsrv/metrics_test.go b/gateway/internal/connectsrv/metrics_test.go index 9e9f314..c2cf8c8 100644 --- a/gateway/internal/connectsrv/metrics_test.go +++ b/gateway/internal/connectsrv/metrics_test.go @@ -128,3 +128,70 @@ func TestBannedMetric(t *testing.T) { 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) + } + } +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index cfe93d3..1863390 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -15,6 +15,7 @@ import ( "io" "net" "net/http" + "strconv" "strings" "time" @@ -197,6 +198,9 @@ func (s *Server) HTTPHandler() http.Handler { mux.Handle("/dl/", s.exportDownloadHandler()) // The client posts its local-move-preview adoption telemetry here (session-gated). 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 // 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 @@ -582,6 +586,76 @@ func clampReport(r *localEvalReport) { 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 // and its guest flag, returning a Connect Unauthenticated error when it is missing // or unknown. diff --git a/gateway/internal/connectsrv/telemetry_test.go b/gateway/internal/connectsrv/telemetry_test.go new file mode 100644 index 0000000..5d08aee --- /dev/null +++ b/gateway/internal/connectsrv/telemetry_test.go @@ -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) + } +} diff --git a/ui/index.html b/ui/index.html index e6167a2..5095a37 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2,6 +2,240 @@
+ + diff --git a/ui/package.json b/ui/package.json index 11fa26e..9138db1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -27,6 +27,7 @@ "@playwright/test": "^1.49.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@types/node": "^22.10.0", + "core-js-bundle": "^3.49.0", "svelte": "^5.15.0", "svelte-check": "^4.1.0", "typescript": "^5.7.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 5f813f2..8b4434c 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@types/node': specifier: ^22.10.0 version: 22.19.19 + core-js-bundle: + specifier: ^3.49.0 + version: 3.49.0 svelte: specifier: ^5.15.0 version: 5.56.0 @@ -508,6 +511,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + core-js-bundle@3.49.0: + resolution: {integrity: sha512-WXc7oOsePN3aKFOJVG5zQdi+h/Jm2W0WIPYvRc4IG3vkNcbC2w6LlSzTmnhOl6N1xmOJEzCSNieX3mwF+3zBGw==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1132,6 +1138,8 @@ snapshots: clsx@2.1.1: {} + core-js-bundle@3.49.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml index 34a05e7..14571e3 100644 --- a/ui/pnpm-workspace.yaml +++ b/ui/pnpm-workspace.yaml @@ -1,4 +1,8 @@ # pnpm 11 records build-script approval here. esbuild's postinstall materialises # 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: + core-js-bundle: false esbuild: true diff --git a/ui/src/App.svelte b/ui/src/App.svelte index ad3f36d..39bd17e 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -26,7 +26,12 @@ import TelegramLaunchError from './screens/TelegramLaunchError.svelte'; 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 diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 7288220..3e94fa3 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -479,6 +479,11 @@ position: absolute; top: 5%; 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-weight: 700; line-height: 1; @@ -487,12 +492,14 @@ position: absolute; right: 5%; bottom: 3%; + font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ font-size: 2.4cqw; font-weight: 600; } /* 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. */ .blankmark { + font-size: calc(2.8vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ font-size: 2.8cqw; bottom: 0; } @@ -501,6 +508,7 @@ inset: 0; display: grid; place-items: center; + font-size: calc(3.6vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ font-size: 3.6cqw; opacity: 0.7; } @@ -509,6 +517,7 @@ inset: 0; display: grid; place-items: center; + font-size: calc(2.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ font-size: 2.7cqw; font-weight: 600; opacity: 0.9; @@ -526,10 +535,12 @@ padding: 0 1px; } .bt { + font-size: calc(1.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ font-size: 1.7cqw; font-weight: 600; } .bb { + font-size: calc(1.9vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ font-size: 1.9cqw; font-weight: 700; white-space: nowrap; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 53258e6..3395ad0 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,7 +1,42 @@ +import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; 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 // 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` @@ -19,7 +54,9 @@ export default defineConfig(({ mode }) => ({ // so a missing build-arg never breaks the build. __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: { port: 5173, proxy: @@ -33,7 +70,14 @@ export default defineConfig(({ mode }) => ({ }, }, 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` // carries full `sourcesContent` — the entire TypeScript/Svelte source — and the // gateway/landing images serve `dist/` verbatim, so production maps would expose