diff --git a/examples/workers-cache/README.md b/examples/workers-cache/README.md index 1bfcf783e..ded3b118b 100644 --- a/examples/workers-cache/README.md +++ b/examples/workers-cache/README.md @@ -80,8 +80,10 @@ the Cloudflare build. The KV adapter still needs a matching 4. **ISR responses** carry `CDN-Cache-Control: public, max-age=N, stale-while-revalidate=M` (for the edge) plus a browser-facing `Cache-Control: public, max-age=0, must-revalidate`, and a `Cache-Tag` - header listing both the bare path (`/cached/intro`) and Next.js's internal - `_N_T_` form. The Workers Cache reads these to cache and tag-purge. + header containing Cloudflare-safe fixed-size digests of both the bare path + (`/cached/intro`) and Next.js's internal `_N_T_` form. The Workers + Cache reads these to cache and tag-purge without losing Next.js's + case-sensitive tag semantics. 5. **`revalidateTag` / `revalidatePath`** in your route handlers fan out to both the KV data cache and `ctx.cache.purge(...)` on the platform layer. diff --git a/packages/cloudflare/src/cache/cdn-adapter.runtime.ts b/packages/cloudflare/src/cache/cdn-adapter.runtime.ts index 7e597527d..f2d6b58b0 100644 --- a/packages/cloudflare/src/cache/cdn-adapter.runtime.ts +++ b/packages/cloudflare/src/cache/cdn-adapter.runtime.ts @@ -22,11 +22,9 @@ * for the edge to honor max-age + stale-while-revalidate. * - `revalidateTag` purges the edge via the request context's `cache.purge({ tags })`. * - * Tag alignment: the tags emitted in `Cache-Tag` come from the page's render - * tags (already canonicalised via `encodeCacheTag`), and the framework's - * `revalidateTag` / `revalidatePath` pass the same canonical form to this - * adapter's `revalidateTag`, so a purge targets exactly the responses that - * carried the tag. + * Tags use fixed-size lowercase digests before emission and purge because + * Workers Cache tags are case-insensitive printable ASCII, while Next.js tags + * are case-sensitive arbitrary strings. * * The default export is the adapter factory the generated * `virtual:vinext-cache-adapters` registration imports; configure it from @@ -42,9 +40,20 @@ import { } from "vinext/shims/cdn-cache"; import type { CacheHandlerValue, IncrementalCacheValue } from "vinext/shims/cache"; import { getRequestExecutionContext } from "vinext/shims/request-context"; +import { fnv1a64 } from "vinext/internal/utils/hash"; import { VINEXT_CDN_BUILD_ID_HEADER } from "./cdn-build-id.js"; import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "../version-headers.js"; +type WorkersCachePurgeError = { + code: number; + message: string; +}; + +type WorkersCachePurgeResult = { + errors: WorkersCachePurgeError[]; + success: boolean; +}; + const DEFAULT_VERSION_METADATA_BINDING = "CF_VERSION_METADATA"; const WORKER_VERSION_OVERRIDE_HEADER = "Cloudflare-Workers-Version-Overrides"; @@ -103,7 +112,9 @@ function hasExplicitCloudflareNonCacheableResponsePolicy(headers: Headers): bool /** The request-context cache surface this adapter relies on (narrowed from `unknown`). */ type WorkersCacheLike = { - purge(options: { tags: string[] }): Promise; + // Miniflare currently resolves undefined; production Workers returns the + // documented result object. + purge(options: { tags: string[] }): Promise; }; function getWorkersCache(): WorkersCacheLike | null { @@ -153,30 +164,27 @@ function toEdgeCacheControl(cacheControl: string): string { } /** - * Cloudflare's `Cache-Tag` header budget is 16 KB total with each tag capped at - * 1024 bytes. Keep a conservative ceiling so a page with a large tag set never - * produces an oversized (silently-dropped) header. + * Cloudflare's `Cache-Tag` header budget is 16 KB total. Fixed-size digests let + * the full Next.js tag set fit without dropping valid long or Unicode tags. */ -const MAX_CACHE_TAG_BYTES = 8 * 1024; -const MAX_SINGLE_TAG_BYTES = 1024; +const MAX_CACHE_TAG_BYTES = 16 * 1024; +const CACHE_TAG_PREFIX = "vinext-"; + +/** Encode a case-sensitive Next.js tag into a fixed Workers Cache tag. */ +export function encodeCloudflareCacheTag(tag: string): string { + // Two domain-separated 64-bit rounds keep accidental collisions negligible + // while staying synchronous for the response-header interface. + return `${CACHE_TAG_PREFIX}${fnv1a64(`0:${tag}`)}${fnv1a64(`1:${tag}`)}`; +} /** - * Build a `Cache-Tag` header value from canonicalised tags. Tags containing a - * comma (the header separator) or exceeding the per-tag size are skipped, and - * the whole value is bounded to stay within Cloudflare's limit. + * Build a complete `Cache-Tag` header value from canonicalised tags. Returning + * null makes the response uncacheable rather than caching with incomplete + * invalidation metadata. */ function formatCacheTag(tags: readonly string[]): string | null { - const parts: string[] = []; - let total = 0; - for (const tag of tags) { - if (!tag || tag.includes(",") || tag.length > MAX_SINGLE_TAG_BYTES) continue; - // +1 accounts for the joining comma. - const next = total + tag.length + (parts.length > 0 ? 1 : 0); - if (next > MAX_CACHE_TAG_BYTES) break; - parts.push(tag); - total = next; - } - return parts.length > 0 ? parts.join(",") : null; + const value = tags.map(encodeCloudflareCacheTag).join(","); + return value && value.length <= MAX_CACHE_TAG_BYTES ? value : null; } export class CloudflareCdnCacheAdapter implements CdnCacheAdapter { @@ -259,6 +267,13 @@ export class CloudflareCdnCacheAdapter implements CdnCacheAdapter { "Cache-Tag": input.tags ? formatCacheTag(input.tags) : null, }; + if (input.tags && input.tags.length > 0) { + const cacheTag = formatCacheTag(input.tags); + if (!cacheTag) { + return clearCloudflareCdnResponseHeaders(NO_STORE); + } + headers["Cache-Tag"] = cacheTag; + } return headers; } @@ -272,11 +287,15 @@ export class CloudflareCdnCacheAdapter implements CdnCacheAdapter { if (!cache) return; // no host cache in the request context (e.g. Node dev) const tagList = (Array.isArray(tags) ? tags : [tags]).filter( - (t): t is string => typeof t === "string" && t.length > 0, + (t): t is string => typeof t === "string", ); if (tagList.length === 0) return; - await cache.purge({ tags: tagList }); + const result = await cache.purge({ tags: tagList.map(encodeCloudflareCacheTag) }); + if (result?.success === false) { + const errors = result.errors.map(({ code, message }) => `${code}: ${message}`).join(", "); + throw new Error(`[vinext] Workers Cache purge failed${errors ? `: ${errors}` : ""}`); + } } } diff --git a/tests/app-route-handler-response.test.ts b/tests/app-route-handler-response.test.ts index 50dddbbb2..cd69c5caa 100644 --- a/tests/app-route-handler-response.test.ts +++ b/tests/app-route-handler-response.test.ts @@ -10,7 +10,10 @@ import { markRouteHandlerCacheMiss, } from "../packages/vinext/src/server/app-route-handler-response.js"; import { setCdnCacheAdapter } from "../packages/vinext/src/shims/cdn-cache.js"; -import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js"; +import { + CloudflareCdnCacheAdapter, + encodeCloudflareCacheTag, +} from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js"; import { hasPostConfigLinkHeaders } from "../packages/vinext/src/server/app-response-header-provenance.js"; function buildCachedRouteValue( @@ -427,7 +430,9 @@ describe("route handler responses route through the CDN cache adapter", () => { expect(response.headers.get("CDN-Cache-Control")).toBe( "public, max-age=60, stale-while-revalidate=540", ); - expect(response.headers.get("Cache-Tag")).toBe("_N_T_/api/feed,posts"); + expect(response.headers.get("Cache-Tag")).toBe( + ["_N_T_/api/feed", "posts"].map(encodeCloudflareCacheTag).join(","), + ); }); it("does not promote a revalidate=0 (non-cacheable) response to the edge", () => { diff --git a/tests/cloudflare-cdn-cache.test.ts b/tests/cloudflare-cdn-cache.test.ts index 5f1fb0ccb..87570a151 100644 --- a/tests/cloudflare-cdn-cache.test.ts +++ b/tests/cloudflare-cdn-cache.test.ts @@ -10,6 +10,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test"; import createCloudflareCdnCacheAdapter, { + encodeCloudflareCacheTag, CloudflareCdnCacheAdapter, } from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js"; import { @@ -230,17 +231,46 @@ describe("CloudflareCdnCacheAdapter", () => { cacheControl: "s-maxage=60", tags: ["/blog", "_N_T_/blog", "posts"], }); - expect(headers["Cache-Tag"]).toBe("/blog,_N_T_/blog,posts"); + expect(headers["Cache-Tag"]).toBe( + ["/blog", "_N_T_/blog", "posts"].map(encodeCloudflareCacheTag).join(","), + ); expect(headers["Cache-Control"]).toBe("public, max-age=0, must-revalidate"); expect(headers["CDN-Cache-Control"]).toBe("public, max-age=60"); }); - it("skips tags containing the comma separator or that are too long", () => { + it("preserves an empty Next.js tag in response invalidation metadata", () => { + const tags = ["", "posts"]; + const headers = adapter.buildResponseHeaders({ cacheControl: "s-maxage=60", tags }); + expect(headers["Cache-Tag"]).toBe(tags.map(encodeCloudflareCacheTag).join(",")); + }); + + it("encodes the complete Next-valid set of long Unicode and case-sensitive tags", () => { + const tags = [ + "é".repeat(128), + "Product List", + "product list", + ...Array.from({ length: 125 }, (_, index) => `tag-${index}-${"x".repeat(240)}`), + ]; const headers = adapter.buildResponseHeaders({ cacheControl: "s-maxage=60", - tags: ["a,b", "x".repeat(2000), "ok"], + tags, + }); + expect(headers["Cache-Tag"]).toBe(tags.map(encodeCloudflareCacheTag).join(",")); + expect(headers["Cache-Tag"]?.split(",")).toHaveLength(128); + }); + + it("makes a response uncacheable rather than emitting incomplete tag metadata", () => { + expect( + adapter.buildResponseHeaders({ + cacheControl: "s-maxage=60", + tags: Array.from({ length: 500 }, (_, index) => `tag-${index}`), + }), + ).toEqual({ + "Cache-Control": "no-store", + "CDN-Cache-Control": null, + "Cloudflare-CDN-Cache-Control": null, + "Cache-Tag": null, }); - expect(headers["Cache-Tag"]).toBe("ok"); }); it("returns no-store and clears owned headers when there is no cacheable policy", () => { @@ -364,7 +394,7 @@ describe("CloudflareCdnCacheAdapter", () => { "public, max-age=60, stale-while-revalidate=31536000", ); expect(response.headers.get("Cloudflare-CDN-Cache-Control")).toBeNull(); - expect(response.headers.get("Cache-Tag")).toBe("/dynamic-html"); + expect(response.headers.get("Cache-Tag")).toBe(encodeCloudflareCacheTag("/dynamic-html")); await expect(response.text()).resolves.toBe("

personalized

"); await Promise.all(pendingCacheWrites); expect(isrSet).not.toHaveBeenCalled(); @@ -502,25 +532,35 @@ describe("CloudflareCdnCacheAdapter", () => { expect(response.headers.get("Cache-Control")).toBe("public, max-age=0, must-revalidate"); expect(response.headers.get("CDN-Cache-Control")).toBe("public, max-age=60"); expect(response.headers.get("Cloudflare-CDN-Cache-Control")).toBeNull(); - expect(response.headers.get("Cache-Tag")).toBe("/dashboard"); + expect(response.headers.get("Cache-Tag")).toBe(encodeCloudflareCacheTag("/dashboard")); expect(response.headers.get("X-Vinext-Cache")).toBe("MISS"); await expect(response.text()).resolves.toBe("pending-dynamic-flight"); }); it("revalidateTag purges the Workers Cache by tag via ctx.cache.purge", async () => { - const purge = vi.fn(async () => {}); + const purge = vi.fn(async () => ({ errors: [], success: true })); await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => { await adapter.revalidateTag(["posts", "_N_T_/blog"]); }); - expect(purge).toHaveBeenCalledWith({ tags: ["posts", "_N_T_/blog"] }); + expect(purge).toHaveBeenCalledWith({ + tags: ["posts", "_N_T_/blog"].map(encodeCloudflareCacheTag), + }); }); it("revalidateTag normalizes a single tag to an array", async () => { - const purge = vi.fn(async () => {}); + const purge = vi.fn(async () => ({ errors: [], success: true })); await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => { await adapter.revalidateTag("posts"); }); - expect(purge).toHaveBeenCalledWith({ tags: ["posts"] }); + expect(purge).toHaveBeenCalledWith({ tags: [encodeCloudflareCacheTag("posts")] }); + }); + + it("revalidateTag purges an empty Next.js tag", async () => { + const purge = vi.fn(async () => ({ errors: [], success: true })); + await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => { + await adapter.revalidateTag(""); + }); + expect(purge).toHaveBeenCalledWith({ tags: [encodeCloudflareCacheTag("")] }); }); it("revalidateTag is a no-op when the Workers Cache is absent (e.g. Node dev)", async () => { @@ -529,12 +569,24 @@ describe("CloudflareCdnCacheAdapter", () => { }); it("revalidateTag does not purge for an empty tag set", async () => { - const purge = vi.fn(async () => {}); + const purge = vi.fn(async () => ({ errors: [], success: true })); await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => { await adapter.revalidateTag([]); }); expect(purge).not.toHaveBeenCalled(); }); + + it("surfaces a resolved Workers Cache purge failure", async () => { + const purge = vi.fn(async () => ({ + errors: [{ code: 10000, message: "rate limited" }], + success: false, + })); + await expect( + runWithExecutionContext({ waitUntil() {}, cache: { purge } }, () => + adapter.revalidateTag("posts"), + ), + ).rejects.toThrow("Workers Cache purge failed: 10000: rate limited"); + }); }); // ─── Adapter selection ────────────────────────────────────────────────────