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
16 changes: 12 additions & 4 deletions packages/cloudflare/src/cache/kv-data-adapter.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ export class KVCacheHandler implements CacheHandler {
let effectiveExpire: number | undefined;
effectiveRevalidate = readCacheControlNumberField(ctx, "revalidate");
effectiveExpire = readCacheControlNumberField(ctx, "expire");
const effectiveStale = readCacheControlNumberField(ctx, "stale");
if (data && "revalidate" in data && typeof data.revalidate === "number") {
effectiveRevalidate = data.revalidate;
}
Expand All @@ -380,11 +381,15 @@ export class KVCacheHandler implements CacheHandler {
typeof effectiveExpire === "number" && effectiveExpire > 0
? now + effectiveExpire * 1000
: null;
const cacheControl =
const cacheControl: CacheControlMetadata | undefined =
typeof effectiveRevalidate === "number"
? effectiveExpire === undefined
? { revalidate: effectiveRevalidate }
: { revalidate: effectiveRevalidate, expire: effectiveExpire }
? {
revalidate: effectiveRevalidate,
...(effectiveExpire === undefined ? {} : { expire: effectiveExpire }),
// Client-router reuse bound — must survive KV so warm hits replay
// the producing render's claim (see CacheControlMetadata.stale).
...(effectiveStale === undefined ? {} : { stale: effectiveStale }),
}
: undefined;

// Prepare entry — convert ArrayBuffers to base64 for JSON storage
Expand Down Expand Up @@ -575,6 +580,9 @@ function validateCacheEntry(raw: unknown): KVCacheEntry | null {
if (obj.cacheControl.expire !== undefined && typeof obj.cacheControl.expire !== "number") {
return null;
}
if (obj.cacheControl.stale !== undefined && typeof obj.cacheControl.stale !== "number") {
return null;
}
}

// value must be null or a valid cache value object with a known kind
Expand Down
14 changes: 11 additions & 3 deletions packages/cloudflare/src/prerender-kv-populate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type KVBulkPair = {
type CacheControlMetadata = {
revalidate: number;
expire?: number;
stale?: number;
};

function resolveContainedFile(rootDir: string, relativePath: string): string {
Expand All @@ -55,13 +56,16 @@ function buildCacheEntry(
now: number,
revalidateSeconds: number | undefined,
expireSeconds: number | undefined,
staleSeconds: number | undefined,
): string {
const cacheControl: CacheControlMetadata | undefined =
revalidateSeconds === undefined
? undefined
: expireSeconds === undefined
? { revalidate: revalidateSeconds }
: { revalidate: revalidateSeconds, expire: expireSeconds };
: {
revalidate: revalidateSeconds,
...(expireSeconds === undefined ? {} : { expire: expireSeconds }),
...(staleSeconds === undefined ? {} : { stale: staleSeconds }),
};

return JSON.stringify({
value,
Expand Down Expand Up @@ -123,6 +127,8 @@ export function buildPrerenderKVPairs(
if (typeof route.revalidate === "number" && route.revalidate <= 0) continue;
const revalidateSeconds = typeof route.revalidate === "number" ? route.revalidate : undefined;
const expireSeconds = typeof route.expire === "number" ? route.expire : undefined;
const staleSeconds =
typeof route.stale === "number" && route.stale >= 0 ? route.stale : undefined;
const expirationTtl = revalidateSeconds === undefined ? undefined : ttlSeconds;
const tags = buildAppPageCacheTags(cachePathname, route.tags ?? []);
const metadata = buildMetadata(tags);
Expand All @@ -141,6 +147,7 @@ export function buildPrerenderKVPairs(
now,
revalidateSeconds,
expireSeconds,
staleSeconds,
),
...(expirationTtl !== undefined ? { expiration_ttl: expirationTtl } : {}),
...(metadata ? { metadata } : {}),
Expand All @@ -160,6 +167,7 @@ export function buildPrerenderKVPairs(
now,
revalidateSeconds,
expireSeconds,
staleSeconds,
),
...(expirationTtl !== undefined ? { expiration_ttl: expirationTtl } : {}),
...(metadata ? { metadata } : {}),
Expand Down
34 changes: 27 additions & 7 deletions packages/vinext/src/build/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
} from "./prerender-server-pool.js";
import { readPrerenderSecret } from "./server-manifest.js";
import { getOutputPath, getRscOutputPath } from "../utils/prerender-output-paths.js";
import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.js";
import type { MetadataFileRoute } from "../server/metadata-routes.js";
import {
createAppPprFallbackShells,
Expand Down Expand Up @@ -144,6 +145,8 @@ export type PrerenderRouteResult =
outputFiles: string[];
revalidate: number | false;
expire?: number;
/** Client-router reuse bound resolved from the prerender's `cacheLife`. */
stale?: number;
/**
* The concrete prerendered URL path, e.g. `/blog/hello-world`.
* Only present when the route is dynamic and `path` differs from `route`.
Expand Down Expand Up @@ -1571,6 +1574,9 @@ export async function prerenderApp({
: Math.min(revalidate, renderedCacheControl.revalidate)
: (renderedCacheControl.revalidate ?? revalidate);

// Seed the same unclamped claim the runtime write path persists.
const renderedStale = resolveClientStaleTimeSeconds(htmlRender.requestCacheLife);

return {
route: routePattern,
status: "rendered",
Expand All @@ -1579,6 +1585,7 @@ export async function prerenderApp({
...(typeof renderedRevalidate === "number"
? { expire: renderedCacheControl.expire }
: {}),
...(renderedStale === undefined ? {} : { stale: renderedStale }),
Comment thread
NathanDrake2406 marked this conversation as resolved.
router: "app",
...(htmlRender.tags.length > 0 ? { tags: htmlRender.tags } : {}),
...(htmlRender.linkHeader ? { headers: { link: htmlRender.linkHeader } } : {}),
Expand Down Expand Up @@ -1686,8 +1693,11 @@ export async function prerenderApp({
}
}

/** Cache life recovered from a prerendered response; `stale` seeds the ISR entry. */
type PrerenderCacheLife = { expire?: number; revalidate?: number; stale?: number };

function resolveRenderedCacheControl(
requestCacheLife: { expire?: number; revalidate?: number },
requestCacheLife: PrerenderCacheLife,
cacheControl: string,
fallbackExpireSeconds: number,
): { expire: number; revalidate?: number } {
Expand All @@ -1707,22 +1717,31 @@ function resolveRenderedCacheControl(
};
}

function readPrerenderCacheLifeHeader(
headers: Headers,
): { expire?: number; revalidate?: number } | null {
function readPrerenderCacheLifeHeader(headers: Headers): PrerenderCacheLife | null {
const value = headers.get(VINEXT_PRERENDER_CACHE_LIFE_HEADER);
if (!value) return null;

try {
const parsed = JSON.parse(value) as { expire?: unknown; revalidate?: unknown };
const cacheLife: { expire?: number; revalidate?: number } = {};
const parsed = JSON.parse(value) as {
expire?: unknown;
revalidate?: unknown;
stale?: unknown;
};
const cacheLife: PrerenderCacheLife = {};
if (typeof parsed.revalidate === "number" && Number.isFinite(parsed.revalidate)) {
cacheLife.revalidate = parsed.revalidate;
}
if (typeof parsed.expire === "number" && Number.isFinite(parsed.expire)) {
cacheLife.expire = parsed.expire;
}
return cacheLife.revalidate === undefined && cacheLife.expire === undefined ? null : cacheLife;
if (typeof parsed.stale === "number" && Number.isFinite(parsed.stale) && parsed.stale >= 0) {
cacheLife.stale = parsed.stale;
}
return cacheLife.revalidate === undefined &&
cacheLife.expire === undefined &&
cacheLife.stale === undefined
? null
: cacheLife;
} catch {
return null;
}
Expand Down Expand Up @@ -1785,6 +1804,7 @@ export function writePrerenderIndex(
status: r.status,
revalidate: r.revalidate,
...(typeof r.revalidate === "number" ? { expire: r.expire } : {}),
...(typeof r.stale === "number" ? { stale: r.stale } : {}),
router: r.router,
...(r.tags && r.tags.length > 0 ? { tags: r.tags } : {}),
...(r.headers ? { headers: r.headers } : {}),
Expand Down
19 changes: 14 additions & 5 deletions packages/vinext/src/client/navigation-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export type NavigationRuntimeRscBootstrap = {
nav?: NavigationRuntimeSnapshot;
params?: Record<string, string | string[]>;
rsc: NavigationRuntimeRscChunk[];
/**
* Client reuse bound in seconds resolved from the initial render's completed
* `cacheLife` — the done-script sibling of `dynamicStaleTimeSeconds`, which
* carries the `experimental.staleTimes` config value instead.
*/
staleTimeSeconds?: number;
};

type NavigationRuntimeKind = "navigate" | "traverse" | "refresh";
Expand Down Expand Up @@ -170,25 +176,28 @@ function isNavigationRuntimeRscBootstrap(value: unknown): value is NavigationRun
const nav = Reflect.get(value, "nav");
const params = Reflect.get(value, "params");
const rsc = Reflect.get(value, "rsc");
const staleTimeSeconds = Reflect.get(value, "staleTimeSeconds");
// getNavigationRuntime() runs at bootstrap/read boundaries, not per chunk.
// Keep full validation here so malformed ambient state is rejected before
// hydration consumes it instead of caching a stale validation result.
return (
(done === undefined || typeof done === "boolean") &&
(dynamicStaleTimeSeconds === undefined ||
(typeof dynamicStaleTimeSeconds === "number" &&
Number.isFinite(dynamicStaleTimeSeconds) &&
dynamicStaleTimeSeconds >= 0)) &&
isOptionalStaleTimeSeconds(dynamicStaleTimeSeconds) &&
(initialCacheKind === undefined ||
initialCacheKind === "dynamic" ||
initialCacheKind === "static") &&
(nav === undefined || isNavigationRuntimeSnapshot(nav)) &&
(params === undefined || isNavigationRuntimeParams(params)) &&
Array.isArray(rsc) &&
rsc.every(isNavigationRuntimeRscChunk)
rsc.every(isNavigationRuntimeRscChunk) &&
isOptionalStaleTimeSeconds(staleTimeSeconds)
);
}

function isOptionalStaleTimeSeconds(value: unknown): boolean {
return value === undefined || (typeof value === "number" && Number.isFinite(value) && value >= 0);
}

function isReadonlyStringArray(value: unknown): value is readonly string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
}
Expand Down
3 changes: 2 additions & 1 deletion packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ import {
appIsrRouteKey as __isrRouteKey,
isrGet as __isrGet,
isrSet as __isrSet,
isrSetAppPage as __isrSetAppPage,
isrSetPrerenderedAppPage as __isrSetPrerenderedAppPage,
isOnDemandRevalidateRequest as __isOnDemandRevalidateRequest,
triggerBackgroundRegeneration as __triggerBackgroundRegeneration,
Expand Down Expand Up @@ -869,7 +870,7 @@ export default createAppRscHandler({
isrGet: __isrGet,
isrHtmlKey: __isrHtmlKey,
isrRscKey: __isrRscKey,
isrSet: __isrSet,
isrSet: __isrSetAppPage,
loadSsrHandler() {
return import.meta.viteRsc.loadModule("ssr", "index");
},
Expand Down
5 changes: 5 additions & 0 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1535,6 +1535,11 @@ function bootstrapHydration(
...(initialRscBootstrap?.dynamicStaleTimeSeconds !== undefined
? { dynamicStaleTimeSeconds: initialRscBootstrap.dynamicStaleTimeSeconds }
: {}),
// The done-script's completed-render cacheLife claim; bounds this
// visited-response entry exactly like the header does for RSC replies.
...(initialRscBootstrap?.staleTimeSeconds !== undefined
? { staleTimeSeconds: initialRscBootstrap.staleTimeSeconds }
: {}),
mountedSlotsHeader,
paramsHeader: encodeURIComponent(JSON.stringify(initialParams)),
renderedPathAndSearch: null,
Expand Down
48 changes: 20 additions & 28 deletions packages/vinext/src/server/app-page-cache-finalizer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { CachedAppPageValue } from "vinext/shims/cache";
import type { AppRscRenderMode } from "./app-rsc-render-mode.js";
import { applyCdnResponseHeaders } from "./cache-control.js";
import { setCacheStateHeaders } from "./cache-headers.js";
Expand All @@ -7,18 +6,12 @@ import {
createEmptyAppPageRenderObservationState,
type AppPageRenderObservationState,
} from "./app-page-render-observation.js";
import { buildAppPageCacheValue } from "./isr-cache.js";
import { buildAppPageCacheValue, type AppPageCacheSetter } from "./isr-cache.js";
import type { RenderObservation } from "./cache-proof.js";
import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.js";
import { readStreamAsText } from "../utils/text-stream.js";

type AppPageDebugLogger = (event: string, detail: string) => void;
type AppPageCacheSetter = (
key: string,
data: CachedAppPageValue,
revalidateSeconds: number,
tags: string[],
expireSeconds?: number,
) => Promise<void>;
type AppPageRscCacheKeyBuilder = (
pathname: string,
mountedSlotsHeader?: string | null,
Expand All @@ -28,6 +21,7 @@ type AppPageRscCacheKeyBuilder = (
type AppPageRequestCacheLife = {
revalidate?: number;
expire?: number;
stale?: number;
};
type BuildAppPageCacheRenderObservation = (input: {
cacheTags: readonly string[];
Expand Down Expand Up @@ -97,7 +91,7 @@ function resolveAppPageCacheWritePolicy(options: {
expireSeconds?: number;
requestCacheLife?: AppPageRequestCacheLife | null;
revalidateSeconds: number | null;
}): { expireSeconds?: number; revalidateSeconds: number } | null {
}): { expireSeconds?: number; revalidateSeconds: number; staleSeconds?: number } | null {
let revalidateSeconds = options.revalidateSeconds;
let expireSeconds = options.expireSeconds;
const requestCacheLife = options.requestCacheLife;
Expand All @@ -116,7 +110,13 @@ function resolveAppPageCacheWritePolicy(options: {
return null;
}

return { expireSeconds, revalidateSeconds };
// Callers reach this only after the render's stream drained, so the
// request-scoped accumulation is the completed render's minimum.
return {
expireSeconds,
revalidateSeconds,
staleSeconds: resolveClientStaleTimeSeconds(requestCacheLife),
};
}

export function finalizeAppPageHtmlCacheResponse(
Expand Down Expand Up @@ -186,22 +186,17 @@ export function finalizeAppPageHtmlCacheResponse(
htmlRenderObservation,
linkHeader ? { link: linkHeader } : undefined,
),
cachePolicy.revalidateSeconds,
pageTags,
cachePolicy.expireSeconds,
{ ...cachePolicy, tags: pageTags },
),
];

if (options.capturedRscDataPromise) {
writes.push(
options.capturedRscDataPromise.then((rscData) =>
options.isrSet(
rscKey,
buildAppPageCacheValue("", rscData, 200, rscRenderObservation),
cachePolicy.revalidateSeconds,
pageTags,
cachePolicy.expireSeconds,
),
options.isrSet(rscKey, buildAppPageCacheValue("", rscData, 200, rscRenderObservation), {
...cachePolicy,
tags: pageTags,
}),
),
);
}
Expand Down Expand Up @@ -287,13 +282,10 @@ export function scheduleAppPageRscCacheWrite(
cacheTags: pageTags,
state: observationState,
});
await options.isrSet(
rscKey,
buildAppPageCacheValue("", rscData, 200, rscRenderObservation),
cachePolicy.revalidateSeconds,
pageTags,
cachePolicy.expireSeconds,
);
await options.isrSet(rscKey, buildAppPageCacheValue("", rscData, 200, rscRenderObservation), {
...cachePolicy,
tags: pageTags,
});
options.isrDebug?.("RSC cache written", rscKey);
} catch (cacheError) {
console.error("[vinext] ISR RSC cache write error:", cacheError);
Expand Down
6 changes: 5 additions & 1 deletion packages/vinext/src/server/app-page-cache-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,11 @@ export async function renderAppPageCacheArtifacts(
tags,
cacheControl:
typeof cacheLife?.revalidate === "number"
? { revalidate: cacheLife.revalidate, expire: cacheLife.expire }
? // `stale` must survive regeneration: this producer feeds
// resolveRegeneratedAppPageCachePolicy, and dropping it here would
// widen client reuse back to the configured fallback after the first
// background regen.
{ revalidate: cacheLife.revalidate, expire: cacheLife.expire, stale: cacheLife.stale }
: undefined,
};

Expand Down
Loading
Loading