ADR-014: bun as the package manager, Node as the runtime¶
Status: Accepted (2026-07-19)
Context¶
web/ ran pnpm 10 for dependency management with Node 22 as the runtime.
v3 (spec_v3 §7) consolidates on bun 1.3.14 — but bun is two products
in one binary: a package manager and a JavaScript runtime/test runner.
The decision that needed recording is where the line sits, because "use
bun" without a scope line invites bun --bun next build and bun test
by drift, and those change what executes in production and what gates
coverage. ADR-005's amendment
records that the migration happened; this ADR records the
scope line and why it sits where it does.
Decision¶
- bun is the package manager only.
bun installowns dependency resolution (web/bun.lock); every script still executes under Node via shebang delegation —bun run buildinvokes the samenextbinary whose#!/usr/bin/env nodeline picks Node 22. No--bun, nobun test, no runtime swap anywhere:web/Dockerfile'sbase/deps/buildstages moved tooven/bun:1.3.14-alpine, and therunstage staysnode:22-alpineexecutingnode server.js. - Vitest stays the test runner, with its four coverage floors
unchanged.
bun testwas rejected on three verified grounds: it ignoresvitest.config.ts, its mocking API differs, and — decisive — its coverage reporting has no threshold enforcement, which would silently drop the floors CI gates on. - The runtime swap is deferred behind two unresolved upstream risks
that land on this exact stack:
sharpunder bun on Alpine in CI (lovell/sharp#4215, unresolved) and Next'soutput: "standalone"under the bun runtime (no official word). Recorded as a post-v3 spike, not a v3 stretch goal. trustedDependencieswas translated by polarity, not mechanically. pnpm'signoredBuiltDependenciesis a default-allow blocklist; bun'strustedDependenciesis a default-deny allowlist over a curated built-in list.sharpsits on bun's default-trusted list — its postinstall now runs where pnpm blocked it (the vendor default for Next's own image library, and strictly required fornext/image).unrs-resolveris not default-trusted; it is allowlisted explicitly inweb/package.jsonas cheap insurance against the known "failed to load native binding" lint failure. Both are transitive dependencies — neither appears independenciesat all.- CI pins both coordinates the spec conflated:
oven-sh/setup-bun@v2.2.0is the action tag;bun-version: 1.3.14is the bun release — there is no bun 2.x.actions/setup-node@v4is kept atnode-version: 22(matching the Dockerfilerunstage) because Node is still what executes Vitest and Next; its pnpm-specific cache inputs are gone, replaced by an explicitactions/cacheon~/.bun/install/cache(setup-buncaches only the bun binary, andsetup-nodehas no bun cache type). - The lockfile conversion is one-way and must stay one-way:
bun installauto-converts apnpm-lock.yamlonce, onbun.lockabsence — sopnpm-lock.yamlandpnpm-workspace.yamlwere deleted in the same commit that addedbun.lock. Two lockfiles in the repo means the next contributor's install silently re-converts a stale file. The"packageManager"field was deleted too — corepack has no bun support, and bun neither reads nor writes it.
Rationale¶
- The migration's value is install speed and one fewer tool, not
runtime performance: a cold-cache
bun install --frozen-lockfilemeasured ~3.2 s for 779 packages. This app's latency is dominated by two local GPUs; swapping the JS runtime buys nothing measurable and carries the two open risks above — which is why the aggressive shape doesn't clear the bar. - Proven equivalence before the cutover: the bun-resolved dependency tree was diffed against pnpm's — 868/868 packages identical — so the swap changed the installer, not the installed.
- Playwright's browser install became a documented step
(
bunx playwright install --with-depsin the runbook): no lifecycle script ever installed browsers under pnpm either; the migration just made the implicit explicit.
Consequences¶
- One JS toolchain command surface:
bun install/bun run <script>/bunx …— nevernpm/yarn/pnpm. Scripts stayed byte-identical, including the POSIX${VAR:-default}expansion ingen:types(on Linuxbun rundelegates to a real shell). sharp's postinstall now runs. If it ever misbehaves, bun has no blocklist to reach for — the recorded fallback is pinning the@img/sharp-*platform packages explicitly.- The web CI job caches by
bun.lockhash; a lockfile change is the cache key change. - Node remains a hard prerequisite alongside bun (contributors need both; the Dockerfile stages encode the same split).
- Revisit trigger, recorded: the runtime spike (
bun --bun,bun test) becomes worth re-opening if sharp#4215 resolves and Vitest coverage thresholds land inbun test— both, not either.