Skip to content

Commit 26ebe6c

Browse files
committed
feat(core): honour Cache-Control on cache entries
The cache handler function now derives freshness from the entry's `Cache-Control` rather than from the revalidation timestamp alone. - New `utils/cache-control.ts` parses `s-maxage`, `stale-while-revalidate` and `must-revalidate`, and the resulting fresh / stale / expired state travels back to the caller through the response headers that `cache-get.ts` reads. - `cacheInterceptor` follows the same state. - The `fetch` and `local` cache overrides forward the cache type in `set`: incremental caches that key entries on the type were writing them where `get` does not look.
1 parent 18f4542 commit 26ebe6c

13 files changed

Lines changed: 516 additions & 30 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@opennextjs/core": patch
3+
---
4+
5+
Derive revalidation from `Cache-Control` on cache entries
6+
7+
The cache handler function now parses the `Cache-Control` of an entry to decide whether it is fresh,
8+
stale or expired, instead of relying on the revalidation timestamp alone. `s-maxage`,
9+
`stale-while-revalidate` and `must-revalidate` are honoured, and the resulting state is carried back
10+
to the caller in the response headers.
11+
12+
The `fetch` and `local` cache overrides also forward the cache type when writing an entry.
13+
Incremental caches that key entries on the type were writing them where `get` does not look.

packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,14 @@ describe("serviceCache", () => {
5656
"x-opennext-cache-type": "cache",
5757
"x-opennext-cache-sub-type": "route",
5858
"x-opennext-cache-last-modified": "1234",
59+
"x-opennext-cache-revalidate": "60",
5960
},
6061
})
6162
);
6263

6364
await expect(serviceCache.get("key")).resolves.toEqual({
6465
lastModified: 1234,
65-
value: expect.objectContaining({ type: "route", body: "body" }),
66+
value: expect.objectContaining({ type: "route", body: "body", revalidate: 60 }),
6667
});
6768
});
6869
});

packages/core/src/adapters/cache-adapter.ts

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import type { StoredComposableCacheEntry } from "@/types/cache";
44
import type { InternalEvent, InternalResult } from "@/types/open-next";
55
import type {
66
CacheEntryType,
7-
CachedFile,
87
CachedFetchValue,
98
CacheValue,
109
OpenNextHandlerOptions,
@@ -13,6 +12,7 @@ import type {
1312

1413
import { createGenericHandler } from "../core/createGenericHandler.js";
1514
import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js";
15+
import { computeEntryCacheControl } from "../utils/cache-control.js";
1616
import { getTagsFromValue, isStale, writeTags } from "../utils/cache.js";
1717
import { runWithOpenNextRequestContext } from "../utils/promise.js";
1818
import { toReadableStream } from "../utils/stream.js";
@@ -130,19 +130,23 @@ async function handleGet(
130130
};
131131
}
132132

133-
if (result.value && !result.shouldBypassTagCache) {
134-
let tags: string[] = [...additionalTags];
133+
// The tags are also used to make the response purgeable, so they are derived for every hit,
134+
// including the ones bypassing the tag cache.
135+
let tags: string[] = [...additionalTags];
136+
137+
if (cacheType === "cache") {
138+
tags = [...tags, ...getTagsFromValue(result.value as CacheValue<"cache">)];
139+
} else if (cacheType === "fetch") {
140+
const fetchValue = result.value as CachedFetchValue;
141+
tags = [...tags, ...(fetchValue.tags ?? []), ...(fetchValue.data?.tags ?? [])];
142+
} else if (cacheType === "composable") {
143+
const composableValue = result.value as StoredComposableCacheEntry;
144+
tags = [...tags, ...(composableValue.tags ?? [])];
145+
}
135146

136-
if (cacheType === "cache") {
137-
tags = [...tags, ...getTagsFromValue(result.value as CacheValue<"cache">)];
138-
} else if (cacheType === "fetch") {
139-
const fetchValue = result.value as CachedFetchValue;
140-
tags = [...tags, ...(fetchValue.tags ?? []), ...(fetchValue.data?.tags ?? [])];
141-
} else if (cacheType === "composable") {
142-
const composableValue = result.value as StoredComposableCacheEntry;
143-
tags = [...tags, ...(composableValue.tags ?? [])];
144-
}
147+
let isEntryStale = false;
145148

149+
if (!result.shouldBypassTagCache) {
146150
const lastModified = result.lastModified ?? Date.now();
147151

148152
if (tags.length > 0) {
@@ -163,13 +167,15 @@ async function handleGet(
163167
}
164168

165169
// Check if the cache entry is stale (valid but needs background revalidation)
166-
const _isStale = tags.length > 0 ? await isStale(key, tags, lastModified) : false;
167-
if (_isStale) {
170+
isEntryStale = tags.length > 0 ? await isStale(key, tags, lastModified) : false;
171+
if (isEntryStale) {
168172
result.lastModified = 1;
169173
}
170174
}
171175

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

402-
function buildCacheGetResponse(result: WithLastModified<CacheValue<CacheEntryType>>): InternalResult {
408+
function buildCacheGetResponse(
409+
result: WithLastModified<CacheValue<CacheEntryType>>,
410+
isStaleFromTagCache: boolean,
411+
tags: string[]
412+
): InternalResult {
403413
const value = result.value!;
404414

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

420+
// The `Cache-Control` above lets an HTTP cache store this response, it can only be invalidated
421+
// through a purge keyed on these tags. See `computeEntryCacheControl`.
422+
if (tags.length > 0) {
423+
headers["cache-tag"] = tags.join(",");
424+
}
425+
410426
if (result.lastModified !== undefined) {
411427
headers["x-opennext-cache-last-modified"] = String(result.lastModified);
412428
}
@@ -415,24 +431,28 @@ function buildCacheGetResponse(result: WithLastModified<CacheValue<CacheEntryTyp
415431
}
416432

417433
if ("kind" in value && value.kind === "FETCH") {
418-
return buildFetchResponse(value as CachedFetchValue, headers);
434+
return buildFetchResponse(value as CacheValue<"fetch">, headers);
419435
}
420436

421437
if ("type" in value) {
422-
return buildCachedFileResponse(value as CachedFile, headers);
438+
return buildCachedFileResponse(value as CacheValue<"cache">, headers);
423439
}
424440

425441
return buildComposableResponse(value as StoredComposableCacheEntry, headers);
426442
}
427443

428444
function buildFetchResponse(
429-
value: CachedFetchValue,
445+
value: CacheValue<"fetch">,
430446
headers: Record<string, string | string[]>
431447
): InternalResult {
432448
headers["x-opennext-cache-type"] = "fetch";
433449
headers["x-opennext-cache-fetch-kind"] = "FETCH";
434450
headers["x-opennext-cache-fetch-data-url"] = value.data.url;
435451

452+
if (value.revalidate !== undefined) {
453+
headers["x-opennext-cache-revalidate"] = String(value.revalidate);
454+
}
455+
436456
if (value.data.status !== undefined) {
437457
headers["x-opennext-cache-fetch-data-status"] = String(value.data.status);
438458
}
@@ -458,12 +478,16 @@ function buildFetchResponse(
458478
}
459479

460480
function buildCachedFileResponse(
461-
value: CachedFile,
481+
value: CacheValue<"cache">,
462482
headers: Record<string, string | string[]>
463483
): InternalResult {
464484
headers["x-opennext-cache-type"] = "cache";
465485
headers["x-opennext-cache-sub-type"] = value.type;
466486

487+
if (value.revalidate !== undefined) {
488+
headers["x-opennext-cache-revalidate"] = String(value.revalidate);
489+
}
490+
467491
if (value.meta?.status !== undefined) {
468492
headers["x-opennext-cache-meta-status"] = String(value.meta.status);
469493
}

packages/core/src/core/routing/cacheInterceptor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import { NextConfig, PrerenderManifest } from "@/config/index";
44
import type { InternalEvent, InternalResult, MiddlewareEvent, PartialResult } from "@/types/open-next";
55
import type { CacheValue } from "@/types/overrides";
66
import { isBinaryContentType } from "@/utils/binary";
7+
import { CACHE_ONE_YEAR } from "@/utils/cache-control";
78
import { emptyReadableStream, toReadableStream } from "@/utils/stream";
89

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

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

14-
const CACHE_ONE_YEAR = 60 * 60 * 24 * 365;
1515
const CACHE_ONE_MONTH = 60 * 60 * 24 * 30;
1616

1717
/*

packages/core/src/overrides/cache/fetch.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@ const fetchCache: Cache = {
2020
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
2121
return parseCacheGetResponse(headers, bodyText) as any;
2222
},
23-
set: async (key, value, _cacheType) => {
24-
const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`;
23+
set: async (key, value, cacheType) => {
24+
// The cache type has to be forwarded: incremental caches may key entries on it,
25+
// writing without it would store the entry where `get` does not look for it.
26+
const queryString = cacheType ? `?type=${cacheType}` : "";
27+
const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}${queryString}`;
2528
await fetch(url, {
2629
method: "PUT",
2730
headers: { "Content-Type": "application/json" },

packages/core/src/overrides/cache/local.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,21 @@ const localCache: Cache = {
4141
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
4242
return parseCacheGetResponse(result.headers, bodyText) as any;
4343
},
44-
set: async (key, value, _cacheType) => {
44+
set: async (key, value, cacheType) => {
4545
const h = (await getHandler())!;
4646
const encodedKey = encodeURIComponent(key);
4747
const url = `https://on/cache/${encodedKey}`;
48+
// The cache type has to be forwarded: incremental caches may key entries on it,
49+
// writing without it would store the entry where `get` does not look for it.
50+
const query: Record<string, string> = {};
51+
if (cacheType) query.type = cacheType;
4852
const event: InternalEvent = {
4953
type: "core",
5054
method: "PUT",
5155
rawPath: `/cache/${encodedKey}`,
5256
url,
5357
headers: { "Content-Type": "application/json" },
54-
query: {},
58+
query,
5559
cookies: {},
5660
remoteAddress: "127.0.0.1",
5761
body: Buffer.from(JSON.stringify({ value })),
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { StoredComposableCacheEntry } from "@/types/cache";
2+
import type { CacheEntryType, CacheValue } from "@/types/overrides";
3+
4+
import { error } from "../adapters/logger";
5+
6+
export const CACHE_ONE_YEAR = 60 * 60 * 24 * 365;
7+
8+
const NO_STORE = "no-store";
9+
10+
/**
11+
* Composable cache entries may carry `Infinity` (i.e. `cacheLife("max")`), and an entry that was
12+
* just written has a negative age when `Date.now()` drifts, so every duration is clamped.
13+
*/
14+
function clampSeconds(seconds: number): number {
15+
if (!Number.isFinite(seconds)) {
16+
return CACHE_ONE_YEAR;
17+
}
18+
return Math.max(0, Math.min(Math.floor(seconds), CACHE_ONE_YEAR));
19+
}
20+
21+
function buildCacheControl(sMaxAge: number, staleWhileRevalidate: number): string {
22+
return `s-maxage=${clampSeconds(sMaxAge)}, stale-while-revalidate=${clampSeconds(staleWhileRevalidate)}`;
23+
}
24+
25+
/**
26+
* Computes the `Cache-Control` of a cache handler `GET` hit, so that an HTTP cache sitting in front
27+
* of the cache handler function can serve reads without hitting the underlying store.
28+
*
29+
* A stale or expired entry is never stored: the next read has to reach the cache handler function so
30+
* that the staleness is signaled to the server (through `lastModified = 1`).
31+
*
32+
* **This is only correct when the cached responses can be purged**, either with a
33+
* `cdnInvalidationHandler` or through another purge mechanism keyed on the `cache-tag` header that
34+
* `buildCacheGetResponse` emits. Tag revalidation cannot invalidate an intermediate cache on its own,
35+
* so without purging `revalidateTag`/`revalidatePath` would be masked for as long as the entry is
36+
* stored - up to a year for SSG entries.
37+
*/
38+
export function computeEntryCacheControl(
39+
value: CacheValue<CacheEntryType>,
40+
lastModified: number | undefined,
41+
isStaleFromTagCache: boolean
42+
): string {
43+
if (isStaleFromTagCache) {
44+
return NO_STORE;
45+
}
46+
47+
// Same discrimination as `buildCacheGetResponse`: fetch entries have a `kind`, cached files have
48+
// a `type`, composable entries have neither.
49+
const isFetch = "kind" in value && value.kind === "FETCH";
50+
const isCachedFile = "type" in value;
51+
52+
if (!isFetch && !isCachedFile) {
53+
return computeComposableCacheControl(value as StoredComposableCacheEntry);
54+
}
55+
56+
return computeRevalidateCacheControl(value.revalidate, lastModified);
57+
}
58+
59+
function computeComposableCacheControl(value: StoredComposableCacheEntry): string {
60+
const age = (Date.now() - value.timestamp) / 1000;
61+
62+
if (age >= value.expire || age >= value.revalidate) {
63+
return NO_STORE;
64+
}
65+
66+
// Composable entries are the only ones carrying an explicit `expire`, so they are also the only
67+
// ones for which we can derive a real stale-while-revalidate window.
68+
return buildCacheControl(value.revalidate - age, value.expire - value.revalidate);
69+
}
70+
71+
function computeRevalidateCacheControl(
72+
revalidate: number | false | undefined,
73+
lastModified: number | undefined
74+
): string {
75+
if (revalidate === 0) {
76+
return NO_STORE;
77+
}
78+
79+
if (revalidate === undefined) {
80+
// `revalidate` is written by the cache handler for every entry, we should always have one here.
81+
error("Missing `revalidate` on a cache entry, assuming it is a static (SSG) entry");
82+
}
83+
84+
if (revalidate === undefined || revalidate === false) {
85+
return buildCacheControl(CACHE_ONE_YEAR, 0);
86+
}
87+
88+
const age = (Date.now() - (lastModified ?? Date.now())) / 1000;
89+
const remainingTtl = revalidate - age;
90+
91+
if (remainingTtl <= 0) {
92+
return NO_STORE;
93+
}
94+
95+
// `stale-while-revalidate` is intentionally `0` for fetch and cached file entries: a response
96+
// served during a stale-while-revalidate window still carries its original
97+
// `x-opennext-cache-last-modified`, which would hide the `lastModified = 1` staleness signal that
98+
// `cacheInterceptor` and the composable cache rely on to trigger a background revalidation.
99+
return buildCacheControl(remainingTtl, 0);
100+
}

packages/core/src/utils/cache-get.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ function getHeaderNumber(headers: HeadersMap, name: string): number | undefined
2222
return Number.isNaN(n) ? undefined : n;
2323
}
2424

25+
/**
26+
* `revalidate` is either a number of seconds or `false` for entries that never revalidate (SSG).
27+
*/
28+
function getHeaderRevalidate(headers: HeadersMap): number | false | undefined {
29+
const v = getHeaderValue(headers, "x-opennext-cache-revalidate");
30+
if (v === undefined) return undefined;
31+
if (v === "false") return false;
32+
const n = Number(v);
33+
return Number.isNaN(n) ? undefined : n;
34+
}
35+
2536
function collectPrefixedHeaders(headers: HeadersMap, prefix: string): Record<string, string | string[]> {
2637
const result: Record<string, string | string[]> = {};
2738
for (const [key, value] of Object.entries(headers)) {
@@ -99,7 +110,7 @@ function reconstructFetch(headers: HeadersMap, bodyText: string, base: Base) {
99110
const dataTags = dataTagsStr ? JSON.parse(dataTagsStr) : undefined;
100111
const fetchTagsStr = getHeaderValue(headers, "x-opennext-cache-fetch-tags");
101112
const fetchTags = fetchTagsStr ? JSON.parse(fetchTagsStr) : undefined;
102-
const revalidate = getHeaderNumber(headers, "x-opennext-cache-revalidate");
113+
const revalidate = getHeaderRevalidate(headers);
103114

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

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

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

0 commit comments

Comments
 (0)