Skip to content

Make WorkerShellBackend commands opt-in to reduce bundle size - #41

Open
aron-cf wants to merge 3 commits into
mainfrom
just-bash-components
Open

Make WorkerShellBackend commands opt-in to reduce bundle size#41
aron-cf wants to merge 3 commits into
mainfrom
just-bash-components

Conversation

@aron-cf

@aron-cf aron-cf commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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 new commands option:

import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell";
import curlModules from "@cloudflare/computer/shell/curl";
import sqliteModules from "@cloudflare/computer/shell/sqlite";

new WorkerShellBackend({
  loader: self.env.LOADER,
  workspace: { binding: "Agent", id: self.ctx.id.toString() },
  ctx: self.ctx,
  commands: [curlModules, sqliteModules], // core is always included
});

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 are curl, html-to-markdown, python, sqlite, js-exec, yq, file, xan, and jq.

build-bundle.mjs (esbuild splitting: true)
        │
        ├── core group          → @cloudflare/computer/shell/core     (imported by the package)
        └── one group / command → @cloudflare/computer/shell/<feature> (imported by the consumer)

shell-modules.ts
   SHELL_CORE_MODULES                 = core
   assembleShellModules([...groups])  = { ...core, ...groups }

WorkerShellBackend({ commands: [curlModules, ...] })
   Loader modules table = assembleShellModules(commands) + runtime shims

build-bundle.mjs runs 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.ts imports only the core group and exposes assembleShellModules, which folds a caller-supplied list of groups on top of core. WorkerShellBackend takes the imported groups from its commands option and builds the Loader modules table from them. Because the package references only core, an optional group is reachable only through the consumer's own import — "not imported, not bundled", the same elimination the bundler gives any unused module (the package is marked sideEffects: false so it is free to drop unused groups). Consumers that build the Loader callback by hand — the fetcher path — call assembleShellModules([...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.

curl runs on a SecureFetch adapter over the isolate's global fetch; undici is redirected to a throwing stub at build time and never ships. Egress stays governed by the Dynamic Worker's globalOutbound (left closed), so including curl does not by itself open the network.

Measured with wrangler deploy --dry-run on examples/worker-shell. Baseline is core only (commands: []); delta is the marginal cost of importing that one group, since shared code stays in core.

Group Δ raw Δ gzip
html-to-markdown +544 KiB +100 KiB
yq +291 KiB +75 KiB
file +101 KiB +26 KiB
sqlite +102 KiB +24 KiB
xan +98 KiB +23 KiB
curl +16 KiB +4 KiB
js-exec +12 KiB +4 KiB
python +11 KiB +3 KiB
jq +11 KiB +3 KiB
Config Raw Gzip
Core only (commands: []) 3,293 KiB 759 KiB
Core + curl + sqlite (this example) 3,411 KiB 787 KiB

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), the grep family (~80 KiB), tar (~68 KiB), gzip/gunzip (~19 KiB), and diff/patch (~19 KiB). Note re2js (~225 KiB) is shared by grep, 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 curl registers when its group is passed to commands. The full package suite runs 913 tests across 55 files under the default config, plus the 5-test worker-shell integration run under vitest.config.worker-backend.ts.

@aron-cf aron-cf changed the title Select worker shell commands via import Make WorkerShellBackend commands opt-in to reduce bundle size Aug 3, 2026
@aron-cf
aron-cf force-pushed the just-bash-components branch from a78c1f7 to 5342703 Compare August 3, 2026 12:24
@pkg-pr-new

pkg-pr-new Bot commented Aug 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@41

commit: d926aef

aron-cf added 3 commits August 4, 2026 15:45
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
aron-cf force-pushed the just-bash-components branch from 5342703 to d926aef Compare August 4, 2026 21:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant