Skip to content

fix: preserve multiple Set-Cookie headers in response merging#297

Merged
james-elicx merged 9 commits intocloudflare:mainfrom
NathanDrake2406:fix/set-cookie-header-flattening
Mar 6, 2026
Merged

fix: preserve multiple Set-Cookie headers in response merging#297
james-elicx merged 9 commits intocloudflare:mainfrom
NathanDrake2406:fix/set-cookie-header-flattening

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

  • Fixes Set-Cookie headers being flattened to a single value in prod-server.ts and the generated Cloudflare worker entry (deploy.ts)
  • The Record<string, string> pattern caused multiple Set-Cookie values to be overwritten (last wins) or corrupted by comma-joining (breaking cookies with Expires dates)
  • Extracts mergeResponseHeaders() helper using getSetCookie() (Node 20+) to preserve array-valued Set-Cookie
  • Applies the same fix to the worker entry template in deploy.ts using the Headers API
  • Fixes both the API route path and the SSR page rendering path in prod-server

Fixes #295

Test plan

  • Added 4 unit tests for Set-Cookie preservation (mergeResponseHeaders + sendCompressed with array headers)
  • All 224 features tests pass
  • All 155 deploy tests pass
  • All 122 pages-router tests pass
  • Typecheck clean
  • Lint clean

…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
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 6, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@297

commit: 831f6a4

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. deploy.ts line 604 — type unsoundness: middlewareHeaders[key] is now string | string[] but is assigned to mwReqHeaders typed as Record<string, string> without a cast. Needs an as string cast to match what prod-server.ts does.
  2. sendCompressed Vary header handling: now that extraHeaders values can be string[], 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.
  3. Missing edge-case test: the mwCookies = Array.isArray(existing) ? existing : [existing] branch in mergeResponseHeaders isn'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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 6, 2026

Review posted successfully. Here's a summary of the feedback I left on PR #297:

Overall: The fix is correct and well-scoped. Using getSetCookie() to preserve multiple Set-Cookie headers is the right approach, and the PR correctly identifies both the prod-server.ts and worker entry (deploy.ts) code paths.

Issues raised:

  1. Type unsoundness in deploy.tsmiddlewareHeaders[key] is now string | string[] but gets assigned to mwReqHeaders typed as Record<string, string> without a cast at line 604. Needs as string to match prod-server.ts.

  2. Vary header handling in sendCompressed — The extraHeaders type widening to string | string[] means the Vary concatenation path could produce malformed values for array inputs. Suggested normalizing with Array.isArray check.

  3. Missing edge-case test — The Array.isArray(existing) ? existing : [existing] branch in mergeResponseHeaders isn't exercised (middleware cookie as plain string, not array).

  4. Deploy test fragility — String-contains assertions on generated code are brittle; suggested adding behavioral tests for the worker entry's mergeHeaders.

  5. Confirmed correctness of the early-return optimization in mergeHeaders and the additive Set-Cookie semantics.

github run

- 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
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Minor type safety gap in deploy.ts template — existing in the else if branch lacks the as string cast that the example workers and prod-server.ts have.
  2. Set-Cookie comparison uses lowercase string literal ("set-cookie") — this is correct since Headers.forEach() always yields lowercase keys and result.responseHeaders from the middleware runner also normalizes to lowercase, but a comment noting this assumption would help future readers.
  3. 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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 6, 2026

Review posted successfully. Here's a summary of what I found:

Overall: The fix is correct and well-targeted. Set-Cookie headers were being flattened by Record<string, string> patterns (last-wins) or corrupted by Headers.forEach() comma-joining. Using getSetCookie() is the right approach.

Key findings:

  1. One actionable fixdeploy.ts line 577 is missing an as string cast on the existing variable in the generated template code. The example workers and prod-server.ts both have this cast, but the template in deploy.ts doesn't. Submitted as a suggestion.

  2. Code duplication concern — The two example worker files (pages-router-cloudflare and realworld-api-rest) are byte-for-byte identical copies that also duplicate the mergeHeaders function from deploy.ts. Three copies of non-trivial cookie-merging logic is a maintenance risk. Flagged as a follow-up.

  3. Good test coverage — The tests cover the main paths including the Array.isArray(existing) ? existing : [existing] branch for plain-string middleware cookies, which the earlier bonk review had flagged as missing. That's been addressed.

  4. Vary header handling — The defensive Array.isArray(rawVary) check in sendCompressed correctly closes the type gap from widening extraHeaders.

  5. Additive Set-Cookie semantics are correct — both middleware and response cookies are preserved, matching Next.js behavior.

github run

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).
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@james-elicx :)

@james-elicx
Copy link
Copy Markdown
Collaborator

@james-elicx :)

funnily enough i was just having opencode go through and resolve addressed comments
/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 mergeResponseHeaders plain-string middleware cookie test (line 2810) exercises the Array.isArray(existing) ? existing : [existing] branch nicely.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 6, 2026

Review posted successfully. Here's a summary of what I found:

Overall: The fix is correct and well-scoped. Using getSetCookie() to preserve multiple Set-Cookie headers is the right approach, and the PR correctly identifies both the prod-server.ts and worker entry (deploy.ts) code paths. The extraction of mergeHeaders into a shared worker-utils.ts module is a nice improvement.

Issues raised:

  1. Bug: deploy.ts config headers still comma-join Set-Cookie (lines 635-651) — The middleware collection was fixed to use arrays, but the "Apply custom headers from next.config.js" section still does middlewareHeaders[lk] += ", " + h.value for Set-Cookie. This will corrupt array-valued cookies. prod-server.ts handles this correctly with array accumulation; the deploy template should match.

  2. Unrelated file: CLAUDE.md — A symlink to AGENTS.md that has nothing to do with the Set-Cookie fix. Should be dropped.

  3. JSDoc nit — Both worker-utils.ts and the deploy.ts template's mergeHeaders say "Response headers take precedence over middleware headers" without noting that Set-Cookie is additive (not overriding).

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@james-elicx could you pls help me resolve this? Gotta pick my mum up at the airport now

@james-elicx
Copy link
Copy Markdown
Collaborator

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.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 6, 2026

Review posted successfully. Here's a summary of what I found:

Overall: The fix is correct. Using getSetCookie() to preserve multiple Set-Cookie headers is the right approach, and the PR correctly identifies all three code paths (prod-server.ts, deploy.ts template, and worker-utils.ts). CI is all green.

Issues raised:

  1. Example workers' config headers still overwrite Set-Cookie — Both pages-router-cloudflare and realworld-api-rest example workers still use middlewareHeaders[h.key.toLowerCase()] = h.value for config headers (line 151). Now that middlewareHeaders is Record<string, string | string[]>, this will overwrite array-valued Set-Cookie from middleware with a plain string. This is inconsistent with the fixed prod-server.ts and deploy.ts template, which both use array accumulation.

  2. Indentation inconsistency in deploy.ts template — Lines 636-659 (the config headers block) use 7-space indent while the rest of the template uses 6-space indent. Also affects the mergeHeaders JSDoc at lines 740-743. Cosmetic but worth fixing.

github run

…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
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 6, 2026

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 getSetCookie() to preserve multiple Set-Cookie headers is the right approach, and the PR correctly identifies all three code paths (prod-server.ts, deploy.ts template, and worker-utils.ts).

Issues raised:

  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 as string, but the config headers section still uses the old as any pattern that's no longer needed with the widened type. Should be cleaned up for consistency.

  2. Example workers missing Vary handling in config headers — Both example workers don't comma-join Vary values in the config headers section, while prod-server.ts and deploy.ts do. Since the block was already rewritten, worth aligning. Posted a code suggestion.

  3. Positive notes — The .toLowerCase() removal on Headers.forEach() keys is correct (spec guarantees lowercase), the defensive getSetCookie?.() optional chaining is good, and the behavioral test for mergeHeaders is a solid addition.

github run

…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)
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 6, 2026

Review submitted successfully. Here's a summary:

Approved PR #297 with two minor inline nits.

What the PR does right:

  • Correctly identifies the Set-Cookie comma-joining bug across all three code paths (prod-server.ts, deploy.ts template, worker-utils.ts)
  • Uses getSetCookie() (Node 20+) to properly preserve multiple Set-Cookie values as arrays
  • Extracts mergeHeaders into a shared worker-utils.ts module to deduplicate the example workers
  • Good test coverage: 4 new test groups covering the main paths including the plain-string middleware cookie branch
  • CI is fully green (Lint, Typecheck, Vitest, all 5 Playwright E2E projects pass)

Inline nits (non-blocking):

  1. Noted the early-return optimization in worker-utils.ts is correct and handles the statusOverride-only case properly
  2. Flagged a pre-existing issue where example workers call matchHeaders without the reqCtx parameter (meaning has/missing conditions on config headers won't work) -- not introduced by this PR but worth a follow-up

github run

@james-elicx james-elicx merged commit af0a839 into cloudflare:main Mar 6, 2026
16 checks passed
@NathanDrake2406 NathanDrake2406 deleted the fix/set-cookie-header-flattening branch March 18, 2026 09:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Set-Cookie headers flattened in prod-server.ts response merging

2 participants