From cab94ae666a388fcbeb9747e55312fdbfe88cbe9 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Thu, 15 Oct 2026 13:36:35 -0500 Subject: [PATCH 1/4] fix: serve Next.js responses over Harper's HTTP/2 secure port 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) --- integrationTests/fixture.ts | 8 +- integrationTests/next-16-http2.pw.ts | 59 +++++++++++ src/http2Compat.test.ts | 147 +++++++++++++++++++++++++++ src/http2Compat.ts | 34 +++++++ src/plugin.ts | 9 +- 5 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 integrationTests/next-16-http2.pw.ts create mode 100644 src/http2Compat.test.ts create mode 100644 src/http2Compat.ts diff --git a/integrationTests/fixture.ts b/integrationTests/fixture.ts index 3ebc0c4..68d26cd 100644 --- a/integrationTests/fixture.ts +++ b/integrationTests/fixture.ts @@ -33,9 +33,14 @@ export interface FixtureOptions { env?: Record; /** Overrides merged over the default `applications` config, e.g. `{ moduleLoader: 'native' }`. */ applications?: Record; + /** + * Harper `http` config, e.g. `{ http2: true, mtls: false }`. Setting `mtls` (even `false`) is what + * makes the harness bind the secure port, at `https://:9927`. + */ + http?: Record; } -export function makeHarperFixture(fixtureName: string, { env, applications }: FixtureOptions = {}) { +export function makeHarperFixture(fixtureName: string, { env, applications, http }: FixtureOptions = {}) { return async ({}: {}, use: (harper: HarperContext) => Promise) => { const fixturePath = join(import.meta.dirname, '..', 'fixtures', fixtureName); const ctx = createHarperContext(fixtureName); @@ -57,6 +62,7 @@ export function makeHarperFixture(fixtureName: string, { env, applications }: Fi allowedDirectory: 'any', ...applications, }, + ...(http && { http }), }, }); } catch (error) { diff --git a/integrationTests/next-16-http2.pw.ts b/integrationTests/next-16-http2.pw.ts new file mode 100644 index 0000000..c46faa6 --- /dev/null +++ b/integrationTests/next-16-http2.pw.ts @@ -0,0 +1,59 @@ +import { connect } from 'node:http2'; +import { gunzipSync } from 'node:zlib'; +import { fixture } from './fixture.ts'; + +// Harper only speaks HTTP/2 on its secure port, with `http.http2: true`. Browsers ignore Early Hints over +// HTTP/1.1, so this is the configuration a site serving 103s runs in. +const { test, expect } = fixture('next-16', { http: { http2: true, mtls: false } }); + +const SECURE_PORT = 9927; + +async function getHttp2(origin: string, path: string, headers: Record = {}) { + const client = connect(origin, { rejectUnauthorized: false }); + try { + return await new Promise<{ status: number; headers: Record; body: Buffer }>((resolve, reject) => { + const stream = client.request({ ':path': path, ...headers }); + const chunks: Buffer[] = []; + let responseHeaders: Record = {}; + stream.on('response', (received) => (responseHeaders = received)); + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => + resolve({ status: responseHeaders[':status'], headers: responseHeaders, body: Buffer.concat(chunks) }) + ); + stream.on('error', reject); + stream.end(); + }); + } finally { + client.close(); + } +} + +function decode(response: { headers: Record; body: Buffer }): string { + return (response.headers['content-encoding'] === 'gzip' ? gunzipSync(response.body) : response.body).toString(); +} + +test('home page renders over HTTP/2', async ({ harper }) => { + const response = await getHttp2(`https://${harper.hostname}:${SECURE_PORT}`, '/', { + accept: 'text/html', + 'accept-encoding': 'gzip', + }); + expect(response.status).toBe(200); + expect(decode(response)).toContain('Next.js v16'); +}); + +test('static assets are served over HTTP/2', async ({ harper }) => { + const origin = `https://${harper.hostname}:${SECURE_PORT}`; + const page = decode(await getHttp2(origin, '/', { accept: 'text/html' })); + const asset = page.match(/\/_next\/static\/[^"]+\.js/)?.[0]; + expect(asset).toBeTruthy(); + + const response = await getHttp2(origin, asset!, { 'accept-encoding': 'gzip' }); + expect(response.status).toBe(200); + expect(decode(response).length).toBeGreaterThan(0); +}); + +test('HTTP/1.1 on the same secure port is unaffected', async ({ request, harper }) => { + const response = await request.get(`https://${harper.hostname}:${SECURE_PORT}/`, { ignoreHTTPSErrors: true }); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('Next.js v16'); +}); diff --git a/src/http2Compat.test.ts b/src/http2Compat.test.ts new file mode 100644 index 0000000..6e2b6da --- /dev/null +++ b/src/http2Compat.test.ts @@ -0,0 +1,147 @@ +import { describe, it, after, before } from 'node:test'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; +import { createServer as createHttpServer, request as httpRequest, type IncomingMessage, type ServerResponse } from 'node:http'; +import { connect, createServer as createHttp2Server, type Http2Server } from 'node:http2'; +import type { AddressInfo } from 'node:net'; +import { gunzipSync } from 'node:zlib'; + +import { adaptHttp2ForNext } from './http2Compat.js'; + +// The middleware Next.js wraps every response in (`compress: true`, its default). Driving the real one +// is what proves the response half of the fix. +const compression = createRequire(import.meta.url)('next-16/dist/compiled/compression'); + +const BODY = 'x'.repeat(4096); + +type Handler = (request: any, response: any) => void; + +async function listenHttp2(handler: Handler): Promise<{ server: Http2Server; url: string }> { + const server = createHttp2Server(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { server, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }; +} + +async function getHttp2(url: string, headers: Record = {}) { + const client = connect(url); + try { + return await new Promise<{ status: number; headers: Record; body: Buffer }>((resolve, reject) => { + const stream = client.request({ ':path': '/', ...headers }); + const chunks: Buffer[] = []; + let responseHeaders: Record = {}; + stream.on('response', (received) => (responseHeaders = received)); + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => + resolve({ status: responseHeaders[':status'], headers: responseHeaders, body: Buffer.concat(chunks) }) + ); + stream.on('error', reject); + stream.end(); + }); + } finally { + client.close(); + } +} + +function compressedHandler(adapt: boolean, onError?: (error: Error) => void): Handler { + const compress = compression(); + return (request, response) => { + if (adapt) adaptHttp2ForNext(request, response); + compress(request, response, () => { + response.setHeader('content-type', 'text/html'); + try { + response.write(BODY.slice(0, 2048)); + response.end(BODY.slice(2048)); + } catch (error) { + onError?.(error as Error); + response.stream.close(); + } + }); + }; +} + +describe('adaptHttp2ForNext', () => { + describe('over HTTP/2', () => { + let observed: { hasOwnProperty: string; host: boolean } | undefined; + let unadaptedHasOwnProperty: string | undefined; + let unadaptedError: Error | undefined; + const servers: Http2Server[] = []; + let headersURL: string; + let adaptedURL: string; + let unadaptedURL: string; + + before(async () => { + const headers = await listenHttp2((request, response) => { + adaptHttp2ForNext(request, response); + observed = { + hasOwnProperty: typeof request.headers.hasOwnProperty, + host: request.headers.hasOwnProperty(':authority'), + }; + response.end(); + }); + const adapted = await listenHttp2(compressedHandler(true)); + const unadapted = await listenHttp2((request, response) => { + unadaptedHasOwnProperty = typeof request.headers.hasOwnProperty; + compressedHandler(false, (error) => (unadaptedError = error))(request, response); + }); + servers.push(headers.server, adapted.server, unadapted.server); + headersURL = headers.url; + adaptedURL = adapted.url; + unadaptedURL = unadapted.url; + }); + + after(() => { + for (const server of servers) server.close(); + }); + + it('gives request headers the Object prototype Next.js calls hasOwnProperty on', async () => { + const response = await getHttp2(headersURL); + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(observed, { hasOwnProperty: 'function', host: true }); + }); + + it("lets Next.js's compression middleware write a gzipped response", async () => { + const response = await getHttp2(adaptedURL, { 'accept-encoding': 'gzip' }); + assert.strictEqual(response.status, 200); + assert.strictEqual(response.headers['content-encoding'], 'gzip'); + assert.strictEqual(gunzipSync(response.body).toString(), BODY); + }); + + it('lets the compression middleware write an uncompressed response', async () => { + const response = await getHttp2(adaptedURL); + assert.strictEqual(response.status, 200); + assert.strictEqual(response.headers['content-encoding'], undefined); + assert.strictEqual(response.body.toString(), BODY); + }); + + it('is needed: unadapted, headers lack hasOwnProperty and the compression middleware throws', async () => { + await getHttp2(unadaptedURL, { 'accept-encoding': 'gzip' }); + assert.strictEqual(unadaptedHasOwnProperty, 'undefined'); + assert.match(unadaptedError?.message ?? '', /_implicitHeader is not a function/); + }); + }); + + it('leaves HTTP/1 requests untouched', async () => { + let observed: { headersProto: object | null; implicitHeader: unknown } | undefined; + const server = createHttpServer((request: IncomingMessage, response: ServerResponse) => { + const ownBefore = Object.hasOwn(response, '_implicitHeader'); + adaptHttp2ForNext(request, response); + observed = { + headersProto: Object.getPrototypeOf(request.headers), + implicitHeader: Object.hasOwn(response, '_implicitHeader') !== ownBefore, + }; + response.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const { port } = server.address() as AddressInfo; + await new Promise((resolve, reject) => + httpRequest({ host: '127.0.0.1', port }, (response) => response.resume().on('end', resolve)) + .on('error', reject) + .end() + ); + assert.deepStrictEqual(observed, { headersProto: Object.prototype, implicitHeader: false }); + } finally { + server.close(); + } + }); +}); diff --git a/src/http2Compat.ts b/src/http2Compat.ts new file mode 100644 index 0000000..cd72f82 --- /dev/null +++ b/src/http2Compat.ts @@ -0,0 +1,34 @@ +import { Http2ServerRequest, type Http2ServerResponse } from 'node:http2'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +/** + * Next.js's request handler assumes Node's HTTP/1 objects. Harper serves HTTP/2 (`http.http2: true` on a + * secure port) through `node:http2`'s compatibility API, whose objects differ in two ways that break every + * Next.js response: + * + * - `req.headers` has a null prototype, and Next.js calls `headers.hasOwnProperty()` while preparing the + * route, so every page renders a 500. + * - The response has no `_implicitHeader()`. The `compression` middleware Next.js bundles calls it on + * the first write whenever `res._header` is unset, which on `Http2ServerResponse` is always, so the + * stream is reset. + * + * Adapts the pair in place; HTTP/1 requests are returned untouched. + */ +export function adaptHttp2ForNext( + request: IncomingMessage | Http2ServerRequest, + response: ServerResponse | Http2ServerResponse +): void { + if (!(request instanceof Http2ServerRequest)) return; + + const headers = request.headers; + if (Object.getPrototypeOf(headers) === null) Object.setPrototypeOf(headers, Object.prototype); + + const http2Response = response as Http2ServerResponse & { _implicitHeader?: () => void }; + if (typeof http2Response._implicitHeader !== 'function') { + http2Response._implicitHeader = implicitHeader; + } +} + +function implicitHeader(this: Http2ServerResponse): void { + if (!this.headersSent) this.writeHead(this.statusCode); +} diff --git a/src/plugin.ts b/src/plugin.ts index d4667e0..0751581 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -16,6 +16,7 @@ import { join } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; import { withBuildLock } from './buildLock.js'; +import { adaptHttp2ForNext } from './http2Compat.js'; import { OLD_BUILD_SWEEP_DELAY_MS, oldBuildSweepEnabled, sweepOtherBuilds } from './buildSweep.js'; import type NextModule14 from 'next-14'; @@ -421,10 +422,10 @@ async function serve(scope: Scope, config: NextPluginConfig, next: NextPackage) scope.server?.http?.( (request, next) => { - return request._nodeResponse === undefined - ? next(request) - : // @ts-expect-error - Not sure when the IncomingMessage.url could be undefined ; need to dig into it. - requestHandler(request._nodeRequest, request._nodeResponse, urlParse(request._nodeRequest.url, true)); + if (request._nodeResponse === undefined) return next(request); + adaptHttp2ForNext(request._nodeRequest, request._nodeResponse); + // @ts-expect-error - Not sure when the IncomingMessage.url could be undefined ; need to dig into it. + return requestHandler(request._nodeRequest, request._nodeResponse, urlParse(request._nodeRequest.url, true)); }, { runFirst: config.runFirst, port: config.port, securePort: config.securePort } ); From c0419441c64487974a196c3e99b5114536e9a741 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Thu, 15 Oct 2026 13:52:49 -0500 Subject: [PATCH 2/4] fix: fill the HTTP/2 host header from :authority for Next.js 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) --- integrationTests/fixture.ts | 5 +-- integrationTests/next-16-http2.pw.ts | 4 +- src/http2Compat.test.ts | 59 ++++++++++++++++++++++++---- src/http2Compat.ts | 17 +++----- 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/integrationTests/fixture.ts b/integrationTests/fixture.ts index 68d26cd..a6711a2 100644 --- a/integrationTests/fixture.ts +++ b/integrationTests/fixture.ts @@ -33,10 +33,7 @@ export interface FixtureOptions { env?: Record; /** Overrides merged over the default `applications` config, e.g. `{ moduleLoader: 'native' }`. */ applications?: Record; - /** - * Harper `http` config, e.g. `{ http2: true, mtls: false }`. Setting `mtls` (even `false`) is what - * makes the harness bind the secure port, at `https://:9927`. - */ + /** Harper `http` config. Setting `mtls`, even to `false`, is what makes the harness bind the secure port (9927). */ http?: Record; } diff --git a/integrationTests/next-16-http2.pw.ts b/integrationTests/next-16-http2.pw.ts index c46faa6..d4719bd 100644 --- a/integrationTests/next-16-http2.pw.ts +++ b/integrationTests/next-16-http2.pw.ts @@ -2,8 +2,6 @@ import { connect } from 'node:http2'; import { gunzipSync } from 'node:zlib'; import { fixture } from './fixture.ts'; -// Harper only speaks HTTP/2 on its secure port, with `http.http2: true`. Browsers ignore Early Hints over -// HTTP/1.1, so this is the configuration a site serving 103s runs in. const { test, expect } = fixture('next-16', { http: { http2: true, mtls: false } }); const SECURE_PORT = 9927; @@ -12,6 +10,7 @@ async function getHttp2(origin: string, path: string, headers: Record; body: Buffer }>((resolve, reject) => { + client.on('error', reject); const stream = client.request({ ':path': path, ...headers }); const chunks: Buffer[] = []; let responseHeaders: Record = {}; @@ -38,6 +37,7 @@ test('home page renders over HTTP/2', async ({ harper }) => { 'accept-encoding': 'gzip', }); expect(response.status).toBe(200); + expect(response.headers['content-encoding']).toBe('gzip'); expect(decode(response)).toContain('Next.js v16'); }); diff --git a/src/http2Compat.test.ts b/src/http2Compat.test.ts index 6e2b6da..1919c9f 100644 --- a/src/http2Compat.test.ts +++ b/src/http2Compat.test.ts @@ -2,14 +2,12 @@ import { describe, it, after, before } from 'node:test'; import assert from 'node:assert'; import { createRequire } from 'node:module'; import { createServer as createHttpServer, request as httpRequest, type IncomingMessage, type ServerResponse } from 'node:http'; -import { connect, createServer as createHttp2Server, type Http2Server } from 'node:http2'; +import { connect, constants, createServer as createHttp2Server, type Http2Server } from 'node:http2'; import type { AddressInfo } from 'node:net'; import { gunzipSync } from 'node:zlib'; import { adaptHttp2ForNext } from './http2Compat.js'; -// The middleware Next.js wraps every response in (`compress: true`, its default). Driving the real one -// is what proves the response half of the fix. const compression = createRequire(import.meta.url)('next-16/dist/compiled/compression'); const BODY = 'x'.repeat(4096); @@ -26,6 +24,7 @@ async function getHttp2(url: string, headers: Record = {}) { const client = connect(url); try { return await new Promise<{ status: number; headers: Record; body: Buffer }>((resolve, reject) => { + client.on('error', reject); const stream = client.request({ ':path': '/', ...headers }); const chunks: Buffer[] = []; let responseHeaders: Record = {}; @@ -61,8 +60,9 @@ function compressedHandler(adapt: boolean, onError?: (error: Error) => void): Ha describe('adaptHttp2ForNext', () => { describe('over HTTP/2', () => { - let observed: { hasOwnProperty: string; host: boolean } | undefined; + let observed: { hasOwnProperty: string; ownAuthority: boolean; host: string; authority: string } | undefined; let unadaptedHasOwnProperty: string | undefined; + let unadaptedHost: string | undefined; let unadaptedError: Error | undefined; const servers: Http2Server[] = []; let headersURL: string; @@ -74,13 +74,16 @@ describe('adaptHttp2ForNext', () => { adaptHttp2ForNext(request, response); observed = { hasOwnProperty: typeof request.headers.hasOwnProperty, - host: request.headers.hasOwnProperty(':authority'), + ownAuthority: request.headers.hasOwnProperty(':authority'), + host: request.headers.host, + authority: request.headers[':authority'], }; response.end(); }); const adapted = await listenHttp2(compressedHandler(true)); const unadapted = await listenHttp2((request, response) => { unadaptedHasOwnProperty = typeof request.headers.hasOwnProperty; + unadaptedHost = request.headers.host; compressedHandler(false, (error) => (unadaptedError = error))(request, response); }); servers.push(headers.server, adapted.server, unadapted.server); @@ -96,7 +99,19 @@ describe('adaptHttp2ForNext', () => { it('gives request headers the Object prototype Next.js calls hasOwnProperty on', async () => { const response = await getHttp2(headersURL); assert.strictEqual(response.status, 200); - assert.deepStrictEqual(observed, { hasOwnProperty: 'function', host: true }); + assert.strictEqual(observed?.hasOwnProperty, 'function'); + assert.strictEqual(observed?.ownAuthority, true); + }); + + it('fills host from :authority, which HTTP/2 sends in its place', async () => { + await getHttp2(headersURL); + assert.ok(observed?.authority); + assert.strictEqual(observed?.host, observed?.authority); + }); + + it('keeps a host header the client sent', async () => { + await getHttp2(headersURL, { host: 'example.test' }); + assert.strictEqual(observed?.host, 'example.test'); }); it("lets Next.js's compression middleware write a gzipped response", async () => { @@ -113,11 +128,41 @@ describe('adaptHttp2ForNext', () => { assert.strictEqual(response.body.toString(), BODY); }); - it('is needed: unadapted, headers lack hasOwnProperty and the compression middleware throws', async () => { + it('is needed: unadapted, headers lack hasOwnProperty and host, and the compression middleware throws', async () => { await getHttp2(unadaptedURL, { 'accept-encoding': 'gzip' }); assert.strictEqual(unadaptedHasOwnProperty, 'undefined'); + assert.strictEqual(unadaptedHost, undefined); assert.match(unadaptedError?.message ?? '', /_implicitHeader is not a function/); }); + + it('does not throw when the client has already reset the stream', async () => { + let outcome: string | undefined; + const { server, url } = await listenHttp2((request, response) => { + adaptHttp2ForNext(request, response); + request.stream.on('close', () => { + try { + response._implicitHeader(); + outcome = 'ok'; + } catch (error) { + outcome = (error as NodeJS.ErrnoException).code; + } + }); + }); + try { + const client = connect(url); + const stream = client.request({ ':path': '/' }); + stream.on('error', () => {}); + await new Promise((resolve) => { + stream.on('close', resolve); + setTimeout(() => stream.close(constants.NGHTTP2_CANCEL), 20); + }); + client.close(); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.strictEqual(outcome, 'ok'); + } finally { + server.close(); + } + }); }); it('leaves HTTP/1 requests untouched', async () => { diff --git a/src/http2Compat.ts b/src/http2Compat.ts index cd72f82..e3dd9b1 100644 --- a/src/http2Compat.ts +++ b/src/http2Compat.ts @@ -2,17 +2,10 @@ import { Http2ServerRequest, type Http2ServerResponse } from 'node:http2'; import type { IncomingMessage, ServerResponse } from 'node:http'; /** - * Next.js's request handler assumes Node's HTTP/1 objects. Harper serves HTTP/2 (`http.http2: true` on a - * secure port) through `node:http2`'s compatibility API, whose objects differ in two ways that break every - * Next.js response: - * - * - `req.headers` has a null prototype, and Next.js calls `headers.hasOwnProperty()` while preparing the - * route, so every page renders a 500. - * - The response has no `_implicitHeader()`. The `compression` middleware Next.js bundles calls it on - * the first write whenever `res._header` is unset, which on `Http2ServerResponse` is always, so the - * stream is reset. - * - * Adapts the pair in place; HTTP/1 requests are returned untouched. + * Next.js's request handler assumes Node's HTTP/1 objects; Harper's HTTP/2 port hands it `node:http2` + * compatibility objects instead. Next.js calls `headers.hasOwnProperty()` (absent on HTTP/2's + * null-prototype headers), reads `host` (HTTP/2 sends `:authority`), and its bundled `compression` + * calls `res._implicitHeader()` (absent on `Http2ServerResponse`). HTTP/1 requests are left untouched. */ export function adaptHttp2ForNext( request: IncomingMessage | Http2ServerRequest, @@ -22,6 +15,7 @@ export function adaptHttp2ForNext( const headers = request.headers; if (Object.getPrototypeOf(headers) === null) Object.setPrototypeOf(headers, Object.prototype); + headers.host ??= headers[':authority']; const http2Response = response as Http2ServerResponse & { _implicitHeader?: () => void }; if (typeof http2Response._implicitHeader !== 'function') { @@ -29,6 +23,7 @@ export function adaptHttp2ForNext( } } +// `compression` calls this on every write because `Http2ServerResponse` never sets `_header`. function implicitHeader(this: Http2ServerResponse): void { if (!this.headersSent) this.writeHead(this.statusCode); } From cb0026a600dd95cd334caa48cedca6ae6e5e6f96 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 19 Oct 2026 15:13:23 -0600 Subject: [PATCH 3/4] fix: give Next.js HTTP/2 request headers without pseudo-headers 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 --- fixtures/next-16/app/api/echo/route.js | 6 ++++ integrationTests/next-16-http2.pw.ts | 10 +++++++ src/http2Compat.test.ts | 38 ++++++++++++++++++++++---- src/http2Compat.ts | 17 ++++++++---- 4 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 fixtures/next-16/app/api/echo/route.js diff --git a/fixtures/next-16/app/api/echo/route.js b/fixtures/next-16/app/api/echo/route.js new file mode 100644 index 0000000..26309ab --- /dev/null +++ b/fixtures/next-16/app/api/echo/route.js @@ -0,0 +1,6 @@ +export const dynamic = 'force-dynamic'; + +export async function GET(request) { + const url = new URL(request.url); + return Response.json({ pathname: url.pathname, search: url.search, host: request.headers.get('host') }); +} diff --git a/integrationTests/next-16-http2.pw.ts b/integrationTests/next-16-http2.pw.ts index d4719bd..e2ac15e 100644 --- a/integrationTests/next-16-http2.pw.ts +++ b/integrationTests/next-16-http2.pw.ts @@ -52,6 +52,16 @@ test('static assets are served over HTTP/2', async ({ harper }) => { expect(decode(response).length).toBeGreaterThan(0); }); +test('route handlers run over HTTP/2', async ({ harper }) => { + const response = await getHttp2(`https://${harper.hostname}:${SECURE_PORT}`, '/api/echo?q=1'); + expect(response.status).toBe(200); + expect(JSON.parse(decode(response))).toEqual({ + pathname: '/api/echo', + search: '?q=1', + host: `${harper.hostname}:${SECURE_PORT}`, + }); +}); + test('HTTP/1.1 on the same secure port is unaffected', async ({ request, harper }) => { const response = await request.get(`https://${harper.hostname}:${SECURE_PORT}/`, { ignoreHTTPSErrors: true }); expect(response.status()).toBe(200); diff --git a/src/http2Compat.test.ts b/src/http2Compat.test.ts index 1919c9f..445b650 100644 --- a/src/http2Compat.test.ts +++ b/src/http2Compat.test.ts @@ -8,7 +8,18 @@ import { gunzipSync } from 'node:zlib'; import { adaptHttp2ForNext } from './http2Compat.js'; -const compression = createRequire(import.meta.url)('next-16/dist/compiled/compression'); +const require = createRequire(import.meta.url); +const compression = require('next-16/dist/compiled/compression'); +const { fromNodeOutgoingHttpHeaders } = require('next-16/dist/server/web/utils.js'); + +function nextHeadersConversion(headers: unknown): string { + try { + fromNodeOutgoingHttpHeaders(headers); + return 'ok'; + } catch (error) { + return (error as Error).message; + } +} const BODY = 'x'.repeat(4096); @@ -60,9 +71,12 @@ function compressedHandler(adapt: boolean, onError?: (error: Error) => void): Ha describe('adaptHttp2ForNext', () => { describe('over HTTP/2', () => { - let observed: { hasOwnProperty: string; ownAuthority: boolean; host: string; authority: string } | undefined; + let observed: + | { hasOwnProperty: string; pseudoHeaders: string[]; host: string; authority: string; url: string; method: string; conversion: string } + | undefined; let unadaptedHasOwnProperty: string | undefined; let unadaptedHost: string | undefined; + let unadaptedConversion: string | undefined; let unadaptedError: Error | undefined; const servers: Http2Server[] = []; let headersURL: string; @@ -74,9 +88,12 @@ describe('adaptHttp2ForNext', () => { adaptHttp2ForNext(request, response); observed = { hasOwnProperty: typeof request.headers.hasOwnProperty, - ownAuthority: request.headers.hasOwnProperty(':authority'), + pseudoHeaders: Object.keys(request.headers).filter((name) => name.startsWith(':')), host: request.headers.host, - authority: request.headers[':authority'], + authority: request.authority, + url: request.url, + method: request.method, + conversion: nextHeadersConversion(request.headers), }; response.end(); }); @@ -84,6 +101,7 @@ describe('adaptHttp2ForNext', () => { const unadapted = await listenHttp2((request, response) => { unadaptedHasOwnProperty = typeof request.headers.hasOwnProperty; unadaptedHost = request.headers.host; + unadaptedConversion = nextHeadersConversion(request.headers); compressedHandler(false, (error) => (unadaptedError = error))(request, response); }); servers.push(headers.server, adapted.server, unadapted.server); @@ -100,7 +118,14 @@ describe('adaptHttp2ForNext', () => { const response = await getHttp2(headersURL); assert.strictEqual(response.status, 200); assert.strictEqual(observed?.hasOwnProperty, 'function'); - assert.strictEqual(observed?.ownAuthority, true); + }); + + it("drops the pseudo-headers Next.js's Headers conversion rejects, keeping url and method", async () => { + await getHttp2(headersURL, { ':path': '/api/echo?q=1', ':method': 'GET' }); + assert.deepStrictEqual(observed?.pseudoHeaders, []); + assert.strictEqual(observed?.conversion, 'ok'); + assert.strictEqual(observed?.url, '/api/echo?q=1'); + assert.strictEqual(observed?.method, 'GET'); }); it('fills host from :authority, which HTTP/2 sends in its place', async () => { @@ -128,10 +153,11 @@ describe('adaptHttp2ForNext', () => { assert.strictEqual(response.body.toString(), BODY); }); - it('is needed: unadapted, headers lack hasOwnProperty and host, and the compression middleware throws', async () => { + it('is needed: unadapted, headers lack hasOwnProperty and host, Next.js rejects them, and the compression middleware throws', async () => { await getHttp2(unadaptedURL, { 'accept-encoding': 'gzip' }); assert.strictEqual(unadaptedHasOwnProperty, 'undefined'); assert.strictEqual(unadaptedHost, undefined); + assert.match(unadaptedConversion ?? '', /is an invalid header name/); assert.match(unadaptedError?.message ?? '', /_implicitHeader is not a function/); }); diff --git a/src/http2Compat.ts b/src/http2Compat.ts index e3dd9b1..b14631e 100644 --- a/src/http2Compat.ts +++ b/src/http2Compat.ts @@ -1,10 +1,11 @@ import { Http2ServerRequest, type Http2ServerResponse } from 'node:http2'; -import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'; /** * Next.js's request handler assumes Node's HTTP/1 objects; Harper's HTTP/2 port hands it `node:http2` * compatibility objects instead. Next.js calls `headers.hasOwnProperty()` (absent on HTTP/2's - * null-prototype headers), reads `host` (HTTP/2 sends `:authority`), and its bundled `compression` + * null-prototype headers), passes every header name to `Headers.append()` (which rejects HTTP/2's + * `:path`-style pseudo-headers), reads `host` (HTTP/2 sends `:authority`), and its bundled `compression` * calls `res._implicitHeader()` (absent on `Http2ServerResponse`). HTTP/1 requests are left untouched. */ export function adaptHttp2ForNext( @@ -13,9 +14,15 @@ export function adaptHttp2ForNext( ): void { if (!(request instanceof Http2ServerRequest)) return; - const headers = request.headers; - if (Object.getPrototypeOf(headers) === null) Object.setPrototypeOf(headers, Object.prototype); - headers.host ??= headers[':authority']; + // `url` and `method` are getters over the stream's headers object, so the pseudo-headers stay there + // and Next.js is given an HTTP/1-shaped copy instead. + const http2Headers = request.headers; + const headers: IncomingHttpHeaders = {}; + for (const name in http2Headers) { + if (name[0] !== ':') headers[name] = http2Headers[name]; + } + headers.host ??= http2Headers[':authority']; + Object.defineProperty(request, 'headers', { value: headers, configurable: true, enumerable: true, writable: true }); const http2Response = response as Http2ServerResponse & { _implicitHeader?: () => void }; if (typeof http2Response._implicitHeader !== 'function') { From 1f387cd32628a4e4da246f65663d7c833d6ef2e7 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 19 Oct 2026 15:23:30 -0600 Subject: [PATCH 4/4] fix: keep a __proto__ HTTP/2 header when copying headers for Next.js 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 --- src/http2Compat.test.ts | 20 +++++++++++++++++++- src/http2Compat.ts | 9 ++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/http2Compat.test.ts b/src/http2Compat.test.ts index 445b650..5b467ea 100644 --- a/src/http2Compat.test.ts +++ b/src/http2Compat.test.ts @@ -72,7 +72,17 @@ function compressedHandler(adapt: boolean, onError?: (error: Error) => void): Ha describe('adaptHttp2ForNext', () => { describe('over HTTP/2', () => { let observed: - | { hasOwnProperty: string; pseudoHeaders: string[]; host: string; authority: string; url: string; method: string; conversion: string } + | { + hasOwnProperty: string; + pseudoHeaders: string[]; + host: string; + authority: string; + url: string; + method: string; + conversion: string; + objectPrototype: boolean; + protoHeader: unknown; + } | undefined; let unadaptedHasOwnProperty: string | undefined; let unadaptedHost: string | undefined; @@ -94,6 +104,8 @@ describe('adaptHttp2ForNext', () => { url: request.url, method: request.method, conversion: nextHeadersConversion(request.headers), + objectPrototype: Object.getPrototypeOf(request.headers) === Object.prototype, + protoHeader: Object.getOwnPropertyDescriptor(request.headers, '__proto__')?.value, }; response.end(); }); @@ -134,6 +146,12 @@ describe('adaptHttp2ForNext', () => { assert.strictEqual(observed?.host, observed?.authority); }); + it('copies a __proto__ header as a header rather than a prototype', async () => { + await getHttp2(headersURL, { ['__proto__']: 'x' }); + assert.strictEqual(observed?.protoHeader, 'x'); + assert.strictEqual(observed?.objectPrototype, true); + }); + it('keeps a host header the client sent', async () => { await getHttp2(headersURL, { host: 'example.test' }); assert.strictEqual(observed?.host, 'example.test'); diff --git a/src/http2Compat.ts b/src/http2Compat.ts index b14631e..cdd90eb 100644 --- a/src/http2Compat.ts +++ b/src/http2Compat.ts @@ -14,12 +14,15 @@ export function adaptHttp2ForNext( ): void { if (!(request instanceof Http2ServerRequest)) return; - // `url` and `method` are getters over the stream's headers object, so the pseudo-headers stay there - // and Next.js is given an HTTP/1-shaped copy instead. + // `url` and `method` are getters over the stream's headers object, so it must keep its pseudo-headers. const http2Headers = request.headers; const headers: IncomingHttpHeaders = {}; for (const name in http2Headers) { - if (name[0] !== ':') headers[name] = http2Headers[name]; + if (name === '__proto__') { + Object.defineProperty(headers, name, { value: http2Headers[name], configurable: true, enumerable: true, writable: true }); + } else if (name[0] !== ':') { + headers[name] = http2Headers[name]; + } } headers.host ??= http2Headers[':authority']; Object.defineProperty(request, 'headers', { value: headers, configurable: true, enumerable: true, writable: true });