From cab94ae666a388fcbeb9747e55312fdbfe88cbe9 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Thu, 15 Oct 2026 13:36:35 -0500 Subject: [PATCH 1/2] 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/2] 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); }