diff --git a/.changeset/cache-control-revalidation.md b/.changeset/cache-control-revalidation.md new file mode 100644 index 00000000..6f78878d --- /dev/null +++ b/.changeset/cache-control-revalidation.md @@ -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. diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts index 2ad47c58..05b140a6 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -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 }), }); }); }); diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index 8e62268d..2d4654d3 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -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, @@ -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"; @@ -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") { @@ -145,6 +145,8 @@ async function handleGet( tags = [...tags, ...(composableValue.tags ?? [])]; } + let isEntryStale = false; + if (!result.shouldBypassTagCache) { const lastModified = result.lastModified ?? Date.now(); @@ -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); @@ -409,14 +413,24 @@ async function handleRevalidateTags(body?: Buffer): Promise { // Cache GET response builder // ///////////////////////////// -function buildCacheGetResponse(result: WithLastModified>): InternalResult { +function buildCacheGetResponse( + result: WithLastModified>, + isStaleFromTagCache: boolean, + tags: string[] +): InternalResult { const value = result.value!; const headers: Record = { "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); } @@ -425,11 +439,11 @@ function buildCacheGetResponse(result: WithLastModified, headers); } if ("type" in value) { - return buildCachedFileResponse(value as CachedFile, headers); + return buildCachedFileResponse(value as CacheValue<"cache">, headers); } return buildComposableResponse(value as StoredComposableCacheEntry, headers); diff --git a/packages/core/src/core/routing/cacheInterceptor.ts b/packages/core/src/core/routing/cacheInterceptor.ts index 0e711802..c38d4950 100644 --- a/packages/core/src/core/routing/cacheInterceptor.ts +++ b/packages/core/src/core/routing/cacheInterceptor.ts @@ -4,6 +4,7 @@ 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"; @@ -11,7 +12,6 @@ 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; /* diff --git a/packages/core/src/utils/cache-control.ts b/packages/core/src/utils/cache-control.ts new file mode 100644 index 00000000..b3856336 --- /dev/null +++ b/packages/core/src/utils/cache-control.ts @@ -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, + 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); + } + + 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); +} diff --git a/packages/core/src/utils/cache-get.ts b/packages/core/src/utils/cache-get.ts index 572a9a42..de858478 100644 --- a/packages/core/src/utils/cache-get.ts +++ b/packages/core/src/utils/cache-get.ts @@ -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 { const result: Record = {}; for (const [key, value] of Object.entries(headers)) { @@ -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; @@ -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; diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index d7bf68fa..f9baf085 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -2,8 +2,9 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { handler } from "@opennextjs/core/adapters/cache-adapter"; import type { InternalEvent, InternalResult, OpenNextConfig } from "@opennextjs/core/types/open-next"; +import { CACHE_ONE_YEAR } from "@opennextjs/core/utils/cache-control"; import { fromReadableStream } from "@opennextjs/core/utils/stream"; -import { type Mock, vi, describe, expect, it, beforeEach } from "vitest"; +import { type Mock, vi, describe, expect, it, afterEach, beforeEach } from "vitest"; const mockResolveIncrementalCache = vi.hoisted(() => vi.fn()); const mockResolveTagCache = vi.hoisted(() => vi.fn()); @@ -266,6 +267,149 @@ describe("cache-adapter", () => { }); }); + describe("Cache-Control and cache-tag in GET", () => { + // The computed ttls are relative to `Date.now()`, freeze it so that they are deterministic. + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(1_700_000_000_000); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should not store a miss", async () => { + mockIncrementalCache.get.mockResolvedValue(null); + + const result = await runHandler(createEvent()); + + expect(result.headers["Cache-Control"]).toBe("no-store"); + }); + + it("should not store a tag revalidated entry", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(-1); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["Cache-Control"]).toBe("no-store"); + }); + + it("should compute the remaining ttl of a cached file entry", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "route-body", revalidate: 120 }, + lastModified: Date.now(), + }); + + const result = await runHandler(createEvent()); + + expect(result.headers["Cache-Control"]).toBe("s-maxage=120, stale-while-revalidate=0"); + expect(result.headers["x-opennext-cache-revalidate"]).toBe("120"); + }); + + it("should forward a `false` revalidate and cache the entry for a year", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "app", html: "", rsc: "rsc-data", revalidate: false }, + lastModified: Date.now(), + }); + + const result = await runHandler(createEvent()); + + expect(result.headers["Cache-Control"]).toBe(`s-maxage=${CACHE_ONE_YEAR}, stale-while-revalidate=0`); + expect(result.headers["x-opennext-cache-revalidate"]).toBe("false"); + }); + + it("should not store a cached file entry past its revalidate window", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "route-body", revalidate: 60 }, + lastModified: Date.now() - 120_000, + }); + + const result = await runHandler(createEvent()); + + expect(result.headers["Cache-Control"]).toBe("no-store"); + }); + + it("should compute the remaining ttl of a fetch entry", async () => { + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockResolvedValue(false); + mockIncrementalCache.get.mockResolvedValue({ + value: { + kind: "FETCH", + data: { headers: {}, body: "fetch-body", url: "https://example.com" }, + revalidate: 300, + tags: ["fetch-tag"], + }, + lastModified: Date.now(), + }); + + const result = await runHandler(createEvent({ query: { type: "fetch" } })); + + expect(result.headers["Cache-Control"]).toBe("s-maxage=300, stale-while-revalidate=0"); + expect(result.headers["x-opennext-cache-revalidate"]).toBe("300"); + expect(result.headers["cache-tag"]).toBe("fetch-tag"); + }); + + it("should derive the stale window of a composable entry", async () => { + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockResolvedValue(false); + mockIncrementalCache.get.mockResolvedValue({ + value: { + value: "composable-body", + tags: ["composable-tag"], + timestamp: Date.now(), + revalidate: 60, + expire: 300, + stale: 5, + }, + lastModified: Date.now(), + }); + + const result = await runHandler(createEvent({ query: { type: "composable" } })); + + expect(result.headers["Cache-Control"]).toBe("s-maxage=60, stale-while-revalidate=240"); + expect(result.headers["cache-tag"]).toBe("composable-tag"); + }); + + it("should not store an expired composable entry", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { + value: "composable-body", + tags: [], + timestamp: Date.now() - 400_000, + revalidate: 600, + expire: 300, + stale: 5, + }, + lastModified: Date.now() - 400_000, + }); + + const result = await runHandler(createEvent({ query: { type: "composable" } })); + + expect(result.headers["Cache-Control"]).toBe("no-store"); + }); + + it("should fall back to the cache key when the entry has no tag", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "route-body", revalidate: 60 }, + lastModified: Date.now(), + }); + + const result = await runHandler(createEvent()); + + expect(result.headers["cache-tag"]).toBe("test-key"); + }); + }); + describe("tag revalidation in GET", () => { it("should return cached value when there are no tags", async () => { mockTagCache.mode = "original"; @@ -400,6 +544,7 @@ describe("cache-adapter", () => { expect(result.statusCode).toBe(200); expect(result.headers["x-opennext-cache-last-modified"]).toBe("1"); + expect(result.headers["Cache-Control"]).toBe("no-store"); }); it("should keep original lastModified when tags are not stale", async () => { @@ -419,6 +564,7 @@ describe("cache-adapter", () => { expect(result.statusCode).toBe(200); expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + expect(result.headers["cache-tag"]).toBe("tag1"); }); it("should skip isStale when shouldBypassTagCache is true", async () => { @@ -433,6 +579,24 @@ describe("cache-adapter", () => { expect(result.statusCode).toBe(200); expect(mockTagCache.isStale).not.toHaveBeenCalled(); }); + + it("should still emit the tags when shouldBypassTagCache is true", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1,tag2" } }, + }, + lastModified: 1000, + shouldBypassTagCache: true, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.isStale).not.toHaveBeenCalled(); + expect(result.headers["cache-tag"]).toBe("tag1,tag2"); + }); }); describe("PUT /cache/:key", () => { diff --git a/packages/tests-unit/tests/utils/cache-control.test.ts b/packages/tests-unit/tests/utils/cache-control.test.ts new file mode 100644 index 00000000..994c4bd5 --- /dev/null +++ b/packages/tests-unit/tests/utils/cache-control.test.ts @@ -0,0 +1,131 @@ +import type { StoredComposableCacheEntry } from "@opennextjs/core/types/cache"; +import type { CacheEntryType, CacheValue } from "@opennextjs/core/types/overrides"; +import { CACHE_ONE_YEAR, computeEntryCacheControl } from "@opennextjs/core/utils/cache-control"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const NOW = 1_700_000_000_000; + +function composable(overrides: Partial = {}): CacheValue<"composable"> { + return { + value: "composable-body", + tags: [], + timestamp: NOW, + revalidate: 60, + expire: 300, + stale: 5, + ...overrides, + }; +} + +function fetchEntry(revalidate?: number | false): CacheValue<"fetch"> { + return { + kind: "FETCH", + data: { headers: {}, body: "fetch-body", url: "https://example.com" }, + ...(revalidate !== undefined ? { revalidate } : {}), + }; +} + +function cachedFile(revalidate?: number | false): CacheValue<"cache"> { + return { + type: "route", + body: "route-body", + ...(revalidate !== undefined ? { revalidate } : {}), + }; +} + +function compute( + value: CacheValue, + lastModified?: number, + isStaleFromTagCache = false +): string { + return computeEntryCacheControl(value, lastModified, isStaleFromTagCache); +} + +describe("computeEntryCacheControl", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + errorSpy.mockRestore(); + }); + + it("should not store an entry that is stale from the tag cache", () => { + expect(compute(cachedFile(60), NOW, true)).toBe("no-store"); + expect(compute(composable(), NOW, true)).toBe("no-store"); + expect(compute(fetchEntry(60), NOW, true)).toBe("no-store"); + }); + + describe("composable entries", () => { + it("should compute the remaining revalidate window and the stale window", () => { + vi.setSystemTime(NOW + 20_000); + + expect(compute(composable())).toBe("s-maxage=40, stale-while-revalidate=240"); + }); + + it("should not store a stale entry", () => { + vi.setSystemTime(NOW + 60_000); + + expect(compute(composable())).toBe("no-store"); + }); + + it("should not store an expired entry", () => { + vi.setSystemTime(NOW + 300_000); + + expect(compute(composable({ revalidate: 600 }))).toBe("no-store"); + }); + + it("should clamp infinite durations to a year", () => { + expect( + compute(composable({ revalidate: Number.POSITIVE_INFINITY, expire: Number.POSITIVE_INFINITY })) + ).toBe(`s-maxage=${CACHE_ONE_YEAR}, stale-while-revalidate=${CACHE_ONE_YEAR}`); + }); + }); + + describe("fetch entries", () => { + it("should compute the remaining ttl", () => { + vi.setSystemTime(NOW + 10_000); + + expect(compute(fetchEntry(60), NOW)).toBe("s-maxage=50, stale-while-revalidate=0"); + }); + + it("should not store an entry past its revalidate window", () => { + vi.setSystemTime(NOW + 60_000); + + expect(compute(fetchEntry(60), NOW)).toBe("no-store"); + }); + }); + + describe("cached file entries", () => { + it("should compute the remaining ttl", () => { + vi.setSystemTime(NOW + 30_000); + + expect(compute(cachedFile(120), NOW)).toBe("s-maxage=90, stale-while-revalidate=0"); + }); + + it("should cache SSG entries for a year", () => { + expect(compute(cachedFile(false), NOW)).toBe(`s-maxage=${CACHE_ONE_YEAR}, stale-while-revalidate=0`); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it("should assume SSG and log an error when revalidate is missing", () => { + expect(compute(cachedFile(), NOW)).toBe(`s-maxage=${CACHE_ONE_YEAR}, stale-while-revalidate=0`); + expect(errorSpy).toHaveBeenCalledWith( + "Missing `revalidate` on a cache entry, assuming it is a static (SSG) entry" + ); + }); + + it("should not store an entry with a revalidate of 0", () => { + expect(compute(cachedFile(0), NOW)).toBe("no-store"); + }); + + it("should treat a missing lastModified as a fresh entry", () => { + expect(compute(cachedFile(60))).toBe("s-maxage=60, stale-while-revalidate=0"); + }); + }); +}); diff --git a/packages/tests-unit/tests/utils/cache-get.test.ts b/packages/tests-unit/tests/utils/cache-get.test.ts index d15c65f2..75560e2c 100644 --- a/packages/tests-unit/tests/utils/cache-get.test.ts +++ b/packages/tests-unit/tests/utils/cache-get.test.ts @@ -186,6 +186,22 @@ describe("parseCacheGetResponse", () => { }); }); + it("should reconstruct a `false` revalidate", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-revalidate": "false", + }; + const result = parseCacheGetResponse(headers, "route body"); + + expect(result!.value).toEqual({ + type: "route", + body: "route body", + revalidate: false, + }); + }); + it("should reconstruct a page cache entry", () => { const headers = { "x-opennext-cache-found": "true",