docs/craft/features/bun-node-modules-dedup.md
Each Craft session in a sandbox currently runs npm install against the
Next.js template in outputs/web/. Measured cost per session on the
onyxdotapp/sandbox:v0.1.44 image:
| Per session | |
|---|---|
| node_modules size on disk | ~836 MB |
npm install wall time | ~30 s |
| node_modules file count | ~48,700 |
The user-shared sandbox model means one Docker named volume (or one K8s emptyDir) holds N session workspaces. Disk usage scales linearly with N:
| Sessions per sandbox | Volume size |
|---|---|
| 1 | ~715 MB |
| 5 | ~3.5 GB |
| 10 | ~7 GB |
This is fine on K8s (emptyDir is ephemeral, cluster has space) but bites on docker-compose deployments where the host is typically a single small EC2. It's also wasted I/O: ~95% of the time the lockfile is identical across sessions, so we're reinstalling the same tree.
npm install.npm install <new-pkg> mid-session must continue to work
and must not corrupt other sessions in the same sandbox.backend/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs/web/)
is shared by all session, and the package.json / lockfile in there is
the canonical version. Sessions never diverge from this lockfile unless
the agent explicitly modifies package.json.We replace npm install with bun install and structure
/workspace/sessions/ as a Bun workspace root. Key properties:
~/.bun/install/cache and uses hard-links by default when populating
a project's node_modules (configurable via --backend=hardlink, which
is the default on Linux). So per-session disk overhead drops to inode
metadata — effectively zero.package.json at /workspace/sessions/
with "workspaces": ["*/outputs/web"] makes Bun resolve once, share a
single global node_modules under /workspace/sessions/node_modules/,
and also maintain per-session node_modules/.bin shadows. Sessions
read their deps from the hoisted store via the workspace resolution
algorithm.bun install is typically 10–25× faster than npm install.
Cold session setup should drop from ~30 s to ~2–5 s./workspace/sessions/
├── package.json # workspace root (baked into image OR written at provision)
├── bun.lock # shared lockfile (baked into image)
├── node_modules/ # ONE hoisted tree per sandbox (Bun-managed)
│ └── <flat deps>
├── .shared/
│ └── ... (already-used for snapshot bootstraps if any)
└── <session_id>/
└── outputs/
└── web/
├── package.json # workspace member, references hoisted deps
└── node_modules/ # workspace-local symlinks/.bin entries
The setup script becomes:
session/outputs/web/.node_modules/ is populated. On first session,
bun install --frozen-lockfile at the workspace root. Subsequent
sessions: no-op (the hoisted tree is already there).postinstall ensures workspace members have their resolution
wired up.bun install <new-pkg> mid-sessionBun's workspace install will add <new-pkg> to the member's
package.json and the hoisted root node_modules. Other sessions in the
same sandbox would see the new package's resolution but wouldn't import
it (it's not in their package.json). The only risk is if the new
package's version upgrades a transitive shared with another session's
expectations; Bun handles this via the hoisted-vs-nested fallback in the
workspace algorithm.
If full isolation per session is required after all, we fall back to one of two patterns:
bun install with --no-save --linker=isolated inside the
member (Bun supports an isolated-installs mode that mirrors pnpm's
per-project store). Each session gets its own dep tree backed by the
global cache.bun install per session. Bun's
hardlink backend still gives us O(file count) inodes but ~0 disk per
session because all files are hardlinked from the global cache. This
is the simplest fallback and likely "good enough" without workspaces.backend/onyx/server/features/build/sandbox/kubernetes/docker/Dockerfile:
curl -fsSL https://bun.sh/install | bash).bun install --frozen-lockfile
in the template's outputs/web/ to populate Bun's cache. The cache is
~50 MB of compressed tarballs (vs. ~836 MB of extracted node_modules)
so image growth is bounded.bun.lock into the template.cd outputs/web && npm install with cd outputs/web && bun install --frozen-lockfile
(or, with workspaces, run once at the workspace root)._build_nextjs_start_script so dev runs via bun run dev
(Bun runs Next.js fine; only the package-manager call changes).package.json at /workspace/sessions/ defining */outputs/web
as workspace members.package.json at provision
time, then run bun install at the workspace root instead of per session.package-lock.json matches the
template's, skip per-session install entirely; if not, fall back to
isolated install just for that session.Same problem exists for /workspace/.venv if sessions ever start mutating
deps. Out of scope for this plan but worth tracking.
du -sh /workspace/sessions/ is well under 1 GB.bun run dev and serve the Next.js preview.bun install lodash in session A doesn't break session B.setup_session_workspace cold (first session) and
warm (2nd+ session in same sandbox). Target: < 5 s warm.--linker=isolated actually exist and work for our deps?
Bun has been moving fast in this space — needs verification before
committing to the workspace approach.bun run dev
vs npm run dev? Likely fine but worth confirming with the template's
exact version of Next.js (16.x).npm specifically? We can audit via grep -r postinstall package.json
in the template.