Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master

This commit is contained in:
2026-07-04 21:23:29 +00:00
14 changed files with 575 additions and 5 deletions
+1 -1
View File
@@ -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
+16
View File
@@ -56,6 +56,22 @@
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 }, "gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }] "targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
},
{
"type": "timeseries",
"title": "Unsupported-engine screens (cumulative by reason)",
"description": "Clients turned away by the index.html boot guard because the engine cannot run the app (an old Android System WebView), by reason: no_bigint / no_proxy (a missing unpolyfillable primitive) or boot_error (an uncaught startup failure). Deduped per device/version, so this counts distinct blocked installs, not launches. The effective floor is Chrome 67 (BigInt).",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(unsupported_engine_total) by (reason)", "legendFormat": "{{reason}}" }]
},
{
"type": "timeseries",
"title": "Unsupported engines by Chromium (rate)",
"description": "The same blocked clients by reported Chromium major version — which old in-app WebViews are still in the wild. \"other\" is an unparseable or out-of-range version.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 32 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(unsupported_engine_total[1h])) by (chromium)", "legendFormat": "Chromium {{chromium}}" }]
} }
] ]
} }
+11
View File
@@ -1070,6 +1070,17 @@ browser), so an open in-app session reflects it at once.
it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by
design (a dropped beacon just retries its batch on the next flush); it is telemetry, never design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
a game input. a game input.
- **Unsupported-engine screens (client-reported):** the ES5 boot guard in `index.html` (§13)
shows a full-screen "your device's OS or browser can't run the app" screen — instead of a white
screen — when the engine lacks an unpolyfillable essential (`BigInt`, the 64-bit FlatBuffers
decode; or `Proxy`, Svelte 5 runes) or an uncaught error aborts boot; the effective floor is
Chrome 67. It then fires one fire-and-forget beacon (`POST /telemetry/unsupported`
unauthenticated, since the client never booted, but per-IP public-limited and body-capped),
deduped in `localStorage` by app version + reason + Chromium so a user reopening the app is one
report. The gateway folds it into `unsupported_engine_total` (`reason` =
no_bigint/no_proxy/boot_error/other; `chromium` = the major version reduced to a bounded range so
a spoofed beacon cannot inflate cardinality) and logs the full user agent (not a label). Surfaced
on the **Scrabble — Users** dashboard beside app opens.
- **Rate-limit observability:** every limiter rejection increments the gateway - **Rate-limit observability:** every limiter rejection increments the gateway
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
only, honouring the no-per-user-label discipline above) and logs one **Debug** line; only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
+14
View File
@@ -32,6 +32,8 @@ type serverMetrics struct {
localColdStart metric.Int64Counter localColdStart metric.Int64Counter
localDictLoad metric.Int64Counter localDictLoad metric.Int64Counter
localPreview metric.Int64Counter localPreview metric.Int64Counter
// Clients turned away by the unsupported-engine boot screen (see unsupportedEngineHandler).
unsupportedEngine metric.Int64Counter
} }
// newServerMetrics builds the instruments on meter (nil selects a no-op meter), // newServerMetrics builds the instruments on meter (nil selects a no-op meter),
@@ -63,6 +65,8 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."), localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."), localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."), localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
unsupportedEngine: counterOf(meter, "unsupported_engine_total",
"Clients that hit the unsupported-engine boot screen (the app cannot run), by reason (no_bigint, no_proxy, boot_error, other) and Chromium major — a deduped beacon from the index.html boot guard; the full user agent is logged, not labelled."),
} }
gauge, err := meter.Int64ObservableGauge("active_users", gauge, err := meter.Int64ObservableGauge("active_users",
@@ -108,6 +112,16 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason))) m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
} }
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
func (m *serverMetrics) recordUnsupportedEngine(ctx context.Context, reason, chromium string) {
m.unsupportedEngine.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
attribute.String("chromium", chromium),
))
}
// localEvalReport is the client-reported local move-preview telemetry batch — deltas since // localEvalReport is the client-reported local move-preview telemetry batch — deltas since
// the client's previous report. It backs the adoption dashboard: app cold starts vs cached // the client's previous report. It backs the adoption dashboard: app cold starts vs cached
// dictionaries vs on-device previews. // dictionaries vs on-device previews.
@@ -128,3 +128,70 @@ func TestBannedMetric(t *testing.T) {
t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts) t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts)
} }
} }
// TestUnsupportedEngineMetric records unsupported-engine beacons through a manual reader and asserts
// unsupported_engine_total splits by reason and Chromium major.
func TestUnsupportedEngineMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
m.recordUnsupportedEngine(ctx, "boot_error", "74")
var rm metricdata.ResourceMetrics
if err := reader.Collect(ctx, &rm); err != nil {
t.Fatalf("collect: %v", err)
}
type key struct{ reason, chromium string }
counts := map[key]int64{}
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != "unsupported_engine_total" {
continue
}
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("unsupported_engine_total is not an int64 sum")
}
for _, dp := range sum.DataPoints {
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
counts[key{reason.AsString(), chromium.AsString()}] += dp.Value
}
}
}
if got := counts[key{"no_bigint", "66"}]; got != 2 {
t.Errorf("unsupported no_bigint/66 = %d, want 2", got)
}
if got := counts[key{"boot_error", "74"}]; got != 1 {
t.Errorf("unsupported boot_error/74 = %d, want 1", got)
}
}
// TestNormalizeUnsupported checks that a beacon's reason and Chromium are reduced to bounded label
// values, so a spoofed beacon cannot explode the metric cardinality.
func TestNormalizeUnsupported(t *testing.T) {
cases := []struct {
reason, chromium string
wantReason, wantChromium string
}{
{"no_bigint", "66", "no_bigint", "66"},
{"no_proxy", " 74 ", "no_proxy", "74"},
{"boot_error", "105", "boot_error", "105"},
{"garbage", "66", "other", "66"},
{"", "", "other", "other"},
{"no_bigint", "not-a-number", "no_bigint", "other"},
{"no_bigint", "9999", "no_bigint", "other"},
{"no_bigint", "0", "no_bigint", "other"},
{"no_bigint", "-5", "no_bigint", "other"},
}
for _, c := range cases {
gotR, gotC := normalizeUnsupported(c.reason, c.chromium)
if gotR != c.wantReason || gotC != c.wantChromium {
t.Errorf("normalizeUnsupported(%q,%q) = %q,%q; want %q,%q", c.reason, c.chromium, gotR, gotC, c.wantReason, c.wantChromium)
}
}
}
+74
View File
@@ -15,6 +15,7 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -197,6 +198,9 @@ func (s *Server) HTTPHandler() http.Handler {
mux.Handle("/dl/", s.exportDownloadHandler()) mux.Handle("/dl/", s.exportDownloadHandler())
// The client posts its local-move-preview adoption telemetry here (session-gated). // The client posts its local-move-preview adoption telemetry here (session-gated).
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
// screen (the app cannot run). Unauthenticated — the client never booted — but rate-limited.
mux.Handle("/telemetry/unsupported", s.unsupportedEngineHandler())
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini // The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md // App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps // §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
@@ -582,6 +586,76 @@ func clampReport(r *localEvalReport) {
clamp(&r.PreviewNetwork) clamp(&r.PreviewNetwork)
} }
// unsupportedEngineBeacon is the small fire-and-forget report the index.html boot guard sends when
// it turns a client away on the unsupported-engine screen (no BigInt/Proxy, or an uncaught boot
// error). It is deduped client-side (one per device / app version / reason).
type unsupportedEngineBeacon struct {
Reason string `json:"reason"`
Chromium string `json:"chromium"`
Version string `json:"version"`
UA string `json:"ua"`
}
// unsupportedEngineHandler folds one unsupported-engine beacon into the edge counter. It is
// unauthenticated (the client never booted, so it carries no session) but per-IP rate-limited with
// the public limiter and body-capped. reason and the Chromium major are reduced to bounded label
// sets (normalizeUnsupported) so a spoofed beacon cannot inflate the metric cardinality; the full
// user agent is logged, not labelled. Only POST; the reply is always 204.
func (s *Server) unsupportedEngineHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "unsupported")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
var b unsupportedEngineBeacon
if err := json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&b); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
reason, chromium := normalizeUnsupported(b.Reason, b.Chromium)
s.metrics.recordUnsupportedEngine(r.Context(), reason, chromium)
s.log.Info("unsupported engine",
zap.String("reason", reason),
zap.String("chromium", chromium),
zap.String("app_version", truncate(b.Version, 40)),
zap.String("user_agent", truncate(b.UA, 400)),
)
w.WriteHeader(http.StatusNoContent)
})
}
// normalizeUnsupported reduces a beacon's reason and Chromium fields to bounded label values, so a
// spoofed beacon cannot explode the metric cardinality: reason is allow-listed, and Chromium is
// parsed as a major version kept only within a plausible range, otherwise "other".
func normalizeUnsupported(reason, chromium string) (string, string) {
switch reason {
case "no_bigint", "no_proxy", "boot_error":
// a recognised reason — keep as-is
default:
reason = "other"
}
major := "other"
if n, err := strconv.Atoi(strings.TrimSpace(chromium)); err == nil && n >= 1 && n <= 199 {
major = strconv.Itoa(n)
}
return reason, major
}
// truncate bounds a logged, client-supplied string to n bytes (a spoofed beacon field is not
// trusted to be small).
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// resolve extracts and resolves the Authorization bearer token to an account id // resolve extracts and resolves the Authorization bearer token to an account id
// and its guest flag, returning a Connect Unauthenticated error when it is missing // and its guest flag, returning a Connect Unauthenticated error when it is missing
// or unknown. // or unknown.
@@ -0,0 +1,81 @@
package connectsrv_test
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/connectsrv"
"scrabble/gateway/internal/ratelimit"
)
// TestUnsupportedEngineHandler drives the /telemetry/unsupported beacon route end to end: a POST
// increments unsupported_engine_total with the normalised labels and replies 204; a GET is 405.
func TestUnsupportedEngineHandler(t *testing.T) {
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
edge := connectsrv.NewServer(connectsrv.Deps{
Limiter: ratelimit.New(),
RateLimit: config.DefaultRateLimit(),
Meter: meter,
})
srv := httptest.NewServer(edge.HTTPHandler())
defer srv.Close()
url := srv.URL + "/telemetry/unsupported"
// A GET is rejected.
getResp, err := http.Get(url)
if err != nil {
t.Fatalf("get: %v", err)
}
getResp.Body.Close()
if getResp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET status = %d, want 405", getResp.StatusCode)
}
// A POST is accepted (204) and folded into the counter with normalised labels.
body := `{"reason":"no_bigint","chromium":"66","version":"v1.2.3","ua":"Mozilla/5.0 ... Chrome/66"}`
resp, err := http.Post(url, "application/json", bytes.NewBufferString(body))
if err != nil {
t.Fatalf("post: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("POST status = %d, want 204", resp.StatusCode)
}
var rm metricdata.ResourceMetrics
if err := reader.Collect(context.Background(), &rm); err != nil {
t.Fatalf("collect: %v", err)
}
var total int64
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != "unsupported_engine_total" {
continue
}
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("unsupported_engine_total is not an int64 sum")
}
for _, dp := range sum.DataPoints {
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
if reason.AsString() != "no_bigint" || chromium.AsString() != "66" {
t.Errorf("labels = %s/%s, want no_bigint/66", reason.AsString(), chromium.AsString())
}
total += dp.Value
}
}
}
if total != 1 {
t.Errorf("unsupported_engine_total = %d, want 1", total)
}
}
+234
View File
@@ -2,6 +2,240 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- Boot capability guard. Runs before the deferred ES module, in plain ES5 so it survives even
on an engine too old to parse the bundle. Three jobs, as early as possible:
1. Hard gate — if an UNPOLYFILLABLE essential is missing (BigInt for the 64-bit
FlatBuffers wire decode, Proxy for Svelte 5 runes) the app cannot run at all: show the
unsupported-engine screen (an old Android System WebView, e.g. Chromium 66, is the case
this guards) instead of a white screen.
2. Soft gate — if only polyfillable es2020+ globals are missing (globalThis,
structuredClone, Array.at, …) pull core-js (emitted as polyfills.js) BEFORE the module
runs. document.write is deliberate: the only way to inject a parser-blocking <script>
guaranteed to run ahead of a deferred module; its argument is a static literal, so no
injection surface.
3. Reactive net — record any uncaught error/rejection during boot; if the app has not
signalled window.__booted within a grace period AND an error fired, show the same
screen with the captured cause (covers a bundle that fails to parse, or an unforeseen
incompatibility). __booted is set in App.svelte once bootstrap resolves.
The screen has a "Diagnostic information" view (engine + feature table + reason + version)
with a Copy button. On a capable engine nothing here renders, so neither the bundle-size
budget nor the mock e2e is affected. -->
<script>
(function () {
'use strict';
var nav = navigator;
var ua = nav.userAgent || '';
var VERSION = '__BOOT_VERSION__'; // replaced at build (vite.config injectBootVersion)
var RU = (nav.language || '').toLowerCase().indexOf('ru') === 0;
var MINIAPP = /\/(?:telegram|vk)\//.test(location.pathname);
var WEBURL = location.protocol + '//' + location.host + '/app/';
// [label, test, Chrome version it landed in, hard?] — hard = required and unpolyfillable.
function has(fn) {
try {
return !!fn();
} catch (e) {
return false;
}
}
var probes = [
['BigInt', function () { return typeof BigInt !== 'undefined'; }, 67, true],
['Proxy', function () { return typeof Proxy !== 'undefined'; }, 49, true],
['globalThis', function () { return typeof globalThis !== 'undefined'; }, 71, false],
['structuredClone', function () { return typeof structuredClone === 'function'; }, 98, false],
['Array.prototype.at', function () { return typeof [].at === 'function'; }, 92, false],
['Array.prototype.findLast', function () { return typeof [].findLast === 'function'; }, 97, false],
['Object.hasOwn', function () { return typeof Object.hasOwn === 'function'; }, 93, false],
['Object.fromEntries', function () { return typeof Object.fromEntries === 'function'; }, 73, false],
['Promise.allSettled', function () { return !!Promise.allSettled; }, 76, false],
['Promise.any', function () { return !!Promise.any; }, 85, false],
['WeakRef', function () { return typeof WeakRef !== 'undefined'; }, 84, false],
['queueMicrotask', function () { return typeof queueMicrotask === 'function'; }, 71, false]
];
var results = [];
var hardMissing = [];
var softMissing = false;
for (var i = 0; i < probes.length; i++) {
var ok = has(probes[i][1]);
results.push({ name: probes[i][0], ok: ok, chrome: probes[i][2], hard: probes[i][3] });
if (!ok) {
if (probes[i][3]) hardMissing.push(probes[i][0]);
else softMissing = true;
}
}
// The diagnostic report (built on demand, behind the button).
function diag(reason) {
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
var wv = /;\s*wv\)/.test(ua) || /\bwv\b/.test(ua);
var out = [];
out.push('reason : ' + reason);
out.push('app : ' + VERSION);
out.push('chromium : ' + (cm ? cm[1] : 'n/a') + (wv ? ' (Android WebView)' : ''));
out.push('userAgent : ' + ua);
out.push('url : ' + location.href);
out.push('viewport : ' + window.innerWidth + 'x' + window.innerHeight + ' @' + (window.devicePixelRatio || 1));
out.push('lang : ' + (nav.language || '?') + ' online: ' + nav.onLine);
out.push('');
out.push('features:');
for (var k = 0; k < results.length; k++) {
var r = results[k];
out.push(' ' + (r.ok ? 'OK' : 'NO') + ' ' + r.name + ' (' + (r.hard ? 'required' : 'polyfilled') + ', Chrome ' + r.chrome + ')');
}
return out.join('\n');
}
function el(tag, style, text) {
var e = document.createElement(tag);
if (style) e.setAttribute('style', style);
if (text != null) e.appendChild(document.createTextNode(text));
return e;
}
function btn(bg, fg) {
return 'display:inline-block;margin:0 10px 10px 0;padding:11px 18px;border:0;border-radius:8px;' +
'font:600 15px/1 inherit;cursor:pointer;color:' + fg + ';background:' + bg + ';';
}
var shown = false;
// Fire-and-forget beacon so the gateway can count who hits this screen (Grafana). Deduped
// in localStorage by app version + reason + Chromium, so a user reopening the app ten times
// is one report. sendBeacon (Chrome 39+, present on the engines that reach here) with a
// fetch fallback; both are best-effort and never block or throw.
function beacon(code) {
try {
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
var chromium = cm ? cm[1] : '';
var sig = VERSION + '|' + code + '|' + chromium;
try {
if (localStorage.getItem('scrabble_unsupp') === sig) return;
} catch (e) {}
var payload = JSON.stringify({ reason: code, chromium: chromium, version: VERSION, ua: ua });
var sent = false;
try {
if (nav.sendBeacon) sent = nav.sendBeacon('/telemetry/unsupported', new Blob([payload], { type: 'application/json' }));
} catch (e) {}
if (!sent && typeof fetch === 'function') {
try {
fetch('/telemetry/unsupported', { method: 'POST', body: payload, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(function () {});
sent = true;
} catch (e) {}
}
if (sent) {
try {
localStorage.setItem('scrabble_unsupp', sig);
} catch (e) {}
}
} catch (e) {}
}
function show(reason, code) {
if (shown) return;
shown = true;
beacon(code);
var root = el('div', 'position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;overflow:auto;' +
'-webkit-overflow-scrolling:touch;background:#0f1115;color:#e8eaed;box-sizing:border-box;padding:24px;' +
'font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;');
var wrap = el('div', 'max-width:520px;margin:0 auto;');
root.appendChild(wrap);
var msg = el('div', null);
msg.appendChild(el('div', 'font-size:22px;font-weight:700;margin-bottom:14px;', 'Эрудит'));
msg.appendChild(el('p', 'margin:0 0 14px;', RU
? 'На вашей версии операционной системы или браузера приложение не сможет работать.'
: "This app can't run on your device's operating system or browser version."));
if (MINIAPP) {
msg.appendChild(el('p', 'margin:0 0 6px;', RU
? 'Откройте веб-версию в обычном браузере (Chrome, Firefox):'
: 'Open the web version in a regular browser (Chrome, Firefox):'));
var a = el('a', 'color:#8ab4ff;word-break:break-all;', WEBURL);
a.setAttribute('href', WEBURL);
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener');
var lp = el('p', 'margin:0 0 18px;');
lp.appendChild(a);
msg.appendChild(lp);
}
var infoBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Диагностическая информация' : 'Diagnostic information');
msg.appendChild(infoBtn);
wrap.appendChild(msg);
var dv = el('div', 'display:none;');
var pre = el('pre', 'white-space:pre-wrap;word-break:break-word;background:#0b0d11;border-radius:8px;padding:12px;' +
'font:12.5px/1.45 ui-monospace,Menlo,Consolas,monospace;overflow:auto;', diag(reason));
dv.appendChild(pre);
var copyBtn = el('button', btn('#8ab4ff', '#0b0d11'), RU ? 'Копировать' : 'Copy');
var backBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Назад' : 'Back');
var row = el('div', 'margin-top:12px;');
row.appendChild(copyBtn);
row.appendChild(backBtn);
dv.appendChild(row);
wrap.appendChild(dv);
infoBtn.onclick = function () {
msg.style.display = 'none';
dv.style.display = 'block';
};
backBtn.onclick = function () {
dv.style.display = 'none';
msg.style.display = 'block';
};
copyBtn.onclick = function () {
var txt = diag(reason);
try {
if (nav.clipboard && nav.clipboard.writeText) {
nav.clipboard.writeText(txt);
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
return;
}
} catch (e) {}
try {
var ta = document.createElement('textarea');
ta.value = txt;
ta.setAttribute('style', 'position:fixed;left:-9999px;top:0;');
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
} catch (e2) {
copyBtn.firstChild.nodeValue = RU ? 'Выделите и скопируйте текст' : 'Select and copy the text';
}
};
function mount() {
document.body.insertBefore(root, document.body.firstChild);
}
if (document.body) mount();
else document.addEventListener('DOMContentLoaded', mount);
}
// 1 + 2: the gate.
if (hardMissing.length) {
show((RU ? 'нет: ' : 'missing: ') + hardMissing.join(', '), hardMissing.indexOf('BigInt') >= 0 ? 'no_bigint' : 'no_proxy');
} else if (softMissing) {
document.write('<script src="polyfills.js"><\/script>');
}
// 3: the reactive net for an unforeseen boot failure.
var bootErr = null;
function note(m) {
if (!bootErr) bootErr = m;
}
window.onerror = function (m, s, l, c, e) {
note(String(m) + (e && e.stack ? '\n' + e.stack : s ? ' @ ' + s + ':' + l : ''));
return false;
};
window.addEventListener('error', function (e) {
if (e && e.message) note(e.message + (e.filename ? ' @ ' + e.filename + ':' + e.lineno : ''));
}, true);
window.addEventListener('unhandledrejection', function (e) {
var r = e && e.reason;
note(r && (r.stack || r.message) ? r.stack || r.message : String(r));
});
setTimeout(function () {
if (!window.__booted && bootErr && !shown) show((RU ? 'ошибка запуска: ' : 'boot error: ') + bootErr, 'boot_error');
}, 8000);
})();
</script>
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it <!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
reach this tag). --> reach this tag). -->
+1
View File
@@ -27,6 +27,7 @@
"@playwright/test": "^1.49.0", "@playwright/test": "^1.49.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"core-js-bundle": "^3.49.0",
"svelte": "^5.15.0", "svelte": "^5.15.0",
"svelte-check": "^4.1.0", "svelte-check": "^4.1.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
+8
View File
@@ -36,6 +36,9 @@ importers:
'@types/node': '@types/node':
specifier: ^22.10.0 specifier: ^22.10.0
version: 22.19.19 version: 22.19.19
core-js-bundle:
specifier: ^3.49.0
version: 3.49.0
svelte: svelte:
specifier: ^5.15.0 specifier: ^5.15.0
version: 5.56.0 version: 5.56.0
@@ -508,6 +511,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
core-js-bundle@3.49.0:
resolution: {integrity: sha512-WXc7oOsePN3aKFOJVG5zQdi+h/Jm2W0WIPYvRc4IG3vkNcbC2w6LlSzTmnhOl6N1xmOJEzCSNieX3mwF+3zBGw==}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -1132,6 +1138,8 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
core-js-bundle@3.49.0: {}
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
+4
View File
@@ -1,4 +1,8 @@
# pnpm 11 records build-script approval here. esbuild's postinstall materialises # pnpm 11 records build-script approval here. esbuild's postinstall materialises
# its CLI shim; the platform binary itself ships as an optional dependency. # its CLI shim; the platform binary itself ships as an optional dependency.
# core-js-bundle ships a prebuilt minified.js (we only read that file at build time,
# via vite.config emitPolyfills) and its install script is just a funding banner, so
# it is denied — nothing to build.
allowBuilds: allowBuilds:
core-js-bundle: false
esbuild: true esbuild: true
+6 -1
View File
@@ -26,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
+11
View File
@@ -479,6 +479,11 @@
position: absolute; position: absolute;
top: 5%; top: 5%;
left: 8%; left: 8%;
/* Chrome < 105 has no container-query units: a dropped `cqw` would inherit the .cell
`font-size: 0` and the glyph would vanish. Fall back to a viewport-relative size that tracks
the board (≈ the viewport-fitted query container) and the zoom (--z), so old Android System
WebViews still draw the tile glyphs. Chrome 105+ takes the exact `cqw` line below. */
font-size: calc(4.2vmin * var(--z, 1));
font-size: 4.2cqw; font-size: 4.2cqw;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
@@ -487,12 +492,14 @@
position: absolute; position: absolute;
right: 5%; right: 5%;
bottom: 3%; bottom: 3%;
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.4cqw; font-size: 2.4cqw;
font-weight: 600; font-weight: 600;
} }
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits, /* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink centred on the same line as a neighbouring tile's value digit. */ its ink centred on the same line as a neighbouring tile's value digit. */
.blankmark { .blankmark {
font-size: calc(2.8vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.8cqw; font-size: 2.8cqw;
bottom: 0; bottom: 0;
} }
@@ -501,6 +508,7 @@
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: calc(3.6vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 3.6cqw; font-size: 3.6cqw;
opacity: 0.7; opacity: 0.7;
} }
@@ -509,6 +517,7 @@
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: calc(2.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.7cqw; font-size: 2.7cqw;
font-weight: 600; font-weight: 600;
opacity: 0.9; opacity: 0.9;
@@ -526,10 +535,12 @@
padding: 0 1px; padding: 0 1px;
} }
.bt { .bt {
font-size: calc(1.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 1.7cqw; font-size: 1.7cqw;
font-weight: 600; font-weight: 600;
} }
.bb { .bb {
font-size: calc(1.9vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 1.9cqw; font-size: 1.9cqw;
font-weight: 700; font-weight: 700;
white-space: nowrap; white-space: nowrap;
+47 -3
View File
@@ -1,7 +1,42 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { defineConfig } from 'vite'; import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelte } from '@sveltejs/vite-plugin-svelte';
/**
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
* __BOOT_VERSION__ placeholder with the same VITE_APP_VERSION build-arg that feeds __APP_VERSION__.
* The guard runs before the bundle, so it cannot read the bundle's version; this lets its on-demand
* diagnostic report name the client version anyway. Falls back to "dev" for a local build.
*/
function injectBootVersion(): Plugin {
const version = process.env.VITE_APP_VERSION || 'dev';
return {
name: 'inject-boot-version',
transformIndexHtml(html) {
return html.replace(/__BOOT_VERSION__/g, version);
},
};
}
/**
* emitPolyfills writes the prebuilt core-js bundle to `dist/polyfills.js`. The index.html gate
* script loads it via document.write only on an old engine that lacks the es2020+ runtime APIs the
* app uses (globalThis, structuredClone, Array.at, …) — e.g. the Chromium 66 Android System WebView
* behind the Telegram/VK in-app browser — before the deferred module runs. A modern engine never
* requests it, so it adds nothing to that payload and stays out of the module graph the bundle-size
* gate measures.
*/
function emitPolyfills(): Plugin {
return {
name: 'emit-polyfills',
generateBundle() {
const src = readFileSync(resolve(import.meta.dirname, 'node_modules/core-js-bundle/minified.js'), 'utf8');
this.emitFile({ type: 'asset', fileName: 'polyfills.js', source: src });
},
};
}
// The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over // The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over
// h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can // h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can
// not speak h2c directly) talks to the dev server on the same origin. In `mock` // not speak h2c directly) talks to the dev server on the same origin. In `mock`
@@ -19,7 +54,9 @@ export default defineConfig(({ mode }) => ({
// so a missing build-arg never breaks the build. // so a missing build-arg never breaks the build.
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'), __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'),
}, },
plugins: [svelte()], // emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the
// index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic.
plugins: [svelte(), emitPolyfills(), injectBootVersion()],
server: { server: {
port: 5173, port: 5173,
proxy: proxy:
@@ -33,7 +70,14 @@ export default defineConfig(({ mode }) => ({
}, },
}, },
build: { build: {
target: 'es2022', // Down-level to es2019 so an old Android System WebView (seen: Telegram/VK in-app WebView on
// Chromium 66, Android 9) can parse the bundle — esbuild lowers es2020+ syntax (?., ??, private
// fields, static blocks, …) that Chrome 66 rejects with "Unexpected token ?", which left the SPA
// on a white screen. esbuild lowers syntax only, not runtime APIs — the es2020+ globals the
// bundle/deps call at runtime (globalThis, structuredClone, Array.at, …) are covered separately
// by the conditional core-js loader (emitPolyfills + the index.html gate), loaded only on an
// engine that actually lacks them.
target: 'es2019',
// Emit sourcemaps everywhere except the production build. A shipped `.map` // Emit sourcemaps everywhere except the production build. A shipped `.map`
// carries full `sourcesContent` — the entire TypeScript/Svelte source — and the // carries full `sourcesContent` — the entire TypeScript/Svelte source — and the
// gateway/landing images serve `dist/` verbatim, so production maps would expose // gateway/landing images serve `dist/` verbatim, so production maps would expose