Skip to content

Commit 1856768

Browse files
committed
fix(cloudflare): preserve cache invalidation parity
1 parent a5a3fc1 commit 1856768

4 files changed

Lines changed: 120 additions & 42 deletions

File tree

examples/workers-cache/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,10 @@ the Cloudflare build. The KV adapter still needs a matching
8080
4. **ISR responses** carry `CDN-Cache-Control: public, max-age=N,
8181
stale-while-revalidate=M` (for the edge) plus a browser-facing
8282
`Cache-Control: public, max-age=0, must-revalidate`, and a `Cache-Tag`
83-
header listing both the bare path (`/cached/intro`) and Next.js's internal
84-
`_N_T_<path>` form. The Workers Cache reads these to cache and tag-purge.
83+
header containing Cloudflare-safe fixed-size digests of both the bare path
84+
(`/cached/intro`) and Next.js's internal `_N_T_<path>` form. The Workers
85+
Cache reads these to cache and tag-purge without losing Next.js's
86+
case-sensitive tag semantics.
8587

8688
5. **`revalidateTag` / `revalidatePath`** in your route handlers fan out to
8789
both the KV data cache and `ctx.cache.purge(...)` on the platform layer.

packages/cloudflare/src/cache/cdn-adapter.runtime.ts

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,9 @@
2222
* for the edge to honor max-age + stale-while-revalidate.
2323
* - `revalidateTag` purges the edge via the request context's `cache.purge({ tags })`.
2424
*
25-
* Tag alignment: the tags emitted in `Cache-Tag` come from the page's render
26-
* tags (already canonicalised via `encodeCacheTag`), and the framework's
27-
* `revalidateTag` / `revalidatePath` pass the same canonical form to this
28-
* adapter's `revalidateTag`, so a purge targets exactly the responses that
29-
* carried the tag.
25+
* Tags use fixed-size lowercase digests before emission and purge because
26+
* Workers Cache tags are case-insensitive printable ASCII, while Next.js tags
27+
* are case-sensitive arbitrary strings.
3028
*
3129
* The default export is the adapter factory the generated
3230
* `virtual:vinext-cache-adapters` registration imports; configure it from
@@ -42,9 +40,20 @@ import {
4240
} from "vinext/shims/cdn-cache";
4341
import type { CacheHandlerValue, IncrementalCacheValue } from "vinext/shims/cache";
4442
import { getRequestExecutionContext } from "vinext/shims/request-context";
43+
import { fnv1a64 } from "vinext/internal/utils/hash";
4544
import { VINEXT_CDN_BUILD_ID_HEADER } from "./cdn-build-id.js";
4645
import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "../version-headers.js";
4746

47+
type WorkersCachePurgeError = {
48+
code: number;
49+
message: string;
50+
};
51+
52+
type WorkersCachePurgeResult = {
53+
errors: WorkersCachePurgeError[];
54+
success: boolean;
55+
};
56+
4857
const DEFAULT_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
4958
const WORKER_VERSION_OVERRIDE_HEADER = "Cloudflare-Workers-Version-Overrides";
5059

@@ -103,7 +112,9 @@ function hasExplicitCloudflareNonCacheableResponsePolicy(headers: Headers): bool
103112

104113
/** The request-context cache surface this adapter relies on (narrowed from `unknown`). */
105114
type WorkersCacheLike = {
106-
purge(options: { tags: string[] }): Promise<unknown>;
115+
// Miniflare currently resolves undefined; production Workers returns the
116+
// documented result object.
117+
purge(options: { tags: string[] }): Promise<WorkersCachePurgeResult | undefined>;
107118
};
108119

109120
function getWorkersCache(): WorkersCacheLike | null {
@@ -153,30 +164,27 @@ function toEdgeCacheControl(cacheControl: string): string {
153164
}
154165

155166
/**
156-
* Cloudflare's `Cache-Tag` header budget is 16 KB total with each tag capped at
157-
* 1024 bytes. Keep a conservative ceiling so a page with a large tag set never
158-
* produces an oversized (silently-dropped) header.
167+
* Cloudflare's `Cache-Tag` header budget is 16 KB total. Fixed-size digests let
168+
* the full Next.js tag set fit without dropping valid long or Unicode tags.
159169
*/
160-
const MAX_CACHE_TAG_BYTES = 8 * 1024;
161-
const MAX_SINGLE_TAG_BYTES = 1024;
170+
const MAX_CACHE_TAG_BYTES = 16 * 1024;
171+
const CACHE_TAG_PREFIX = "vinext-";
172+
173+
/** Encode a case-sensitive Next.js tag into a fixed Workers Cache tag. */
174+
export function encodeCloudflareCacheTag(tag: string): string {
175+
// Two domain-separated 64-bit rounds keep accidental collisions negligible
176+
// while staying synchronous for the response-header interface.
177+
return `${CACHE_TAG_PREFIX}${fnv1a64(`0:${tag}`)}${fnv1a64(`1:${tag}`)}`;
178+
}
162179

163180
/**
164-
* Build a `Cache-Tag` header value from canonicalised tags. Tags containing a
165-
* comma (the header separator) or exceeding the per-tag size are skipped, and
166-
* the whole value is bounded to stay within Cloudflare's limit.
181+
* Build a complete `Cache-Tag` header value from canonicalised tags. Returning
182+
* null makes the response uncacheable rather than caching with incomplete
183+
* invalidation metadata.
167184
*/
168185
function formatCacheTag(tags: readonly string[]): string | null {
169-
const parts: string[] = [];
170-
let total = 0;
171-
for (const tag of tags) {
172-
if (!tag || tag.includes(",") || tag.length > MAX_SINGLE_TAG_BYTES) continue;
173-
// +1 accounts for the joining comma.
174-
const next = total + tag.length + (parts.length > 0 ? 1 : 0);
175-
if (next > MAX_CACHE_TAG_BYTES) break;
176-
parts.push(tag);
177-
total = next;
178-
}
179-
return parts.length > 0 ? parts.join(",") : null;
186+
const value = tags.map(encodeCloudflareCacheTag).join(",");
187+
return value && value.length <= MAX_CACHE_TAG_BYTES ? value : null;
180188
}
181189

182190
export class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
@@ -259,6 +267,13 @@ export class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
259267
"Cache-Tag": input.tags ? formatCacheTag(input.tags) : null,
260268
};
261269

270+
if (input.tags && input.tags.length > 0) {
271+
const cacheTag = formatCacheTag(input.tags);
272+
if (!cacheTag) {
273+
return clearCloudflareCdnResponseHeaders(NO_STORE);
274+
}
275+
headers["Cache-Tag"] = cacheTag;
276+
}
262277
return headers;
263278
}
264279

@@ -272,11 +287,15 @@ export class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
272287
if (!cache) return; // no host cache in the request context (e.g. Node dev)
273288

274289
const tagList = (Array.isArray(tags) ? tags : [tags]).filter(
275-
(t): t is string => typeof t === "string" && t.length > 0,
290+
(t): t is string => typeof t === "string",
276291
);
277292
if (tagList.length === 0) return;
278293

279-
await cache.purge({ tags: tagList });
294+
const result = await cache.purge({ tags: tagList.map(encodeCloudflareCacheTag) });
295+
if (result?.success === false) {
296+
const errors = result.errors.map(({ code, message }) => `${code}: ${message}`).join(", ");
297+
throw new Error(`[vinext] Workers Cache purge failed${errors ? `: ${errors}` : ""}`);
298+
}
280299
}
281300
}
282301

tests/app-route-handler-response.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010
markRouteHandlerCacheMiss,
1111
} from "../packages/vinext/src/server/app-route-handler-response.js";
1212
import { setCdnCacheAdapter } from "../packages/vinext/src/shims/cdn-cache.js";
13-
import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js";
13+
import {
14+
CloudflareCdnCacheAdapter,
15+
encodeCloudflareCacheTag,
16+
} from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js";
1417
import { hasPostConfigLinkHeaders } from "../packages/vinext/src/server/app-response-header-provenance.js";
1518

1619
function buildCachedRouteValue(
@@ -427,7 +430,9 @@ describe("route handler responses route through the CDN cache adapter", () => {
427430
expect(response.headers.get("CDN-Cache-Control")).toBe(
428431
"public, max-age=60, stale-while-revalidate=540",
429432
);
430-
expect(response.headers.get("Cache-Tag")).toBe("_N_T_/api/feed,posts");
433+
expect(response.headers.get("Cache-Tag")).toBe(
434+
["_N_T_/api/feed", "posts"].map(encodeCloudflareCacheTag).join(","),
435+
);
431436
});
432437

433438
it("does not promote a revalidate=0 (non-cacheable) response to the edge", () => {

tests/cloudflare-cdn-cache.test.ts

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111
import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
1212
import createCloudflareCdnCacheAdapter, {
13+
encodeCloudflareCacheTag,
1314
CloudflareCdnCacheAdapter,
1415
} from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js";
1516
import {
@@ -230,17 +231,46 @@ describe("CloudflareCdnCacheAdapter", () => {
230231
cacheControl: "s-maxage=60",
231232
tags: ["/blog", "_N_T_/blog", "posts"],
232233
});
233-
expect(headers["Cache-Tag"]).toBe("/blog,_N_T_/blog,posts");
234+
expect(headers["Cache-Tag"]).toBe(
235+
["/blog", "_N_T_/blog", "posts"].map(encodeCloudflareCacheTag).join(","),
236+
);
234237
expect(headers["Cache-Control"]).toBe("public, max-age=0, must-revalidate");
235238
expect(headers["CDN-Cache-Control"]).toBe("public, max-age=60");
236239
});
237240

238-
it("skips tags containing the comma separator or that are too long", () => {
241+
it("preserves an empty Next.js tag in response invalidation metadata", () => {
242+
const tags = ["", "posts"];
243+
const headers = adapter.buildResponseHeaders({ cacheControl: "s-maxage=60", tags });
244+
expect(headers["Cache-Tag"]).toBe(tags.map(encodeCloudflareCacheTag).join(","));
245+
});
246+
247+
it("encodes the complete Next-valid set of long Unicode and case-sensitive tags", () => {
248+
const tags = [
249+
"é".repeat(128),
250+
"Product List",
251+
"product list",
252+
...Array.from({ length: 125 }, (_, index) => `tag-${index}-${"x".repeat(240)}`),
253+
];
239254
const headers = adapter.buildResponseHeaders({
240255
cacheControl: "s-maxage=60",
241-
tags: ["a,b", "x".repeat(2000), "ok"],
256+
tags,
257+
});
258+
expect(headers["Cache-Tag"]).toBe(tags.map(encodeCloudflareCacheTag).join(","));
259+
expect(headers["Cache-Tag"]?.split(",")).toHaveLength(128);
260+
});
261+
262+
it("makes a response uncacheable rather than emitting incomplete tag metadata", () => {
263+
expect(
264+
adapter.buildResponseHeaders({
265+
cacheControl: "s-maxage=60",
266+
tags: Array.from({ length: 500 }, (_, index) => `tag-${index}`),
267+
}),
268+
).toEqual({
269+
"Cache-Control": "no-store",
270+
"CDN-Cache-Control": null,
271+
"Cloudflare-CDN-Cache-Control": null,
272+
"Cache-Tag": null,
242273
});
243-
expect(headers["Cache-Tag"]).toBe("ok");
244274
});
245275

246276
it("returns no-store and clears owned headers when there is no cacheable policy", () => {
@@ -364,7 +394,7 @@ describe("CloudflareCdnCacheAdapter", () => {
364394
"public, max-age=60, stale-while-revalidate=31536000",
365395
);
366396
expect(response.headers.get("Cloudflare-CDN-Cache-Control")).toBeNull();
367-
expect(response.headers.get("Cache-Tag")).toBe("/dynamic-html");
397+
expect(response.headers.get("Cache-Tag")).toBe(encodeCloudflareCacheTag("/dynamic-html"));
368398
await expect(response.text()).resolves.toBe("<h1>personalized</h1>");
369399
await Promise.all(pendingCacheWrites);
370400
expect(isrSet).not.toHaveBeenCalled();
@@ -502,25 +532,35 @@ describe("CloudflareCdnCacheAdapter", () => {
502532
expect(response.headers.get("Cache-Control")).toBe("public, max-age=0, must-revalidate");
503533
expect(response.headers.get("CDN-Cache-Control")).toBe("public, max-age=60");
504534
expect(response.headers.get("Cloudflare-CDN-Cache-Control")).toBeNull();
505-
expect(response.headers.get("Cache-Tag")).toBe("/dashboard");
535+
expect(response.headers.get("Cache-Tag")).toBe(encodeCloudflareCacheTag("/dashboard"));
506536
expect(response.headers.get("X-Vinext-Cache")).toBe("MISS");
507537
await expect(response.text()).resolves.toBe("pending-dynamic-flight");
508538
});
509539

510540
it("revalidateTag purges the Workers Cache by tag via ctx.cache.purge", async () => {
511-
const purge = vi.fn(async () => {});
541+
const purge = vi.fn(async () => ({ errors: [], success: true }));
512542
await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => {
513543
await adapter.revalidateTag(["posts", "_N_T_/blog"]);
514544
});
515-
expect(purge).toHaveBeenCalledWith({ tags: ["posts", "_N_T_/blog"] });
545+
expect(purge).toHaveBeenCalledWith({
546+
tags: ["posts", "_N_T_/blog"].map(encodeCloudflareCacheTag),
547+
});
516548
});
517549

518550
it("revalidateTag normalizes a single tag to an array", async () => {
519-
const purge = vi.fn(async () => {});
551+
const purge = vi.fn(async () => ({ errors: [], success: true }));
520552
await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => {
521553
await adapter.revalidateTag("posts");
522554
});
523-
expect(purge).toHaveBeenCalledWith({ tags: ["posts"] });
555+
expect(purge).toHaveBeenCalledWith({ tags: [encodeCloudflareCacheTag("posts")] });
556+
});
557+
558+
it("revalidateTag purges an empty Next.js tag", async () => {
559+
const purge = vi.fn(async () => ({ errors: [], success: true }));
560+
await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => {
561+
await adapter.revalidateTag("");
562+
});
563+
expect(purge).toHaveBeenCalledWith({ tags: [encodeCloudflareCacheTag("")] });
524564
});
525565

526566
it("revalidateTag is a no-op when the Workers Cache is absent (e.g. Node dev)", async () => {
@@ -529,12 +569,24 @@ describe("CloudflareCdnCacheAdapter", () => {
529569
});
530570

531571
it("revalidateTag does not purge for an empty tag set", async () => {
532-
const purge = vi.fn(async () => {});
572+
const purge = vi.fn(async () => ({ errors: [], success: true }));
533573
await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => {
534574
await adapter.revalidateTag([]);
535575
});
536576
expect(purge).not.toHaveBeenCalled();
537577
});
578+
579+
it("surfaces a resolved Workers Cache purge failure", async () => {
580+
const purge = vi.fn(async () => ({
581+
errors: [{ code: 10000, message: "rate limited" }],
582+
success: false,
583+
}));
584+
await expect(
585+
runWithExecutionContext({ waitUntil() {}, cache: { purge } }, () =>
586+
adapter.revalidateTag("posts"),
587+
),
588+
).rejects.toThrow("Workers Cache purge failed: 10000: rate limited");
589+
});
538590
});
539591

540592
// ─── Adapter selection ────────────────────────────────────────────────────

0 commit comments

Comments
 (0)