diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 7813819b..c2b61fba 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -67,6 +67,17 @@ jobs: exit 1 fi + # The production build is needed only for the size measurement; the + # browser smoke test builds its own copy. + - name: 'Build UI for the bundle size' + run: npm run build + + # Fails when the first-visit download (what index.html names, gzip) + # exceeds the limit in config/bundle-budget.json, and lists every other + # chunk in the log and the job summary. + - name: 'Bundle size' + run: node scripts/bundle-size.mjs --budget config/bundle-budget.json + merge-commits: # Only a pull request has a range to check; on main the merge commit that # brought the pull request in is expected. diff --git a/README.md b/README.md index e3df8118..96b19eb6 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,17 @@ npm run build The production build is written to `dist/`. It needs no environment variables inside a git checkout. +CI measures the initial load, the gzip size of every script and stylesheet that `dist/index.html` +names, against the limit in `config/bundle-budget.json`, and reports every other chunk in the job +log and the job summary. After a build the same numbers are available locally: + +```sh +node scripts/bundle-size.mjs --budget config/bundle-budget.json +``` + +The check fails when the initial load is over the limit. Raising the limit is a reviewed change of +`config/bundle-budget.json` in a pull request. + ### Unit tests ```sh diff --git a/config/bundle-budget.json b/config/bundle-budget.json new file mode 100644 index 00000000..9d2cb9b4 --- /dev/null +++ b/config/bundle-budget.json @@ -0,0 +1,3 @@ +{ + "initialGzipBytes": 494000 +} diff --git a/scripts/bundle-size.mjs b/scripts/bundle-size.mjs new file mode 100644 index 00000000..975fda17 --- /dev/null +++ b/scripts/bundle-size.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node +// Initial-load size check for the production build. +// +// The initial load is everything a first visit downloads before any screen +// shows: each script and stylesheet that dist/index.html names, compressed the +// way a server compresses it (gzip, level 9) and added up. It grows silently +// when a heavy import lands in the entry instead of a lazily loaded section, +// and every user then pays for it on every first visit. This check compares +// the total with the limit in a file of the repository, so raising the limit +// is a visible change in a pull request, and it reports every other chunk +// (raw and gzip size) without budgeting it. +// +// Usage: +// node scripts/bundle-size.mjs --budget config/bundle-budget.json [--dist dist] +// +// The budget file is JSON: { "initialGzipBytes": }. +// +// Exit status: 0 within the budget, 1 over it, 2 when index.html, an asset it +// names or the budget file is missing or invalid, or an asset path would leave +// the dist folder. When GITHUB_STEP_SUMMARY names a file, the report is +// appended to it as Markdown. +import { appendFileSync, readFileSync, readdirSync } from 'node:fs'; +import { join, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { gzipSync } from 'node:zlib'; + +const BUDGET_FILE = 'config/bundle-budget.json'; + +// Raised for input that cannot be measured; the command line maps it to 2. +class InputError extends Error {} + +/** + * The initial assets named by a page: the path of every script src and link + * href that ends in .js or .css and sits under a js or css folder, as a path + * relative to the dist folder ("js/App..js"), in page order, once each. + * Handles quoted and unquoted attribute values. + */ +export function parseAssets(html) { + const found = []; + const attribute = /\b(?:src|href)\s*=\s*("[^"]*"|'[^']*'|[^\s>"']+)/gi; + for (const match of html.matchAll(attribute)) { + const value = match[1].replace(/^["']|["']$/g, '').replace(/[?#].*$/, ''); + if (!/\.(?:js|css)$/.test(value)) { + continue; + } + const tail = /(?:^|\/)((?:js|css)\/.*)$/.exec(value); + if (tail && !found.includes(tail[1])) { + found.push(tail[1]); + } + } + return found; +} + +/** Size in bytes of the buffer after gzip at the highest level. */ +export function gzipSize(buffer) { + return gzipSync(buffer, { level: 9 }).length; +} + +/** Whether the total is within the budget, and by how much it is over. */ +export function compare(total, budget) { + const over = Math.max(0, total - budget); + return { ok: over === 0, over }; +} + +function describeAsset(distDir, relPath) { + let data; + try { + data = readFileSync(join(distDir, relPath)); + } catch { + throw new InputError(`asset ${relPath} is missing from ${distDir}`); + } + return { path: relPath, raw: data.length, gzip: gzipSize(data) }; +} + +function listChunks(distDir) { + const out = []; + for (const [folder, ext] of [ + ['js', '.js'], + ['css', '.css'], + ]) { + let names = []; + try { + names = readdirSync(join(distDir, folder), { withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith(ext)) + .map((e) => e.name); + } catch { + // no such folder: nothing to list + } + for (const name of names.sort()) { + out.push(`${folder}/${name}`); + } + } + return out; +} + +/** + * Measure a dist folder: { total, initial, others } where initial holds the + * assets index.html names and others every remaining script and stylesheet, + * each as { path, raw, gzip }. Throws InputError when index.html or a named + * asset is missing or a named asset resolves outside the dist folder. + */ +export function measure(distDir) { + const root = resolve(distDir); + let html; + try { + html = readFileSync(join(root, 'index.html'), 'utf8'); + } catch { + throw new InputError(`${join(root, 'index.html')} not found`); + } + const named = parseAssets(html); + if (named.length === 0) { + throw new InputError('index.html names no script or stylesheet'); + } + for (const rel of named) { + const target = resolve(root, rel); + if (target !== root && !target.startsWith(root + sep)) { + throw new InputError(`asset path ${rel} resolves outside ${root}`); + } + } + const initial = named.map((rel) => describeAsset(root, rel)); + const others = listChunks(root) + .filter((rel) => !named.includes(rel)) + .map((rel) => describeAsset(root, rel)); + const total = initial.reduce((sum, a) => sum + a.gzip, 0); + return { total, initial, others }; +} + +const number = (n) => n.toLocaleString('en-US'); + +function textTable(title, rows) { + const width = Math.max(5, ...rows.map((r) => r.path.length)); + const lines = [ + '', + title, + `${'Asset'.padEnd(width)} ${'Raw bytes'.padStart(12)} ${'Gzip bytes'.padStart(12)}`, + ]; + for (const r of rows) { + lines.push( + `${r.path.padEnd(width)} ${number(r.raw).padStart(12)} ${number(r.gzip).padStart(12)}`, + ); + } + return lines; +} + +function markdownTable(title, rows) { + const lines = [ + '', + `### ${title}`, + '', + '| Asset | Raw bytes | Gzip bytes |', + '| --- | ---: | ---: |', + ]; + for (const r of rows) { + lines.push(`| ${r.path} | ${number(r.raw)} | ${number(r.gzip)} |`); + } + return lines; +} + +/** + * The report of a measurement against a budget, as plain text for the job log + * and as Markdown for the job summary. The first line of the text is the + * headline "Initial load: N bytes gzip (budget M)". + */ +export function formatReport({ total, initial, others, budget }) { + const headline = `Initial load: ${total} bytes gzip (budget ${budget})`; + const initialTitle = 'Initial assets (named by index.html)'; + const othersTitle = 'Other chunks (loaded on demand, not budgeted)'; + const text = [ + headline, + ...textTable(initialTitle, initial), + ...textTable(othersTitle, others), + '', + ].join('\n'); + const markdown = [ + `## Bundle size`, + '', + `**${headline}**`, + ...markdownTable(initialTitle, initial), + ...markdownTable(othersTitle, others), + '', + ].join('\n'); + return { text, markdown }; +} + +function readBudget(file) { + let parsed; + try { + parsed = JSON.parse(readFileSync(file, 'utf8')); + } catch (err) { + throw new InputError( + `cannot read budget file ${file}: ${err instanceof Error ? err.message : err}`, + ); + } + const value = parsed && parsed.initialGzipBytes; + if (!Number.isInteger(value) || value < 0) { + throw new InputError( + `${file} must hold { "initialGzipBytes": }`, + ); + } + return value; +} + +function parseArgs(args) { + const options = { budget: null, dist: 'dist' }; + for (let i = 0; i < args.length; i += 2) { + const name = args[i]; + const value = args[i + 1]; + if ((name !== '--budget' && name !== '--dist') || !value) { + throw new InputError( + `usage: bundle-size.mjs --budget ${BUDGET_FILE} [--dist dist]`, + ); + } + options[name.slice(2)] = value; + } + if (!options.budget) { + throw new InputError( + `usage: bundle-size.mjs --budget ${BUDGET_FILE} [--dist dist]`, + ); + } + return options; +} + +function main(args) { + try { + const options = parseArgs(args); + const budget = readBudget(options.budget); + const result = measure(options.dist); + const report = formatReport({ ...result, budget }); + process.stdout.write(report.text); + const summary = process.env['GITHUB_STEP_SUMMARY']; + if (summary) { + appendFileSync(summary, report.markdown); + } + const verdict = compare(result.total, budget); + if (!verdict.ok) { + process.stderr.write( + `The initial load is ${number(verdict.over)} bytes over the budget of ${number(budget)} bytes gzip. ` + + `If the larger initial load is intended, change initialGzipBytes in ${BUDGET_FILE} in a pull request.\n`, + ); + return 1; + } + return 0; + } catch (err) { + if (err instanceof InputError) { + process.stderr.write(`bundle-size: ${err.message}\n`); + return 2; + } + throw err; + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/scripts/bundle-size.test.mjs b/scripts/bundle-size.test.mjs new file mode 100644 index 00000000..5ad3b086 --- /dev/null +++ b/scripts/bundle-size.test.mjs @@ -0,0 +1,235 @@ +// @vitest-environment node +// Tests of the initial-load size check. Fixtures are temporary dist folders +// with incompressible assets of known size, so the measured sizes are stable. +import { spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + compare, + formatReport, + gzipSize, + measure, + parseAssets, +} from './bundle-size.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = join(HERE, 'bundle-size.mjs'); + +const INDEX_UNQUOTED = + 'Pulp UI' + + '' + + '' + + '' + + '
'; + +const INDEX_QUOTED = + '' + + '' + + "" + + '' + + ''; + +describe('parseAssets', () => { + it('finds the script and the stylesheet of a minified page with unquoted attributes', () => { + expect(parseAssets(INDEX_UNQUOTED)).toEqual([ + 'js/App.abc.js', + 'css/App.def.css', + ]); + }); + + it('gives the same result for quoted attributes and ignores images and icons', () => { + expect(parseAssets(INDEX_QUOTED)).toEqual([ + 'js/App.abc.js', + 'css/App.def.css', + ]); + }); + + it('lists an asset named twice once', () => { + const html = INDEX_UNQUOTED.replace( + '', + '', + ); + expect(parseAssets(html)).toEqual(['js/App.abc.js', 'css/App.def.css']); + }); + + it('ignores scripts and links that are not under a js or css folder', () => { + const html = + ''; + expect(parseAssets(html)).toEqual([]); + }); +}); + +describe('gzipSize', () => { + it('is the size of the buffer compressed at the highest level', () => { + const buf = Buffer.from('abc'.repeat(1000)); + expect(gzipSize(buf)).toBe(gzipSync(buf, { level: 9 }).length); + expect(gzipSize(buf)).toBeLessThan(buf.length); + }); +}); + +describe('compare', () => { + it('accepts a total within or equal to the budget', () => { + expect(compare(100, 200)).toEqual({ ok: true, over: 0 }); + expect(compare(200, 200)).toEqual({ ok: true, over: 0 }); + }); + + it('reports by how much the total is over the budget', () => { + expect(compare(250, 200)).toEqual({ ok: false, over: 50 }); + }); +}); + +let dist; +const write = (rel, data) => { + mkdirSync(dirname(join(dist, rel)), { recursive: true }); + writeFileSync(join(dist, rel), data); +}; +const incompressible = (n) => randomBytes(n); + +const run = (args, env = {}) => + spawnSync(process.execPath, [SCRIPT, ...args], { + encoding: 'utf8', + env: { ...process.env, GITHUB_STEP_SUMMARY: '', ...env }, + }); + +const writeBudget = (value) => { + const file = join(dist, '..', `budget-${process.pid}.json`); + writeFileSync(file, JSON.stringify({ initialGzipBytes: value })); + return file; +}; + +beforeEach(() => { + dist = join(mkdtempSync(join(tmpdir(), 'bundle-size-')), 'dist'); + mkdirSync(dist); + write('index.html', INDEX_UNQUOTED); + write('js/App.abc.js', incompressible(3000)); + write('css/App.def.css', incompressible(2000)); + write('js/section.111.js', incompressible(500)); + write('css/section.222.css', incompressible(400)); + write('js/App.abc.js.map', incompressible(9000)); + write('js/App.abc.js.LICENSE.txt', 'licenses'); +}); + +afterEach(() => { + rmSync(dirname(dist), { recursive: true, force: true }); +}); + +describe('measure', () => { + it('sums the gzip size of the assets the page names and lists the rest apart', () => { + const result = measure(dist); + const expected = + gzipSize(readFileSync(join(dist, 'js/App.abc.js'))) + + gzipSize(readFileSync(join(dist, 'css/App.def.css'))); + expect(result.total).toBe(expected); + expect(result.initial.map((a) => a.path)).toEqual([ + 'js/App.abc.js', + 'css/App.def.css', + ]); + expect(result.others.map((a) => a.path).sort()).toEqual([ + 'css/section.222.css', + 'js/section.111.js', + ]); + }); + + it('refuses an asset path that leaves the dist folder', () => { + write( + 'index.html', + '', + ); + writeFileSync(join(dist, '..', 'outside.js'), 'x'); + expect(() => measure(dist)).toThrow(/outside/); + }); +}); + +describe('formatReport', () => { + it('prints the headline first and names every other chunk in both outputs', () => { + const report = formatReport({ ...measure(dist), budget: 100000 }); + expect(report.text.split('\n')[0]).toMatch( + /^Initial load: \d+ bytes gzip \(budget 100000\)$/, + ); + for (const out of [report.text, report.markdown]) { + expect(out).toContain('js/section.111.js'); + expect(out).toContain('css/section.222.css'); + expect(out).not.toContain('.map'); + expect(out).not.toContain('LICENSE'); + } + expect(report.markdown).toContain('| --- |'); + }); +}); + +describe('command line', () => { + it('exits 0 within the budget and prints the headline and the chunk table', () => { + const budget = writeBudget(100000); + const res = run(['--budget', budget, '--dist', dist]); + expect(res.status).toBe(0); + expect(res.stdout).toMatch( + /^Initial load: \d+ bytes gzip \(budget 100000\)/, + ); + expect(res.stdout).toContain('js/section.111.js'); + expect(res.stdout).toContain('css/section.222.css'); + expect(res.stdout).not.toContain('.map'); + expect(res.stdout).not.toContain('LICENSE'); + }); + + it('exits 1 over the budget and names the file to change', () => { + const budget = writeBudget(1000); + const res = run(['--budget', budget, '--dist', dist]); + expect(res.status).toBe(1); + expect(res.stdout + res.stderr).toContain('config/bundle-budget.json'); + expect(res.stdout).toMatch(/^Initial load: /); + }); + + it('exits 2 without index.html', () => { + rmSync(join(dist, 'index.html')); + const res = run(['--budget', writeBudget(100000), '--dist', dist]); + expect(res.status).toBe(2); + }); + + it('exits 2 when a named asset is missing', () => { + rmSync(join(dist, 'css/App.def.css')); + const res = run(['--budget', writeBudget(100000), '--dist', dist]); + expect(res.status).toBe(2); + }); + + it('exits 2 when an asset path leaves the dist folder', () => { + write('index.html', ''); + const res = run(['--budget', writeBudget(100000), '--dist', dist]); + expect(res.status).toBe(2); + }); + + it('exits 2 for a missing budget file, a non-numeric limit and a missing argument', () => { + expect( + run(['--budget', join(dist, 'nope.json'), '--dist', dist]).status, + ).toBe(2); + const bad = join(dist, '..', 'bad.json'); + writeFileSync(bad, JSON.stringify({ initialGzipBytes: 'lots' })); + expect(run(['--budget', bad, '--dist', dist]).status).toBe(2); + writeFileSync(bad, 'not json'); + expect(run(['--budget', bad, '--dist', dist]).status).toBe(2); + expect(run(['--dist', dist]).status).toBe(2); + }); + + it('appends the same tables as Markdown to the job summary file', () => { + const summary = join(dist, '..', 'summary.md'); + const res = run(['--budget', writeBudget(100000), '--dist', dist], { + GITHUB_STEP_SUMMARY: summary, + }); + expect(res.status).toBe(0); + expect(existsSync(summary)).toBe(true); + const md = readFileSync(summary, 'utf8'); + expect(md).toContain('Initial load'); + expect(md).toContain('| --- |'); + expect(md).toContain('js/section.111.js'); + }); +});