diff --git a/.changes/web-version-only-from-package-json.md b/.changes/web-version-only-from-package-json.md new file mode 100644 index 000000000..3cecf7190 --- /dev/null +++ b/.changes/web-version-only-from-package-json.md @@ -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. diff --git a/README.md b/README.md index 7a9f69617..9e43bd5bc 100644 --- a/README.md +++ b/README.md @@ -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.** diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index 15277199e..6fb90a9a1 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -39,6 +39,7 @@ export default { tslib: 'tslib/tslib.es6.js', '^iptv-playlist-parser$': '/src/test-stubs/iptv-playlist-parser.mjs', + '^@package$': '/src/test-stubs/package.mjs', '^shaka-player$': '/src/test-stubs/shaka-player.js', '^video.js$': '/src/test-stubs/video-js.js', '^rxjs': '/../../node_modules/rxjs/dist/bundles/rxjs.umd.js', diff --git a/apps/web/src/environments/environment.dev.ts b/apps/web/src/environments/environment.dev.ts index f176f814a..24edf3e91 100644 --- a/apps/web/src/environments/environment.dev.ts +++ b/apps/web/src/environments/environment.dev.ts @@ -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', }; diff --git a/apps/web/src/environments/environment.prod.ts b/apps/web/src/environments/environment.prod.ts index 87f4284ef..f0e2a57d0 100644 --- a/apps/web/src/environments/environment.prod.ts +++ b/apps/web/src/environments/environment.prod.ts @@ -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', }; diff --git a/apps/web/src/environments/environment.ts b/apps/web/src/environments/environment.ts index 0790b2105..92988b7ba 100644 --- a/apps/web/src/environments/environment.ts +++ b/apps/web/src/environments/environment.ts @@ -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', }; diff --git a/apps/web/src/environments/environment.web.ts b/apps/web/src/environments/environment.web.ts index 688520218..6b85ff494 100644 --- a/apps/web/src/environments/environment.web.ts +++ b/apps/web/src/environments/environment.web.ts @@ -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', }; diff --git a/apps/web/src/test-stubs/package.mjs b/apps/web/src/test-stubs/package.mjs new file mode 100644 index 000000000..04f30e1f5 --- /dev/null +++ b/apps/web/src/test-stubs/package.mjs @@ -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; diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md new file mode 100644 index 000000000..e2352362b --- /dev/null +++ b/docs/architecture/performance-journeys.md @@ -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 ` + + +`; + +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 = ` + + + + + + + + `; + 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 = ` + + + + + + + `; + 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 = ` + + + + + + `; + assert.deepEqual( + extractInitialResources(html).map((resource) => resource.url), + ['assets/app-config.js', 'main.js'] + ); + assert.deepEqual( + scanLiveTags('').map((t) => t.tag), + ['script', 'link'] + ); + // '', so + // the script after it is live, exactly as the HTML tokenizer sees it. + assert.deepEqual( + extractInitialResources( + '-- -->' + ).map((resource) => resource.url), + ['x.js'] + ); +}); + +test('a comment opener inside a script body does not swallow later live tags', () => { + const html = ``; + assert.deepEqual( + extractInitialResources(html).map((resource) => resource.url), + ['main.js', 'chunk.js'] + ); + const reverse = ``; + 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('`; + assert.deepEqual( + extractInitialResources(lookalike).map((resource) => resource.url), + ['real.js'] + ); + const fallback = `<script src="t.js"></script>`; + assert.deepEqual( + extractInitialResources(fallback).map((resource) => resource.url), + ['app.js'] + ); + assert.deepEqual( + extractInitialResources( + '' + ).map((r) => r.url), + ['y.js'] + ); +}); + +test('template contents are inert and character references are decoded', () => { + const html = ``; + 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 = ``; + 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: ``, + 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/); +}); diff --git a/tools/performance/project.json b/tools/performance/project.json new file mode 100644 index 000000000..44afb96eb --- /dev/null +++ b/tools/performance/project.json @@ -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\"" + } + } +}