Skip to content

Commit 97c154a

Browse files
fix(isr): honor route expire ceilings
Track expireAt alongside revalidateAt in the memory and KV cache handlers so ISR entries past their expire ceiling become blocking misses instead of stale responses. Plumb expireTime and request cacheLife expire values through App Router, Pages Router, prerender seeding, and cache writes while keeping generated entries as thin app-shape wiring over normal server modules. Match Next.js cache-control semantics for finite stale-while-revalidate windows when an expire value is known.
1 parent dd1dfae commit 97c154a

35 files changed

Lines changed: 874 additions & 242 deletions

packages/vinext/src/build/prerender.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export type PrerenderRouteResult =
4545
status: "rendered";
4646
outputFiles: string[];
4747
revalidate: number | false;
48+
expire?: number;
4849
/**
4950
* The concrete prerendered URL path, e.g. `/blog/hello-world`.
5051
* Only present when the route is dynamic and `path` differs from `route`.
@@ -591,6 +592,7 @@ export async function prerenderPages({
591592
status: "rendered",
592593
outputFiles,
593594
revalidate,
595+
...(typeof revalidate === "number" ? { expire: config.expireTime } : {}),
594596
router: "pages",
595597
...(urlPath !== route.pattern ? { path: urlPath } : {}),
596598
};
@@ -998,6 +1000,7 @@ export async function prerenderApp({
9981000
const htmlRes = await runWithHeadersContext(headersContextFromRequest(htmlRequest), () =>
9991001
rscHandler(htmlRequest),
10001002
);
1003+
const htmlCacheControl = htmlRes.headers.get("cache-control") ?? "";
10011004
if (!htmlRes.ok) {
10021005
if (isSpeculative) {
10031006
return { route: routePattern, status: "skipped", reason: "dynamic" };
@@ -1014,8 +1017,7 @@ export async function prerenderApp({
10141017
// render, the server sets Cache-Control: no-store. We treat this as a
10151018
// signal that the route is dynamic and should be skipped.
10161019
if (isSpeculative) {
1017-
const cacheControl = htmlRes.headers.get("cache-control") ?? "";
1018-
if (cacheControl.includes("no-store")) {
1020+
if (htmlCacheControl.includes("no-store")) {
10191021
await htmlRes.body?.cancel();
10201022
return { route: routePattern, status: "skipped", reason: "dynamic" };
10211023
}
@@ -1056,6 +1058,9 @@ export async function prerenderApp({
10561058
status: "rendered",
10571059
outputFiles,
10581060
revalidate,
1061+
...(typeof revalidate === "number"
1062+
? { expire: resolveRenderedExpireSeconds(htmlCacheControl, config.expireTime) }
1063+
: {}),
10591064
router: "app",
10601065
...(urlPath !== routePattern ? { path: urlPath } : {}),
10611066
};
@@ -1137,6 +1142,32 @@ export function getRscOutputPath(urlPath: string): string {
11371142
return urlPath.replace(/^\//, "") + ".rsc";
11381143
}
11391144

1145+
function resolveRenderedExpireSeconds(cacheControl: string, fallbackExpireSeconds: number): number {
1146+
const sMaxage = parseCacheControlSeconds(cacheControl, "s-maxage");
1147+
const staleWhileRevalidate = parseCacheControlSeconds(cacheControl, "stale-while-revalidate");
1148+
1149+
if (sMaxage === undefined || staleWhileRevalidate === undefined) {
1150+
return fallbackExpireSeconds;
1151+
}
1152+
1153+
return sMaxage + staleWhileRevalidate;
1154+
}
1155+
1156+
function parseCacheControlSeconds(cacheControl: string, directive: string): number | undefined {
1157+
for (const part of cacheControl.split(",")) {
1158+
const [rawName, rawValue] = part.trim().split("=", 2);
1159+
if (rawName.toLowerCase() !== directive) continue;
1160+
if (rawValue === undefined) return undefined;
1161+
1162+
const value = Number(rawValue.trim());
1163+
if (!Number.isFinite(value) || value < 0) return undefined;
1164+
1165+
return value;
1166+
}
1167+
1168+
return undefined;
1169+
}
1170+
11401171
// ─── Build index ──────────────────────────────────────────────────────────────
11411172

11421173
/**
@@ -1159,6 +1190,7 @@ export function writePrerenderIndex(
11591190
route: r.route,
11601191
status: r.status,
11611192
revalidate: r.revalidate,
1193+
...(typeof r.revalidate === "number" ? { expire: r.expire } : {}),
11621194
router: r.router,
11631195
...(r.path ? { path: r.path } : {}),
11641196
};

packages/vinext/src/cloudflare/kv-cache-handler.ts

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { Buffer } from "node:buffer";
3333
import type {
3434
CacheHandler,
3535
CacheHandlerValue,
36+
CacheControlMetadata,
3637
CachedAppPageValue,
3738
CachedRouteValue,
3839
CachedImageValue,
@@ -86,6 +87,10 @@ type KVCacheEntry = {
8687
lastModified: number;
8788
/** Absolute timestamp (ms) after which the entry is "stale" (but still served). */
8889
revalidateAt: number | null;
90+
/** Absolute timestamp (ms) after which the entry must block on fresh render. */
91+
expireAt?: number | null;
92+
/** Effective cache-control policy used for response headers. */
93+
cacheControl?: CacheControlMetadata;
8994
};
9095

9196
/** Key prefix for tag invalidation timestamps. */
@@ -124,6 +129,27 @@ function readStringArrayField(ctx: Record<string, unknown> | undefined, field: s
124129
return value.filter((item): item is string => typeof item === "string");
125130
}
126131

132+
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
133+
return value !== null && typeof value === "object" && !Array.isArray(value);
134+
}
135+
136+
function readRecordField(
137+
ctx: Record<string, unknown> | undefined,
138+
field: string,
139+
): Record<string, unknown> | undefined {
140+
const value = ctx?.[field];
141+
return isUnknownRecord(value) ? value : undefined;
142+
}
143+
144+
function readCacheControlNumberField(
145+
ctx: Record<string, unknown> | undefined,
146+
field: string,
147+
): number | undefined {
148+
const cacheControl = readRecordField(ctx, "cacheControl");
149+
const value = cacheControl?.[field] ?? ctx?.[field];
150+
return typeof value === "number" ? value : undefined;
151+
}
152+
127153
function validUniqueTags(tags: string[]): string[] {
128154
const result: string[] = [];
129155
const seen = new Set<string>();
@@ -220,18 +246,25 @@ export class KVCacheHandler implements CacheHandler {
220246
return null;
221247
}
222248

223-
// Check time-based expiry — return stale with cacheState
249+
if (entry.expireAt !== undefined && entry.expireAt !== null && Date.now() > entry.expireAt) {
250+
this._deleteInBackground(kvKey);
251+
return null;
252+
}
253+
254+
// Check time-based revalidation — return stale with cacheState
224255
if (entry.revalidateAt !== null && Date.now() > entry.revalidateAt) {
225256
return {
226257
lastModified: entry.lastModified,
227258
value: restoredValue,
228259
cacheState: "stale",
260+
cacheControl: entry.cacheControl,
229261
};
230262
}
231263

232264
return {
233265
lastModified: entry.lastModified,
234266
value: restoredValue,
267+
cacheControl: entry.cacheControl,
235268
};
236269
}
237270

@@ -311,31 +344,40 @@ export class KVCacheHandler implements CacheHandler {
311344
// Resolve effective revalidate — data overrides ctx.
312345
// revalidate: 0 means "don't cache", so skip storage entirely.
313346
let effectiveRevalidate: number | undefined;
314-
if (ctx) {
315-
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
316-
const revalidate = (ctx as any).cacheControl?.revalidate ?? (ctx as any).revalidate;
317-
if (typeof revalidate === "number") {
318-
effectiveRevalidate = revalidate;
319-
}
320-
}
347+
let effectiveExpire: number | undefined;
348+
effectiveRevalidate = readCacheControlNumberField(ctx, "revalidate");
349+
effectiveExpire = readCacheControlNumberField(ctx, "expire");
321350
if (data && "revalidate" in data && typeof data.revalidate === "number") {
322351
effectiveRevalidate = data.revalidate;
323352
}
324353
if (effectiveRevalidate === 0) return Promise.resolve();
325354

355+
const now = Date.now();
326356
const revalidateAt =
327357
typeof effectiveRevalidate === "number" && effectiveRevalidate > 0
328-
? Date.now() + effectiveRevalidate * 1000
358+
? now + effectiveRevalidate * 1000
359+
: null;
360+
const expireAt =
361+
typeof effectiveExpire === "number" && effectiveExpire > 0
362+
? now + effectiveExpire * 1000
329363
: null;
364+
const cacheControl =
365+
typeof effectiveRevalidate === "number"
366+
? effectiveExpire === undefined
367+
? { revalidate: effectiveRevalidate }
368+
: { revalidate: effectiveRevalidate, expire: effectiveExpire }
369+
: undefined;
330370

331371
// Prepare entry — convert ArrayBuffers to base64 for JSON storage
332372
const serializable = data ? serializeForJSON(data) : null;
333373

334374
const entry: KVCacheEntry = {
335375
value: serializable,
336376
tags,
337-
lastModified: Date.now(),
377+
lastModified: now,
338378
revalidateAt,
379+
expireAt,
380+
cacheControl,
339381
};
340382

341383
// KV TTL is decoupled from the revalidation period.
@@ -505,6 +547,16 @@ function validateCacheEntry(raw: unknown): KVCacheEntry | null {
505547
if (typeof obj.lastModified !== "number") return null;
506548
if (!Array.isArray(obj.tags)) return null;
507549
if (obj.revalidateAt !== null && typeof obj.revalidateAt !== "number") return null;
550+
if (obj.expireAt !== undefined && obj.expireAt !== null && typeof obj.expireAt !== "number") {
551+
return null;
552+
}
553+
if (obj.cacheControl !== undefined) {
554+
if (!isUnknownRecord(obj.cacheControl)) return null;
555+
if (typeof obj.cacheControl.revalidate !== "number") return null;
556+
if (obj.cacheControl.expire !== undefined && typeof obj.cacheControl.expire !== "number") {
557+
return null;
558+
}
559+
}
508560

509561
// value must be null or a valid cache value object with a known kind
510562
if (obj.value !== null) {

packages/vinext/src/config/next-config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ export type NextConfig = {
179179
pageExtensions?: string[];
180180
/** Extra origins allowed to access the dev server. */
181181
allowedDevOrigins?: string[];
182+
/** Maximum age in seconds for stale ISR entries before blocking regeneration. */
183+
expireTime?: number;
182184
/**
183185
* Enable Cache Components (Next.js 16).
184186
* When true, enables the "use cache" directive for pages, components, and functions.
@@ -242,6 +244,8 @@ export type ResolvedNextConfig = {
242244
optimizePackageImports: string[];
243245
/** Parsed body size limit for server actions in bytes (from experimental.serverActions.bodySizeLimit). Defaults to 1MB. */
244246
serverActionsBodySizeLimit: number;
247+
/** Route-level expire fallback in seconds for ISR entries with numeric revalidate. */
248+
expireTime: number;
245249
/**
246250
* Packages that should be treated as server-external (not bundled by Vite).
247251
* Sourced from `serverExternalPackages` or the legacy
@@ -253,6 +257,7 @@ export type ResolvedNextConfig = {
253257
};
254258

255259
const CONFIG_FILES = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.cjs"];
260+
const DEFAULT_EXPIRE_TIME = 31_536_000;
256261

257262
/**
258263
* Check whether an error indicates a CJS module was loaded in an ESM context
@@ -462,6 +467,7 @@ export async function resolveNextConfig(
462467
serverActionsAllowedOrigins: [],
463468
optimizePackageImports: [],
464469
serverActionsBodySizeLimit: 1 * 1024 * 1024,
470+
expireTime: DEFAULT_EXPIRE_TIME,
465471
serverExternalPackages: [],
466472
buildId,
467473
};
@@ -613,6 +619,7 @@ export async function resolveNextConfig(
613619
serverActionsAllowedOrigins,
614620
optimizePackageImports,
615621
serverActionsBodySizeLimit,
622+
expireTime: typeof config.expireTime === "number" ? config.expireTime : DEFAULT_EXPIRE_TIME,
616623
serverExternalPackages,
617624
buildId,
618625
};

packages/vinext/src/entries/app-rsc-entry.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ const routeTriePath = resolveEntryPath("../routing/route-trie.js", import.meta.u
8585
const metadataRoutesPath = resolveEntryPath("../server/metadata-routes.js", import.meta.url);
8686
const rootParamsShimPath = resolveEntryPath("../shims/root-params.js", import.meta.url);
8787
const errorCausePath = resolveEntryPath("../utils/error-cause.js", import.meta.url);
88+
const isrCachePath = resolveEntryPath("../server/isr-cache.js", import.meta.url);
8889

8990
/**
9091
* Resolved config options relevant to App Router request handling.
@@ -104,6 +105,8 @@ export type AppRouterConfig = {
104105
allowedDevOrigins?: string[];
105106
/** Body size limit for server actions in bytes (from experimental.serverActions.bodySizeLimit). */
106107
bodySizeLimit?: number;
108+
/** Maximum age in seconds for stale ISR entries before blocking regeneration. */
109+
expireTime?: number;
107110
/** Internationalization routing config for middleware matcher locale handling. */
108111
i18n?: NextI18nConfig | null;
109112
/**
@@ -144,6 +147,7 @@ export function generateRscEntry(
144147
const headers = config?.headers ?? [];
145148
const allowedOrigins = config?.allowedOrigins ?? [];
146149
const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024;
150+
const expireTime = config?.expireTime ?? 31_536_000;
147151
const i18nConfig = config?.i18n ?? null;
148152
const hasPagesDir = config?.hasPagesDir ?? false;
149153
const publicFiles = config?.publicFiles ?? [];
@@ -466,9 +470,10 @@ import {
466470
applyRouteHandlerMiddlewareContext as __applyRouteHandlerMiddlewareContext,
467471
} from ${JSON.stringify(appRouteHandlerResponsePath)};
468472
import { buildPageCacheTags } from ${JSON.stringify(implicitTagsPath)};
469-
import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache";
473+
import { _consumeRequestScopedCacheLife } from "next/cache";
470474
import { getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)};
471475
import { setRootParams as __setRootParams, pickRootParams as __pickRootParams } from ${JSON.stringify(rootParamsShimPath)};
476+
import { isrGet as __sharedIsrGet, isrSet as __sharedIsrSet } from ${JSON.stringify(isrCachePath)};
472477
import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags, setCurrentFetchSoftTags } from "vinext/fetch-cache";
473478
import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from ${JSON.stringify(routeTriePath)};
474479
// Import server-only state module to register ALS-backed accessors.
@@ -512,24 +517,17 @@ function __clearRequestContext() {
512517
// setNavigationContext(null) already clears root params internally
513518
}
514519
515-
// ISR cache is disabled in dev mode — every request re-renders fresh,
516-
// matching Next.js dev behavior. Cache-Control headers are still emitted
517-
// based on export const revalidate for testing purposes.
518-
// Production ISR uses the MemoryCacheHandler (or configured KV handler).
519-
//
520-
// These helpers are inlined instead of imported from isr-cache.js because
521-
// the virtual RSC entry module runs in the RSC Vite environment which
522-
// cannot use dynamic imports at the module-evaluation level for server-only
523-
// modules, and direct imports must use the pre-computed absolute paths.
524520
async function __isrGet(key) {
525-
const handler = getCacheHandler();
526-
const result = await handler.get(key);
527-
if (!result || !result.value) return null;
528-
return { value: result, isStale: result.cacheState === "stale" };
521+
return __sharedIsrGet(key);
529522
}
530-
async function __isrSet(key, data, revalidateSeconds, tags) {
531-
const handler = getCacheHandler();
532-
await handler.set(key, data, { revalidate: revalidateSeconds, tags: Array.isArray(tags) ? tags : [] });
523+
async function __isrSet(key, data, revalidateSeconds, tags, expireSeconds) {
524+
return __sharedIsrSet(
525+
key,
526+
data,
527+
revalidateSeconds,
528+
Array.isArray(tags) ? tags : [],
529+
expireSeconds
530+
);
533531
}
534532
// Note: cache entries are written with \`headers: undefined\`. Next.js stores
535533
// response headers (e.g. set-cookie from cookies().set() during render) in the
@@ -1118,6 +1116,7 @@ ${middlewarePath ? generateMiddlewareMatcherCode("modern") : ""}
11181116
const __basePath = ${JSON.stringify(bp)};
11191117
const __trailingSlash = ${JSON.stringify(ts)};
11201118
const __i18nConfig = ${JSON.stringify(i18nConfig)};
1119+
const __expireTime = ${JSON.stringify(expireTime)};
11211120
const __configRedirects = ${JSON.stringify(redirects)};
11221121
const __configRewrites = ${JSON.stringify(rewrites)};
11231122
const __configHeaders = ${JSON.stringify(headers)};
@@ -2146,6 +2145,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
21462145
params,
21472146
requestUrl: request.url,
21482147
revalidateSearchParams: url.searchParams,
2148+
expireSeconds: __expireTime,
21492149
revalidateSeconds,
21502150
routePattern: route.pattern,
21512151
runInRevalidationContext: async function(renderFn) {
@@ -2197,6 +2197,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
21972197
params: makeThenableParams(params),
21982198
reportRequestError: _reportRequestError,
21992199
request,
2200+
expireSeconds: __expireTime,
22002201
revalidateSeconds,
22012202
routePattern: route.pattern,
22022203
setHeadersAccessPhase,
@@ -2304,6 +2305,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
23042305
isrRscKey: __isrRscKey,
23052306
isrSet: __isrSet,
23062307
mountedSlotsHeader: __mountedSlotsHeader,
2308+
expireSeconds: __expireTime,
23072309
revalidateSeconds,
23082310
renderFreshPageForCache: async function() {
23092311
// Re-render the page to produce fresh HTML + RSC data for the cache
@@ -2523,6 +2525,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
25232525
isDynamicError,
25242526
isForceDynamic,
25252527
isForceStatic,
2528+
isPrerender: process.env.VINEXT_PRERENDER === "1",
25262529
isProduction: process.env.NODE_ENV === "production",
25272530
isRscRequest,
25282531
isrDebug: __isrDebug,
@@ -2574,6 +2577,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
25742577
}
25752578
},
25762579
},
2580+
expireSeconds: __expireTime,
25772581
revalidateSeconds,
25782582
mountedSlotsHeader: __mountedSlotsHeader,
25792583
renderErrorBoundaryResponse(renderErr) {

0 commit comments

Comments
 (0)