From 9a2338ff9c16a1cbba1f1302a8e0387e6a7cac60 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:46:56 +1100 Subject: [PATCH 1/3] fix: ResponseCookies deduplicate Set-Cookie headers and add missing API 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 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 --- packages/vinext/src/shims/server.ts | 151 +++++++++++--------- tests/shims.test.ts | 209 +++++++++++++++++++++++++++- 2 files changed, 291 insertions(+), 69 deletions(-) diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 44c09fc2..b4ebe46f 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -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 = 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()) { const eq = header.indexOf("="); if (eq === -1) continue; const cookieName = header.slice(0, eq); @@ -623,34 +579,78 @@ 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 ?? "/"; + 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 } }); + this._syncHeaders(); 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; + } + + 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] + ): 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 }); + } + [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 +663,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, Object.keys(opts).length > 0 ? (opts as CookieOptions) : undefined]; +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 887dfd94..0b65b817 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -3887,7 +3887,7 @@ describe("ResponseCookies API", () => { expect(cookies.has("missing")).toBe(false); }); - it("delete() sets Max-Age=0", async () => { + it("delete() expires the cookie (matching edge-runtime)", async () => { const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); const headers = new Headers(); const cookies = new ResponseCookies(headers); @@ -3897,7 +3897,7 @@ describe("ResponseCookies API", () => { const setCookie = headers.getSetCookie(); expect(setCookie).toHaveLength(1); expect(setCookie[0]).toContain("session="); - expect(setCookie[0]).toContain("Max-Age=0"); + expect(setCookie[0]).toContain("Expires=Thu, 01 Jan 1970 00:00:00 GMT"); expect(setCookie[0]).toContain("Path=/"); }); @@ -3957,6 +3957,211 @@ describe("ResponseCookies API", () => { }); }); +// --------------------------------------------------------------------------- +// ResponseCookies correctness (Next.js parity) +// Ported from @edge-runtime/cookies: packages/cookies/src/response-cookies.ts +// https://github.com/vercel/edge-runtime/blob/main/packages/cookies/src/response-cookies.ts + +describe("ResponseCookies correctness", () => { + // Bug 1: set() same cookie name twice should replace, not duplicate + it("set() same cookie name twice replaces the header (no duplicates)", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.set("session", "old-value"); + cookies.set("session", "new-value"); + + const setCookie = headers.getSetCookie(); + expect(setCookie).toHaveLength(1); + expect(setCookie[0]).toContain("session=new-value"); + }); + + it("get() returns the latest value after set() overwrites", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.set("token", "first"); + cookies.set("token", "second"); + + const result = cookies.get("token"); + expect(result?.value).toBe("second"); + }); + + it("set() replaces only the matching cookie, preserves others", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.set("a", "1"); + cookies.set("b", "2"); + cookies.set("a", "3"); + + const setCookie = headers.getSetCookie(); + expect(setCookie).toHaveLength(2); + // 'a' was replaced, 'b' stays + const aHeader = setCookie.find((h: string) => h.startsWith("a=")); + const bHeader = setCookie.find((h: string) => h.startsWith("b=")); + expect(aHeader).toContain("a=3"); + expect(bHeader).toContain("b=2"); + }); + + // Bug 2: set() should accept object form + it("set() accepts object form { name, value, ... }", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.set({ name: "token", value: "abc", httpOnly: true, path: "/api" }); + + const setCookie = headers.getSetCookie(); + expect(setCookie).toHaveLength(1); + expect(setCookie[0]).toContain("token=abc"); + expect(setCookie[0]).toContain("HttpOnly"); + expect(setCookie[0]).toContain("Path=/api"); + + const result = cookies.get("token"); + expect(result?.value).toBe("abc"); + }); + + it("set() object form replaces existing cookie of same name", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.set("token", "old"); + cookies.set({ name: "token", value: "new", secure: true }); + + const setCookie = headers.getSetCookie(); + expect(setCookie).toHaveLength(1); + expect(setCookie[0]).toContain("token=new"); + expect(setCookie[0]).toContain("Secure"); + }); + + // Bug 3: getAll() should accept optional name filter + it("getAll(name) filters by cookie name", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.set("a", "1"); + cookies.set("b", "2"); + cookies.set("c", "3"); + + const filtered = cookies.getAll("b"); + expect(filtered).toHaveLength(1); + expect(filtered[0]).toEqual({ name: "b", value: "2" }); + }); + + it("getAll({ name }) filters by cookie name (object form)", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.set("x", "10"); + cookies.set("y", "20"); + + const filtered = cookies.getAll({ name: "x" }); + expect(filtered).toHaveLength(1); + expect(filtered[0]).toEqual({ name: "x", value: "10" }); + }); + + it("getAll() with no args returns all cookies", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.set("a", "1"); + cookies.set("b", "2"); + + const all = cookies.getAll(); + expect(all).toHaveLength(2); + }); + + it("getAll(name) returns empty array for non-existent cookie", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.set("a", "1"); + + const filtered = cookies.getAll("missing"); + expect(filtered).toHaveLength(0); + }); + + // Bug 4: delete() should accept object and array forms + it("delete({ name, path, domain }) sets correct expiry cookie with path and domain", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.delete({ name: "session", path: "/app", domain: ".example.com" }); + + const setCookie = headers.getSetCookie(); + expect(setCookie).toHaveLength(1); + expect(setCookie[0]).toContain("session="); + expect(setCookie[0]).toContain("Path=/app"); + expect(setCookie[0]).toContain("Domain=.example.com"); + // Should expire the cookie + expect(setCookie[0]).toContain("Expires="); + }); + + it("delete() replaces existing cookie's Set-Cookie header", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.set("session", "abc123", { path: "/app" }); + cookies.delete("session"); + + // Should have exactly one Set-Cookie for 'session' (the deletion one) + const setCookie = headers.getSetCookie(); + const sessionHeaders = setCookie.filter((h: string) => h.startsWith("session=")); + expect(sessionHeaders).toHaveLength(1); + expect(sessionHeaders[0]).toContain("Expires="); + }); + + // Constructor should parse existing Set-Cookie headers + it("constructor parses existing Set-Cookie headers", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + headers.append("Set-Cookie", "existing=value; Path=/"); + + const cookies = new ResponseCookies(headers); + const result = cookies.get("existing"); + expect(result?.value).toBe("value"); + }); + + // has() should work with internal map + it("has() returns false after delete()", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.set("session", "abc"); + expect(cookies.has("session")).toBe(true); + + cookies.delete("session"); + // After delete, a new expired cookie replaces the old one. + // The cookie still "exists" in the map (with empty value and past expiry), + // matching edge-runtime behavior where delete() calls set(). + // has() returns true because the entry exists in the map. + expect(cookies.has("session")).toBe(true); + }); + + // get() with object form (matching edge-runtime) + it("get() accepts object { name } form", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.set("token", "abc"); + const result = cookies.get({ name: "token" }); + expect(result?.value).toBe("abc"); + }); +}); + // --------------------------------------------------------------------------- // Cookie name/value injection prevention (RFC 6265) From f0a1979749a54b60e91a1b6f0c3e2a5f5c135326 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:55:52 +1100 Subject: [PATCH 2/3] fix: only forward path/domain in ResponseCookies.delete() 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. --- packages/vinext/src/shims/server.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index b4ebe46f..27964950 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -633,7 +633,13 @@ export class ResponseCookies { ): 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 }); + return this.set({ + name, + value: "", + expires: new Date(0), + path: opts?.path, + domain: opts?.domain, + }); } [Symbol.iterator](): IterableIterator<[string, CookieEntry]> { From 6dce2329cb73caa51ed7433872df7f43ea5977f5 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:09:12 +1100 Subject: [PATCH 3/3] fix: forward httpOnly/secure/sameSite in ResponseCookies.delete() 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) --- packages/vinext/src/shims/server.ts | 5 ++++- tests/shims.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 27964950..a8b5cd8b 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -639,6 +639,9 @@ export class ResponseCookies { expires: new Date(0), path: opts?.path, domain: opts?.domain, + httpOnly: opts?.httpOnly, + secure: opts?.secure, + sameSite: opts?.sameSite, }); } @@ -683,7 +686,7 @@ function parseCookieSetArgs( 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]; + return [name, value, opts as CookieOptions]; } // --------------------------------------------------------------------------- diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 0b65b817..3acd1bed 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -4107,6 +4107,28 @@ describe("ResponseCookies correctness", () => { expect(setCookie[0]).toContain("Expires="); }); + it("delete() forwards httpOnly, secure, and sameSite attributes", async () => { + const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); + const headers = new Headers(); + const cookies = new ResponseCookies(headers); + + cookies.delete({ + name: "session", + path: "/app", + httpOnly: true, + secure: true, + sameSite: "Lax", + }); + + const setCookie = headers.getSetCookie(); + expect(setCookie).toHaveLength(1); + expect(setCookie[0]).toContain("HttpOnly"); + expect(setCookie[0]).toContain("Secure"); + expect(setCookie[0]).toContain("SameSite=Lax"); + expect(setCookie[0]).toContain("Path=/app"); + expect(setCookie[0]).toContain("Expires="); + }); + it("delete() replaces existing cookie's Set-Cookie header", async () => { const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js"); const headers = new Headers();