Skip to content

Commit 8a21554

Browse files
committed
computer: derive shell bundle graph from esbuild metafile
The bundle partitioner read the module graph by scraping the emitted shell.js and chunk sources with regexes: one pass matched import()/from specifiers to recover edges, another matched just-bash's { name, load } registry to map commands to chunks. The edge scrape was fragile — a change in esbuild's codegen or a minification pass would silently return no edges, and an empty graph folds every optional command's heavy dependency back into the core bundle, the exact regression the split exists to prevent. Turn on esbuild's metafile and read the graph from it. The metafile is esbuild's own structured account of every output and its imports, each tagged static or dynamic, so the edge set no longer depends on the shape of the generated source. Command identity still comes from the { name, load } registry in shell.js, because that is the only signal that separates a real command from an internal diagnostic such as flag-coverage, whose dynamic fan-out reaches every command and must not be followed into core. Move the partitioning logic into partition.mjs as pure functions and cover them with unit tests over synthetic graphs, including the diagnostic-fan-out case. resolveFeatureRoots now throws when an optional feature resolves to no command chunk or to a chunk missing from the output, so a broken registry parse fails the build loudly instead of quietly shipping a fat core. The emitted groups are unchanged, byte for byte.
1 parent 74b2dae commit 8a21554

3 files changed

Lines changed: 405 additions & 110 deletions

File tree

packages/computer/src/backends/worker-shell/script/build-bundle.mjs

Lines changed: 20 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@
4343

4444
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4545
import { tmpdir } from "node:os";
46-
import { dirname, relative, resolve } from "node:path";
46+
import { basename, dirname, resolve } from "node:path";
4747
import { fileURLToPath } from "node:url";
4848
import { build } from "esbuild";
4949

50+
import { buildModuleGraph, parseCommandRegistry, partitionModules } from "./partition.mjs";
51+
5052
const here = dirname(fileURLToPath(import.meta.url));
5153
// Script lives at .../backends/worker-shell/script/build-bundle.mjs;
5254
// the bundle target is one level up at .../backends/worker-shell/.
@@ -173,6 +175,10 @@ try {
173175
define: {
174176
"import.meta.url": JSON.stringify("file:///shell.js"),
175177
},
178+
// Emit the module graph as structured data so the partitioner
179+
// reads import edges (and their static/dynamic kind) from
180+
// esbuild rather than scraping the emitted source with regexes.
181+
metafile: true,
176182
});
177183
} finally {
178184
// The scratch outdir is only used to anchor relative paths.
@@ -186,12 +192,13 @@ if (!result.outputFiles || result.outputFiles.length === 0) {
186192
}
187193

188194
// Collect each emitted file into a modules record keyed by its
189-
// scratch-relative path. esbuild writes the entry as shell.js and
190-
// chunks as chunk-<hash>.js per entryNames / chunkNames above.
195+
// output basename. esbuild writes the entry as shell.js and chunks
196+
// as chunk-<hash>.js per entryNames / chunkNames above; the
197+
// basenames are unique and match the keys the module graph and
198+
// partitioner use.
191199
const modules = {};
192200
for (const file of result.outputFiles) {
193-
const name = relative(scratch, file.path).split(/[\\/]/).join("/");
194-
modules[name] = file.text;
201+
modules[basename(file.path)] = file.text;
195202
}
196203

197204
if (!modules["shell.js"]) {
@@ -200,7 +207,14 @@ if (!modules["shell.js"]) {
200207
);
201208
}
202209

203-
const partition = partitionModules(modules);
210+
// The graph (import edges) comes from esbuild's metafile; command
211+
// identity (which chunks are just-bash commands vs internal
212+
// diagnostics) comes from parsing shell.js's { name, load }
213+
// registry. partitionModules combines the two and throws if any
214+
// optional feature resolves to no command chunk.
215+
const graph = buildModuleGraph(result.metafile.outputs);
216+
const registry = parseCommandRegistry(modules["shell.js"]);
217+
const partition = partitionModules({ graph, registry, optionalFeatures: OPTIONAL_FEATURES });
204218

205219
// Emit one generated file per group. shell-modules.ts imports
206220
// each by its @cloudflare/computer/shell/<group> subpath and
@@ -234,107 +248,3 @@ console.log(
234248
`Wrote ${outDir} (core ${coreCount} modules, shell.js ${mainBytes} bytes, ` +
235249
`features: ${featureSummary}, total ${totalBytes} bytes)`,
236250
);
237-
238-
// Assign every emitted module to exactly one group: "core" or one
239-
// of the OPTIONAL_FEATURES keys. A module belongs to a feature
240-
// only when that feature is its sole reacher and core can't reach
241-
// it; everything else — shared chunks, chunks reachable from a
242-
// kept command, the shell.js entry itself — stays in core.
243-
function partitionModules(mods) {
244-
const names = Object.keys(mods);
245-
const staticEdges = new Map();
246-
const dynamicEdges = new Map();
247-
for (const name of names) {
248-
staticEdges.set(name, moduleEdges(mods[name], /* dynamic */ false));
249-
dynamicEdges.set(name, moduleEdges(mods[name], /* dynamic */ true));
250-
}
251-
252-
const closure = (starts, followDynamic) => {
253-
const seen = new Set();
254-
const stack = [...starts];
255-
while (stack.length > 0) {
256-
const cur = stack.pop();
257-
if (seen.has(cur) || !mods[cur]) continue;
258-
seen.add(cur);
259-
for (const next of staticEdges.get(cur) ?? []) stack.push(next);
260-
if (followDynamic) for (const next of dynamicEdges.get(cur) ?? []) stack.push(next);
261-
}
262-
return seen;
263-
};
264-
265-
const registry = parseCommandChunks(mods["shell.js"]);
266-
267-
const optionalCommands = new Set(Object.values(OPTIONAL_FEATURES).flat());
268-
269-
// Core reach: everything statically pulled by shell.js (the
270-
// always-parsed entry) plus the full closure of every command
271-
// that isn't optional. Dynamic edges out of shell.js are the
272-
// per-command import() fan-out — following them would drag every
273-
// optional chunk into core, so core uses shell.js's static edges
274-
// only.
275-
const coreReach = closure(["shell.js"], /* dynamic */ false);
276-
for (const [command, chunk] of Object.entries(registry)) {
277-
if (!optionalCommands.has(command)) {
278-
for (const m of closure([chunk], /* dynamic */ true)) coreReach.add(m);
279-
}
280-
}
281-
282-
// Each feature reaches the full closure of its command entry
283-
// chunks.
284-
const featureReach = new Map();
285-
for (const [feature, commands] of Object.entries(OPTIONAL_FEATURES)) {
286-
const roots = commands.map((c) => registry[c]).filter(Boolean);
287-
featureReach.set(feature, closure(roots, /* dynamic */ true));
288-
}
289-
290-
const partition = { core: [] };
291-
for (const feature of Object.keys(OPTIONAL_FEATURES)) partition[feature] = [];
292-
for (const name of names) {
293-
const owners = [];
294-
if (coreReach.has(name)) owners.push("core");
295-
for (const feature of Object.keys(OPTIONAL_FEATURES)) {
296-
if (featureReach.get(feature).has(name)) owners.push(feature);
297-
}
298-
const optionalOwners = owners.filter((o) => o !== "core");
299-
if (!owners.includes("core") && optionalOwners.length === 1) {
300-
partition[optionalOwners[0]].push(name);
301-
} else {
302-
partition.core.push(name);
303-
}
304-
}
305-
return partition;
306-
}
307-
308-
// Import specifiers a module references. Static edges are the
309-
// top-level `import`/`export … from` and bare side-effect
310-
// imports; dynamic edges are `import(...)` calls. Only relative
311-
// chunk specifiers matter — externals resolve at runtime.
312-
function moduleEdges(source, dynamic) {
313-
const targets = new Set();
314-
if (dynamic) {
315-
for (const m of source.matchAll(/import\("(\.\/[^"]+)"\)/g)) {
316-
targets.add(m[1].replace(/^\.\//, ""));
317-
}
318-
return targets;
319-
}
320-
for (const m of source.matchAll(/(?:import|export)[^;]*?from\s*"(\.\/[^"]+)"/g)) {
321-
targets.add(m[1].replace(/^\.\//, ""));
322-
}
323-
for (const m of source.matchAll(/import\s*"(\.\/[^"]+)"/g)) {
324-
targets.add(m[1].replace(/^\.\//, ""));
325-
}
326-
return targets;
327-
}
328-
329-
// Map each just-bash command to the chunk its lazy loader
330-
// imports. The registry entries look like
331-
// { name: "curl", load: async () => (await import("./chunk-…js")).curlCommand }
332-
function parseCommandChunks(shellSource) {
333-
const registry = {};
334-
const re =
335-
/\{\s*name:\s*"([^"]+)",\s*load:\s*async\s*\(\)\s*=>\s*\(await import\("(\.\/chunk-[^"]+)"\)\)/g;
336-
for (const m of shellSource.matchAll(re)) {
337-
registry[m[1]] = m[2].replace(/^\.\//, "");
338-
}
339-
return registry;
340-
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Pure partitioning logic for build-bundle.mjs, split out so it can
2+
// be unit-tested against synthetic module graphs without running a
3+
// real esbuild bundle.
4+
//
5+
// The problem: esbuild emits shell.js plus a flat set of
6+
// chunk-<hash>.js code-split fragments. We assign each fragment to
7+
// exactly one group — "core" (always shipped) or one optional
8+
// command feature — so a consumer that never imports a feature's
9+
// group drops the feature's exclusive chunks from its bundle.
10+
//
11+
// Two independent facts drive the assignment, and they come from
12+
// two different sources on purpose:
13+
//
14+
// 1. The module graph — which chunk imports which, and whether
15+
// the edge is a static `import` or a lazy `import()`. This
16+
// comes from esbuild's metafile (imports[].kind), not from
17+
// scraping the emitted source. The metafile is esbuild's own
18+
// structured account of the graph, so it survives codegen and
19+
// minification changes that would break a text scrape.
20+
//
21+
// 2. Command identity — which chunks are just-bash *commands*
22+
// (curl, python3, …) versus internal diagnostics that also
23+
// lazy-load chunks (e.g. flag-coverage, which fans out to
24+
// every command). Only just-bash's `{ name, load }` registry
25+
// in shell.js carries this: a diagnostic's dynamic edges must
26+
// not drag every command's heavy dependency into core, so we
27+
// follow dynamic edges only out of genuine commands. The
28+
// metafile can't distinguish the two — both look like
29+
// code-split entry points — so the registry parse stays.
30+
31+
import { basename } from "node:path";
32+
33+
// Build the module graph from esbuild's metafile `outputs`. Keys
34+
// are output basenames (shell.js, chunk-<hash>.js) to match the
35+
// modules record build-bundle.mjs assembles from result.outputFiles.
36+
// Static and dynamic edges are kept apart so the partitioner can
37+
// follow them selectively; external imports (node:*, workerd
38+
// built-ins) are dropped since they resolve at runtime, not from
39+
// the modules table.
40+
export function buildModuleGraph(metafileOutputs) {
41+
const modules = new Set();
42+
const staticEdges = new Map();
43+
const dynamicEdges = new Map();
44+
const entryPointOf = new Map();
45+
for (const [output, meta] of Object.entries(metafileOutputs)) {
46+
const name = basename(output);
47+
modules.add(name);
48+
const staticTargets = new Set();
49+
const dynamicTargets = new Set();
50+
for (const edge of meta.imports ?? []) {
51+
if (edge.external) continue;
52+
const target = basename(edge.path);
53+
if (edge.kind === "dynamic-import") dynamicTargets.add(target);
54+
else if (edge.kind === "import-statement") staticTargets.add(target);
55+
}
56+
staticEdges.set(name, staticTargets);
57+
dynamicEdges.set(name, dynamicTargets);
58+
if (meta.entryPoint) entryPointOf.set(name, basename(meta.entryPoint));
59+
}
60+
return { modules, staticEdges, dynamicEdges, entryPointOf };
61+
}
62+
63+
// Parse just-bash's lazy command registry out of shell.js. Each
64+
// entry looks like
65+
// { name: "curl", load: async () => (await import("./chunk-…js")).curlCommand }
66+
// Returns a command-name -> chunk-basename map. Diagnostics that
67+
// lazy-load chunks through a different shape (flag-coverage's
68+
// `{ …FlagCoverage } = await import(…)`) are deliberately not
69+
// matched: they are not commands, and following their fan-out would
70+
// pull every command's dependency into core.
71+
export function parseCommandRegistry(shellSource) {
72+
const registry = {};
73+
const re =
74+
/\{\s*name:\s*"([^"]+)",\s*load:\s*async\s*\(\)\s*=>\s*\(await import\("(\.\/chunk-[^"]+)"\)\)/g;
75+
for (const match of shellSource.matchAll(re)) {
76+
registry[match[1]] = basename(match[2]);
77+
}
78+
return registry;
79+
}
80+
81+
// Resolve each optional feature to the chunk(s) its command entries
82+
// load. A feature may list several command names (aliases such as
83+
// python/python3 or node/js-exec) that resolve to the same chunk;
84+
// duplicates collapse. Throws when a feature resolves to nothing:
85+
// that means the registry parse came back empty or the command
86+
// names drifted, and silently continuing would fold the feature's
87+
// heavy chunk into core — the exact regression the split exists to
88+
// prevent. Also throws if a resolved chunk is missing from the
89+
// module graph, which signals the registry and the emitted output
90+
// have drifted apart.
91+
export function resolveFeatureRoots(registry, optionalFeatures, modules) {
92+
const roots = new Map();
93+
for (const [feature, commands] of Object.entries(optionalFeatures)) {
94+
const chunks = new Set();
95+
for (const command of commands) {
96+
const chunk = registry[command];
97+
if (chunk !== undefined) chunks.add(chunk);
98+
}
99+
if (chunks.size === 0) {
100+
throw new Error(
101+
`partition: optional feature "${feature}" resolved no command chunk ` +
102+
`from the shell registry (commands: ${commands.join(", ")}). The ` +
103+
`registry parse likely broke or just-bash's command names changed; ` +
104+
`refusing to fold the feature's chunks into core silently.`,
105+
);
106+
}
107+
for (const chunk of chunks) {
108+
if (modules !== undefined && !modules.has(chunk)) {
109+
throw new Error(
110+
`partition: feature "${feature}" command chunk "${chunk}" is not in ` +
111+
`the emitted module set. The shell registry and the bundle output ` +
112+
`have drifted apart.`,
113+
);
114+
}
115+
}
116+
roots.set(feature, [...chunks]);
117+
}
118+
return roots;
119+
}
120+
121+
// Assign every emitted module to exactly one group: "core" or one
122+
// optional feature. A module belongs to a feature only when that
123+
// feature is its sole reacher and core can't reach it; everything
124+
// else — shared chunks, chunks a non-optional command reaches, the
125+
// shell.js entry — stays in core. That invariant is what makes
126+
// dropping one feature safe: no core code and no other feature can
127+
// depend on a feature's exclusive chunks.
128+
export function partitionModules({ graph, registry, optionalFeatures }) {
129+
const { modules, staticEdges, dynamicEdges } = graph;
130+
131+
const closure = (starts, followDynamic) => {
132+
const seen = new Set();
133+
const stack = [...starts];
134+
while (stack.length > 0) {
135+
const current = stack.pop();
136+
if (seen.has(current) || !modules.has(current)) continue;
137+
seen.add(current);
138+
for (const next of staticEdges.get(current) ?? []) stack.push(next);
139+
if (followDynamic) for (const next of dynamicEdges.get(current) ?? []) stack.push(next);
140+
}
141+
return seen;
142+
};
143+
144+
const featureRoots = resolveFeatureRoots(registry, optionalFeatures, modules);
145+
const optionalCommands = new Set(Object.values(optionalFeatures).flat());
146+
147+
// Core reach: everything statically pulled by shell.js (the
148+
// always-parsed entry) plus the full closure of every command
149+
// that isn't optional. Dynamic edges out of shell.js are the
150+
// per-command import() fan-out — following them would drag every
151+
// optional chunk into core, so core uses shell.js's static edges
152+
// only, then adds the closure of each kept command explicitly.
153+
const coreReach = closure(["shell.js"], /* followDynamic */ false);
154+
for (const [command, chunk] of Object.entries(registry)) {
155+
if (!optionalCommands.has(command)) {
156+
for (const name of closure([chunk], /* followDynamic */ true)) coreReach.add(name);
157+
}
158+
}
159+
160+
const featureReach = new Map();
161+
for (const [feature, roots] of featureRoots) {
162+
featureReach.set(feature, closure(roots, /* followDynamic */ true));
163+
}
164+
165+
const partition = { core: [] };
166+
for (const feature of Object.keys(optionalFeatures)) partition[feature] = [];
167+
for (const name of modules) {
168+
const optionalOwners = [];
169+
for (const feature of Object.keys(optionalFeatures)) {
170+
if (featureReach.get(feature).has(name)) optionalOwners.push(feature);
171+
}
172+
if (!coreReach.has(name) && optionalOwners.length === 1) {
173+
partition[optionalOwners[0]].push(name);
174+
} else {
175+
partition.core.push(name);
176+
}
177+
}
178+
return partition;
179+
}

0 commit comments

Comments
 (0)