From f6415e3d44f7066e41c6bd76c95e6254607acec8 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Fri, 14 Nov 2025 11:01:43 -0500 Subject: [PATCH] fix: handle HTTP/2 requests with pseudo-headers HTTP/2 requests are required to have a specific set of request and response "pseudo-headers": https://www.rfc-editor.org/rfc/rfc9113.html#name-request-pseudo-header-field. When converting a Node.js `IncomingMessage` to a Web `Request`, we must ignore these pseudo-headers because `IncomingMessage.prototype.headers` *does* contain them, but newer versions of Node.js will throw on `new Request()` if given any such header. --- packages/dev/src/lib/reqres.ts | 21 +++++++++++++--- packages/dev/src/main.test.ts | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/dev/src/lib/reqres.ts b/packages/dev/src/lib/reqres.ts index 977d7903..602df1e1 100644 --- a/packages/dev/src/lib/reqres.ts +++ b/packages/dev/src/lib/reqres.ts @@ -1,8 +1,20 @@ -import { IncomingHttpHeaders, IncomingMessage } from 'node:http' +import { IncomingMessage } from 'node:http' import { Readable } from 'node:stream' -export const normalizeHeaders = (headers: IncomingHttpHeaders): HeadersInit => { +export const normalizeHeaders = (request: IncomingMessage) => { const result: [string, string][] = [] + let headers = request.headers + + // Handle HTTP/2 pseudo-headers: https://www.rfc-editor.org/rfc/rfc9113.html#name-request-pseudo-header-field + // In certain versions of Node.js, the built-in `Request` constructor from undici throws + // if a header starts with a colon. + if (request.httpVersionMajor >= 2) { + headers = { ...headers } + delete headers[':authority'] + delete headers[':method'] + delete headers[':path'] + delete headers[':scheme'] + } for (const [key, value] of Object.entries(headers)) { if (Array.isArray(value)) { @@ -43,11 +55,14 @@ export const getNormalizedRequestFromNodeRequest = ( ? null : (Readable.toWeb(input) as unknown as ReadableStream) + const normalizedHeaders = normalizeHeaders(input) + normalizedHeaders.push(['x-nf-request-id', requestID]) + return new Request(fullUrl, { body, // @ts-expect-error Not typed! duplex: 'half', - headers: normalizeHeaders({ ...input.headers, 'x-nf-request-id': requestID }), + headers: normalizedHeaders, method, }) } diff --git a/packages/dev/src/main.test.ts b/packages/dev/src/main.test.ts index 2d0129d5..968a1dfa 100644 --- a/packages/dev/src/main.test.ts +++ b/packages/dev/src/main.test.ts @@ -1,4 +1,6 @@ import { readFile } from 'node:fs/promises' +import { IncomingMessage } from 'node:http' +import { Socket } from 'node:net' import { resolve } from 'node:path' import { createImageServerHandler, Fixture, generateImage, getImageResponseSize, HTTPServer } from '@netlify/dev-utils' @@ -14,6 +16,48 @@ describe('Handling requests', () => { vi.unstubAllEnvs() }) + test('Handles HTTP/2 Node.js requests', async () => { + const fixture = new Fixture() + .withFile( + 'netlify.toml', + `[build] + publish = "public" + `, + ) + .withFile('public/index.html', 'Hello from static file') + const directory = await fixture.create() + const dev = new NetlifyDev({ + projectRoot: directory, + geolocation: { enabled: false }, + }) + await dev.start() + + const nodeReq = new IncomingMessage(new Socket()) + nodeReq.httpVersionMajor = 2 + nodeReq.httpVersionMinor = 0 + nodeReq.method = 'GET' + nodeReq.url = '/index.html' + nodeReq.headers = { + accept: 'text/html', + host: 'example.netlify.app', + 'user-agent': 'test-agent', + // These four HTTP/2 pseudo request headers are required per the HTTP/2 spec: + // https://www.rfc-editor.org/rfc/rfc9113.html#name-request-pseudo-header-field + // These show up here like any other header on Node.js IncomingMessage objects, + ':method': 'GET', + ':path': '/index.html', + ':scheme': 'https', + ':authority': 'example.netlify.app', + } + const result = await dev.handleAndIntrospectNodeRequest(nodeReq) + + expect(result?.response.ok).toBe(true) + expect(await result?.response.text()).toBe('Hello from static file') + + await dev.stop() + await fixture.destroy() + }) + describe('No linked site', () => { test('Same-site rewrite to a static file', async () => { const fixture = new Fixture()