From 7db27e51683ddee7c2cd1a938cd81569711682bb Mon Sep 17 00:00:00 2001 From: intrdx Date: Thu, 30 Jul 2026 17:17:11 +0530 Subject: [PATCH 1/2] fix: preserve HTTPS/HTTP2 host port in vite-plugin requests Co-authored-by: Cursor --- .changeset/fix-https-http2-host-port.md | 5 ++++ .../src/__tests__/utils.spec.ts | 29 ++++++++++++++++++- packages/vite-plugin-cloudflare/src/utils.ts | 20 ++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-https-http2-host-port.md diff --git a/.changeset/fix-https-http2-host-port.md b/.changeset/fix-https-http2-host-port.md new file mode 100644 index 00000000000..077aaa12978 --- /dev/null +++ b/.changeset/fix-https-http2-host-port.md @@ -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 diff --git a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts index 846cbfa37da..3e3a546deeb 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts @@ -106,9 +106,11 @@ describe("createRequestHandler", () => { let httpServer: http.Server; let port: number; let capturedUrls: string[]; + let capturedForwardedHosts: (string | null)[]; beforeEach(async () => { capturedUrls = []; + capturedForwardedHosts = []; }); afterEach(async () => { @@ -117,13 +119,15 @@ describe("createRequestHandler", () => { ); }); - function startServer() { + function startServer(mutateReq?: (req: http.IncomingMessage) => void) { const handler = createRequestHandler(async (request) => { capturedUrls.push(request.url); + capturedForwardedHosts.push(request.headers.get("X-Forwarded-Host")); return new MiniflareResponse("OK"); }); httpServer = http.createServer((req, res) => { + mutateReq?.(req); void handler( req as unknown as Parameters[0], res, @@ -188,4 +192,27 @@ describe("createRequestHandler", () => { }); expect(capturedUrls[0]).toBe(`http://127.0.0.1:${port}/path`); }); + + test("preserves non-default port from `:authority` when `Host` is missing", async ({ + expect, + }) => { + await startServer((req) => { + delete req.headers.host; + req.headers[":authority"] = "localhost:5173"; + }); + await fetch(`http://127.0.0.1:${port}/path`); + expect(capturedUrls[0]).toBe("http://localhost:5173/path"); + expect(capturedForwardedHosts[0]).toBe("localhost:5173"); + }); + + test("preserves non-default port from the `Host` header", async ({ + expect, + }) => { + await startServer((req) => { + req.headers.host = "localhost:5173"; + }); + await fetch(`http://127.0.0.1:${port}/path`); + expect(capturedUrls[0]).toBe("http://localhost:5173/path"); + expect(capturedForwardedHosts[0]).toBe("localhost:5173"); + }); }); diff --git a/packages/vite-plugin-cloudflare/src/utils.ts b/packages/vite-plugin-cloudflare/src/utils.ts index 8609cfe6b80..c46bea4bf25 100644 --- a/packages/vite-plugin-cloudflare/src/utils.ts +++ b/packages/vite-plugin-cloudflare/src/utils.ts @@ -97,7 +97,25 @@ export function createRequestHandler( // If the header is absent or invalid, `createRequest` falls back to the // connection protocol (`req.socket.encrypted`). const protocol = getForwardedProto(req); - request = createRequest(req, res, protocol ? { protocol } : undefined); + // Prefer Node host/:authority so HTTPS/HTTP2 keeps non-default ports (e.g. :5173). + // createHeaders() skips pseudo-headers, so Host can be missing and X-Forwarded-Host + // would otherwise not preserve the Vite origin port used by auth libraries (Clerk). + const nodeHost = + typeof req.headers.host === "string" ? req.headers.host : undefined; + const authority = + typeof req.headers[":authority"] === "string" + ? req.headers[":authority"] + : undefined; + const host = nodeHost ?? authority; + + request = createRequest(req, res, { + ...(protocol ? { protocol } : {}), + ...(host ? { host } : {}), + }); + + if (host && !request.headers.has("Host")) { + request.headers.set("Host", host); + } let response = await handler(toMiniflareRequest(request), req); From 3c38904cd79edba6580fcb5408b09d9867a47d14 Mon Sep 17 00:00:00 2001 From: intrdx Date: Mon, 3 Aug 2026 23:51:26 +0530 Subject: [PATCH 2/2] fix: sync Host with resolved origin for X-Forwarded-Host tests createHeaders reads rawHeaders, so always set Host from host/:authority and mutate rawHeaders in tests that simulate HTTP/2 or a custom Host. Co-authored-by: Cursor --- .../src/__tests__/utils.spec.ts | 28 +++++++++++++++++++ packages/vite-plugin-cloudflare/src/utils.ts | 7 +++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts index 3e3a546deeb..09b68e0ee1a 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts @@ -197,6 +197,9 @@ describe("createRequestHandler", () => { expect, }) => { await startServer((req) => { + // createHeaders reads rawHeaders (and skips pseudo-headers), so drop Host + // there to mirror HTTP/2 where only `:authority` carries the origin. + deleteRawHeader(req, "host"); delete req.headers.host; req.headers[":authority"] = "localhost:5173"; }); @@ -209,6 +212,7 @@ describe("createRequestHandler", () => { expect, }) => { await startServer((req) => { + setRawHeader(req, "host", "localhost:5173"); req.headers.host = "localhost:5173"; }); await fetch(`http://127.0.0.1:${port}/path`); @@ -216,3 +220,27 @@ describe("createRequestHandler", () => { expect(capturedForwardedHosts[0]).toBe("localhost:5173"); }); }); + +/** Mutate `rawHeaders` — `createHeaders` ignores `req.headers` mutations. */ +function deleteRawHeader(req: http.IncomingMessage, name: string) { + const lower = name.toLowerCase(); + const raw = req.rawHeaders; + for (let i = 0; i < raw.length; i += 2) { + if (raw[i].toLowerCase() === lower) { + raw.splice(i, 2); + return; + } + } +} + +function setRawHeader(req: http.IncomingMessage, name: string, value: string) { + const lower = name.toLowerCase(); + const raw = req.rawHeaders; + for (let i = 0; i < raw.length; i += 2) { + if (raw[i].toLowerCase() === lower) { + raw[i + 1] = value; + return; + } + } + raw.push(name, value); +} diff --git a/packages/vite-plugin-cloudflare/src/utils.ts b/packages/vite-plugin-cloudflare/src/utils.ts index c46bea4bf25..3af3e64a649 100644 --- a/packages/vite-plugin-cloudflare/src/utils.ts +++ b/packages/vite-plugin-cloudflare/src/utils.ts @@ -98,8 +98,9 @@ export function createRequestHandler( // connection protocol (`req.socket.encrypted`). const protocol = getForwardedProto(req); // Prefer Node host/:authority so HTTPS/HTTP2 keeps non-default ports (e.g. :5173). - // createHeaders() skips pseudo-headers, so Host can be missing and X-Forwarded-Host - // would otherwise not preserve the Vite origin port used by auth libraries (Clerk). + // createRequest only applies `options.host` to request.url; Host still comes from + // rawHeaders (and createHeaders skips :authority), so we must set Host ourselves + // for toMiniflareRequest to populate X-Forwarded-Host with the same origin. const nodeHost = typeof req.headers.host === "string" ? req.headers.host : undefined; const authority = @@ -113,7 +114,7 @@ export function createRequestHandler( ...(host ? { host } : {}), }); - if (host && !request.headers.has("Host")) { + if (host) { request.headers.set("Host", host); }