Skip to content

fix: ResponseCookies deduplicate Set-Cookie headers and add missing API surface#761

Merged
james-elicx merged 3 commits intocloudflare:mainfrom
NathanDrake2406:fix/response-cookies-correctness
Apr 3, 2026
Merged

fix: ResponseCookies deduplicate Set-Cookie headers and add missing API surface#761
james-elicx merged 3 commits intocloudflare:mainfrom
NathanDrake2406:fix/response-cookies-correctness

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Apr 3, 2026

Summary

  • Bug 1 (critical): ResponseCookies.set() unconditionally appended Set-Cookie headers — setting the same cookie name twice produced duplicate headers, and get() returned the stale first match. Rewritten to use an internal Map<name, {serialized, entry}> as the single source of truth (matching @edge-runtime/cookies's architecture). The _syncHeaders() method deletes all Set-Cookie headers and re-appends from the map on every mutation.
  • Bug 2: Added object form for set({ name, value, httpOnly, ... }) matching Next.js docs and middleware patterns.
  • Bug 3: Added name filter parameter to getAll(name?) and getAll({ name }).
  • Bug 4: Added object form to delete({ name, path, domain }) with path/domain propagation.
  • Constructor now hydrates the internal map from existing Set-Cookie headers.
  • get() accepts object form get({ name }) matching edge-runtime.
  • delete() now uses Expires=epoch (matching edge-runtime) instead of Max-Age=0.
  • delete() now forwards httpOnly, secure, and sameSite attributes (matching edge-runtime — browsers need matching Secure to actually delete a cookie).
  • Behavioral change: set() now defaults Path=/ when no path option is given (previously omitted Path entirely). This matches @edge-runtime/cookies's normalizeCookie behavior.

Test plan

  • Test: set same cookie name twice → only one Set-Cookie header, get() returns latest
  • Test: set replaces only matching cookie, preserves others
  • Test: set({ name, value, httpOnly }) object form works
  • Test: object form replaces existing cookie of same name
  • Test: getAll("name") filters correctly
  • Test: getAll({ name }) object form filters correctly
  • Test: getAll(name) returns empty array for non-existent cookie
  • Test: delete({ name, path, domain }) produces correct Set-Cookie with matching path/domain
  • Test: delete() forwards httpOnly/secure/sameSite attributes
  • Test: delete() replaces existing cookie's Set-Cookie (no duplicates)
  • Test: constructor parses existing Set-Cookie headers
  • Test: has() works with internal map after delete()
  • Test: get({ name }) object form
  • All existing shims tests pass

…PI surface

ResponseCookies.set() unconditionally appended Set-Cookie headers, causing
duplicate headers when the same cookie name was set twice and get() returning
stale values. Rewritten to use an internal Map<name, {serialized, entry}>
as the single source of truth, matching @edge-runtime/cookies.

Also adds missing Next.js API surface:
- set() object form: set({ name, value, httpOnly, ... })
- get() object form: get({ name })
- getAll(name?) / getAll({ name }) filtering
- delete({ name, path, domain }) object form with path/domain propagation
- Constructor hydrates from existing Set-Cookie headers
- delete() now uses Expires=epoch (matching edge-runtime) instead of Max-Age=0
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 3, 2026

Open in StackBlitz

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

commit: 6dce232

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9a2338ff9c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const entries: CookieEntry[] = [];
for (const header of this._headers.getSetCookie()) {
// Hydrate internal map from any existing Set-Cookie headers
for (const header of headers.getSetCookie()) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add getSetCookie fallback when hydrating ResponseCookies

ResponseCookies now calls headers.getSetCookie() unconditionally in its constructor, and NextResponse always constructs ResponseCookies, so environments where Headers does not implement getSetCookie will now throw during new NextResponse(...) even if no cookie API is used. This is a regression from the previous behavior where only cookie-access paths required that method; use a guarded fallback (getSetCookie?.() / get('set-cookie')) to avoid breaking basic response creation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The old code already called headers.getSetCookie() unconditionally in get(), getAll(), and [Symbol.iterator]() — so this isn't a regression, it's just eagerness (constructor vs first access). getSetCookie() is also used unconditionally throughout the codebase (worker-utils.ts:57, prod-server.ts:173), and vinext targets Node 18+ / Cloudflare Workers which both support it. Adding a silent fallback would mask a broken environment instead of failing fast.

): this {
const [name, opts] =
typeof args[0] === "string" ? [args[0], undefined] : [args[0].name, args[0]];
return this.set({ name, value: "", expires: new Date(0), ...opts });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Prevent delete options from overriding tombstone fields

delete() currently builds the cookie as { name, value: "", expires: new Date(0), ...opts }, which lets user-provided properties in opts override value/expires. In JavaScript (or TypeScript with widened variables), passing an object that contains expires can produce a non-expired Set-Cookie, so the delete operation silently fails. Spread opts first (or only forward path/domain) so tombstone semantics are always enforced.

Useful? React with 👍 / 👎.

Explicitly extract path and domain instead of spreading the full opts
object, preventing callers from accidentally overriding the tombstone
expires/value fields. Matches @edge-runtime/cookies behavior.
@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

Solid fix — the core deduplication bug was real and the Map-as-source-of-truth architecture matches @edge-runtime/cookies well. Tests are thorough. A few things below, one of which affects correctness.

delete(
...args:
| [name: string]
| [options: Omit<CookieOptions & { name: string }, "maxAge" | "expires">]
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.

The Omit type removes maxAge and expires (good), but still admits httpOnly, secure, and sameSite at the type level. The runtime on lines 641–642 silently drops them. This is a parity gap with @edge-runtime/cookies, where delete() passes those attributes through to the Set-Cookie header — which matters because a browser won't delete a cookie unless Secure/Path/Domain match the original.

Two options:

  1. Narrow the type to only allow path and domain (matches the current runtime).
  2. Forward httpOnly/secure/sameSite too (matches edge-runtime).

I'd lean toward option 2 for parity:

Suggested change
| [options: Omit<CookieOptions & { name: string }, "maxAge" | "expires">]
| [options: Omit<CookieOptions & { name: string }, "maxAge" | "expires" | "value">]

and then forward the extra attributes:

return this.set({
  name,
  value: "",
  expires: new Date(0),
  path: opts?.path,
  domain: opts?.domain,
  httpOnly: opts?.httpOnly,
  secure: opts?.secure,
  sameSite: opts?.sameSite,
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6dce232 — delete() now forwards httpOnly, secure, and sameSite through to set(), matching edge-runtime behavior.

validateCookieName(name);

const parts = [`${name}=${encodeURIComponent(value)}`];
const path = opts?.path ?? "/";
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.

Previously set() without a path option would NOT emit Path= at all. Now it always defaults to Path=/. This matches @edge-runtime/cookies's normalizeCookie, so it's correct — but it is a subtle behavioral change beyond the bugs listed in the PR description. Worth a one-liner callout in the PR body so reviewers know it's intentional.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a callout in the PR description.

if (opts?.secure) parts.push("Secure");
if (opts?.sameSite) parts.push(`SameSite=${opts.sameSite}`);

this._parsed.set(name, { serialized: parts.join("; "), entry: { name, value } });
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.

The internal map stores { serialized, entry } where entry is { name, value } — it doesn't preserve the CookieOptions (httpOnly, secure, sameSite, path, domain, maxAge, expires). The edge-runtime stores the full ResponseCookie object in its map, which lets callers inspect all attributes via get().

This isn't a regression (the old code didn't store options either), but it means get("session") returns { name, value } whereas edge-runtime returns { name, value, httpOnly: true, path: "/", ... }. If this is a known gap, fine — just flagging it as a potential follow-up since you're reworking this class anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Known gap — tracking as a follow-up.

const entries: CookieEntry[] = [];
for (const header of this._headers.getSetCookie()) {
// Hydrate internal map from any existing Set-Cookie headers
for (const header of headers.getSetCookie()) {
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.

Agree with the author's response to the Codex review here — getSetCookie() was already used unconditionally and the target runtimes (Node 18+, Workers) all support it. Eagerly hydrating in the constructor is the right call since you need the map to be populated before any method is called.


// has() should work with internal map
it("has() returns false after delete()", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
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.

Good test — this is the most important behavioral assertion. After delete(), the entry still exists in the map with an empty value and epoch expiry, so has() returning true is correct and matches edge-runtime. The comment explaining this is helpful.

return [args[0], args[1] as string, args[2] as CookieOptions | undefined];
}
const { name, value, ...opts } = args[0];
return [name, value, Object.keys(opts).length > 0 ? (opts as CookieOptions) : undefined];
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: the Object.keys(opts).length > 0 check means that set({ name: "x", value: "y" }) passes undefined as options, which then triggers the opts?.path ?? "/" default in set(). This works, but it adds a subtle code path — if someone later adds a property with a falsy default to CookieOptions, the empty-check could mask it.

Simpler alternative: always return opts as CookieOptions (an empty object with no keys is harmless since every property is optional).

Suggested change
return [name, value, Object.keys(opts).length > 0 ? (opts as CookieOptions) : undefined];
return [name, value, opts as CookieOptions];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6dce232 — always returns opts as CookieOptions now.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 3, 2026

Review posted. Here's a summary of the feedback:

Overall: Good fix. The core deduplication bug was real and the Map-as-source-of-truth architecture matches @edge-runtime/cookies. Tests are thorough.

Key findings:

  1. delete() silently drops httpOnly/secure/sameSite — The type admits these properties but the runtime only forwards path and domain. Edge-runtime passes them through, which matters because browsers need matching attributes to actually delete a cookie. Suggested forwarding all attributes for parity.

  2. set() now defaults Path=/ — This is a behavioral change from the old code (which omitted Path entirely when no option was given). It's correct per edge-runtime, but worth calling out explicitly in the PR description since it's beyond the listed bug fixes.

  3. get() returns { name, value } not full cookie attributes — Not a regression, but a parity gap with edge-runtime which stores the full ResponseCookie object. Flagged as a potential follow-up.

  4. parseCookieSetArgs empty-opts check — The Object.keys(opts).length > 0 guard is unnecessarily complex; always returning opts as CookieOptions would be simpler since all properties are optional.

  5. Constructor getSetCookie() usage — Agreed with the author's response to the Codex review; eagerly hydrating is correct.

github run

Address review feedback:
- delete() now forwards httpOnly, secure, and sameSite attributes
  to match @edge-runtime/cookies behavior (browsers need matching
  Secure flag to actually delete a cookie)
- Simplify parseCookieSetArgs: always return opts (empty object is
  harmless since all CookieOptions properties are optional)
@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 well-executed fix. The Map-as-source-of-truth architecture correctly solves the header duplication bug and matches @edge-runtime/cookies. The API surface additions (object forms, getAll filter, delete with attribute forwarding) are all correct per edge-runtime parity. Tests are thorough and cover the important behavioral cases.

All 31 ResponseCookies tests pass. One minor issue below — the test description/assertion mismatch. The rest looks good to merge.

});

// has() should work with internal map
it("has() returns false after delete()", async () => {
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.

The test description says "has() returns false after delete()" but the assertion on line 4172 expects true (because delete() calls set() under the hood, so the entry stays in the map). The inline comment explains the behavior correctly, but the test name is misleading — someone scanning test names would think has() returns false after deletion.

Suggested change
it("has() returns false after delete()", async () => {
it("has() returns true after delete() (entry persists as expired tombstone)", async () => {

delete(
...args:
| [name: string]
| [options: Omit<CookieOptions & { name: string }, "maxAge" | "expires">]
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 (not blocking): The Omit removes maxAge and expires from the type, which is correct. Worth noting that value is not in CookieOptions so it's implicitly excluded — this is fine but could confuse someone reading the type who expects to see "value" in the omit list.

Also, the Codex review flagged that spreading opts could override tombstone fields — but the current implementation correctly avoids this by explicitly listing each property on lines 640-644 rather than using ...opts. Good.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 3, 2026

Review submitted. Here's a summary of my findings:

Approved — the core fix is solid. The Map-as-source-of-truth architecture correctly eliminates the Set-Cookie header duplication bug, matching @edge-runtime/cookies. The API surface additions (object forms for set/get/delete, getAll name filter, attribute forwarding in delete) are all correct per edge-runtime parity.

Two comments posted:

  1. Test name mismatch (tests/shims.test.ts:4159): The test says "has() returns false after delete()" but asserts true. Suggested renaming to "has() returns true after delete() (entry persists as expired tombstone)" to match the actual assertion and behavior.

  2. Informational note on delete() type (server.ts:632): Confirmed that the Codex concern about spread overriding tombstone fields is not an issue — the implementation correctly uses explicit property listing rather than ...opts.

All 31 ResponseCookies tests pass.

github run

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6dce2329cb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +608 to +609
this._parsed.set(name, { serialized: parts.join("; "), entry: { name, value } });
this._syncHeaders();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve same-name cookies across different path/domain scopes

Using name as the sole key in ResponseCookies causes cookies that legitimately share a name but differ by Path/Domain to overwrite each other, and _syncHeaders() then rewrites headers with only the last variant. In practice, if a response already has Set-Cookie: sid=...; Path=/ and Set-Cookie: sid=...; Path=/app, then any later cookies.set()/cookies.delete() call will silently drop one scope, which can break auth/session behavior for multi-path apps.

Useful? React with 👍 / 👎.


get(...args: [name: string] | [options: { name: string }]): CookieEntry | undefined {
const key = typeof args[0] === "string" ? args[0] : args[0].name;
return this._parsed.get(key)?.entry;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return defensive copies from cookie read APIs

get() returns the internal entry object directly from _parsed, so user mutation (for example, mutating cookies.get("x")!.value) changes internal in-memory state without updating the serialized Set-Cookie header string. This creates a desynchronized state where future reads differ from emitted headers, whereas the previous implementation always returned freshly parsed snapshots.

Useful? React with 👍 / 👎.

@james-elicx james-elicx merged commit adf8ab5 into cloudflare:main Apr 3, 2026
33 checks passed
@NathanDrake2406 NathanDrake2406 deleted the fix/response-cookies-correctness branch April 3, 2026 11:46
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.

2 participants