mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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>
270 lines
9.5 KiB
JavaScript
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;
|
|
}
|
|
}
|