Skip to content
Merged
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-request-set-cookie.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 46 additions & 3 deletions packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -329,11 +330,15 @@ describe("createRequestHandler", () => {
);
});

function startServer() {
const handler = createRequestHandler(async (request) => {
function startServer(
fetchHandler: Parameters<typeof createRequestHandler>[0] = async (
request
) => {
capturedUrls.push(request.url);
return new MiniflareResponse("OK");
});
}
) {
const handler = createRequestHandler(fetchHandler);

httpServer = http.createServer((req, res) => {
void handler(
Expand All @@ -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<http.IncomingMessage>(
(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,
}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down
23 changes: 22 additions & 1 deletion packages/vite-plugin-cloudflare/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) ??
Expand Down
5 changes: 2 additions & 3 deletions packages/vite-plugin-cloudflare/src/websockets.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -65,7 +64,7 @@ export function handleWebSocket(
return;
}

const headers = createHeaders(request);
const headers = createHeadersForIncomingMessage(request);

if (entryWorkerName) {
headers.set(CoreHeaders.ROUTE_OVERRIDE, entryWorkerName);
Expand Down
Loading