Rebuild withNodeAdapter's response on a real Writable so Node middleware (Next.js, compression, send) runs against it - #2528
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
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.
|
Reviewed; no blockers found. |
dawsontoth
left a comment
There was a problem hiding this comment.
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.
|
(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.) |
…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>
6885673 to
3f1a363
Compare
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
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KuqVspWdk6AWvYsaunX14W
Request.withNodeAdapter()handed middleware anEventEmitterwith hand-forwardedwrite/end/drain, but real Node middleware treats aServerResponseas a Writable and relies on_header,_implicitHeader()andwriteHead()ordering. Next.js therefore returned 500s, and larger responses hung once the obvious missing members were patched. The response is nowNodeAdapterResponse 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; andHeaders.deleteis case-insensitive, so compression can removesend's staleContent-Length. Post-review maintenance also makessetHeaders()accept HarperHeaders, 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 publicPromise<{ status, headers, body: PassThrough }>shape is unchanged.Implementation and evidence are in
NodeAdapterResponse.ts,Request.ts,Headers.ts, theRequestcontract tests,Headersregression test, and real-middleware/wire tests. The non-obvious contract is recorded inDESIGN.md; the real middleware fixtures are documented independencies.mdand added throughpackage.jsonplus itspackage-lock.json.For the human reviewer
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.pipeBodyToResponsetreat the premature close as routine. One consequence to weigh: the rejection carries anAbortError, which has nostatusCode, so a consumer that routes it intoserver/http.ts'sonErrorgets a 500 written andharperLogger.errorfor a routine hangup. Giving the rejection a classified status is the one-line alternative.errorlistener.res.writeHead(200); res.destroy(err)emits before an awaiting caller can attach one. The error remains in the stream's state forpipeline(),finished()or async iteration, and the immediate-destroy regression proves the process does not crash.writeHead()throwsERR_HTTP_HEADERS_SENT. This now matches Node, as do latesetHeader,appendHeaderandremoveHeadercalls; the contract test pins the change.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.setTimeout()forwarding and server-default forwarding withoutsetTimeout()preserve the socket argument and detach on close.writeContinue,writeProcessingandwriteEarlyHintsdelegate when the transport provides a response and call back without hanging in the response-less fixture path._header/_implicitHeader(), while 1.8 gates onheadersSent/writeHead(). The matrix runs both;sendwas already a runtime dependency.http.createServer, Node request/response objects,compression+send, and Harper'spipeBodyToResponse. The out-of-tree@harperfast/nextjsCI matrix is the mounted consumer this unblocks.strictContentLengthis declared but not enforced.implements ServerResponserequires the property, and the adapter's defaultfalsematches Node exactly (verified on Node v26: a 10-byteContent-Lengthended with 5 bytes throwsERR_HTTP_CONTENT_LENGTH_MISMATCHonly when the flag is set totrue). A middleware that opts in by setting ittruewould get no enforcement here. No supported middleware sets it; enforcing it means counting bytes perwrite().Two earlier suggestions remain declined: adding
statusMessageto the resolved public shape, and routingwrite()/end()throughflushHeaders()instead of_implicitHeader().Verification
origin/main's adapter and running the new middleware suite produced 16 failures / 3 passes: compression 1.7 lacked_implicitHeader,send/on-finishedpaths timed out, header-ordering assertions failed, and an immediate post-writeHeaddestroy escaped as an uncaught error.npm ci,npm run build,npm run test:types,npm run lint:required,npm run format:check, andgit diff --checkpass.npx mocha unitTests/server/serverHelpers/Request.test.js unitTests/server/serverHelpers/Headers.test.js unitTests/server/serverHelpers/nodeAdapterMiddleware.test.jsreports 126 passing.npm run test:unit:windowspasses all 9 groups / 3,762 tests on the final head.npm run test:unit:mainreports 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 inconfigValidator, andstuckWorkerDiagnostics(amaintest from6d725818c, untouched here) which counts two diagnostic samples and loses the race when the box is loaded — it passes on its own, 5 passing.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 duplicatenode_modules/@harperfast/rocksdb-js-linux-x64-muslkey inpackage-lock.json(this branch andmainhad each restored the entry, and the merge kept both, leaving which metadata wins parser-dependent), and a functional bug wherewriteHead(302, undefined, { Location })dropped the third argument's headers because theelsebranch assigned over them unconditionally — Node usesobj ??= reason, verified against a realhttp.createServer, and the regression test fails on the previous behaviour. Three reviewer claims were rejected on executed evidence rather than argument:setHeaderstakingHeaders | Mapis Node's contract (Node v26 rejects a plain object withERR_INVALID_ARG_TYPE);req.headershavingObject.prototypematches Node, which also lets aConstructor:header shadow it; andstrictContentLengthis enforced by Node only when set totrue, so the adapter'sfalsedefault matches.6bb377753): 45 checks pass, none fail. The branch was brought current withmainby 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-base3a3c6f1ff, each already fixed onmain: the Dockersmokejob's shrinkwrap pin comparison (fixed by #2560; the root pins here are identical tomain's, and the same job failed onmainat the merge-base and two later commits), and theeviction-secondary-index500 plus thetxnlog-restart-reclaimdouble-cleanup-pass case on shards 2 and 6 (both fixed bybf0d17d78). 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