-
Notifications
You must be signed in to change notification settings - Fork 334
refactor(isr): centralise HIT/STALE/MISS + Cache-Control decision in isr-decision.ts #1883
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2dfb08a
26c2a23
c6dba24
072fe61
a571a1b
78c5a38
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| * 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: |
||
| 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 }; | ||
There was a problem hiding this comment.
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
dispositionandscheduleRegenerationare still returned bydecideIsrand 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.