Skip to content

Node→web bridge (toFetchHandler): chunked-TE body corruption, hop-by-hop header leak, full buffering breaks streaming/SSE #248

Description

@pi0x

Found while auditing the Node adapter (dist/adapters/node.mjs, Node 24.18) for v1 stable. These three issues share a root cause in the node→web bridge (toFetchHandler / fetchNodeHandler): toWebResponse() treats the captured wire bytes and wire headers of the synthetic ServerResponse as if they were application-level body/headers.

1. Explicit Transfer-Encoding: chunked corrupts the bridged body

WebServerResponse sets useChunkedEncodingByDefault = false precisely because chunk framing would corrupt the captured body (the comment in src/adapters/_node/web/response.ts says so) — but that only covers the default. A handler that sets the header explicitly re-enables framing:

import { toFetchHandler } from "srvx/node";

const handler = toFetchHandler((req, res) => {
  res.writeHead(200, { "transfer-encoding": "chunked" });
  res.write("hello");
  res.end("world");
});

const res = await handler(new Request("http://localhost/"));
console.log(JSON.stringify(await res.text()));
// "5\r\nhello\r\n5\r\nworld\r\n0\r\n\r\n"  ← chunk framing in the body

The transfer-encoding header also leaks into the web Response (see next item), so when re-served the client receives double-framed garbage. Fix: strip/ignore an explicit transfer-encoding header (force chunkedEncoding = false), or de-chunk the captured stream.

2. Hop-by-hop headers leak out of toWebResponse()

The header parse of _header copies everything, including connection-level headers Node generated for the synthetic wire. Since chunked is disabled, every bridged response without a content-length carries Connection: close:

const handler = toFetchHandler((req, res) => {
  res.writeHead(200, { "content-type": "text/plain" });
  res.end("ok");
});
const res = await handler(new Request("http://localhost/"));
console.log([...res.headers]);
// [["connection","close"],["content-type","text/plain"],["date","..."]]

Consequences when that Response is re-served by serve():

  • HTTP/1: connection: close is written through writeHead → keep-alive is disabled for every bridged response.
  • HTTP/2: Node drops it but prints an UnsupportedWarning per request.

Hop-by-hop headers (connection, keep-alive, transfer-encoding, upgrade) should be filtered; date is also copied and arguably shouldn't be.

3. Bridged responses are fully buffered; streaming/SSE hangs forever

toWebResponse() awaits waitToFinish(), so the web Response is only returned after the node handler calls res.end(), and the entire body accumulates unbounded in the ReadableStream controller queue in the meantime.

const handler = toFetchHandler((req, res) => {
  res.writeHead(200, { "content-type": "text/event-stream" });
  res.write("data: 1\n\n");
  // typical SSE: never ends
});
await handler(new Request("http://localhost/")); // never resolves

Measured: a handler that ends after 60 ms resolves the fetch promise at ~66 ms with the full body — no incremental delivery. The source comment ("toWebResponse() captures the raw body buffer") suggests this is by design, but for v1 it should either be a documented limitation or the Response should be resolved once headers/first write are available so the body can stream through (large payloads currently also buffer fully in memory, e.g. static file middleware behind toFetchHandler).

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