81 lines
3.8 KiB
JavaScript
81 lines
3.8 KiB
JavaScript
#!/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;
|