From a2203178292e3d3988088197e1ac1b7c2dc9771c Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Fri, 18 Sep 2026 01:16:06 +0530 Subject: [PATCH 1/2] fix(cache): round-trip Infinity revalidate in kvDataAdapter --- .../src/cache/kv-data-adapter.runtime.ts | 105 +++++++++++++++--- tests/kv-cache-handler.test.ts | 57 ++++++++++ 2 files changed, 148 insertions(+), 14 deletions(-) diff --git a/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts b/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts index bf01363b8e..0d0097681a 100644 --- a/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts +++ b/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts @@ -95,10 +95,58 @@ type KVCacheEntry = { revalidateAt: number | null; /** Absolute timestamp (ms) after which the entry must block on fresh render. */ expireAt?: number | null; - /** Effective cache-control policy used for response headers. */ - cacheControl?: CacheControlMetadata; + /** + * Effective cache-control policy used for response headers. + * + * JSON cannot carry `Infinity`, so non-finite values (static pages use + * `revalidate: Infinity`) are encoded as `null` on write and restored to + * `Infinity` on read. `null` never meant anything else here — the previous + * validator rejected it — so old beta.9/10 rows already stored as `null` + * heal to `Infinity` automatically. + */ + cacheControl?: SerializedCacheControlMetadata; +}; + +/** On-wire form of {@link CacheControlMetadata} with `Infinity` encoded as `null`. */ +type SerializedCacheControlMetadata = { + revalidate: number | null; + expire?: number | null; + stale?: number | null; }; +/** Encode a required finite number for JSON storage (`Infinity`/`NaN` → `null`). */ +function encodeFiniteNumber(value: number): number | null { + return Number.isFinite(value) ? value : null; +} + +/** Encode an optional finite number (`undefined` stays absent, non-finite → `null`). */ +function encodeOptionalFiniteNumber(value: number | undefined): number | null | undefined { + return value === undefined ? undefined : encodeFiniteNumber(value); +} + +/** Restore an encoded required number (`null` → `Infinity`). */ +function restoreInfiniteNumber(value: number | null): number { + return value === null ? Infinity : value; +} + +/** Restore an encoded optional number (`null` → `Infinity`, `undefined` stays absent). */ +function restoreOptionalInfiniteNumber(value: number | null | undefined): number | undefined { + if (value === undefined) return undefined; + return value === null ? Infinity : value; +} + +/** Restore stored cache-control to its in-memory form (`null` → `Infinity`). */ +function restoreCacheControl(stored: SerializedCacheControlMetadata): CacheControlMetadata { + const restored: CacheControlMetadata = { + revalidate: restoreInfiniteNumber(stored.revalidate), + }; + const expire = restoreOptionalInfiniteNumber(stored.expire); + if (expire !== undefined) restored.expire = expire; + const stale = restoreOptionalInfiniteNumber(stored.stale); + if (stale !== undefined) restored.stale = stale; + return restored; +} + /** Prefix used by revalidatePath for path-based tags. */ const PATH_TAG_PREFIX = "_N_T_"; @@ -341,19 +389,23 @@ export class KVCacheHandler implements CacheHandler { (entry.revalidateAt !== null && now > entry.revalidateAt) || (requestedRevalidateAt !== null && now > requestedRevalidateAt); + const restoredCacheControl = entry.cacheControl + ? restoreCacheControl(entry.cacheControl) + : undefined; + if (isStale) { return { lastModified: entry.lastModified, value: restoredValue, cacheState: "stale", - cacheControl: entry.cacheControl, + cacheControl: restoredCacheControl, }; } return { lastModified: entry.lastModified, value: restoredValue, - cacheControl: entry.cacheControl, + cacheControl: restoredCacheControl, }; } @@ -471,22 +523,30 @@ export class KVCacheHandler implements CacheHandler { if (effectiveRevalidate === 0) return Promise.resolve(); const now = Date.now(); - const revalidateAt = + const rawRevalidateAt = typeof effectiveRevalidate === "number" && effectiveRevalidate > 0 ? now + effectiveRevalidate * 1000 : null; - const expireAt = + // JSON.stringify(Infinity) becomes null, so encode non-finite timestamps + // explicitly. `null` already means "never stale/expire" to readers. + const revalidateAt = rawRevalidateAt === null ? null : encodeFiniteNumber(rawRevalidateAt); + const rawExpireAt = typeof effectiveExpire === "number" && effectiveExpire > 0 ? now + effectiveExpire * 1000 : null; - const cacheControl: CacheControlMetadata | undefined = + const expireAt = rawExpireAt === null ? null : encodeFiniteNumber(rawExpireAt); + const cacheControl: SerializedCacheControlMetadata | undefined = typeof effectiveRevalidate === "number" ? { - revalidate: effectiveRevalidate, - ...(effectiveExpire === undefined ? {} : { expire: effectiveExpire }), + revalidate: encodeFiniteNumber(effectiveRevalidate), + ...(effectiveExpire === undefined + ? {} + : { expire: encodeOptionalFiniteNumber(effectiveExpire) }), // Client-router reuse bound — must survive KV so warm hits replay // the producing render's claim (see CacheControlMetadata.stale). - ...(effectiveStale === undefined ? {} : { stale: effectiveStale }), + ...(effectiveStale === undefined + ? {} + : { stale: encodeOptionalFiniteNumber(effectiveStale) }), } : undefined; @@ -516,7 +576,11 @@ export class KVCacheHandler implements CacheHandler { // Background regen overwrites the key with a fresh entry + new revalidateAt, // so active pages always have something to serve. Entries only disappear after // 30 days of zero traffic, or when explicitly deleted via tag invalidation. - const expirationTtl: number | undefined = revalidateAt !== null ? this.ttlSeconds : undefined; + // Use the pre-encoding timestamp so `Infinity` (static pages, encoded as + // `null` above) still gets the standard 30-day KV TTL instead of becoming + // immortal storage. + const expirationTtl: number | undefined = + rawRevalidateAt !== null ? this.ttlSeconds : undefined; // Store tags in KV metadata so revalidateByPathPrefix can discover them // via kv.list() without fetching entry values. Cloudflare KV limits @@ -684,11 +748,24 @@ function validateCacheEntry(raw: unknown): KVCacheEntry | null { } if (obj.cacheControl !== undefined) { if (!isUnknownRecord(obj.cacheControl)) return null; - if (typeof obj.cacheControl.revalidate !== "number") return null; - if (obj.cacheControl.expire !== undefined && typeof obj.cacheControl.expire !== "number") { + // `null` is the JSON encoding of `Infinity` (static pages). Accept it so + // entries written by set() — including beta.9/10 rows already stored as + // `null` — validate and restore instead of being deleted as corrupt. + if (typeof obj.cacheControl.revalidate !== "number" && obj.cacheControl.revalidate !== null) { + return null; + } + if ( + obj.cacheControl.expire !== undefined && + obj.cacheControl.expire !== null && + typeof obj.cacheControl.expire !== "number" + ) { return null; } - if (obj.cacheControl.stale !== undefined && typeof obj.cacheControl.stale !== "number") { + if ( + obj.cacheControl.stale !== undefined && + obj.cacheControl.stale !== null && + typeof obj.cacheControl.stale !== "number" + ) { return null; } } diff --git a/tests/kv-cache-handler.test.ts b/tests/kv-cache-handler.test.ts index bbe18fa08e..d650c39967 100644 --- a/tests/kv-cache-handler.test.ts +++ b/tests/kv-cache-handler.test.ts @@ -639,6 +639,63 @@ describe("KVCacheHandler", () => { expect(hit?.cacheControl).toEqual({ revalidate: 60, expire: 300, stale: 30 }); }); + it("round-trips revalidate: Infinity without invalid-shape errors (static pages)", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await handler.set( + "static-infinity", + { + kind: "APP_PAGE", + html: "
static
", + rscData: undefined, + headers: undefined, + postponed: undefined, + status: 200, + }, + { cacheControl: { revalidate: Infinity } }, + ); + + // JSON cannot carry Infinity — the adapter must encode it explicitly. + const raw = store.get("cache:static-infinity")!; + expect(raw).toBeDefined(); + expect(JSON.parse(raw).cacheControl.revalidate).toBeNull(); + + const hit = await handler.get("static-infinity"); + expect(hit).not.toBeNull(); + expect(hit?.cacheControl).toEqual({ revalidate: Infinity }); + expect(hit?.value?.kind).toBe("APP_PAGE"); + expect(consoleError).not.toHaveBeenCalledWith( + expect.stringContaining("Invalid cache entry shape"), + expect.anything(), + ); + expect(kv.delete).not.toHaveBeenCalledWith("cache:static-infinity"); + } finally { + consoleError.mockRestore(); + } + }); + + it("heals legacy beta.9/10 entries already stored with revalidate: null", async () => { + store.set( + "cache:legacy-infinity", + JSON.stringify({ + value: { + kind: "PAGES", + html: "legacy", + pageData: {}, + status: 200, + }, + tags: [], + lastModified: Date.now(), + revalidateAt: null, + cacheControl: { revalidate: null }, + }), + ); + + const hit = await handler.get("legacy-infinity"); + expect(hit).not.toBeNull(); + expect(hit?.cacheControl).toEqual({ revalidate: Infinity }); + }); + it("serves stale when a shorter read-time revalidate has elapsed", async () => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(1_000); From 845edc8f58567934df09ba676334b741bbb23d4a Mon Sep 17 00:00:00 2001 From: James Date: Thu, 17 Sep 2026 23:26:26 +0100 Subject: [PATCH 2/2] fix(cache): simplify KV Infinity serialization --- .../src/cache/kv-data-adapter.runtime.ts | 110 ++++-------------- tests/kv-cache-handler.test.ts | 85 ++++++-------- 2 files changed, 55 insertions(+), 140 deletions(-) diff --git a/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts b/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts index 0d0097681a..934568e6ad 100644 --- a/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts +++ b/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts @@ -95,58 +95,10 @@ type KVCacheEntry = { revalidateAt: number | null; /** Absolute timestamp (ms) after which the entry must block on fresh render. */ expireAt?: number | null; - /** - * Effective cache-control policy used for response headers. - * - * JSON cannot carry `Infinity`, so non-finite values (static pages use - * `revalidate: Infinity`) are encoded as `null` on write and restored to - * `Infinity` on read. `null` never meant anything else here — the previous - * validator rejected it — so old beta.9/10 rows already stored as `null` - * heal to `Infinity` automatically. - */ - cacheControl?: SerializedCacheControlMetadata; + /** Effective cache-control policy used for response headers. */ + cacheControl?: CacheControlMetadata; }; -/** On-wire form of {@link CacheControlMetadata} with `Infinity` encoded as `null`. */ -type SerializedCacheControlMetadata = { - revalidate: number | null; - expire?: number | null; - stale?: number | null; -}; - -/** Encode a required finite number for JSON storage (`Infinity`/`NaN` → `null`). */ -function encodeFiniteNumber(value: number): number | null { - return Number.isFinite(value) ? value : null; -} - -/** Encode an optional finite number (`undefined` stays absent, non-finite → `null`). */ -function encodeOptionalFiniteNumber(value: number | undefined): number | null | undefined { - return value === undefined ? undefined : encodeFiniteNumber(value); -} - -/** Restore an encoded required number (`null` → `Infinity`). */ -function restoreInfiniteNumber(value: number | null): number { - return value === null ? Infinity : value; -} - -/** Restore an encoded optional number (`null` → `Infinity`, `undefined` stays absent). */ -function restoreOptionalInfiniteNumber(value: number | null | undefined): number | undefined { - if (value === undefined) return undefined; - return value === null ? Infinity : value; -} - -/** Restore stored cache-control to its in-memory form (`null` → `Infinity`). */ -function restoreCacheControl(stored: SerializedCacheControlMetadata): CacheControlMetadata { - const restored: CacheControlMetadata = { - revalidate: restoreInfiniteNumber(stored.revalidate), - }; - const expire = restoreOptionalInfiniteNumber(stored.expire); - if (expire !== undefined) restored.expire = expire; - const stale = restoreOptionalInfiniteNumber(stored.stale); - if (stale !== undefined) restored.stale = stale; - return restored; -} - /** Prefix used by revalidatePath for path-based tags. */ const PATH_TAG_PREFIX = "_N_T_"; @@ -389,23 +341,19 @@ export class KVCacheHandler implements CacheHandler { (entry.revalidateAt !== null && now > entry.revalidateAt) || (requestedRevalidateAt !== null && now > requestedRevalidateAt); - const restoredCacheControl = entry.cacheControl - ? restoreCacheControl(entry.cacheControl) - : undefined; - if (isStale) { return { lastModified: entry.lastModified, value: restoredValue, cacheState: "stale", - cacheControl: restoredCacheControl, + cacheControl: entry.cacheControl, }; } return { lastModified: entry.lastModified, value: restoredValue, - cacheControl: restoredCacheControl, + cacheControl: entry.cacheControl, }; } @@ -521,32 +469,31 @@ export class KVCacheHandler implements CacheHandler { effectiveRevalidate = data.revalidate; } if (effectiveRevalidate === 0) return Promise.resolve(); + if ( + typeof effectiveRevalidate === "number" && + !Number.isFinite(effectiveRevalidate) && + effectiveRevalidate !== Infinity + ) { + return Promise.resolve(); + } const now = Date.now(); - const rawRevalidateAt = + const revalidateAt = typeof effectiveRevalidate === "number" && effectiveRevalidate > 0 ? now + effectiveRevalidate * 1000 : null; - // JSON.stringify(Infinity) becomes null, so encode non-finite timestamps - // explicitly. `null` already means "never stale/expire" to readers. - const revalidateAt = rawRevalidateAt === null ? null : encodeFiniteNumber(rawRevalidateAt); - const rawExpireAt = + const expireAt = typeof effectiveExpire === "number" && effectiveExpire > 0 ? now + effectiveExpire * 1000 : null; - const expireAt = rawExpireAt === null ? null : encodeFiniteNumber(rawExpireAt); - const cacheControl: SerializedCacheControlMetadata | undefined = + const cacheControl: CacheControlMetadata | undefined = typeof effectiveRevalidate === "number" ? { - revalidate: encodeFiniteNumber(effectiveRevalidate), - ...(effectiveExpire === undefined - ? {} - : { expire: encodeOptionalFiniteNumber(effectiveExpire) }), + revalidate: effectiveRevalidate, + ...(effectiveExpire === undefined ? {} : { expire: effectiveExpire }), // Client-router reuse bound — must survive KV so warm hits replay // the producing render's claim (see CacheControlMetadata.stale). - ...(effectiveStale === undefined - ? {} - : { stale: encodeOptionalFiniteNumber(effectiveStale) }), + ...(effectiveStale === undefined ? {} : { stale: effectiveStale }), } : undefined; @@ -576,11 +523,7 @@ export class KVCacheHandler implements CacheHandler { // Background regen overwrites the key with a fresh entry + new revalidateAt, // so active pages always have something to serve. Entries only disappear after // 30 days of zero traffic, or when explicitly deleted via tag invalidation. - // Use the pre-encoding timestamp so `Infinity` (static pages, encoded as - // `null` above) still gets the standard 30-day KV TTL instead of becoming - // immortal storage. - const expirationTtl: number | undefined = - rawRevalidateAt !== null ? this.ttlSeconds : undefined; + const expirationTtl: number | undefined = revalidateAt !== null ? this.ttlSeconds : undefined; // Store tags in KV metadata so revalidateByPathPrefix can discover them // via kv.list() without fetching entry values. Cloudflare KV limits @@ -748,24 +691,15 @@ function validateCacheEntry(raw: unknown): KVCacheEntry | null { } if (obj.cacheControl !== undefined) { if (!isUnknownRecord(obj.cacheControl)) return null; - // `null` is the JSON encoding of `Infinity` (static pages). Accept it so - // entries written by set() — including beta.9/10 rows already stored as - // `null` — validate and restore instead of being deleted as corrupt. + // `null` is the JSON encoding of `Infinity` used by static pages. if (typeof obj.cacheControl.revalidate !== "number" && obj.cacheControl.revalidate !== null) { return null; } - if ( - obj.cacheControl.expire !== undefined && - obj.cacheControl.expire !== null && - typeof obj.cacheControl.expire !== "number" - ) { + if (obj.cacheControl.revalidate === null) obj.cacheControl.revalidate = Infinity; + if (obj.cacheControl.expire !== undefined && typeof obj.cacheControl.expire !== "number") { return null; } - if ( - obj.cacheControl.stale !== undefined && - obj.cacheControl.stale !== null && - typeof obj.cacheControl.stale !== "number" - ) { + if (obj.cacheControl.stale !== undefined && typeof obj.cacheControl.stale !== "number") { return null; } } diff --git a/tests/kv-cache-handler.test.ts b/tests/kv-cache-handler.test.ts index d650c39967..6b58e97224 100644 --- a/tests/kv-cache-handler.test.ts +++ b/tests/kv-cache-handler.test.ts @@ -639,62 +639,43 @@ describe("KVCacheHandler", () => { expect(hit?.cacheControl).toEqual({ revalidate: 60, expire: 300, stale: 30 }); }); - it("round-trips revalidate: Infinity without invalid-shape errors (static pages)", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - try { - await handler.set( - "static-infinity", - { - kind: "APP_PAGE", - html: "
static
", - rscData: undefined, - headers: undefined, - postponed: undefined, - status: 200, - }, - { cacheControl: { revalidate: Infinity } }, - ); + it("round-trips the Infinity revalidate used by static pages", async () => { + await handler.set( + "static-infinity", + { + kind: "APP_PAGE", + html: "
static
", + rscData: undefined, + headers: undefined, + postponed: undefined, + status: 200, + }, + { cacheControl: { revalidate: Infinity } }, + ); - // JSON cannot carry Infinity — the adapter must encode it explicitly. - const raw = store.get("cache:static-infinity")!; - expect(raw).toBeDefined(); - expect(JSON.parse(raw).cacheControl.revalidate).toBeNull(); - - const hit = await handler.get("static-infinity"); - expect(hit).not.toBeNull(); - expect(hit?.cacheControl).toEqual({ revalidate: Infinity }); - expect(hit?.value?.kind).toBe("APP_PAGE"); - expect(consoleError).not.toHaveBeenCalledWith( - expect.stringContaining("Invalid cache entry shape"), - expect.anything(), - ); - expect(kv.delete).not.toHaveBeenCalledWith("cache:static-infinity"); - } finally { - consoleError.mockRestore(); - } + const stored = JSON.parse(store.get("cache:static-infinity")!); + expect(stored).toMatchObject({ + revalidateAt: null, + cacheControl: { revalidate: null }, + }); + expect(kv.put).toHaveBeenCalledWith("cache:static-infinity", expect.any(String), { + expirationTtl: 30 * 24 * 3600, + metadata: { tags: [] }, + }); + expect((await handler.get("static-infinity"))?.cacheControl).toEqual({ + revalidate: Infinity, + }); + expect(kv.delete).not.toHaveBeenCalled(); }); - it("heals legacy beta.9/10 entries already stored with revalidate: null", async () => { - store.set( - "cache:legacy-infinity", - JSON.stringify({ - value: { - kind: "PAGES", - html: "legacy", - pageData: {}, - status: 200, - }, - tags: [], - lastModified: Date.now(), - revalidateAt: null, - cacheControl: { revalidate: null }, - }), - ); + it.each([NaN, -Infinity])( + "does not encode invalid revalidate %s as Infinity", + async (value) => { + await handler.set("invalid-revalidate", null, { cacheControl: { revalidate: value } }); - const hit = await handler.get("legacy-infinity"); - expect(hit).not.toBeNull(); - expect(hit?.cacheControl).toEqual({ revalidate: Infinity }); - }); + expect(kv.put).not.toHaveBeenCalled(); + }, + ); it("serves stale when a shorter read-time revalidate has elapsed", async () => { vi.useFakeTimers({ toFake: ["Date"] });