aea395ae65
## Summary Closes the ADR-0017 follow-up that was sitting on an unpushed local branch (`feat/ci/gzip-transfer-size-budgets`) for ~5 days, while ADRs 0018–0021 and the auth/admin/security tracks landed. Cherry-picked onto current `main`, with a small follow-up fix to make the script work against the locale-split build layout introduced by ADR-0019 between the two commits. ## What lands ### Commit 1 — `feat(ci): assert gzip transfer sizes against ADR-0017 budgets` [`scripts/check-gzip-budgets.mjs`](scripts/check-gzip-budgets.mjs) — plain-Node, no deps, ~120 LOC at landing. Closes the ADR-0017 §Confirmation gap (Angular CLI's `budgets` only compare RAW sizes): - Parses Angular's emitted `index.html` to separate **initial** assets (anything referenced via `src=` / `href=`) from **lazy** chunks (the rest). - Gzips every JS / CSS file at level 9 — what most HTTP servers serve for static assets — and reports a per-file + per-bucket table. - Asserts the [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md) thresholds (`initial JS total ≤ 300 KB`, `any lazy chunk ≤ 100 KB`, `total CSS ≤ 150 KB`) and exits non-zero on any breach. - Wired through: - `pnpm ci:gzip-budgets` invokes the script with the default dist path. - `ci:perf` chains build → gzip check → Lighthouse, so a budget breach short-circuits before Lighthouse even runs. ADR-0017 §Confirmation also updated to point at the script instead of describing it as a "future follow-up". ### Commit 2 — `fix(ci): adapt gzip-budget check for the @angular/localize multi-bundle layout` The original commit predated PR #91 / [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md), which made `@angular/localize` emit one self-contained bundle per locale under `dist/apps/portal-shell/browser/<locale>/`. The script looked for `index.html` directly under the dist root and failed with ENOENT on every build since. Fix: - Auto-detects layout. If `dist/index.html` exists → flat mode (unchanged behaviour, kept for single-locale apps like `portal-admin`). Otherwise enumerates immediate subdirectories with their own `index.html` and runs the check **per locale**. - Budget thresholds apply per locale — each bundle is what the user's browser actually downloads. Aggregating across locales would understate the worst case. - Violations across locales are collected + reported together with the locale tag prefixed so a single CI run surfaces every breach. - ADR-0017 §Confirmation amendment expanded to spell out the per-locale check + cite ADR-0019. ## Test plan - [x] `node --check scripts/check-gzip-budgets.mjs` — syntax OK. - [x] `pnpm ci:gzip-budgets` against the current production build: - 2 locale bundles detected (`en`, `fr`). - Initial JS total: ~141 KB / 300 KB budget ✓ (each locale). - CSS total: 4.62 KB / 150 KB budget ✓ (each locale). - Largest lazy chunk: 1.55 KB / 100 KB per-chunk budget ✓. - [x] No conflict on `package.json` despite 11 PRs touching it since the branch base — cherry-pick auto-merged cleanly. - [ ] CI: `pnpm ci:perf` end-to-end (build → gzip-budgets → Lighthouse). Validates on the runner. ## Notes for the reviewer - The script lives at the repo root under `scripts/` rather than as an Nx target — it's a pure CI helper, not project-scoped. Matches the existing `ci:audit` / `ci:commits` pattern in `package.json`. - No dependencies added. Uses `node:fs/promises` and `node:zlib`. - The original branch (`feat/ci/gzip-transfer-size-budgets`) is preserved in local-only state for paranoia; once this PR merges, it can be `git branch -D`'d. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #133
230 lines
7.7 KiB
JavaScript
230 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Check the gzip-transfer size of an Angular production build against
|
|
* the ADR-0017 perf budgets. Complements Angular CLI's native
|
|
* `budgets` (which only compare RAW sizes — no built-in gzip mode).
|
|
*
|
|
* Strategy
|
|
* --------
|
|
* 1. Parse the Angular `index.html` of the build output. Every
|
|
* `<script src="…">` and `<link href="…">` referencing a JS/CSS
|
|
* asset is part of the **initial** payload. Everything else in
|
|
* the output dir is a **lazy** chunk loaded on demand.
|
|
* 2. Gzip every JS/CSS file with level 9 (matching what HTTP
|
|
* servers typically emit for static assets) and report the
|
|
* resulting transfer size.
|
|
* 3. Compare:
|
|
* - sum of initial JS ≤ BUDGET_INITIAL_JS_TOTAL
|
|
* - any single lazy JS ≤ BUDGET_LAZY_JS_EACH
|
|
* - sum of all CSS ≤ BUDGET_CSS_TOTAL
|
|
* Exit non-zero on any violation.
|
|
*
|
|
* Locale-split builds (ADR-0019)
|
|
* ------------------------------
|
|
* `@angular/localize` emits one self-contained bundle per locale under
|
|
* `dist/apps/portal-shell/browser/<locale>/`. The script auto-detects
|
|
* the layout: if the root has no `index.html` but immediate
|
|
* subdirectories do, each subdirectory is checked against the budgets
|
|
* separately. Budget thresholds apply *per locale* — each bundle is
|
|
* what the user's browser actually downloads. Violations across
|
|
* locales are aggregated and reported together so a single CI run
|
|
* surfaces every breach.
|
|
*
|
|
* Invocation: `node scripts/check-gzip-budgets.mjs [dist-dir]`.
|
|
* The default `dist-dir` is `dist/apps/portal-shell/browser`.
|
|
*/
|
|
|
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
import { gzipSync } from 'node:zlib';
|
|
import { basename, join } from 'node:path';
|
|
|
|
const DEFAULT_DIST = 'dist/apps/portal-shell/browser';
|
|
const dist = process.argv[2] ?? DEFAULT_DIST;
|
|
|
|
// ADR-0017 budgets (gzip transfer, in bytes).
|
|
const BUDGET_INITIAL_JS_TOTAL = 300 * 1024;
|
|
const BUDGET_LAZY_JS_EACH = 100 * 1024;
|
|
const BUDGET_CSS_TOTAL = 150 * 1024;
|
|
|
|
const fmtKb = (n) => `${(n / 1024).toFixed(2)} KB`;
|
|
const padFile = (s) => s.padEnd(36);
|
|
|
|
async function gzipSize(path) {
|
|
return gzipSync(await readFile(path), { level: 9 }).byteLength;
|
|
}
|
|
|
|
async function fileExists(path) {
|
|
try {
|
|
await stat(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check one Angular build directory (a single `index.html` and its
|
|
* sibling JS/CSS assets) against the ADR-0017 budgets. Returns the
|
|
* violations found — empty array means the bundle passed.
|
|
*
|
|
* The header passed in is rendered above the per-file table so the
|
|
* multi-locale invocation can prefix each section with the locale
|
|
* tag.
|
|
*/
|
|
async function checkBundle(bundleDir, header) {
|
|
let indexHtml;
|
|
try {
|
|
indexHtml = await readFile(join(bundleDir, 'index.html'), 'utf8');
|
|
} catch (err) {
|
|
throw new Error(`could not read ${join(bundleDir, 'index.html')}: ${err.message}`);
|
|
}
|
|
|
|
// Match every src=/href= that points at a .js or .css file in the
|
|
// output dir. Robust enough for Angular's emitted index.html (which
|
|
// is well-formed and predictable).
|
|
const referenced = new Set(
|
|
[...indexHtml.matchAll(/(?:src|href)\s*=\s*["']([^"']+\.(?:js|css))["']/g)].map((m) =>
|
|
basename(m[1]),
|
|
),
|
|
);
|
|
|
|
const all = await readdir(bundleDir);
|
|
const jsFiles = all.filter((f) => f.endsWith('.js'));
|
|
const cssFiles = all.filter((f) => f.endsWith('.css'));
|
|
|
|
const initialJs = jsFiles.filter((f) => referenced.has(f));
|
|
const lazyJs = jsFiles.filter((f) => !referenced.has(f));
|
|
|
|
const sized = async (files) =>
|
|
Promise.all(files.map(async (f) => ({ f, size: await gzipSize(join(bundleDir, f)) })));
|
|
|
|
const initialJsSizes = await sized(initialJs);
|
|
const lazyJsSizes = await sized(lazyJs);
|
|
const cssSizes = await sized(cssFiles);
|
|
|
|
const sum = (arr) => arr.reduce((s, x) => s + x.size, 0);
|
|
const totalInitialJs = sum(initialJsSizes);
|
|
const totalCss = sum(cssSizes);
|
|
|
|
const violations = [];
|
|
|
|
console.log(header);
|
|
|
|
console.log(' Initial JS (loaded on first paint):');
|
|
for (const { f, size } of initialJsSizes.sort((a, b) => b.size - a.size)) {
|
|
console.log(` ${padFile(f)} ${fmtKb(size)}`);
|
|
}
|
|
const initialFlag = totalInitialJs > BUDGET_INITIAL_JS_TOTAL ? ' ✗ EXCEEDS BUDGET' : ' ✓';
|
|
console.log(
|
|
` ${padFile('TOTAL')} ${fmtKb(totalInitialJs)} / ${fmtKb(BUDGET_INITIAL_JS_TOTAL)}${initialFlag}`,
|
|
);
|
|
if (totalInitialJs > BUDGET_INITIAL_JS_TOTAL) {
|
|
violations.push(
|
|
`${header.trim()}: initial JS total ${fmtKb(totalInitialJs)} > budget ${fmtKb(BUDGET_INITIAL_JS_TOTAL)}`,
|
|
);
|
|
}
|
|
|
|
console.log('\n Lazy JS chunks (loaded on demand):');
|
|
if (lazyJsSizes.length === 0) {
|
|
console.log(' (none)');
|
|
}
|
|
for (const { f, size } of lazyJsSizes.sort((a, b) => b.size - a.size)) {
|
|
const flag = size > BUDGET_LAZY_JS_EACH ? ' ✗ EXCEEDS BUDGET' : '';
|
|
console.log(` ${padFile(f)} ${fmtKb(size)}${flag}`);
|
|
if (size > BUDGET_LAZY_JS_EACH) {
|
|
violations.push(
|
|
`${header.trim()}: lazy chunk ${f} ${fmtKb(size)} > per-chunk budget ${fmtKb(BUDGET_LAZY_JS_EACH)}`,
|
|
);
|
|
}
|
|
}
|
|
console.log(` (per-chunk budget: ${fmtKb(BUDGET_LAZY_JS_EACH)})`);
|
|
|
|
console.log('\n CSS files:');
|
|
for (const { f, size } of cssSizes.sort((a, b) => b.size - a.size)) {
|
|
console.log(` ${padFile(f)} ${fmtKb(size)}`);
|
|
}
|
|
const cssFlag = totalCss > BUDGET_CSS_TOTAL ? ' ✗ EXCEEDS BUDGET' : ' ✓';
|
|
console.log(
|
|
` ${padFile('TOTAL')} ${fmtKb(totalCss)} / ${fmtKb(BUDGET_CSS_TOTAL)}${cssFlag}\n`,
|
|
);
|
|
if (totalCss > BUDGET_CSS_TOTAL) {
|
|
violations.push(
|
|
`${header.trim()}: CSS total ${fmtKb(totalCss)} > budget ${fmtKb(BUDGET_CSS_TOTAL)}`,
|
|
);
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
/**
|
|
* Resolve which directories to check. A locale-split build has no
|
|
* `index.html` at the root but does have one in each immediate
|
|
* subdirectory (ADR-0019 §"`@angular/localize` build-time mode").
|
|
* Returns either `[dist]` (flat layout) or `[dist/fr, dist/en, …]`
|
|
* (locale split, sorted alphabetically for deterministic output).
|
|
*/
|
|
async function resolveBundles(root) {
|
|
if (await fileExists(join(root, 'index.html'))) {
|
|
return [{ dir: root, label: root }];
|
|
}
|
|
let entries;
|
|
try {
|
|
entries = await readdir(root, { withFileTypes: true });
|
|
} catch (err) {
|
|
throw new Error(`could not read ${root}: ${err.message}`);
|
|
}
|
|
const subdirsWithIndex = [];
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const candidate = join(root, entry.name);
|
|
if (await fileExists(join(candidate, 'index.html'))) {
|
|
subdirsWithIndex.push({ dir: candidate, label: entry.name });
|
|
}
|
|
}
|
|
if (subdirsWithIndex.length === 0) {
|
|
throw new Error(
|
|
`no index.html in ${root} and no locale subdirectory contains one — ` +
|
|
`did you run "nx build portal-shell --configuration=production" first?`,
|
|
);
|
|
}
|
|
subdirsWithIndex.sort((a, b) => a.label.localeCompare(b.label));
|
|
return subdirsWithIndex;
|
|
}
|
|
|
|
async function main() {
|
|
let bundles;
|
|
try {
|
|
bundles = await resolveBundles(dist);
|
|
} catch (err) {
|
|
console.error(`✗ ${err.message}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
const multi = bundles.length > 1;
|
|
console.log(
|
|
`Gzip-transfer budget check (ADR-0017) — output: ${dist}` +
|
|
(multi ? ` (${bundles.length} locale bundles)` : '') +
|
|
'\n',
|
|
);
|
|
|
|
const violations = [];
|
|
for (const { dir, label } of bundles) {
|
|
const header = multi ? `[locale: ${label}]\n` : '';
|
|
const found = await checkBundle(dir, header);
|
|
violations.push(...found);
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error('✗ ADR-0017 budget violations:');
|
|
for (const v of violations) console.error(` - ${v}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('✓ All ADR-0017 gzip-transfer budgets respected.');
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('Budget check failed:', err);
|
|
process.exit(2);
|
|
});
|