#!/usr/bin/env node import { readFile, stat } from "node:fs/promises"; import path from "node:path"; const rootDir = process.cwd(); const manifestPath = path.resolve(rootDir, process.argv[2] || "apps/frontend/.vinxi/build/client/_build/.vite/manifest.json"); const assetsDir = path.resolve(path.dirname(path.dirname(manifestPath)), "assets"); const budget = { maxTotalJsBytes: Number.parseInt(process.env.MAX_TOTAL_JS_BYTES || "320000", 10), maxTotalCssBytes: Number.parseInt(process.env.MAX_TOTAL_CSS_BYTES || "50000", 10), maxEntryJsBytes: Number.parseInt(process.env.MAX_ENTRY_JS_BYTES || "35000", 10), maxRouteJsBytes: Number.parseInt(process.env.MAX_ROUTE_JS_BYTES || "45000", 10), maxChunkJsBytes: Number.parseInt(process.env.MAX_CHUNK_JS_BYTES || "50000", 10) }; function formatBytes(bytes) { if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; } return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } async function readManifest(filePath) { const raw = await readFile(filePath, "utf8"); return JSON.parse(raw); } function parseRouteEntries(manifest) { return Object.entries(manifest) .filter(([key]) => key.startsWith("src/routes/") && key.includes("?pick=default&pick=$css")) .map(([key, value]) => ({ routeKey: key, file: value?.file })) .filter(entry => typeof entry.file === "string" && entry.file.endsWith(".js")); } async function fileSizeForAsset(assetFile) { const absolute = path.resolve(assetsDir, path.basename(assetFile)); const info = await stat(absolute); return info.size; } async function main() { const failures = []; let manifest; try { manifest = await readManifest(manifestPath); } catch (error) { console.error( `[error] failed to read frontend build manifest at ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); } const values = Object.values(manifest).filter(Boolean); const jsAssets = new Set(); const cssAssets = new Set(); for (const item of values) { if (typeof item?.file === "string") { if (item.file.endsWith(".js")) { jsAssets.add(path.basename(item.file)); } else if (item.file.endsWith(".css")) { cssAssets.add(path.basename(item.file)); } } if (Array.isArray(item?.css)) { for (const cssFile of item.css) { if (typeof cssFile === "string" && cssFile.endsWith(".css")) { cssAssets.add(path.basename(cssFile)); } } } } let totalJsBytes = 0; let totalCssBytes = 0; let largestJsChunk = { name: "", size: 0 }; for (const jsFile of jsAssets) { const size = await fileSizeForAsset(jsFile); totalJsBytes += size; if (size > largestJsChunk.size) { largestJsChunk = { name: jsFile, size }; } } for (const cssFile of cssAssets) { totalCssBytes += await fileSizeForAsset(cssFile); } const entryKey = "virtual:$vinxi/handler/client"; const entryFile = manifest[entryKey]?.file; if (!entryFile) { failures.push(`missing entry chunk in manifest: ${entryKey}`); } const entrySize = entryFile ? await fileSizeForAsset(entryFile) : 0; const routeEntries = parseRouteEntries(manifest); const routeSizes = []; for (const route of routeEntries) { routeSizes.push({ ...route, size: await fileSizeForAsset(route.file) }); } routeSizes.sort((left, right) => right.size - left.size); const largestRoute = routeSizes[0] ?? { routeKey: "n/a", file: "n/a", size: 0 }; if (totalJsBytes > budget.maxTotalJsBytes) { failures.push(`total JS size ${formatBytes(totalJsBytes)} exceeds budget ${formatBytes(budget.maxTotalJsBytes)}`); } if (totalCssBytes > budget.maxTotalCssBytes) { failures.push(`total CSS size ${formatBytes(totalCssBytes)} exceeds budget ${formatBytes(budget.maxTotalCssBytes)}`); } if (entrySize > budget.maxEntryJsBytes) { failures.push(`entry chunk ${path.basename(entryFile)} is ${formatBytes(entrySize)} (budget ${formatBytes(budget.maxEntryJsBytes)})`); } if (largestJsChunk.size > budget.maxChunkJsBytes) { failures.push( `largest JS chunk ${largestJsChunk.name} is ${formatBytes(largestJsChunk.size)} (budget ${formatBytes(budget.maxChunkJsBytes)})`, ); } if (largestRoute.size > budget.maxRouteJsBytes) { failures.push( `largest route chunk ${path.basename(largestRoute.file)} is ${formatBytes(largestRoute.size)} (budget ${formatBytes(budget.maxRouteJsBytes)})`, ); } console.log(`[info] frontend budget report (${manifestPath})`); console.log(`[info] total JS: ${formatBytes(totalJsBytes)} (${jsAssets.size} files)`); console.log(`[info] total CSS: ${formatBytes(totalCssBytes)} (${cssAssets.size} files)`); console.log(`[info] entry JS: ${formatBytes(entrySize)} (${entryFile ? path.basename(entryFile) : "missing"})`); console.log(`[info] largest JS chunk: ${formatBytes(largestJsChunk.size)} (${largestJsChunk.name || "none"})`); console.log( `[info] largest route chunk: ${formatBytes(largestRoute.size)} (${path.basename(largestRoute.file || "none")} / ${largestRoute.routeKey})`, ); if (routeSizes.length) { const topRoutes = routeSizes.slice(0, 5); console.log("[info] top route chunks:"); for (const route of topRoutes) { console.log(` - ${route.routeKey}: ${formatBytes(route.size)} (${path.basename(route.file)})`); } } if (failures.length) { for (const failure of failures) { console.error(`[error] ${failure}`); } process.exit(1); } console.log("[ok] frontend bundle budgets passed."); } void main();