Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/vite-plugin-preserve-authority-port.md
Original file line number Diff line number Diff line change
@@ -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.
211 changes: 211 additions & 0 deletions packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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", ({
Expand Down Expand Up @@ -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
Expand Down
69 changes: 65 additions & 4 deletions packages/vite-plugin-cloudflare/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Loading