Skip to content
Merged
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
28 changes: 9 additions & 19 deletions packages/vinext/src/server/app-page-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
VINEXT_RSC_VARY_HEADER,
applyRscCompatibilityIdHeader,
} from "./app-rsc-cache-busting.js";
import { applyCdnResponseHeaders, buildCachedRevalidateCacheControl } from "./cache-control.js";
import { applyCdnResponseHeaders } from "./cache-control.js";
import { decideIsr } from "./isr-decision.js";
import { VINEXT_MOUNTED_SLOTS_HEADER } from "./headers.js";
import { applyEdgeRuntimeHeader } from "./app-page-response.js";
import { setCacheStateHeaders } from "./cache-headers.js";
Expand Down Expand Up @@ -200,14 +201,6 @@ export function buildAppPageCacheTags(pathname: string, extraTags: readonly stri
return tags.map(encodeCacheTag);
}

function buildAppPageCacheControl(
cacheState: BuildAppPageCachedResponseOptions["cacheState"],
revalidateSeconds: number,
expireSeconds?: number,
): string {
return buildCachedRevalidateCacheControl(cacheState, revalidateSeconds, expireSeconds);
}

function buildAppPageCachedHeaders(options: {
cacheControl: string;
cacheState: BuildAppPageCachedResponseOptions["cacheState"];
Expand Down Expand Up @@ -278,16 +271,13 @@ export function buildAppPageCachedResponse(
// Preserve the legacy fallback semantics from the generated entry: invalid
// falsy statuses still fall back to 200 rather than being forwarded through.
const status = options.middlewareStatus ?? (cachedValue.status || 200);
const revalidateSeconds = options.cacheControl?.revalidate ?? options.revalidateSeconds;
const expireSeconds =
options.cacheControl === undefined
? undefined
: (options.cacheControl.expire ?? options.expireSeconds);
const cacheControl = buildAppPageCacheControl(
options.cacheState,
revalidateSeconds,
expireSeconds,
);
const { cacheControl } = decideIsr({
cacheState: options.cacheState,
kind: "app-page",
revalidateSeconds: options.revalidateSeconds,
expireSeconds: options.expireSeconds,
cacheControlMeta: options.cacheControl,
});
if (options.isRscRequest) {
if (!cachedValue.rscData) {
return null;
Expand Down
53 changes: 13 additions & 40 deletions packages/vinext/src/server/app-route-handler-response.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import type { CachedRouteValue, CacheControlMetadata } from "vinext/shims/cache";
import {
applyCdnResponseHeaders,
buildCachedRevalidateCacheControl,
NEVER_CACHE_CONTROL,
STATIC_CACHE_CONTROL,
} from "./cache-control.js";
import { applyCdnResponseHeaders } from "./cache-control.js";
import { decideIsr, buildAppRouteMissIsrCacheControl } from "./isr-decision.js";
import {
MIDDLEWARE_HEADER_PREFIX,
MIDDLEWARE_NEXT_HEADER,
Expand Down Expand Up @@ -48,28 +44,6 @@ function hasMiddlewareHeader(headers: Headers): boolean {
return false;
}

function buildRouteHandlerCacheControl(
cacheState: BuildRouteHandlerCachedResponseOptions["cacheState"],
revalidateSeconds: number,
expireSeconds?: number,
): string {
if (revalidateSeconds === 0) {
// A cached response is never produced for revalidate = 0 (the ISR write
// path skips it), so only the HIT/STALE->fresh rewrite can arrive here
// with a 0 value, via applyRouteHandlerRevalidateHeader. In all such
// cases the author opted out of caching entirely.
return NEVER_CACHE_CONTROL;
}

if (revalidateSeconds === Infinity) {
// revalidate = false / Infinity means "cache indefinitely" — emit the
// same static Cache-Control used by pages, not a dynamic SWR value.
return STATIC_CACHE_CONTROL;
}

return buildCachedRevalidateCacheControl(cacheState, revalidateSeconds, expireSeconds);
}

export function applyRouteHandlerMiddlewareContext(
response: Response,
middlewareContext: RouteHandlerMiddlewareContext,
Expand Down Expand Up @@ -115,21 +89,17 @@ export function buildRouteHandlerCachedResponse(
}
}
setCacheStateHeaders(headers, options.cacheState);
const revalidateSeconds = options.cacheControl?.revalidate ?? options.revalidateSeconds;
const expireSeconds =
options.cacheControl === undefined
? undefined
: (options.cacheControl.expire ?? options.expireSeconds);
// HIT/STALE served from the origin store: route the cache header through the
// CDN adapter (default: identical single Cache-Control). Edge adapters never
// reach this path because their get() returns null.
applyCdnResponseHeaders(headers, {
cacheControl: buildRouteHandlerCacheControl(
options.cacheState,
revalidateSeconds,
expireSeconds,
),
const { cacheControl } = decideIsr({
cacheState: options.cacheState,
kind: "app-route",
revalidateSeconds: options.revalidateSeconds,
expireSeconds: options.expireSeconds,
cacheControlMeta: options.cacheControl,
});
applyCdnResponseHeaders(headers, { cacheControl });

return new Response(options.isHead ? null : cachedValue.body, {
status: cachedValue.status,
Expand All @@ -145,8 +115,11 @@ export function applyRouteHandlerRevalidateHeader(
): void {
// Fresh (MISS) response: route through the CDN adapter so edge adapters emit
// CDN-Cache-Control + Cache-Tag while the default emits a single Cache-Control.
// Uses buildAppRouteMissIsrCacheControl so the revalidate=0→NEVER and
// Infinity→STATIC gates apply, and expireSeconds is used as the direct route
// config ceiling (not a per-entry metadata fallback).
applyCdnResponseHeaders(response.headers, {
cacheControl: buildRouteHandlerCacheControl("HIT", revalidateSeconds, expireSeconds),
cacheControl: buildAppRouteMissIsrCacheControl(revalidateSeconds, expireSeconds),
tags,
});
}
Expand Down
31 changes: 24 additions & 7 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import type { ModuleImporter } from "./instrumentation.js";
import { importModule, reportRequestError } from "./instrumentation.js";
import type { NextI18nConfig } from "../config/next-config.js";
import { buildCacheStateHeaders } from "./cache-headers.js";
import {
decideIsr,
buildMissIsrCacheControl,
ISR_NEVER_CACHE_CONTROL,
ISR_NO_STORE_CACHE_CONTROL,
} from "./isr-decision.js";
import {
isrGet,
isrSet,
Expand Down Expand Up @@ -833,8 +839,7 @@ export function createSSRHandler(
(k) => k.toLowerCase() === "cache-control",
);
if (!hasUserCacheControl) {
gsspExtraHeaders["Cache-Control"] =
"private, no-cache, no-store, max-age=0, must-revalidate";
gsspExtraHeaders["Cache-Control"] = ISR_NEVER_CACHE_CONTROL;
}
}
// Collect font preloads early so ISR cached responses can include
Expand Down Expand Up @@ -894,10 +899,15 @@ export function createSSRHandler(
const cachedHtml = cachedPage.html;
const transformedHtml = await server.transformIndexHtml(url, cachedHtml);
const revalidateSecs = getRevalidateDuration(cacheKey) ?? 60;
const { cacheControl: hitCacheControl } = decideIsr({
cacheState: "HIT",
kind: "dev",
revalidateSeconds: revalidateSecs,
});
const hitHeaders: Record<string, string> = {
"Content-Type": "text/html",
...buildCacheStateHeaders("HIT"),
"Cache-Control": `s-maxage=${revalidateSecs}, stale-while-revalidate`,
"Cache-Control": hitCacheControl,
};
if (earlyFontLinkHeader) hitHeaders["Link"] = earlyFontLinkHeader;
res.writeHead(200, hitHeaders);
Expand Down Expand Up @@ -1058,10 +1068,18 @@ export function createSSRHandler(
);

const revalidateSecs = getRevalidateDuration(cacheKey) ?? 60;
// Deliberate parity fix: dev STALE now emits s-maxage=0, stale-while-revalidate
// matching prod Pages Router and the canonical buildCachedRevalidateCacheControl
// helper. Previously emitted s-maxage=<secs> which was a dev/prod divergence.
const { cacheControl: staleCacheControl } = decideIsr({
cacheState: "STALE",
kind: "dev",
revalidateSeconds: revalidateSecs,
});
const staleHeaders: Record<string, string> = {
"Content-Type": "text/html",
...buildCacheStateHeaders("STALE"),
"Cache-Control": `s-maxage=${revalidateSecs}, stale-while-revalidate`,
"Cache-Control": staleCacheControl,
};
if (earlyFontLinkHeader) staleHeaders["Link"] = earlyFontLinkHeader;
res.writeHead(200, staleHeaders);
Expand Down Expand Up @@ -1378,10 +1396,9 @@ hydrate();
};
if (isrRevalidateSeconds) {
if (scriptNonce) {
extraHeaders["Cache-Control"] = "no-store, must-revalidate";
extraHeaders["Cache-Control"] = ISR_NO_STORE_CACHE_CONTROL;
} else {
extraHeaders["Cache-Control"] =
`s-maxage=${isrRevalidateSeconds}, stale-while-revalidate`;
extraHeaders["Cache-Control"] = buildMissIsrCacheControl(isrRevalidateSeconds);
Object.assign(extraHeaders, buildCacheStateHeaders("MISS"));
}
}
Expand Down
183 changes: 183 additions & 0 deletions packages/vinext/src/server/isr-decision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* Centralised ISR `Cache-Control` derivation module.
*
* `decideIsr` is the single place that maps (router kind, cache state,
* revalidate/expire metadata) → the exact `Cache-Control` string to stamp on
* an ISR response. Every ISR code path (app-page, app-route, pages,
* dev-server) routes through it.
*
* `disposition` and `scheduleRegeneration` are informational fields for

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The docstring now honestly labels these as informational, which resolves the earlier concern. But disposition and scheduleRegeneration are still returned by decideIsr and read by no caller anywhere in the repo (verified with a repo-wide grep — only the test references .disposition/.scheduleRegeneration). Since YAGNI-style dead return surface tends to rot, consider either dropping them now and returning just { cacheControl }, or keeping them only if there's a concrete near-term consumer. Not blocking — the prose is accurate as-is.

* callers that want them; all current callers only read `cacheControl`.
*/

import type { CacheControlMetadata } from "vinext/shims/cache";
import {
buildCachedRevalidateCacheControl,
buildRevalidateCacheControl,
NEVER_CACHE_CONTROL,
NO_STORE_CACHE_CONTROL,
STATIC_CACHE_CONTROL,
} from "./cache-control.js";

type IsrDisposition = "HIT" | "STALE" | "MISS";

export type IsrDecision = {
disposition: IsrDisposition;
/** True when the caller must schedule a background regeneration. */
scheduleRegeneration: boolean;
/** The `Cache-Control` string to stamp on the response. */
cacheControl: string;
};

/**
* Per-router special-case policies for `Cache-Control`.
*
* - `"app-page"` / `"pages"`: `buildCachedRevalidateCacheControl` for HIT/STALE.
* - `"app-route"`: same, but `revalidateSeconds=0` forces `NEVER_CACHE_CONTROL`
* and `revalidateSeconds=Infinity` forces `STATIC_CACHE_CONTROL`.
* - `"dev"`: like `"pages"`, but `revalidate=0`/`Infinity` guards are absent
* (dev never caches when revalidate=0 and never has Infinity entries in practice).
*/
type IsrPolicyKind = "app-page" | "app-route" | "pages" | "dev";

type DecideIsrOptions = {
/**
* The cache state. Content guards (kind-mismatch, empty body,
* query-variant-unproven) must have already passed before passing
* `"HIT"` or `"STALE"` here.
*/
cacheState: "HIT" | "STALE" | "MISS";
/** Which router is making the decision. */
kind: IsrPolicyKind;
/**
* The route's configured revalidate window in seconds. Used as the fallback
* when `cacheControlMeta` is absent.
*
* For `"dev"` call sites this is the only source of the revalidate value —
* dev never has metadata attached to a cache entry.
*/
revalidateSeconds: number;
/**
* The expire ceiling (seconds from epoch) read from the route config.
* Absent when the route pre-dates expire metadata support.
*/
expireSeconds?: number;
/**
* Optional per-entry metadata written alongside the cache value.
* When present its `revalidate`/`expire` fields override the route defaults,
* exactly as the call sites do today with `cacheControl?.revalidate ?? revalidateSeconds`.
*/
cacheControlMeta?: CacheControlMetadata;
};

/** Resolve effective revalidate/expire, preferring per-entry metadata. */
function resolveRevalidate(options: DecideIsrOptions): {
effectiveRevalidate: number;
effectiveExpire: number | undefined;
} {
const effectiveRevalidate = options.cacheControlMeta?.revalidate ?? options.revalidateSeconds;
// `expireSeconds` is the route-level config fallback. It is only meaningful
// when per-entry metadata is present — it acts as the fallback for entries
// written before expire support was added. When `cacheControlMeta` is absent
// entirely, the expire ceiling is unknown (undefined), matching the
// original per-call-site logic:
//
// const expire = options.cacheControl === undefined
// ? undefined
// : (options.cacheControl.expire ?? options.expireSeconds);
const effectiveExpire =
options.cacheControlMeta === undefined
? undefined
: (options.cacheControlMeta.expire ?? options.expireSeconds);
return { effectiveRevalidate, effectiveExpire };
}

function buildCacheControl(
disposition: "HIT" | "STALE",
kind: IsrPolicyKind,
revalidate: number,
expire: number | undefined,
): string {
if (kind === "app-route") {
if (revalidate === 0) return NEVER_CACHE_CONTROL;
if (revalidate === Infinity) return STATIC_CACHE_CONTROL;
}
return buildCachedRevalidateCacheControl(disposition, revalidate, expire);
}

/**
* Derive the `Cache-Control` string for an ISR response.
*
* Content guards (kind mismatch, query-variant-unproven, empty body) are the
* caller's responsibility and must happen *before* this call. `cacheState`
* must only be `"HIT"` or `"STALE"` when those guards have already passed.
*/
export function decideIsr(options: DecideIsrOptions): IsrDecision {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: resolveRevalidate(options) is called identically in both the HIT and STALE branches. You could hoist it above the if (!options.isStale) to compute it once and remove the duplication (the MISS early-return already short-circuits before either branch). Not a correctness issue.

if (options.cacheState === "MISS") {
return { disposition: "MISS", scheduleRegeneration: false, cacheControl: "" };
}

const { effectiveRevalidate, effectiveExpire } = resolveRevalidate(options);

if (options.cacheState === "HIT") {
return {
disposition: "HIT",
scheduleRegeneration: false,
cacheControl: buildCacheControl("HIT", options.kind, effectiveRevalidate, effectiveExpire),
};
}

// STALE: serve + schedule regen.
return {
disposition: "STALE",
scheduleRegeneration: true,
cacheControl: buildCacheControl("STALE", options.kind, effectiveRevalidate, effectiveExpire),
};
}

/**
* Build the `Cache-Control` string for a fresh MISS response whose ISR policy
* is known (i.e. revalidate is set and > 0). Uses the unbounded SWR form when
* no expire ceiling is available, exactly as `buildRevalidateCacheControl` does.
*
* Separate from `decideIsr` because a MISS doesn't read a cache entry and
* therefore never has `cacheControlMeta`. `expireSeconds` here is the route
* config ceiling passed directly from the caller (not a per-entry fallback).
*/
export function buildMissIsrCacheControl(
revalidateSeconds: number,
expireSeconds?: number,
): string {
return buildRevalidateCacheControl(revalidateSeconds, expireSeconds);
}

/**
* Build the `Cache-Control` string for a fresh (MISS) app-route response.
*
* Applies the same `revalidateSeconds=0`→NEVER and `Infinity`→STATIC gates
* that `decideIsr` uses for app-route cached responses. `expireSeconds` is
* the route config ceiling passed directly (not per-entry metadata fallback).
*
* Used by `applyRouteHandlerRevalidateHeader` which operates on a fresh
* response that has no per-entry cache metadata.
*/
export function buildAppRouteMissIsrCacheControl(
revalidateSeconds: number,
expireSeconds?: number,
): string {
if (revalidateSeconds === 0) return NEVER_CACHE_CONTROL;
if (revalidateSeconds === Infinity) return STATIC_CACHE_CONTROL;
return buildRevalidateCacheControl(revalidateSeconds, expireSeconds);
}

/**
* The `Cache-Control` for a response that must never be cached (getServerSideProps
* default, on-demand revalidation, nonce-bearing pages). Matches `NEVER_CACHE_CONTROL`.
*/
export { NEVER_CACHE_CONTROL as ISR_NEVER_CACHE_CONTROL };

/**
* The `Cache-Control` for a nonce-bearing ISR response (the page has a
* script nonce, so it must not enter any shared cache). Matches `NO_STORE_CACHE_CONTROL`.
*/
export { NO_STORE_CACHE_CONTROL as ISR_NO_STORE_CACHE_CONTROL };
Loading
Loading