6.1 KiB
Design: Bun-native filesystem adapter for @suaveplan/storage-file
This document is non-normative. The normative contract lives in
./specs/storage-file/spec.md.
Background
NodeFsStorage consumes a structural NodeFsLike interface that mirrors a
strict subset of node:fs/promises plus the two node:fs stream constructors
and open. The shape was chosen so the adapter is injectable for tests
without vi.mock and so callers running outside Node-the-runtime can plug in
a shim.
defaultNodeFs() is the canonical implementation. It is a thin wrapper over
node:fs/promises. Under Bun, every call goes through Bun's Node-compat
layer — which is a real native (Zig) implementation under the hood for most
methods, but readFile and writeFile specifically take a longer path
through Buffer / Uint8Array reshaping than the Bun-native APIs.
Bun's native fast path
Bun exposes:
Bun.file(path)— a lazyBunFilehandle..bytes(): Promise<Uint8Array>— returns the file contents as a freshUint8Array. This is the canonical replacement fornode:fs/promises.readFile(path)when the caller wants aUint8Array.- Throws
ENOENT-shaped errors (with.code === "ENOENT") when the file is missing — same surfaceNodeFsStoragealready handles viaisEnoent.
Bun.write(path, data): Promise<number>— writesdata(aUint8Array, string, orBunFile) and returns the byte count. The byte count is discarded by the adapter (we only need void).
These two methods are the only Bun-native fast paths that have no
behaviourally identical node:fs/promises equivalent — every other method
on NodeFsLike either has no Bun-native equivalent (mkdir, readdir,
stat, access, rename, unlink, open) or already uses Bun's native
implementation transparently via the Node-compat layer (streams).
Adapter design
src/bun-fs/bun-fs.ts exports defaultBunFs(): NodeFsLike. The factory
returns a fresh object so callers can selectively override individual methods
— same convention defaultNodeFs() uses. The implementation is intentionally
small:
defaultBunFs():
readFile(path) → Bun.file(path).bytes()
writeFile(path, data) → Bun.write(path, data); void
every other method → node:fs/promises (same call defaultNodeFs makes)
The "every other method" set is large enough that we DO NOT duplicate
defaultNodeFs()'s body. Instead defaultBunFs() constructs a base
NodeFsLike via defaultNodeFs() and overrides readFile/writeFile. This
keeps the diff between the two adapters minimal and audit-friendly.
writeFile accepts Uint8Array | string. Bun.write accepts both natively.
The adapter does not buffer or copy.
readFile returns Uint8Array. Bun.file(path).bytes() already returns a
fresh Uint8Array, so no copy is needed.
Runtime detection in resolveConfig
resolveConfig() (in node-fs-storage.helpers.ts) currently falls back to
defaultNodeFs() when no fs is injected. This change makes the fallback
runtime-aware via a pickDefaultFs() helper exported from
src/bun-fs/bun-fs.ts:
pickDefaultFs():
if (typeof globalThis.Bun !== "undefined") return defaultBunFs()
return defaultNodeFs()
globalThis.Bun is the documented detection probe. The detection is done
at resolveConfig time, not at module-import time, so test harnesses can
inject either factory deterministically regardless of host runtime.
When a caller passes fs explicitly, the detection branch is not taken.
This preserves every existing test that injects a fixture NodeFsLike.
File layout
packages/storage/storage-file/src/bun-fs/
bun-fs.ts # defaultBunFs() + pickDefaultFs()
bun-fs.test.ts # tests under @suaveplan/testing/runner
bun-fs.md # sibling doc
index.ts # explicit named re-export
src/node-fs-storage/node-fs-storage.helpers.ts switches its default-fs
resolution from defaultNodeFs() to pickDefaultFs().
src/index.ts adds defaultBunFs and pickDefaultFs to the public surface.
package.json exports map gains ./bun-fs/bun-fs.
Test plan
- Unit test: round-trips bytes through
defaultBunFs().writeFile/defaultBunFs().readFileon a real temp dir. The mirror test for the Node adapter issrc/node-fs/node-fs.test.ts::"forwards every method to node:fs/promises"; the Bun test asserts the same invariants. - Unit test:
defaultBunFs()forwardsmkdir,readdir,stat,access,rename,unlink,open, and the two stream constructors to thenode:fs/promises-backed delegate. Proven by composing on a real temp dir — no mocking. - Unit test:
pickDefaultFs()returns aNodeFsLikewhosereadFileexists and is callable; under Bun the runtime probe selects the Bun factory (the test runs under Bun via@suaveplan/testing/runner). - Unit test:
NodeFsStorageconstructed without anfsoption under Bun uses the Bun-backed default — verified end-to-end by setting and getting a value, then asserting the resulting bytes match. (This is the spec-conformance scenario "auto-picks the Bun default under Bun".) - All existing tests continue to pass — the runtime-aware default is
backwards compatible with every fixture that injects
fs.
Trade-offs
- We deliberately do NOT route
createReadStream/createWriteStreamthroughBun.file(path).stream(). Bun'sBunFile.stream()returns a WebReadableStream, butNodeFsLike.createReadStreamis typed as a NodeReadable. Translating between the two introduces a copy and an API impedance mismatch that erases the speedup. The Nodenode:fsstream constructor already dispatches to Bun's native impl under Bun. - We do NOT introduce a separate
BunFsLikeinterface. The whole point of the adapter is to satisfy the existingNodeFsLikecontract so consumers do not branch on runtime. pickDefaultFs()performs the runtime probe on everyresolveConfigcall. The cost is onetypeofcheck and one object allocation — both trivial compared to the filesystem syscall the caller is about to make. Caching the choice in module scope would defeat test injection.