Make WorkerShellBackend commands opt-in to reduce bundle size - #41
Open
aron-cf wants to merge 3 commits into
Open
Make WorkerShellBackend commands opt-in to reduce bundle size#41aron-cf wants to merge 3 commits into
aron-cf wants to merge 3 commits into
Conversation
aron-cf
force-pushed
the
just-bash-components
branch
from
August 3, 2026 12:24
a78c1f7 to
5342703
Compare
commit: |
Split the worker shell bundle into a core group and one optional group per heavy command, and select commands by importing their group rather than by a build-time flag. build-bundle.mjs runs esbuild with splitting and partitions the emitted modules into a core group (every always-on command plus the ShellWorker entry) and one group per optional command: curl, html-to-markdown, python, sqlite, js-exec, yq, file, xan, and jq. Each group is published on its own @cloudflare/computer/shell/* subpath. shell-modules.ts imports only the core group and exposes assembleShellModules, which folds a caller-supplied list of groups on top of core. WorkerBackend gains a commands option that takes the imported groups and assembles the Loader modules table from them. Because nothing in the package references the optional groups, a group the consumer never imports is unreachable in their module graph and the bundler drops it, along with its exclusive dependencies. Opting a command in is a single import; there is no default-on cost to opt out of and no flag to set. The package is marked sideEffects: false so the bundler is free to elide unused groups. curl runs on a SecureFetch adapter over the isolate's global fetch rather than undici, which is redirected to a throwing stub at build time and never ships. Egress stays governed by the Dynamic Worker's globalOutbound, so including curl does not by itself open the network.
Update the worker example and the worker-backend docs to the import-based command selection. The example imports the curl and sqlite groups and passes them to WorkerBackend's commands option, demonstrating that opting a command in is a single import and opting out is deleting it. The package README, the example README, and docs/12_worker_backend.md describe the always-on core, the per-command groups published at @cloudflare/computer/shell/*, the commands option, and the assembleShellModules helper for callers that build the Loader callback by hand.
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.
aron-cf
force-pushed
the
just-bash-components
branch
from
August 4, 2026 21:52
5342703 to
d926aef
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The worker-shell backend runs a just-bash shell inside a Dynamic Worker. That shell ships as source strings in the consumer's Worker upload, so every command it supports — and every dependency those commands pull in — adds to the bundle. Many of those commands are heavy and rarely needed.
This change splits the shell into an always-on core plus one optional group per heavy command, and lets a consumer choose which commands ship by importing the group and passing it to
WorkerShellBackend's newcommandsoption:Core carries the always-on command set (
cat,ls,grep,sed,awk,sort, …). A group you never import is unreachable in your module graph, so the bundler drops it along with its exclusive dependencies. Import nothing and you ship only core. The optional groups arecurl,html-to-markdown,python,sqlite,js-exec,yq,file,xan, andjq.build-bundle.mjsruns esbuild with code splitting and cuts the emitted modules into the core group and one group per optional command, each published on its own@cloudflare/computer/shell/*subpath.shell-modules.tsimports only the core group and exposesassembleShellModules, which folds a caller-supplied list of groups on top of core.WorkerShellBackendtakes the imported groups from itscommandsoption and builds the Loader modules table from them. Because the package references only core, an optional group is reachable only through the consumer's ownimport— "not imported, not bundled", the same elimination the bundler gives any unused module (the package is markedsideEffects: falseso it is free to drop unused groups). Consumers that build the Loader callback by hand — thefetcherpath — callassembleShellModules([...groups])directly.Splitting is only safe if the build knows exactly which chunk belongs to which command, so no command's heavy dependency leaks into core and no group is missing a chunk it needs at runtime. The partitioner takes the module graph from esbuild's build metadata, where every import edge is already tagged as static or lazy, rather than scanning the emitted source with regular expressions that a change in code generation could silently break. It reads one thing from the generated source that the metadata cannot express: just-bash's command table, which is the only signal that tells a real command apart from an internal diagnostic whose lazy imports reach every command and must not be followed into core. If any optional group fails to resolve to a command chunk, the build throws rather than quietly folding that group's dependency back into core — the exact regression the split exists to prevent.
curlruns on aSecureFetchadapter over the isolate's globalfetch;undiciis redirected to a throwing stub at build time and never ships. Egress stays governed by the Dynamic Worker'sglobalOutbound(left closed), so includingcurldoes not by itself open the network.Measured with
wrangler deploy --dry-runonexamples/worker-shell. Baseline is core only (commands: []); delta is the marginal cost of importing that one group, since shared code stays in core.html-to-markdownyqfilesqlitexancurljs-execpythonjqcommands: [])curl+sqlite(this example)Excluding
undici(build-side, unconditional) accounts for a further ~620 KiB raw / ~170 KiB gzip that no longer ships.Core still bundles some heavy always-on commands that could become opt-in groups later:
awk(~86 KiB), thegrepfamily (~80 KiB),tar(~68 KiB),gzip/gunzip(~19 KiB), anddiff/patch(~19 KiB). Notere2js(~225 KiB) is shared bygrep,awk, and bash regex, so it only leaves core if all of them become opt-in together. Minifying the generated groups would take a further ~617 KiB raw / ~66 KiB gzip off core with no feature loss.The partitioner's assignment rules are covered by unit tests over synthetic module graphs, including the diagnostic-fan-out case that would otherwise pull a heavy dependency into core. The end-to-end split is checked against the generated output, and the worker-shell integration suite proves the fetch-path
curlregisters when its group is passed tocommands. The full package suite runs 913 tests across 55 files under the default config, plus the 5-test worker-shell integration run undervitest.config.worker-backend.ts.