mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
48e932e615
* 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.
238 lines
8.8 KiB
TypeScript
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"]);
|
|
});
|
|
});
|