Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 93 additions & 67 deletions packages/vinext/src/shims/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
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.

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.

const eq = header.indexOf("=");
if (eq === -1) continue;
const cookieName = header.slice(0, eq);
Expand All @@ -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 ?? "/";
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.

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 } });
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.

this._syncHeaders();
Comment on lines +608 to +609
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 👍 / 👎.

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;
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 👍 / 👎.

}

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">]
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.

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.

): 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 = {
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading