Skip to content

fix: serve Next.js over Harper's HTTP/2 secure port - #73

Draft
jjohnson-hdb wants to merge 2 commits into
mainfrom
fix/http2-next-request-handler
Draft

jjohnson-hdb wants to merge 2 commits into
mainfrom
fix/http2-next-request-handler

Conversation

@jjohnson-hdb

@jjohnson-hdb jjohnson-hdb commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Next.js apps now render when Harper serves them over HTTP/2 (http.http2: true on 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 plugin node:http2 compatibility 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() in src/plugin.ts now runs each request/response pair through the adapter (imported here) before calling Next.js. The adapter is the new adaptHttp2ForNext in src/http2Compat.ts. For HTTP/2 only, it does three things:

HTTP/1 requests return before any of this runs.

For the human reviewer

  1. Requirement: does this belong in the plugin? Next.js doesn't support node:http2 compatibility objects in a custom server. Harper uses createSecureServer with allowHTTP1 when http.http2 is on. The plugin's serve() is the one place the two meet. Two alternatives were considered:

    • normalizing in Harper, which would change request objects for every component;
    • patching Http2ServerRequest/Http2ServerResponse prototypes globally, which would change every node:http2 user 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.

  2. The request and response are modified in place, not wrapped. In-place costs one instanceof per request and nothing on HTTP/1, and it keeps Harper's _nodeRequest identity. The trade-off is that later readers of the same headers object see the prototype and the filled host. Next.js already runs with that headers shape on HTTP/1. Easy to reverse.

  3. _implicitHeader is private Node API, and only its headersSent guard is emulated. compression calls it on every write because Http2ServerResponse never 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.

  4. When host and :authority disagree, the client's host wins. The adapter only fills host when it's absent. The alternatives are to prefer :authority (as Node's req.authority does) or to reject the mismatch, which RFC 9113 §8.3.1 allows. The risk is an intermediary that routes on :authority passing a different host into Next.js's redirects and absolute URLs. Each option is a one-line change.

  5. Only the next-16 fixture runs over HTTP/2 end to end. Next.js 14 and 15 bundle the same compression middleware, and the adapter is version-independent. Covering them means adding an http option to each of those fixtures.

Two review findings were judged unfounded and left as they are:

  • That instanceof could fail across VM realms: Node built-ins are shared across the process, and the integration test passes on the default loader.
  • That setPrototypeOf slows V8 down: these headers objects are already in V8's slower dictionary form, and the suggested alternative would leak an own hasOwnProperty key into Object.entries.

Verification

  • Unit, node --test dist/http2Compat.test.js: 8/8 pass. The tests in the new src/http2Compat.test.ts use a real node:http2 server and Next.js 16's own bundled compression. They show that:

    With the adapter turned into a no-op, the positive tests fail.

  • Integration, new integrationTests/next-16-http2.pw.ts. It starts Harper with http: { 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 with Protocol error (the stream reset) and the HTTP/1.1 control passes. integrationTests/fixture.ts gains the http option and passes it into Harper's config.

  • Real app. The Tractor Supply homepage build (Next.js 16, webpack) was served through node:http2 with 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 with http.http2: true, the same build returned 500 or INTERNAL_ERROR before the fix.

  • Full suites.

    • npm test: 163/163 pass.
    • npm run test:integration: 37 passed before the review fixes. After them, next-16-http2 and next-16 were rerun and pass 8/8.
    • cluster.pw.ts did 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

jjohnson-hdb and others added 2 commits October 15, 2026 13:36
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>
@jjohnson-hdb jjohnson-hdb added the bug Something isn't working label Oct 15, 2026

@gemini-code-assist gemini-code-assist 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.

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.

Comment thread src/http2Compat.ts
Comment thread src/http2Compat.ts

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant