// ---------------------------------------------------------------------------// 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.//// 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);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);console.log(`\ntotal evaluated on a warm-path request: ${mb(bytes(evaluated))}`);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.
//
// 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);
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);
console.log(`\ntotal evaluated on a warm-path request: ${mb(bytes(evaluated))}`);
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);
}
}