Files
cloudflare__vinext/examples/workers-cache/app/styles.css
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

269 lines
4.3 KiB
CSS

:root {
color-scheme: light dark;
--bg: #0b1020;
--panel: #111733;
--panel-2: #161c3d;
--text: #e7ecff;
--muted: #93a0c8;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--good: #34d399;
--warn: #f59e0b;
--bad: #f43f5e;
--border: rgba(255, 255, 255, 0.08);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(1200px 600px at 80% -10%, rgba(139, 92, 246, 0.18), transparent),
radial-gradient(800px 400px at -10% 10%, rgba(34, 211, 238, 0.12), transparent), var(--bg);
color: var(--text);
}
a {
color: var(--accent-2);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
main {
max-width: 980px;
margin: 0 auto;
padding: 48px 24px 96px;
}
h1 {
font-size: 2.25rem;
margin: 0 0 8px;
letter-spacing: -0.02em;
}
h2 {
margin-top: 32px;
font-size: 1.25rem;
letter-spacing: -0.01em;
}
.tagline {
color: var(--muted);
margin: 0 0 32px;
max-width: 70ch;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
margin-top: 16px;
}
.card {
background: linear-gradient(180deg, var(--panel), var(--panel-2));
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 140px;
}
.card h3 {
margin: 0;
font-size: 1rem;
letter-spacing: 0;
}
.card p {
color: var(--muted);
margin: 0;
font-size: 0.9rem;
line-height: 1.4;
}
.card a {
margin-top: auto;
font-weight: 600;
}
.badge {
display: inline-block;
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
background: rgba(139, 92, 246, 0.2);
color: #d6c4ff;
margin-right: 6px;
}
.badge-hit {
background: rgba(52, 211, 153, 0.18);
color: #aaf3d4;
}
.badge-miss {
background: rgba(244, 63, 94, 0.18);
color: #ffb5c2;
}
.badge-stale {
background: rgba(245, 158, 11, 0.18);
color: #ffd692;
}
.kv {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 16px;
margin: 12px 0 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem;
}
.kv dt {
color: var(--muted);
}
.kv dd {
margin: 0;
word-break: break-all;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.controls button,
.controls a.button {
appearance: none;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
padding: 10px 14px;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.05s ease, background 0.15s ease;
}
.controls button:hover,
.controls a.button:hover {
background: rgba(139, 92, 246, 0.2);
text-decoration: none;
}
.controls button:active {
transform: scale(0.98);
}
.status {
font-size: 0.85rem;
color: var(--muted);
margin-top: 8px;
min-height: 1.2em;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px;
margin-top: 16px;
}
code {
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 6px;
font-size: 0.85em;
}
.ssr-banner {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
border-radius: 12px;
margin: 16px 0;
border: 1px solid var(--border);
position: relative;
}
.ssr-banner::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
border-radius: 12px 0 0 12px;
}
.ssr-banner strong {
font-size: 0.95rem;
letter-spacing: -0.01em;
}
.ssr-banner span {
font-size: 0.85rem;
color: var(--muted);
}
.ssr-banner-ran {
background: rgba(245, 158, 11, 0.08);
}
.ssr-banner-ran::before {
background: var(--warn);
}
.ssr-banner-cached {
background: rgba(52, 211, 153, 0.08);
}
.ssr-banner-cached::before {
background: var(--good);
}
.ssr-banner-unknown {
background: rgba(147, 160, 200, 0.06);
}
.ssr-banner-unknown::before {
background: var(--muted);
}
.timestamp {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 1rem;
background: rgba(0, 0, 0, 0.25);
border-radius: 8px;
padding: 10px 14px;
display: inline-block;
margin-top: 6px;
}
nav.crumbs {
margin-bottom: 24px;
color: var(--muted);
font-size: 0.9rem;
}
nav.crumbs a {
color: var(--muted);
}