e011b2ebe5
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m27s
Building the set of words a game has already used belonged with the rest of the rule, behind the same lazy import as the evaluator that consumes it, rather than in the always-loaded game screen. What is left in the app entry — the per-game flag on the decoded GameView and the caption that names a rejected word — costs more than the budget had free, so raise it by the usual kilobyte with the reason recorded alongside the earlier raises.
111 lines
6.5 KiB
JavaScript
111 lines
6.5 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. Raised to 131 for the Erudit no-repeat-words rule: the per-game flag rides the decoded
|
|
// GameView (codec + model), and the always-loaded game screen has to name the word its preview
|
|
// rejected, since a repeated word is a real word and the board alone cannot say why the play is
|
|
// refused. The rule's own logic — the played-word set and the rescoring — sits with the evaluator in
|
|
// the lazy dict chunk. Scoped CSS lands in the CSS chunk, not this JS budget.
|
|
const BUDGET = { app: 131, 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);
|
|
}
|