diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 44c09fc2..a8b5cd8b 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,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 ?? "/"; + 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), + 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 // --------------------------------------------------------------------------- diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 887dfd94..3acd1bed 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,233 @@ 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() 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(); + 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)