-
Notifications
You must be signed in to change notification settings - Fork 292
fix: ResponseCookies deduplicate Set-Cookie headers and add missing API surface #761
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9a2338f
f0a1979
6dce232
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -560,58 +560,14 @@ function validateCookieAttributeValue(value: string, attributeName: string): voi | |||||
|
|
||||||
| export class ResponseCookies { | ||||||
| private _headers: Headers; | ||||||
| /** Internal map keyed by cookie name — single source of truth. */ | ||||||
| private _parsed: Map<string, { serialized: string; entry: CookieEntry }> = new Map(); | ||||||
|
|
||||||
| constructor(headers: Headers) { | ||||||
| this._headers = headers; | ||||||
| } | ||||||
|
|
||||||
| set(name: string, value: string, options?: CookieOptions): this { | ||||||
| validateCookieName(name); | ||||||
| const parts = [`${name}=${encodeURIComponent(value)}`]; | ||||||
| if (options?.path) { | ||||||
| validateCookieAttributeValue(options.path, "Path"); | ||||||
| parts.push(`Path=${options.path}`); | ||||||
| } | ||||||
| if (options?.domain) { | ||||||
| validateCookieAttributeValue(options.domain, "Domain"); | ||||||
| parts.push(`Domain=${options.domain}`); | ||||||
| } | ||||||
| if (options?.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`); | ||||||
| if (options?.expires) parts.push(`Expires=${options.expires.toUTCString()}`); | ||||||
| if (options?.httpOnly) parts.push("HttpOnly"); | ||||||
| if (options?.secure) parts.push("Secure"); | ||||||
| if (options?.sameSite) parts.push(`SameSite=${options.sameSite}`); | ||||||
| this._headers.append("Set-Cookie", parts.join("; ")); | ||||||
| return this; | ||||||
| } | ||||||
|
|
||||||
| get(name: string): CookieEntry | undefined { | ||||||
| for (const header of this._headers.getSetCookie()) { | ||||||
| const eq = header.indexOf("="); | ||||||
| if (eq === -1) continue; | ||||||
| const cookieName = header.slice(0, eq); | ||||||
| if (cookieName === name) { | ||||||
| const semi = header.indexOf(";", eq); | ||||||
| const raw = header.slice(eq + 1, semi === -1 ? undefined : semi); | ||||||
| let value: string; | ||||||
| try { | ||||||
| value = decodeURIComponent(raw); | ||||||
| } catch { | ||||||
| value = raw; | ||||||
| } | ||||||
| return { name, value }; | ||||||
| } | ||||||
| } | ||||||
| return undefined; | ||||||
| } | ||||||
|
|
||||||
| has(name: string): boolean { | ||||||
| return this.get(name) !== undefined; | ||||||
| } | ||||||
|
|
||||||
| getAll(): CookieEntry[] { | ||||||
| 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()) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree with the author's response to the Codex review here — |
||||||
| const eq = header.indexOf("="); | ||||||
| if (eq === -1) continue; | ||||||
| const cookieName = header.slice(0, eq); | ||||||
|
|
@@ -623,34 +579,87 @@ export class ResponseCookies { | |||||
| } catch { | ||||||
| value = raw; | ||||||
| } | ||||||
| entries.push({ name: cookieName, value }); | ||||||
| this._parsed.set(cookieName, { serialized: header, entry: { name: cookieName, value } }); | ||||||
| } | ||||||
| return entries; | ||||||
| } | ||||||
|
|
||||||
| delete(name: string): this { | ||||||
| this.set(name, "", { maxAge: 0, path: "/" }); | ||||||
| set( | ||||||
| ...args: | ||||||
| | [name: string, value: string, options?: CookieOptions] | ||||||
| | [options: CookieOptions & { name: string; value: string }] | ||||||
| ): this { | ||||||
| const [name, value, opts] = parseCookieSetArgs(args); | ||||||
| validateCookieName(name); | ||||||
|
|
||||||
| const parts = [`${name}=${encodeURIComponent(value)}`]; | ||||||
| const path = opts?.path ?? "/"; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Previously
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a callout in the PR description. |
||||||
| validateCookieAttributeValue(path, "Path"); | ||||||
| parts.push(`Path=${path}`); | ||||||
| if (opts?.domain) { | ||||||
| validateCookieAttributeValue(opts.domain, "Domain"); | ||||||
| parts.push(`Domain=${opts.domain}`); | ||||||
| } | ||||||
| if (opts?.maxAge !== undefined) parts.push(`Max-Age=${opts.maxAge}`); | ||||||
| if (opts?.expires) parts.push(`Expires=${opts.expires.toUTCString()}`); | ||||||
| if (opts?.httpOnly) parts.push("HttpOnly"); | ||||||
| if (opts?.secure) parts.push("Secure"); | ||||||
| if (opts?.sameSite) parts.push(`SameSite=${opts.sameSite}`); | ||||||
|
|
||||||
| this._parsed.set(name, { serialized: parts.join("; "), entry: { name, value } }); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The internal map stores This isn't a regression (the old code didn't store options either), but it means
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Known gap — tracking as a follow-up. |
||||||
| this._syncHeaders(); | ||||||
|
Comment on lines
+608
to
+609
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Using Useful? React with 👍 / 👎. |
||||||
| return this; | ||||||
| } | ||||||
|
|
||||||
| 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; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||||||
| } | ||||||
|
|
||||||
| has(name: string): boolean { | ||||||
| return this._parsed.has(name); | ||||||
| } | ||||||
|
|
||||||
| getAll(...args: [name: string] | [options: { name: string }] | []): CookieEntry[] { | ||||||
| const all = [...this._parsed.values()].map((v) => v.entry); | ||||||
| if (args.length === 0) return all; | ||||||
| const key = typeof args[0] === "string" ? args[0] : args[0].name; | ||||||
| return all.filter((c) => c.name === key); | ||||||
| } | ||||||
|
|
||||||
| delete( | ||||||
| ...args: | ||||||
| | [name: string] | ||||||
| | [options: Omit<CookieOptions & { name: string }, "maxAge" | "expires">] | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Two options:
I'd lean toward option 2 for parity:
Suggested change
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,
});
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit (not blocking): The Also, the Codex review flagged that spreading |
||||||
| ): 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), | ||||||
| path: opts?.path, | ||||||
| domain: opts?.domain, | ||||||
| httpOnly: opts?.httpOnly, | ||||||
| secure: opts?.secure, | ||||||
| sameSite: opts?.sameSite, | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| [Symbol.iterator](): IterableIterator<[string, CookieEntry]> { | ||||||
| const entries: [string, CookieEntry][] = []; | ||||||
| for (const header of this._headers.getSetCookie()) { | ||||||
| const eq = header.indexOf("="); | ||||||
| if (eq === -1) continue; | ||||||
| const cookieName = header.slice(0, eq); | ||||||
| const semi = header.indexOf(";", eq); | ||||||
| const raw = header.slice(eq + 1, semi === -1 ? undefined : semi); | ||||||
| let value: string; | ||||||
| try { | ||||||
| value = decodeURIComponent(raw); | ||||||
| } catch { | ||||||
| value = raw; | ||||||
| } | ||||||
| entries.push([cookieName, { name: cookieName, value }]); | ||||||
| } | ||||||
| const entries: [string, CookieEntry][] = [...this._parsed.values()].map((v) => [ | ||||||
| v.entry.name, | ||||||
| v.entry, | ||||||
| ]); | ||||||
| return entries[Symbol.iterator](); | ||||||
| } | ||||||
|
|
||||||
| /** Delete all Set-Cookie headers and re-append from the internal map. */ | ||||||
| private _syncHeaders(): void { | ||||||
| this._headers.delete("Set-Cookie"); | ||||||
| for (const { serialized } of this._parsed.values()) { | ||||||
| this._headers.append("Set-Cookie", serialized); | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| type CookieOptions = { | ||||||
|
|
@@ -663,6 +672,23 @@ type CookieOptions = { | |||||
| sameSite?: "Strict" | "Lax" | "None"; | ||||||
| }; | ||||||
|
|
||||||
| /** | ||||||
| * Parse the overloaded arguments for ResponseCookies.set(): | ||||||
| * - (name, value, options?) — positional form | ||||||
| * - ({ name, value, ...options }) — object form | ||||||
| */ | ||||||
| function parseCookieSetArgs( | ||||||
| args: | ||||||
| | [name: string, value: string, options?: CookieOptions] | ||||||
| | [options: CookieOptions & { name: string; value: string }], | ||||||
| ): [string, string, CookieOptions | undefined] { | ||||||
| if (typeof args[0] === "string") { | ||||||
| return [args[0], args[1] as string, args[2] as CookieOptions | undefined]; | ||||||
| } | ||||||
| const { name, value, ...opts } = args[0]; | ||||||
| return [name, value, opts as CookieOptions]; | ||||||
| } | ||||||
|
|
||||||
| // --------------------------------------------------------------------------- | ||||||
| // Types | ||||||
| // --------------------------------------------------------------------------- | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ResponseCookiesnow callsheaders.getSetCookie()unconditionally in its constructor, andNextResponsealways constructsResponseCookies, so environments whereHeadersdoes not implementgetSetCookiewill now throw duringnew 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 👍 / 👎.
There was a problem hiding this comment.
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 inget(),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.