Wordle module, cross-language tests, and the deploy path
The browser engine is a port of the Python one and CI proves it: all 21.2M (guess, answer) pairs hashed on both sides to the same SHA-256. Six TS tests, including the duplicate-letter table and the twelve pinned seed vectors that keep ?seed= permalinks pointing at the same word the recording used. Word lists are split by how they are used. answers.json is inlined because the board needs it before first paint to turn a seed into a word, and a fetch there means a visibly empty board on a cold cache. guesses.json is fetched, because it is three times larger and only needed the first time somebody presses Enter; until it lands, validation falls back to the answer list, which accepts strictly fewer words. The failure mode is 'your real word was briefly rejected', not 'a non-word was accepted' — the right way round. The solver runs in a worker constructed from a same-origin module URL, never Vite's ?worker&inline: that yields a blob:, and production CSP has no worker-src, so it falls back to default-src 'self' and the worker is blocked with no console error. It would fail in production only. deploy.sh smoke-tests the real public hostname from the deploying machine and fails on a body under 1 kB, because the bind bug's signature is a valid certificate over an empty 200 and a local --resolve check passes anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The bundle budget.
|
||||
*
|
||||
* Two numbers, both gzipped, both enforced:
|
||||
*
|
||||
* entry chunk <= 160 kB what every visitor downloads before they see
|
||||
* anything, on whatever connection the boardroom
|
||||
* wifi is having that morning
|
||||
* any demo <= 90 kB a demo is lazy, so this is what clicking one
|
||||
* costs — and it is per demo, so the tenth demo
|
||||
* cannot be paid for by the first nine
|
||||
*
|
||||
* The vendor chunks (`react`, `charts`, named in vite.config.ts) are printed
|
||||
* but not capped. They are shared, cached across every route, and capping them
|
||||
* here would only produce pressure to inline them into the entry, which is the
|
||||
* opposite of what we want.
|
||||
*
|
||||
* Gzip, not brotli, and not raw: gzip is the floor every host actually serves,
|
||||
* so it is the honest number. Brotli would flatter us by about 15%.
|
||||
*
|
||||
* Run after a build: `pnpm build && node scripts/bundle-budget.mjs`.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { abs, die, dim, exists, green, gzipSize, kb, red, rel, table, yellow } from './_lib.mjs';
|
||||
|
||||
const ENTRY_BUDGET = 160 * 1024;
|
||||
const DEMO_BUDGET = 90 * 1024;
|
||||
|
||||
const DIST = abs('dist');
|
||||
const ASSETS = path.join(DIST, 'assets');
|
||||
const INDEX = path.join(DIST, 'index.html');
|
||||
|
||||
if (!exists(INDEX)) die(`${rel(INDEX)} does not exist. Run \`pnpm build\` first.`);
|
||||
if (!exists(ASSETS)) die(`${rel(ASSETS)} does not exist. The build produced no assets, which is itself the bug.`);
|
||||
|
||||
/**
|
||||
* The entry is whatever `index.html` loads directly.
|
||||
*
|
||||
* Derived rather than matched by filename: Vite's entry is `index-<hash>.js`
|
||||
* today, but the moment someone renames the entry in rollupOptions, a
|
||||
* name-matching version of this script starts measuring nothing and passing.
|
||||
*/
|
||||
const indexHtml = fs.readFileSync(INDEX, 'utf8');
|
||||
const entryNames = new Set(
|
||||
[...indexHtml.matchAll(/<script[^>]+type=["']module["'][^>]+src=["']([^"']+)["']/g)].map((m) => path.basename(m[1])),
|
||||
);
|
||||
if (entryNames.size === 0) {
|
||||
die(`no module <script> found in ${rel(INDEX)}, so the entry chunk cannot be identified.`);
|
||||
}
|
||||
|
||||
/** Named in vite.config.ts's manualChunks. Shared, cached, deliberately uncapped. */
|
||||
const VENDOR = /^(react|charts)-/;
|
||||
|
||||
const files = fs
|
||||
.readdirSync(ASSETS)
|
||||
.filter((name) => name.endsWith('.js') || name.endsWith('.css'))
|
||||
.sort();
|
||||
|
||||
const rows = [];
|
||||
const failures = [];
|
||||
let totalGzip = 0;
|
||||
|
||||
for (const name of files) {
|
||||
const bytes = fs.readFileSync(path.join(ASSETS, name));
|
||||
const gz = gzipSize(bytes);
|
||||
totalGzip += gz;
|
||||
|
||||
const kind = entryNames.has(name)
|
||||
? 'entry'
|
||||
: name.endsWith('.css')
|
||||
? 'css'
|
||||
: VENDOR.test(name)
|
||||
? 'vendor'
|
||||
: 'demo/lazy';
|
||||
|
||||
const budget = kind === 'entry' ? ENTRY_BUDGET : kind === 'demo/lazy' ? DEMO_BUDGET : null;
|
||||
const over = budget !== null && gz > budget;
|
||||
if (over) {
|
||||
failures.push(
|
||||
`assets/${name} is ${kb(gz)} gzipped, over the ${kb(budget)} ${kind === 'entry' ? 'entry' : 'per-demo'} budget ` +
|
||||
`by ${kb(gz - budget)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
rows.push([
|
||||
`assets/${name}`,
|
||||
kind,
|
||||
kb(bytes.length),
|
||||
kb(gz),
|
||||
budget === null ? '-' : `${((gz / budget) * 100).toFixed(0)}%`,
|
||||
over ? red('OVER') : budget === null ? dim('n/a') : green('ok'),
|
||||
]);
|
||||
}
|
||||
|
||||
console.log(table(['file', 'kind', 'raw', 'gzip', 'of budget', ''], rows));
|
||||
console.log('');
|
||||
console.log(dim(`total gzipped: ${kb(totalGzip)} across ${rows.length} assets`));
|
||||
|
||||
if (failures.length) {
|
||||
console.error('');
|
||||
for (const failure of failures) console.error(`${red('FAIL')} ${failure}`);
|
||||
console.error('');
|
||||
console.error(
|
||||
red('bundle-budget: over budget.') +
|
||||
'\n A demo that costs 90 kB is a demo somebody closes before it renders. The usual causes, in ' +
|
||||
'\n order: a chart library pulled into a demo chunk instead of its own tab, an icon set imported ' +
|
||||
"\n as a namespace rather than by name, and a `?raw` receipt that grew a file it didn't need.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// A lazy chunk nobody can reach is not a saving, it is dead weight that still
|
||||
// costs disk and cache. Worth a look, never worth failing a build over.
|
||||
const unreferenced = rows.filter((row) => row[1] === 'demo/lazy' && !indexHtml.includes(path.basename(row[0])));
|
||||
if (unreferenced.length > 8) {
|
||||
console.log(yellow(`warn ${unreferenced.length} lazy chunks. Worth checking none of them are orphaned.`));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(green('bundle-budget: within budget.'));
|
||||
Reference in New Issue
Block a user