Repository navigation
fix: serve Next.js over Harper's HTTP/2 secure port - #73
Draft
jjohnson-hdb wants to merge 2 commits into
Draft
jjohnson-hdb wants to merge 2 commits into
jjohnson-hdb wants to merge 2 commits into
Conversation
With `http.http2: true`, Harper hands the plugin node:http2 compatibility objects, and Next.js's request handler assumes HTTP/1 ones. Every page returned a 500 (`headers.hasOwnProperty is not a function`, the HTTP/2 headers object has a null prototype) and every compressed response reset the stream (`this._implicitHeader is not a function`, called by the compression middleware Next.js bundles). Adapt the request/response pair per request before calling Next.js. HTTP/1 requests are untouched. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
HTTP/2 clients send :authority in place of Host, and Node's compatibility layer does not synthesize one, so Next.js saw no host header. Its Server Actions origin check reads host, so an action posted over HTTP/2 would be rejected. Fill host from :authority when the client sent none. Also pin that _implicitHeader does not throw on a stream the client has reset, assert gzip in the HTTP/2 integration test, and fail test helpers cleanly on a connection error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces HTTP/2 compatibility support for Next.js by adapting incoming HTTP/2 request and response objects to match the HTTP/1.1 structures expected by Next.js. It includes new integration and unit tests to verify the compatibility. The feedback suggests two improvements: avoiding the performance overhead of Object.setPrototypeOf by using Object.defineProperty to add hasOwnProperty directly to the headers object, and safely handling cases where the :authority header is an array before assigning it to headers.host.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Next.js apps now render when Harper serves them over HTTP/2 (
http.http2: trueon the secure port). Before this, every page returned a 500 and every compressed asset reset its stream, because Next.js's request handler assumes Node's HTTP/1 request/response objects and Harper hands the pluginnode:http2compatibility objects. HTTP/2 is what Chromium needs before it acts on 103 Early Hints, so this was blocking Early Hints for Next.js sites served directly from Harper.The plugin's
serve()insrc/plugin.tsnow runs each request/response pair through the adapter (imported here) before calling Next.js. The adapter is the newadaptHttp2ForNextinsrc/http2Compat.ts. For HTTP/2 only, it does three things:Object.prototype, because Next.js callsheaders.hasOwnProperty();hostfrom:authoritywhen the client sent nohost, because Next.js's Server Actions origin check readshost;_implicitHeader()for thecompressionmiddleware Next.js bundles. It writes the head only while headers are unsent.HTTP/1 requests return before any of this runs.
For the human reviewer
Requirement: does this belong in the plugin? Next.js doesn't support
node:http2compatibility objects in a custom server. Harper usescreateSecureServerwithallowHTTP1whenhttp.http2is on. The plugin'sserve()is the one place the two meet. Two alternatives were considered:Http2ServerRequest/Http2ServerResponseprototypes globally, which would change everynode:http2user in the process.If Harper later normalizes these objects, this adapter becomes a harmless no-op. Declining the fix leaves Next.js sites on
http.http2: false, without Early Hints in Chromium.The request and response are modified in place, not wrapped. In-place costs one
instanceofper request and nothing on HTTP/1, and it keeps Harper's_nodeRequestidentity. The trade-off is that later readers of the same headers object see the prototype and the filledhost. Next.js already runs with that headers shape on HTTP/1. Easy to reverse._implicitHeaderis private Node API, and only itsheadersSentguard is emulated.compressioncalls it on every write becauseHttp2ServerResponsenever sets_header, so repeat calls must be no-ops. Setting_header = true, as HTTP/1 does, would avoid the repeat calls; it's a one-line change if preferred. A unit test confirms that calling it after the client resets the stream doesn't throw on Node 24.When
hostand:authoritydisagree, the client'shostwins. The adapter only fillshostwhen it's absent. The alternatives are to prefer:authority(as Node'sreq.authoritydoes) or to reject the mismatch, which RFC 9113 §8.3.1 allows. The risk is an intermediary that routes on:authoritypassing a differenthostinto Next.js's redirects and absolute URLs. Each option is a one-line change.Only the
next-16fixture runs over HTTP/2 end to end. Next.js 14 and 15 bundle the samecompressionmiddleware, and the adapter is version-independent. Covering them means adding anhttpoption to each of those fixtures.Two review findings were judged unfounded and left as they are:
instanceofcould fail across VM realms: Node built-ins are shared across the process, and the integration test passes on the default loader.setPrototypeOfslows V8 down: these headers objects are already in V8's slower dictionary form, and the suggested alternative would leak an ownhasOwnPropertykey intoObject.entries.Verification
Unit,
node --test dist/http2Compat.test.js: 8/8 pass. The tests in the newsrc/http2Compat.test.tsuse a realnode:http2server and Next.js 16's own bundledcompression. They show that:hasOwnProperty;hostis filled from:authority, and ahostthe client sent is kept;_implicitHeaderdoesn't throw after a client reset;With the adapter turned into a no-op, the positive tests fail.
Integration, new
integrationTests/next-16-http2.pw.ts. It starts Harper withhttp: { http2: true, mtls: false }, which binds the secure port. With the fix, all three tests pass: the home page returns 200 gzipped over HTTP/2, a hashed static chunk returns 200, and HTTP/1.1 on the same secure port still returns 200. On base, the two HTTP/2 tests fail withProtocol error(the stream reset) and the HTTP/1.1 control passes.integrationTests/fixture.tsgains thehttpoption and passes it into Harper's config.Real app. The Tractor Supply homepage build (Next.js 16, webpack) was served through
node:http2with this adapter. The home page, an RSC request,guest-home, a JS chunk and a CSS file all return the same status over HTTP/2 as over HTTP/1.1, gzipped, with no handler errors. On Harper Fabric stage withhttp.http2: true, the same build returned 500 orINTERNAL_ERRORbefore the fix.Full suites.
npm test: 163/163 pass.npm run test:integration: 37 passed before the review fixes. After them,next-16-http2andnext-16were rerun and pass 8/8.cluster.pw.tsdid not run: its Docker rig couldn't bind port 29925, which an unrelated local container already holds. That's an environment conflict, not a test failure.Complexity: medium
Review-Coverage: authored=claude; ran=codex,gemini; adjudicated=domain; declined=cursor-grok,cursor-composer; rounds=2; full=2 @ c041944
Human-Review-Need: 3 (decisions: host-vs-authority-precedence, per-instance-vs-prototype-shim, compensate-in-plugin-vs-harper) @ c041944