fix(node): reject body reads after the body is consumed#254
Conversation
`_request` serves a null-body native Request once srvx has consumed the body, so undici's own "Body is unusable" guard saw a pristine body and `arrayBuffer()` / `bytes()` / `blob()` / `formData()` resolved empty instead of rejecting, silently masking double-read bugs that throw on every other runtime. Only `text()` / `json()` guarded against this. Guard the remaining body methods, and reject rather than throw when materializing `_request` over a locked stream fails synchronously. Consuming `request.body` directly also never flipped `bodyUsed`, so a later read looked like a first read and threw "... disturbed or locked" synchronously out of the handler. Track the fetch spec's "disturbed" bit by wrapping the body stream. The wrapper uses `highWaterMark: 0` so it does not pull a chunk on construction and mark a body used merely because a handler touched `request.body`; `onDisturb` is inert once `_request` holds the body, since undici tees on `clone()` and a pull may then be the clone being read rather than this request's body. Co-Authored-By: Claude Opus 4.8 <[email protected]>
📝 WalkthroughWalkthroughThe Node ChangesRequest body semantics
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
commit: |
…wrapping the stream Replaces the trackDisturbed wrapper with node:stream's isDisturbed() (the same primitive undici's own guards use), consulted lazily and latched into #bodyUsed by #isBodyUsed(). This drops the extra ReadableStream + reader per body access, the per-chunk pull/enqueue microtask hop, and the highWaterMark: 0 read-ahead loss on streamed uploads. The latch happens before _request releases #bodyStream to the native Request, past which undici owns the accounting — so its tee reading the stream on clone() never counts against this request, which the wrapper previously needed the !this.#request guard for. Adds a cancel-path regression test: cancelling req.body disturbs it like a read (bodyUsed flips, later reads reject). Co-Authored-By: Claude Fable 5 <[email protected]>
…bility `@types/node` doesn't declare the module-level `isDisturbed` export of `node:stream`, only the identical `Readable.isDisturbed` static, so the named import fails typecheck in CI. Co-Authored-By: Claude Fable 5 <[email protected]>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
test/node-adapters.test.ts (1)
854-857: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winWrap this test in
try...finallyso cleanup always runs.
Iffetchor the assertion throws,server.close(true)is skipped and the open server can keep the worker alive.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/node-adapters.test.ts` around lines 854 - 857, Wrap the test body containing the fetch request and response assertion in a try...finally block, and move server.close(true) into the finally block so cleanup runs even when fetch or the assertion fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/adapters/_node/request.ts`:
- Line 8: Update src/adapters/_node/request.ts at lines 8-8 and 236-242: remove
the top-level isDisturbed import, and change its usage in the request body
stream logic to call Readable.isDisturbed(this.#bodyStream) instead. No other
changes are needed.
---
Nitpick comments:
In `@test/node-adapters.test.ts`:
- Around line 854-857: Wrap the test body containing the fetch request and
response assertion in a try...finally block, and move server.close(true) into
the finally block so cleanup runs even when fetch or the assertion fails.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: a3283d43-1f0d-4aa6-91f4-fdb42835e0f6
📒 Files selected for processing (2)
src/adapters/_node/request.tstest/node-adapters.test.ts
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThe Node ChangesRequest body semantics
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Caution Review failedAn error occurred during the review process. Please try again later. 📝 WalkthroughWalkthroughThe Node ChangesRequest body semantics
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThe Node ChangesRequest body semantics
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Fixes #247.
The bug
_requestserves a null-body native Request once srvx has consumed the body. That request's body is pristine, so undici's ownBody is unusableguard never fires andarrayBuffer()/bytes()/blob()/formData()read an empty body and resolve happily. Onlytext()/json()had explicit guards. This silently masks double-read bugs that throw on every other runtime.The "related" item in the issue turned out to be worse than reported. Draining
req.bodydirectly leftbodyUsedfalse, so a later read looked like a first read, reached the_requestgetter, and threwResponse body object should not be disturbed or lockedsynchronously out of the handler — not a rejection, a crash:The fix
arrayBuffer/bytes/blob/formDatareject via a shared#consumeNativehelper, which also wraps the delegation in try/catch so a synchronous throw while materializing_requestover a locked stream becomes a rejection. That's what fixes the handler crash above.node:stream'sisDisturbed()— the same primitive undici's own guards use.#isBodyUsed()consults it on the streambodyhanded out and latches the answer into#bodyUsed, with zero cost on the streaming path itself (no wrapper stream, no per-chunk hop). Merely touchingrequest.bodydoesn't disturb it, so that stays free too. Pinned by a test._requestreleases the stream to the native Request. Past that point undici owns the accounting: it tees the stream onclone(), so its reads must not count against this request. Without this,req.clone()then reading the clone would wrongly poison the original — breaking the common clone-to-inspect middleware pattern. Pinned by a test.Verification
srvx now matches native undici on every case — consumed-body reads, direct streaming, cancel, body-touch, first/second read, and clone:
arrayBuffer()aftertext()resolved(0)TypeError: Body is unusablebytes()/blob()aftertext()resolved(0)TypeError: Body is unusablearrayBuffer()after drainingbodyTypeError: Body is unusablebodyUsedafter drainingbodyfalsetruebodyUsedafterbody.cancel()falsetruebody, thentext()resolvedresolved(unchanged)clone().text(), thentext()resolvedresolved(unchanged)7 regression tests added. Lint/format clean; typecheck unchanged.
Summary by CodeRabbit
bodyUsednow updates correctly when the body is disturbed or consumed.text,json,arrayBuffer,formData) now reject consistently after the body has already been used.