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
14 changes: 7 additions & 7 deletions packages/open-next/src/adapters/edge-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,14 @@ const defaultHandler = async (
});
const responseHeaders: Record<string, string | string[]> = {};
response.headers.forEach((value, key) => {
if (key.toLowerCase() === "set-cookie") {
responseHeaders[key] = responseHeaders[key]
? [...responseHeaders[key], value]
: [value];
} else {
responseHeaders[key] = value;
}
// Headers.forEach folds same-name headers; use getSetCookie() below instead.
if (key.toLowerCase() === "set-cookie") return;
responseHeaders[key] = value;
});
const setCookies = response.headers.getSetCookie();
if (setCookies.length > 0) {
responseHeaders["set-cookie"] = setCookies;
}

const body =
(response.body as ReadableStream<Uint8Array>) ?? emptyReadableStream();
Expand Down
15 changes: 7 additions & 8 deletions packages/open-next/src/core/routing/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,20 +130,19 @@ export async function handleMiddleware(
reqHeaders[k] = value;
} else {
if (filteredHeaders.includes(key.toLowerCase())) return;
if (key.toLowerCase() === "set-cookie") {
resHeaders[key] = resHeaders[key]
? [...resHeaders[key], value]
: [value];
} else if (
REDIRECTS.has(statusCode) &&
key.toLowerCase() === "location"
) {
// Headers.forEach folds same-name headers; use getSetCookie() below instead.
if (key.toLowerCase() === "set-cookie") return;
if (REDIRECTS.has(statusCode) && key.toLowerCase() === "location") {
resHeaders[key] = normalizeLocationHeader(value, internalEvent.url);
} else {
resHeaders[key] = value;
}
}
});
const setCookies = responseHeaders.getSetCookie();
if (setCookies.length > 0) {
resHeaders["set-cookie"] = setCookies;
}

// If the middleware returned a Rewrite, set the `url` to the pathname of the rewrite
// NOTE: the header was added to `req` from above
Expand Down
82 changes: 82 additions & 0 deletions packages/tests-unit/tests/adapters/edge-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// edge-adapter imports globalThis.isEdgeRuntime and createGenericHandler which
// require heavy bundled context. Unit-test the header harvest logic directly.

function harvestHeaders(
headers: Pick<Headers, "forEach" | "getSetCookie">,
): Record<string, string | string[]> {
const responseHeaders: Record<string, string | string[]> = {};
headers.forEach((value, key) => {
if (key.toLowerCase() === "set-cookie") return;
responseHeaders[key] = value;
});
const setCookies = headers.getSetCookie();
if (setCookies.length > 0) {
responseHeaders["set-cookie"] = setCookies;
}
return responseHeaders;
}

// Simulates Headers.forEach folding same-name headers (WHATWG-compliant behavior
// on runtimes where this occurs), while getSetCookie() still returns them split.
function makeFoldingHeaders(
cookies: string[],
extra: Record<string, string> = {},
): Pick<Headers, "forEach" | "getSetCookie"> {
return {
forEach(fn: (value: string, key: string) => void) {
if (cookies.length > 0) fn(cookies.join(", "), "set-cookie");
for (const [k, v] of Object.entries(extra)) fn(v, k);
},
getSetCookie() {
return [...cookies];
},
} as unknown as Pick<Headers, "forEach" | "getSetCookie">;
}

describe("edge-adapter header harvest", () => {
it("emits a single set-cookie as a one-element array", () => {
const headers = makeFoldingHeaders(["session=abc; Path=/; HttpOnly"]);
const out = harvestHeaders(headers);
expect(out["set-cookie"]).toEqual(["session=abc; Path=/; HttpOnly"]);
});

it("preserves multiple set-cookie headers when forEach folds them", () => {
const cookies = [
"appSession.0=AAA; HttpOnly; SameSite=Lax; Path=/",
"appSession.1=BBB; HttpOnly; SameSite=Lax; Path=/",
"appSession.2=CCC; HttpOnly; SameSite=Lax; Path=/",
];
const out = harvestHeaders(
makeFoldingHeaders(cookies, { "x-custom": "value" }),
);
expect(out["set-cookie"]).toEqual(cookies);
});

it("each set-cookie entry is discrete, not comma-joined", () => {
const cookies = [
"appSession.0=AAA; HttpOnly; Path=/",
"appSession.1=BBB; HttpOnly; Path=/",
];
const out = harvestHeaders(makeFoldingHeaders(cookies));
for (const entry of out["set-cookie"] as string[]) {
expect(entry).not.toContain(", appSession");
}
});

it("passes non-set-cookie headers through unchanged", () => {
const out = harvestHeaders(
makeFoldingHeaders(["tok=x; Path=/"], {
"content-type": "application/json",
"x-request-id": "abc-123",
}),
);
expect(out["content-type"]).toBe("application/json");
expect(out["x-request-id"]).toBe("abc-123");
});

it("omits set-cookie key when there are no cookies", () => {
const out = harvestHeaders(makeFoldingHeaders([], { "x-foo": "bar" }));
expect("set-cookie" in out).toBe(false);
expect(out["x-foo"]).toBe("bar");
});
});
78 changes: 78 additions & 0 deletions packages/tests-unit/tests/core/routing/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,4 +363,82 @@ describe("handleMiddleware", () => {
}),
);
});

it("should preserve multiple set-cookie headers from a terminal middleware response", async () => {
// Headers.forEach folds same-name headers; simulate that to keep the test
// runtime-independent while getSetCookie() still returns them split.
const foldingHeaders = {
forEach(fn: (value: string, key: string) => void) {
fn(
"appSession.0=AAA; HttpOnly; Path=/, appSession.1=BBB; HttpOnly; Path=/, appSession.2=CCC; HttpOnly; Path=/",
"set-cookie",
);
fn("https://example.com/", "location");
},
get(name: string) {
if (name.toLowerCase() === "location") return "https://example.com/";
return null;
},
getSetCookie() {
return [
"appSession.0=AAA; HttpOnly; Path=/",
"appSession.1=BBB; HttpOnly; Path=/",
"appSession.2=CCC; HttpOnly; Path=/",
];
},
};

const event = createEvent({});
middleware.mockResolvedValue({
status: 302,
headers: foldingHeaders,
body: null,
});

const result = await handleMiddleware(event, "", middlewareLoader);

expect(result.headers["set-cookie"]).toEqual([
"appSession.0=AAA; HttpOnly; Path=/",
"appSession.1=BBB; HttpOnly; Path=/",
"appSession.2=CCC; HttpOnly; Path=/",
]);
expect(result.headers.location).toEqual("https://example.com/");
});

it("should preserve multiple set-cookie headers when middleware returns next() with a rewrite", async () => {
const foldingHeaders = {
forEach(fn: (value: string, key: string) => void) {
fn(
"appSession.0=AAA; HttpOnly; Path=/, appSession.1=BBB; HttpOnly; Path=/",
"set-cookie",
);
fn("http://localhost/rewrite", "x-middleware-rewrite");
},
get(name: string) {
if (name.toLowerCase() === "x-middleware-rewrite")
return "http://localhost/rewrite";
return null;
},
getSetCookie() {
return [
"appSession.0=AAA; HttpOnly; Path=/",
"appSession.1=BBB; HttpOnly; Path=/",
];
},
};

const event = createEvent({ headers: { host: "localhost" } });
middleware.mockResolvedValue({
status: 200,
headers: foldingHeaders,
body: null,
});

const result = await handleMiddleware(event, "", middlewareLoader);

expect(result.responseHeaders?.["set-cookie"]).toEqual([
"appSession.0=AAA; HttpOnly; Path=/",
"appSession.1=BBB; HttpOnly; Path=/",
]);
});
});