perf(webui): lazily load module descriptors
This commit is contained in:
173
webui/scripts/check-bundle-budget.mjs
Normal file
173
webui/scripts/check-bundle-budget.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
import { appendFileSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
const webuiRoot = new URL("..", import.meta.url);
|
||||
const distRoot = new URL("./dist/", webuiRoot);
|
||||
const manifestPath = new URL("./.vite/manifest.json", distRoot);
|
||||
const budgetPath = new URL("./bundle-budget.json", webuiRoot);
|
||||
const metricsPath = new URL("./bundle-metrics.json", distRoot);
|
||||
const buildName = process.env.GOVOPLAN_WEBUI_BUILD_NAME || "default";
|
||||
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
const budgets = JSON.parse(readFileSync(budgetPath, "utf8"));
|
||||
const entries = Object.entries(manifest);
|
||||
const entryKeys = entries
|
||||
.filter(([, chunk]) => chunk.isEntry && isJavaScript(chunk.file))
|
||||
.map(([key]) => key);
|
||||
|
||||
if (entryKeys.length === 0) {
|
||||
throw new Error("Vite manifest has no JavaScript entry");
|
||||
}
|
||||
|
||||
const initialKeys = new Set();
|
||||
for (const entryKey of entryKeys) collectStaticImports(entryKey, initialKeys);
|
||||
const initialFiles = uniqueChunks(
|
||||
[...initialKeys].map((key) => [key, manifest[key]]).filter(([, chunk]) => chunk)
|
||||
);
|
||||
const initialFileNames = new Set(initialFiles.map(([, chunk]) => chunk.file));
|
||||
const moduleDescriptorChunks = entries.filter(([key, chunk]) => (
|
||||
isJavaScript(chunk.file) &&
|
||||
chunk.isDynamicEntry &&
|
||||
(
|
||||
key.startsWith("virtual:govoplan-installed-module/") ||
|
||||
chunk.name?.startsWith("_40govoplan_2F")
|
||||
)
|
||||
));
|
||||
const eagerModuleDescriptors = moduleDescriptorChunks.filter(
|
||||
([, chunk]) => initialFileNames.has(chunk.file)
|
||||
);
|
||||
const asyncFiles = uniqueChunks(
|
||||
entries.filter(([, chunk]) => (
|
||||
isJavaScript(chunk.file) && !initialFileNames.has(chunk.file)
|
||||
))
|
||||
);
|
||||
|
||||
const initial = summarizeChunks(initialFiles);
|
||||
const measuredAsyncChunks = asyncFiles.map(measureChunk);
|
||||
const largestAsync = measuredAsyncChunks.sort(
|
||||
(left, right) => right.rawBytes - left.rawBytes
|
||||
)[0] ?? null;
|
||||
const failures = [
|
||||
...eagerModuleDescriptors.map(([, chunk]) => (
|
||||
`Installed module descriptor ${chunk.file} is part of the initial static closure`
|
||||
)),
|
||||
...budgetFailures("Initial JavaScript", initial, budgets.initialJs),
|
||||
...(largestAsync
|
||||
? budgetFailures("Largest async chunk", largestAsync, budgets.asyncChunk)
|
||||
: ["No asynchronous JavaScript chunk was produced"])
|
||||
];
|
||||
|
||||
const metrics = {
|
||||
schemaVersion: 1,
|
||||
buildName,
|
||||
measuredAt: new Date().toISOString(),
|
||||
initial,
|
||||
largestAsync,
|
||||
moduleDescriptorCount: moduleDescriptorChunks.length,
|
||||
eagerModuleDescriptorCount: eagerModuleDescriptors.length,
|
||||
budgets,
|
||||
passed: failures.length === 0,
|
||||
failures
|
||||
};
|
||||
writeFileSync(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`);
|
||||
|
||||
console.log(`\nWebUI bundle budget (${buildName})`);
|
||||
console.log(formatMetric("Initial JavaScript", initial, budgets.initialJs));
|
||||
console.log(formatMetric("Largest async chunk", largestAsync, budgets.asyncChunk));
|
||||
console.log(`Metrics: ${metricsPath.pathname}`);
|
||||
appendCiSummary(metrics);
|
||||
|
||||
if (failures.length) {
|
||||
for (const failure of failures) console.error(`Bundle budget exceeded: ${failure}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function collectStaticImports(key, target) {
|
||||
if (target.has(key)) return;
|
||||
const chunk = manifest[key];
|
||||
if (!chunk || !isJavaScript(chunk.file)) return;
|
||||
target.add(key);
|
||||
for (const importedKey of chunk.imports ?? []) {
|
||||
collectStaticImports(importedKey, target);
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueChunks(chunks) {
|
||||
const byFile = new Map();
|
||||
for (const [key, chunk] of chunks) {
|
||||
if (!chunk || byFile.has(chunk.file)) continue;
|
||||
byFile.set(chunk.file, [key, chunk]);
|
||||
}
|
||||
return [...byFile.values()];
|
||||
}
|
||||
|
||||
function summarizeChunks(chunks) {
|
||||
const measured = chunks.map(measureChunk);
|
||||
return {
|
||||
assetCount: measured.length,
|
||||
rawBytes: measured.reduce((total, chunk) => total + chunk.rawBytes, 0),
|
||||
gzipBytes: measured.reduce((total, chunk) => total + chunk.gzipBytes, 0),
|
||||
assets: measured
|
||||
};
|
||||
}
|
||||
|
||||
function measureChunk([key, chunk]) {
|
||||
const assetUrl = new URL(chunk.file, distRoot);
|
||||
const source = readFileSync(assetUrl);
|
||||
return {
|
||||
key,
|
||||
file: chunk.file,
|
||||
rawBytes: statSync(assetUrl).size,
|
||||
gzipBytes: gzipSync(source, { level: 9 }).length
|
||||
};
|
||||
}
|
||||
|
||||
function budgetFailures(label, measured, budget) {
|
||||
if (!measured) return [`${label} was not measured`];
|
||||
const failures = [];
|
||||
for (const field of ["rawBytes", "gzipBytes"]) {
|
||||
if (measured[field] > budget[field]) {
|
||||
failures.push(
|
||||
`${label} ${field} is ${measured[field]} bytes; budget is ${budget[field]} bytes`
|
||||
);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
function formatMetric(label, measured, budget) {
|
||||
if (!measured) return `${label}: not produced`;
|
||||
const file = measured.file ? ` (${measured.file})` : ` (${measured.assetCount} assets)`;
|
||||
return [
|
||||
`${label}${file}:`,
|
||||
`${formatBytes(measured.rawBytes)} raw / ${formatBytes(budget.rawBytes)} budget,`,
|
||||
`${formatBytes(measured.gzipBytes)} gzip / ${formatBytes(budget.gzipBytes)} budget`
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
return `${(value / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
|
||||
function isJavaScript(file) {
|
||||
return typeof file === "string" && file.endsWith(".js");
|
||||
}
|
||||
|
||||
function appendCiSummary(result) {
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
||||
if (!summaryPath) return;
|
||||
const status = result.passed ? "pass" : "fail";
|
||||
const asyncFile = result.largestAsync?.file ?? "none";
|
||||
appendFileSync(
|
||||
summaryPath,
|
||||
[
|
||||
"",
|
||||
`### WebUI bundle budget: ${result.buildName}`,
|
||||
"",
|
||||
"| Status | Initial raw | Initial gzip | Largest async | Async raw | Async gzip |",
|
||||
"| --- | ---: | ---: | --- | ---: | ---: |",
|
||||
`| ${status} | ${formatBytes(result.initial.rawBytes)} | ${formatBytes(result.initial.gzipBytes)} | \`${asyncFile}\` | ${formatBytes(result.largestAsync?.rawBytes ?? 0)} | ${formatBytes(result.largestAsync?.gzipBytes ?? 0)} |`,
|
||||
""
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user