fix: close performance evidence and budget gates
This commit is contained in:
@@ -1,8 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
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';
|
||||
import sharp from 'sharp';
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
import { probeVideo } from './lib/video-frames.mjs';
|
||||
import {
|
||||
classifyArtifact,
|
||||
evaluateBudgetTotals,
|
||||
evaluateMediaAssetBudget,
|
||||
evaluateRuntimeBudgetEvidence,
|
||||
} 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.');
|
||||
@@ -21,18 +30,74 @@ const listFiles = async (directory) => {
|
||||
|
||||
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);
|
||||
: brotliCompressSync(bytes, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 } }).byteLength);
|
||||
|
||||
const readJson = async (path, label) => {
|
||||
try { return JSON.parse(await readFile(path, 'utf8')); } catch { throw new Error(`${label} must be valid JSON.`); }
|
||||
};
|
||||
|
||||
const manifest = await readJson(resolve('content/public/asset-manifest.json'), 'Public asset manifest');
|
||||
const content = await readJson(resolve('content/public/portfolio-content.json'), 'Portfolio content');
|
||||
const runtimeEvidence = await readJson(resolve('config/performance-budget-evidence.json'), 'Runtime budget evidence');
|
||||
if (!Array.isArray(manifest?.assets)) throw new Error('Public asset manifest is invalid.');
|
||||
|
||||
const assetClassById = new Map();
|
||||
for (const project of [...(content.projects?.selected ?? []), ...(content.projects?.additional ?? [])]) {
|
||||
for (const media of project.media ?? []) {
|
||||
assetClassById.set(media.assetId, media.kind === 'chart' ? 'chart' : 'project-still');
|
||||
if (media.posterAssetId) assetClassById.set(media.posterAssetId, 'project-still');
|
||||
if (media.fallbackAssetId) assetClassById.set(media.fallbackAssetId, 'project-still');
|
||||
}
|
||||
for (const assetId of project.assetIds ?? []) if (!assetClassById.has(assetId)) assetClassById.set(assetId, 'project-still');
|
||||
}
|
||||
for (const group of content.toolkit ?? []) for (const technology of group.technologies ?? []) {
|
||||
if (technology.assetId) assetClassById.set(technology.assetId, 'project-still');
|
||||
}
|
||||
if (content.metadata?.socialPreviewAssetId) assetClassById.set(content.metadata.socialPreviewAssetId, 'project-still');
|
||||
|
||||
const manifestByPath = new Map(manifest.assets.map((asset) => [`assets/${asset.fileName}`, asset]));
|
||||
const records = [];
|
||||
const mediaViolations = [];
|
||||
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);
|
||||
const manifestAsset = manifestByPath.get(path);
|
||||
let bucket;
|
||||
if (manifestAsset !== undefined) {
|
||||
const digest = createHash('sha256').update(bytes).digest('hex');
|
||||
if (digest !== manifestAsset.sha256) throw new Error(`Published asset hash mismatch: ${path}`);
|
||||
const mediaType = manifestAsset.mediaType;
|
||||
const detected = await fileTypeFromBuffer(bytes);
|
||||
const detectedMediaType = detected?.mime ?? (/^\s*<svg[\s>]/u.test(bytes.toString('utf8', 0, Math.min(bytes.length, 256))) ? 'image/svg+xml' : null);
|
||||
if (detectedMediaType !== mediaType) throw new Error(`Manifest media type disagrees with deterministic probing: ${manifestAsset.id}`);
|
||||
let probe;
|
||||
if (mediaType?.startsWith('video/')) {
|
||||
const video = await probeVideo(absolute, { allowAudio: false });
|
||||
probe = { mediaType, width: video.width, height: video.height, durationMs: Math.round(video.duration * 1_000) };
|
||||
bucket = 'project-video';
|
||||
} else if (mediaType?.startsWith('image/')) {
|
||||
const image = await sharp(absolute, { failOn: 'error', limitInputPixels: 50_000_000 }).metadata();
|
||||
probe = { mediaType, width: image.width, height: image.height, durationMs: null };
|
||||
bucket = assetClassById.get(manifestAsset.id) === 'chart' ? 'chart' : 'project-still';
|
||||
} else {
|
||||
throw new Error(`Manifest asset has unsupported media type: ${manifestAsset.id}`);
|
||||
}
|
||||
mediaViolations.push(...evaluateMediaAssetBudget(
|
||||
manifestAsset,
|
||||
probe,
|
||||
compressedSize(path, bytes),
|
||||
bucket === 'chart' ? 'chart' : 'project-still',
|
||||
).violations);
|
||||
} else {
|
||||
bucket = classifyArtifact(path, source);
|
||||
if (['project-video', 'project-still', 'chart'].includes(bucket)) {
|
||||
throw new Error(`Project media must be present in the public asset manifest: ${path}`);
|
||||
}
|
||||
}
|
||||
records.push({ path, bucket, bytes: bytes.byteLength, transferBytes: compressedSize(path, bytes) });
|
||||
}
|
||||
for (const path of manifestByPath.keys()) if (!records.some((record) => record.path === path)) throw new Error(`Manifest asset is missing from the build: ${path}`);
|
||||
|
||||
const sum = (bucket) => records.filter((record) => record.bucket === bucket).reduce((total, record) => total + record.transferBytes, 0);
|
||||
const totals = {
|
||||
@@ -42,25 +107,10 @@ const totals = {
|
||||
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'),
|
||||
highTierExtras: sum('high-tier-extras'),
|
||||
deferredNonVideo: sum('first-live-scene') + sum('high-tier-extras') + 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.');
|
||||
@@ -68,13 +118,15 @@ 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 runtime = evaluateRuntimeBudgetEvidence(runtimeEvidence);
|
||||
const violations = [...evaluateBudgetTotals(totals).violations, ...mediaViolations, ...runtime.violations];
|
||||
const report = {
|
||||
schema: 'portfolio-bundle-budget/v1',
|
||||
compression: 'brotli-quality-11-or-raw-already-compressed',
|
||||
totals,
|
||||
runtime: { peakRequests: runtimeEvidence.peakRequests, tiers: runtime.tiers },
|
||||
records: records.sort((left, right) => left.path.localeCompare(right.path)),
|
||||
violations: result.violations,
|
||||
violations,
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
if (result.violations.length > 0) process.exitCode = 1;
|
||||
if (violations.length > 0) process.exitCode = 1;
|
||||
|
||||
Reference in New Issue
Block a user