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);