Skip to content

fix(node): Node adapter correctness fixes (statusText, empty body, HEAD streaming, send errors, sync bridge)#243

Merged
pi0 merged 6 commits into
mainfrom
fix/node-t3-batch
Jul 16, 2026
Merged

fix(node): Node adapter correctness fixes (statusText, empty body, HEAD streaming, send errors, sync bridge)#243
pi0 merged 6 commits into
mainfrom
fix/node-t3-batch

Conversation

@pi0x

@pi0x pi0x commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

A batch of correctness fixes for the Node adapter, aligning its behavior with native fetch/Response/Request and with Deno/Bun. Each fix has a regression test in test/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 consumed req.body directly) now returns a rejected promise, matching native fetch, instead of throwing synchronously.

  • statusText defaults to "", not Node's "OK". NodeResponse no longer looks up Node's http.STATUS_CODES table for a default reason phrase. It now matches native Response, Bun, and Deno, which all default to an empty string.

  • Empty-string response bodies get correct headers. new NodeResponse("") previously skipped the content-type: text/plain and content-length: 0 headers because "" is falsy. Both are now set correctly, matching native Response("").

  • 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 with silent: true) instead of being swallowed. The client still just gets a bare 500.

  • Synthetic IncomingMessage now reports HTTP version and raw headers. The Node-compat bridge used for legacy (req, res) handlers previously left httpVersion/httpVersionMajor/httpVersionMinor at their defaults and didn't populate rawHeaders, breaking things like morgan's :http-version token. These are now filled in, including proper set-cookie array handling. Because reporting HTTP/1.1 makes ServerResponse chunk-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.ts handler-detection heuristic. No behavior change — added comments explaining how a bare export default function is disambiguated between a web fetch handler and a legacy Node handler (by parameter count), and the known edge cases where that guess is wrong.

  • Clarified _request / _url JSDoc. _request's doc incorrectly said it held "Node.js native instance of request" — it actually holds the web-standard Request. Fixed the wording.

Not included: a fix for _request/headers snapshot 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: no upgrade listener support (documented limitation, not a regression from this PR).

Testing

pnpm test (lint + typecheck + vitest --coverage) passes.

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]>
@pi0x
pi0x requested a review from pi0 as a code owner July 14, 2026 12:53
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Node adapter request body errors, response metadata, send handling, HEAD streaming, synthetic Node metadata, loader documentation, and regression tests are updated.

Changes

Node adapter correctness

Layer / File(s) Summary
Request state and body semantics
src/adapters/_node/request.ts
Locked or disturbed body streams now produce rejected promises from text() and json().
Response conversion and sending
src/adapters/_node/response.ts, src/adapters/_node/send.ts, src/adapters/_node/web/response.ts, src/adapters/node.ts
Empty bodies emit the expected metadata; HEAD streams are cancelled; send errors support silent logging; synthetic responses disable default chunking.
Synthetic incoming request metadata
src/adapters/_node/web/incoming.ts
Synthetic incoming messages expose HTTP/1.1 fields and reconstructed raw headers, including repeated set-cookie entries.
Loader contracts and regression coverage
src/loader.ts, src/types.ts, test/node-adapters.test.ts
Comments clarify handler-arity detection and request properties, while tests cover the updated Node adapter behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested reviewers: pi0

Poem

I’m a rabbit with headers held tight,
Through empty responses and streams in flight.
HEAD hops away, errors whisper or call,
Raw cookies line up neatly for all.
Tests thump their paws: the adapter stands tall!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main Node adapter correctness fixes and mentions several of the key behavior changes.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/node-t3-batch

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/srvx@243

commit: a48cfe1

@pi0x pi0x left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. F53 types.ts_request JSDoc now correctly describes the undici web Request (not IncomingMessage, points at runtime.node.req); both _request/_url marked supported/frozen (D7).
  2. Loader --import message ✅ Verified loader.ts:166 already reads srvx --import jiti/register on main; this PR does not touch it. Claim correct.
  3. Loader arity heuristic ✅ Comment/JSDoc added only; mod.default.length < 2 condition byte-identical. No behavior change.
  4. handleSendError diagnosticsconsole.errors unless silent; logged once (only from the detached catch — sendNodeResponse still rejects, streaming errors stay .catch(()=>{})). silent is threaded via this.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).
  5. HEAD stream cancelstream.cancel().catch(()=>{}) is error-guarded; headers are already written by the caller before streamBody, so content-type/length still emitted; non-stream HEAD bodies fall to Node's own _hasBody=false suppression. Correct.
  6. statusText"" ✅ Defaults to spec empty phrase; Node still fills a reason phrase into the status line on the wire (_storeHeader uses STATUS_CODES when statusMessage is falsy), so wire output stays valid. Test asserts at the web-Response layer, which is the right layer.
  7. Empty-string body!= null (not truthy) now emits text/plain; charset=UTF-8 + content-length: 0. See note A on the exact string.
  8. text()/json() on locked/disturbed streamnew Response(stream) synchronous throw wrapped → Promise.reject. Test drives it via a directly-disturbed req.body and asserts a TypeError rejection.
  9. GET/HEAD null-body ✅ Verified #hasBody() already on main (#228); PR only adds the locked-stream regression test.
  10. Materialization identity (headers) ✅ for the requirement in the plan — see note B for the residual divergence.
  11. 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
test/node-adapters.test.ts (1)

920-945: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover multiple set-cookie values in this regression.

The constructor has dedicated array and rawHeaders expansion logic for set-cookie, but this test exercises only a scalar header. Add two cookie values and assert both ordered raw pairs and req.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

📥 Commits

Reviewing files that changed from the base of the PR and between d8523dc and 72a5049.

📒 Files selected for processing (9)
  • src/adapters/_node/request.ts
  • src/adapters/_node/response.ts
  • src/adapters/_node/send.ts
  • src/adapters/_node/web/incoming.ts
  • src/adapters/_node/web/response.ts
  • src/adapters/node.ts
  • src/loader.ts
  • src/types.ts
  • test/node-adapters.test.ts

Comment on lines +30 to +45
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.

Comment on lines +777 to +805
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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.

@pi0 pi0 changed the title fix(node): T3 correctness batch fix(node): improvements Jul 16, 2026
@pi0 pi0 changed the title fix(node): improvements fix(node, node/web): improvements Jul 16, 2026
@pi0x pi0x changed the title fix(node, node/web): improvements fix(node): Node adapter correctness fixes (statusText, empty body, HEAD streaming, send errors, sync bridge) Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants