Skip to content

Add Bun support via a runtime-adaptive HTTP backend - #337

Open
IshanArya wants to merge 10 commits into
nikeee:mainfrom
IshanArya:main
Open

Add Bun support via a runtime-adaptive HTTP backend#337
IshanArya wants to merge 10 commits into
nikeee:mainfrom
IshanArya:main

Conversation

@IshanArya

Copy link
Copy Markdown

Summary

lean-s3 was built on undici, which doesn't run on Bun (Bun ships an incomplete undici — no usable Agent/Dispatcher, missing BodyReadable.dump()). Calling lean-s3 under Bun crashed with response.body.dump is not a function.

This PR makes lean-s3 work on both Node.js and Bun while keeping the fast undici path on Node, by introducing a runtime-adaptive HTTP layer.

Why not just use Bun's built-in S3 API?

Bun ships a native Bun.S3Client, but it's limited compared to lean-s3, so it's not a drop-in replacement for users who need the full feature set. Notable gaps include:

  • No CopyObject — you can't copy objects server-side.
  • presign for PUT doesn't support constraining content length / content type the way lean-s3 does (e.g. presign(key, { method: "PUT", contentLength, type })), and presign-POST policies are limited.
  • Generally a narrower surface than lean-s3 (bucket CORS, fine-grained multipart control, DeleteObjects, etc.).

So rather than telling Bun users to fall back to Bun.S3Client and lose functionality, this PR makes lean-s3 itself run on Bun with the same API and feature set everywhere.

What changed

  • New src/http.ts — a small HTTP abstraction exposing a single request() + normalized response shape (statusCode, lower-cased headers, and a body with text() / dump() / streaming on("data"|"end"|"error")). It selects a backend once at runtime and caches it:
    • Node.js → undici (the original fast path, unchanged behavior), via a shared Agent.
    • Bun / any runtime without usable undici → global fetch, normalized to the same shape. The streaming read path reads the web ReadableStream directly (no Readable.fromWeb round-trip) to keep reads cheap.
  • undici moved from a hard dependency to an optionalDependency; S3Client.ts and error.ts now go through the shim instead of importing undici directly.
  • Bug fix in S3File.stream() cancellation: cancelling a stream reader could throw Controller is already closed when the in-flight request rejected afterward. Fixed by sharing a settled flag between the stream's start and cancel. Benefits Node too.
  • Runner-agnostic tests (src/test-harness.ts): the suite was written for node:test + the expect package, which don't work cleanly under Bun (bun test partially implements node:test, and expect@30 has a Bun interop bug). The harness maps describe/test/before/after/expect to node:test+expect on Node and bun:test on Bun (bridging t.skip/t.todo and describe(name, { skip }, fn)), so the exact same test files run under both runtimes.
  • LEAN_S3_FORCE_FETCH=1 escape hatch to force the fetch backend on Node (debugging / odd environments).
  • Added a docker-compose.yml for local MinIO testing and a test:bun script.
  • Docs: updated DESIGN_DECISIONS.md (dual-backend rationale), CONTRIBUTING.md (Bun + Docker socket notes), and BENCHMARKS.md (Node-vs-Bun comparison).

Performance

Benchmarked against a local MinIO. Bun (fetch) is at parity with or faster than Node (undici) across put/get/list, and substantially faster on HeadObject. On Node, the direct undici path beats fetch (most visibly on writes) — which is exactly why undici stays the Node default. Full numbers are in BENCHMARKS.md.

Testing

  • Typecheck, lint (oxlint), format (oxfmt): clean.
  • Node (npm test): full unit + integration suite (MinIO, LocalStack, S3Mock, Ceph, Garage) green.
  • Bun (npm run test:bun): same suite green (255 pass).
  • Cancellation suite passes on Node, Bun, and Node-with-forced-fetch.
  • Built dist verified working on both runtimes against MinIO.

Notes / compatibility

  • No public API changes; existing Node consumers are unaffected (still undici by default).
  • undici is now optional — installs that skip optional deps fall back to fetch automatically.

@IshanArya

IshanArya commented Jun 30, 2026

Copy link
Copy Markdown
Author

Hey @nikeee is this of interest to you? Otherwise I can maintain it on the side. Appreciate your work.

@nikeee

nikeee commented Jun 30, 2026

Copy link
Copy Markdown
Owner

In general, this is something I'd consider adding to lean-s3.

This pr in particular touches a little bit too much for my taste (e.g., why the CI files?).

Bun is not a primary target, and I don't want to support bun:test due to that. I think the correct way is to wait for Bun to implement node:test.

We're also focusing on the server-side, so support for fetch is too much. Also, the browser doesn't have node:crypto, which we need for the sync version of hashing/signing. Using it in the browser wouldn't even work.

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.

2 participants