diff --git a/.changeset/vite-plugin-request-set-cookie.md b/.changeset/vite-plugin-request-set-cookie.md new file mode 100644 index 0000000000..5724a83300 --- /dev/null +++ b/.changeset/vite-plugin-request-set-cookie.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Preserve separate `Set-Cookie` request headers in Vite dev and preview + +Workers now receive each incoming `Set-Cookie` value separately for HTTP requests and WebSocket upgrades, including values with commas in an `Expires` date. Previously, multiple values could be combined into one header. diff --git a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts index 4b09a4c005..5550c7cc1e 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import http from "node:http"; import net from "node:net"; import * as path from "node:path"; +import { json } from "node:stream/consumers"; import { Response as MiniflareResponse } from "miniflare"; import { afterEach, beforeEach, describe, test } from "vitest"; import { @@ -329,11 +330,15 @@ describe("createRequestHandler", () => { ); }); - function startServer() { - const handler = createRequestHandler(async (request) => { + function startServer( + fetchHandler: Parameters[0] = async ( + request + ) => { capturedUrls.push(request.url); return new MiniflareResponse("OK"); - }); + } + ) { + const handler = createRequestHandler(fetchHandler); httpServer = http.createServer((req, res) => { void handler( @@ -353,6 +358,44 @@ describe("createRequestHandler", () => { ); } + test("preserves repeated request Set-Cookie headers", async ({ expect }) => { + await startServer(async (request) => + MiniflareResponse.json({ + setCookie: request.headers.getSetCookie(), + cookie: request.headers.get("cookie"), + tags: request.headers.get("x-tag"), + }) + ); + + const response = await new Promise( + (resolve, reject) => { + http + .get( + `http://127.0.0.1:${port}/`, + { + headers: { + "set-cookie": [ + "a=1; Expires=Wed, 21 Oct 2037 07:28:00 GMT", + "b=2; Path=/", + ], + cookie: ["session=abc", "theme=dark"], + "x-tag": ["one", "two"], + }, + }, + resolve + ) + .on("error", reject); + } + ); + + expect(response.statusCode).toBe(200); + expect(await json(response)).toEqual({ + setCookie: ["a=1; Expires=Wed, 21 Oct 2037 07:28:00 GMT", "b=2; Path=/"], + cookie: "session=abc; theme=dark", + tags: "one, two", + }); + }); + test("falls back to `http://` when no `X-Forwarded-Proto` header is set", async ({ expect, }) => { diff --git a/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts index d322be027f..ca8aef6f6e 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts @@ -216,6 +216,41 @@ describe("handleWebSocket", () => { return mockedDispatchFetch.mock.lastCall[0]; } + test("preserves repeated request Set-Cookie headers during upgrade", async ({ + expect, + }) => { + const mf = await listen(); + const dispatchFetch = vi + .spyOn(mf, "dispatchFetch") + .mockResolvedValue(new Response(null)); + const socket = await connect(); + socket.write( + [ + "GET / HTTP/1.1", + `Host: localhost:${port}`, + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version: 13", + "Set-Cookie: a=1; Expires=Wed, 21 Oct 2037 07:28:00 GMT", + "Set-Cookie: b=2; Path=/", + "Cookie: session=abc", + "Cookie: theme=dark", + "", + "", + ].join("\r\n") + ); + + await vi.waitFor(() => expect(dispatchFetch).toHaveBeenCalled()); + const init = dispatchFetch.mock.lastCall?.[1]; + assert(init?.headers instanceof Headers); + expect(init.headers.getSetCookie()).toEqual([ + "a=1; Expires=Wed, 21 Oct 2037 07:28:00 GMT", + "b=2; Path=/", + ]); + expect(init.headers.get("cookie")).toBe("session=abc; theme=dark"); + }); + test("falls back to `http://` when no `X-Forwarded-Proto` header is set", async ({ expect, }) => { diff --git a/packages/vite-plugin-cloudflare/src/utils.ts b/packages/vite-plugin-cloudflare/src/utils.ts index bf63d8e401..dd79aa40fc 100644 --- a/packages/vite-plugin-cloudflare/src/utils.ts +++ b/packages/vite-plugin-cloudflare/src/utils.ts @@ -131,6 +131,27 @@ export function satisfiesMinimumViteVersion(minVersion: string): boolean { return semverGte(viteVersion, minVersion); } +/** + * Converts incoming request headers while preserving separate `Set-Cookie` values. + * + * @param req The incoming HTTP request + * @returns Fetch headers with the original cookie boundaries + */ +export function createHeadersForIncomingMessage( + req: http.IncomingMessage +): Headers { + const headers = createHeaders(req); + const cookies = req.headers["set-cookie"]; + if (cookies) { + // Upstream joins array entries with commas, which can also appear in Expires. + headers.delete("set-cookie"); + for (const cookie of cookies) { + headers.append("set-cookie", cookie); + } + } + return headers; +} + export function createRequestForIncomingMessage( req: vite.Connect.IncomingMessage, res: http.ServerResponse, @@ -142,7 +163,7 @@ export function createRequestForIncomingMessage( }); const method = req.method ?? "GET"; - const headers = createHeaders(req); + const headers = createHeadersForIncomingMessage(req); const protocol = options?.protocol ?? getScheme(req) ?? diff --git a/packages/vite-plugin-cloudflare/src/websockets.ts b/packages/vite-plugin-cloudflare/src/websockets.ts index 8b9521b819..b2ad52b1e1 100644 --- a/packages/vite-plugin-cloudflare/src/websockets.ts +++ b/packages/vite-plugin-cloudflare/src/websockets.ts @@ -1,8 +1,7 @@ -import { createHeaders } from "@remix-run/node-fetch-server"; import { CoreHeaders, coupleWebSocket } from "miniflare"; import { WebSocketServer } from "ws"; import { UNKNOWN_HOST } from "./shared"; -import { getForwardedProto } from "./utils"; +import { createHeadersForIncomingMessage, getForwardedProto } from "./utils"; import type { Headers, Miniflare } from "miniflare"; import type { IncomingMessage } from "node:http"; import type { Duplex } from "node:stream"; @@ -65,7 +64,7 @@ export function handleWebSocket( return; } - const headers = createHeaders(request); + const headers = createHeadersForIncomingMessage(request); if (entryWorkerName) { headers.set(CoreHeaders.ROUTE_OVERRIDE, entryWorkerName);