Files
cloudflare__vinext/tests/cdn-cache.test.ts
T
James Anderson 48e932e615 feat(cache): split CDN and data cache adapters; add Cloudflare edge adapter (#1693)
* feat(cache): split CDN and data cache adapters; add Cloudflare edge adapter

Separate two caching concerns behind distinct adapters:

- Data cache handler (existing CacheHandler): fetch, "use cache", unstable_cache. Canonical get/setDataCacheHandler; get/setCacheHandler kept as deprecated aliases.

- CDN cache adapter (new): page-level ISR serving strategy — readPage/writePage, buildResponseHeaders (header map), ownsBackgroundRevalidation, revalidate. DefaultCdnCacheAdapter delegates storage to the data cache, so default behavior is unchanged.

Page/route ISR now routes through the CDN adapter (isr-cache, app-page-cache); revalidateTag/revalidatePath/updateTag invalidate the data cache and purge the CDN adapter (default no-op).

Add CloudflareCdnCacheAdapter (edge-managed): readPage null / writePage no-op, ownsBackgroundRevalidation false, emits CDN-Cache-Control for SWR + Cache-Control: no-store (no browser storage) + Cache-Tag, and purges via the request-context cache. Auto-selected via a global detector when the request context exposes a cache handle; explicit setCdnCacheAdapter wins. The request-context type stays generic (cache: unknown).

Adds next.config cdnCacheHandler (symmetric with cacheHandler) and updates the worker codegen to setDataCacheHandler.

* address review: drop config plumbing; refine Cloudflare cache headers

- Remove the cdnCacheHandler next.config plumbing entirely (deferred); next-config.ts is back to baseline.

- CloudflareCdnCacheAdapter: emit the edge directive on CDN-Cache-Control as 'public, max-age=…, stale-while-revalidate=…' (max-age, not s-maxage, so the edge caches + SWRs), and set the browser-facing Cache-Control to 'public, max-age=0, must-revalidate' so a browser never serves a stored copy without revalidating against the edge.

* chore: fix formatting (vp check) for cache adapter files

* feat(cache): route App Router route handlers + Pages Router ISR through the CDN adapter

Closes the two parity gaps from review: route-handler and Pages Router ISR responses now emit the CDN adapter's headers (CDN-Cache-Control + Cache-Tag on edge adapters) instead of a hardcoded Cache-Control.

- Add shared applyCdnResponseHeaders() in cache-control.ts; app-page-cache now uses it (drops its local helper).

- Route handlers: applyRouteHandlerRevalidateHeader (fresh) and buildRouteHandlerCachedResponse (HIT/STALE) go through the adapter; routeTags hoisted in execution so the fresh response carries Cache-Tag.

- Pages Router: fresh ISR response emits a path-based Cache-Tag (matching revalidatePath); HIT/STALE served response routes through the adapter.

- CloudflareCdnCacheAdapter: guard so non-cacheable policies (no-store/no-cache/private) are never promoted to CDN-Cache-Control.

Default behavior unchanged (adapter yields a single identical Cache-Control).

* address review: simplify applyCdnResponseHeaders + strip CDN headers from stored route values

- applyCdnResponseHeaders now clears only Cache-Control (the header vinext stamps internally); the adapter's own headers are applied via set() which overrides, so pre-clearing adapter-specific headers was redundant and presumptuous (per review).

- buildAppRouteCacheValue strips cdn-cache-control / cache-tag so CDN policy headers are never baked into a stored route value (re-derived from the adapter on every served response).

- pages-page-data buildPagesCacheResponse now uses applyCdnResponseHeaders (Headers) for consistency with every other call site.

* revert presumptuous CDN header strip in buildAppRouteCacheValue

Hardcoding cdn-cache-control/cache-tag in the store denylist presumes a specific adapter's header names (the same presumption rejected for applyCdnResponseHeaders) and is also unnecessary: CDN policy headers never reach a stored route value — the edge adapter's writePage is a no-op and the default adapter never emits them. Back to the original denylist.

* example(workers-cache): add demo app for route-cache testing (#1695)

* example(workers-cache): add demo app for route-cache testing

Adds the examples/workers-cache-cloudflare demo app from
feat/route-cache-request-context so the route-cache CDN adapter
changes on this branch can be tested.

* ci: trigger PR workflows on any base branch, not just main

Drop the `branches: [main]` filter from the pull_request trigger in
ci.yml, deploy-examples.yml, and preview-release.yml so these workflows
run on PRs against any base branch (e.g. stacked PRs).

* update lockfile

* ci(deploy-examples): add workers-cache-cloudflare to deploy matrix

Pull in the deploy matrix + preview-URL comment entry from
feat/route-cache-request-context so the workers-cache demo app gets
built, deployed, and linked on PR previews.

* fix(cache): auto-select edge CDN adapter from request context, no import needed

The edge-managed CDN cache adapter was only activated when a detector got
registered as a side effect of importing `vinext/cloudflare`. Apps with a
hand-written worker entry (e.g. the workers-cache demo) never imported it, so
ISR silently fell back to the origin-managed default — emitting plain
`Cache-Control` instead of `CDN-Cache-Control` / `Cache-Tag`.

The adapter is platform-agnostic (it only touches the generic request-context
cache surface), so move it into core as `RequestContextCdnCacheAdapter` and
have `getCdnCacheAdapter()` select it directly. Resolution is now:

  1. explicit `setCdnCacheAdapter()`            (always wins)
  2. request-context cache present (`ctx.cache`) -> edge adapter
  3. otherwise                                    -> origin-managed default

Drops the detector-registry indirection and the import/registration
requirement. `CloudflareCdnCacheAdapter` is kept as a re-export alias for
backwards compatibility.

* fix(cache): select Cloudflare edge adapter from resolver, keep it in the cloudflare module

Previous commit moved the adapter into core — revert that. The
CloudflareCdnCacheAdapter stays in cloudflare/cloudflare-cdn-cache.ts; the
core resolver imports it and instantiates it as the built-in default when the
request context exposes a host cache (ctx.cache). Drops the detector-registry
side-effect-import requirement; resolution is explicit -> ctx.cache edge
adapter -> origin-managed default.

* example(workers-cache): show CDN-Cache-Control in the probe headers

* example(workers-cache): rename example app from workers-cache-cloudflare to workers-cache

Rename the example directory and update its package name, wrangler worker
name, the deploy-examples matrix + preview-URL list, and the lockfile.

* fix(cache): give bare stale-while-revalidate an explicit window for the CF edge

The framework emits a value-less `stale-while-revalidate` (Vercel's unbounded
extension). Cloudflare follows RFC 5861 and ignores the bare directive, so the
edge had no stale window — entries hard-expired at max-age and the next request
was a MISS instead of UPDATING. Normalize bare SWR to an explicit 1-year window
in the edge adapter's toEdgeCacheControl so Cloudflare actually serves stale
while revalidating.

* use link component

* fix(cache): let the CDN adapter own the default Cache-Control when none is set

Rendered responses that produced no cacheable policy (e.g. dynamic App Router
pages) were going out with no Cache-Control at all, bypassing the CDN adapter —
so on the edge Cloudflare applied its own default caching heuristic instead of
the adapter's policy.

Add a guard in finalizeAppRscResponse (the single App Router egress, already
run for every page/route-handler/metadata/not-found response) that, when no
Cache-Control is present, routes through the adapter to supply the default: the
edge adapter emits no-store (never accidentally edge-cache an unspecified
response), the default adapter leaves it absent (unchanged). Runs only when the
header is absent, so it never clobbers a policy a renderer already applied
(incl. CDN-Cache-Control). Also stop applyCdnResponseHeaders from stamping an
empty Cache-Control value.

* refactor(cdn-cache): align adapter method names with data cache + gate ctx.cache auto-detection

Address PR #1693 review feedback:

- Align CdnCacheAdapter field naming with the data cache adapter
  (CacheHandler): readPage->get, writePage->set, revalidate->revalidateTag.
  buildResponseHeaders / ownsBackgroundRevalidation stay CDN-specific (no
  CacheHandler equivalent). Updated both implementations and all call sites.

- Gate ctx.cache auto-detection behind VINEXT_CDN_CACHE_AUTO_DETECT (default
  off) so edge-managed page ISR is opt-in until deployment skew protection is
  figured out. Removed the dedicated _edgeAdapter variable; the resolved edge
  adapter is now stored on the single active-adapter global slot that
  setCdnCacheAdapter uses. Enable the flag for the workers-cache demo via
  wrangler.jsonc vars.

* test(cdn-cache): update tests for renamed adapter API + flag-gated auto-detect

Align the CDN adapter unit tests with the refactor:
- get/set/revalidateTag method names (was readPage/writePage/revalidate)
- bare stale-while-revalidate now normalized to an explicit window
- auto-detection is gated behind VINEXT_CDN_CACHE_AUTO_DETECT (no detector /
  no vinext/cloudflare side-effect import)

* chore: reconcile pnpm-lock.yaml after merge

The merge auto-resolved pnpm-lock.yaml into a broken state (missing
@vitejs/plugin-rsc entry), so `vp install` failed at CI setup. Regenerated
with pnpm install --no-frozen-lockfile; frozen install now passes.
2026-06-04 12:09:27 +01:00

238 lines
8.8 KiB
TypeScript

/**
* CDN cache adapter unit + integration tests.
*
* Covers the page-level ISR serving-strategy split:
* - DefaultCdnCacheAdapter delegates storage to the data cache and reproduces
* the framework's existing header behavior (byte-for-byte).
* - A custom edge adapter can return null from get (origin renders fresh),
* no-op set, emit split Cache-Control + CDN-Cache-Control headers, skip
* in-process background regeneration, and purge via revalidateTag().
* - isrGet/isrSet route through the active CDN adapter.
* - revalidateTag/revalidatePath/updateTag invalidate the data cache AND ask
* the CDN adapter to purge.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
import {
DefaultCdnCacheAdapter,
getCdnCacheAdapter,
setCdnCacheAdapter,
type CdnCacheAdapter,
type CdnCacheableHeaderInput,
type CdnResponseHeaders,
} from "../packages/vinext/src/shims/cdn-cache.js";
import {
MemoryCacheHandler,
setDataCacheHandler,
setCacheHandler,
getDataCacheHandler,
getCacheHandler,
revalidateTag,
revalidatePath,
updateTag,
type CacheHandler,
} from "../packages/vinext/src/shims/cache.js";
import {
isrGet,
isrSet,
triggerBackgroundRegeneration,
buildPagesCacheValue,
} from "../packages/vinext/src/server/isr-cache.js";
import { setHeadersAccessPhase } from "../packages/vinext/src/shims/headers.js";
function resetAdapters(): void {
setDataCacheHandler(new MemoryCacheHandler());
setCdnCacheAdapter(new DefaultCdnCacheAdapter());
}
beforeEach(resetAdapters);
afterEach(resetAdapters);
// ─── Backwards-compatible data cache aliases ─────────────────────────────
describe("data cache handler aliases", () => {
it("setCacheHandler is an alias for setDataCacheHandler", () => {
const handler = new MemoryCacheHandler();
setCacheHandler(handler);
expect(getDataCacheHandler()).toBe(handler);
expect(getCacheHandler()).toBe(handler);
});
it("setDataCacheHandler is visible through the legacy getter", () => {
const handler = new MemoryCacheHandler();
setDataCacheHandler(handler);
expect(getCacheHandler()).toBe(handler);
});
});
// ─── DefaultCdnCacheAdapter ──────────────────────────────────────────────
describe("DefaultCdnCacheAdapter", () => {
it("owns background revalidation (origin-managed ISR)", () => {
expect(new DefaultCdnCacheAdapter().ownsBackgroundRevalidation).toBe(true);
});
it("delegates get/set to the active data cache handler", async () => {
const get = vi.fn(async () => null);
const set = vi.fn(async () => {});
const handler: CacheHandler = { get, set, async revalidateTag() {} };
setDataCacheHandler(handler);
const adapter = new DefaultCdnCacheAdapter();
await adapter.set("k", buildPagesCacheValue("<p>x</p>", {}), { tags: ["t"] });
await adapter.get("k", { kind: "PAGES" });
expect(set).toHaveBeenCalledWith("k", expect.objectContaining({ kind: "PAGES" }), {
tags: ["t"],
});
expect(get).toHaveBeenCalledWith("k", { kind: "PAGES" });
});
it("emits a single Cache-Control header for a cacheable policy", () => {
const headers = new DefaultCdnCacheAdapter().buildResponseHeaders({
cacheControl: "s-maxage=60, stale-while-revalidate",
});
expect(headers).toEqual({ "Cache-Control": "s-maxage=60, stale-while-revalidate" });
});
it("forces no-store while a streamed render's dynamic-ness is unproven", () => {
const headers = new DefaultCdnCacheAdapter().buildResponseHeaders({
cacheControl: "s-maxage=60, stale-while-revalidate",
pendingDynamicCheck: true,
});
// Matches the legacy NO_STORE_CACHE_CONTROL the finalize path used to stamp.
expect(headers).toEqual({ "Cache-Control": "no-store, must-revalidate" });
});
it("revalidateTag() is a no-op (data cache owns store invalidation)", async () => {
await expect(new DefaultCdnCacheAdapter().revalidateTag("tag")).resolves.toBeUndefined();
});
});
// ─── Active adapter resolution ───────────────────────────────────────────
describe("getCdnCacheAdapter / setCdnCacheAdapter", () => {
it("defaults to a DefaultCdnCacheAdapter", () => {
expect(getCdnCacheAdapter()).toBeInstanceOf(DefaultCdnCacheAdapter);
});
it("returns the adapter set via setCdnCacheAdapter", () => {
const custom = new DefaultCdnCacheAdapter();
setCdnCacheAdapter(custom);
expect(getCdnCacheAdapter()).toBe(custom);
});
});
// ─── Edge-managed (Cloudflare-style) adapter ─────────────────────────────
/** Minimal edge adapter: never serves from origin, emits split headers, purges. */
class EdgeCdnAdapter implements CdnCacheAdapter {
readonly ownsBackgroundRevalidation = false;
readonly purges: string[] = [];
writes = 0;
async get(): Promise<null> {
return null; // origin renders fresh; the edge serves the cache
}
async set(): Promise<void> {
this.writes++; // intentionally does not persist anything
}
buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders {
if (!input.cacheControl) return { "Cache-Control": "no-store" };
return { "Cache-Control": "no-store", "CDN-Cache-Control": input.cacheControl };
}
async revalidateTag(tags: string | string[]): Promise<void> {
for (const tag of Array.isArray(tags) ? tags : [tags]) this.purges.push(tag);
}
}
describe("edge CDN adapter integration", () => {
it("isrGet returns null (origin renders) even after isrSet", async () => {
setCdnCacheAdapter(new EdgeCdnAdapter());
await isrSet("app:/p:html", buildPagesCacheValue("<p>cached</p>", {}), 60, []);
expect(await isrGet("app:/p:html")).toBeNull();
});
it("isrSet does not write to the data cache when the edge adapter no-ops storage", async () => {
const set = vi.fn(async () => {});
setDataCacheHandler({
async get() {
return null;
},
set,
async revalidateTag() {},
});
const edge = new EdgeCdnAdapter();
setCdnCacheAdapter(edge);
await isrSet("app:/p:html", buildPagesCacheValue("<p>x</p>", {}), 60, []);
expect(edge.writes).toBe(1);
expect(set).not.toHaveBeenCalled();
});
it("skips in-process background regeneration when the adapter does not own it", async () => {
setCdnCacheAdapter(new EdgeCdnAdapter());
const renderFn = vi.fn(async () => {});
triggerBackgroundRegeneration("regen-edge", renderFn);
await new Promise((r) => setTimeout(r, 10));
expect(renderFn).not.toHaveBeenCalled();
});
it("still runs background regeneration under the default adapter", async () => {
const renderFn = vi.fn(async () => {});
triggerBackgroundRegeneration("regen-default-cdn", renderFn);
await new Promise((r) => setTimeout(r, 10));
expect(renderFn).toHaveBeenCalledOnce();
});
});
// ─── Invalidation propagation ────────────────────────────────────────────
describe("revalidation propagates to both data cache and CDN adapter", () => {
function spyAdapters() {
const dataRevalidate = vi.fn(
async (_tags: string | string[], _durations?: { expire?: number }) => {},
);
setDataCacheHandler({
async get() {
return null;
},
async set() {},
revalidateTag: dataRevalidate,
});
const edge = new EdgeCdnAdapter();
setCdnCacheAdapter(edge);
return { dataRevalidate, edge };
}
it("revalidateTag invalidates the data cache and purges the CDN", async () => {
const { dataRevalidate, edge } = spyAdapters();
await revalidateTag("posts");
expect(dataRevalidate).toHaveBeenCalledWith("posts", undefined);
expect(edge.purges).toEqual(["posts"]);
});
it("revalidatePath invalidates the data cache and purges the CDN", async () => {
const { dataRevalidate, edge } = spyAdapters();
await revalidatePath("/blog");
// Same encoded tag is sent to both layers.
expect(dataRevalidate).toHaveBeenCalledTimes(1);
const tag = dataRevalidate.mock.calls[0][0];
expect(edge.purges).toEqual([tag]);
});
it("updateTag invalidates the data cache and purges the CDN", async () => {
const { dataRevalidate, edge } = spyAdapters();
// updateTag may only be called from within a Server Action.
const previousPhase = setHeadersAccessPhase("action");
try {
await updateTag("cart");
} finally {
setHeadersAccessPhase(previousPhase);
}
expect(dataRevalidate).toHaveBeenCalledTimes(1);
expect(dataRevalidate.mock.calls[0][0]).toBe("cart");
expect(edge.purges).toEqual(["cart"]);
});
});