merge: complete bundle graph accounting
This commit is contained in:
+22
-15
@@ -1,6 +1,7 @@
|
|||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.js";
|
import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.js";
|
||||||
|
import { classifyViteJavascript } from "./lib/classify-vite-bundle.mjs";
|
||||||
|
|
||||||
const report =
|
const report =
|
||||||
/** @type {{
|
/** @type {{
|
||||||
@@ -10,7 +11,7 @@ const report =
|
|||||||
JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8"))
|
JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8"))
|
||||||
);
|
);
|
||||||
const viteManifest =
|
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"))
|
JSON.parse(await readFile("dist/.vite/manifest.json", "utf8"))
|
||||||
);
|
);
|
||||||
const budgets =
|
const budgets =
|
||||||
@@ -21,24 +22,19 @@ const budgets =
|
|||||||
const outputByPath = new Map(
|
const outputByPath = new Map(
|
||||||
report.outputs.map((output) => [output.path.replace(/^dist\//, ""), output]),
|
report.outputs.map((output) => [output.path.replace(/^dist\//, ""), output]),
|
||||||
);
|
);
|
||||||
const initialFiles = new Set(
|
const classification = classifyViteJavascript(viteManifest);
|
||||||
Object.values(viteManifest)
|
const initialJsGzipBytes = classification.initialFiles.reduce(
|
||||||
.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(
|
|
||||||
(total, file) => total + (outputByPath.get(file)?.gzipBytes ?? 0),
|
(total, file) => total + (outputByPath.get(file)?.gzipBytes ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const lazyChunks = [...lazyFiles].map((file) => ({
|
const lazyChunks = classification.lazyFiles.map((file) => ({
|
||||||
path: file,
|
path: file,
|
||||||
gzipBytes: outputByPath.get(file)?.gzipBytes ?? 0,
|
gzipBytes: outputByPath.get(file)?.gzipBytes ?? 0,
|
||||||
}));
|
}));
|
||||||
|
const missingOutputs = [
|
||||||
|
...classification.initialFiles,
|
||||||
|
...classification.lazyFiles,
|
||||||
|
].filter((file) => !outputByPath.has(file));
|
||||||
const measurements = { initialJsGzipBytes, lazyChunks };
|
const measurements = { initialJsGzipBytes, lazyChunks };
|
||||||
const result = evaluateBundleBudget(measurements, budgets);
|
const result = evaluateBundleBudget(measurements, budgets);
|
||||||
const fixtures = [
|
const fixtures = [
|
||||||
@@ -70,10 +66,16 @@ const fixtures = [
|
|||||||
).passed,
|
).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 = {
|
const completedReport = {
|
||||||
...report,
|
...report,
|
||||||
measurements,
|
measurements,
|
||||||
|
classification,
|
||||||
|
missingOutputs,
|
||||||
thresholds: budgets,
|
thresholds: budgets,
|
||||||
results: result,
|
results: result,
|
||||||
fixtures,
|
fixtures,
|
||||||
@@ -85,7 +87,12 @@ await writeFile(
|
|||||||
`${JSON.stringify(completedReport, null, 2)}\n`,
|
`${JSON.stringify(completedReport, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
if (!passed) {
|
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.exit(1);
|
||||||
}
|
}
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { classifyViteJavascript } from "../../scripts/lib/classify-vite-bundle.mjs";
|
||||||
|
|
||||||
|
describe("Vite bundle classification", () => {
|
||||||
|
it("counts transitive static imports as initial and keeps dynamic chunks lazy", () => {
|
||||||
|
expect(
|
||||||
|
classifyViteJavascript({
|
||||||
|
"index.html": {
|
||||||
|
file: "assets/entry.js",
|
||||||
|
isEntry: true,
|
||||||
|
imports: ["_shared.js"],
|
||||||
|
},
|
||||||
|
"_shared.js": { file: "assets/shared.js", imports: ["_runtime.js"] },
|
||||||
|
"_runtime.js": { file: "assets/runtime.js" },
|
||||||
|
"src/lazy.js": { file: "assets/lazy.js" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
initialFiles: [
|
||||||
|
"assets/entry.js",
|
||||||
|
"assets/runtime.js",
|
||||||
|
"assets/shared.js",
|
||||||
|
],
|
||||||
|
lazyFiles: ["assets/lazy.js"],
|
||||||
|
missingImports: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a manifest import that cannot be resolved", () => {
|
||||||
|
expect(
|
||||||
|
classifyViteJavascript({
|
||||||
|
"index.html": {
|
||||||
|
file: "assets/entry.js",
|
||||||
|
isEntry: true,
|
||||||
|
imports: ["_missing.js"],
|
||||||
|
},
|
||||||
|
}).missingImports,
|
||||||
|
).toEqual(["_missing.js"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user