Skip to content

Commit 3ae6163

Browse files
fix(to-response): preserve multiple Set-Cookie when merging headers
Co-authored-by: Rastislav ₡ore <rastislav@onion.email>
1 parent 394fd03 commit 3ae6163

2 files changed

Lines changed: 103 additions & 17 deletions

File tree

packages/better-call/src/to-response.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,89 @@ describe("toResponse", () => {
411411
});
412412
});
413413

414+
/**
415+
* @see https://github.com/better-auth/better-call/issues/118
416+
*/
417+
describe("multiple Set-Cookie preservation", () => {
418+
it("preserves every Set-Cookie when merging init.headers onto an existing Response", () => {
419+
const existing = new Response("ok");
420+
existing.headers.append("set-cookie", "a=1; Path=/");
421+
const initHeaders = new Headers();
422+
initHeaders.append("set-cookie", "b=2; Path=/");
423+
initHeaders.append("set-cookie", "c=3; Path=/");
424+
425+
const response = toResponse(existing, { headers: initHeaders });
426+
427+
expect(response.headers.getSetCookie()).toEqual([
428+
"a=1; Path=/",
429+
"b=2; Path=/",
430+
"c=3; Path=/",
431+
]);
432+
});
433+
434+
it("preserves every Set-Cookie from init.headers in JSON-flag responses", () => {
435+
const initHeaders = new Headers();
436+
initHeaders.append("set-cookie", "a=1; Path=/");
437+
initHeaders.append("set-cookie", "b=2; Path=/");
438+
439+
const response = toResponse(
440+
{ _flag: "json", body: { ok: true } },
441+
{ headers: initHeaders },
442+
);
443+
444+
expect(response.headers.getSetCookie()).toEqual([
445+
"a=1; Path=/",
446+
"b=2; Path=/",
447+
]);
448+
});
449+
450+
it("preserves every Set-Cookie from data.headers in JSON-flag responses", () => {
451+
const dataHeaders = new Headers();
452+
dataHeaders.append("set-cookie", "a=1; Path=/");
453+
dataHeaders.append("set-cookie", "b=2; Path=/");
454+
455+
const response = toResponse({
456+
_flag: "json",
457+
body: { ok: true },
458+
headers: dataHeaders,
459+
});
460+
461+
expect(response.headers.getSetCookie()).toEqual([
462+
"a=1; Path=/",
463+
"b=2; Path=/",
464+
]);
465+
});
466+
467+
it("preserves every Set-Cookie from routerResponse.headers in JSON-flag responses", () => {
468+
const routerHeaders = new Headers();
469+
routerHeaders.append("set-cookie", "a=1; Path=/");
470+
routerHeaders.append("set-cookie", "b=2; Path=/");
471+
472+
const response = toResponse({
473+
_flag: "json",
474+
body: { ok: true },
475+
routerResponse: { headers: routerHeaders },
476+
});
477+
478+
expect(response.headers.getSetCookie()).toEqual([
479+
"a=1; Path=/",
480+
"b=2; Path=/",
481+
]);
482+
});
483+
484+
it("propagates non-cookie headers from routerResponse.headers in JSON-flag responses", () => {
485+
const response = toResponse({
486+
_flag: "json",
487+
body: { ok: true },
488+
routerResponse: {
489+
headers: { "x-custom": "from-router" },
490+
},
491+
});
492+
493+
expect(response.headers.get("x-custom")).toBe("from-router");
494+
});
495+
});
496+
414497
describe("Request header stripping", () => {
415498
const REQUEST_ONLY_HEADERS = [
416499
// Request context (RFC 9110 §10.1)

packages/better-call/src/to-response.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,28 @@ function stripRequestOnlyHeaders(headers: Headers): void {
139139
}
140140
}
141141

142+
/**
143+
* Copy headers from `source` into `target`. `Set-Cookie` is appended (one
144+
* header per cookie) because RFC 9110 §5.3 notes it cannot be combined
145+
* into a single comma-separated value; other headers are set (replace).
146+
*/
147+
function copyHeaders(target: Headers, source: HeadersInit | undefined): void {
148+
if (!source) return;
149+
for (const [key, value] of new Headers(source).entries()) {
150+
if (key.toLowerCase() === "set-cookie") {
151+
target.append(key, value);
152+
} else {
153+
target.set(key, value);
154+
}
155+
}
156+
}
157+
142158
export function toResponse(data?: any, init?: ResponseInit): Response {
143159
if (data instanceof Response) {
144160
if (init?.headers) {
145161
const safeHeaders = new Headers(init.headers);
146162
stripRequestOnlyHeaders(safeHeaders);
147-
safeHeaders.forEach((value, key) => {
148-
data.headers.set(key, value);
149-
});
163+
copyHeaders(data.headers, safeHeaders);
150164
}
151165
return data;
152166
}
@@ -158,23 +172,12 @@ export function toResponse(data?: any, init?: ResponseInit): Response {
158172
return routerResponse;
159173
}
160174
const headers = new Headers();
161-
if (routerResponse?.headers) {
162-
const headers = new Headers(routerResponse.headers);
163-
for (const [key, value] of headers.entries()) {
164-
headers.set(key, value);
165-
}
166-
}
167-
if (data.headers) {
168-
for (const [key, value] of new Headers(data.headers).entries()) {
169-
headers.set(key, value);
170-
}
171-
}
175+
copyHeaders(headers, routerResponse?.headers);
176+
copyHeaders(headers, data.headers);
172177
if (init?.headers) {
173178
const safeHeaders = new Headers(init.headers);
174179
stripRequestOnlyHeaders(safeHeaders);
175-
for (const [key, value] of safeHeaders.entries()) {
176-
headers.set(key, value);
177-
}
180+
copyHeaders(headers, safeHeaders);
178181
}
179182

180183
headers.set("Content-Type", "application/json");

0 commit comments

Comments
 (0)