diff --git a/.changeset/fix-https-http2-host-port.md b/.changeset/fix-https-http2-host-port.md new file mode 100644 index 0000000000..077aaa1297 --- /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 846cbfa37d..09b68e0ee1 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,55 @@ 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) => { + // 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"; + }); + 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) => { + setRawHeader(req, "host", "localhost:5173"); + 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"); + }); }); + +/** 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 8609cfe6b8..3af3e64a64 100644 --- a/packages/vite-plugin-cloudflare/src/utils.ts +++ b/packages/vite-plugin-cloudflare/src/utils.ts @@ -97,7 +97,26 @@ 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). + // 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 = + 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.set("Host", host); + } let response = await handler(toMiniflareRequest(request), req);