mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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>
This commit is contained in:
1 parent
3ed612ba65
commit
8bc877b625
18 files changed
+777
-10
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: perf
|
||||
area: web
|
||||
---
|
||||
|
||||
The app no longer ships its whole `package.json` inside the startup bundle,
|
||||
only its version number, which trims about 11 KB from every launch.
|
||||
@@ -376,6 +376,17 @@ To run only the Angular app without Electron, use:
|
||||
$ pnpm run serve:frontend
|
||||
```
|
||||
|
||||
To see how many bytes the built web app puts on the initial load path (the
|
||||
number the performance ratchet guards), build it and run the measurement:
|
||||
|
||||
```
|
||||
$ pnpm nx build web
|
||||
$ pnpm run perf:initial-bytes
|
||||
```
|
||||
|
||||
The contract behind that number is in
|
||||
[docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**IPTVnator doesn't provide any playlists or other digital content.**
|
||||
|
||||
@@ -39,6 +39,7 @@ export default {
|
||||
tslib: 'tslib/tslib.es6.js',
|
||||
'^iptv-playlist-parser$':
|
||||
'<rootDir>/src/test-stubs/iptv-playlist-parser.mjs',
|
||||
'^@package$': '<rootDir>/src/test-stubs/package.mjs',
|
||||
'^shaka-player$': '<rootDir>/src/test-stubs/shaka-player.js',
|
||||
'^video.js$': '<rootDir>/src/test-stubs/video-js.js',
|
||||
'^rxjs': '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
// `ng build --env=prod` then `index.prod.ts` will be used instead.
|
||||
// The list of which env maps to which file can be found in `.angular-cli.json`.
|
||||
|
||||
import packageJson from '@package';
|
||||
import { version as appVersion } from '@package';
|
||||
|
||||
export const AppConfig = {
|
||||
production: false,
|
||||
environment: 'DEV',
|
||||
version: packageJson.version,
|
||||
version: appVersion,
|
||||
BACKEND_URL: 'http://localhost:3000',
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import packageJson from '@package';
|
||||
import { version as appVersion } from '@package';
|
||||
|
||||
export const AppConfig = {
|
||||
production: true,
|
||||
environment: 'PROD',
|
||||
version: packageJson.version,
|
||||
version: appVersion,
|
||||
BACKEND_URL: 'https://iptvnator-playlist-parser-api.vercel.app',
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import packageJson from '@package';
|
||||
import { version as appVersion } from '@package';
|
||||
|
||||
export const AppConfig = {
|
||||
production: false,
|
||||
environment: 'LOCAL',
|
||||
version: packageJson.version,
|
||||
version: appVersion,
|
||||
BACKEND_URL: 'http://localhost:3000',
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import packageJson from '@package';
|
||||
import { version as appVersion } from '@package';
|
||||
|
||||
export const AppConfig = {
|
||||
production: false,
|
||||
environment: 'WEB',
|
||||
version: packageJson.version,
|
||||
version: appVersion,
|
||||
BACKEND_URL: 'http://localhost:3333',
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
// Jest's ESM loader exposes a JSON module only as a default export, while the
|
||||
// app imports `{ version }` from '@package' so esbuild can tree-shake the rest
|
||||
// of package.json out of the bundle. This stub serves the real file's fields
|
||||
// as named exports for tests.
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(new URL('../../../../package.json', import.meta.url), 'utf8')
|
||||
);
|
||||
|
||||
export const version = packageJson.version;
|
||||
export default packageJson;
|
||||
@@ -0,0 +1,68 @@
|
||||
# Performance journeys and the CI ratchet
|
||||
|
||||
IPTVnator measures performance through a small set of everyday user journeys.
|
||||
Each journey has deterministic counters that are asserted exactly, and
|
||||
wall-clock timings that are recorded as evidence. Counters are meant to be
|
||||
ratcheted in CI: a committed baseline that may only be lowered, and only with
|
||||
the measured output as evidence. This document is the contract for that loop;
|
||||
`tools/performance/` holds the scripts. The measurement script lands first;
|
||||
the baseline file and the CI job follow in their own PRs (#1693, #1694), so
|
||||
until they merge the reported number is informational, not enforced.
|
||||
|
||||
## Journeys
|
||||
|
||||
| Journey | Start | End |
|
||||
| ---------------- | -------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| J1 `launch` | Electron process spawn | first playlist or portal card rendered on `/workspace`, inline splash removed |
|
||||
| J2 `open-source` | click on a portal card | live category list and first channel page painted |
|
||||
| J3 `playback` | click on a channel | HTML5 `playing` event |
|
||||
| J4 `search` | six-character query typed into global search | results list settled |
|
||||
|
||||
Only the J1 counter `renderer.initialBytes` is instrumented today. The other
|
||||
journeys and counters follow the plan in `.plans/` and are added one thread at
|
||||
a time; each thread names its journey and counter in the PR description.
|
||||
|
||||
## `renderer.initialBytes`
|
||||
|
||||
The bytes a browser fetches before Angular can bootstrap, read from the built
|
||||
`dist/apps/web/index.html`:
|
||||
|
||||
- `index.html` itself,
|
||||
- every same-origin `<script src>`, including `assets/app-config.js`,
|
||||
- every `<link rel="stylesheet">`,
|
||||
- every `<link rel="modulepreload">` chunk.
|
||||
|
||||
Manifest, icons, external URLs, commented-out tags and lazy chunks are not
|
||||
counted. A file that `index.html` references but the build did not emit is an
|
||||
error, never zero bytes. The value is raw (uncompressed) size, which is what the
|
||||
renderer parses. It is Angular's "Initial total" plus `index.html` and
|
||||
`assets/app-config.js` (about 4 KB together), so it sits slightly above the
|
||||
rounded figure the build prints; never copy that figure into a baseline, use
|
||||
the script's output. The bundle embeds only the app version from
|
||||
`package.json` (a named import, which esbuild tree-shakes), not the whole
|
||||
file, so editing scripts or dependencies does not move the counter.
|
||||
|
||||
```bash
|
||||
pnpm nx build web # production configuration
|
||||
pnpm run perf:initial-bytes # human-readable breakdown
|
||||
pnpm --silent run perf:initial-bytes -- --json # machine-readable; --silent keeps pnpm's headers out of stdout
|
||||
node tools/performance/measure-initial-bytes.mjs --summary dist/performance/journey-summary.json
|
||||
```
|
||||
|
||||
`--summary` writes the journey summary shape (`journeys.<journey>.counters`)
|
||||
that the ratchet checker consumes. `--dist <dir>` points the script at another
|
||||
build output, for example the `electron-performance` configuration.
|
||||
|
||||
The measurement script is `tools/performance/measure-initial-bytes.mjs`; its
|
||||
Node tests run with `pnpm nx test performance-tools` (Tier B in the coverage
|
||||
policy) and lint with `pnpm nx lint performance-tools`.
|
||||
|
||||
## Adding a counter
|
||||
|
||||
1. Produce the value from the built output or from a deterministic probe, not
|
||||
from source heuristics. Missing inputs must fail the measurement.
|
||||
2. Emit it under `journeys.<journey>.counters.<name>` in the summary JSON.
|
||||
3. Cover the extraction and the failure modes with `node --test` and register
|
||||
the test file in `tools/performance/project.json`.
|
||||
4. Validate the counter before it becomes a guardrail: one PR must show that
|
||||
lowering it moved wall-clock in the same journey.
|
||||
@@ -153,6 +153,19 @@ Identical English fallback values are reported as warnings by default; use
|
||||
`node tools/i18n/check-drift.mjs --fail-on-identical` for a stricter translation
|
||||
audit.
|
||||
|
||||
## Performance
|
||||
|
||||
```bash
|
||||
pnpm nx build web
|
||||
pnpm run perf:initial-bytes
|
||||
pnpm nx test performance-tools
|
||||
```
|
||||
|
||||
`perf:initial-bytes` reads the built `dist/apps/web/index.html` and sums the
|
||||
bytes on the initial path (the J1 counter `renderer.initialBytes`). The
|
||||
contract, what counts and how to add a counter are in the
|
||||
[performance journeys](performance-journeys.md) document.
|
||||
|
||||
## Logging
|
||||
|
||||
Runtime playback and EPG debug logs should use the existing logger or trace
|
||||
|
||||
@@ -14,6 +14,7 @@ are not prerequisites for reading repository contracts.
|
||||
| Bootstrap, project placement, dependencies, aliases and lint configuration; root Nx config and project-local project.json files | [Nx boundaries](../architecture/nx-workspace-boundaries.md), [security overrides](../architecture/dependency-security-overrides.md) | [Nx architecture](../../.codex/skills/iptvnator-nx-architecture/SKILL.md) |
|
||||
| Angular conventions; docs and skills maintenance | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below |
|
||||
| Unit, E2E, lint and coverage; `tools/coverage` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
|
||||
| Performance journeys, counters and the CI ratchet; `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly |
|
||||
| Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation |
|
||||
| Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) |
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export default {
|
||||
tslib: 'tslib/tslib.es6.js',
|
||||
'^iptv-playlist-parser$':
|
||||
'<rootDir>/apps/web/src/test-stubs/iptv-playlist-parser.mjs',
|
||||
'^@package$': '<rootDir>/apps/web/src/test-stubs/package.mjs',
|
||||
'^shaka-player$': '<rootDir>/apps/web/src/test-stubs/shaka-player.js',
|
||||
'^rxjs': '<rootDir>/node_modules/rxjs/dist/bundles/rxjs.umd.js',
|
||||
'^uuid$': '<rootDir>/node_modules/uuid/wrapper.mjs',
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import packageJson from '@package';
|
||||
import { version as appVersion } from '@package';
|
||||
import { createDiagnosticReport } from './playback-diagnostic-report.util';
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import {
|
||||
@@ -91,7 +91,7 @@ export class PlaybackDiagnosticPanelComponent {
|
||||
readonly diagnosticReport = computed(() =>
|
||||
createDiagnosticReport(
|
||||
this.diagnostic(),
|
||||
packageJson.version,
|
||||
appVersion,
|
||||
this.document.defaultView?.navigator.userAgent ?? ''
|
||||
)
|
||||
);
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
"serve:website": "nx serve website",
|
||||
"build:website": "nx build website",
|
||||
"i18n:check": "node tools/i18n/check-drift.mjs",
|
||||
"perf:initial-bytes": "node tools/performance/measure-initial-bytes.mjs",
|
||||
"perf:tools:test": "node --test tools/performance/measure-initial-bytes.test.mjs",
|
||||
"agents:validate": "node tools/skills/validate-agent-guidance.mjs",
|
||||
"skills:validate": "node tools/skills/validate-repository-skills.mjs",
|
||||
"release:artwork:dry-run": "tsx --tsconfig tsconfig.base.json tools/release/generate-marketing-artwork.ts --dry-run",
|
||||
|
||||
@@ -317,6 +317,12 @@
|
||||
"validationCommand": "pnpm nx test eslint-tools",
|
||||
"reason": "Node tests assert the committed max-lines baseline still matches what the generator produces; the scripts are lint tooling, not shipped source, and percentage coverage over a generated list would not mean anything."
|
||||
},
|
||||
{
|
||||
"name": "performance-tools",
|
||||
"root": "tools/performance",
|
||||
"validationCommand": "pnpm nx test performance-tools",
|
||||
"reason": "Node tests validate the initial-bytes measurement over synthetic build output; the scripts are performance tooling, not shipped source."
|
||||
},
|
||||
{
|
||||
"name": "shared-marketing-fixtures",
|
||||
"root": "libs/shared/marketing-fixtures",
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { after, before, test } from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_DIST_DIR,
|
||||
INITIAL_BYTES_COUNTER,
|
||||
extractInitialResources,
|
||||
formatReport,
|
||||
measureInitialBytes,
|
||||
parseArgs,
|
||||
scanLiveTags,
|
||||
toJourneySummary,
|
||||
} from './measure-initial-bytes.mjs';
|
||||
|
||||
const scriptPath = fileURLToPath(
|
||||
new URL('./measure-initial-bytes.mjs', import.meta.url)
|
||||
);
|
||||
|
||||
/** Mirrors the shape the Angular application builder emits for apps/web. */
|
||||
const BUILT_INDEX_HTML = `<!doctype html>
|
||||
<html><head>
|
||||
<meta charset="utf-8"/>
|
||||
<link rel="manifest" href="manifest.webmanifest"/>
|
||||
<link rel="apple-touch-icon" href="assets/icons/apple-touch-icon.png"/>
|
||||
<link rel="icon" type="image/x-icon" href="assets/icons/favicon.ico"/>
|
||||
<script src="assets/app-config.js" defer=""></script>
|
||||
<link rel="stylesheet" href="styles-VDU4SQ5F.css"></head>
|
||||
<body class="mat-app-background"><app-root></app-root>
|
||||
<link rel="modulepreload" href="chunk-B6uziQ1i.js"><link rel="modulepreload" href="chunk-Cn2Agfvf.js"><script src="polyfills-EBB6HFCX.js" type="module"></script><script src="main-EI6PCDGR.js" type="module"></script></body></html>`;
|
||||
|
||||
const BUILT_FILES = {
|
||||
'assets/app-config.js': 65,
|
||||
'styles-VDU4SQ5F.css': 311539,
|
||||
'chunk-B6uziQ1i.js': 1566,
|
||||
'chunk-Cn2Agfvf.js': 529624,
|
||||
'polyfills-EBB6HFCX.js': 35876,
|
||||
'main-EI6PCDGR.js': 1131437,
|
||||
};
|
||||
|
||||
let workDir;
|
||||
|
||||
async function writeDist(
|
||||
name,
|
||||
{ indexHtml = BUILT_INDEX_HTML, files = BUILT_FILES } = {}
|
||||
) {
|
||||
const distDir = path.join(workDir, name);
|
||||
await mkdir(distDir, { recursive: true });
|
||||
if (indexHtml !== null) {
|
||||
await writeFile(path.join(distDir, 'index.html'), indexHtml);
|
||||
}
|
||||
for (const [file, bytes] of Object.entries(files)) {
|
||||
const target = path.join(distDir, file);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, 'x'.repeat(bytes));
|
||||
}
|
||||
return distDir;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
workDir = await mkdtemp(path.join(os.tmpdir(), 'measure-initial-bytes-'));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('extracts scripts, stylesheets and modulepreload chunks in document order', () => {
|
||||
assert.deepEqual(
|
||||
extractInitialResources(BUILT_INDEX_HTML).map(({ path, kind }) => ({
|
||||
path,
|
||||
kind,
|
||||
})),
|
||||
[
|
||||
{ path: 'assets/app-config.js', kind: 'script' },
|
||||
{ path: 'styles-VDU4SQ5F.css', kind: 'stylesheet' },
|
||||
{ path: 'chunk-B6uziQ1i.js', kind: 'modulepreload' },
|
||||
{ path: 'chunk-Cn2Agfvf.js', kind: 'modulepreload' },
|
||||
{ path: 'polyfills-EBB6HFCX.js', kind: 'script' },
|
||||
{ path: 'main-EI6PCDGR.js', kind: 'script' },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores manifest, icon and external references', () => {
|
||||
const html = `
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" href="favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link rel="stylesheet" href="https://cdn.example.com/theme.css">
|
||||
<script src="//cdn.example.com/analytics.js"></script>
|
||||
<script src="data:text/javascript,1"></script>
|
||||
<script>inline()</script>
|
||||
<script src="main.js" type="module"></script>`;
|
||||
assert.deepEqual(extractInitialResources(html), [
|
||||
{ path: 'main.js', url: 'main.js', kind: 'script' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('deduplicates by request URL, ignores fragments and normalizes relative URLs', () => {
|
||||
const html = `
|
||||
<LINK REL="modulepreload" HREF='./chunk-a.js'>
|
||||
<link rel="modulepreload" href="chunk-a.js">
|
||||
<link rel="modulepreload" href="chunk-a.js?v=2">
|
||||
<link rel="modulepreload" href="/chunk-b.js#hash">
|
||||
<link rel="modulepreload" href="chunk-b.js#other">
|
||||
<script src=main.js></script>
|
||||
<script src="main.js"></script>`;
|
||||
assert.deepEqual(extractInitialResources(html), [
|
||||
{ path: 'chunk-a.js', url: 'chunk-a.js', kind: 'modulepreload' },
|
||||
{ path: 'chunk-a.js', url: 'chunk-a.js?v=2', kind: 'modulepreload' },
|
||||
{ path: 'chunk-b.js', url: 'chunk-b.js', kind: 'modulepreload' },
|
||||
{ path: 'main.js', url: 'main.js', kind: 'script' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('ignores commented-out tags and tag-like text inside inline scripts and styles', () => {
|
||||
const html = `
|
||||
<!-- <script src="old.js"></script> -->
|
||||
<!--
|
||||
<link rel="stylesheet" href="legacy.css">
|
||||
-->
|
||||
<script>const markup = '<script src="fake.js"><\\/script><link rel="modulepreload" href="fake-chunk.js">';</script>
|
||||
<style>/* <link rel="stylesheet" href="fake.css"> */ body { color: red; }</style>
|
||||
<script src="assets/app-config.js" defer></script>
|
||||
<script src="main.js" type="module"></script>`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(html).map((resource) => resource.url),
|
||||
['assets/app-config.js', 'main.js']
|
||||
);
|
||||
assert.deepEqual(
|
||||
scanLiveTags('<script>1 < 2</script><LINK rel=x>').map((t) => t.tag),
|
||||
['script', 'link']
|
||||
);
|
||||
// '<!' that is not '<!--' opens a bogus comment up to the next '>', so
|
||||
// the script after it is live, exactly as the HTML tokenizer sees it.
|
||||
assert.deepEqual(
|
||||
extractInitialResources(
|
||||
'<!doctype html><!<!-- a -->-- <script src="x.js"></script> -->'
|
||||
).map((resource) => resource.url),
|
||||
['x.js']
|
||||
);
|
||||
});
|
||||
|
||||
test('a comment opener inside a script body does not swallow later live tags', () => {
|
||||
const html = `<script>const x='<!--';</script><script src="main.js"></script><!-- real --><link rel="modulepreload" href="chunk.js">`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(html).map((resource) => resource.url),
|
||||
['main.js', 'chunk.js']
|
||||
);
|
||||
const reverse = `<!-- <script>x</script> --><style>a::before{content:'<!--'}</style><script src="live.js"></script>`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(reverse).map((resource) => resource.url),
|
||||
['live.js']
|
||||
);
|
||||
// Unterminated raw text swallows the rest, as it does in a browser.
|
||||
assert.deepEqual(
|
||||
extractInitialResources('<script>x<script src="a.js"></script'),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test('raw text ends only at an exact end tag, and noscript/title bodies are text', () => {
|
||||
const lookalike = `<script>const a='</scriptlet>', b='<script src="fake.js">';</script><script src="real.js"></script>`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(lookalike).map((resource) => resource.url),
|
||||
['real.js']
|
||||
);
|
||||
const fallback = `<noscript><script src="fallback.js"></script><link rel="stylesheet" href="noscript.css"></noscript><title><script src="t.js"></script></title><textarea><link rel="modulepreload" href="ta.js"></textarea><script src="app.js"></script>`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(fallback).map((resource) => resource.url),
|
||||
['app.js']
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractInitialResources(
|
||||
'<script>x</script\t><script src="y.js"></script >'
|
||||
).map((r) => r.url),
|
||||
['y.js']
|
||||
);
|
||||
});
|
||||
|
||||
test('template contents are inert and character references are decoded', () => {
|
||||
const html = `<template><script src="fallback.js"></script><link rel="stylesheet" href="t.css"></template><script src="chunk.js?a=1&b=2"></script><link rel="modulepreload" href="chunk.js?a=1&b=2"><script src="main.js"></script>`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(html).map((resource) => resource.url),
|
||||
['chunk.js?a=1&b=2', 'main.js']
|
||||
);
|
||||
});
|
||||
|
||||
test('SVG script elements are not HTML scripts, HTML inside foreignObject is', () => {
|
||||
const html = `<svg><script src="icon.js"></script><script href="icon2.js"></script><foreignObject><script src="html-in-svg.js"></script></foreignObject></svg><script src="main.js"></script>`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(html).map((resource) => resource.url),
|
||||
['html-in-svg.js', 'main.js']
|
||||
);
|
||||
});
|
||||
|
||||
test('counts a file once per distinct request URL', async () => {
|
||||
const distDir = await writeDist('cache-busted', {
|
||||
indexHtml: `<link rel="modulepreload" href="chunk-a.js"><link rel="modulepreload" href="chunk-a.js?v=2">`,
|
||||
files: { 'chunk-a.js': 100 },
|
||||
});
|
||||
const measurement = await measureInitialBytes({ distDir });
|
||||
assert.equal(measurement.resources.length, 2);
|
||||
assert.equal(measurement.totals.modulepreload, 200);
|
||||
});
|
||||
|
||||
test('sums index.html and every referenced file into the counter', async () => {
|
||||
const distDir = await writeDist('built');
|
||||
const measurement = await measureInitialBytes({ distDir });
|
||||
|
||||
const indexBytes = Buffer.byteLength(BUILT_INDEX_HTML);
|
||||
const script = 65 + 35876 + 1131437;
|
||||
const stylesheet = 311539;
|
||||
const modulepreload = 1566 + 529624;
|
||||
|
||||
assert.deepEqual(measurement.indexHtml, {
|
||||
path: 'index.html',
|
||||
bytes: indexBytes,
|
||||
});
|
||||
assert.equal(measurement.resources.length, 6);
|
||||
assert.deepEqual(measurement.totals, {
|
||||
indexHtml: indexBytes,
|
||||
script,
|
||||
stylesheet,
|
||||
modulepreload,
|
||||
initialBytes: indexBytes + script + stylesheet + modulepreload,
|
||||
});
|
||||
assert.deepEqual(measurement.counters, {
|
||||
[INITIAL_BYTES_COUNTER]:
|
||||
indexBytes + script + stylesheet + modulepreload,
|
||||
});
|
||||
});
|
||||
|
||||
test('fails when index.html is missing instead of reporting zero bytes', async () => {
|
||||
const distDir = await writeDist('no-index', { indexHtml: null, files: {} });
|
||||
await assert.rejects(
|
||||
measureInitialBytes({ distDir }),
|
||||
/No index\.html under .*no-index.*pnpm nx build web/
|
||||
);
|
||||
});
|
||||
|
||||
test('fails and names every referenced file that is missing from the build', async () => {
|
||||
const dropped = ['chunk-Cn2Agfvf.js', 'main-EI6PCDGR.js'];
|
||||
const files = Object.fromEntries(
|
||||
Object.entries(BUILT_FILES).filter(([file]) => !dropped.includes(file))
|
||||
);
|
||||
const distDir = await writeDist('missing-chunk', { files });
|
||||
await assert.rejects(
|
||||
measureInitialBytes({ distDir }),
|
||||
/not in .*missing-chunk: chunk-Cn2Agfvf\.js, main-EI6PCDGR\.js/
|
||||
);
|
||||
});
|
||||
|
||||
test('journey summary carries the counter under the launch journey', async () => {
|
||||
const distDir = await writeDist('summary');
|
||||
const measurement = await measureInitialBytes({ distDir });
|
||||
const summary = toJourneySummary(measurement, {
|
||||
measuredAt: new Date('2026-09-26T00:00:00.000Z'),
|
||||
});
|
||||
assert.deepEqual(summary, {
|
||||
version: 1,
|
||||
measuredAt: '2026-09-26T00:00:00.000Z',
|
||||
journeys: {
|
||||
launch: {
|
||||
counters: {
|
||||
[INITIAL_BYTES_COUNTER]: measurement.totals.initialBytes,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('report lists the largest files first and ends with the counter', async () => {
|
||||
const distDir = await writeDist('report');
|
||||
const report = formatReport(await measureInitialBytes({ distDir }));
|
||||
const lines = report.split('\n');
|
||||
const mainLine = lines.findIndex((line) =>
|
||||
line.startsWith('main-EI6PCDGR.js')
|
||||
);
|
||||
const configLine = lines.findIndex((line) =>
|
||||
line.startsWith('assets/app-config.js')
|
||||
);
|
||||
assert.ok(mainLine > 0 && mainLine < configLine);
|
||||
assert.match(
|
||||
lines.at(-1),
|
||||
/^renderer\.initialBytes = [\d,]+ bytes \(6 files \+ index\.html\)$/
|
||||
);
|
||||
});
|
||||
|
||||
test('parses CLI arguments and rejects unknown ones', () => {
|
||||
assert.deepEqual(parseArgs([]), {
|
||||
distDir: DEFAULT_DIST_DIR,
|
||||
json: false,
|
||||
summary: null,
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseArgs(['--', '--dist', 'out', '--json', '--summary=s.json']),
|
||||
{
|
||||
distDir: 'out',
|
||||
json: true,
|
||||
summary: 's.json',
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseArgs(['--dist=out/web', '--summary', 'dist/s.json']).distDir,
|
||||
'out/web'
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs(['--verbose']),
|
||||
/Unknown argument: --verbose/
|
||||
);
|
||||
assert.throws(() => parseArgs(['--dist']), /Missing value for --dist/);
|
||||
});
|
||||
|
||||
test('CLI writes the journey summary and exits 0 on a complete build', async () => {
|
||||
const distDir = await writeDist('cli');
|
||||
const summaryPath = path.join(workDir, 'out', 'journey-summary.json');
|
||||
const stdout = execFileSync(
|
||||
process.execPath,
|
||||
[scriptPath, '--dist', distDir, '--summary', summaryPath],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
assert.match(stdout, /renderer\.initialBytes = [\d,]+ bytes/);
|
||||
const summary = JSON.parse(await readFile(summaryPath, 'utf8'));
|
||||
assert.equal(
|
||||
summary.journeys.launch.counters[INITIAL_BYTES_COUNTER],
|
||||
Buffer.byteLength(BUILT_INDEX_HTML) +
|
||||
Object.values(BUILT_FILES).reduce((sum, bytes) => sum + bytes, 0)
|
||||
);
|
||||
});
|
||||
|
||||
test('CLI exits 1 with a readable message when the build is missing', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[scriptPath, '--dist', path.join(workDir, 'does-not-exist')],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /measure-initial-bytes: No index\.html under/);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"name": "performance-tools",
|
||||
"projectType": "library",
|
||||
"sourceRoot": "tools/performance",
|
||||
"tags": ["scope:tools", "domain:performance", "type:tool"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"{projectRoot}/*.mjs",
|
||||
"{projectRoot}/*.json",
|
||||
{ "externalDependencies": ["parse5"] }
|
||||
],
|
||||
"options": {
|
||||
"command": "node --test tools/performance/measure-initial-bytes.test.mjs",
|
||||
"cwd": "{workspaceRoot}"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"inputs": [
|
||||
"default",
|
||||
"{workspaceRoot}/eslint.config.mjs",
|
||||
"{workspaceRoot}/tools/eslint-rules/**/*",
|
||||
"{workspaceRoot}/tools/eslint/**/*"
|
||||
],
|
||||
"command": "eslint \"tools/performance/*.mjs\""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user