Skip to content

Node: arrayBuffer()/bytes()/blob()/formData() resolve empty after body is consumed instead of rejecting #247

Description

@pi0x

Found while auditing the Node adapter (dist/adapters/node.mjs, Node 24.18) for v1 stable.

NodeRequest#text() and #json() correctly guard against double reads, but arrayBuffer(), bytes(), blob() and formData() resolve successfully with an empty body when the body was already consumed, instead of rejecting like native fetch.

Repro

import { serve } from "srvx/node";

serve({
  async fetch(req) {
    await req.text();
    const buf = await req.arrayBuffer(); // native: rejects "Body is unusable"
    return new Response(`resolved, byteLength=${buf.byteLength}, bodyUsed=${req.bodyUsed}`);
  },
});
$ curl -X POST --data hi http://localhost:3000
resolved, byteLength=0, bodyUsed=true

Native comparison (undici):

const req = new Request("http://localhost/", { method: "POST", body: "hi" });
await req.text();
await req.arrayBuffer(); // TypeError: Body is unusable: Body has already been read

Root cause

Only text()/json() have explicit #bodyUsed guards. The other body methods are delegated through lazyInherit to the lazily-created native request, and the _request getter builds that request with body: null once #bodyUsed is set:

https://github.com/h3js/srvx/blob/main/src/adapters/_node/request.ts#L291

So the native request is a fresh empty-bodied request and every consumer on it resolves with an empty result. This silently masks double-read bugs in user code that would throw on every other runtime/adapter.

Related (same accounting, lower impact)

Consuming the body stream directly (req.body.getReader() / iterating req.body) never flips bodyUsed to true — natively the flag turns true as soon as the stream is disturbed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions