Skip to content
Open
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
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
306 changes: 203 additions & 103 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 @@ -1986,6 +1986,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"),
}).flatMap(([k, v]) =>
k.startsWith("next/")
? [
Expand Down Expand Up @@ -3267,13 +3268,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.

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 when serializing into x-vinext-mw-ctx. This prevents the comma-joining that Headers.entries() does for Set-Cookie.

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
114 changes: 101 additions & 13 deletions packages/vinext/src/server/app-page-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type AppPageCacheSetter = (
type AppPageBackgroundRegenerator = (key: string, renderFn: () => Promise<void>) => void;

export interface AppPageCacheRenderResult {
headers?: Record<string, string | string[]>;
html: string;
rscData: ArrayBuffer;
tags: string[];
Expand Down Expand Up @@ -40,7 +41,10 @@ export interface ReadAppPageCacheResponseOptions {
export interface FinalizeAppPageHtmlCacheResponseOptions {
capturedRscDataPromise: Promise<ArrayBuffer> | null;
cleanPathname: string;
consumeDynamicUsage: () => boolean;
consumeRenderResponseHeaders: () => Record<string, string | string[]> | undefined;
getPageTags: () => string[];
initialRenderHeaders?: Record<string, string | string[]>;
isrDebug?: AppPageDebugLogger;
isrHtmlKey: (pathname: string) => string;
isrRscKey: (pathname: string) => string;
Expand All @@ -53,8 +57,10 @@ export interface ScheduleAppPageRscCacheWriteOptions {
capturedRscDataPromise: Promise<ArrayBuffer> | null;
cleanPathname: string;
consumeDynamicUsage: () => boolean;
consumeRenderResponseHeaders: () => Record<string, string | string[]> | undefined;
dynamicUsedDuringBuild: boolean;
getPageTags: () => string[];
initialRenderHeaders?: Record<string, string | string[]>;
isrDebug?: AppPageDebugLogger;
isrRscKey: (pathname: string) => string;
isrSet: AppPageCacheSetter;
Expand All @@ -77,6 +83,70 @@ function getCachedAppPageValue(entry: ISRCacheEntry | null): CachedAppPageValue
return entry?.value.value && entry.value.value.kind === "APP_PAGE" ? entry.value.value : null;
}

function isAppendOnlyResponseHeader(lowerKey: string): boolean {
return (
lowerKey === "set-cookie" ||
lowerKey === "vary" ||
lowerKey === "www-authenticate" ||
lowerKey === "proxy-authenticate"
);
}

function mergeResponseHeaderValues(
targetHeaders: Headers,
key: string,
value: string | string[],
mode: "fallback" | "override",
): void {
const lowerKey = key.toLowerCase();
const values = Array.isArray(value) ? value : [value];

if (isAppendOnlyResponseHeader(lowerKey)) {
for (const item of values) {
targetHeaders.append(key, item);
}
return;
}

if (mode === "fallback" && targetHeaders.has(key)) {
return;
}

targetHeaders.delete(key);
if (values.length === 1) {
targetHeaders.set(key, values[0]!);
return;
}

for (const item of values) {
targetHeaders.append(key, item);
}
}

function mergeResponseHeaders(
targetHeaders: Headers,
sourceHeaders: Record<string, string | string[]> | null | undefined,
mode: "fallback" | "override",
): void {
if (!sourceHeaders) {
return;
}

for (const [key, value] of Object.entries(sourceHeaders)) {
mergeResponseHeaderValues(targetHeaders, key, value, mode);
}
}

function headersWithCachedHeaders(
baseHeaders: HeadersInit,
cachedHeaders: Record<string, string | string[]> | undefined,
): Headers {
const headers = new Headers();
mergeResponseHeaders(headers, cachedHeaders, "fallback");
mergeResponseHeaders(headers, Object.fromEntries(new Headers(baseHeaders)), "override");
Copy link
Contributor

Choose a reason for hiding this comment

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

P2: Fragile Object.fromEntries(new Headers(...)) pattern

Object.fromEntries(new Headers(baseHeaders)) converts the headers to a plain object, but Object.fromEntries on a Headers iterator flattens multi-value headers into their comma-joined form (per the Fetch spec's entries() semantics). For Set-Cookie specifically, this produces broken values.

Right now this is safe because baseHeaders in buildAppPageCachedResponse is always a plain Record<string, string> with no multi-value entries (Content-Type, Cache-Control, Vary, X-Vinext-Cache). But the pattern is fragile — if anyone adds a Set-Cookie or repeated header to the base headers, it would silently flatten.

The other two copies of this helper (app-page-response.ts:133, app-route-handler-response.ts:105) accept ResponseHeaderSource = Headers | Record<string, string | string[]> and route through the mergeResponseHeaders function that properly handles Headers instances via getSetCookie(). Consider aligning this copy to use the same pattern, or at minimum tightening the baseHeaders type to Record<string, string> to make the safety guarantee explicit.

Suggested change
mergeResponseHeaders(headers, Object.fromEntries(new Headers(baseHeaders)), "override");
function headersWithCachedHeaders(
baseHeaders: Record<string, string>,
cachedHeaders: Record<string, string | string[]> | undefined,
): Headers {
const headers = new Headers();
mergeResponseHeaders(headers, cachedHeaders, "fallback");
for (const [key, value] of Object.entries(baseHeaders)) {
mergeResponseHeaderValues(headers, key, value, "override");
}
return headers;
}

return headers;
}

export function buildAppPageCachedResponse(
cachedValue: CachedAppPageValue,
options: BuildAppPageCachedResponseOptions,
Expand All @@ -97,10 +167,13 @@ export function buildAppPageCachedResponse(

return new Response(cachedValue.rscData, {
status,
headers: {
"Content-Type": "text/x-component; charset=utf-8",
...headers,
},
headers: headersWithCachedHeaders(
{
"Content-Type": "text/x-component; charset=utf-8",
...headers,
},
cachedValue.headers,
),
});
}

Expand All @@ -110,10 +183,13 @@ export function buildAppPageCachedResponse(

return new Response(cachedValue.html, {
status,
headers: {
"Content-Type": "text/html; charset=utf-8",
...headers,
},
headers: headersWithCachedHeaders(
{
"Content-Type": "text/html; charset=utf-8",
...headers,
},
cachedValue.headers,
),
});
}

Expand Down Expand Up @@ -157,13 +233,13 @@ export async function readAppPageCacheResponse(
await Promise.all([
options.isrSet(
options.isrHtmlKey(options.cleanPathname),
buildAppPageCacheValue(revalidatedPage.html, undefined, 200),
buildAppPageCacheValue(revalidatedPage.html, undefined, revalidatedPage.headers, 200),
options.revalidateSeconds,
revalidatedPage.tags,
),
options.isrSet(
options.isrRscKey(options.cleanPathname),
buildAppPageCacheValue("", revalidatedPage.rscData, 200),
buildAppPageCacheValue("", revalidatedPage.rscData, revalidatedPage.headers, 200),
options.revalidateSeconds,
revalidatedPage.tags,
),
Expand Down Expand Up @@ -225,11 +301,19 @@ export function finalizeAppPageHtmlCacheResponse(
}
chunks.push(decoder.decode());

const renderHeadersForCache =
options.consumeRenderResponseHeaders() ?? options.initialRenderHeaders;

if (options.consumeDynamicUsage()) {
options.isrDebug?.("skip HTML cache write after late dynamic usage", options.cleanPathname);
return;
}

const pageTags = options.getPageTags();
const writes = [
options.isrSet(
htmlKey,
buildAppPageCacheValue(chunks.join(""), undefined, 200),
buildAppPageCacheValue(chunks.join(""), undefined, renderHeadersForCache, 200),
options.revalidateSeconds,
pageTags,
),
Expand All @@ -240,7 +324,7 @@ export function finalizeAppPageHtmlCacheResponse(
options.capturedRscDataPromise.then((rscData) =>
options.isrSet(
rscKey,
buildAppPageCacheValue("", rscData, 200),
buildAppPageCacheValue("", rscData, renderHeadersForCache, 200),
options.revalidateSeconds,
pageTags,
),
Expand All @@ -252,6 +336,8 @@ export function finalizeAppPageHtmlCacheResponse(
options.isrDebug?.("HTML cache written", htmlKey);
} catch (cacheError) {
console.error("[vinext] ISR cache write error:", cacheError);
} finally {
options.consumeRenderResponseHeaders();
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: double-consume could use a comment

On the happy path, consumeRenderResponseHeaders() was already called at line 305. Calling it again here returns undefined (the state was already reset). This is a safety net for the error path where line 305 might not have been reached. A brief comment would help:

Suggested change
options.consumeRenderResponseHeaders();
options.consumeRenderResponseHeaders();
// ^ Safety net: ensure render response headers are consumed even if
// the cache write threw before reaching the consume at line 305.

}
})();

Expand All @@ -276,6 +362,8 @@ export function scheduleAppPageRscCacheWrite(
const cachePromise = (async () => {
try {
const rscData = await capturedRscDataPromise;
const renderHeadersForCache =
options.consumeRenderResponseHeaders() ?? options.initialRenderHeaders;

// Two-phase dynamic detection:
// 1. dynamicUsedDuringBuild catches searchParams-driven opt-in before the
Expand All @@ -289,7 +377,7 @@ export function scheduleAppPageRscCacheWrite(

await options.isrSet(
rscKey,
buildAppPageCacheValue("", rscData, 200),
buildAppPageCacheValue("", rscData, renderHeadersForCache, 200),
options.revalidateSeconds,
options.getPageTags(),
);
Expand Down
35 changes: 32 additions & 3 deletions packages/vinext/src/server/app-page-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,12 @@ interface AppPageRequestCacheLife {
}

export interface RenderAppPageLifecycleOptions {
buildDynamicUsage: boolean;
buildRenderHeaders?: Record<string, string | string[]>;
cleanPathname: string;
clearRequestContext: () => void;
consumeDynamicUsage: () => boolean;
consumeRenderResponseHeaders: () => Record<string, string | string[]> | undefined;
createRscOnErrorHandler: (pathname: string, routePath: string) => AppPageBoundaryOnError;
getFontLinks: () => string[];
getFontPreloads: () => AppPageFontPreload[];
Expand All @@ -73,6 +76,8 @@ export interface RenderAppPageLifecycleOptions {
loadSsrHandler: () => Promise<AppPageSsrHandler>;
middlewareContext: AppPageMiddlewareContext;
params: Record<string, unknown>;
peekDynamicUsage: () => boolean;
peekRenderResponseHeaders: () => Record<string, string | string[]> | undefined;
probeLayoutAt: (layoutIndex: number) => unknown;
probePage: () => unknown;
revalidateSeconds: number | null;
Expand All @@ -86,6 +91,10 @@ export interface RenderAppPageLifecycleOptions {
element: ReactNode,
options: { onError: AppPageBoundaryOnError },
) => ReadableStream<Uint8Array>;
restoreDynamicUsage: (dynamicUsed: boolean) => void;
restoreRenderResponseHeaders: (
renderHeaders: Record<string, string | string[]> | undefined,
) => void;
routeHasLocalBoundary: boolean;
routePattern: string;
runWithSuppressedHookWarning<T>(probe: () => Promise<T>): Promise<T>;
Expand Down Expand Up @@ -139,6 +148,9 @@ export async function renderAppPageLifecycle(
return preRenderResponse;
}

options.restoreRenderResponseHeaders(options.buildRenderHeaders);
Copy link
Contributor

Choose a reason for hiding this comment

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

Good pattern. The probe phase may trigger appendRenderResponseHeader calls (e.g., in layout components that call notFound()), then the probe's special-error handler consumes them. This restore re-establishes the build-time snapshot so the actual render starts from a clean, known state. Subtle and correct.

options.restoreDynamicUsage(options.buildDynamicUsage);

const compileEnd = options.isProduction ? undefined : performance.now();
const baseOnError = options.createRscOnErrorHandler(options.cleanPathname, options.routePattern);
const rscErrorTracker = createAppPageRscErrorTracker(baseOnError);
Expand All @@ -160,8 +172,12 @@ export async function renderAppPageLifecycle(

if (options.isRscRequest) {
const dynamicUsedDuringBuild = options.consumeDynamicUsage();
const lateDynamicUsage = isrRscDataPromise ? options.peekDynamicUsage() : false;
const rscResponseRenderHeaders = isrRscDataPromise
? options.peekRenderResponseHeaders()
: options.consumeRenderResponseHeaders();
const rscResponsePolicy = resolveAppPageRscResponsePolicy({
dynamicUsedDuringBuild,
dynamicUsedDuringBuild: dynamicUsedDuringBuild || lateDynamicUsage,
isDynamicError: options.isDynamicError,
isForceDynamic: options.isForceDynamic,
isForceStatic: options.isForceStatic,
Expand All @@ -172,6 +188,7 @@ export async function renderAppPageLifecycle(
middlewareContext: options.middlewareContext,
params: options.params,
policy: rscResponsePolicy,
renderHeaders: rscResponseRenderHeaders,
timing: buildResponseTiming({
compileEnd,
handlerStart: options.handlerStart,
Expand All @@ -184,10 +201,12 @@ export async function renderAppPageLifecycle(
capturedRscDataPromise: options.isProduction ? isrRscDataPromise : null,
cleanPathname: options.cleanPathname,
consumeDynamicUsage: options.consumeDynamicUsage,
dynamicUsedDuringBuild,
consumeRenderResponseHeaders: options.consumeRenderResponseHeaders,
dynamicUsedDuringBuild: dynamicUsedDuringBuild || lateDynamicUsage,
getPageTags() {
return options.getPageTags();
},
initialRenderHeaders: rscResponseRenderHeaders,
isrDebug: options.isrDebug,
isrRscKey: options.isrRscKey,
isrSet: options.isrSet,
Expand Down Expand Up @@ -253,10 +272,15 @@ export async function renderAppPageLifecycle(
}
}

const renderResponseHeaders = isrRscDataPromise
? options.peekRenderResponseHeaders()
: options.consumeRenderResponseHeaders();
options.clearRequestContext();
const draftCookie = options.getDraftModeCookieHeader();

const dynamicUsedDuringRender = options.consumeDynamicUsage();
const dynamicUsedDuringRender = isrRscDataPromise
? options.peekDynamicUsage()
: options.consumeDynamicUsage();
const requestCacheLife = options.getRequestCacheLife();
if (requestCacheLife?.revalidate !== undefined && revalidateSeconds === null) {
revalidateSeconds = requestCacheLife.revalidate;
Expand Down Expand Up @@ -284,14 +308,18 @@ export async function renderAppPageLifecycle(
fontLinkHeader,
middlewareContext: options.middlewareContext,
policy: htmlResponsePolicy,
renderHeaders: renderResponseHeaders,
timing: htmlResponseTiming,
});
return finalizeAppPageHtmlCacheResponse(isrResponse, {
capturedRscDataPromise: isrRscDataPromise,
cleanPathname: options.cleanPathname,
consumeDynamicUsage: options.consumeDynamicUsage,
consumeRenderResponseHeaders: options.consumeRenderResponseHeaders,
getPageTags() {
return options.getPageTags();
},
initialRenderHeaders: renderResponseHeaders,
isrDebug: options.isrDebug,
isrHtmlKey: options.isrHtmlKey,
isrRscKey: options.isrRscKey,
Expand All @@ -308,6 +336,7 @@ export async function renderAppPageLifecycle(
fontLinkHeader,
middlewareContext: options.middlewareContext,
policy: htmlResponsePolicy,
renderHeaders: renderResponseHeaders,
timing: htmlResponseTiming,
});
}
Loading
Loading