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
5 changes: 5 additions & 0 deletions .changeset/fix-https-http2-host-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/vite-plugin": patch
---

Preserve the host and non-default port (e.g. `localhost:5173`) when the Vite dev server runs over HTTPS/HTTP2, so authentication flows such as Clerk no longer redirect-loop to the wrong origin
76 changes: 76 additions & 0 deletions packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import http from "node:http";
import http2 from "node:http2";
import net from "node:net";
import * as path from "node:path";
import { Response as MiniflareResponse } from "miniflare";
Expand Down Expand Up @@ -368,3 +369,78 @@ describe("createRequestHandler", () => {
}
});
});

describe("createRequestHandler over HTTP/2", () => {
// Browsers use HTTP/2 whenever `server.https` is enabled, and HTTP/2 carries
// the authority in the `:authority` pseudo-header rather than in `Host`.
// A cleartext (h2c) server exercises the same code path without needing TLS.
let h2Server: http2.Http2Server;
let h2Port: number;
let capturedUrls: string[];
let capturedForwardedHosts: (string | null)[];

afterEach(async () => {
await new Promise<void>((resolve, reject) =>
h2Server?.close((e) => (e ? reject(e) : resolve()))
);
});

function startH2Server() {
capturedUrls = [];
capturedForwardedHosts = [];
const handler = createRequestHandler(async (request) => {
capturedUrls.push(request.url);
capturedForwardedHosts.push(request.headers.get("X-Forwarded-Host"));
return new MiniflareResponse("OK");
});

h2Server = http2.createServer((req, res) => {
void handler(
req as unknown as Parameters<typeof handler>[0],
res as unknown as Parameters<typeof handler>[1],
(error: unknown) => {
res.statusCode = 500;
res.end(error instanceof Error ? error.message : String(error));
}
);
});

return new Promise<void>((r) =>
h2Server.listen(0, "127.0.0.1", () => {
h2Port = (h2Server.address() as AddressInfo).port;
r();
})
);
}

async function h2Get(pathname: string) {
const client = http2.connect(`http://127.0.0.1:${h2Port}`);
try {
await new Promise<void>((resolve, reject) => {
const req = client.request({ ":path": pathname, ":method": "GET" });
req.on("response", () => req.resume());
req.on("end", () => resolve());
req.on("error", reject);
req.end();
});
} finally {
client.close();
}
}

test("keeps the authority, including the port, in `request.url`", async ({
expect,
}) => {
await startH2Server();
await h2Get("/path");
expect(capturedUrls[0]).toBe(`http://127.0.0.1:${h2Port}/path`);
});

test("sets `X-Forwarded-Host` from the `:authority` pseudo-header", async ({
expect,
}) => {
await startH2Server();
await h2Get("/path");
expect(capturedForwardedHosts[0]).toBe(`127.0.0.1:${h2Port}`);
});
});
26 changes: 24 additions & 2 deletions packages/vite-plugin-cloudflare/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ function createRequestForIncomingMessage(
const protocol =
options?.protocol ??
("encrypted" in req.socket && req.socket.encrypted ? "https:" : "http:");
const host = options?.host ?? headers.get("Host") ?? "localhost";
const host = options?.host ?? getRequestHost(req) ?? "localhost";
const url = new URL(req.url ?? "/", `${protocol}//${host}`);
const init: RequestInit & { duplex?: "half" } = {
method,
Expand Down Expand Up @@ -213,7 +213,9 @@ function createCancellableRequestBody(
}

function toMiniflareRequest(request: Request): MiniflareRequest {
const host = request.headers.get("Host");
// Under HTTP/2 there is no `Host` header, but the request URL was built from
// the resolved authority, so it still carries the host and port.
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 @@ -243,6 +245,26 @@ function toMiniflareRequest(request: Request): MiniflareRequest {

export const isRolldown = "rolldownVersion" in vite;

/**
* Resolves the authority (host and port) of an incoming Node.js request.
*
* HTTP/1.1 carries it in the `Host` header, but HTTP/2 — which browsers use
* whenever `server.https` is enabled — carries it in the `:authority`
* pseudo-header instead. `createHeaders()` skips pseudo-headers, so `Host` is
* absent from the parsed headers under HTTP/2 and the authority has to be read
* from `req.headers` directly.
*
* Returns `undefined` if neither is present, so callers can apply their own
* fallback.
*/
export function getRequestHost(req: {
headers: http.IncomingHttpHeaders;
}): string | undefined {
const raw = req.headers.host ?? req.headers[":authority"];
Comment thread
intrdx marked this conversation as resolved.
const value = Array.isArray(raw) ? raw[0] : raw;
return value?.trim() || undefined;
}

/**
* Parses the `X-Forwarded-Proto` header from an incoming Node.js request.
*
Expand Down
Loading