perf(webui): lazily load module descriptors

This commit is contained in:
2026-07-30 04:35:57 +02:00
parent 9e219bc4d3
commit 47e106684d
14 changed files with 604 additions and 84 deletions

View 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")
);
}

View File

@@ -1,4 +1,5 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
const packageByModule = {
access: "@govoplan/access-webui",
@@ -19,7 +20,9 @@ const packageByModule = {
ops: "@govoplan/ops-webui",
policy: "@govoplan/policy-webui",
postbox: "@govoplan/postbox-webui",
risk_compliance: "@govoplan/risk-compliance-webui",
scheduling: "@govoplan/scheduling-webui",
search: "@govoplan/search-webui",
views: "@govoplan/views-webui",
workflow: "@govoplan/workflow-webui"
};
@@ -52,19 +55,23 @@ const cases = [
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
{ name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "search-only", modules: ["search"] },
{ name: "risk-compliance-only", modules: ["risk_compliance"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox"] }
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] }
];
const npmExec = process.env.npm_execpath;
const command = npmExec ? process.execPath : (process.platform === "win32" ? "npm.cmd" : "npm");
const baseArgs = npmExec ? [npmExec, "run", "build"] : ["run", "build"];
const collectedMetrics = [];
for (const testCase of cases) {
const packages = testCase.modules.map((moduleId) => packageByModule[moduleId]).join(",");
const env = {
...process.env,
GOVOPLAN_WEBUI_MODULE_PACKAGES: packages
GOVOPLAN_WEBUI_MODULE_PACKAGES: packages,
GOVOPLAN_WEBUI_BUILD_NAME: testCase.name
};
delete env.npm_config_tmp;
delete env.NPM_CONFIG_TMP;
@@ -81,4 +88,15 @@ for (const testCase of cases) {
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
collectedMetrics.push(
JSON.parse(readFileSync(new URL("../dist/bundle-metrics.json", import.meta.url), "utf8"))
);
}
const metricsDirectory = new URL("../dist/", import.meta.url);
mkdirSync(metricsDirectory, { recursive: true });
writeFileSync(
new URL("module-permutation-bundle-metrics.json", metricsDirectory),
`${JSON.stringify({ schemaVersion: 1, builds: collectedMetrics }, null, 2)}\n`
);
console.log(`\nRecorded bundle metrics for ${collectedMetrics.length} module permutations.`);