diff --git a/.changeset/vite-plugin-preserve-authority-port.md b/.changeset/vite-plugin-preserve-authority-port.md new file mode 100644 index 00000000000..82acf436173 --- /dev/null +++ b/.changeset/vite-plugin-preserve-authority-port.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Preserve HTTP/2 `:authority` header and non-default port in dev server requests + +When Vite runs over HTTPS with HTTP/2 enabled, browsers send authority via the `:authority` pseudo-header rather than `Host`. Previously, pseudo-headers were omitted when creating Fetch requests, causing non-default ports to be dropped from `request.url` and `X-Forwarded-Host`. Authority and scheme are now preserved from HTTP/2 pseudo-headers and request properties. diff --git a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts index cfe6729887e..39e236cf84a 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts @@ -1,14 +1,20 @@ +import { EventEmitter } from "node:events"; import http from "node:http"; import net from "node:net"; import * as path from "node:path"; import { Response as MiniflareResponse } from "miniflare"; import { afterEach, beforeEach, describe, test } from "vitest"; import { + createRequestForIncomingMessage, createRequestHandler, + getAuthority, getForwardedProto, getOutputDirectory, + getScheme, + toMiniflareRequest, } from "../utils"; import type { AddressInfo } from "node:net"; +import type * as vite from "vite"; describe("getOutputDirectory", () => { test("returns the correct output if `environments[environmentName].build.outDir` is defined", ({ @@ -100,6 +106,211 @@ describe("getForwardedProto", () => { }); }); +describe("getAuthority", () => { + test("returns undefined when header and authority property are missing", ({ + expect, + }) => { + expect(getAuthority({ headers: {} })).toBeUndefined(); + }); + + test("extracts authority from `:authority` header", ({ expect }) => { + expect(getAuthority({ headers: { ":authority": "localhost:5173" } })).toBe( + "localhost:5173" + ); + }); + + test("handles string array for `:authority` header", ({ expect }) => { + expect( + getAuthority({ headers: { ":authority": ["localhost:5173"] } }) + ).toBe("localhost:5173"); + }); + + test("extracts authority from `authority` property on request", ({ + expect, + }) => { + expect(getAuthority({ headers: {}, authority: "localhost:5173" })).toBe( + "localhost:5173" + ); + }); + + test("prioritizes `:authority` header over `authority` property", ({ + expect, + }) => { + expect( + getAuthority({ + headers: { ":authority": "header-host:5173" }, + authority: "prop-host:5173", + }) + ).toBe("header-host:5173"); + }); + + test("returns undefined when `:authority` is an empty string", ({ + expect, + }) => { + expect(getAuthority({ headers: { ":authority": "" } })).toBeUndefined(); + }); +}); + +describe("getScheme", () => { + test("returns undefined when header and scheme property are missing", ({ + expect, + }) => { + expect(getScheme({ headers: {} })).toBeUndefined(); + }); + + test("returns https: when `:scheme` header is `https`", ({ expect }) => { + expect(getScheme({ headers: { ":scheme": "https" } })).toBe("https:"); + }); + + test("returns http: when `:scheme` header is `http`", ({ expect }) => { + expect(getScheme({ headers: { ":scheme": "http" } })).toBe("http:"); + }); + + test("is case-insensitive", ({ expect }) => { + expect(getScheme({ headers: { ":scheme": "HTTPS" } })).toBe("https:"); + }); + + test("handles string array for `:scheme` header", ({ expect }) => { + expect(getScheme({ headers: { ":scheme": ["https"] } })).toBe("https:"); + }); + + test("extracts scheme from `scheme` property on request", ({ expect }) => { + expect(getScheme({ headers: {}, scheme: "https" })).toBe("https:"); + }); + + test("returns undefined for unsupported schemes", ({ expect }) => { + expect(getScheme({ headers: { ":scheme": "ws" } })).toBeUndefined(); + expect(getScheme({ headers: { ":scheme": "" } })).toBeUndefined(); + }); +}); + +describe("createRequestForIncomingMessage and toMiniflareRequest", () => { + test("preserves non-default port and hostname from `:authority` in request.url", ({ + expect, + }) => { + const res = new EventEmitter() as unknown as http.ServerResponse; + const req = { + method: "GET", + url: "/path?query=1", + headers: { + ":authority": "localhost:5173", + ":scheme": "https", + }, + rawHeaders: [":authority", "localhost:5173", ":scheme", "https"], + socket: {}, + } as unknown as vite.Connect.IncomingMessage; + + const request = createRequestForIncomingMessage(req, res); + expect(request.url).toBe("https://localhost:5173/path?query=1"); + expect(request.headers.get("Host")).toBe("localhost:5173"); + }); + + test("preserves non-default port with IPv6 authority", ({ expect }) => { + const res = new EventEmitter() as unknown as http.ServerResponse; + const req = { + method: "GET", + url: "/path", + headers: { + ":authority": "[::1]:5173", + ":scheme": "https", + }, + rawHeaders: [":authority", "[::1]:5173", ":scheme", "https"], + socket: {}, + } as unknown as vite.Connect.IncomingMessage; + + const request = createRequestForIncomingMessage(req, res); + expect(request.url).toBe("https://[::1]:5173/path"); + expect(request.headers.get("Host")).toBe("[::1]:5173"); + }); + + test("toMiniflareRequest sets X-Forwarded-Host including port when missing", ({ + expect, + }) => { + const res = new EventEmitter() as unknown as http.ServerResponse; + const req = { + method: "GET", + url: "/", + headers: { + ":authority": "localhost:5173", + ":scheme": "https", + }, + rawHeaders: [":authority", "localhost:5173", ":scheme", "https"], + socket: {}, + } as unknown as vite.Connect.IncomingMessage; + + const request = createRequestForIncomingMessage(req, res); + const miniflareRequest = toMiniflareRequest(request); + + expect(miniflareRequest.headers.get("X-Forwarded-Host")).toBe( + "localhost:5173" + ); + }); + + test("toMiniflareRequest preserves existing X-Forwarded-Host if already set", ({ + expect, + }) => { + const res = new EventEmitter() as unknown as http.ServerResponse; + const req = { + method: "GET", + url: "/", + headers: { + ":authority": "localhost:5173", + ":scheme": "https", + "x-forwarded-host": "external-proxy.com:8443", + }, + rawHeaders: [ + ":authority", + "localhost:5173", + ":scheme", + "https", + "x-forwarded-host", + "external-proxy.com:8443", + ], + socket: {}, + } as unknown as vite.Connect.IncomingMessage; + + const request = createRequestForIncomingMessage(req, res); + const miniflareRequest = toMiniflareRequest(request); + + expect(miniflareRequest.headers.get("X-Forwarded-Host")).toBe( + "external-proxy.com:8443" + ); + }); + + test("prioritizes `:authority` over a stale or port-less `Host` header", ({ + expect, + }) => { + const res = new EventEmitter() as unknown as http.ServerResponse; + const req = { + method: "GET", + url: "/path", + headers: { + ":authority": "localhost:5173", + ":scheme": "https", + host: "localhost", + }, + rawHeaders: [ + ":authority", + "localhost:5173", + ":scheme", + "https", + "host", + "localhost", + ], + socket: {}, + } as unknown as vite.Connect.IncomingMessage; + + const request = createRequestForIncomingMessage(req, res); + expect(request.url).toBe("https://localhost:5173/path"); + expect(request.headers.get("Host")).toBe("localhost:5173"); + + const miniflareRequest = toMiniflareRequest(request); + expect(miniflareRequest.headers.get("X-Forwarded-Host")).toBe( + "localhost:5173" + ); + }); +}); + describe("createRequestHandler", () => { // Use a real HTTP server so that `req`, `res`, and `req.socket` look like // what `createRequest` from `@remix-run/node-fetch-server` expects in diff --git a/packages/vite-plugin-cloudflare/src/utils.ts b/packages/vite-plugin-cloudflare/src/utils.ts index f30ca3dc063..92c5c15f36f 100644 --- a/packages/vite-plugin-cloudflare/src/utils.ts +++ b/packages/vite-plugin-cloudflare/src/utils.ts @@ -130,7 +130,7 @@ export function satisfiesMinimumViteVersion(minVersion: string): boolean { return semverGte(viteVersion, minVersion); } -function createRequestForIncomingMessage( +export function createRequestForIncomingMessage( req: vite.Connect.IncomingMessage, res: http.ServerResponse, options?: { protocol?: "http:" | "https:"; host?: string } @@ -144,8 +144,13 @@ function createRequestForIncomingMessage( const headers = createHeaders(req); const protocol = options?.protocol ?? + getScheme(req) ?? ("encrypted" in req.socket && req.socket.encrypted ? "https:" : "http:"); - const host = options?.host ?? headers.get("Host") ?? "localhost"; + const host = + options?.host ?? getAuthority(req) ?? headers.get("Host") ?? "localhost"; + if (headers.get("Host") !== host) { + headers.set("Host", host); + } const url = new URL(req.url ?? "/", `${protocol}//${host}`); const init: RequestInit & { duplex?: "half" } = { method, @@ -212,8 +217,8 @@ function createCancellableRequestBody( }); } -function toMiniflareRequest(request: Request): MiniflareRequest { - const host = request.headers.get("Host"); +export function toMiniflareRequest(request: Request): MiniflareRequest { + const host = request.headers.get("Host") ?? new URL(request.url).host; const xForwardedHost = request.headers.get("X-Forwarded-Host"); if (host && !xForwardedHost) { @@ -264,3 +269,59 @@ export function getForwardedProto(req: { } return undefined; } + +/** + * Extracts the authority (host and port) from an incoming Node.js request. + * + * In HTTP/2, browsers pass the host and port in the `:authority` pseudo-header + * rather than the HTTP/1.1 `Host` header. This helper inspects `:authority` + * (or the `authority` property on `http2.Http2ServerRequest`) so that ports + * are preserved when converting Node requests to standard Fetch requests. + * + * @param req Incoming request containing HTTP headers and optional authority property + * @returns The authority string (e.g. "localhost:5173"), or undefined if not present + */ +export function getAuthority(req: { + headers: http.IncomingHttpHeaders; + authority?: string; +}): string | undefined { + const raw = req.headers[":authority"]; + const value = Array.isArray(raw) ? raw[0] : raw; + if (value) { + return value; + } + if (typeof req.authority === "string" && req.authority) { + return req.authority; + } + return undefined; +} + +/** + * Extracts the scheme from an incoming Node.js request. + * + * Checks the `:scheme` pseudo-header in HTTP/2 requests or the `scheme` + * property on `http2.Http2ServerRequest`. + * + * @param req Incoming request containing HTTP headers and optional scheme property + * @returns The normalized scheme ("http:" or "https:"), or undefined if not present or unsupported + */ +export function getScheme(req: { + headers: http.IncomingHttpHeaders; + scheme?: string; +}): "http:" | "https:" | undefined { + const raw = req.headers[":scheme"]; + const value = Array.isArray(raw) ? raw[0] : raw; + if (value) { + const lower = value.toLowerCase(); + if (lower === "http" || lower === "https") { + return `${lower}:`; + } + } + if (typeof req.scheme === "string" && req.scheme) { + const lower = req.scheme.toLowerCase(); + if (lower === "http" || lower === "https") { + return `${lower}:`; + } + } + return undefined; +}