|
| 1 | +// --------------------------------------------------------------------------- |
| 2 | +// Measure the Worker's *evaluated* module closures from the build output. |
| 3 | +// --------------------------------------------------------------------------- |
| 4 | +// |
| 5 | +// Upload size does not predict cold-isolate cost: a Worker can ship megabytes |
| 6 | +// that never load, and the bytes that matter are the ones an isolate must |
| 7 | +// evaluate before it can answer. Rollup already separates static from dynamic |
| 8 | +// edges, so we can compute that split directly instead of reading totals. |
| 9 | +// |
| 10 | +// Two closures matter: |
| 11 | +// startup - statically reachable from the Worker entry. Evaluated on every |
| 12 | +// cold isolate before any request is served. |
| 13 | +// start - the TanStack Start server graph, reached through the lazy |
| 14 | +// `loadEntries` dynamic imports. Evaluated on the first request |
| 15 | +// that enters the app. |
| 16 | +// |
| 17 | +// Anything reachable only through a dynamic import is not counted: making a |
| 18 | +// heavy dependency lazy is exactly the outcome this rewards. |
| 19 | +// |
| 20 | +// Scope note: this measures bytes, which is a proxy for cold-start cost, not a |
| 21 | +// proven cause of any particular regression. The Aug 2026 MCP incident was |
| 22 | +// NOT explained by this number - reverting the offending packages moved the |
| 23 | +// evaluated closure by 0.02 MB while restoring production latency, so |
| 24 | +// module-scope execution cost, not size, drove that one. Treat a budget breach |
| 25 | +// as "this will make cold starts worse", not as "this is why the site is slow". |
| 26 | +import { readFileSync, readdirSync, statSync } from "node:fs"; |
| 27 | +import { dirname, join, relative, resolve } from "node:path"; |
| 28 | + |
| 29 | +const DIST = resolve(process.argv[2] ?? "dist/server"); |
| 30 | +const ENTRY = join(DIST, "index.js"); |
| 31 | + |
| 32 | +// Rollup emits `from "./x.js"`, bare `import "./x.js"`, `export ... from "./x.js"` |
| 33 | +// (all static) and `import("./x.js")` (dynamic). Matching the emitted output |
| 34 | +// rather than source means we see the graph as the runtime sees it. |
| 35 | +const STATIC_RE = /(?:from|import)\s*["'](\.[^"']+)["']/g; |
| 36 | +const DYNAMIC_RE = /import\(\s*["'](\.[^"']+)["']\s*\)/g; |
| 37 | + |
| 38 | +const listChunks = (dir) => |
| 39 | + readdirSync(dir, { withFileTypes: true }).flatMap((e) => |
| 40 | + e.isDirectory() |
| 41 | + ? listChunks(join(dir, e.name)) |
| 42 | + : e.name.endsWith(".js") |
| 43 | + ? [join(dir, e.name)] |
| 44 | + : [], |
| 45 | + ); |
| 46 | + |
| 47 | +const graph = new Map(); |
| 48 | +for (const file of listChunks(DIST)) { |
| 49 | + const code = readFileSync(file, "utf8"); |
| 50 | + const dynamic = new Set([...code.matchAll(DYNAMIC_RE)].map((m) => resolve(dirname(file), m[1]))); |
| 51 | + // A specifier inside `import(...)` also matches STATIC_RE's `import` branch, |
| 52 | + // so subtract the dynamic set rather than trusting the static matches alone. |
| 53 | + const staticDeps = new Set( |
| 54 | + [...code.matchAll(STATIC_RE)] |
| 55 | + .map((m) => resolve(dirname(file), m[1])) |
| 56 | + .filter((p) => !dynamic.has(p)), |
| 57 | + ); |
| 58 | + graph.set(file, { size: statSync(file).size, static: staticDeps, dynamic }); |
| 59 | +} |
| 60 | + |
| 61 | +/** Bytes evaluated when `roots` are loaded, following static edges only. */ |
| 62 | +const closure = (roots) => { |
| 63 | + const seen = new Set(); |
| 64 | + const queue = [...roots]; |
| 65 | + while (queue.length > 0) { |
| 66 | + const file = queue.pop(); |
| 67 | + if (seen.has(file) || !graph.has(file)) continue; |
| 68 | + seen.add(file); |
| 69 | + queue.push(...graph.get(file).static); |
| 70 | + } |
| 71 | + return seen; |
| 72 | +}; |
| 73 | + |
| 74 | +const bytes = (files) => [...files].reduce((sum, f) => sum + (graph.get(f)?.size ?? 0), 0); |
| 75 | +const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`; |
| 76 | +const name = (f) => relative(DIST, f); |
| 77 | + |
| 78 | +const startup = closure([ENTRY]); |
| 79 | +// The lazy server-graph entries Start pulls on first request. |
| 80 | +const startRoots = [...graph.get(ENTRY).dynamic].filter((f) => |
| 81 | + /(start|router|tanstack)/.test(name(f)), |
| 82 | +); |
| 83 | +const start = closure(startRoots); |
| 84 | +const evaluated = new Set([...startup, ...start]); |
| 85 | + |
| 86 | +const report = (label, files) => { |
| 87 | + console.log(`\n${label}: ${mb(bytes(files))} (${files.size} chunks)`); |
| 88 | + const own = [...files].filter((f) => !startup.has(f) || label === "startup"); |
| 89 | + for (const f of own.sort((a, b) => graph.get(b).size - graph.get(a).size).slice(0, 12)) { |
| 90 | + console.log(` ${(graph.get(f).size / 1024).toFixed(0).padStart(6)} KB ${name(f)}`); |
| 91 | + } |
| 92 | +}; |
| 93 | + |
| 94 | +report("startup", startup); |
| 95 | +report("start", start); |
| 96 | +console.log(`\ntotal evaluated on a warm-path request: ${mb(bytes(evaluated))}`); |
| 97 | +const lazyOnly = [...graph.keys()].filter((f) => !evaluated.has(f)); |
| 98 | +console.log( |
| 99 | + `deferred behind dynamic import: ${mb(bytes(lazyOnly))} (${lazyOnly.length} chunks)`, |
| 100 | +); |
| 101 | + |
| 102 | +const budget = Number(process.env.START_CLOSURE_BUDGET_MB ?? 0); |
| 103 | +if (budget > 0) { |
| 104 | + const actual = bytes(evaluated) / 1024 / 1024; |
| 105 | + console.log(`\nbudget ${budget} MB — actual ${actual.toFixed(2)} MB`); |
| 106 | + if (actual > budget) { |
| 107 | + console.error( |
| 108 | + `\nFAIL: evaluated closure ${actual.toFixed(2)} MB exceeds the ${budget} MB budget.\n` + |
| 109 | + `Every cold isolate pays to evaluate this closure before it can answer. Move the\n` + |
| 110 | + `new weight behind a dynamic import rather than raising the budget; run this\n` + |
| 111 | + `script with no budget set to see the biggest members and what is already lazy.`, |
| 112 | + ); |
| 113 | + process.exit(1); |
| 114 | + } |
| 115 | +} |
0 commit comments