Skip to content

Rebuild withNodeAdapter's response on a real Writable so Node middleware (Next.js, compression, send) runs against it - #2528

Merged
kriszyp merged 19 commits into
mainfrom
fix/node-adapter-writable-response
Sep 14, 2026
Merged

kriszyp merged 19 commits into
mainfrom
fix/node-adapter-writable-response

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 8, 2026

Copy link
Copy Markdown
Member

Request.withNodeAdapter() handed middleware an EventEmitter with hand-forwarded write/end/drain, but real Node middleware treats a ServerResponse as a Writable and relies on _header, _implicitHeader() and writeHead() ordering. Next.js therefore returned 500s, and larger responses hung once the obvious missing members were patched. The response is now NodeAdapterResponse extends PassThrough, and that exact object is the body the adapter resolves with, so backpressure and stream lifecycle events are Node's own.

Headers commit once through this.writeHead; disconnects and handler failures close or error the stream instead of leaving it open; and Headers.delete is case-insensitive, so compression can remove send's stale Content-Length. Post-review maintenance also makes setHeaders() accept Harper Headers, preserves an own __proto__ request header without changing the Node-compatible plain-object prototype, and forwards explicit and server-default timeout events with their socket argument. The public Promise<{ status, headers, body: PassThrough }> shape is unchanged.

Implementation and evidence are in NodeAdapterResponse.ts, Request.ts, Headers.ts, the Request contract tests, Headers regression test, and real-middleware/wire tests. The non-obvious contract is recorded in DESIGN.md; the real middleware fixtures are documented in dependencies.md and added through package.json plus its package-lock.json.

For the human reviewer

  1. Express is an explicit non-goal. Express replaces the response prototype with one rooted at http.ServerResponse.prototype, which a Writable-derived response cannot survive. Next.js, compression, send, on-finished, on-headers, finalhandler, serve-static, h3 and fastify duck-type the response without replacing its prototype. Supporting Express requires a separate design.
  2. Client hangup semantics are deliberate. After headers, a disconnect destroys the body without an error; before headers, the response promise rejects. This matches Node's dropped-connection behavior and lets pipeBodyToResponse treat the premature close as routine. One consequence to weigh: the rejection carries an AbortError, which has no statusCode, so a consumer that routes it into server/http.ts's onError gets a 500 written and harperLogger.error for a routine hangup. Giving the rejection a classified status is the one-line alternative.
  3. The adapter owns an error listener. res.writeHead(200); res.destroy(err) emits before an awaiting caller can attach one. The error remains in the stream's state for pipeline(), finished() or async iteration, and the immediate-destroy regression proves the process does not crash.
  4. A second writeHead() throws ERR_HTTP_HEADERS_SENT. This now matches Node, as do late setHeader, appendHeader and removeHeader calls; the contract test pins the change.
  5. A handler failure after res.end() is logged at warn, sync or async. It cannot reach the client or the already-resolved promise, so both paths use the same operator signal.
  6. Timeout forwarding follows listener ownership. The adapter attaches to the real response only while middleware has a timeout listener, so it does not accidentally mark an unhandled timeout as handled. Both setTimeout() forwarding and server-default forwarding without setTimeout() preserve the socket argument and detach on close.
  7. An already-aborted request still invokes the handler against a destroyed response. Node can invoke a handler after its client is gone; writes then fail through normal stream semantics. Returning early is the one-line alternative if this compatibility choice changes.
  8. Informational responses use the real Node response. writeContinue, writeProcessing and writeEarlyHints delegate when the transport provides a response and call back without hanging in the response-less fixture path.
  9. The added packages are test-only real middleware. Next.js vendors compression 1.7.4, which gates on _header/_implicitHeader(), while 1.8 gates on headersSent/writeHead(). The matrix runs both; send was already a runtime dependency.
  10. The in-repo wire proof is intentionally below a mounted Harper route. It uses a real http.createServer, Node request/response objects, compression + send, and Harper's pipeBodyToResponse. The out-of-tree @harperfast/nextjs CI matrix is the mounted consumer this unblocks.
  11. strictContentLength is declared but not enforced. implements ServerResponse requires the property, and the adapter's default false matches Node exactly (verified on Node v26: a 10-byte Content-Length ended with 5 bytes throws ERR_HTTP_CONTENT_LENGTH_MISMATCH only when the flag is set to true). A middleware that opts in by setting it true would get no enforcement here. No supported middleware sets it; enforcing it means counting bytes per write().

Two earlier suggestions remain declined: adding statusMessage to the resolved public shape, and routing write()/end() through flushHeaders() instead of _implicitHeader().

Verification

  • Fails on base: building origin/main's adapter and running the new middleware suite produced 16 failures / 3 passes: compression 1.7 lacked _implicitHeader, send/on-finished paths timed out, header-ordering assertions failed, and an immediate post-writeHead destroy escaped as an uncaught error.
  • Current focused gates: npm ci, npm run build, npm run test:types, npm run lint:required, npm run format:check, and git diff --check pass. npx mocha unitTests/server/serverHelpers/Request.test.js unitTests/server/serverHelpers/Headers.test.js unitTests/server/serverHelpers/nodeAdapterMiddleware.test.js reports 126 passing.
  • Windows gate: npm run test:unit:windows passes all 9 groups / 3,762 tests on the final head.
  • Broader local gates (on the merge): npm run test:unit:main reports 5,582 passing / 197 pending with two failures, both environmental and both confirmed by re-running them alone: the known long-worktree domain-socket path case in configValidator, and stuckWorkerDiagnostics (a main test from 6d725818c, untouched here) which counts two diagnostic samples and loses the race when the box is loaded — it passes on its own, 5 passing.
  • Review: the original design gate concluded chosen-approach-sound. Four further full cross-model passes ran on this head lineage (claude + gemini + Harper-domain adjudication each time), converging at round 11 with verdict LGTM and adjudicated severity minor. They produced two real fixes, both included here: a duplicate node_modules/@harperfast/rocksdb-js-linux-x64-musl key in package-lock.json (this branch and main had each restored the entry, and the merge kept both, leaving which metadata wins parser-dependent), and a functional bug where writeHead(302, undefined, { Location }) dropped the third argument's headers because the else branch assigned over them unconditionally — Node uses obj ??= reason, verified against a real http.createServer, and the regression test fails on the previous behaviour. Three reviewer claims were rejected on executed evidence rather than argument: setHeaders taking Headers | Map is Node's contract (Node v26 rejects a plain object with ERR_INVALID_ARG_TYPE); req.headers having Object.prototype matches Node, which also lets a Constructor: header shadow it; and strictContentLength is enforced by Node only when set to true, so the adapter's false default matches.
  • CI (head 6bb377753): 45 checks pass, none fail. The branch was brought current with main by a merge commit rather than a rebase. That cleared four checks that had been red at the previous head and were all reproducing failures from the merge-base 3a3c6f1ff, each already fixed on main: the Docker smoke job's shrinkwrap pin comparison (fixed by #2560; the root pins here are identical to main's, and the same job failed on main at the merge-base and two later commits), and the eviction-secondary-index 500 plus the txnlog-restart-reclaim double-cleanup-pass case on shards 2 and 6 (both fixed by bf0d17d78). The Next.js adapter matrix passes on Node 20/22/24, every integration shard passes on Node 24, Bun, uWS and Windows, and the Windows unit gate passes.

Refs #2527

— Claude Fable 5.1

🤖 Generated with Claude Code

https://claude.ai/code/session_01BL9FvWh4XHTxvPgsdak2Ni

Complexity: complicated

Review-Coverage: authored=codex; ran=gemini,claude; adjudicated=domain; declined=cursor-grok,cursor-composer; rounds=11; full=4 @ 6bb3777

Human-Review-Need: 4 (decisions: response-is-body, disconnect-settlement, timeout-relay, post-end-handler-failure, middleware-boundary, verification-boundary) @ 6bb3777

@kriszyp kriszyp added this to the v5.3 milestone Sep 8, 2026
@socket-security

socket-security Bot commented Sep 8, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedon-headers@​1.1.01001009483100
Addedcompression@​1.7.49910010083100
Addedcompression@​1.8.110010010089100

View full report

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the withNodeAdapter implementation by introducing a dedicated NodeAdapterResponse class that implements the Node.js ServerResponse contract. This improves robustness around client disconnects, backpressure, and handler failures. It also adds a case-insensitive delete method to the custom Headers class, adds devDependencies for testing with real Node middleware, and introduces comprehensive unit tests. The review feedback correctly identifies a TypeScript type mismatch in the setHeaders method of NodeAdapterResponse where passing the custom ResponseHeaders class would cause a compilation error, and provides a clear code suggestion to resolve it.

Comment thread server/serverHelpers/NodeAdapterResponse.ts Outdated
@kriszyp
kriszyp requested review from Ethan-Arrowood and dawsontoth and removed request for dawsontoth and kylebernhardy September 9, 2026 04:00
@kriszyp
kriszyp marked this pull request as ready for review September 9, 2026 04:00
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@dawsontoth dawsontoth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I read through it, and things roughly make sense to me. I imagine this is something for fixing up the Next.js component, or people building more middleware on top of Harper? I had one question about a unit test completeness, but nothing necessary. The failing unit tests look unrelated to the changes made here, probably tackled in a different PR already.

Comment thread unitTests/server/serverHelpers/Request.test.js
@dawsontoth

Copy link
Copy Markdown
Contributor

(Rebasing or merging the latest from main into this branch may tackle the merge conflict and the failing MQTT tests, though I haven't checked that to make sure.)

kriszyp and others added 12 commits September 9, 2026 16:00
…are runs against it (#2527)

Request.withNodeAdapter() handed middleware an EventEmitter with hand-forwarded
write/end/drain. Real Node middleware treats a ServerResponse as a Writable and
relies on the _header / _implicitHeader() / writeHead() ordering, so Next.js,
compression, send and on-finished each hit the next missing piece: null-prototype
request headers, no appendHeader, no _implicitHeader, no `finished`, a stall past
the high-water mark, and a gzip body still labelled with the uncompressed
Content-Length (Headers.delete was the inherited case-sensitive Map.delete).

The response is now NodeAdapterResponse, a PassThrough that is the body the
adapter resolves with, so backpressure, 'drain', 'finish' and 'close' are Node's
own, with the ServerResponse status/header API layered over the Harper Headers
map. Headers commit through this.writeHead so an on-headers patch runs first;
later header mutation throws ERR_HTTP_HEADERS_SENT like Node. Client disconnect
(Request.signal) and a rejected async handler now destroy the response instead
of leaving it open, and the adapter owns the 'error' listener so a destroy right
after writeHead cannot crash before the caller receives the body. Headers.delete
is case-insensitive like the rest of the class.

The public shape, Promise<{ status, headers, body: PassThrough }>, is unchanged.

Refs #2527

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BL9FvWh4XHTxvPgsdak2Ni
…esponse

A handler that threw synchronously escaped withNodeAdapter() before the
promise was returned, leaving the abort listener able to reject it unhandled
on a later disconnect; the throw now destroys an unfinished response (rejecting
before headers, erroring the body after) and is rethrown only once the response
had already ended. setTimeout() no longer installs its callback on the shared
keep-alive socket: the callback is a 'timeout' listener on this response, the
timeout is configured on the real Node response, and its 'timeout' is forwarded
here until close. Tests use plain node:assert per AGENTS.md, add the wire-level
client-disconnect case, and the narrating comments are trimmed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BL9FvWh4XHTxvPgsdak2Ni
…eaders are out

Matches Node's ServerResponse on a dropped connection: the body closes without
an error, so pipeline() reports the premature close pipeBodyToResponse already
treats as routine instead of warning on every hangup; before headers the
promise still rejects with the abort reason. writeContinue/writeProcessing/
writeEarlyHints invoke their callback when the transport has no Node response
(uWS and Bun bridges) so a handler awaiting it cannot hang.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BL9FvWh4XHTxvPgsdak2Ni
…t listeners

Node destroys a timed-out socket only when no request, response or server
listener handled the event, judged by emit()'s return value; an unconditional
forwarder on the real response marked every timeout as handled and left an
otherwise-stalled connection open. The forwarder now tracks the adapter's own
'timeout' listeners, proven against real HTTP connections both ways.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BL9FvWh4XHTxvPgsdak2Ni
…ad throw like Node

A synchronous throw after res.end() was rethrown from a Promise-returning
method, bypassing any .catch(), while the same failure from an async handler
was swallowed; both now log a warning, the only channel left once the response
is complete. writeHead() after headers are sent throws ERR_HTTP_HEADERS_SENT,
matching Node and the adapter's own setHeader/removeHeader (through on-headers a
second call already threw), instead of silently keeping the first status.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BL9FvWh4XHTxvPgsdak2Ni
… informational-response comment

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BL9FvWh4XHTxvPgsdak2Ni
…-insensitively in setHeaders()

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BL9FvWh4XHTxvPgsdak2Ni
…aces them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BL9FvWh4XHTxvPgsdak2Ni
… non-goal

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BL9FvWh4XHTxvPgsdak2Ni
… the failure handler

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BL9FvWh4XHTxvPgsdak2Ni
Widen NodeAdapterResponse.setHeaders to match its existing runtime support for Harper's case-insensitive Headers iterator, and cover that input alongside Map cookie grouping. Preserve both additive DESIGN sections while rebasing onto current main.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Keep the Node-compatible plain headers object while defining __proto__ as an own data property so array-valued custom headers cannot replace the object's prototype. Cover the conversion and remove a stale response test comment.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
@kriszyp
kriszyp force-pushed the fix/node-adapter-writable-response branch from 6885673 to 3f1a363 Compare September 9, 2026 23:13
kriszyp and others added 4 commits September 9, 2026 17:15
Regenerate the lockfile so current main's RocksDB optional dependencies remain complete alongside the middleware test aliases. This restores npm ci for CI runners.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Forward timeout event arguments, avoid scalar cookie wrapper allocations, and make the response lifecycle test wait on the actual close condition. Trim narrative comments while keeping the adapter's established Node-compatible header and unsupported-method contracts.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Wire timeout listeners when the adapter is constructed so server-default timeouts reach middleware even when it never calls setTimeout. Preserve timeout arguments, cover listener cleanup, and avoid scalar header formatting allocations.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Picks up the baseline fixes for the four checks failing on this PR head:
the shrinkwrap pin comparison (d6f4efa) and the RocksDB range-activity
work that repaired eviction-secondary-index and txnlog-restart-reclaim
(bf0d17d). DESIGN.md kept both appended sections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuqVspWdk6AWvYsaunX14W
kriszyp and others added 3 commits September 13, 2026 18:35
6a2ccdb restored the entry on this branch while da01073 restored it on
main; merging main left package-lock.json with the same
node_modules/@harperfast/rocksdb-js-linux-x64-musl key twice, so which
metadata wins depends on the parser. Keep main's copy — the package now has
no lockfile delta against main, and npm ci still resolves it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuqVspWdk6AWvYsaunX14W
…itted

writeHead(302, undefined, { Location: '/login' }) is the three-argument form
with no status message. The else branch assigned the second argument over the
third unconditionally, so the hole erased the headers and a redirect committed
without its target. Node uses `obj ??= reason` here; match it.

Verified against a real http.createServer: Node answers that call with
302 Found and Location: /login. The new test fails on the previous behaviour
with location undefined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuqVspWdk6AWvYsaunX14W
@kriszyp
kriszyp merged commit eec839e into main Sep 14, 2026
51 of 53 checks passed
@kriszyp
kriszyp deleted the fix/node-adapter-writable-response branch September 14, 2026 04:19
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