#!/usr/bin/env node /** * Fail when the built Worker's static closure grows too much. * * The static closure is every chunk the Worker entry imports without * `import()`, followed transitively. A Durable Object activation that starts * an isolate loads all of it before its constructor runs, at roughly 0.1 s per * MiB, so this is a latency budget; the upload budget in verify-worker-size.mjs * is a platform limit. * * Two checks. A change may add GROWTH_ALLOWANCE_BYTES over its merge base * (scripts/lib/budget-growth.mjs), measured by building that commit in a * temporary export with this checkout's node_modules. So a dependency upgrade * counts against the ceiling only. A base that does not build fails the * check with the build's output; when it cannot build with these * dependencies, CHICKPEA_BUDGET_UNBUILDABLE_BASE=1 checks the ceiling alone. * The ceiling backstops the total: set it to the measured size plus 64 KiB * once per merge train, and lower it when a change moves code behind * `import()`. * * Reads the build in dist-cf, which must be of this checkout. Its builds run * inside verify:regression's host reservation; standalone, wrap it in * `npm run verify:host --`. */ import { existsSync, readFileSync, rmSync, statSync, symlinkSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { builtWorkerConfigPath, staleBuildReason } from './lib/built-worker-config.mjs'; import { compareSizes, exportCommit, formatGrowth, growthFailure, growthLine, growthPlan, short } from './lib/budget-growth.mjs'; const MIB = 1024 * 1024; export const WORKER_STATIC_CLOSURE_CEILING_BYTES = Math.round(7.75 * MIB); export const UNBUILDABLE_BASE_ENV = 'CHICKPEA_BUDGET_UNBUILDABLE_BASE'; const ENTRY = 'virtual:cloudflare/worker-entry'; const BASE_REPORT = 'budget-modules.json'; /** * The entry's static chunks, largest first, from the build's Vite manifest, * and the modules in them by rendered length when `report` records each * module's chunk. */ export function measureStaticClosure(directory, report = 'bundle-modules.json') { const manifest = JSON.parse(readFileSync(path.join(directory, '.vite', 'manifest.json'), 'utf8')); if (!manifest[ENTRY]) throw new Error(`The Vite manifest in ${directory} has no ${ENTRY} entry.`); const seen = new Set(); const pending = [ENTRY]; for (let key = pending.pop(); key !== undefined; key = pending.pop()) { if (seen.has(key)) continue; seen.add(key); pending.push(...(manifest[key].imports ?? [])); } const chunks = [...seen] .map((key) => ({ file: manifest[key].file, bytes: statSync(path.join(directory, manifest[key].file)).size })) .sort((a, b) => b.bytes - a.bytes); return { chunks, bytes: chunks.reduce((sum, chunk) => sum + chunk.bytes, 0), modules: closureModules(directory, report, chunks) }; } function closureModules(directory, report, chunks) { const file = path.join(directory, report); if (!existsSync(file)) return undefined; const entries = JSON.parse(readFileSync(file, 'utf8')); if (entries.some((entry) => entry.chunk === undefined)) return undefined; const files = new Set(chunks.map((chunk) => chunk.file)); const modules = new Map(); for (const { id, renderedLength, chunk } of entries) { if (files.has(chunk)) modules.set(id, (modules.get(id) ?? 0) + renderedLength); } return modules; } /** Builds `commit` in a temporary export with this checkout's dependencies and measures its closure. */ export function measureCommit(projectRoot, commit) { const directory = exportCommit(projectRoot, commit); try { symlinkSync(path.join(projectRoot, 'node_modules'), path.join(directory, 'node_modules'), 'dir'); const built = spawnSync(process.execPath, [path.join(projectRoot, 'scripts/lib/build-worker-report.mjs'), BASE_REPORT], { cwd: directory, encoding: 'utf8', maxBuffer: 64 * MIB, }); if (built.status !== 0) { const lockfile = spawnSync('git', ['-C', projectRoot, 'diff', '--quiet', commit, '--', 'package-lock.json']).status === 0 ? 'the same as' : 'not'; throw new Error(`Building ${short(commit)} to measure its closure failed. Its package-lock.json is ${lockfile} ` + `this checkout's, whose node_modules it built with.\n${built.stdout}${built.stderr}`); } return measureStaticClosure(path.dirname(builtWorkerConfigPath(directory)), BASE_REPORT); } finally { rmSync(directory, { recursive: true, force: true }); } } const mib = (bytes) => `${(bytes / MIB).toFixed(2)} MiB`; const rowsBetween = (before, after) => before.modules && after.modules ? compareSizes(before.modules, after.modules).rows : undefined; /** * Why the closure fails its budgets, as messages: each change over its * allowance, each change that could not be measured unless * `unbuildableBaseAllowed`, and the ceiling. `plan` is growthPlan's. * `at(commit)` measures a commit, or the working tree when it is undefined, * and throws when it cannot. Each change's growth goes to `log`. */ export function closureFailures({ head, plan, at, ceiling = WORKER_STATIC_CLOSURE_CEILING_BYTES, unbuildableBaseAllowed = false, log = console.log, }) { const failures = []; if (plan.skip) log(plan.skip); if (plan.error) failures.push(plan.error); if (plan.changes) log(`The merge base with ${plan.ref} is ${short(plan.base)}.`); for (const change of plan.changes ?? []) { let before; let after; try { before = at(change.from); after = at(change.to); } catch (error) { if (unbuildableBaseAllowed) { log(`Growth in ${change.label} is not checked, as ${UNBUILDABLE_BASE_ENV} is set; the ceiling still is. ${error.message}`); } else { failures.push(`Growth in ${change.label} could not be measured. ${error.message}\n` + `When that commit cannot build with this checkout's dependencies, as after a dependency upgrade, ` + `set ${UNBUILDABLE_BASE_ENV}=1 to check the ceiling alone.`); } continue; } const growth = after.bytes - before.bytes; log(growthLine(change, growth)); if (growth > change.allowance) { failures.push(growthFailure({ what: 'The Worker static closure', change, ref: plan.ref, growth, rows: rowsBetween(before, after) })); } } if (head.bytes > ceiling) { let sinceBase; try { sinceBase = plan.base === undefined ? undefined : rowsBetween(at(plan.base), head); } catch { // The base's build failure is reported above. } failures.push( `The static closure is ${mib(head.bytes - ceiling)} over its ceiling.` + (sinceBase ? `\nModules that grew since the merge base:\n${formatGrowth(sinceBase)}` : '') + '\nLoad the new code with import() where it is used. Once per merge train, the ceiling in ' + 'scripts/worker-closure.mjs may be set to the measured size plus 64 KiB.', ); } return failures; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const stale = staleBuildReason(projectRoot); if (stale) { console.error(stale); process.exit(2); } const head = measureStaticClosure(path.dirname(builtWorkerConfigPath(projectRoot))); console.log(`Worker static closure: ${head.chunks.length} chunks, ${mib(head.bytes)} (ceiling ${mib(WORKER_STATIC_CLOSURE_CEILING_BYTES)})`); for (const chunk of head.chunks.slice(0, 8)) console.log(` ${mib(chunk.bytes).padStart(10)} ${chunk.file}`); // Each commit builds once, and a build that failed is not retried. const measured = new Map(); const at = (commit) => { if (commit === undefined) return head; if (!measured.has(commit)) { try { measured.set(commit, { closure: measureCommit(projectRoot, commit) }); } catch (error) { measured.set(commit, { error }); } } const { closure, error } = measured.get(commit); if (error) throw error; return closure; }; const failures = closureFailures({ head, plan: growthPlan(projectRoot), at, unbuildableBaseAllowed: process.env[UNBUILDABLE_BASE_ENV] === '1', }); for (const failure of failures) console.error(`\n${failure}`); if (failures.length > 0) process.exit(1); }