diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs index e2fdf84..9d8878c 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.mjs @@ -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} */ ( + /** @type {Record} */ ( 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( diff --git a/scripts/lib/classify-vite-bundle.mjs b/scripts/lib/classify-vite-bundle.mjs new file mode 100644 index 0000000..2d1cd45 --- /dev/null +++ b/scripts/lib/classify-vite-bundle.mjs @@ -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} 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()), + }); +} diff --git a/tests/unit/bundle-classification.test.js b/tests/unit/bundle-classification.test.js new file mode 100644 index 0000000..5711f87 --- /dev/null +++ b/tests/unit/bundle-classification.test.js @@ -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"]); + }); +});