mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
Repeat the comment and inline-body removal until nothing changes, so the pieces around a removed comment cannot assemble into a tag that a single pass would then count (CodeQL: incomplete multi-character sanitization). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
272 lines
9.5 KiB
JavaScript
272 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';
|
|
|
|
export const DEFAULT_DIST_DIR = 'dist/apps/web';
|
|
export const INITIAL_BYTES_COUNTER = 'renderer.initialBytes';
|
|
export const LAUNCH_JOURNEY = 'launch';
|
|
|
|
const TAG_PATTERN = /<(script|link)\b([^>]*)>/gi;
|
|
const HTML_COMMENT = /<!--[\s\S]*?-->/g;
|
|
const INLINE_ELEMENT_BODY = /(<(script|style)\b[^>]*>)[\s\S]*?(<\/\2\s*>)/gi;
|
|
const ATTRIBUTE_PATTERN =
|
|
/([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
const EXTERNAL_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
|
|
function parseAttributes(raw) {
|
|
const attributes = {};
|
|
for (const match of raw.matchAll(ATTRIBUTE_PATTERN)) {
|
|
const [, name, doubleQuoted, singleQuoted, bare] = match;
|
|
attributes[name.toLowerCase()] =
|
|
doubleQuoted ?? singleQuoted ?? bare ?? '';
|
|
}
|
|
return attributes;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Removes the parts of the document the browser never treats as markup: HTML
|
|
* comments (a commented-out `<script>` is not a request) and the bodies of
|
|
* inline `<script>`/`<style>` elements (a string literal that looks like a tag
|
|
* is not one either). The opening tags themselves are kept so a `src` on an
|
|
* inline script element is still seen.
|
|
*/
|
|
export function stripInertHtml(html) {
|
|
// Repeat until nothing changes: a single pass can expose a new comment or
|
|
// element body assembled from the pieces around a removed one.
|
|
let previous;
|
|
let stripped = html;
|
|
do {
|
|
previous = stripped;
|
|
stripped = stripped
|
|
.replace(HTML_COMMENT, '')
|
|
.replace(INLINE_ELEMENT_BODY, '$1$3');
|
|
} while (stripped !== previous);
|
|
return stripped;
|
|
}
|
|
|
|
/**
|
|
* 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, rawAttributes] of stripInertHtml(html).matchAll(
|
|
TAG_PATTERN
|
|
)) {
|
|
const resource = classify(
|
|
tag.toLowerCase(),
|
|
parseAttributes(rawAttributes)
|
|
);
|
|
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;
|
|
}
|
|
}
|