Files
scrabble-game/ui/scripts/bundle-size.mjs
T
Ilia Denisov 2d1fadb50c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
feat(offline): implicit net-state model, two-tier version gate, unified lobby
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
2026-07-13 02:50:41 +02:00

107 lines
6.1 KiB
JavaScript

// Bundle-size budget gate. Measures the gzipped JS each built HTML entry actually loads and
// fails CI if any part overruns its budget — a guard against an accidental heavy dependency.
// The build has two entries (vite rollupOptions.input): the game app (index.html, served at
// /app/ + /telegram/) and the landing (landing.html, served at /). Rollup hoists the code
// shared by both (the Svelte runtime + i18n + aboutContent) into one chunk each entry
// preloads, so a page's real payload is its own entry chunk plus that shared chunk.
//
// Three independent gates on the natural chunk boundaries, each with realistic headroom:
// - app entry (main): the app's own code; grows with features.
// - shared (svelte+i18n): the framework runtime plus the flat i18n map; drifts on a dep/Svelte
// bump and, slightly, as feature copy adds i18n keys (raised to 31 for
// the rewarded-ad strings).
// - landing own: the landing's own code; kept minimal.
// Today ~74 KB (app entry) + ~23 KB (shared) = ~97 KB for the app; the landing's own chunk is
// ~2 KB. Lazy-loading was analysed and rejected (no total-size win — every chunk still
// ships and is summed — plus added request latency); the bulk is the Connect/FlatBuffers
// transport runtime + generated bindings + the Svelte runtime, irreducible within scope.
import { readFileSync, existsSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { join } from 'node:path';
const DIST = 'dist';
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, to 114 for
// the offline auto-detect (the cold-start reachability check and the "no connection" dialog live in
// the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and
// countdown toast live in the always-loaded game screen (Game.svelte) — and to 120 for the offline
// pass-and-play (hotseat) mode: the on-screen PIN pad, the creation roster and the in-game host menu
// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
// hashing stay in lazy chunks / are negligible), then to 123 for the Wallet section — its screen,
// storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands
// in the shared chunk) — and to 125 for the payment intake rails: the wallet order flow, the
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
// always-loaded Wallet screen, then to 126 for the in-game board-feedback UX — the staged-play
// highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom
// wiring live in the always-loaded Game / Board / Rack screens, then to 127 for the wallet storefront
// redesign — its buy/spend toggle, compact balance and exchange-confirm dialog ride the same
// always-loaded Wallet screen — then to 130 for the offline-model redesign: the net-state machine
// (`netstate`), the two-tier version gate (the transport interceptor + the Update/Play-offline notice
// + the update-available nudge) and the unified-lobby / create-flow wiring ride the always-loaded
// transport / App / Lobby / New Game screens (the dict-availability guard's `getDawg` stays lazy). The
// heavy parts — the dict loader, the move generator and the preload orchestration — still stay in lazy
// chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 130, shared: 31, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
function gzipped(file) {
return file && existsSync(file) ? gzipSync(readFileSync(file)).length : 0;
}
// attr reads a double-quoted HTML attribute from a single tag string.
function attr(tag, name) {
const m = tag.match(new RegExp(`\\s${name}="([^"]+)"`));
return m ? m[1] : null;
}
// localAsset maps an HTML asset URL to its path under dist/, or null for an external URL.
function localAsset(url) {
return !url || /^https?:/.test(url) ? null : join(DIST, url.replace(/^\.?\/+/, ''));
}
// entryAssets parses a built HTML entry and returns the local JS it eagerly loads: the module
// entry chunk (<script type="module" src>) and the preloaded chunks (<link rel="modulepreload"
// href>). Robust to attribute order; external and non-JS references drop out.
function entryAssets(html) {
const src = readFileSync(join(DIST, html), 'utf8');
const tags = [...src.matchAll(/<(?:script|link)\b[^>]*>/g)].map((m) => m[0]);
const entryTag = tags.find((t) => /type="module"/.test(t) && /\ssrc="/.test(t));
const entry = entryTag ? localAsset(attr(entryTag, 'src')) : null;
const preloads = tags
.filter((t) => /rel="modulepreload"/.test(t))
.map((t) => localAsset(attr(t, 'href')))
.filter((f) => f && f.endsWith('.js'));
return { entry: entry && entry.endsWith('.js') ? entry : null, preloads };
}
const kb = (bytes) => (bytes / 1024).toFixed(1);
const app = entryAssets('index.html');
const landing = entryAssets('landing.html');
const appEntry = gzipped(app.entry);
const shared = app.preloads.reduce((sum, f) => sum + gzipped(f), 0);
const landingEntry = gzipped(landing.entry);
const landingShared = landing.preloads.reduce((sum, f) => sum + gzipped(f), 0);
console.log(`app (index.html) : entry ${kb(appEntry)} + shared ${kb(shared)} = ${kb(appEntry + shared)} KB gzip`);
console.log(`landing (landing.html): entry ${kb(landingEntry)} + shared ${kb(landingShared)} = ${kb(landingEntry + landingShared)} KB gzip`);
console.log('gates (gzip):');
let failed = false;
function gate(label, bytes, budgetKb) {
const over = bytes > budgetKb * 1024;
console.log(` ${over ? 'FAIL' : ' ok '} ${label}: ${kb(bytes)} / ${budgetKb} KB`);
if (over) failed = true;
}
gate('app entry (main)', appEntry, BUDGET.app);
gate('shared (svelte + i18n)', shared, BUDGET.shared);
gate('landing own', landingEntry, BUDGET.landing);
if (failed) {
console.error('bundle exceeds size budget');
process.exit(1);
}