Files
iptvnator/tools/performance/measure-initial-bytes.mjs
T
4grayandClaude Fable 5.1 8bc877b625 chore(performance): measure initial bytes of the built web app (#1692)
First step of the performance-journeys ratchet (plan thread: J1 `launch`, counter `renderer.initialBytes`).

- `tools/performance/measure-initial-bytes.mjs` reads the built `dist/apps/web/index.html` and sums `index.html` plus every same-origin `<script src>`, `<link rel="stylesheet">` and `<link rel="modulepreload">` it references. Manifest, icons, external URLs and lazy chunks are not counted. A referenced file missing from the build fails the measurement instead of counting as zero bytes.
- `--json` prints the breakdown; `--summary <file>` writes the `journeys.<journey>.counters` shape a ratchet checker will consume (next PR).
- New Nx project `performance-tools` (test + lint targets), Tier B in `tools/coverage/coverage-policy.json`, root scripts `perf:initial-bytes` and `perf:tools:test`.
- New contract `docs/architecture/performance-journeys.md`, linked from the validation map, the agent context map and the README.
- **Review follow-ups:** resources are deduplicated by request URL (query kept, fragment dropped); `index.html` is parsed with parse5 (already a repository dependency, scripting enabled), so comments, bogus comments, raw-text bodies (script/style/noscript/title/textarea), inert `<template>` contents and character references in attributes all follow the HTML5 algorithm instead of a hand-written scanner; the review's edge cases stay as regression tests; docs show the `pnpm --silent` form for JSON output and explain how the counter relates to Angular's rounded "Initial total".
- **Found while measuring:** the environment files and the playback diagnostic panel imported the whole `package.json` (`import packageJson from '@package'`), which esbuild cannot tree-shake, so `main.js` carried the complete file and the counter moved with every script or dependency edit. They now import `{ version }` only (eb662c485): `main.js` shrinks by **11,539 bytes** and the counter no longer depends on `package.json`. Jest's ESM loader exposes JSON only as a default export, so the two web Jest configs map `@package` to a stub that serves the real file's fields as named exports. Release note: `.changes/web-version-only-from-package-json.md` (`type: perf`).

Production build after this PR measures **2,739,508 bytes** (11 files + `index.html`); Angular's "Initial total" is this minus `index.html` and `assets/app-config.js`. The baseline file and the CI check land in the follow-up PRs; C1 (lazy Angular date locales) then lowers it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 13:31:27 +02:00

270 lines
9.5 KiB
JavaScript

/**
* Measures the bytes a browser fetches before the Angular app can bootstrap:
* `index.html` itself plus every same-origin script, stylesheet and
* `modulepreload` chunk it references. This is the J1 ("launch to usable")
* counter `renderer.initialBytes` from docs/architecture/performance-journeys.md.
*
* The number is read from the built output, not estimated from source, so it
* is deterministic for a given build and can be ratcheted in CI.
*
* Usage:
* node tools/performance/measure-initial-bytes.mjs [--dist dist/apps/web]
* [--json] [--summary dist/performance/journey-summary.json]
*/
import { existsSync } from 'node:fs';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'parse5';
export const DEFAULT_DIST_DIR = 'dist/apps/web';
export const INITIAL_BYTES_COUNTER = 'renderer.initialBytes';
export const LAUNCH_JOURNEY = 'launch';
const EXTERNAL_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
/**
* A resource only counts when the browser fetches it on the initial path from
* the same origin. Manifest, icons and external URLs are not part of the
* payload the ratchet guards: icons load lazily, and external hosts are
* outside the build's control.
*/
function classify(tag, attributes) {
if (tag === 'script') {
return attributes.src ? { kind: 'script', url: attributes.src } : null;
}
const rel = (attributes.rel ?? '').toLowerCase().split(/\s+/);
if (!attributes.href) return null;
if (rel.includes('stylesheet')) {
return { kind: 'stylesheet', url: attributes.href };
}
if (rel.includes('modulepreload')) {
return { kind: 'modulepreload', url: attributes.href };
}
return null;
}
/**
* Yields the `script` and `link` elements the browser actually creates, in
* document order, by parsing `index.html` with the same HTML5 algorithm the
* browser uses (parse5, scripting enabled): comments, doctype and bogus
* comments are not elements, script/style/noscript/textarea/title bodies are
* text, `<template>` contents are inert and live in a separate fragment,
* attribute values arrive with character references decoded, and an SVG
* `<script>` inside inline SVG is in another namespace (it uses `href`, and
* the HTML parser does not fetch it), while HTML inside `<foreignObject>`
* is back in the HTML namespace.
*/
export function scanLiveTags(html) {
const tags = [];
const visit = (node) => {
for (const child of node.childNodes ?? []) {
if (
child.namespaceURI === HTML_NAMESPACE &&
(child.nodeName === 'script' || child.nodeName === 'link')
) {
tags.push({
tag: child.nodeName,
attributes: Object.fromEntries(
child.attrs.map((attribute) => [
attribute.name.toLowerCase(),
attribute.value,
])
),
});
}
visit(child);
}
};
visit(parse(html, { scriptingEnabled: true }));
return tags;
}
/**
* Lists the same-origin resources `index.html` puts on the initial path, in
* document order. Duplicates are collapsed by request URL, not by file: the
* browser fetches `chunk.js?v=1` and `chunk.js?v=2` separately, so both count,
* while a fragment never reaches the server and is ignored. `path` is the
* file on disk the URL maps to.
*/
export function extractInitialResources(html) {
const seen = new Set();
const resources = [];
for (const { tag, attributes } of scanLiveTags(html)) {
const resource = classify(tag, attributes);
if (!resource || EXTERNAL_URL.test(resource.url)) continue;
const url = resource.url.replace(/#.*$/, '').replace(/^\.?\//, '');
const file = url.replace(/\?.*$/, '');
if (!file || seen.has(url)) continue;
seen.add(url);
resources.push({ path: file, url, kind: resource.kind });
}
return resources;
}
async function sizeOf(filePath) {
const stats = await stat(filePath);
return stats.size;
}
/**
* Reads the built output and returns the per-file breakdown plus the counter
* value. Missing files are an error rather than zero bytes: a broken reference
* would otherwise look like a bundle-size win.
*/
export async function measureInitialBytes({ distDir, readSize = sizeOf }) {
const indexPath = path.join(distDir, 'index.html');
if (!existsSync(indexPath)) {
throw new Error(
`No index.html under ${distDir}. Build the web app first (pnpm nx build web).`
);
}
const html = await readFile(indexPath, 'utf8');
const indexBytes = await readSize(indexPath);
const resources = [];
const missing = [];
for (const resource of extractInitialResources(html)) {
const absolute = path.join(distDir, resource.path);
if (!existsSync(absolute)) {
missing.push(resource.path);
continue;
}
resources.push({ ...resource, bytes: await readSize(absolute) });
}
if (missing.length > 0) {
throw new Error(
`index.html references files that are not in ${distDir}: ${missing.join(', ')}`
);
}
const totals = {
indexHtml: indexBytes,
script: 0,
stylesheet: 0,
modulepreload: 0,
};
for (const resource of resources) totals[resource.kind] += resource.bytes;
const initialBytes =
totals.indexHtml +
totals.script +
totals.stylesheet +
totals.modulepreload;
return {
distDir,
indexHtml: { path: 'index.html', bytes: indexBytes },
resources,
totals: { ...totals, initialBytes },
counters: { [INITIAL_BYTES_COUNTER]: initialBytes },
};
}
/** The journey summary shape consumed by check-journey-ratchet.mjs. */
export function toJourneySummary(
measurement,
{ measuredAt = new Date() } = {}
) {
return {
version: 1,
measuredAt: measuredAt.toISOString(),
journeys: {
[LAUNCH_JOURNEY]: {
counters: { ...measurement.counters },
},
},
};
}
function formatBytes(bytes) {
return bytes.toLocaleString('en-US');
}
export function formatReport(measurement) {
const rows = [...measurement.resources].sort((a, b) => b.bytes - a.bytes);
const width = Math.max(
...rows.map((row) => row.url.length),
'index.html'.length
);
const lines = [
`Initial payload of ${measurement.distDir}`,
'',
`${'index.html'.padEnd(width)} html ${formatBytes(measurement.indexHtml.bytes).padStart(11)}`,
...rows.map(
(row) =>
`${row.url.padEnd(width)} ${row.kind.padEnd(13)} ${formatBytes(row.bytes).padStart(11)}`
),
'',
`scripts ${formatBytes(measurement.totals.script).padStart(11)}`,
`stylesheets ${formatBytes(measurement.totals.stylesheet).padStart(11)}`,
`modulepreload ${formatBytes(measurement.totals.modulepreload).padStart(11)}`,
`${INITIAL_BYTES_COUNTER} = ${formatBytes(measurement.totals.initialBytes)} bytes (${measurement.resources.length} files + index.html)`,
];
return lines.join('\n');
}
export function parseArgs(argv) {
const options = { distDir: DEFAULT_DIST_DIR, json: false, summary: null };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--') continue;
if (argument === '--json') {
options.json = true;
} else if (argument === '--dist') {
options.distDir = argv[++index];
} else if (argument.startsWith('--dist=')) {
options.distDir = argument.slice('--dist='.length);
} else if (argument === '--summary') {
options.summary = argv[++index];
} else if (argument.startsWith('--summary=')) {
options.summary = argument.slice('--summary='.length);
} else {
throw new Error(`Unknown argument: ${argument}`);
}
if (options.distDir === undefined || options.summary === undefined) {
throw new Error(`Missing value for ${argument}`);
}
}
return options;
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) ===
path.resolve(fileURLToPath(import.meta.url));
if (isMain) {
try {
const options = parseArgs(process.argv.slice(2));
const measurement = await measureInitialBytes({
distDir: path.resolve(options.distDir),
});
measurement.distDir =
path.relative(process.cwd(), measurement.distDir) || '.';
if (options.summary) {
const summaryPath = path.resolve(options.summary);
await mkdir(path.dirname(summaryPath), { recursive: true });
await writeFile(
summaryPath,
`${JSON.stringify(toJourneySummary(measurement), null, 4)}\n`
);
}
console.log(
options.json
? JSON.stringify(measurement, null, 4)
: formatReport(measurement)
);
if (options.summary && !options.json) {
console.log(`\nJourney summary written to ${options.summary}`);
}
} catch (error) {
console.error(`measure-initial-bytes: ${error.message}`);
process.exitCode = 1;
}
}