diff --git a/integrationTests/fixture.ts b/integrationTests/fixture.ts index 3ebc0c4..a6711a2 100644 --- a/integrationTests/fixture.ts +++ b/integrationTests/fixture.ts @@ -33,9 +33,11 @@ export interface FixtureOptions { env?: Record; /** Overrides merged over the default `applications` config, e.g. `{ moduleLoader: 'native' }`. */ applications?: Record; + /** Harper `http` config. Setting `mtls`, even to `false`, is what makes the harness bind the secure port (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 +59,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..d4719bd --- /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'; + +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) => { + client.on('error', 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(response.headers['content-encoding']).toBe('gzip'); + 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..1919c9f --- /dev/null +++ b/src/http2Compat.test.ts @@ -0,0 +1,192 @@ +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, 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'; + +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) => { + client.on('error', 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; 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; + let adaptedURL: string; + let unadaptedURL: string; + + before(async () => { + const headers = await listenHttp2((request, response) => { + adaptHttp2ForNext(request, response); + observed = { + hasOwnProperty: typeof request.headers.hasOwnProperty, + 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); + 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.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 () => { + 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 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 () => { + 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..e3dd9b1 --- /dev/null +++ b/src/http2Compat.ts @@ -0,0 +1,29 @@ +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'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, + response: ServerResponse | Http2ServerResponse +): void { + if (!(request instanceof Http2ServerRequest)) return; + + 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') { + http2Response._implicitHeader = implicitHeader; + } +} + +// `compression` calls this on every write because `Http2ServerResponse` never sets `_header`. +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 } );