fix: preserve multiple Set-Cookie headers in response merging#297
Conversation
…ntry The response header merging in prod-server.ts and the generated Cloudflare worker entry used Record<string, string> which flattened multiple Set-Cookie headers — the last value won, or cookies with Expires dates got corrupted by comma-joining. Extract mergeResponseHeaders() helper that uses getSetCookie() to preserve array-valued Set-Cookie headers. Apply the same fix to the worker entry template in deploy.ts using the Headers API. Fixes cloudflare#295
commit: |
|
/bigbonk review |
There was a problem hiding this comment.
Good fix for a real problem — Set-Cookie flattening via Record<string, string> or Headers.forEach() comma-joining is a well-known footgun. The approach using getSetCookie() is correct and the tests are well-structured.
A few issues to address:
- deploy.ts line 604 — type unsoundness:
middlewareHeaders[key]is nowstring | string[]but is assigned tomwReqHeaderstyped asRecord<string, string>without a cast. Needs anas stringcast to match what prod-server.ts does. sendCompressedVary header handling: now thatextraHeadersvalues can bestring[], the Vary concatenation path (existingVary + ", Accept-Encoding") can produce malformed values for array inputs. Minor since Vary is unlikely to be an array, but the type now permits it.- Missing edge-case test: the
mwCookies = Array.isArray(existing) ? existing : [existing]branch inmergeResponseHeadersisn't exercised by any test (middleware cookie as a plain string, not an array).
None of these are blockers — the core fix is correct and well-tested. Nice work identifying both the prod-server and worker entry code paths.
|
Review posted successfully. Here's a summary of the feedback I left on PR #297: Overall: The fix is correct and well-scoped. Using Issues raised:
|
- Add `as string` cast in deploy.ts worker entry for x-middleware-request-* header unpacking (matches prod-server.ts) - Normalize Vary header with Array.isArray check in sendCompressed to handle the widened type correctly - Add edge-case test for middleware cookie as plain string (not array)
…rCase - Fix the same Set-Cookie flattening bug in hand-written example worker entries (pages-router-cloudflare, realworld-api-rest) - Remove 4 redundant .toLowerCase() calls — Headers.forEach() always yields lowercase keys
|
/bigbonk review |
There was a problem hiding this comment.
Good fix for a real bug. Set-Cookie is the only HTTP header that legitimately carries multiple values and must not be comma-joined or deduplicated. Using getSetCookie() is the correct approach.
The core logic in prod-server.ts and the worker entry template in deploy.ts both look correct. I have a few observations, one of which I'd call a real issue worth addressing before merge.
Main concern: The two example worker files are exact copies of each other and both duplicate the mergeHeaders logic from deploy.ts. This is three copies of the same function that must be kept in sync manually. This isn't introduced by this PR (it was already the case), but this PR makes the duplication more painful since the function is now non-trivial. I'd suggest extracting the shared worker logic to avoid this maintenance burden — but that's a follow-up, not a blocker for this PR.
Summary of inline comments:
- Minor type safety gap in
deploy.tstemplate —existingin theelse ifbranch lacks theas stringcast that the example workers andprod-server.tshave. Set-Cookiecomparison uses lowercase string literal ("set-cookie") — this is correct sinceHeaders.forEach()always yields lowercase keys andresult.responseHeadersfrom the middleware runner also normalizes to lowercase, but a comment noting this assumption would help future readers.- The additive semantics for Set-Cookie (middleware + response cookies are both preserved, rather than response overriding middleware) is the right choice — it matches how Next.js handles this.
|
Review posted successfully. Here's a summary of what I found: Overall: The fix is correct and well-targeted. Key findings:
|
Keep upstream's API route content-type default (application/octet-stream) and our mergeResponseHeaders() fix.
The mergeHeaders function was duplicated in both example worker entries (pages-router-cloudflare, realworld-api-rest) and the deploy.ts template. Extract it to a shared module that example workers import. The deploy.ts template still inlines it (code generation constraint). Reduces 3 copies to 2 (shared module + generated template).
|
@james-elicx :) |
funnily enough i was just having opencode go through and resolve addressed comments |
There was a problem hiding this comment.
Review
The core fix is correct and well-targeted — Set-Cookie must never be comma-joined or deduplicated, and using getSetCookie() is the right approach. The extraction of mergeHeaders into worker-utils.ts is a good step toward reducing duplication in the example workers. Test coverage is solid.
I have one real bug to flag, one file that shouldn't be in this PR, and a couple of minor observations.
Bug: deploy.ts config headers still comma-join Set-Cookie (lines 635-651)
The generated worker template's "Apply custom headers from next.config.js" section still does:
if ((lk === "vary" || lk === "set-cookie") && middlewareHeaders[lk]) {
middlewareHeaders[lk] += ", " + h.value;
}This is exactly the comma-joining bug this PR is fixing! If middleware already set a Set-Cookie (now stored as ["mw=1; Path=/"]), the += will call .toString() on the array and produce "mw=1; Path=/, config=2; Path=/" — a single corrupted cookie string.
prod-server.ts correctly handles this with array accumulation (lines 899-907). The deploy.ts template needs the same treatment. The stale comment on line 638-640 also still says "Set-Cookie values are comma-joined here because middlewareHeaders is a flat Record<string, string>" which is no longer true.
Unrelated file: CLAUDE.md
This symlink is unrelated to the Set-Cookie fix. It should be dropped from this PR.
Observations
- The example workers (
pages-router-cloudflare,realworld-api-rest) also don't handle config Set-Cookie as arrays in their config headers section, but that's a pre-existing issue not introduced by this PR. - Good test coverage overall. The
mergeResponseHeadersplain-string middleware cookie test (line 2810) exercises theArray.isArray(existing) ? existing : [existing]branch nicely.
|
Review posted successfully. Here's a summary of what I found: Overall: The fix is correct and well-scoped. Using Issues raised:
|
|
@james-elicx could you pls help me resolve this? Gotta pick my mum up at the airport now |
|
Yeah no worries, thanks for your work so far |
…havioral test - deploy.ts template: config headers section was comma-joining Set-Cookie values using += which corrupts cookies with Expires dates and loses all but the last cookie when multiple config rules match. Matches the array-accumulation pattern already used in prod-server.ts (lines 899-907) and the middleware section above. - worker-utils.ts + deploy.ts template JSDoc: 'Response headers take precedence' was misleading — Set-Cookie is additive, not overriding. Clarify both docs. - tests/deploy.test.ts: add behavioral test for mergeHeaders via worker-utils import (same function inlined in the generated template), replacing reliance on string-contains assertions alone.
|
/bigbonk review |
There was a problem hiding this comment.
Review
The core fix is correct and well-scoped. Set-Cookie is the one HTTP header that must not be comma-joined (RFC 6265), and the old Record<string, string> + Headers.forEach() patterns were silently corrupting multi-cookie responses. Using getSetCookie() and array accumulation is the right approach.
CI is all green. The extraction of mergeHeaders into worker-utils.ts is a good improvement that deduplicates the example workers. Test coverage is solid — the four new test groups cover the important code paths including the Array.isArray(existing) ? existing : [existing] branch for plain-string middleware cookies.
Two issues to flag:
1. Example workers' config headers still overwrite Set-Cookie
Both examples/pages-router-cloudflare/worker/index.ts and examples/realworld-api-rest/worker/index.ts still use a simple overwrite pattern for config headers (line 151):
middlewareHeaders[h.key.toLowerCase()] = h.value;Now that middlewareHeaders is Record<string, string | string[]>, a config header with set-cookie will overwrite any middleware Set-Cookie array with a plain string. This is inconsistent with both prod-server.ts (lines 899-907) and the deploy.ts template (lines 644-657), which both use array accumulation. Since this PR already touched these files to fix middleware collection and import mergeHeaders, it would be good to also fix the config headers section to match.
2. Indentation inconsistency in deploy.ts template
Lines 636-659 use 7-space indent while the rest of the template uses 6-space indent. This was likely introduced when replacing the old block. Cosmetic only (affects the generated code's indentation), but worth fixing for consistency.
|
Review posted successfully. Here's a summary of what I found: Overall: The fix is correct. Using Issues raised:
|
…n example workers - deploy.ts: fix extra leading space in config headers block (lines 636-659) and JSDoc lines 740-743 introduced in previous commit - pages-router-cloudflare, realworld-api-rest: config headers section was doing a plain assignment (middlewareHeaders[lk] = h.value) which overwrites array-valued Set-Cookie built up by the middleware section above; use the same array-accumulation pattern as prod-server.ts and the deploy.ts template
|
/bigbonk review |
There was a problem hiding this comment.
Review
Good fix for a real and well-known bug. Set-Cookie is the one HTTP header where comma-joining corrupts values (cookies with Expires dates contain commas), and the old Record<string, string> + Headers.forEach() patterns were silently breaking multi-cookie responses. Using getSetCookie() and array accumulation is the correct approach.
The extraction of mergeHeaders into worker-utils.ts is a solid improvement — it eliminates the duplicated inline function from both example workers. Test coverage is good: the four new test groups in features.test.ts cover the main code paths including the Array.isArray(existing) ? existing : [existing] branch for plain-string middleware cookies, and the behavioral test in deploy.test.ts properly validates the mergeHeaders function against real Response objects.
Two items to address:
1. Stale (middlewareHeaders as any) casts in prod-server.ts config headers section (lines 904, 906)
The middleware collection section was correctly updated to use middlewareHeaders[key] = [existing as string, value] (line 848), but the config headers section (lines 899-907, not in this diff) still uses the old (middlewareHeaders as any)[lk] = [existing, value] pattern. Now that middlewareHeaders is Record<string, string | string[]>, the as any cast is unnecessary and inconsistent with every other location that was updated. This should be cleaned up to match.
2. Example workers missing Vary handling in config headers
The example workers' config headers section (line 162 of both) doesn't handle Vary specially, while both prod-server.ts (line 908) and deploy.ts template (line 653) comma-join Vary values. Pre-existing issue but worth aligning since the block was already rewritten.
Otherwise the PR looks correct and ready to ship.
|
The review was posted successfully. Here's a summary of the feedback I left on PR #297: Overall: The fix is correct and well-targeted. Using Issues raised:
|
…ple workers - prod-server.ts lines 904/906: middlewareHeaders is now Record<string, string | string[]> so the as any casts are unnecessary; replace with as string (consistent with the middleware collection section above at line 848) - pages-router-cloudflare, realworld-api-rest: add Vary comma-joining to config headers section to match prod-server.ts (line 908) and deploy.ts template (line 653)
|
/bigbonk review |
There was a problem hiding this comment.
Review
This is a solid fix for a real and well-documented bug. Set-Cookie is the one HTTP header where comma-joining corrupts values (cookies with Expires dates contain commas), and the old Record<string, string> + Headers.forEach() patterns were silently breaking multi-cookie responses. Using getSetCookie() and array accumulation is the correct approach.
CI is all green. The core logic is correct across all three code paths (prod-server.ts, deploy.ts template, worker-utils.ts). The extraction of mergeHeaders into worker-utils.ts is a nice deduplication win for the example workers. Test coverage is good -- the four new test groups cover the important paths including the Array.isArray(existing) ? existing : [existing] branch for plain-string middleware cookies.
I have two minor nits inline but nothing blocking. Ship it.
| extraHeaders: Record<string, string | string[]>, | ||
| statusOverride?: number, | ||
| ): Response { | ||
| if (!Object.keys(extraHeaders).length && !statusOverride) return response; |
There was a problem hiding this comment.
Nit: this early return is correct but has a subtle behavior difference from the main path -- if extraHeaders is empty and statusOverride is undefined, the original Response is returned as-is (preserving its Set-Cookie headers untouched). If extraHeaders is empty but statusOverride is set, we go through the full merge path which reconstructs the response. This is fine and intentional, just noting that the statusOverride-only case works correctly because the response headers are fully copied via forEach + getSetCookie().
| @@ -134,7 +148,21 @@ export default { | |||
| if (configHeaders.length) { | |||
| const matched = matchHeaders(resolvedPathname, configHeaders); | |||
There was a problem hiding this comment.
Pre-existing issue (not introduced by this PR): the example workers call matchHeaders(resolvedPathname, configHeaders) without the reqCtx parameter, while both prod-server.ts and the deploy.ts template pass reqCtx. This means has/missing conditions on config headers won't work in the example workers. Not a blocker for this PR since it predates it, but worth a follow-up.
|
Review submitted successfully. Here's a summary: Approved PR #297 with two minor inline nits. What the PR does right:
Inline nits (non-blocking):
|
Summary
prod-server.tsand the generated Cloudflare worker entry (deploy.ts)Record<string, string>pattern caused multiple Set-Cookie values to be overwritten (last wins) or corrupted by comma-joining (breaking cookies withExpiresdates)mergeResponseHeaders()helper usinggetSetCookie()(Node 20+) to preserve array-valued Set-Cookiedeploy.tsusing theHeadersAPIFixes #295
Test plan
mergeResponseHeaders+sendCompressedwith array headers)