#4604·nitro

bun preset: `event.node.req` is a non-readable unenv mock — raw body proxying (`fetch(url, { body: event.node.req })`) throws `Readable.asyncIterator is not implemented yet!`, and manual stream wraps read an empty body

Author: Katyara11Created Sep 9, 2026Updated Sep 16, 2026
Labelspending triage

Environment

  • nitropack 2.13.4 (latest), unenv 2.0.0-rc.24
  • Bun 1.3.14, 1.4.2, and 1.4.3 (the 1.4.3 run reproduced by a Bun maintainer — see Additional context)
  • Reproduced on macOS arm64, Debian (Docker), and Linux x64
  • No custom nitro config — NITRO_PRESET=bun nitro build, served with bun .output/server/index.mjs
  • Works correctly with NITRO_PRESET=node-server under Node — bun preset only

Reproduction

Inline below rather than a StackBlitz/CodeSandbox link: the starters run Node WebContainers and cannot execute the Bun runtime this bug requires. It is three tiny files with no external services, and it has already been reproduced independently by a Bun maintainer from this exact snippet (Bun 1.4.3, Linux x64 — link in Additional context).

package.json

json
{
  "name": "nitro-bun-raw-proxy-repro",
  "private": true,
  "dependencies": { "nitropack": "^2.13.4" }
}

routes/proxy.post.ts

typescript
// Standard raw pass-through: stream the incoming body to an upstream
// (the usual BFF pattern for multipart uploads).
export default defineEventHandler(async (event) => {
  const res = await fetch('http://localhost:9999/echo', {
    method: 'POST',
    headers: { 'content-type': 'text/plain' },
    body: event.node.req as unknown as ReadableStream,
    // @ts-expect-error duplex is required for stream bodies
    duplex: 'half'
  })
  return { upstreamStatus: res.status, upstreamBody: await res.text() }
})

upstream.ts

typescript
Bun.serve({
  port: 9999,
  async fetch(req) {
    const text = await req.text()
    return Response.json({ receivedBytes: text.length })
  }
})
console.log('upstream on 9999')

Run:

bash
bun install
NITRO_PRESET=bun bun node_modules/.bin/nitro build
bun upstream.ts &
PORT=9998 bun .output/server/index.mjs &
curl -s -X POST http://localhost:9998/proxy -H 'content-type: text/plain' --data 'hello-from-repro'

Actual: {"error":true,"url":"http://localhost:9998/proxy","statusCode":500,...} with Readable.asyncIterator is not implemented yet! in the server log (see Logs).

Expected (and what NITRO_PRESET=node-server + Node returns for the identical project): {"upstreamStatus":200,"upstreamBody":"{\"receivedBytes\":16}"}.

Describe the bug

Under the bun preset, event.node.req is unenv's mock Readable, not a readable stream. The preset runtime (nitropack/dist/presets/bun/runtime/bun.mjs) reads the whole body up front with req.arrayBuffer() and passes the buffer to nitroApp.localFetch(...), which builds event.node.req from unenv's mock IncomingMessage. The mock:

  • hardcodes readableEnded = true
  • never emits data
  • throws from Symbol.asyncIterator on first call

So any raw body pass-through fails in one of two ways:

  1. Loudfetch(upstream, { body: event.node.req, duplex: 'half' }): fetch consumes the body via the async iterator, the stub throws, the route 500s, and the upstream request is never sent. Runtime-agnostic: undici consumes async-iterable bodies the same way, so the mock is unreadable by design — a Bun maintainer verified Bun's fetch streams a real node:stream Readable and a real http.IncomingMessage body correctly.
  2. Silent (worse) — wrapping event.node.req in a manual web ReadableStream (data/end listeners plus a readableEnded pre-check): the wrapper sees readableEnded === true, closes immediately, and forwards a 0-byte body. Silent data loss — the upstream receives {"receivedBytes":0} for a 16-byte request.

Expected: event.node.req behaves as a readable carrying the request body, as it does under node-server. The body demonstrably exists in the preset runtime — it was already buffered by req.arrayBuffer() — it just is not wired into the node-compat request. Either side could fix it: the bun preset hands the buffered body to a real Readable, or the unenv mock iterates over the buffered body instead of throwing.

A deploy-path note on impact: builds inside oven/bun images resolve node to the image's bun-node fallback shim, so nitro auto-selects the bun preset even when the build command looks Node-based — teams can be on this preset without knowing it, and the silent variant means raw proxies can appear to work while dropping every uploaded byte.

Additional context

  • First filed against Bun and closed as not-planned after a maintainer ran this repro and confirmed the analysis above (Bun 1.4.3, Linux x64): <BUN_ISSUE_URL>. Their conclusion: "Bun cannot read a body from an object that throws when it is read. Please report this to nitrojs/nitro (the bun preset could hand the buffered body to a real Readable) or unjs/unenv (the mock could iterate over the buffered body)."

  • The bundled mock, at the throw site in .output/server/index.mjs:

    javascript
    function o(n){throw new Error(`${n} is not implemented yet!`)}
    class i extends EventEmitter{__unenv__={};readableEnded=true;/* … */
      async*[Symbol.asyncIterator](){throw o("Readable.asyncIterator")} /* … */}
  • Workaround that works today: h3's getRequestWebStream(event) — it detects the shimmed request (__unenv__ marker) and returns the raw body h3 buffered, forwarding correctly under both presets and both runtimes.

Logs

bash
[request error] [unhandled] [POST] http://localhost:9998/proxy
error: Readable.asyncIterator is not implemented yet!
      at o (.output/server/index.mjs:590:25)
      at .output/server/index.mjs:590:861