Skip to content

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

Merged
Ethan-Arrowood merged 4 commits into
mainfrom
fix/http2-next-request-handler
Oct 19, 2026
Merged

Ethan-Arrowood merged 4 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 a compatibility gap for Next.js sites on http.http2: true.

  2. The response is patched in place; the request gets a headers copy. _implicitHeader is set on the response instance and Harper's _nodeRequest identity is kept. The headers are copied because deleting the pseudo-headers from the original breaks req.url and req.method. The trade-off is one object allocation per HTTP/2 request, and Harper's RequestHeaders, which wraps the original object, does not see the filled host. HTTP/1 pays nothing.

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

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

One review finding was judged unfounded and left as it is: that instanceof could fail across VM realms. Node built-ins are shared across the process, and the integration test passes on the default loader. The earlier setPrototypeOf question no longer applies, because the copy replaces the prototype swap.

Verification

Related PRs: #62 overlaps (edits the same request handler in src/plugin.ts; whichever lands second needs a rebase)
Complexity: medium

🤖 Generated by Claude Opus 5.5; posted via @Ethan-Arrowood.

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 Outdated
Comment thread src/http2Compat.ts Outdated
@jjohnson-hdb
jjohnson-hdb marked this pull request as ready for review October 19, 2026 14:43
Ethan-Arrowood and others added 2 commits October 19, 2026 15:13
Node's HTTP/2 compatibility headers carry `:path`, `:method`,
`:authority` and `:scheme`. Next.js converts request headers with
`Headers.append()`, which rejects those names, so every App Router route
handler (and the middleware adapter) threw `":path" is an invalid header
name` over HTTP/2 while pages still rendered.

`Http2ServerRequest`'s `url` and `method` are getters over that same
headers object, so the pseudo-headers cannot be deleted from it. Shadow
`headers` on the request with an HTTP/1-shaped copy instead: an
Object-prototype object with the pseudo-headers dropped and `host` filled
from `:authority`. That copy also replaces the prototype swap.

Add a route handler to the next-16 fixture and request it over HTTP/2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assigning `__proto__` on the Object-prototype copy goes through the
prototype setter, which drops the header. Define it as an own property,
as Harper's own Node adapter does, and keep the inline comment to the
constraint that rules out deleting pseudo-headers in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Ethan-Arrowood
Ethan-Arrowood merged commit e85e091 into main Oct 19, 2026
13 checks passed
@Ethan-Arrowood
Ethan-Arrowood deleted the fix/http2-next-request-handler branch October 19, 2026 21:42
github-actions Bot pushed a commit that referenced this pull request Oct 19, 2026
## [2.4.0](v2.3.0...v2.4.0) (2026-10-19)

### Features

* isolate persistent Next.js caches by build ([#72](#72)) ([2a0e90d](2a0e90d))

### Bug Fixes

* serve Next.js over Harper's HTTP/2 secure port ([#73](#73)) ([e85e091](e85e091))

### Chores

* add CODEOWNERS ([#74](#74)) ([2da178e](2da178e))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.4.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

bug Something isn't working released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants