Skip to content
3 changes: 2 additions & 1 deletion benchmarks/vinext-rolldown/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"vinext": ["../../packages/vinext/src/index.ts"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
Expand Down
3 changes: 2 additions & 1 deletion benchmarks/vinext/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"vinext": ["../../packages/vinext/src/index.ts"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
Expand Down
486 changes: 275 additions & 211 deletions packages/vinext/src/entries/app-rsc-entry.ts

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1669,6 +1669,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
"vinext/i18n-context": path.join(shimsDir, "i18n-context"),
"vinext/instrumentation": path.resolve(__dirname, "server", "instrumentation"),
"vinext/html": path.resolve(__dirname, "server", "html"),
"vinext/server/app-router-entry": path.resolve(__dirname, "server", "app-router-entry"),
};

// Detect if Cloudflare's vite plugin is present — if so, skip
Expand Down Expand Up @@ -2923,13 +2924,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
if (hasAppDir) {
const mwCtxEntries: [string, string][] = [];
if (result.responseHeaders) {
const setCookies = result.responseHeaders.getSetCookie();
for (const [key, value] of result.responseHeaders) {
// Exclude control headers that runMiddleware already
// consumed — matches the RSC entry's inline filtering.
Copy link
Contributor

Choose a reason for hiding this comment

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

Good fix. The dev server middleware context forwarding now correctly uses getSetCookie() to preserve individual Set-Cookie values, matching the pattern in __copyResponseHeaders and __mergeResponseHeaders.

if (key === "set-cookie") continue;
if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") {
mwCtxEntries.push([key, value]);
}
}
for (const cookie of setCookies) {
mwCtxEntries.push(["set-cookie", cookie]);
}
}
req.headers["x-vinext-mw-ctx"] = JSON.stringify({
h: mwCtxEntries,
Expand Down
3 changes: 2 additions & 1 deletion packages/vinext/src/server/isr-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,14 @@ export function buildPagesCacheValue(
export function buildAppPageCacheValue(
html: string,
rscData?: ArrayBuffer,
headers?: Record<string, string | string[]>,
status?: number,
): CachedAppPageValue {
return {
kind: "APP_PAGE",
html,
rscData,
headers: undefined,
headers,
postponed: undefined,
status,
};
Expand Down
215 changes: 196 additions & 19 deletions packages/vinext/src/shims/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,91 @@ export interface HeadersContext {

export type HeadersAccessPhase = "render" | "action" | "route-handler";

type RenderSetCookieSource = "cookie" | "draft" | "header";

interface RenderSetCookieEntry {
source: RenderSetCookieSource;
value: string;
}

interface RenderResponseHeaderEntry {
name: string;
values: string[];
}

interface RenderResponseHeaders {
headers: Map<string, RenderResponseHeaderEntry>;
setCookies: RenderSetCookieEntry[];
}

export type VinextHeadersShimState = {
headersContext: HeadersContext | null;
dynamicUsageDetected: boolean;
pendingSetCookies: string[];
draftModeCookieHeader: string | null;
renderResponseHeaders: RenderResponseHeaders;
phase: HeadersAccessPhase;
};

function createRenderResponseHeaders(): RenderResponseHeaders {
return {
headers: new Map(),
setCookies: [],
};
}

function serializeRenderResponseHeaders(
renderResponseHeaders: RenderResponseHeaders,
): Record<string, string | string[]> | undefined {
if (renderResponseHeaders.headers.size === 0 && renderResponseHeaders.setCookies.length === 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Good: the early return when both maps are empty avoids unnecessary allocation on the common path (most pages don't call appendRenderResponseHeader). This addresses the optimization concern from prior reviews.

return undefined;
}

const serialized: Record<string, string | string[]> = {};

for (const entry of renderResponseHeaders.headers.values()) {
if (entry.values.length === 1) {
serialized[entry.name] = entry.values[0]!;
continue;
}
if (entry.values.length > 1) {
serialized[entry.name] = [...entry.values];
}
}

if (renderResponseHeaders.setCookies.length > 0) {
serialized["set-cookie"] = renderResponseHeaders.setCookies.map((entry) => entry.value);
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: serializeRenderResponseHeaders uses the original casing from entry.name for non-cookie headers but always lowercases the key for set-cookie. This is correct (Set-Cookie is case-insensitive per spec), just noting the asymmetry is intentional since set-cookie is always stored via the setCookies array with a fixed lowercase key.

}

return Object.keys(serialized).length > 0 ? serialized : undefined;
Comment on lines +66 to +89
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor note: serializeRenderResponseHeaders allocates a serialized object and iterates all entries even when the render response headers are empty (which is the common case for most pages that don't call appendRenderResponseHeader). An early return when both headers and setCookies are empty would avoid the allocation:

Suggested change
function serializeRenderResponseHeaders(
renderResponseHeaders: RenderResponseHeaders,
): Record<string, string | string[]> | undefined {
const serialized: Record<string, string | string[]> = {};
for (const entry of renderResponseHeaders.headers.values()) {
if (entry.values.length === 1) {
serialized[entry.name] = entry.values[0]!;
continue;
}
if (entry.values.length > 1) {
serialized[entry.name] = [...entry.values];
}
}
if (renderResponseHeaders.setCookies.length > 0) {
serialized["set-cookie"] = renderResponseHeaders.setCookies.map((entry) => entry.value);
}
return Object.keys(serialized).length > 0 ? serialized : undefined;
function serializeRenderResponseHeaders(
renderResponseHeaders: RenderResponseHeaders,
): Record<string, string | string[]> | undefined {
if (renderResponseHeaders.headers.size === 0 && renderResponseHeaders.setCookies.length === 0) {
return undefined;
}
const serialized: Record<string, string | string[]> = {};
for (const entry of renderResponseHeaders.headers.values()) {
if (entry.values.length === 1) {
serialized[entry.name] = entry.values[0]!;
continue;
}
if (entry.values.length > 1) {
serialized[entry.name] = [...entry.values];
}
}
if (renderResponseHeaders.setCookies.length > 0) {
serialized["set-cookie"] = renderResponseHeaders.setCookies.map((entry) => entry.value);
}
return Object.keys(serialized).length > 0 ? serialized : undefined;
}

This is a hot path — peek and consume are called multiple times per request. The early return avoids object allocation and iteration when there are no render headers (most requests).

Copy link
Contributor

Choose a reason for hiding this comment

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

Minor optimization: serializeRenderResponseHeaders allocates and iterates even when both maps are empty — the common case for pages that don't call appendRenderResponseHeader. Since peek and consume are called multiple times per request, an early return would avoid unnecessary work:

Suggested change
return Object.keys(serialized).length > 0 ? serialized : undefined;
function serializeRenderResponseHeaders(
renderResponseHeaders: RenderResponseHeaders,
): Record<string, string | string[]> | undefined {
if (
renderResponseHeaders.headers.size === 0 &&
renderResponseHeaders.setCookies.length === 0
) {
return undefined;
}
const serialized: Record<string, string | string[]> = {};

}

function deserializeRenderResponseHeaders(
serialized?: Record<string, string | string[]>,
): RenderResponseHeaders {
const renderResponseHeaders = createRenderResponseHeaders();

if (!serialized) {
return renderResponseHeaders;
}

for (const [key, value] of Object.entries(serialized)) {
if (key.toLowerCase() === "set-cookie") {
const values = Array.isArray(value) ? value : [value];
renderResponseHeaders.setCookies = values.map((item) => ({
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: deserialize always tags Set-Cookie entries as source: "header"

When restoring from cache, all Set-Cookie entries get source: "header" regardless of their original source ("cookie", "draft", or "header"). This means after a cache restore, getAndClearPendingCookies() (which filters by source === "cookie") would return an empty array even if the original render used cookies().set().

This is fine because getAndClearPendingCookies and getDraftModeCookieHeader are deprecated and not called from the cache replay path. The only consumer of deserialized headers is restoreRenderResponseHeaders (used for probe state restoration) and the cache HIT/STALE paths (which use the serialized form directly without deserializing). But it's worth noting that the round-trip is not lossless with respect to source tags — only the header values themselves are preserved.

source: "header",
value: item,
}));
continue;
}

renderResponseHeaders.headers.set(key.toLowerCase(), {
name: key,
values: Array.isArray(value) ? [...value] : [value],
});
}

return renderResponseHeaders;
}

// NOTE:
// - This shim can be loaded under multiple module specifiers in Vite's
// multi-environment setup (RSC/SSR). Store the AsyncLocalStorage on
Expand All @@ -56,8 +133,7 @@ const _als = (_g[_ALS_KEY] ??=
const _fallbackState = (_g[_FALLBACK_KEY] ??= {
headersContext: null,
dynamicUsageDetected: false,
pendingSetCookies: [],
draftModeCookieHeader: null,
renderResponseHeaders: createRenderResponseHeaders(),
phase: "render",
} satisfies VinextHeadersShimState) as VinextHeadersShimState;
const EXPIRED_COOKIE_DATE = new Date(0).toUTCString();
Expand All @@ -66,7 +142,31 @@ function _getState(): VinextHeadersShimState {
if (isInsideUnifiedScope()) {
return getRequestContext();
}
return _als.getStore() ?? _fallbackState;

const state = _als.getStore();
return state ?? _fallbackState;
}

function _appendRenderResponseHeaderWithSource(
name: string,
value: string,
source: RenderSetCookieSource,
): void {
const state = _getState();
if (name.toLowerCase() === "set-cookie") {
state.renderResponseHeaders.setCookies.push({ source, value });
return;
}
const lowerName = name.toLowerCase();
const existing = state.renderResponseHeaders.headers.get(lowerName);
if (existing) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: existing.name = name; unconditionally overwrites the stored header name with the latest casing on every append. This means if you do appendRenderResponseHeader("Vary", "a") then appendRenderResponseHeader("vary", "b"), the serialized key will be "vary" not "Vary". This probably doesn't matter (HTTP headers are case-insensitive), but if you want to preserve the original casing of the first append, consider not overwriting:

Suggested change
if (existing) {
if (existing) {
existing.values.push(value);
return;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: existing.values.push(value) is correct, but the name casing stored in existing.name reflects whichever casing was used on the first append() call. If a later append uses different casing (e.g., appendRenderResponseHeader("vary", "b") after appendRenderResponseHeader("Vary", "a")), the serialized key will be "Vary" — the original casing is preserved.

This is actually the better behavior (preserve first-seen casing), and it already works correctly because of the early return here. The previous review's suggestion to remove existing.name = name was based on an earlier revision that had that overwrite — this version doesn't have it. No action needed, just confirming the current code is correct.

existing.values.push(value);
return;
}
state.renderResponseHeaders.headers.set(lowerName, {
name,
values: [value],
});
}

/**
Expand Down Expand Up @@ -142,6 +242,14 @@ export function consumeDynamicUsage(): boolean {
return used;
}

export function peekDynamicUsage(): boolean {
return _getState().dynamicUsageDetected;
}

export function restoreDynamicUsage(used: boolean): void {
_getState().dynamicUsageDetected = used;
}

function _setStatePhase(
state: VinextHeadersShimState,
phase: HeadersAccessPhase,
Expand Down Expand Up @@ -183,8 +291,13 @@ export function setHeadersContext(ctx: HeadersContext | null): void {
if (ctx !== null) {
state.headersContext = ctx;
state.dynamicUsageDetected = false;
state.pendingSetCookies = [];
state.draftModeCookieHeader = null;
state.renderResponseHeaders = createRenderResponseHeaders();
const legacyState = state as VinextHeadersShimState & {
pendingSetCookies?: string[];
draftModeCookieHeader?: string | null;
};
legacyState.pendingSetCookies = [];
legacyState.draftModeCookieHeader = null;
state.phase = "render";
} else {
state.headersContext = null;
Expand Down Expand Up @@ -212,15 +325,15 @@ export function runWithHeadersContext<T>(
uCtx.dynamicUsageDetected = false;
uCtx.pendingSetCookies = [];
uCtx.draftModeCookieHeader = null;
uCtx.renderResponseHeaders = createRenderResponseHeaders();
uCtx.phase = "render";
}, fn);
}

const state: VinextHeadersShimState = {
headersContext: ctx,
dynamicUsageDetected: false,
pendingSetCookies: [],
draftModeCookieHeader: null,
renderResponseHeaders: createRenderResponseHeaders(),
phase: "render",
};

Expand Down Expand Up @@ -566,11 +679,19 @@ export function cookies(): Promise<RequestCookies> & RequestCookies {
/**
* Get and clear all pending Set-Cookie headers generated by cookies().set()/delete().
* Called by the framework after rendering to attach headers to the response.
*
* @deprecated Prefer consumeRenderResponseHeaders() when you need the full
* render-time response header set.
*/
export function getAndClearPendingCookies(): string[] {
Copy link
Contributor

Choose a reason for hiding this comment

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

Both getAndClearPendingCookies and getDraftModeCookieHeader are no longer imported by the generated RSC entry — they've been fully replaced by consumeRenderResponseHeaders(). They're still exported and tested in shims.test.ts, but are they needed by any other consumer? If they're only kept for backward compatibility with user code that might import them directly, consider marking them as deprecated. If they're purely internal, they could be removed to reduce the API surface.

Copy link
Contributor

Choose a reason for hiding this comment

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

Both getAndClearPendingCookies and getDraftModeCookieHeader are no longer used by the generated RSC entry — all call sites have been replaced with consumeRenderResponseHeaders(). They're still exported and tested in shims.test.ts, and getAndClearPendingCookies is still tested indirectly (the "keeps cookie helper queues separate" test).

If these are only kept for backward compatibility (e.g., user code or third-party libraries importing them directly), consider marking them @deprecated with a note pointing to the new API. If they're purely internal, they could be removed in a follow-up to shrink the API surface.

Not blocking — just a cleanup opportunity.

const state = _getState();
const cookies = state.pendingSetCookies;
state.pendingSetCookies = [];
const cookies = state.renderResponseHeaders.setCookies
.filter((entry) => entry.source === "cookie")
.map((entry) => entry.value);
if (cookies.length === 0) return [];
state.renderResponseHeaders.setCookies = state.renderResponseHeaders.setCookies.filter(
(entry) => entry.source !== "cookie",
);
return cookies;
}

Expand All @@ -597,12 +718,60 @@ function getDraftSecret(): string {
/**
* Get any Set-Cookie header generated by draftMode().enable()/disable().
* Called by the framework after rendering to attach the header to the response.
*
* @deprecated Prefer consumeRenderResponseHeaders() when you need the full
* render-time response header set.
*/
export function getDraftModeCookieHeader(): string | null {
const state = _getState();
const header = state.draftModeCookieHeader;
state.draftModeCookieHeader = null;
return header;
const draftEntries = state.renderResponseHeaders.setCookies.filter(
(entry) => entry.source === "draft",
);
if (draftEntries.length === 0) return null;
state.renderResponseHeaders.setCookies = state.renderResponseHeaders.setCookies.filter(
(entry) => entry.source !== "draft",
);
return draftEntries[draftEntries.length - 1]?.value ?? null;
}

export function appendRenderResponseHeader(name: string, value: string): void {
_appendRenderResponseHeaderWithSource(name, value, "header");
}

export function setRenderResponseHeader(name: string, value: string): void {
const state = _getState();
if (name.toLowerCase() === "set-cookie") {
state.renderResponseHeaders.setCookies = [{ source: "header", value }];
return;
}
state.renderResponseHeaders.headers.set(name.toLowerCase(), {
name,
values: [value],
});
}

export function deleteRenderResponseHeader(name: string): void {
const state = _getState();
if (name.toLowerCase() === "set-cookie") {
state.renderResponseHeaders.setCookies = [];
return;
}
state.renderResponseHeaders.headers.delete(name.toLowerCase());
}

export function peekRenderResponseHeaders(): Record<string, string | string[]> | undefined {
return serializeRenderResponseHeaders(_getState().renderResponseHeaders);
}

export function restoreRenderResponseHeaders(serialized?: Record<string, string | string[]>): void {
_getState().renderResponseHeaders = deserializeRenderResponseHeaders(serialized);
}

export function consumeRenderResponseHeaders(): Record<string, string | string[]> | undefined {
const state = _getState();
const serialized = serializeRenderResponseHeaders(state.renderResponseHeaders);
state.renderResponseHeaders = createRenderResponseHeaders();
return serialized;
}

interface DraftModeResult {
Expand Down Expand Up @@ -642,7 +811,11 @@ export async function draftMode(): Promise<DraftModeResult> {
}
const secure =
typeof process !== "undefined" && process.env?.NODE_ENV === "production" ? "; Secure" : "";
state.draftModeCookieHeader = `${DRAFT_MODE_COOKIE}=${secret}; Path=/; HttpOnly; SameSite=Lax${secure}`;
_appendRenderResponseHeaderWithSource(
"Set-Cookie",
`${DRAFT_MODE_COOKIE}=${secret}; Path=/; HttpOnly; SameSite=Lax${secure}`,
"draft",
);
},
disable(): void {
if (state.headersContext?.accessError) {
Expand All @@ -653,7 +826,11 @@ export async function draftMode(): Promise<DraftModeResult> {
}
const secure =
typeof process !== "undefined" && process.env?.NODE_ENV === "production" ? "; Secure" : "";
state.draftModeCookieHeader = `${DRAFT_MODE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secure}; Max-Age=0`;
_appendRenderResponseHeaderWithSource(
"Set-Cookie",
`${DRAFT_MODE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secure}; Max-Age=0`,
"draft",
);
},
};
}
Expand Down Expand Up @@ -783,12 +960,12 @@ class RequestCookies {
if (opts?.secure) parts.push("Secure");
if (opts?.sameSite) parts.push(`SameSite=${opts.sameSite}`);

_getState().pendingSetCookies.push(parts.join("; "));
_appendRenderResponseHeaderWithSource("Set-Cookie", parts.join("; "), "cookie");
return this;
}

/**
* Delete a cookie by emitting an expired Set-Cookie header.
* Delete a cookie by setting it with Max-Age=0.
*/
delete(nameOrOptions: string | { name: string; path?: string; domain?: string }): this {
const name = typeof nameOrOptions === "string" ? nameOrOptions : nameOrOptions.name;
Expand All @@ -805,7 +982,7 @@ class RequestCookies {
const parts = [`${name}=`, `Path=${path}`];
if (domain) parts.push(`Domain=${domain}`);
parts.push(`Expires=${EXPIRED_COOKIE_DATE}`);
_getState().pendingSetCookies.push(parts.join("; "));
_appendRenderResponseHeaderWithSource("Set-Cookie", parts.join("; "), "cookie");
return this;
}

Expand Down
Loading
Loading