Skip to content

Commit faf741d

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

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
@@ -440,6 +440,89 @@ describe("toResponse", () => {
440440
});
441441
});
442442

443+
/**
444+
* @see https://github.com/better-auth/better-call/issues/118
445+
*/
446+
describe("multiple Set-Cookie preservation", () => {
447+
it("preserves every Set-Cookie when merging init.headers onto an existing Response", () => {
448+
const existing = new Response("ok");
449+
existing.headers.append("set-cookie", "a=1; Path=/");
450+
const initHeaders = new Headers();
451+
initHeaders.append("set-cookie", "b=2; Path=/");
452+
initHeaders.append("set-cookie", "c=3; Path=/");
453+
454+
const response = toResponse(existing, { headers: initHeaders });
455+
456+
expect(response.headers.getSetCookie()).toEqual([
457+
"a=1; Path=/",
458+
"b=2; Path=/",
459+
"c=3; Path=/",
460+
]);
461+
});
462+
463+
it("preserves every Set-Cookie from init.headers in JSON-flag responses", () => {
464+
const initHeaders = new Headers();
465+
initHeaders.append("set-cookie", "a=1; Path=/");
466+
initHeaders.append("set-cookie", "b=2; Path=/");
467+
468+
const response = toResponse(
469+
{ _flag: "json", body: { ok: true } },
470+
{ headers: initHeaders },
471+
);
472+
473+
expect(response.headers.getSetCookie()).toEqual([
474+
"a=1; Path=/",
475+
"b=2; Path=/",
476+
]);
477+
});
478+
479+
it("preserves every Set-Cookie from data.headers in JSON-flag responses", () => {
480+
const dataHeaders = new Headers();
481+
dataHeaders.append("set-cookie", "a=1; Path=/");
482+
dataHeaders.append("set-cookie", "b=2; Path=/");
483+
484+
const response = toResponse({
485+
_flag: "json",
486+
body: { ok: true },
487+
headers: dataHeaders,
488+
});
489+
490+
expect(response.headers.getSetCookie()).toEqual([
491+
"a=1; Path=/",
492+
"b=2; Path=/",
493+
]);
494+
});
495+
496+
it("preserves every Set-Cookie from routerResponse.headers in JSON-flag responses", () => {
497+
const routerHeaders = new Headers();
498+
routerHeaders.append("set-cookie", "a=1; Path=/");
499+
routerHeaders.append("set-cookie", "b=2; Path=/");
500+
501+
const response = toResponse({
502+
_flag: "json",
503+
body: { ok: true },
504+
routerResponse: { headers: routerHeaders },
505+
});
506+
507+
expect(response.headers.getSetCookie()).toEqual([
508+
"a=1; Path=/",
509+
"b=2; Path=/",
510+
]);
511+
});
512+
513+
it("propagates non-cookie headers from routerResponse.headers in JSON-flag responses", () => {
514+
const response = toResponse({
515+
_flag: "json",
516+
body: { ok: true },
517+
routerResponse: {
518+
headers: { "x-custom": "from-router" },
519+
},
520+
});
521+
522+
expect(response.headers.get("x-custom")).toBe("from-router");
523+
});
524+
});
525+
443526
describe("Request header stripping", () => {
444527
const REQUEST_ONLY_HEADERS = [
445528
// 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
@@ -133,14 +133,28 @@ function stripRequestOnlyHeaders(headers: Headers): void {
133133
}
134134
}
135135

136+
/**
137+
* Copy headers from `source` into `target`. `Set-Cookie` is appended (one
138+
* header per cookie) because RFC 9110 §5.3 notes it cannot be combined
139+
* into a single comma-separated value; other headers are set (replace).
140+
*/
141+
function copyHeaders(target: Headers, source: HeadersInit | undefined): void {
142+
if (!source) return;
143+
for (const [key, value] of new Headers(source).entries()) {
144+
if (key.toLowerCase() === "set-cookie") {
145+
target.append(key, value);
146+
} else {
147+
target.set(key, value);
148+
}
149+
}
150+
}
151+
136152
export function toResponse(data?: any, init?: ResponseInit): Response {
137153
if (data instanceof Response) {
138154
if (init?.headers) {
139155
const safeHeaders = new Headers(init.headers);
140156
stripRequestOnlyHeaders(safeHeaders);
141-
safeHeaders.forEach((value, key) => {
142-
data.headers.set(key, value);
143-
});
157+
copyHeaders(data.headers, safeHeaders);
144158
}
145159
return data;
146160
}
@@ -152,23 +166,12 @@ export function toResponse(data?: any, init?: ResponseInit): Response {
152166
return routerResponse;
153167
}
154168
const headers = new Headers();
155-
if (routerResponse?.headers) {
156-
const headers = new Headers(routerResponse.headers);
157-
for (const [key, value] of headers.entries()) {
158-
headers.set(key, value);
159-
}
160-
}
161-
if (data.headers) {
162-
for (const [key, value] of new Headers(data.headers).entries()) {
163-
headers.set(key, value);
164-
}
165-
}
169+
copyHeaders(headers, routerResponse?.headers);
170+
copyHeaders(headers, data.headers);
166171
if (init?.headers) {
167172
const safeHeaders = new Headers(init.headers);
168173
stripRequestOnlyHeaders(safeHeaders);
169-
for (const [key, value] of safeHeaders.entries()) {
170-
headers.set(key, value);
171-
}
174+
copyHeaders(headers, safeHeaders);
172175
}
173176

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

0 commit comments

Comments
 (0)