// ---------------------------------------------------------------------------// Measure the Worker's *evaluated* module closures from the build output.// ---------------------------------------------------------------------------//// Upload size does not predict cold-isolate cost: a Worker can ship megabytes// that never load, and the bytes that matter are the ones an isolate must// evaluate before it can answer. Rollup already separates static from dynamic// edges, so we can compute that split directly instead of reading totals.//// Two closures matter:// startup - statically reachable from the Worker entry. Evaluated on every// cold isolate before any request is served.// start - the TanStack Start server graph, reached through the lazy// `loadEntries` dynamic imports. Evaluated on the first request// that enters the app - i.e. a page request.// app - the Effect app plane. `/api/*` dispatches at the Worker entry and// skips Start entirely, so an API request evaluates this instead of// `start`; reported separately because the two planes now diverge.// auth - the session/auth plane (`./app-auth`). The session routes are// dispatched ahead of `app` and mount none of the plugin/OpenAPI/// MCP/GraphQL/execution graph, so a sign-out on a cold isolate// evaluates this closure instead of `app`.//// Anything reachable only through a dynamic import is not counted: making a// heavy dependency lazy is exactly the outcome this rewards.//// Scope note: this measures bytes, which is a proxy for cold-start cost, not a// proven cause of any particular regression. The Aug 2026 MCP incident was// NOT explained by this number - reverting the offending packages moved the// evaluated closure by 0.02 MB while restoring production latency, so// module-scope execution cost, not size, drove that one. Treat a budget breach// as "this will make cold starts worse", not as "this is why the site is slow".import { readFileSync, readdirSync, statSync } from "node:fs";import { dirname, join, relative, resolve } from "node:path"; const DIST = resolve(process.argv[2] ?? "dist/server");const ENTRY = join(DIST, "index.js"); // Rollup emits `from "./x.js"`, bare `import "./x.js"`, `export ... from "./x.js"`// (all static) and `import("./x.js")` (dynamic). Matching the emitted output// rather than source means we see the graph as the runtime sees it.const STATIC_RE = /(?:from|import)\s*["'](\.[^"']+)["']/g;const DYNAMIC_RE = /import\(\s*["'](\.[^"']+)["']\s*\)/g; const listChunks = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? listChunks(join(dir, e.name)) : e.name.endsWith(".js") ? [join(dir, e.name)] : [], ); const graph = new Map();for (const file of listChunks(DIST)) { const code = readFileSync(file, "utf8"); const dynamic = new Set([...code.matchAll(DYNAMIC_RE)].map((m) => resolve(dirname(file), m[1]))); // A specifier inside `import(...)` also matches STATIC_RE's `import` branch, // so subtract the dynamic set rather than trusting the static matches alone. const staticDeps = new Set( [...code.matchAll(STATIC_RE)] .map((m) => resolve(dirname(file), m[1])) .filter((p) => !dynamic.has(p)), ); graph.set(file, { size: statSync(file).size, static: staticDeps, dynamic });} /** Bytes evaluated when `roots` are loaded, following static edges only. */const closure = (roots) => { const seen = new Set(); const queue = [...roots]; while (queue.length > 0) { const file = queue.pop(); if (seen.has(file) || !graph.has(file)) continue; seen.add(file); queue.push(...graph.get(file).static); } return seen;}; const bytes = (files) => [...files].reduce((sum, f) => sum + (graph.get(f)?.size ?? 0), 0);const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`;const name = (f) => relative(DIST, f); const startup = closure([ENTRY]);// The lazy server-graph entries Start pulls on first request.const startRoots = [...graph.get(ENTRY).dynamic].filter((f) => /(start|router|tanstack)/.test(name(f)),);const start = closure(startRoots);// Rollup names an entry chunk after its module, so `./app` emits `app-<hash>.js`// and `./app-auth` emits `app-auth-<hash>.js`. Match the auth root first and// subtract it, or the app pattern would claim both.const dynamicRoots = [...graph.get(ENTRY).dynamic];const authRoots = dynamicRoots.filter((f) => /\/app-auth-[A-Za-z0-9_-]+\.js$/.test(f));const appRoots = dynamicRoots.filter( (f) => /\/app-[A-Za-z0-9_-]+\.js$/.test(f) && !authRoots.includes(f),);const app = closure([ENTRY, ...appRoots]);const auth = closure([ENTRY, ...authRoots]);// The budget tracks the worst plane: whichever costs a cold isolate more.const evaluated = new Set([...startup, ...start]); const report = (label, files) => { console.log(`\n${label}: ${mb(bytes(files))} (${files.size} chunks)`); const own = [...files].filter((f) => !startup.has(f) || label === "startup"); for (const f of own.sort((a, b) => graph.get(b).size - graph.get(a).size).slice(0, 12)) { console.log(` ${(graph.get(f).size / 1024).toFixed(0).padStart(6)} KB ${name(f)}`); }}; report("startup", startup);report("start", start);if (authRoots.length > 0) report("auth", auth);console.log(`\npage request (startup + start): ${mb(bytes(evaluated))}`);console.log( `API request (startup + app): ${mb(bytes(app))}${appRoots.length ? "" : " [no app chunk - /api still routes through Start]"}`,);console.log( `auth request (startup + auth): ${mb(bytes(auth))}${ authRoots.length ? ` [+${mb(bytes(auth) - bytes(startup))} over startup]` : " [no auth chunk - session routes still route through the app plane]" }`,);const lazyOnly = [...graph.keys()].filter((f) => !evaluated.has(f));console.log( `deferred behind dynamic import: ${mb(bytes(lazyOnly))} (${lazyOnly.length} chunks)`,); const budget = Number(process.env.START_CLOSURE_BUDGET_MB ?? 0);if (budget > 0) { const actual = bytes(evaluated) / 1024 / 1024; console.log(`\nbudget ${budget} MB — actual ${actual.toFixed(2)} MB`); if (actual > budget) { console.error( `\nFAIL: evaluated closure ${actual.toFixed(2)} MB exceeds the ${budget} MB budget.\n` + `Every cold isolate pays to evaluate this closure before it can answer. Move the\n` + `new weight behind a dynamic import rather than raising the budget; run this\n` + `script with no budget set to see the biggest members and what is already lazy.`, ); process.exit(1); }}
// ---------------------------------------------------------------------------
// Measure the Worker's *evaluated* module closures from the build output.
// ---------------------------------------------------------------------------
//
// Upload size does not predict cold-isolate cost: a Worker can ship megabytes
// that never load, and the bytes that matter are the ones an isolate must
// evaluate before it can answer. Rollup already separates static from dynamic
// edges, so we can compute that split directly instead of reading totals.
//
// Two closures matter:
// startup - statically reachable from the Worker entry. Evaluated on every
// cold isolate before any request is served.
// start - the TanStack Start server graph, reached through the lazy
// `loadEntries` dynamic imports. Evaluated on the first request
// that enters the app - i.e. a page request.
// app - the Effect app plane. `/api/*` dispatches at the Worker entry and
// skips Start entirely, so an API request evaluates this instead of
// `start`; reported separately because the two planes now diverge.
// auth - the session/auth plane (`./app-auth`). The session routes are
// dispatched ahead of `app` and mount none of the plugin/OpenAPI/
// MCP/GraphQL/execution graph, so a sign-out on a cold isolate
// evaluates this closure instead of `app`.
//
// Anything reachable only through a dynamic import is not counted: making a
// heavy dependency lazy is exactly the outcome this rewards.
//
// Scope note: this measures bytes, which is a proxy for cold-start cost, not a
// proven cause of any particular regression. The Aug 2026 MCP incident was
// NOT explained by this number - reverting the offending packages moved the
// evaluated closure by 0.02 MB while restoring production latency, so
// module-scope execution cost, not size, drove that one. Treat a budget breach
// as "this will make cold starts worse", not as "this is why the site is slow".
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
const DIST = resolve(process.argv[2] ?? "dist/server");
const ENTRY = join(DIST, "index.js");
// Rollup emits `from "./x.js"`, bare `import "./x.js"`, `export ... from "./x.js"`
// (all static) and `import("./x.js")` (dynamic). Matching the emitted output
// rather than source means we see the graph as the runtime sees it.
const STATIC_RE = /(?:from|import)\s*["'](\.[^"']+)["']/g;
const DYNAMIC_RE = /import\(\s*["'](\.[^"']+)["']\s*\)/g;
const listChunks = (dir) =>
readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
e.isDirectory()
? listChunks(join(dir, e.name))
: e.name.endsWith(".js")
? [join(dir, e.name)]
: [],
);
const graph = new Map();
for (const file of listChunks(DIST)) {
const code = readFileSync(file, "utf8");
const dynamic = new Set([...code.matchAll(DYNAMIC_RE)].map((m) => resolve(dirname(file), m[1])));
// A specifier inside `import(...)` also matches STATIC_RE's `import` branch,
// so subtract the dynamic set rather than trusting the static matches alone.
const staticDeps = new Set(
[...code.matchAll(STATIC_RE)]
.map((m) => resolve(dirname(file), m[1]))
.filter((p) => !dynamic.has(p)),
);
graph.set(file, { size: statSync(file).size, static: staticDeps, dynamic });
}
/** Bytes evaluated when `roots` are loaded, following static edges only. */
const closure = (roots) => {
const seen = new Set();
const queue = [...roots];
while (queue.length > 0) {
const file = queue.pop();
if (seen.has(file) || !graph.has(file)) continue;
seen.add(file);
queue.push(...graph.get(file).static);
}
return seen;
};
const bytes = (files) => [...files].reduce((sum, f) => sum + (graph.get(f)?.size ?? 0), 0);
const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`;
const name = (f) => relative(DIST, f);
const startup = closure([ENTRY]);
// The lazy server-graph entries Start pulls on first request.
const startRoots = [...graph.get(ENTRY).dynamic].filter((f) =>
/(start|router|tanstack)/.test(name(f)),
);
const start = closure(startRoots);
// Rollup names an entry chunk after its module, so `./app` emits `app-<hash>.js`
// and `./app-auth` emits `app-auth-<hash>.js`. Match the auth root first and
// subtract it, or the app pattern would claim both.
const dynamicRoots = [...graph.get(ENTRY).dynamic];
const authRoots = dynamicRoots.filter((f) => /\/app-auth-[A-Za-z0-9_-]+\.js$/.test(f));
const appRoots = dynamicRoots.filter(
(f) => /\/app-[A-Za-z0-9_-]+\.js$/.test(f) && !authRoots.includes(f),
);
const app = closure([ENTRY, ...appRoots]);
const auth = closure([ENTRY, ...authRoots]);
// The budget tracks the worst plane: whichever costs a cold isolate more.
const evaluated = new Set([...startup, ...start]);
const report = (label, files) => {
console.log(`\n${label}: ${mb(bytes(files))} (${files.size} chunks)`);
const own = [...files].filter((f) => !startup.has(f) || label === "startup");
for (const f of own.sort((a, b) => graph.get(b).size - graph.get(a).size).slice(0, 12)) {
console.log(` ${(graph.get(f).size / 1024).toFixed(0).padStart(6)} KB ${name(f)}`);
}
};
report("startup", startup);
report("start", start);
if (authRoots.length > 0) report("auth", auth);
console.log(`\npage request (startup + start): ${mb(bytes(evaluated))}`);
console.log(
`API request (startup + app): ${mb(bytes(app))}${appRoots.length ? "" : " [no app chunk - /api still routes through Start]"}`,
);
console.log(
`auth request (startup + auth): ${mb(bytes(auth))}${
authRoots.length
? ` [+${mb(bytes(auth) - bytes(startup))} over startup]`
: " [no auth chunk - session routes still route through the app plane]"
}`,
);
const lazyOnly = [...graph.keys()].filter((f) => !evaluated.has(f));
console.log(
`deferred behind dynamic import: ${mb(bytes(lazyOnly))} (${lazyOnly.length} chunks)`,
);
const budget = Number(process.env.START_CLOSURE_BUDGET_MB ?? 0);
if (budget > 0) {
const actual = bytes(evaluated) / 1024 / 1024;
console.log(`\nbudget ${budget} MB — actual ${actual.toFixed(2)} MB`);
if (actual > budget) {
console.error(
`\nFAIL: evaluated closure ${actual.toFixed(2)} MB exceeds the ${budget} MB budget.\n` +
`Every cold isolate pays to evaluate this closure before it can answer. Move the\n` +
`new weight behind a dynamic import rather than raising the budget; run this\n` +
`script with no budget set to see the biggest members and what is already lazy.`,
);
process.exit(1);
}
}