fix(node): Node adapter correctness fixes (statusText, empty body, HEAD streaming, send errors, sync bridge)#243
Conversation
Node-scope T3 stabilization batch (v1 plan):
- F53: fix `_request` JSDoc (holds the web `Request`, not `IncomingMessage`);
document `_request`/`_url` as supported API frozen at v1.
- loader: document the `mod.default.length < 2` arity heuristic and its edge
cases (code comment + JSDoc); no behavior change.
- send: `handleSendError` now logs the underlying error (unless `silent`) so an
invalid-header 500 is debuggable, without leaking detail to the client.
- send: cancel a streaming body immediately for HEAD requests instead of
pumping it to completion (an unbounded SSE stream pumped forever).
- response: `statusText` defaults to the spec's empty reason phrase, not Node's
`STATUS_CODES` phrase ("OK"), matching native Response/Bun/Deno.
- response: an empty-string body (`new NodeResponse("")`) keeps the implicit
`text/plain` content-type and `content-length: 0`, matching native Response.
- request: `text()`/`json()` on a locked/disturbed stream reject instead of
throwing synchronously.
- request: a headers reference taken before `_request` materialization stays
live (keep `#headers` canonical instead of swapping to the native copy).
- web/incoming: populate `rawHeaders`, `httpVersion`, `httpVersionMajor/Minor`
on the synthetic IncomingMessage (morgan `:http-version`, keep-alive), and
keep the web->node bridge response un-chunked so the captured body stays raw.
Adds regression tests for each behavioral item in test/node-adapters.test.ts.
Co-Authored-By: Claude Fable 5 <[email protected]>
📝 WalkthroughWalkthroughNode adapter request body errors, response metadata, send handling, HEAD streaming, synthetic Node metadata, loader documentation, and regression tests are updated. ChangesNode adapter correctness
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 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: |
pi0x
left a comment
There was a problem hiding this comment.
Review: PR #243 — fix(node): T3 correctness batch
Verdict: Approve (non-blocking). I checked out the branch, read every touched file in full, and ran the suites. pnpm vitest run test/node-adapters.test.ts test/node.test.ts → 159 passed / 6 skipped. Full pnpm test (after pnpm build so the srvx self-imports resolve) → 985 passed / 35 skipped, no type errors, lint clean. Diff is scoped strictly to the Node adapter + loader + types + tests; no scope creep, no upgrade listener added, F17/F18 in response.ts/node.ts left untouched (owned by #230). No correctness bugs found — the changes are careful and the reasoning in the comments holds up. A few informational notes and one expected merge conflict below.
Per-item checklist (1–11)
- F53
types.ts✅_requestJSDoc now correctly describes the undici webRequest(notIncomingMessage, points atruntime.node.req); both_request/_urlmarked supported/frozen (D7). - Loader
--importmessage ✅ Verifiedloader.ts:166already readssrvx --import jiti/registeron main; this PR does not touch it. Claim correct. - Loader arity heuristic ✅ Comment/JSDoc added only;
mod.default.length < 2condition byte-identical. No behavior change. handleSendErrordiagnostics ✅console.errors unlesssilent; logged once (only from the detached catch —sendNodeResponsestill rejects, streaming errors stay.catch(()=>{})).silentis threaded viathis.options.silent; the changed function is@internal(sendNodeResponse, the public re-export, is unchanged) → no public signature change. Client still gets a bare 500, no detail leak (test asserts empty body).- HEAD stream cancel ✅
stream.cancel().catch(()=>{})is error-guarded; headers are already written by the caller beforestreamBody, so content-type/length still emitted; non-stream HEAD bodies fall to Node's own_hasBody=falsesuppression. Correct. statusText→""✅ Defaults to spec empty phrase; Node still fills a reason phrase into the status line on the wire (_storeHeaderusesSTATUS_CODESwhenstatusMessageis falsy), so wire output stays valid. Test asserts at the web-Response layer, which is the right layer.- Empty-string body ✅
!= null(not truthy) now emitstext/plain; charset=UTF-8+content-length: 0. See note A on the exact string. text()/json()on locked/disturbed stream ✅new Response(stream)synchronous throw wrapped →Promise.reject. Test drives it via a directly-disturbedreq.bodyand asserts aTypeErrorrejection.- GET/HEAD null-body ✅ Verified
#hasBody()already on main (#228); PR only adds the locked-stream regression test. - Materialization identity (headers) ✅ for the requirement in the plan — see note B for the residual divergence.
- Synthetic IncomingMessage + chunked fix ✅ — scrutinized hard, see below.
On item 11 (the one that needed the hardest look): the useChunkedEncodingByDefault = false in web/response.ts is correct and necessary, not incidental. Reporting HTTP/1.1 flips Node's ServerResponse default: previously the synthetic httpVersionMajor was null (null < 1 → true), so Node's constructor took the <1.1 branch and set useChunkedEncodingByDefault = false; now that branch is skipped and it inherits true from OutgoingMessage. The bridge's WebRequestSocket._write captures the post-header bytes verbatim (no de-chunking) into _webResBody, so without this line every non-content-length streamed bridge response would be chunk-framed and corrupted. The bridge socket is a Duplex, never a real wire, so disabling the default has no on-wire effect. rawHeaders is built as flat [name, value, …] preserving case, with set-cookie expanded per-entry and stored as an array on headers (matching Node) — good.
Findings
A. [informational] Empty-string content-type differs from native undici. _node/response.ts:130 emits text/plain; charset=UTF-8 (space, UTF-8) whereas native Response("") gives text/plain;charset=UTF-8. This is the adapter's long-standing convention for all string bodies, not new to this change — the PR's own test documents the mismatch. Consistent within the adapter; flagging only so it's a conscious call.
B. [low] req.headers and req._request.headers diverge after materialization. _node/request.ts now keeps #headers (the NodeRequestHeaders) canonical across _request materialization — which correctly satisfies the plan's "a reference taken before materialization stays live." But the native Request built in the _request getter copies header entries into its own Headers at construction. So after void req._request, mutating req.headers (or the pre-materialization ref) is not visible on req._request.headers, and vice-versa. Since _request is now documented supported API, a consumer that materializes, mutates req.headers, then hands req._request to undici/fetch will silently lose the mutation. The added test only exercises the req.headers/old-ref direction, not the _request direction. Not a regression (pre-PR the pre-materialization ref detached instead), and arguably acceptable, but the boundary is worth a doc line or a follow-up.
C. [informational] Body identity across materialization is punted. The _request comment addresses headers only; #bodyStream = undefined is retained so post-materialization body comes from the native request. No regression (body reads funnel consistently through one source), but the plan flagged body identity too and the PR neither covers nor explicitly calls it out. A one-line "body identity intentionally deferred" note would close the loop.
D. [informational] rawHeaders cannot fully reconstruct wire form. web/incoming.ts builds rawHeaders from req.headers.entries(), which comma-joins duplicate non-set-cookie headers, so repeated headers collapse to one entry and original wire order isn't preserved. The comment acknowledges "best-effort." Acceptable for the morgan/:http-version use case.
E. [merge — expected conflict with #230] Both PRs edit _node/response.ts in the same region. #243 deletes const STATUS_CODES = … and rewrites statusText to drop it; #230 keeps STATUS_CODES (its statusText still reads it) and inserts NULL_BODY_STATUS + a null-body constructor throw immediately after that line. This will conflict textually and semantically — resolution must keep #243's statusText/STATUS_CODES removal and #230's null-body constructor throw. types.ts (#230 removes netlify/stormkit/vercel at ~347–353; #243 edits _request/_url JSDoc at ~361–386) and node.ts (#230 at lines 6/54/140/194; #243 at 86–92) are non-adjacent and should auto-merge.
Nice work — the hot-path reasoning (chunked-default restoration, once-only error logging, canonical-headers identity) is sound and well-commented.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
test/node-adapters.test.ts (1)
920-945: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCover multiple
set-cookievalues in this regression.The constructor has dedicated array and
rawHeadersexpansion logic forset-cookie, but this test exercises only a scalar header. Add two cookie values and assert both ordered raw pairs andreq.headers["set-cookie"].🤖 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 920 - 945, Extend the “synthetic IncomingMessage exposes httpVersion and rawHeaders” test to send two ordered set-cookie values, then have the handler return both the ordered raw header pairs and req.headers["set-cookie"]. Assert that the response preserves both cookie values in order and retains the existing HTTP version expectations.
🤖 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/web/incoming.ts`:
- Around line 30-45: The synthetic transfer-encoding handling added after header
conversion must update both representations. In the body-bearing request path
around the transfer-encoding logic, append the same “transfer-encoding”,
“chunked” pair to rawHeaders whenever it is added to this.headers, while
preserving existing behavior for requests with length metadata.
In `@test/node-adapters.test.ts`:
- Around line 777-805: Update the HEAD streaming-body test to synchronize
cancellation through a promise resolved by the stream’s cancel() callback, then
await that promise before asserting cancelled and pulls. Remove the fixed 50 ms
timeout while preserving the existing cancellation and pull-count assertions.
---
Nitpick comments:
In `@test/node-adapters.test.ts`:
- Around line 920-945: Extend the “synthetic IncomingMessage exposes httpVersion
and rawHeaders” test to send two ordered set-cookie values, then have the
handler return both the ordered raw header pairs and req.headers["set-cookie"].
Assert that the response preserves both cookie values in order and retains the
existing HTTP version expectations.
🪄 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: 1303fa17-91b7-4f4d-bfbe-6bf309a789b1
📒 Files selected for processing (9)
src/adapters/_node/request.tssrc/adapters/_node/response.tssrc/adapters/_node/send.tssrc/adapters/_node/web/incoming.tssrc/adapters/_node/web/response.tssrc/adapters/node.tssrc/loader.tssrc/types.tstest/node-adapters.test.ts
| const rawHeaders = this.rawHeaders; | ||
| for (const [key, value] of req.headers.entries()) { | ||
| this.headers[key.toLowerCase()] = value; | ||
| const lowerKey = key.toLowerCase(); | ||
| if (lowerKey === "set-cookie") { | ||
| continue; | ||
| } | ||
| this.headers[lowerKey] = value; | ||
| rawHeaders.push(key, value); | ||
| } | ||
| const setCookie = req.headers.getSetCookie?.() ?? []; | ||
| if (setCookie.length > 0) { | ||
| // Node keeps `set-cookie` as an array on `headers` and one raw entry each. | ||
| (this.headers as Record<string, string | string[]>)["set-cookie"] = setCookie; | ||
| for (const cookie of setCookie) { | ||
| rawHeaders.push("set-cookie", cookie); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Keep rawHeaders synchronized with synthetic transfer encoding.
For body-bearing requests without length metadata, Lines 47-53 add transfer-encoding: chunked only to this.headers. Node handlers then observe conflicting headers and rawHeaders; append the synthetic pair to rawHeaders as well.
🤖 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 `@src/adapters/_node/web/incoming.ts` around lines 30 - 45, The synthetic
transfer-encoding handling added after header conversion must update both
representations. In the body-bearing request path around the transfer-encoding
logic, append the same “transfer-encoding”, “chunked” pair to rawHeaders
whenever it is added to this.headers, while preserving existing behavior for
requests with length metadata.
| test("HEAD cancels a streaming body instead of pumping it", async () => { | ||
| let cancelled = false; | ||
| let pulls = 0; | ||
| const server = serve({ | ||
| port: 0, | ||
| fetch() { | ||
| const stream = new ReadableStream({ | ||
| pull(controller) { | ||
| pulls++; | ||
| controller.enqueue(new TextEncoder().encode("data\n")); | ||
| }, | ||
| cancel() { | ||
| cancelled = true; | ||
| }, | ||
| }); | ||
| return new Response(stream); | ||
| }, | ||
| }); | ||
| await server.ready(); | ||
|
|
||
| const res = await fetch(server.url!, { method: "HEAD" }); | ||
| expect(res.status).toBe(200); | ||
| expect((await res.arrayBuffer()).byteLength).toBe(0); | ||
| // Let the cancellation settle. | ||
| await new Promise((r) => setTimeout(r, 50)); | ||
| expect(cancelled).toBe(true); | ||
| // The stream was cancelled up front rather than pumped. | ||
| expect(pulls).toBeLessThanOrEqual(1); | ||
| await server.close(true); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Await the cancellation signal instead of sleeping for 50 ms.
A loaded CI worker may not process cancellation within this fixed delay. Resolve a promise from cancel() and await it directly before asserting pulls.
🤖 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 777 - 805, Update the HEAD
streaming-body test to synchronize cancellation through a promise resolved by
the stream’s cancel() callback, then await that promise before asserting
cancelled and pulls. Remove the fixed 50 ms timeout while preserving the
existing cancellation and pull-count assertions.
Co-Authored-By: Claude Fable 5 <[email protected]>
Summary
A batch of correctness fixes for the Node adapter, aligning its behavior with native
fetch/Response/Requestand with Deno/Bun. Each fix has a regression test intest/node-adapters.test.ts.Fixes
req.text()/req.json()reject instead of throwing. Reading a body stream that's already locked or disturbed (e.g. something else consumedreq.bodydirectly) now returns a rejected promise, matching nativefetch, instead of throwing synchronously.statusTextdefaults to"", not Node's"OK".NodeResponseno longer looks up Node'shttp.STATUS_CODEStable for a default reason phrase. It now matches nativeResponse, Bun, and Deno, which all default to an empty string.Empty-string response bodies get correct headers.
new NodeResponse("")previously skipped thecontent-type: text/plainandcontent-length: 0headers because""is falsy. Both are now set correctly, matching nativeResponse("").HEAD responses no longer pump the body stream. A streaming body attached to a HEAD response is now cancelled immediately instead of read to completion — previously an unbounded stream (e.g. SSE) would pump forever on a HEAD request. Matches Deno/Bun.
Send errors are now logged. If sending a response fails synchronously (e.g. an invalid header), the error is now logged via
console.error(unless the server was created withsilent: true) instead of being swallowed. The client still just gets a bare 500.Synthetic
IncomingMessagenow reports HTTP version and raw headers. The Node-compat bridge used for legacy(req, res)handlers previously lefthttpVersion/httpVersionMajor/httpVersionMinorat their defaults and didn't populaterawHeaders, breaking things like morgan's:http-versiontoken. These are now filled in, including properset-cookiearray handling. Because reporting HTTP/1.1 makesServerResponsechunk-encode by default, the bridge's response is explicitly kept un-chunked so the raw captured body isn't corrupted by chunk framing.Documented the
loader.tshandler-detection heuristic. No behavior change — added comments explaining how a bareexport default functionis disambiguated between a webfetchhandler and a legacy Node handler (by parameter count), and the known edge cases where that guess is wrong.Clarified
_request/_urlJSDoc._request's doc incorrectly said it held "Node.js native instance of request" — it actually holds the web-standardRequest. Fixed the wording.Not included: a fix for
_request/headerssnapshot identity was attempted and reverted (1754b02) — it trades early-reference liveness for a frozen headers snapshot and needs its own discussion. Tracked separately in #245. Also out of scope: noupgradelistener support (documented limitation, not a regression from this PR).Testing
pnpm test(lint + typecheck + vitest --coverage) passes.