merge: complete bundle graph accounting

This commit is contained in:
donghyeon-ka
2026-07-25 21:46:07 +09:00
3 changed files with 111 additions and 15 deletions
+22 -15
View File
@@ -1,6 +1,7 @@
import { readFile, writeFile } from "node:fs/promises";
import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.js";
import { classifyViteJavascript } from "./lib/classify-vite-bundle.mjs";
const report =
/** @type {{
@@ -10,7 +11,7 @@ const report =
JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8"))
);
const viteManifest =
/** @type {Record<string, { file: string, isEntry?: boolean }>} */ (
/** @type {Record<string, { file: string, isEntry?: boolean, imports?: string[] }>} */ (
JSON.parse(await readFile("dist/.vite/manifest.json", "utf8"))
);
const budgets =
@@ -21,24 +22,19 @@ const budgets =
const outputByPath = new Map(
report.outputs.map((output) => [output.path.replace(/^dist\//, ""), output]),
);
const initialFiles = new Set(
Object.values(viteManifest)
.filter((entry) => entry.isEntry)
.map((entry) => entry.file),
);
const lazyFiles = new Set(
Object.values(viteManifest)
.filter((entry) => !entry.isEntry && entry.file.endsWith(".js"))
.map((entry) => entry.file),
);
const initialJsGzipBytes = [...initialFiles].reduce(
const classification = classifyViteJavascript(viteManifest);
const initialJsGzipBytes = classification.initialFiles.reduce(
(total, file) => total + (outputByPath.get(file)?.gzipBytes ?? 0),
0,
);
const lazyChunks = [...lazyFiles].map((file) => ({
const lazyChunks = classification.lazyFiles.map((file) => ({
path: file,
gzipBytes: outputByPath.get(file)?.gzipBytes ?? 0,
}));
const missingOutputs = [
...classification.initialFiles,
...classification.lazyFiles,
].filter((file) => !outputByPath.has(file));
const measurements = { initialJsGzipBytes, lazyChunks };
const result = evaluateBundleBudget(measurements, budgets);
const fixtures = [
@@ -70,10 +66,16 @@ const fixtures = [
).passed,
},
];
const passed = result.passed && fixtures.every((fixture) => fixture.passed);
const passed =
result.passed &&
fixtures.every((fixture) => fixture.passed) &&
classification.missingImports.length === 0 &&
missingOutputs.length === 0;
const completedReport = {
...report,
measurements,
classification,
missingOutputs,
thresholds: budgets,
results: result,
fixtures,
@@ -85,7 +87,12 @@ await writeFile(
`${JSON.stringify(completedReport, null, 2)}\n`,
);
if (!passed) {
process.stderr.write("Bundle budget exceeded.\n");
process.stderr.write(
`Bundle budget or manifest integrity failed: ${[
...classification.missingImports,
...missingOutputs,
].join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write(
+49
View File
@@ -0,0 +1,49 @@
/**
* @typedef {{
* file: string,
* isEntry?: boolean,
* imports?: string[]
* }} ViteManifestEntry
*/
/**
* Static imports of an entry are part of initial JavaScript. Every remaining
* JavaScript output is governed by the lazy-chunk budget.
*
* @param {Record<string, ViteManifestEntry>} manifest
*/
export function classifyViteJavascript(manifest) {
const initialFiles = new Set();
const visitedKeys = new Set();
const pendingKeys = Object.entries(manifest)
.filter(([, entry]) => entry.isEntry)
.map(([key]) => key);
const missingImports = [];
while (pendingKeys.length > 0) {
const key = /** @type {string} */ (pendingKeys.pop());
if (visitedKeys.has(key)) continue;
visitedKeys.add(key);
const entry = manifest[key];
if (!entry) {
missingImports.push(key);
continue;
}
if (entry.file.endsWith(".js")) initialFiles.add(entry.file);
pendingKeys.push(...(entry.imports ?? []));
}
const allJavaScript = new Set(
Object.values(manifest)
.map((entry) => entry.file)
.filter((file) => file.endsWith(".js")),
);
const lazyFiles = [...allJavaScript].filter(
(file) => !initialFiles.has(file),
);
return Object.freeze({
initialFiles: Object.freeze([...initialFiles].sort()),
lazyFiles: Object.freeze(lazyFiles.sort()),
missingImports: Object.freeze(missingImports.sort()),
});
}