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).
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 syntheticServerResponseas if they were application-level body/headers.1. Explicit
Transfer-Encoding: chunkedcorrupts the bridged bodyWebServerResponsesetsuseChunkedEncodingByDefault = falseprecisely because chunk framing would corrupt the captured body (the comment insrc/adapters/_node/web/response.tssays so) — but that only covers the default. A handler that sets the header explicitly re-enables framing:The
transfer-encodingheader also leaks into the web Response (see next item), so when re-served the client receives double-framed garbage. Fix: strip/ignore an explicittransfer-encodingheader (forcechunkedEncoding = false), or de-chunk the captured stream.2. Hop-by-hop headers leak out of
toWebResponse()The header parse of
_headercopies everything, including connection-level headers Node generated for the synthetic wire. Since chunked is disabled, every bridged response without acontent-lengthcarriesConnection: close:Consequences when that Response is re-served by
serve():connection: closeis written throughwriteHead→ keep-alive is disabled for every bridged response.UnsupportedWarningper request.Hop-by-hop headers (
connection,keep-alive,transfer-encoding,upgrade) should be filtered;dateis also copied and arguably shouldn't be.3. Bridged responses are fully buffered; streaming/SSE hangs forever
toWebResponse()awaitswaitToFinish(), so the webResponseis only returned after the node handler callsres.end(), and the entire body accumulates unbounded in theReadableStreamcontroller queue in the meantime.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 behindtoFetchHandler).