From b079c9456d49f77b77d2d7d5bd40a3161347e5c9 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Thu, 27 Aug 2026 20:17:12 -0300 Subject: [PATCH] feat(tanstack): opt-in CDN caching for /_serverFn via a segment marker in the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anything in front of the Worker — Workers Cache, a CDN rule — keys on the raw URL. The Worker keys on a synthetic Request carrying `__seg`/`__cf_device`/ `__cf_geo`/`__bot`/`__fetch`/`__abf` (`buildCacheKey`). That mismatch is why every response ships `CDN-Cache-Control: no-store`, and why 100% of traffic comes back uncached. This inverts the problem for `/_serverFn` (SPA navigation and prefetch data): the client appends the segment to the URL itself, so the two keys become equivalent and releasing the cache is safe. // worker-entry.ts createDecoWorkerEntry(serverEntry, { cdnCacheControl: "serverfn-segment", buildSegment: (request) => ({ /* ... */ }), // required }); // src/start.ts import { decoServerFnFetch } from "@decocms/tanstack/sdk/serverFnFetch"; export const startInstance = createStart(() => ({ serverFns: { fetch: decoServerFnFetch }, })); The default is unchanged, and the marker is a HINT, not a source of truth: the Worker recomputes the segment from the request and only relaxes `no-store` on an exact match. Absent, diverging, forged or stale-build markers, logged-in / region / sales-channel requests, an unknown custom segment field, a bot UA, an A/B cohort cookie, or geo-varying keys all keep today's behaviour. The worst case is not caching, never a wrong response. The build hash is part of the token because deploying does not purge whatever caches the response, so the URL has to change on its own when the bundle does. `isBot` and the A/B cookie are checked with the same helpers `buildCacheKey` uses — keying and releasing off different predicates is exactly how the two silently diverge. HTML documents stay `no-store`: the initial navigation is a browser request with no client hook to attach a marker to. Also refuses `cdnCacheControl: "match-profile"` when the cache key is segmented. Its JSDoc promised safety "without buildSegment and deviceSpecificKeys: false", but `deviceSpecificKeys` defaults to true, so the condition is false on every site — following the docs would have silently cross-served segments. Ported from a site-level implementation running in production, with two gaps closed: that version verified only the device, leaving bots (which execute JS — Lighthouse, PageSpeed) and A/B cohorts able to poison a shared entry. Co-Authored-By: Claude Opus 5 (1M context) --- packages/tanstack/package.json | 4 +- packages/tanstack/src/index.ts | 5 + packages/tanstack/src/sdk/cdnSegment.test.ts | 55 +++++++ packages/tanstack/src/sdk/cdnSegment.ts | 74 +++++++++ packages/tanstack/src/sdk/serverFnFetch.ts | 63 ++++++++ packages/tanstack/src/sdk/workerEntry.test.ts | 123 +++++++++++++++ packages/tanstack/src/sdk/workerEntry.ts | 146 ++++++++++++++++-- 7 files changed, 455 insertions(+), 15 deletions(-) create mode 100644 packages/tanstack/src/sdk/cdnSegment.test.ts create mode 100644 packages/tanstack/src/sdk/cdnSegment.ts create mode 100644 packages/tanstack/src/sdk/serverFnFetch.ts diff --git a/packages/tanstack/package.json b/packages/tanstack/package.json index 60b6c76d..809e172b 100644 --- a/packages/tanstack/package.json +++ b/packages/tanstack/package.json @@ -15,7 +15,9 @@ "./daemon": "./src/daemon/index.ts", "./sdk/createInvoke": "./src/sdk/createInvoke.ts", "./sdk/cookiePassthrough": "./src/sdk/cookiePassthrough.ts", - "./sdk/deferredSectionLoader": "./src/sdk/deferredSectionLoader.ts" + "./sdk/deferredSectionLoader": "./src/sdk/deferredSectionLoader.ts", + "./sdk/serverFnFetch": "./src/sdk/serverFnFetch.ts", + "./sdk/cdnSegment": "./src/sdk/cdnSegment.ts" }, "scripts": { "build": "tsc", diff --git a/packages/tanstack/src/index.ts b/packages/tanstack/src/index.ts index 2ca71c8c..f01c3e9b 100644 --- a/packages/tanstack/src/index.ts +++ b/packages/tanstack/src/index.ts @@ -52,3 +52,8 @@ export type { // parses (never imports) to emit real top-level createServerFn declarations // into each site's own src/server/invoke.gen.ts. Import it from the // dedicated "@decocms/tanstack/sdk/createInvoke" subpath instead. +// decoServerFnFetch is intentionally NOT re-exported from this root barrel +// either. A site wires it in `src/start.ts`, which is part of the CLIENT +// bundle; importing it from here would drag `createDecoWorkerEntry` (and the +// whole server graph behind it) into that bundle. Import it from the dedicated +// "@decocms/tanstack/sdk/serverFnFetch" subpath instead. diff --git a/packages/tanstack/src/sdk/cdnSegment.test.ts b/packages/tanstack/src/sdk/cdnSegment.test.ts new file mode 100644 index 00000000..7ee0603b --- /dev/null +++ b/packages/tanstack/src/sdk/cdnSegment.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { segmentToken } from "./cdnSegment"; + +const BUILD = "abc123"; + +describe("segmentToken", () => { + it("anonymous: token is device.build", () => { + expect(segmentToken({ device: "mobile" }, BUILD)).toBe("mobile.abc123"); + expect(segmentToken({ device: "desktop" }, BUILD)).toBe("desktop.abc123"); + // tablet is its own detectDevice value — it must not collapse into mobile + expect(segmentToken({ device: "tablet" }, BUILD)).toBe("tablet.abc123"); + }); + + it("mobile and desktop never share a token", () => { + expect(segmentToken({ device: "mobile" }, BUILD)).not.toBe( + segmentToken({ device: "desktop" }, BUILD), + ); + }); + + it("personalization disables CDN caching", () => { + expect(segmentToken({ device: "mobile", loggedIn: true }, BUILD)).toBeNull(); + expect(segmentToken({ device: "mobile", regionId: "v2.XYZ" }, BUILD)).toBeNull(); + expect(segmentToken({ device: "mobile", salesChannel: "3" }, BUILD)).toBeNull(); + }); + + it("an unknown custom dimension fails closed", () => { + // A site adding its own SegmentKey field must not silently share entries + // across that dimension. + expect(segmentToken({ device: "mobile", storeId: "sp-01" }, BUILD)).toBeNull(); + expect(segmentToken({ device: "mobile", flags: ["promo"] }, BUILD)).toBeNull(); + }); + + it("empty-ish custom values do not disable caching", () => { + // These carry no dimension — hashSegment skips them too, so the Worker key + // is identical with or without them. + expect(segmentToken({ device: "mobile", storeId: "" }, BUILD)).toBe("mobile.abc123"); + expect(segmentToken({ device: "mobile", beta: false }, BUILD)).toBe("mobile.abc123"); + expect(segmentToken({ device: "mobile", flags: [] }, BUILD)).toBe("mobile.abc123"); + expect(segmentToken({ device: "mobile", loggedIn: undefined }, BUILD)).toBe("mobile.abc123"); + }); + + it("missing or dev build hash disables CDN caching", () => { + // without a build hash there is no way to invalidate on deploy — the CDN + // would serve stale code + expect(segmentToken({ device: "mobile" }, undefined)).toBeNull(); + expect(segmentToken({ device: "mobile" }, "")).toBeNull(); + expect(segmentToken({ device: "mobile" }, "dev")).toBeNull(); + }); + + it("a different build yields a different token (invalidates on deploy)", () => { + expect(segmentToken({ device: "mobile" }, "buildA")).not.toBe( + segmentToken({ device: "mobile" }, "buildB"), + ); + }); +}); diff --git a/packages/tanstack/src/sdk/cdnSegment.ts b/packages/tanstack/src/sdk/cdnSegment.ts new file mode 100644 index 00000000..33c6444f --- /dev/null +++ b/packages/tanstack/src/sdk/cdnSegment.ts @@ -0,0 +1,74 @@ +/** + * Segment marker on `/_serverFn` URLs, so Cloudflare's CDN can serve the + * response without invoking the Worker. + * + * The problem: the CDN keys on the raw URL. The Worker keys on a SYNTHETIC + * Request carrying `__seg`/`__v`/`__bot`/`__fetch`/`__abf` (`buildCacheKey` in + * `./workerEntry`) — params the CDN never sees. That mismatch is why the + * framework stamps `CDN-Cache-Control: no-store` on every public response, and + * why 100% of traffic comes back `cf-cache-status: BYPASS`. + * + * The fix: put the segment in the URL itself. The CDN's key then becomes + * equivalent to the Worker's, and relaxing the `no-store` is safe. + * + * This is the ONLY definition of the token format. The client uses it to build + * the marker, the worker uses it to recompute and compare — same function on + * both sides, so they cannot drift. + * + * Note the split of responsibilities: this module covers what is observable on + * BOTH sides (device + build). Request-only dimensions — bot UA, the A/B + * cookie — are checked by the worker alone, in `cdnServerFnToken`. A client + * that can't see them just emits a marker that fails verification, which keeps + * the existing `no-store`. + */ + +import type { Device } from "@decocms/blocks/sdk/detectDevice"; + +/** `__d` is reserved: `workerEntry` uses `?__d=` as an OTel debug flag. */ +export const CSEG_PARAM = "__cseg"; + +/** + * The subset of `SegmentKey` this token can express. + * + * Deliberately structural rather than importing `SegmentKey` from + * `./workerEntry`: this module is bundled into the CLIENT, and workerEntry + * pulls in the whole server graph. + */ +export interface CdnSegment { + device: Device; + loggedIn?: boolean; + salesChannel?: string; + regionId?: string; + [key: string]: unknown; +} + +/** + * The segment token, or `null` when this request must not be CDN-cached. + * + * Returns `null` — keeping today's `no-store` — when: + * + * - there is any personalization beyond device (`loggedIn`, `salesChannel`, + * `regionId`, or any custom `SegmentKey` field a site added). Only device is + * safe to expose in a URL; everything else has to keep resolving in the + * Worker. Unknown fields fail closed precisely because we can't know whether + * a site's custom dimension is personal. + * - there is no build hash, or it is `"dev"`. The build is part of the token + * because deploying does NOT purge the CDN (the framework's purge clears + * `caches.default`), so the URL has to change on its own when the bundle does. + */ +export function segmentToken(seg: CdnSegment, buildHash: string | undefined): string | null { + if (!buildHash || buildHash === "dev") return null; + if (!seg.device) return null; + if (seg.loggedIn || seg.salesChannel || seg.regionId) return null; + + // Any dimension we don't recognize is assumed personal. + for (const [key, value] of Object.entries(seg)) { + if (key === "device") continue; + if (value === undefined || value === false) continue; + if (Array.isArray(value) && value.length === 0) continue; + if (value === "") continue; + return null; + } + + return `${seg.device}.${buildHash}`; +} diff --git a/packages/tanstack/src/sdk/serverFnFetch.ts b/packages/tanstack/src/sdk/serverFnFetch.ts new file mode 100644 index 00000000..e31bca56 --- /dev/null +++ b/packages/tanstack/src/sdk/serverFnFetch.ts @@ -0,0 +1,63 @@ +/** + * Client-side `serverFns.fetch` hook that attaches the CDN segment marker to + * `/_serverFn` URLs. + * + * Pairs with `cdnCacheControl: "serverfn-segment"` on `createDecoWorkerEntry`. + * Attaching the segment to the URL makes Cloudflare's CDN key (the raw URL) + * equivalent to the key the Worker builds internally — see `./cdnSegment` for + * why that is the whole problem. + * + * Only the client can do this: the initial HTML document is a browser + * navigation with no JS hook. This covers SPA data requests and prefetches, + * which is the volume Speculation Rules creates. + * + * SECURITY: the marker is a HINT, not a source of truth. The worker recomputes + * the segment from the request itself and only releases the CDN when it matches + * exactly (`cdnCacheableServerFn` in `./workerEntry`). A missing, diverging, + * forged or stale-build marker just keeps today's `no-store` — it can never + * produce a wrong response. + * + * @example + * ```ts + * // src/start.ts + * import { createStart } from "@tanstack/react-start"; + * import { decoServerFnFetch } from "@decocms/tanstack"; + * + * export const startInstance = createStart(() => ({ + * serverFns: { fetch: decoServerFnFetch }, + * })); + * ``` + */ + +import { detectDevice } from "@decocms/blocks/sdk/detectDevice"; +import { CSEG_PARAM, segmentToken } from "./cdnSegment"; + +declare const __DECO_BUILD_HASH__: string | undefined; + +function buildHash(): string | undefined { + return typeof __DECO_BUILD_HASH__ !== "undefined" ? __DECO_BUILD_HASH__ : undefined; +} + +function segmentMarker(): string | null { + if (typeof navigator === "undefined") return null; + // Device is the only dimension observable on the client. If this request is + // in fact from a logged-in user, or in a region, or an A/B cohort, the worker + // catches it during verification and keeps the no-store — the marker simply + // won't match. + return segmentToken({ device: detectDevice(navigator.userAgent) }, buildHash()); +} + +/** + * Drop-in `serverFns.fetch` implementation. Falls back to a plain `fetch` when + * there is no marker to add. + */ +export const decoServerFnFetch: typeof fetch = (input, init) => { + // TanStack's serverFnFetcher always calls with the URL already built as a + // string (start-client-core/src/client-rpc/serverFnFetcher.ts). Anything else + // goes through untouched. + if (typeof input !== "string") return fetch(input, init); + const marker = segmentMarker(); + if (!marker) return fetch(input, init); + const sep = input.includes("?") ? "&" : "?"; + return fetch(`${input}${sep}${CSEG_PARAM}=${marker}`, init); +}; diff --git a/packages/tanstack/src/sdk/workerEntry.test.ts b/packages/tanstack/src/sdk/workerEntry.test.ts index 8c8d9d1f..aca60e5d 100644 --- a/packages/tanstack/src/sdk/workerEntry.test.ts +++ b/packages/tanstack/src/sdk/workerEntry.test.ts @@ -737,6 +737,107 @@ describe("draft preview (pull-based)", () => { }); }); +describe('cdnCacheControl: "serverfn-segment"', () => { + const BUILD = "abc123"; + const ENV = { BUILD_HASH: BUILD }; + + function worker(overrides: Record = {}) { + return createDecoWorkerEntry(MOCK_SERVER_ENTRY, { + observability: false, + cdnCacheControl: "serverfn-segment", + buildSegment: (req: Request) => ({ + device: req.headers.get("user-agent")?.includes("iPhone") + ? ("mobile" as const) + : ("desktop" as const), + ...(req.headers.get("cookie")?.includes("auth=1") ? { loggedIn: true } : {}), + }), + ...overrides, + }); + } + + const sfnUrl = (marker?: string) => + `https://example.com/_serverFn/loadCmsPage${marker ? `?__cseg=${marker}` : ""}`; + + async function cdnHeader(url: string, headers: Record = {}) { + const res = await worker().fetch(new Request(url, { headers }), ENV, MOCK_CTX); + return res.headers.get("CDN-Cache-Control"); + } + + it("releases the CDN when the marker matches the recomputed segment", async () => { + expect(await cdnHeader(sfnUrl(`desktop.${BUILD}`))).toMatch(/^public, max-age=\d+$/); + }); + + it("keeps no-store without a marker (bot, curl, old client)", async () => { + expect(await cdnHeader(sfnUrl())).toBe("no-store"); + }); + + it("keeps no-store when the marker is for another device", async () => { + // The forged/diverging case: a desktop request claiming a mobile entry + // would let the CDN serve mobile HTML to desktop. + expect(await cdnHeader(sfnUrl(`mobile.${BUILD}`))).toBe("no-store"); + }); + + it("keeps no-store when the marker is from an older build", async () => { + // Deploying does not purge the CDN, so a stale build token must not match. + expect(await cdnHeader(sfnUrl("desktop.oldbuild"))).toBe("no-store"); + }); + + it("keeps no-store when there is no build hash", async () => { + const res = await worker().fetch(new Request(sfnUrl(`desktop.${BUILD}`)), {}, MOCK_CTX); + expect(res.headers.get("CDN-Cache-Control")).toBe("no-store"); + }); + + it("keeps no-store for a bot UA even with a valid marker", async () => { + // Bots render every section eagerly (~10x payload). Sharing one CDN entry + // would serve that to humans, or the deferred one to crawlers. + expect( + await cdnHeader(sfnUrl(`desktop.${BUILD}`), { + "user-agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", + }), + ).toBe("no-store"); + }); + + it("keeps no-store for an A/B cohort cookie even with a valid marker", async () => { + expect( + await cdnHeader(sfnUrl(`desktop.${BUILD}`), { + cookie: "deco_segment=eyJhY3RpdmUiOlsiYSJdfQ==", + }), + ).toBe("no-store"); + }); + + it("keeps no-store for a logged-in request", async () => { + expect(await cdnHeader(sfnUrl(`desktop.${BUILD}`), { cookie: "auth=1" })).toBe("no-store"); + }); + + it("keeps no-store on HTML documents — they carry no marker", async () => { + expect(await cdnHeader("https://example.com/some-category")).toBe("no-store"); + }); + + it("keeps no-store when the cache key varies by geo", async () => { + // `__cf_geo` is in the Worker key but cannot be expressed in the marker nor + // reproduced by the CDN, so a site with geo keying must not release it — + // otherwise one region's regionalized data (pricing, stock, store) is + // served to another from the same colo. + const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { + observability: false, + cdnCacheControl: "serverfn-segment", + geoCacheKey: "region", + buildSegment: () => ({ device: "desktop" as const }), + }); + const res = await w.fetch(new Request(sfnUrl(`desktop.${BUILD}`)), ENV, MOCK_CTX); + expect(res.headers.get("CDN-Cache-Control")).toBe("no-store"); + }); + + it("keeps no-store without buildSegment, since the logged-in bypass is inert", async () => { + const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { + observability: false, + cdnCacheControl: "serverfn-segment", + }); + const res = await w.fetch(new Request(sfnUrl(`desktop.${BUILD}`)), ENV, MOCK_CTX); + expect(res.headers.get("CDN-Cache-Control")).toBe("no-store"); + }); +}); + describe("CDN-Cache-Control at the single response exit", () => { it("defaults to no-store on early returns that never reach dressResponse", async () => { // `?asJson` returns the fully resolved page — loaders run with the caller's @@ -765,3 +866,25 @@ describe("CDN-Cache-Control at the single response exit", () => { expect(res.headers.get("CDN-Cache-Control")).toMatch(/^public, max-age=\d+$/); }); }); + +describe('cdnCacheControl: "match-profile" guard', () => { + it("is ignored while the cache key is segmented (deviceSpecificKeys defaults to true)", async () => { + const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { + observability: false, + cdnCacheControl: "match-profile", + }); + const res = await w.fetch(new Request("https://example.com/some-category"), {}, MOCK_CTX); + expect(res.headers.get("CDN-Cache-Control")).toBe("no-store"); + }); + + it("is honored only when the key really is the raw URL", async () => { + const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { + observability: false, + cdnCacheControl: "match-profile", + deviceSpecificKeys: false, + geoCacheKey: "off", + }); + const res = await w.fetch(new Request("https://example.com/some-category"), {}, MOCK_CTX); + expect(res.headers.get("CDN-Cache-Control")).toMatch(/^public, max-age=\d+$/); + }); +}); diff --git a/packages/tanstack/src/sdk/workerEntry.ts b/packages/tanstack/src/sdk/workerEntry.ts index e0e05c78..d46ad6f2 100644 --- a/packages/tanstack/src/sdk/workerEntry.ts +++ b/packages/tanstack/src/sdk/workerEntry.ts @@ -53,6 +53,7 @@ import { } from "@decocms/blocks/sdk/cacheHeaders"; import { isDevMode } from "@decocms/blocks/sdk/env"; import { parseSegmentCookie, SEGMENT_COOKIE, segmentCacheToken } from "@decocms/blocks/sdk/flags"; +import { CSEG_PARAM, segmentToken } from "./cdnSegment"; import { getActiveSpan, logRequest, @@ -307,7 +308,6 @@ export interface DecoWorkerEntryOptions { * @default "PURGE_TOKEN" */ purgeTokenEnv?: string | false; - /** * Paths that should always bypass the edge cache, even if the * profile detector would otherwise cache them. @@ -498,18 +498,43 @@ export interface DecoWorkerEntryOptions { staticPaths?: string[]; /** - * CDN-Cache-Control header strategy. + * CDN-Cache-Control header strategy — i.e. what Cloudflare's own CDN layer is + * allowed to do, as distinct from the Cache API this Worker manages directly. + * + * The default is `"no-store"` because the Worker's cache key is a SYNTHETIC + * Request carrying `__seg`/`__cf_device`/`__cf_geo`/`__bot`/`__fetch`/`__abf` + * (see `buildCacheKey`), while the CDN keys on the raw URL and ignores `Vary` + * beyond `Accept-Encoding`. Letting the CDN cache by URL alone would serve + * desktop HTML to mobile, one region's to another, or a crawler's eager + * render to humans. * - * - `"no-store"` (default): CDN never caches; every request invokes the Worker. - * Correct when segment-based cache keys differ from the original URL. - * - `"match-profile"`: Set CDN-Cache-Control to a short TTL matching the - * profile's edge.fresh value. Only safe when you are NOT using segment-based - * cache keys (i.e., no `buildSegment` and `deviceSpecificKeys: false`). - * - A function: Return a CDN-Cache-Control value per profile, or `null` for no-store. + * - `"no-store"` (default): the CDN never caches; every request invokes the + * Worker. Always correct, never fast. + * - `"serverfn-segment"`: opt in to CDN caching for `/_serverFn` requests + * whose URL carries a verified `__cseg` marker (see `./cdnSegment` and + * `decoServerFnFetch`). The marker makes the CDN's key equivalent to the + * Worker's. HTML documents keep `no-store` — the initial navigation is a + * browser request with no client hook to attach a marker. + * - `"match-profile"`: mirror the profile's `edge.fresh` as a CDN TTL. Sound + * ONLY when the cache key is the raw URL — no `buildSegment`, + * `deviceSpecificKeys: false`, `geoCacheKey: "off"`. Since + * `deviceSpecificKeys` defaults to **true**, this is almost never the case; + * when it isn't, the option is ignored with a warning rather than silently + * cross-serving segments. + * - A function: return a CDN-Cache-Control value per profile, or `null` for + * no-store. You own the correctness of the key/TTL pairing. + * + * Caching HTML is deliberately not covered here. It is not a header change: + * whatever sits in front of the Worker has to reproduce the key above, and + * the initial navigation has no client hook to attach a marker to. * * @default "no-store" */ - cdnCacheControl?: "no-store" | "match-profile" | ((profile: CacheProfileName) => string | null); + cdnCacheControl?: + | "no-store" + | "serverfn-segment" + | "match-profile" + | ((profile: CacheProfileName) => string | null); /** * Auto-instrumentation via `instrumentWorker` is enabled by default. The * framework wraps the returned handler so that, when OTel env vars @@ -1034,6 +1059,10 @@ export function createDecoWorkerEntry( const safeCookieSet = new Set(safeCookiesOpt); + // One warning per worker instance, not per request — same pattern as + // `warnedLongMaxAge` in @decocms/blocks/sdk/cachedLoader. + let warnedMatchProfile = false; + // Build the final security headers map (merged defaults + custom + CSP) const secHeaders: Record | null = (() => { if (securityHeadersOpt === false) return null; @@ -1195,6 +1224,67 @@ export function createDecoWorkerEntry( return undefined; } + function isServerFnPathname(pathname: string): boolean { + return pathname.startsWith("/_serverFn/") || pathname.startsWith("/_server/"); + } + + /** + * Whether this `/_serverFn` request may be cached by Cloudflare's CDN. + * + * True only when the URL carries a `__cseg` marker (put there by + * `decoServerFnFetch` on the client) AND recomputing the segment from this + * request produces the same token. When it matches, the CDN's key — the raw + * URL — is equivalent to the Worker's synthetic key, so serving from the CDN + * cannot cross segments. + * + * Fail-closed on every axis: no marker (bot, curl, old client), diverging + * marker, forged marker, stale build, logged-in / region / sales channel, a + * custom segment dimension, a bot UA, or an A/B cohort cookie all keep + * today's `no-store`. The worst case is not caching — never a wrong response. + * + * `isBot` and the A/B cookie are checked HERE rather than inside + * `segmentToken` because they are request-only: the client cannot observe + * them, and they are exactly the two dimensions the original site-level + * version missed. They must use the same `isBot` / `segmentCacheToken` that + * `buildCacheKey` uses — keying and releasing off different predicates is how + * the two silently diverge. + */ + function cdnCacheableServerFn(request: Request, url: URL, env: Record): boolean { + if (!isServerFnPathname(url.pathname)) return false; + + const marker = url.searchParams.get(CSEG_PARAM); + if (!marker) return false; + + // Without buildSegment the logged-in bypass is inert (see the boot warning), + // so there is nothing reliable to verify the marker against. + if (!buildSegment) return false; + + // `__bot=1` in buildCacheKey: bots render every section eagerly (~10x + // payload). Letting the CDN share one entry would serve that to humans, or + // the deferred one to crawlers. + if (isBot(request.headers.get("user-agent") ?? undefined)) return false; + + // `__abf` in buildCacheKey: an A/B visitor must not get another cohort's + // cached variant. + if (segmentCacheToken(parseSegmentCookie(readRequestCookie(request, SEGMENT_COOKIE)))) { + return false; + } + + // `__cf_geo` in buildCacheKey, which the marker cannot express and the CDN + // cannot reproduce. Mostly moot — with geo on, the `buildSegment` wrapper + // back-fills `regionId`, and any `regionId` already makes `segmentToken` + // return null. But the back-fill reads only `cf.regionCode` while + // `buildGeoCacheParam` keys on country/region/city, so a request with a + // country but no region code (Tor exits, some carriers, countries without + // first-level subdivisions) slips through with a releasable token while the + // Worker key still varies by geo. Refuse outright, the same way the + // `match-profile` branch below does. + if (effectiveGeoKey() !== "off") return false; + + const expected = segmentToken(buildSegment(request), getBuildHash(env)); + return expected !== null && marker === expected; + } + function buildCacheKey( request: Request, env: Record, @@ -2427,17 +2517,45 @@ export function createDecoWorkerEntry( // CDN-Cache-Control: controls Cloudflare's automatic CDN layer // (separate from Cache API which the worker manages directly). - if (cdnCacheControlOpt === "no-store") { - out.headers.set("CDN-Cache-Control", "no-store"); + const cdnPublic = `public, max-age=${edgeConfig.fresh}`; + const cdnCacheable = edgeConfig.isPublic && edgeConfig.fresh > 0; + + if (cdnCacheControlOpt === "serverfn-segment") { + // Only `/_serverFn` requests whose URL carries a verified segment + // marker. Everything else — including every HTML document, which has no + // client hook to attach a marker on the initial navigation — keeps + // `no-store`. + out.headers.set( + "CDN-Cache-Control", + cdnCacheable && cdnCacheableServerFn(request, url, env) ? cdnPublic : "no-store", + ); } else if (cdnCacheControlOpt === "match-profile") { - if (edgeConfig.isPublic && edgeConfig.fresh > 0) { - out.headers.set("CDN-Cache-Control", `public, max-age=${edgeConfig.fresh}`); - } else { + // `match-profile` is only sound when the Worker's cache key is the raw + // URL. It almost never is: `deviceSpecificKeys` defaults to true, so + // every site keys on `__cf_device` even without a `buildSegment`, and a + // location matcher adds `__cf_geo`. Honoring the option under those + // conditions would serve desktop HTML to mobile, or one region's to + // another — silently. Refuse, and say why once. + const keyed = + buildSegment !== undefined || deviceSpecificKeys || effectiveGeoKey() !== "off"; + if (keyed) { + if (!warnedMatchProfile) { + warnedMatchProfile = true; + console.warn( + '[deco] cdnCacheControl: "match-profile" ignored — the edge cache key is ' + + "segmented (buildSegment / deviceSpecificKeys / geoCacheKey), which the CDN " + + "cannot reproduce from the URL alone. Keeping CDN-Cache-Control: no-store.", + ); + } out.headers.set("CDN-Cache-Control", "no-store"); + } else { + out.headers.set("CDN-Cache-Control", cdnCacheable ? cdnPublic : "no-store"); } } else if (typeof cdnCacheControlOpt === "function") { const val = cdnCacheControlOpt(profile); out.headers.set("CDN-Cache-Control", val ?? "no-store"); + } else { + out.headers.set("CDN-Cache-Control", "no-store"); } out.headers.set("X-Cache", xCache);