Files
cloudflare__vinext/tests/cloudflare-cdn-cache.test.ts
T
James Anderson 8d00797ada feat(cache): extract Cloudflare cache adapters into @vinext/cloudflare (#1748)
* refactor(cache): extract Cloudflare cache adapters into @vinext/cloudflare

Move the Cloudflare KV data cache and edge CDN cache adapters out of
vinext into a new publishable @vinext/cloudflare package:

  - cache/kv-data-adapter(.runtime).ts  (KVCacheHandler, kvDataAdapter)
  - cache/cdn-adapter(.runtime).ts       (CloudflareCdnCacheAdapter, cdnAdapter)

tpr.ts stays in vinext. vinext now depends on @vinext/cloudflare
(workspace:*) and the package declares vinext as a peer dep; both build
from source via tsconfig paths so there is no build-order cycle. The
vinext/cloudflare barrel still re-exports KVCacheHandler for back-compat.

Wires up tsconfig paths, a vitest source alias, root build/postinstall,
and the preview/publish workflows for the new package. Updates internal
consumers (apps/web, examples/workers-cache), docs, and tests.

* ci(create-next-app): install @vinext/cloudflare from local tarball

vinext now depends on @vinext/cloudflare, which isn't published to npm
yet. The create-next-app smoke test packs vinext locally and resolves
its deps from the registry, so the install (and dev server) failed with
ERR_PNPM_FETCH_404 for @vinext/cloudflare.

Pack @vinext/cloudflare alongside vinext and add a pnpm override in the
scaffolded project pointing at the local tarball so the dependency
resolves offline.

* refactor(cloudflare): address review feedback

- Remove the root barrel export from @vinext/cloudflare; expose only the
  ./cache/* subpaths via a wildcard export (no root main/types).
- vinext/cloudflare re-exports KVCacheHandler from the full subpath.
- Drop the redundant .npmignore (the package.json "files" allowlist
  already restricts the publish to dist).
- Remove the unsupported imperative setCacheHandler/KVCacheHandler usage
  from both READMEs; the cache plugin config is the supported approach.
- Simplify test wiring: drop the now-unused @vinext/cloudflare tsconfig
  path and dedupe the vitest source alias into a shared constant.

* chore(cloudflare): drop unused vite devDependency

The @vinext/cloudflare config uses vite-plus and nothing imports vite, so
the vite devDependency was unused. build/check/knip stay green without it.

* Apply suggestion from @james-elicx
2026-06-05 14:57:20 +01:00

186 lines
7.5 KiB
TypeScript

/**
* CloudflareCdnCacheAdapter + auto-detection tests.
*
* Covers the edge-managed adapter backed by the Workers Cache (ctx.cache):
* - get null / set no-op / ownsBackgroundRevalidation false
* - buildResponseHeaders emits a cacheable Cache-Control + Cache-Tag
* - revalidateTag purges via ctx.cache.purge({ tags })
* - getCdnCacheAdapter() auto-switches to the Cloudflare adapter when the
* VINEXT_CDN_CACHE_AUTO_DETECT flag is set and ctx.cache exists.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js";
import {
getCdnCacheAdapter,
setCdnCacheAdapter,
DefaultCdnCacheAdapter,
} from "../packages/vinext/src/shims/cdn-cache.js";
import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js";
const CDN_KEY = Symbol.for("vinext.cdnCacheAdapter");
const AUTO_DETECT_ENV = "VINEXT_CDN_CACHE_AUTO_DETECT";
function resetActiveAdapter(): void {
delete (globalThis as Record<PropertyKey, unknown>)[CDN_KEY];
}
beforeEach(resetActiveAdapter);
afterEach(resetActiveAdapter);
// ─── Adapter behavior ────────────────────────────────────────────────────
describe("CloudflareCdnCacheAdapter", () => {
const adapter = new CloudflareCdnCacheAdapter();
it("does not own background revalidation (the edge re-requests origin)", () => {
expect(adapter.ownsBackgroundRevalidation).toBe(false);
});
it("get returns null so the origin always renders fresh", async () => {
expect(await adapter.get()).toBeNull();
});
it("set is a no-op (platform caches the response, not an origin store)", async () => {
await expect(adapter.set("k", null)).resolves.toBeUndefined();
});
it("carries SWR on CDN-Cache-Control (public + max-age) and revalidates the browser", () => {
// A value-less `stale-while-revalidate` is normalized to an explicit window
// (Cloudflare ignores the bare directive — RFC 5861 requires a value).
expect(
adapter.buildResponseHeaders({ cacheControl: "s-maxage=60, stale-while-revalidate" }),
).toEqual({
"Cache-Control": "public, max-age=0, must-revalidate",
"CDN-Cache-Control": "public, max-age=60, stale-while-revalidate=31536000",
});
});
it("uses max-age (not s-maxage) and public on the edge directive, even pending-dynamic", () => {
const headers = adapter.buildResponseHeaders({
cacheControl: "s-maxage=60, stale-while-revalidate=540",
pendingDynamicCheck: true,
});
// Edge caches + SWRs via CDN-Cache-Control; the browser always revalidates.
// An already-valued stale-while-revalidate is passed through unchanged.
expect(headers["CDN-Cache-Control"]).toBe("public, max-age=60, stale-while-revalidate=540");
expect(headers["Cache-Control"]).toBe("public, max-age=0, must-revalidate");
});
it("adds a Cache-Tag header from the page tags", () => {
const headers = adapter.buildResponseHeaders({
cacheControl: "s-maxage=60",
tags: ["/blog", "_N_T_/blog", "posts"],
});
expect(headers["Cache-Tag"]).toBe("/blog,_N_T_/blog,posts");
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", () => {
const headers = adapter.buildResponseHeaders({
cacheControl: "s-maxage=60",
tags: ["a,b", "x".repeat(2000), "ok"],
});
expect(headers["Cache-Tag"]).toBe("ok");
});
it("returns only no-store (no CDN-Cache-Control) when there is no cacheable policy", () => {
expect(adapter.buildResponseHeaders({ cacheControl: "" })).toEqual({
"Cache-Control": "no-store",
});
});
it("passes a non-cacheable policy through without promoting it to the edge", () => {
// revalidate=0 / gssp paths produce no-store / private — must never become
// a CDN-Cache-Control directive (which would cache an uncacheable response).
for (const cc of [
"no-store, must-revalidate",
"private, no-cache, no-store, max-age=0, must-revalidate",
]) {
const headers = adapter.buildResponseHeaders({ cacheControl: cc, tags: ["x"] });
expect(headers).toEqual({ "Cache-Control": cc });
expect(headers["CDN-Cache-Control"]).toBeUndefined();
expect(headers["Cache-Tag"]).toBeUndefined();
}
});
it("revalidateTag purges the Workers Cache by tag via ctx.cache.purge", async () => {
const purge = vi.fn(async () => {});
await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => {
await adapter.revalidateTag(["posts", "_N_T_/blog"]);
});
expect(purge).toHaveBeenCalledWith({ tags: ["posts", "_N_T_/blog"] });
});
it("revalidateTag normalizes a single tag to an array", async () => {
const purge = vi.fn(async () => {});
await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => {
await adapter.revalidateTag("posts");
});
expect(purge).toHaveBeenCalledWith({ tags: ["posts"] });
});
it("revalidateTag is a no-op when the Workers Cache is absent (e.g. Node dev)", async () => {
// No runWithExecutionContext scope → getRequestExecutionContext() is null.
await expect(adapter.revalidateTag("posts")).resolves.toBeUndefined();
});
it("revalidateTag does not purge for an empty tag set", async () => {
const purge = vi.fn(async () => {});
await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => {
await adapter.revalidateTag([]);
});
expect(purge).not.toHaveBeenCalled();
});
});
// ─── Auto-detection (flag-gated) ───────────────────────────────────────────
describe("auto-switch to the Cloudflare adapter when ctx.cache exists", () => {
afterEach(() => {
delete process.env[AUTO_DETECT_ENV];
});
it("selects the Cloudflare adapter when the flag is on and ctx.cache exists", async () => {
process.env[AUTO_DETECT_ENV] = "1";
resetActiveAdapter();
const adapter = await runWithExecutionContext(
{ waitUntil() {}, cache: { async purge() {} } },
async () => getCdnCacheAdapter(),
);
expect(adapter).toBeInstanceOf(CloudflareCdnCacheAdapter);
});
it("does NOT auto-detect when the flag is off, even with ctx.cache present", async () => {
delete process.env[AUTO_DETECT_ENV];
resetActiveAdapter();
const adapter = await runWithExecutionContext(
{ waitUntil() {}, cache: { async purge() {} } },
async () => getCdnCacheAdapter(),
);
expect(adapter).toBeInstanceOf(DefaultCdnCacheAdapter);
});
it("falls back to the default adapter when ctx.cache is absent (flag on)", async () => {
process.env[AUTO_DETECT_ENV] = "1";
resetActiveAdapter();
// No request context / no ctx.cache → no auto-detection.
expect(getCdnCacheAdapter()).toBeInstanceOf(DefaultCdnCacheAdapter);
});
it("an explicitly set adapter wins over auto-detection", async () => {
process.env[AUTO_DETECT_ENV] = "1";
resetActiveAdapter();
const explicit = new DefaultCdnCacheAdapter();
setCdnCacheAdapter(explicit);
const adapter = await runWithExecutionContext(
{ waitUntil() {}, cache: { async purge() {} } },
async () => getCdnCacheAdapter(),
);
expect(adapter).toBe(explicit);
});
});