Files
apf_portal/scripts/check-gzip-budgets.mjs
T
Julien Gautier 1a20490320
CI / commits (pull_request) Successful in 2m27s
CI / check (pull_request) Successful in 2m33s
CI / scan (pull_request) Successful in 2m42s
CI / a11y (pull_request) Successful in 1m58s
CI / perf (pull_request) Successful in 5m37s
fix(ci): adapt gzip-budget check for the @angular/localize multi-bundle layout
The original commit on this branch (9ed74ca, then cherry-picked as
f776ce7) predated ADR-0019 / PR #91, which switched the portal-shell
build to `@angular/localize` and started emitting 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 modern build.

Fix

- `scripts/check-gzip-budgets.mjs`:
  - Auto-detects layout: if `dist/index.html` exists → flat mode
    (unchanged behaviour). Otherwise enumerate immediate
    subdirectories with their own `index.html` and run 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 and reported together
    so a single CI run surfaces every breach; the locale tag is
    prefixed to each violation message for clarity.

- `docs/decisions/0017-...md` §Confirmation: spell out the per-locale
  check + cite ADR-0019 so a future reader of either ADR sees the
  connection.

Verified locally on the 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 ✓.
2026-05-14 16:21:10 +02:00

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);
});