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
13 changes: 13 additions & 0 deletions .changeset/cache-control-revalidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@opennextjs/core": patch
---

Derive revalidation from `Cache-Control` on cache entries

The cache handler function now parses the `Cache-Control` of an entry to decide whether it is fresh,
stale or expired, instead of relying on the revalidation timestamp alone. `s-maxage`,
`stale-while-revalidate` and `must-revalidate` are honoured, and the resulting state is carried back
to the caller in the response headers.

The `fetch` and `local` cache overrides also forward the cache type when writing an entry.
Incremental caches that key entries on the type were writing them where `get` does not look.
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,14 @@ describe("serviceCache", () => {
"x-opennext-cache-type": "cache",
"x-opennext-cache-sub-type": "route",
"x-opennext-cache-last-modified": "1234",
"x-opennext-cache-revalidate": "60",
},
})
);

await expect(serviceCache.get("key")).resolves.toEqual({
lastModified: 1234,
value: expect.objectContaining({ type: "route", body: "body" }),
value: expect.objectContaining({ type: "route", body: "body", revalidate: 60 }),
});
});
});
Expand Down
36 changes: 25 additions & 11 deletions packages/core/src/adapters/cache-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { StoredComposableCacheEntry } from "@/types/cache";
import type { InternalEvent, InternalResult } from "@/types/open-next";
import type {
CacheEntryType,
CachedFile,
CachedFetchValue,
CacheValue,
OpenNextHandlerOptions,
Expand All @@ -13,6 +12,7 @@ import type {

import { createGenericHandler } from "../core/createGenericHandler.js";
import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js";
import { computeEntryCacheControl } from "../utils/cache-control.js";
import { getTagsFromValue, isStale, writeTags } from "../utils/cache.js";
import { runWithOpenNextRequestContext } from "../utils/promise.js";
import { toReadableStream } from "../utils/stream.js";
Expand Down Expand Up @@ -130,9 +130,9 @@ async function handleGet(
};
}

// `getTagsFromValue` also strips the internal `x-next-cache-tags` header from the entry, so
// the tags are derived for every hit - including the ones bypassing the tag cache - or that
// header would be handed back to Next.js and echoed to the client.
// The tags are also used to make the response purgeable, so they are derived for every hit,
// including the ones bypassing the tag cache. `getTagsFromValue` additionally strips the
// internal `x-next-cache-tags` header, which would otherwise be echoed to the client.
let tags: string[] = [...additionalTags];

if (cacheType === "cache") {
Expand All @@ -145,6 +145,8 @@ async function handleGet(
tags = [...tags, ...(composableValue.tags ?? [])];
}

let isEntryStale = false;

if (!result.shouldBypassTagCache) {
const lastModified = result.lastModified ?? Date.now();

Expand All @@ -166,13 +168,15 @@ async function handleGet(
}

// Check if the cache entry is stale (valid but needs background revalidation)
const _isStale = tags.length > 0 ? await isStale(key, tags, lastModified) : false;
if (_isStale) {
isEntryStale = tags.length > 0 ? await isStale(key, tags, lastModified) : false;
if (isEntryStale) {
result.lastModified = 1;
}
}

return buildCacheGetResponse(result);
// We default to the entry key when no tag is found, so that page router based entries can also
// be purged this way.
return buildCacheGetResponse(result, isEntryStale, tags.length > 0 ? tags : [key]);
} catch (e) {
error("Failed to get cache entry", e);
return buildErrorResponse("Failed to get cache entry", 500);
Expand Down Expand Up @@ -409,14 +413,24 @@ async function handleRevalidateTags(body?: Buffer): Promise<InternalResult> {
// Cache GET response builder //
/////////////////////////////

function buildCacheGetResponse(result: WithLastModified<CacheValue<CacheEntryType>>): InternalResult {
function buildCacheGetResponse(
result: WithLastModified<CacheValue<CacheEntryType>>,
isStaleFromTagCache: boolean,
tags: string[]
): InternalResult {
const value = result.value!;

const headers: Record<string, string | string[]> = {
"x-opennext-cache-found": "true",
"Cache-Control": "no-store",
"Cache-Control": computeEntryCacheControl(value, result.lastModified, isStaleFromTagCache),
};

// The `Cache-Control` above lets an HTTP cache store this response, it can only be invalidated
// through a purge keyed on these tags. See `computeEntryCacheControl`.
if (tags.length > 0) {
headers["cache-tag"] = tags.join(",");
}

if (result.lastModified !== undefined) {
headers["x-opennext-cache-last-modified"] = String(result.lastModified);
}
Expand All @@ -425,11 +439,11 @@ function buildCacheGetResponse(result: WithLastModified<CacheValue<CacheEntryTyp
}

if ("kind" in value && value.kind === "FETCH") {
return buildFetchResponse(value as CachedFetchValue, headers);
return buildFetchResponse(value as CacheValue<"fetch">, headers);
}

if ("type" in value) {
return buildCachedFileResponse(value as CachedFile, headers);
return buildCachedFileResponse(value as CacheValue<"cache">, headers);
}

return buildComposableResponse(value as StoredComposableCacheEntry, headers);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/core/routing/cacheInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { NextConfig, PrerenderManifest } from "@/config/index";
import type { InternalEvent, InternalResult, MiddlewareEvent, PartialResult } from "@/types/open-next";
import type { CacheValue } from "@/types/overrides";
import { isBinaryContentType } from "@/utils/binary";
import { CACHE_ONE_YEAR } from "@/utils/cache-control";
import { emptyReadableStream, toReadableStream } from "@/utils/stream";

import { debug, error } from "../../adapters/logger";

import { localizePath } from "./i18n";
import { generateMessageGroupId } from "./queue";

const CACHE_ONE_YEAR = 60 * 60 * 24 * 365;
const CACHE_ONE_MONTH = 60 * 60 * 24 * 30;

/*
Expand Down
100 changes: 100 additions & 0 deletions packages/core/src/utils/cache-control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { StoredComposableCacheEntry } from "@/types/cache";
import type { CacheEntryType, CacheValue } from "@/types/overrides";

import { error } from "../adapters/logger";

export const CACHE_ONE_YEAR = 60 * 60 * 24 * 365;

const NO_STORE = "no-store";

/**
* Composable cache entries may carry `Infinity` (i.e. `cacheLife("max")`), and an entry that was
* just written has a negative age when `Date.now()` drifts, so every duration is clamped.
*/
function clampSeconds(seconds: number): number {
if (!Number.isFinite(seconds)) {
return CACHE_ONE_YEAR;
}
return Math.max(0, Math.min(Math.floor(seconds), CACHE_ONE_YEAR));
}

function buildCacheControl(sMaxAge: number, staleWhileRevalidate: number): string {
return `s-maxage=${clampSeconds(sMaxAge)}, stale-while-revalidate=${clampSeconds(staleWhileRevalidate)}`;
}

/**
* Computes the `Cache-Control` of a cache handler `GET` hit, so that an HTTP cache sitting in front
* of the cache handler function can serve reads without hitting the underlying store.
*
* A stale or expired entry is never stored: the next read has to reach the cache handler function so
* that the staleness is signaled to the server (through `lastModified = 1`).
*
* **This is only correct when the cached responses can be purged**, either with a
* `cdnInvalidationHandler` or through another purge mechanism keyed on the `cache-tag` header that
* `buildCacheGetResponse` emits. Tag revalidation cannot invalidate an intermediate cache on its own,
* so without purging `revalidateTag`/`revalidatePath` would be masked for as long as the entry is
* stored - up to a year for SSG entries.
*/
export function computeEntryCacheControl(
value: CacheValue<CacheEntryType>,
lastModified: number | undefined,
isStaleFromTagCache: boolean
): string {
if (isStaleFromTagCache) {
return NO_STORE;
}

// Same discrimination as `buildCacheGetResponse`: fetch entries have a `kind`, cached files have
// a `type`, composable entries have neither.
const isFetch = "kind" in value && value.kind === "FETCH";
const isCachedFile = "type" in value;

if (!isFetch && !isCachedFile) {
return computeComposableCacheControl(value as StoredComposableCacheEntry);
}

return computeRevalidateCacheControl(value.revalidate, lastModified);
}

function computeComposableCacheControl(value: StoredComposableCacheEntry): string {
const age = (Date.now() - value.timestamp) / 1000;

if (age >= value.expire || age >= value.revalidate) {
return NO_STORE;
}

// Composable entries are the only ones carrying an explicit `expire`, so they are also the only
// ones for which we can derive a real stale-while-revalidate window.
return buildCacheControl(value.revalidate - age, value.expire - value.revalidate);
}

function computeRevalidateCacheControl(
revalidate: number | false | undefined,
lastModified: number | undefined
): string {
if (revalidate === 0) {
return NO_STORE;
}

if (revalidate === undefined) {
// `revalidate` is written by the cache handler for every entry, we should always have one here.
error("Missing `revalidate` on a cache entry, assuming it is a static (SSG) entry");
}

if (revalidate === undefined || revalidate === false) {
return buildCacheControl(CACHE_ONE_YEAR, 0);
}
Comment on lines +79 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Pages built ahead of time are told to stay cached for a year instead of their own refresh interval

Pre-built page entries, which carry no refresh interval, are treated as never-changing and given a one-year lifetime (buildCacheControl(CACHE_ONE_YEAR, 0) at packages/core/src/utils/cache-control.ts:85) instead of the page's configured refresh interval, so incrementally-regenerated pages can stay frozen for a year in any cache sitting in front of the cache handler.
Impact: Pages that are supposed to refresh every few seconds/minutes may keep serving build-time content until manually purged, and an error is logged on every read of such a page.

Why build-time entries reach this branch without a revalidate value

The cache assets generated at build time (packages/core/src/build/createAssets.ts:184-197) write only { type, meta, html, json, rsc, body, segmentData } — there is no revalidate field. Those files are what globalThis.incrementalCache.get(key, "cache") returns until the page is regenerated at runtime (runtime writes do include revalidate, see packages/core/src/adapters/cache.ts:145-233).

So for every prerendered ISR page, computeEntryCacheControl hits the revalidate === undefined path: it logs error("Missing \revalidate` on a cache entry, ...") (packages/core/src/utils/cache-control.ts:81) and returns s-maxage=31536000`.

The pre-existing computeCacheControl in packages/core/src/core/routing/cacheInterceptor.ts:42-50 handles exactly this case by falling back to PrerenderManifest.routes[path].initialRevalidateSeconds when revalidate is undefined; the new util has no equivalent fallback.

Prompt for agents
computeEntryCacheControl in packages/core/src/utils/cache-control.ts assumes every cached-file entry carries a `revalidate` value and treats a missing one as a static (SSG) entry cached for a year, while also logging an error. That assumption does not hold for entries produced at build time: createCacheAssets (packages/core/src/build/createAssets.ts) writes cache files containing only type/meta/html/json/rsc/body/segmentData, with no `revalidate`. Those entries are served for every prerendered route until the first runtime regeneration, so ISR pages with e.g. `revalidate: 60` would be advertised as cacheable for a year (and would spam the error log on every read).

Possible approaches: derive the fallback the same way cacheInterceptor's computeCacheControl already does, by looking up PrerenderManifest.routes[path].initialRevalidateSeconds when `revalidate` is undefined (this requires the entry key/path to be threaded into the helper), or make the undefined case conservative (no-store / short s-maxage) instead of a one-year lifetime, and drop or downgrade the error log since the situation is expected for build-time entries.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const age = (Date.now() - (lastModified ?? Date.now())) / 1000;
const remainingTtl = revalidate - age;

if (remainingTtl <= 0) {
return NO_STORE;
}

// `stale-while-revalidate` is intentionally `0` for fetch and cached file entries: a response
// served during a stale-while-revalidate window still carries its original
// `x-opennext-cache-last-modified`, which would hide the `lastModified = 1` staleness signal that
// `cacheInterceptor` and the composable cache rely on to trigger a background revalidation.
return buildCacheControl(remainingTtl, 0);
}
15 changes: 13 additions & 2 deletions packages/core/src/utils/cache-get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ function getHeaderNumber(headers: HeadersMap, name: string): number | undefined
return Number.isNaN(n) ? undefined : n;
}

/**
* `revalidate` is either a number of seconds or `false` for entries that never revalidate (SSG).
*/
function getHeaderRevalidate(headers: HeadersMap): number | false | undefined {
const v = getHeaderValue(headers, "x-opennext-cache-revalidate");
if (v === undefined) return undefined;
if (v === "false") return false;
const n = Number(v);
return Number.isNaN(n) ? undefined : n;
}

function collectPrefixedHeaders(headers: HeadersMap, prefix: string): Record<string, string | string[]> {
const result: Record<string, string | string[]> = {};
for (const [key, value] of Object.entries(headers)) {
Expand Down Expand Up @@ -99,7 +110,7 @@ function reconstructFetch(headers: HeadersMap, bodyText: string, base: Base) {
const dataTags = dataTagsStr ? JSON.parse(dataTagsStr) : undefined;
const fetchTagsStr = getHeaderValue(headers, "x-opennext-cache-fetch-tags");
const fetchTags = fetchTagsStr ? JSON.parse(fetchTagsStr) : undefined;
const revalidate = getHeaderNumber(headers, "x-opennext-cache-revalidate");
const revalidate = getHeaderRevalidate(headers);

const dataHeaders = collectPrefixedHeaders(headers, "x-opennext-cache-header-") as Record<string, string>;

Expand All @@ -123,7 +134,7 @@ function reconstructCachedFile(headers: HeadersMap, bodyText: string, base: Base
const subType = getHeaderValue(headers, "x-opennext-cache-sub-type");
const metaStatus = getHeaderNumber(headers, "x-opennext-cache-meta-status");
const metaPostponed = getHeaderValue(headers, "x-opennext-cache-meta-postponed");
const revalidate = getHeaderNumber(headers, "x-opennext-cache-revalidate");
const revalidate = getHeaderRevalidate(headers);

const metaHeaders = collectPrefixedHeaders(headers, "x-opennext-cache-header-");
const hasMetaHeaders = Object.keys(metaHeaders).length > 0;
Expand Down
Loading
Loading