5689f7f6a3
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Score and validate a tentative move on-device instead of a per-arrangement network
round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a `conformance` CI job (full-dictionary reader
parity plus a battery of plays across every variant and both cross-word rules,
including the inferred orientation). The server stays authoritative — submit_play
re-validates — so the local result is an advisory accelerator only.
- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
(immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: the dictionary loads on game open (low priority so it never starves the
game on a slow link; aborted at a 5s cap or when leaving the game), is cached in
IndexedDB (best-effort, self-healing on a rejected blob) and reused across
sessions; a warm-up overlay covers a cold load, then the network preview is the
fallback; a bad-connection breaker stops warming after repeated misses; the move
preview cancels its in-flight request when the tiles change.
- parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in
CI against the release dictionaries. A hidden debug readout lists the cached
dictionaries + breaker state, and its reset clears the cache.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
87 lines
4.2 KiB
JavaScript
87 lines
4.2 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): near-static framework runtime; only drifts on a dep/Svelte bump.
|
|
// - 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 when the local
|
|
// move-preview wiring landed (the heavy dict subsystem stays in lazy chunks; this
|
|
// covers the small in-entry game/debug wiring it needs).
|
|
const BUDGET = { app: 110, shared: 30, 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);
|
|
}
|