perf: enforce journey budgets and reference traces

This commit is contained in:
2026-09-02 07:48:02 +03:00
parent 8089d061ec
commit f4cf9fc77e
25 changed files with 1432 additions and 23 deletions

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env node
import { brotliCompressSync, constants as zlibConstants } from 'node:zlib';
import { readFile, readdir, stat } from 'node:fs/promises';
import { relative, resolve, sep } from 'node:path';
import { classifyArtifact, evaluateBudgetTotals, PERFORMANCE_BUDGETS } from './lib/bundle-budgets.mjs';
const root = resolve(process.argv[2] ?? 'dist');
if (!(await stat(root)).isDirectory()) throw new Error('Bundle budget target must be a directory.');
const listFiles = async (directory) => {
const files = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = resolve(directory, entry.name);
if (entry.isSymbolicLink()) throw new Error('Bundle budget input cannot contain symbolic links.');
if (entry.isDirectory()) files.push(...await listFiles(path));
else if (entry.isFile()) files.push(path);
else throw new Error('Bundle budget input contains an unsupported entry.');
}
return files;
};
const compressedSize = (path, bytes) => (/\.(?:avif|br|gif|jpe?g|mp4|png|webm|webp|woff2?)$/iu.test(path)
? bytes.byteLength
: brotliCompressSync(bytes, {
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 },
}).byteLength);
const records = [];
for (const absolute of await listFiles(root)) {
const path = relative(root, absolute).split(sep).join('/');
const bytes = await readFile(absolute);
const source = /\.(?:css|html|js|json|txt|xml)$/u.test(path) ? bytes.toString('utf8') : '';
const bucket = classifyArtifact(path, source);
records.push({ path, bucket, bytes: bytes.byteLength, transferBytes: compressedSize(path, bytes) });
}
const sum = (bucket) => records.filter((record) => record.bucket === bucket).reduce((total, record) => total + record.transferBytes, 0);
const totals = {
initialHtmlCriticalCss: sum('initial-html') + sum('critical-css'),
initialApplicationJs: sum('initial-application-js'),
heroPoster: sum('hero-poster'),
initialFonts: sum('initial-fonts'),
firstLiveScene: sum('first-live-scene'),
deferredProjectVisuals: sum('project-still') + sum('chart'),
deferredNonVideo: sum('first-live-scene') + sum('project-still') + sum('chart'),
};
for (const record of records.filter((item) => item.bucket === 'project-video')) {
if (record.transferBytes > PERFORMANCE_BUDGETS.projectVideo) {
throw new Error(`Project video exceeds its transfer budget: ${record.path}`);
}
}
for (const record of records.filter((item) => item.bucket === 'project-still')) {
if (record.transferBytes > PERFORMANCE_BUDGETS.projectStill) {
throw new Error(`Project still exceeds its transfer budget: ${record.path}`);
}
}
for (const record of records.filter((item) => item.bucket === 'chart')) {
if (record.transferBytes > PERFORMANCE_BUDGETS.chart) {
throw new Error(`Chart exceeds its transfer budget: ${record.path}`);
}
}
const entry = records.find((record) => record.bucket === 'initial-application-js');
const scene = records.find((record) => record.bucket === 'first-live-scene');
if (entry === undefined || scene === undefined) throw new Error('Both initial application and deferred live-scene chunks are required.');
const entrySource = await readFile(resolve(root, entry.path), 'utf8');
if (!/import\([^)]*environment-/u.test(entrySource)) throw new Error('The live scene is not separated behind a dynamic import.');
if (/WebGLRenderer|WebGLProgram|ShaderMaterial/u.test(entrySource)) throw new Error('Three.js/live-scene code leaked into the reduced-motion initial chunk.');
const result = evaluateBudgetTotals(totals);
const report = {
schema: 'portfolio-bundle-budget/v1',
compression: 'brotli-quality-11-or-raw-already-compressed',
totals,
records: records.sort((left, right) => left.path.localeCompare(right.path)),
violations: result.violations,
};
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
if (result.violations.length > 0) process.exitCode = 1;