Skip to content

node: preserve pre-existing Set-Cookie headers in sendNodeResponse#200

Closed
SAY-5 wants to merge 1 commit intoh3js:mainfrom
SAY-5:fix-node-set-cookie-merge-144
Closed

node: preserve pre-existing Set-Cookie headers in sendNodeResponse#200
SAY-5 wants to merge 1 commit intoh3js:mainfrom
SAY-5:fix-node-set-cookie-merge-144

Conversation

@SAY-5
Copy link
Copy Markdown

@SAY-5 SAY-5 commented Apr 17, 2026

Fixes #144.

Problem

When a caller sets a header on the Node response before invoking sendNodeResponse (e.g. nodeRes.setHeader("set-cookie", "a=...")), Node's writeHead(status, rawHeaders) silently replaces every header of the same name rather than appending. Any Set-Cookie the caller had registered disappears the moment sendNodeResponse calls writeHead with the Web Response's own headers.

The reporter hit this in TanStack Start's dev-server plugin while setting both access-token and refresh-token cookies: the access-token cookie set via setHeader was silently dropped as soon as the Web Response's Set-Cookie reached writeHead.

Fix

Before calling writeHead, merge any headers already present on nodeRes:

  • Read them via getHeaderNames() / getHeader().
  • For Set-Cookie, preserve every line unconditionally so all cookies survive (both the ones on nodeRes and the ones in the Web Response).
  • For any other header, let the incoming Response value win when both sides specify the same name (preserves prior behaviour for Content-Type, Cache-Control, etc.); when only the caller specified it, keep the caller's value.
  • Remove the merged headers from nodeRes so writeHead doesn't emit them twice through its own pending-headers channel.

HTTP/2 path, HTTP/1 path and Deno path share the same merged array so the fix covers all three.

Signed off per DCO.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed HTTP header handling to properly preserve and merge header values, preventing duplication while ensuring correct handling of authentication-related headers across request cycles.

Reported as #144. When a caller sets headers on the Node
response (e.g. via nodeRes.setHeader("set-cookie", ...)) before
handing the response off to sendNodeResponse, Node's writeHead
silently replaces every header of the same name rather than
appending. Any Set-Cookie the caller had registered therefore
disappears as soon as we call writeHead(status, rawHeaders).

The scenario shows up in TanStack Start's dev-server plugin and in
JWT auth middleware that sets an access-token cookie via setHeader
and expects the refresh-token cookie from the Web Response to merge
with it.

Fix: before writeHead, pull every header already on nodeRes, strip
them off (so writeHead doesn't double-emit through its own pending-
headers channel), and prepend them onto the rawHeaders array. For
Set-Cookie we preserve every line unconditionally; for other header
names the incoming Response value still wins (so no behaviour change
on e.g. Content-Type, Cache-Control) and only pre-existing headers
that the Response did NOT also specify are kept. The HTTP/2 path and
Deno path share the same merged array.

Fixes #144

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>
@SAY-5 SAY-5 requested a review from pi0 as a code owner April 17, 2026 19:50
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

The Node adapter's sendNodeResponse now properly preserves headers previously set on the response object via setHeader before writeHead is called. A new mergeExistingHeaders function combines pre-existing and incoming headers, with special handling for multiple Set-Cookie entries to prevent header overwriting.

Changes

Cohort / File(s) Summary
Header Merge Logic
src/adapters/_node/send.ts
Refactors writeHead to integrate mergeExistingHeaders function that preserves pre-existing headers, accumulates all Set-Cookie values, and applies platform-specific flattening before passing merged headers to nodeRes.writeHead.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hops with glee!
Headers merge with grace and care,
Cookies stack up in the air! 🍪
No more loss in the Node domain,
Multiple sets now all remain! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: preserving pre-existing Set-Cookie headers in sendNodeResponse, which is the core objective of the PR.
Linked Issues check ✅ Passed The changes fully address issue #144 by implementing header merging logic that preserves multiple Set-Cookie headers and prevents writeHead from overwriting pre-existing headers.
Out of Scope Changes check ✅ Passed All changes are scoped to the header merging functionality required by issue #144; no unrelated modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/adapters/_node/send.ts`:
- Around line 63-114: The loop in mergeExistingHeaders inconsistently skips
nodeRes.removeHeader when an existing non-Set-Cookie header is going to be
overridden (the continue on line 98), which can leave duplicate headers emitted
by writeHead; change the loop so removeHeader is always called for every
existing header (call nodeRes.removeHeader?(name) before the continue/after
handling values) while preserving the special-case logic that concatenates
Set-Cookie entries into preserved, and update any tests (add a case that sets
nodeRes.setHeader("set-cookie", "a=1") and returns a Response with one or more
set-cookie values on both HTTP/1 and HTTP/2) to validate merged Set-Cookie
behavior through writeHead; refer to mergeExistingHeaders, existingNames,
incomingNonSetCookieNames, nodeRes.getHeader, nodeRes.removeHeader, and
writeHead when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 03758be7-4db4-497e-8ad7-e42081dd5dc8

📥 Commits

Reviewing files that changed from the base of the PR and between ba8d059 and 4773f95.

📒 Files selected for processing (1)
  • src/adapters/_node/send.ts

Comment on lines +63 to 114
const merged = mergeExistingHeaders(nodeRes, rawHeaders);
const writeHeaders: any = globalThis.Deno ? merged : merged.flat();
if (nodeRes.req?.httpVersion === "2.0") {
// @ts-expect-error
nodeRes.writeHead(status, writeHeaders);
} else {
// @ts-expect-error
nodeRes.writeHead(status, statusText, writeHeaders);
}
}

function mergeExistingHeaders(
nodeRes: NodeServerResponse,
incoming: [string, string][],
): [string, string][] {
const existingNames =
typeof nodeRes.getHeaderNames === "function"
? nodeRes.getHeaderNames()
: [];
if (existingNames.length === 0) {
return incoming;
}

// For every Set-Cookie line set via setHeader, keep it and
// concatenate with the incoming ones so all cookies survive. For
// other single-valued headers, let the incoming Response value win
// if both sides specify the same name (preserves prior behaviour),
// otherwise preserve the pre-existing header.
const incomingNonSetCookieNames = new Set(
incoming.map(([k]) => k.toLowerCase()).filter((k) => k !== "set-cookie"),
);
const preserved: [string, string][] = [];
for (const name of existingNames) {
const lower = name.toLowerCase();
if (lower !== "set-cookie" && incomingNonSetCookieNames.has(lower)) {
continue;
}
const value = nodeRes.getHeader?.(name);
if (Array.isArray(value)) {
for (const v of value) {
preserved.push([name, String(v)]);
}
} else if (value !== undefined) {
preserved.push([name, String(value)]);
}
// Drop from nodeRes so writeHead doesn't also emit the header
// through its own pending-headers channel and duplicate it.
nodeRes.removeHeader?.(name);
}

return preserved.length > 0 ? [...preserved, ...incoming] : incoming;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Node.js 20/22/24 http OutgoingMessage.writeHead, when the response already has headers set via setHeader (so [kOutHeaders] exists), does passing a flat array like ["set-cookie","a","set-cookie","b"] as the rawHeaders argument preserve both Set-Cookie lines, or does the slow-path call setHeader for each pair and cause the later value to overwrite the earlier one?

💡 Result:

In Node.js 20/22/24, when headers are already set via setHeader (kOutHeaders exists), passing a flat rawHeaders array like ["set-cookie","a","set-cookie","b"] to OutgoingMessage.writeHead preserves both Set-Cookie lines as separate headers. It does not overwrite via the slow-path setHeader calls. Prior to a fix merged in December 2023 (commit 4b76ccea, included in Node.js 21.0.0+ and backported to 20.x/22.x), the behavior was buggy: duplicates were collapsed/lost even with rawHeaders arrays after setHeader calls, as writeHead looped over the array calling setHeader (which overwrites) instead of preserving raw duplicates. The fix explicitly handles this case in lib/_http_server.js writeHead: when obj is an array (rawHeaders format) and headers exist, it first removes all conflicting existing headers (via removeHeader for each key), then appends each pair using appendHeader (which supports multiples like Set-Cookie). This ensures raw duplicates are preserved while giving writeHead headers precedence (per docs), without falling to the old setHeader overwrite behavior. Official docs confirm writeHead accepts rawHeaders arrays in request.rawHeaders format (flattened [key,value,...]) and merges with setHeader headers, writeHead taking precedence. The test added in the fix verifies res.rawHeaders retains both ["array-val","1","array-val","2"] even after prior setHeader. {value: Preserves both Set-Cookie lines as separate headers (no overwrite), citations: [23,24,66,67,68,69,70,71,72,73], confidence: high}

Citations:


🏁 Script executed:

#!/bin/bash
# Look at the PR's new tests (if any) covering the multi-Set-Cookie + prior setHeader scenario.
fd -t f -e ts -e js . test | xargs rg -n -C3 -i 'set-cookie'
rg -nP -C3 'setHeader\s*\(\s*["'\'']set-cookie' 
rg -nP -C3 'mergeExistingHeaders|getHeaderNames\(\)'

Repository: h3js/srvx

Length of output: 2015


🏁 Script executed:

#!/bin/bash
# Check package.json for Node version requirements
cat package.json | grep -A 5 '"engines"'

# List test files to find fixture and tests
fd -t f -e ts -e js . test | head -20

# Look at the fixture file mentioned
fd -t f -n '_fixture.ts' test

Repository: h3js/srvx

Length of output: 896


🏁 Script executed:

#!/bin/bash
# Search for the x-set-with-node fixture mentioned in the review
rg -n 'x-set-with-node' test/ --type ts

# Look for tests that combine setHeader + set-cookie with incoming headers
rg -n -B2 -A5 'setHeader.*set-cookie|set-cookie.*setHeader' test/ --type ts

Repository: h3js/srvx

Length of output: 140


🏁 Script executed:

#!/bin/bash
# Check if there are tests specifically for mergeExistingHeaders or the scenario in question
rg -n -B3 -A10 'beforeEach|describe.*merge|describe.*header' test/ --type ts | head -100

Repository: h3js/srvx

Length of output: 816


🏁 Script executed:

#!/bin/bash
# Read the fixture file to understand the x-set-with-node test
cat test/_fixture.ts | head -100

Repository: h3js/srvx

Length of output: 2972


🏁 Script executed:

#!/bin/bash
# Look at the complete context around x-set-with-node
rg -C15 'x-set-with-node' test/_fixture.ts

Repository: h3js/srvx

Length of output: 1063


🏁 Script executed:

#!/bin/bash
# Check test/node-headers.test.ts for Set-Cookie tests
cat test/node-headers.test.ts

Repository: h3js/srvx

Length of output: 1956


🏁 Script executed:

#!/bin/bash
# Read the relevant section of send.ts to see the continue branch
sed -n '90,110p' src/adapters/_node/send.ts

Repository: h3js/srvx

Length of output: 859


🏁 Script executed:

#!/bin/bash
# Search for `#144` and `#40` references in the codebase
rg -n '#144|#40' src/ test/

Repository: h3js/srvx

Length of output: 140


🏁 Script executed:

#!/bin/bash
# Search for any Set-Cookie related tests in the test directory
rg -n -i 'set-cookie' test/ --type ts

Repository: h3js/srvx

Length of output: 218


🏁 Script executed:

#!/bin/bash
# Check for CI configuration to see what Node versions are tested
fd -t f -e yml -e yaml . .github/workflows/ 2>/dev/null | head -5
cat .github/workflows/*.yml 2>/dev/null | grep -A10 -B2 'node.*version\|node-version' | head -40

Repository: h3js/srvx

Length of output: 1401


🏁 Script executed:

#!/bin/bash
# Look at the complete mergeExistingHeaders function to understand the flow
sed -n '74,115p' src/adapters/_node/send.ts

Repository: h3js/srvx

Length of output: 1507


🏁 Script executed:

#!/bin/bash
# Check if there's any documentation or comment explaining why line 98 continue skips removeHeader
git log -p --all -S 'incomingNonSetCookieNames.has' -- src/adapters/_node/send.ts 2>/dev/null | head -150

Repository: h3js/srvx

Length of output: 35


🏁 Script executed:

#!/bin/bash
# Check git blame for the continue line
git blame -L 96,100 src/adapters/_node/send.ts 2>/dev/null

Repository: h3js/srvx

Length of output: 496


Line 98: Inconsistent header removal defeats stated intent.

When a non-set-cookie header from existingNames appears in incomingNonSetCookieNames (i.e., incoming will override it), the continue skips the nodeRes.removeHeader() call. However, the comment above line 107 states the goal is to "Drop from nodeRes so writeHead doesn't also emit the header through its own pending-headers channel and duplicate it." This creates inconsistent code intent: set-cookie and non-overridden headers are removed, but overridden headers are left in [kOutHeaders]. Consider always calling removeHeader inside the loop for clarity, even when the slot will be overwritten by the incoming value.

Test coverage: Add an explicit test case where nodeRes.setHeader("set-cookie", "a=1") is called by middleware and the Web Response also carries one or more set-cookie values, on both HTTP/1 and HTTP/2, to ensure the merged array survives writeHead correctly. The current x-set-with-node fixture only sets a non-cookie header.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/adapters/_node/send.ts` around lines 63 - 114, The loop in
mergeExistingHeaders inconsistently skips nodeRes.removeHeader when an existing
non-Set-Cookie header is going to be overridden (the continue on line 98), which
can leave duplicate headers emitted by writeHead; change the loop so
removeHeader is always called for every existing header (call
nodeRes.removeHeader?(name) before the continue/after handling values) while
preserving the special-case logic that concatenates Set-Cookie entries into
preserved, and update any tests (add a case that sets
nodeRes.setHeader("set-cookie", "a=1") and returns a Response with one or more
set-cookie values on both HTTP/1 and HTTP/2) to validate merged Set-Cookie
behavior through writeHead; refer to mergeExistingHeaders, existingNames,
incomingNonSetCookieNames, nodeRes.getHeader, nodeRes.removeHeader, and
writeHead when making the change.

@pi0
Copy link
Copy Markdown
Member

pi0 commented Apr 18, 2026

Thanks for PR. Considering overhead of this patch, i very much appreciate if you can provide a reasonable minimal reproduction (not tanstack start)

As i mentioned in related issue, nodeRes.setHeader should NOT be used in conjuction with APIs for setting headers, if used, it should handle such case to not happen in first place.

If framework itself somehow sets multiple set-cookies, this can be handled by a middleware or plugin for framework.

@SAY-5
Copy link
Copy Markdown
Author

SAY-5 commented Apr 20, 2026

Fair call on the repro — the behaviour I was seeing came from the tanstack-start adapter layering its own setHeader calls before handing back the Response, which per your guidance is the framework's contract violation rather than srvx's to absorb.

Closing this since the merge you're outlining would impose a permanent cost on the Node path for all users just to tolerate a pattern that shouldn't be in use. I'll take the "frameworks own setHeader / Response coherence" rule back to the caller side.

@SAY-5 SAY-5 closed this Apr 20, 2026
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.

Node adapter sendNodeResponse loses set-cookie header when setting multiple cookies at once

2 participants