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