Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions examples/workers-cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<path>` 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_<path>` 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.
Expand Down
73 changes: 46 additions & 27 deletions packages/cloudflare/src/cache/cdn-adapter.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";

Expand Down Expand Up @@ -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<unknown>;
// Miniflare currently resolves undefined; production Workers returns the
// documented result object.
purge(options: { tags: string[] }): Promise<WorkersCachePurgeResult | undefined>;
};

function getWorkersCache(): WorkersCacheLike | null {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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}` : ""}`);
}
}
}

Expand Down
9 changes: 7 additions & 2 deletions tests/app-route-handler-response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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", () => {
Expand Down
74 changes: 63 additions & 11 deletions tests/cloudflare-cdn-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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("<h1>personalized</h1>");
await Promise.all(pendingCacheWrites);
expect(isrSet).not.toHaveBeenCalled();
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 ────────────────────────────────────────────────────
Expand Down
Loading