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
197 changes: 145 additions & 52 deletions packages/vinext/src/server/metadata-route-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "./app-route-handler-response.js";
import {
_consumeRequestScopedCacheLife,
_setRequestScopedCacheLife,
cacheLifeProfiles,
type CacheLifeConfig,
} from "vinext/shims/cache-request-state";
Expand All @@ -41,6 +42,9 @@ import { buildPageCacheTags } from "./implicit-tags.js";
import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.js";
import { VINEXT_METADATA_ROUTE_CACHE_HEADER } from "./headers.js";
import { isMetadataResponseCacheable } from "./metadata-route-cache-policy.js";
import { applyCdnResponseHeaders, NEVER_CACHE_CONTROL } from "./cache-control.js";
import { runWithIsolatedDynamicUsage } from "vinext/shims/headers.js";
import { makeThenableParams } from "vinext/shims/thenable-params";

type AppPageParams = Record<string, string | string[]>;
type MetadataRouteFunction = (props: Record<string, unknown>) => unknown;
Expand Down Expand Up @@ -93,18 +97,21 @@ export type PrerenderableMetadataRoute = {
routeSegments: string[];
};

type RenderedMetadataRoute = {
type CapturedMetadataRoute = {
cacheLife: CacheLifeConfig | null;
collectedTags: string[];
response: Response;
};

type RenderedMetadataRoute = CapturedMetadataRoute & {
dynamic: boolean;
};

function isOuterMetadataCacheEnabled(): boolean {
return process.env.NODE_ENV !== "development";
}

const routeFunctionCache = new WeakMap<MetadataRuntimeRoute, MetadataRouteFunctions>();
const USE_CACHE_FUNCTION_SYMBOL = Symbol.for("vinext.useCacheFunction");
const CACHE_HEADERS = {
noCache: "no-cache, no-store",
revalidate: "public, max-age=0, must-revalidate",
Expand All @@ -125,11 +132,7 @@ function readFunction(
if (typeof value !== "function") {
return null;
}
const fn: MetadataRouteFunction = (props) => Reflect.apply(value, module, [props]);
if (Reflect.get(value, USE_CACHE_FUNCTION_SYMBOL) === true) {
Reflect.set(fn, USE_CACHE_FUNCTION_SYMBOL, true);
}
return fn;
return (props) => Reflect.apply(value, module, [props]);
}

function isSitemapEntries(value: unknown): value is SitemapEntry[] {
Expand Down Expand Up @@ -196,50 +199,100 @@ function getMetadataRouteFunctions(route: MetadataRuntimeRoute): MetadataRouteFu
return functions;
}

function isUseCacheFunction(value: MetadataRouteFunction | null): boolean {
return value !== null && Reflect.get(value, USE_CACHE_FUNCTION_SYMBOL) === true;
function isMetadataRouteDynamic(route: MetadataRuntimeRoute, dynamicDetected: boolean): boolean {
const module = route.module ?? {};

// `export const dynamic = "force-dynamic"` forces the route to be dynamic.
if (Reflect.get(module, "dynamic") === "force-dynamic") return true;

// `export const revalidate = 0` means "never cache",
// so treat it the same as force-dynamic.
return Reflect.get(module, "revalidate") === 0 || dynamicDetected;
Comment on lines +205 to +210

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 Apply static dynamic configs during metadata rendering

When a metadata module exports dynamic = "force-static" and calls headers() or cookies(), the metadata handler never installs the force-static headers context used by configureAppRouteStaticGenerationContext; the isolated detector therefore reports dynamic usage and the prerender is skipped instead of receiving empty request data and producing a static artifact. The related dynamic = "error" mode is also treated like auto, silently skipping rather than raising the required static-generation error. Configure these two modes around metadata execution as the App Route path does.

AGENTS.md reference: AGENTS.md:L174-L181

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.

Maybe beyond the scope of this PR

Comment on lines +205 to +210

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 Bypass cached entries for explicitly dynamic metadata routes

When a deployment changes a previously cached metadata route to dynamic = "force-dynamic" or revalidate = 0, this predicate prevents new writes and prerendering but does not prevent readMatchedPrerenderedMetadataRouteResponse() from returning the existing persistent ISR entry before the route executes. The route can therefore continue serving the old static response—and a stale entry indefinitely—despite explicitly opting out of caching; apply the same dynamic decision before attempting the cache read.

AGENTS.md reference: AGENTS.md:L174-L181

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.

Beyond the scope of this PR

}

/**
* If the metadata module exports a finite positive `revalidate` interval, push
* it into the request-scoped cacheLife so that prerender seeding and runtime
* ISR writes use the requested TTL instead of the default profile.
*/
function applyMetadataRouteRevalidate(route: MetadataRuntimeRoute): void {
const module = route.module ?? {};
const revalidate = Reflect.get(module, "revalidate");

if (revalidate === false) {
_setRequestScopedCacheLife({ revalidate: Infinity });
return;
}

if (typeof revalidate === "number" && Number.isFinite(revalidate) && revalidate > 0) {
_setRequestScopedCacheLife({ revalidate });
Comment thread
NriotHrreion marked this conversation as resolved.
}
}

/**
* Enumerate metadata URLs whose default export is an explicit public
* `"use cache"` function. These are safe to invoke during prerendering and the
* resulting response can be seeded as an App Route artifact.
* Enumerate metadata URLs that can be prerendered as App Route artifacts.
*
* Static metadata files and dynamic metadata routes that do not use request-time
* APIs are eligible. Dynamic routes are rendered speculatively during
* prerendering; if they detect dynamic usage they are skipped rather than
* persisted.
*/
export async function getPrerenderableMetadataRoutePaths(
metadataRoutes: readonly MetadataRuntimeRoute[],
): Promise<PrerenderableMetadataRoute[]> {
const paths: PrerenderableMetadataRoute[] = [];

for (const route of metadataRoutes) {
if (!route.isDynamic || route.servedUrl.includes("[")) continue;
if (!route.isDynamic || route.servedUrl.includes("[") || isMetadataRouteDynamic(route, false))
continue;

const functions = getMetadataRouteFunctions(route);
if (!isUseCacheFunction(functions.defaultExport)) continue;

if (route.type !== "sitemap" || !functions.generateSitemaps) {
paths.push({
path: route.servedUrl,
routePattern: route.servedUrl,
routeSegments: route.routeSegments ?? [],
});
if (!functions.defaultExport) continue;
Comment thread
NriotHrreion marked this conversation as resolved.

if (route.type === "sitemap" && functions.generateSitemaps) {
const entries = await functions.generateSitemaps({});
if (!Array.isArray(entries)) continue;
const sitemapPrefix = route.servedUrl.slice(0, -4);
for (const entry of entries) {
if (!isObject(entry) || Reflect.get(entry, "id") == null) {
throw new Error("id property is required for every item returned from generateSitemaps");
}
const id = String(Reflect.get(entry, "id"));
if (!id || id.includes("/")) continue;
paths.push({
path: `${sitemapPrefix}/${encodeURIComponent(id)}.xml`,
routePattern: route.servedUrl,
routeSegments: route.routeSegments ?? [],
});
}
continue;
}

const entries = await functions.generateSitemaps({});
if (!Array.isArray(entries)) continue;
const sitemapPrefix = route.servedUrl.slice(0, -4);
for (const entry of entries) {
if (!isObject(entry) || Reflect.get(entry, "id") == null) {
throw new Error("id property is required for every item returned from generateSitemaps");
if (isImageMetadataRoute(route) && functions.generateImageMetadata) {
const entries = await functions.generateImageMetadata({ params: makeThenableParams({}) });
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (!isObject(entry) || Reflect.get(entry, "id") == null) {
throw new Error(
"id property is required for every item returned from generateImageMetadata",
);
}
const id = String(Reflect.get(entry, "id"));
if (!id || !isValidMetadataImageId(id)) continue;
paths.push({
path: `${route.servedUrl}/${encodeURIComponent(id)}`,
routePattern: route.servedUrl,
routeSegments: route.routeSegments ?? [],
});
}
const id = String(Reflect.get(entry, "id"));
if (!id || id.includes("/")) continue;
paths.push({
path: `${sitemapPrefix}/${encodeURIComponent(id)}.xml`,
routePattern: route.servedUrl,
routeSegments: route.routeSegments ?? [],
});
continue;
}

paths.push({
path: route.servedUrl,
routePattern: route.servedUrl,
routeSegments: route.routeSegments ?? [],
});
}

return paths;
Expand Down Expand Up @@ -278,14 +331,35 @@ function buildMetadataRouteTags(
return buildPageCacheTags(cleanPathname, collectedTags, route.routeSegments ?? [], "route");
}

function captureRenderedMetadataRoute(response: Response): RenderedMetadataRoute {
const cacheLife = _consumeRequestScopedCacheLife();
const collectedTags = getCollectedFetchTags();
function captureRenderedMetadataRoute(response: Response): CapturedMetadataRoute {
return {
cacheLife: _consumeRequestScopedCacheLife(),
collectedTags: getCollectedFetchTags(),
response,
};
}

function finalizeRenderedMetadataRoute(
captured: CapturedMetadataRoute,
dynamic: boolean,
): RenderedMetadataRoute {
if (process.env.VINEXT_PRERENDER === "1") {
applyPrerenderCacheLifeHeader(response.headers, cacheLife);
applyPrerenderCacheTagsHeader(response.headers, collectedTags);
applyPrerenderCacheLifeHeader(captured.response.headers, captured.cacheLife);
applyPrerenderCacheTagsHeader(captured.response.headers, captured.collectedTags);
}
if (dynamic) {
applyCdnResponseHeaders(captured.response.headers, {
cacheControl: NEVER_CACHE_CONTROL,
});
}
return { cacheLife, collectedTags, response };

return { ...captured, dynamic };
}

function isRenderedMetadataRouteCacheable(rendered: RenderedMetadataRoute): boolean {
return (
rendered.response.ok && !rendered.dynamic && isMetadataResponseCacheable(rendered.response)
);
}

async function writeRenderedMetadataRoute(
Expand All @@ -295,16 +369,17 @@ async function writeRenderedMetadataRoute(
rendered: RenderedMetadataRoute,
previousEntry: ISRCacheEntry | null,
): Promise<void> {
if (!options.isrSet || !rendered.response.ok || !isMetadataResponseCacheable(rendered.response)) {
if (!options.isrSet || !isRenderedMetadataRouteCacheable(rendered)) {
return;
}
const previousCacheControl = previousEntry?.value.cacheControl;
const defaultCacheLife = cacheLifeProfiles.default;
const revalidate =
const resolvedRevalidate =
rendered.cacheLife?.revalidate ??
previousCacheControl?.revalidate ??
defaultCacheLife.revalidate ??
900;
const revalidate = resolvedRevalidate === Infinity ? false : resolvedRevalidate;
const expire =
rendered.cacheLife?.expire ?? previousCacheControl?.expire ?? defaultCacheLife.expire;
const stale = resolveClientStaleTimeSeconds(rendered.cacheLife) ?? previousCacheControl?.stale;
Expand Down Expand Up @@ -381,7 +456,7 @@ async function readMatchedPrerenderedMetadataRouteResponse(
}

if (
isUseCacheFunction(functions.defaultExport) &&
functions.defaultExport !== null &&
options.isrSet &&
options.scheduleBackgroundRegeneration
) {
Expand Down Expand Up @@ -695,9 +770,7 @@ async function writeMetadataRouteMiss(
if (
process.env.VINEXT_PRERENDER === "1" ||
!isOuterMetadataCacheEnabled() ||
!rendered.response.ok ||
!isMetadataResponseCacheable(rendered.response) ||
!isUseCacheFunction(functions.defaultExport) ||
!isRenderedMetadataRouteCacheable(rendered) ||

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 Keep private metadata responses out of the shared cache

After removing the "use cache" gate, a regular metadata function that returns Cache-Control: private is now admitted to the outer ISR cache because isMetadataResponseCacheable rejects only no-cache and no-store. A later HIT rebuilds the response with shared ISR cache-control, replacing the original private policy and potentially serving request-specific metadata to other users; reject private responses before prerendering or writing them to ISR.

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.

Beyond the scope of this PR

!options.isrRouteKey ||
!options.isrSet
) {
Expand Down Expand Up @@ -732,8 +805,21 @@ export async function handleMetadataRouteRequest(
if (isGeneratedSitemapPath(route, options.cleanPathname)) {
const render = async (): Promise<RenderedMetadataRoute | null> => {
setCurrentFetchSoftTags(buildMetadataRouteTags(route, options.cleanPathname, []));
const response = await handleGeneratedSitemap(route, options.cleanPathname, functions);
return response ? captureRenderedMetadataRoute(response) : null;

const { result: captured, dynamicDetected } = await runWithIsolatedDynamicUsage(
async () => {
applyMetadataRouteRevalidate(route);
const response = await handleGeneratedSitemap(
route,
options.cleanPathname,
functions,
);
return response ? captureRenderedMetadataRoute(response) : null;
},
);
const dynamic = isMetadataRouteDynamic(route, dynamicDetected);

return captured ? finalizeRenderedMetadataRoute(captured, dynamic) : null;
};
const cached = await readMatchedPrerenderedMetadataRouteResponse(
options,
Expand Down Expand Up @@ -762,10 +848,17 @@ export async function handleMetadataRouteRequest(

const render = async (): Promise<RenderedMetadataRoute> => {
setCurrentFetchSoftTags(buildMetadataRouteTags(route, options.cleanPathname, []));
const response = route.isDynamic
? await callDynamicMetadataRoute(route, match, options.makeThenableParams, functions)
: serveStaticMetadataRoute(route);
return captureRenderedMetadataRoute(response);

const { result: captured, dynamicDetected } = await runWithIsolatedDynamicUsage(async () => {
applyMetadataRouteRevalidate(route);
const response = route.isDynamic
? await callDynamicMetadataRoute(route, match, options.makeThenableParams, functions)
: serveStaticMetadataRoute(route);
return captureRenderedMetadataRoute(response);
});
Comment on lines +854 to +858

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 Observe dynamic usage while consuming streaming responses

When a metadata export returns a lazy ImageResponse or Response(stream) whose producer calls headers(), cookies(), noStore(), or establishes cache lifetime while the body is pulled, this isolated scope ends as soon as the Response object is returned. buildAppRouteCacheValue() consumes the body afterward, so that late usage is recorded only in the parent context and rendered.dynamic remains false, allowing request-dependent bytes to be prerendered or written to shared ISR; keep observation active through body materialization or propagate observations made during consumption.

AGENTS.md reference: AGENTS.md:L194-L198

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.

Beyond the scope of this PR

const dynamic = isMetadataRouteDynamic(route, dynamicDetected);

return finalizeRenderedMetadataRoute(captured, dynamic);
};
const cached = await readMatchedPrerenderedMetadataRouteResponse(
options,
Expand Down
Loading
Loading