Summary
When a client connection closes before any handler accesses request.signal, the Node adapter will lazily instantiate the internal AbortController while handling the close event. At that point the underlying ReadableStream has already been disturbed/locked, so Undici throws TypeError: Response body object should not be disturbed or locked and the dev server exits.
Steps to reproduce
- Start a Node server that uses
srvx/node (e.g., via TanStack Start or Nitro).
- Trigger any route that streams a response (SSE, large file, etc.).
- Abort the HTTP connection immediately (close the browser tab or
curl with --max-time 1).
- Observe the server crash:
node:internal/deps/undici/undici:15845
Error.captureStackTrace(err);
^
TypeError: Response body object should not be disturbed or locked
at node:internal/deps/undici/undici:15845:13
Root cause
In src/adapters/node.ts, the NodeServer handler creates a NodeRequest and only instantiates the abort controller when request.signal is accessed. The server also listens for nodeRes/socket close events and unconditionally calls req._abortController.abort(...). On a premature client disconnect, that call lazily constructs the controller after Node has already disturbed the body stream, so Undici throws before any of our code runs.
Hono hit the same bug recently (see honojs/node-server#221) and fixed it by:
- Exporting the internal
abortControllerKey symbol from the request implementation.
- Checking whether the controller exists before calling
abort() inside the close handler.
Proposed fix
- Export the internal controller symbol from
src/adapters/_node/request.ts (or expose a helper) so other modules can tell whether the controller was initialized.
- In
src/adapters/node.ts, register a nodeRes.on('close') listener that retrieves req[abortControllerKey] and returns early if it is undefined. Only call abort() when a controller already exists, and reuse the same error messages used today.
- Add a regression test similar to Hono’s “should handle request abort without requestCache” case to ensure future changes don’t reintroduce the bug.
With that guard in place, aborted client connections simply stop the response without crashing the Node process.
Happy to submit a PR if the above approach sounds good.
Summary
When a client connection closes before any handler accesses
request.signal, the Node adapter will lazily instantiate the internalAbortControllerwhile handling thecloseevent. At that point the underlyingReadableStreamhas already been disturbed/locked, so Undici throwsTypeError: Response body object should not be disturbed or lockedand the dev server exits.Steps to reproduce
srvx/node(e.g., via TanStack Start or Nitro).curlwith--max-time 1).Root cause
In
src/adapters/node.ts, theNodeServerhandler creates aNodeRequestand only instantiates the abort controller whenrequest.signalis accessed. The server also listens fornodeRes/socketclose events and unconditionally callsreq._abortController.abort(...). On a premature client disconnect, that call lazily constructs the controller after Node has already disturbed the body stream, so Undici throws before any of our code runs.Hono hit the same bug recently (see honojs/node-server#221) and fixed it by:
abortControllerKeysymbol from the request implementation.abort()inside the close handler.Proposed fix
src/adapters/_node/request.ts(or expose a helper) so other modules can tell whether the controller was initialized.src/adapters/node.ts, register anodeRes.on('close')listener that retrievesreq[abortControllerKey]and returns early if it isundefined. Only callabort()when a controller already exists, and reuse the same error messages used today.With that guard in place, aborted client connections simply stop the response without crashing the Node process.
Happy to submit a PR if the above approach sounds good.