|
| 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