@decocms/tanstack 8.0.0 → 8.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +12 -5
  2. package/src/__conformance__/observability.test.ts +221 -0
  3. package/src/hooks/CdnSegmentMarker.tsx +38 -0
  4. package/src/hooks/DecoPageRenderer.tsx +91 -29
  5. package/src/hooks/DecoRootLayout.tsx +139 -58
  6. package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
  7. package/src/hooks/DraftPreviewIndicator.tsx +64 -0
  8. package/src/hooks/NavigationProgress.test.tsx +58 -0
  9. package/src/hooks/NavigationProgress.tsx +45 -3
  10. package/src/hooks/PreviewProviders.test.tsx +42 -0
  11. package/src/hooks/PreviewProviders.tsx +21 -2
  12. package/src/hooks/deferredObserverTiming.test.tsx +91 -0
  13. package/src/hooks/index.ts +1 -0
  14. package/src/index.ts +30 -13
  15. package/src/routes/cmsRoute.test.ts +168 -1
  16. package/src/routes/cmsRoute.ts +148 -39
  17. package/src/routes/pageUrl.test.ts +6 -6
  18. package/src/routes/pageUrl.ts +5 -4
  19. package/src/routes/withSiteGlobals.test.ts +37 -0
  20. package/src/routes/withSiteGlobals.ts +45 -24
  21. package/src/sdk/backgroundTasks.test.ts +68 -0
  22. package/src/sdk/backgroundTasks.ts +38 -0
  23. package/src/sdk/cdnSegment.test.ts +67 -0
  24. package/src/sdk/cdnSegment.ts +71 -0
  25. package/src/sdk/deferredSectionLoader.test.ts +1 -1
  26. package/src/sdk/draft.test.ts +341 -0
  27. package/src/sdk/draft.ts +277 -0
  28. package/src/sdk/draftShared.ts +16 -0
  29. package/src/sdk/kvHydration.test.ts +156 -0
  30. package/src/sdk/kvHydration.ts +82 -12
  31. package/src/sdk/kvRedirects.test.ts +132 -0
  32. package/src/sdk/kvRedirects.ts +125 -0
  33. package/src/sdk/router.ts +70 -61
  34. package/src/sdk/serverFnFetch.ts +59 -0
  35. package/src/sdk/speculationRules.test.ts +82 -0
  36. package/src/sdk/speculationRules.ts +136 -0
  37. package/src/sdk/startEntry.ts +31 -0
  38. package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
  39. package/src/sdk/workerEntry.test.ts +554 -16
  40. package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
  41. package/src/sdk/workerEntry.ts +915 -68
  42. package/src/v8/__conformance__/delivery.test.ts +100 -0
  43. package/src/v8/binding.ts +70 -0
  44. package/src/v8/kvLoader.bundle.test.ts +42 -0
  45. package/src/v8/kvLoader.test.ts +74 -0
  46. package/src/v8/kvLoader.ts +43 -0
  47. package/src/vite/plugin.js +180 -8
  48. package/src/vite/plugin.test.js +126 -1
  49. package/src/vite/plugin.test.ts +125 -1
@@ -25,15 +25,20 @@
25
25
  * ```
26
26
  */
27
27
 
28
+ import { draftPointer } from "@decocms/blocks";
28
29
  import {
29
30
  getRevision,
31
+ getSectionOptions,
30
32
  isBot,
31
33
  loadBlocks,
32
34
  type MatcherContext,
33
35
  onChange,
34
36
  resolveDecoPage,
37
+ resolveDeferredSectionFull,
35
38
  runSectionLoaders,
36
39
  runSingleSectionLoader,
40
+ serializeRenderJson,
41
+ stringifyWithoutSecrets,
37
42
  } from "@decocms/blocks/cms";
38
43
  import { DECO_MATCHERS_OVERRIDE_PARAM } from "@decocms/blocks/matchers/override";
39
44
  import { clearLoaderCache } from "@decocms/blocks/sdk/cachedLoader";
@@ -44,10 +49,19 @@ import {
44
49
  detectCacheProfile,
45
50
  edgeCacheConfig,
46
51
  getCacheProfile,
52
+ payloadHasTrackingParams,
47
53
  serverFnPagePath,
48
54
  } from "@decocms/blocks/sdk/cacheHeaders";
55
+ import {
56
+ bindCacheStorage,
57
+ type CacheStorage,
58
+ createWebCacheStorage,
59
+ getCacheStorageContext,
60
+ } from "@decocms/blocks/sdk/cacheStorage";
61
+ import { djb2Hex } from "@decocms/blocks/sdk/djb2";
49
62
  import { isDevMode } from "@decocms/blocks/sdk/env";
50
63
  import { parseSegmentCookie, SEGMENT_COOKIE, segmentCacheToken } from "@decocms/blocks/sdk/flags";
64
+ import { generateNonce, NONCE_BAG_KEY } from "@decocms/blocks/sdk/nonce";
51
65
  import {
52
66
  getActiveSpan,
53
67
  logRequest,
@@ -64,15 +78,37 @@ import {
64
78
  } from "@decocms/blocks/sdk/otel";
65
79
  import { setRuntimeEnv } from "@decocms/blocks/sdk/otelAdapters";
66
80
  import { parseTraceparent } from "@decocms/blocks/sdk/otelHttpTracer";
67
- import { loadRedirects, matchRedirect, type RedirectMap } from "@decocms/blocks/sdk/redirects";
81
+ import {
82
+ loadRedirects,
83
+ matchExactRedirect,
84
+ matchPatternRedirect,
85
+ normalizePath,
86
+ type RedirectMap,
87
+ } from "@decocms/blocks/sdk/redirects";
68
88
  import { RequestContext } from "@decocms/blocks/sdk/requestContext";
69
- import { cleanPathForCacheKey } from "@decocms/blocks/sdk/urlUtils";
89
+ import { createResponseCache } from "@decocms/blocks/sdk/responseCache";
90
+ import { cleanPathForCacheKey, hasTrackingParams } from "@decocms/blocks/sdk/urlUtils";
70
91
  import { type Device, isMobileUA } from "@decocms/blocks/sdk/useDevice";
71
92
  import { getRenderShellConfig } from "@decocms/blocks-admin/admin/setup";
93
+ import { reconfigureAppsOnce } from "@decocms/blocks-admin/apps/autoconfig";
72
94
  import { buildHtmlShell } from "@decocms/blocks-admin/sdk/htmlShell";
73
95
  import { getAppMiddleware } from "@decocms/blocks-admin/sdk/setupApps";
96
+ import { installUpstreamCache } from "../v8/binding";
97
+ import { installBackgroundHook, runBackgroundTasks } from "./backgroundTasks";
98
+ import { CSEG_BAG_KEY, CSEG_PARAM, segmentToken } from "./cdnSegment";
99
+ import {
100
+ applyDraftCookieAndHeaders,
101
+ bindRequestDraft,
102
+ type DraftDecision,
103
+ installDecoSiteHostFromEnv,
104
+ installPreviewHostsFromBlocks,
105
+ registerDraftOverride,
106
+ requestCarriesDraft,
107
+ } from "./draft";
74
108
  import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration";
109
+ import { lookupExactRedirect } from "./kvRedirects";
75
110
  import { DECO_POWERED_BY, installDefaultUserAgent } from "./outboundHeaders";
111
+ import { type SpeculationRulesConfig, setSpeculationRules } from "./speculationRules";
76
112
 
77
113
  /**
78
114
  * Build-time identifier injected by `decoVitePlugin()` (see
@@ -187,7 +223,7 @@ export interface SegmentKey {
187
223
  * (not pulled into the client Vite build).
188
224
  */
189
225
  export interface AdminHandlers {
190
- handleMeta: (request: Request) => Response;
226
+ handleMeta: (request: Request) => Response | Promise<Response>;
191
227
  handleDecofileRead: () => Response;
192
228
  handleDecofileReload: (request: Request) => Response | Promise<Response>;
193
229
  handleRender: (request: Request) => Response | Promise<Response>;
@@ -195,6 +231,11 @@ export interface AdminHandlers {
195
231
  }
196
232
 
197
233
  export interface DecoWorkerEntryOptions {
234
+ /** Choose storage once per request using the app's runtime bindings. Null disables shared storage.
235
+ * Omit to retain Web Cache API response caching and memory-only data caches.
236
+ */
237
+ cacheStorage?: (env: Record<string, unknown>, request: Request) => CacheStorage | null;
238
+
198
239
  /**
199
240
  * Admin route handlers (/live/_meta, /.decofile, /live/previews).
200
241
  * Pass the handlers from `@decocms/start/admin` here.
@@ -215,6 +256,44 @@ export interface DecoWorkerEntryOptions {
215
256
  */
216
257
  deviceSpecificKeys?: boolean;
217
258
 
259
+ /**
260
+ * Serve pages as structured per-section JSON on `?renderJson` (the mobile
261
+ * page-as-JSON path). Set to `false` to disable the endpoint site-wide —
262
+ * `?renderJson` then just renders the normal HTML page.
263
+ * @default true
264
+ */
265
+ renderJson?: boolean;
266
+
267
+ /**
268
+ * Serve the raw resolved page as JSON on `?asJson` (legacy admin-preview
269
+ * path). Set to `false` to disable it site-wide.
270
+ * @default true
271
+ */
272
+ asJson?: boolean;
273
+
274
+ /**
275
+ * Opt-in Speculation Rules API. When set, a `<script type="speculationrules">`
276
+ * is emitted in every page's <head> (via DecoRootLayout, so it's cache-safe)
277
+ * telling the browser to prerender/prefetch the next document on intent.
278
+ *
279
+ * Ships DISABLED (undefined). Only activate on sites whose analytics/pixel
280
+ * loaders are prerender-guarded (see ANALYTICS_SCRIPT / gtmScript in
281
+ * @decocms/blocks/sdk/analytics), otherwise a prerender can double-count.
282
+ * Scope candidates to hard-navigation links via `linkSelector` so client-
283
+ * routed `<Link>`s are excluded.
284
+ * @default undefined (disabled)
285
+ */
286
+ speculationRules?: SpeculationRulesConfig;
287
+
288
+ /**
289
+ * Cross-origin access to the `?renderJson` response. It is public page data
290
+ * (secrets + request-derived keys are stripped), so it defaults to open.
291
+ * - unset (default) / `"*"` → any origin, without credentials (public API).
292
+ * - `string[]` → reflect a matching request Origin, *with* credentials.
293
+ * - `false` → no CORS headers (same-origin + native apps only).
294
+ */
295
+ pageJsonCors?: string[] | "*" | false;
296
+
218
297
  /**
219
298
  * Build a full segment key from the incoming request.
220
299
  *
@@ -253,7 +332,6 @@ export interface DecoWorkerEntryOptions {
253
332
  * @default "PURGE_TOKEN"
254
333
  */
255
334
  purgeTokenEnv?: string | false;
256
-
257
335
  /**
258
336
  * Paths that should always bypass the edge cache, even if the
259
337
  * profile detector would otherwise cache them.
@@ -367,6 +445,24 @@ export interface DecoWorkerEntryOptions {
367
445
  */
368
446
  csp?: string[] | false;
369
447
 
448
+ /**
449
+ * How the `csp` directives are delivered.
450
+ *
451
+ * - `"report-only"` (default): emitted as `Content-Security-Policy-Report-Only`.
452
+ * Browsers report violations but never block — backward-compatible, zero risk.
453
+ * - `"enforce"`: emitted as an enforced `Content-Security-Policy`, but ONLY on
454
+ * non-cacheable (`CDN-Cache-Control: no-store`) HTML responses. A fresh nonce
455
+ * is generated per request, threaded into every SSR inline `<script>` (via
456
+ * `router.options.ssr.nonce` + the deco layout components) and into the
457
+ * header's `script-src` (which also drops `'unsafe-inline'`). Cacheable
458
+ * responses stay report-only, because a per-request nonce would desync from a
459
+ * cached body and block every script. `'unsafe-eval'`, if present in the
460
+ * site's `script-src`, is left untouched — remove it explicitly in your `csp`.
461
+ *
462
+ * @default "report-only"
463
+ */
464
+ cspMode?: "report-only" | "enforce";
465
+
370
466
  /**
371
467
  * Automatically inject Cloudflare geo data (country, region, city)
372
468
  * as internal cookies on every request so location matchers can read
@@ -444,18 +540,48 @@ export interface DecoWorkerEntryOptions {
444
540
  staticPaths?: string[];
445
541
 
446
542
  /**
447
- * CDN-Cache-Control header strategy.
543
+ * CDN-Cache-Control header strategy — i.e. what Cloudflare's own CDN layer is
544
+ * allowed to do, as distinct from the Cache API this Worker manages directly.
448
545
  *
449
- * - `"no-store"` (default): CDN never caches; every request invokes the Worker.
450
- * Correct when segment-based cache keys differ from the original URL.
451
- * - `"match-profile"`: Set CDN-Cache-Control to a short TTL matching the
452
- * profile's edge.fresh value. Only safe when you are NOT using segment-based
453
- * cache keys (i.e., no `buildSegment` and `deviceSpecificKeys: false`).
454
- * - A function: Return a CDN-Cache-Control value per profile, or `null` for no-store.
546
+ * The default is `"no-store"` because the Worker's cache key is a SYNTHETIC
547
+ * Request carrying `__seg`/`__cf_device`/`__cf_geo`/`__bot`/`__fetch`/`__abf`
548
+ * (see `buildCacheKey`), while the CDN keys on the raw URL and ignores `Vary`
549
+ * beyond `Accept-Encoding`. Letting the CDN cache by URL alone would serve
550
+ * desktop HTML to mobile, one region's to another, or a crawler's eager
551
+ * render to humans.
455
552
  *
456
- * @default "no-store"
553
+ * - `"serverfn-segment"` (default): allow CDN caching for `/_serverFn`
554
+ * requests whose URL carries a verified `__cseg` marker (see
555
+ * `./cdnSegment` and `decoServerFnFetch`). The marker makes the CDN's key
556
+ * equivalent to the Worker's. HTML documents keep `no-store` — the initial
557
+ * navigation is a browser request with no client hook to attach a marker.
558
+ *
559
+ * This is the default because it is inert until a marker actually arrives:
560
+ * a client that never sends `__cseg` keeps getting `no-store`, so enabling
561
+ * it cannot change behaviour on its own. That is what lets a site pick the
562
+ * feature up from a version bump rather than a per-site change.
563
+ * - `"no-store"`: the CDN never caches; every request invokes the Worker.
564
+ * Always correct, never fast. Set this to opt OUT.
565
+ * - `"match-profile"`: mirror the profile's `edge.fresh` as a CDN TTL. Sound
566
+ * ONLY when the cache key is the raw URL — no `buildSegment`,
567
+ * `deviceSpecificKeys: false`, `geoCacheKey: "off"`. Since
568
+ * `deviceSpecificKeys` defaults to **true**, this is almost never the case;
569
+ * when it isn't, the option is ignored with a warning rather than silently
570
+ * cross-serving segments.
571
+ * - A function: return a CDN-Cache-Control value per profile, or `null` for
572
+ * no-store. You own the correctness of the key/TTL pairing.
573
+ *
574
+ * Caching HTML is deliberately not covered here. It is not a header change:
575
+ * whatever sits in front of the Worker has to reproduce the key above, and
576
+ * the initial navigation has no client hook to attach a marker to.
577
+ *
578
+ * @default "serverfn-segment"
457
579
  */
458
- cdnCacheControl?: "no-store" | "match-profile" | ((profile: CacheProfileName) => string | null);
580
+ cdnCacheControl?:
581
+ | "no-store"
582
+ | "serverfn-segment"
583
+ | "match-profile"
584
+ | ((profile: CacheProfileName) => string | null);
459
585
  /**
460
586
  * Auto-instrumentation via `instrumentWorker` is enabled by default. The
461
587
  * framework wraps the returned handler so that, when OTel env vars
@@ -674,6 +800,53 @@ export const DECO_ADMIN_FRAME_ANCESTORS: string[] = [
674
800
  */
675
801
  export const DEFAULT_FRAME_ANCESTORS_CSP = `frame-ancestors ${DECO_ADMIN_FRAME_ANCESTORS.join(" ")}`;
676
802
 
803
+ /**
804
+ * Rewrite a `script-src` directive for nonce enforcement: drop `'unsafe-inline'`
805
+ * (a nonce makes it dead weight — and CSP ignores it once a nonce is present)
806
+ * and add `'nonce-<nonce>'` plus `'inline-speculation-rules'` (so the layout's
807
+ * `<script type="speculationrules">` still runs). `'unsafe-eval'` is left as-is.
808
+ */
809
+ function hardenScriptSrc(directive: string, nonce: string): string {
810
+ const tokens = directive.trim().split(/\s+/).slice(1);
811
+ const kept = tokens.filter((t) => t.toLowerCase() !== "'unsafe-inline'");
812
+ for (const extra of [`'nonce-${nonce}'`, "'inline-speculation-rules'"]) {
813
+ if (!kept.includes(extra)) kept.push(extra);
814
+ }
815
+ return `script-src ${kept.join(" ")}`;
816
+ }
817
+
818
+ /**
819
+ * Build the enforced `Content-Security-Policy` value from the site's `csp`
820
+ * directives + the enforced `frame-ancestors`. The `script-src` directive is
821
+ * hardened with the request nonce (added if the site declared none). Any
822
+ * `frame-ancestors` the site put in `csp` is dropped in favour of the canonical
823
+ * enforced one.
824
+ */
825
+ export function buildEnforcedCsp(
826
+ directives: string[],
827
+ frameAncestorsCsp: string,
828
+ nonce: string,
829
+ ): string {
830
+ const out: string[] = [];
831
+ let sawScriptSrc = false;
832
+ for (const directive of directives) {
833
+ const name = directive.trim().split(/\s+/)[0]?.toLowerCase();
834
+ if (name === "script-src") {
835
+ sawScriptSrc = true;
836
+ out.push(hardenScriptSrc(directive, nonce));
837
+ } else if (name === "frame-ancestors") {
838
+ // replaced by the canonical enforced frame-ancestors appended below
839
+ } else {
840
+ out.push(directive.trim());
841
+ }
842
+ }
843
+ if (!sawScriptSrc) {
844
+ out.push(`script-src 'self' 'nonce-${nonce}' 'inline-speculation-rules'`);
845
+ }
846
+ out.push(frameAncestorsCsp.trim());
847
+ return out.join("; ");
848
+ }
849
+
677
850
  const DEFAULT_BYPASS_PATHS = ["/_build", "/deco/", "/live/", "/.decofile"];
678
851
 
679
852
  /**
@@ -723,11 +896,17 @@ function parseCookieNames(response: Response): string[] {
723
896
  /**
724
897
  * Check if ALL cookies in a response are in the safe list.
725
898
  * Returns true if the response has no cookies or only safe cookies.
899
+ *
900
+ * Fail-closed: a response that HAS a `set-cookie` we couldn't parse into names
901
+ * is treated as unsafe. The parser's fallback path (comma-splitting the
902
+ * combined header) is documented as unreliable, and the cost of the two
903
+ * outcomes is not symmetric — guessing "safe" here caches a personalized
904
+ * response into the shared edge entry.
726
905
  */
727
906
  function hasOnlySafeCookies(response: Response, safeCookieSet: Set<string>): boolean {
728
907
  if (!response.headers.has("set-cookie")) return true;
729
908
  const names = parseCookieNames(response);
730
- if (names.length === 0) return true;
909
+ if (names.length === 0) return false;
731
910
  return names.every((name) => safeCookieSet.has(name));
732
911
  }
733
912
 
@@ -791,6 +970,15 @@ const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+
791
970
  // Sites that need city precision should set geoCacheKey: "city" explicitly.
792
971
 
793
972
  let _autoGeoKey: "off" | "region" = "off";
973
+ /**
974
+ * Whether `_autoGeoKey` is an answer or still the pre-hydration default. It
975
+ * starts `"off"` and only becomes `"region"` once a decofile carrying a
976
+ * location matcher loads, so anything that *reports* on `"off"` (rather than
977
+ * just keying on it) has to wait for a non-empty decofile first. Recomputed on
978
+ * every change rather than latched, so a reset back to an empty decofile
979
+ * un-settles it.
980
+ */
981
+ let _autoGeoSettled = false;
794
982
  let _autoGeoListenerInstalled = false;
795
983
 
796
984
  function installAutoGeoListener(): void {
@@ -798,11 +986,28 @@ function installAutoGeoListener(): void {
798
986
  _autoGeoListenerInstalled = true;
799
987
  const update = (blocks: Record<string, unknown>) => {
800
988
  _autoGeoKey = detectLocationMatcher(blocks) ? "region" : "off";
989
+ _autoGeoSettled = Object.keys(blocks).length > 0;
801
990
  };
802
991
  onChange(update);
803
992
  update(loadBlocks());
804
993
  }
805
994
 
995
+ let _draftPreviewInstalled = false;
996
+
997
+ /**
998
+ * Wire draft preview once: register the request-scoped draft getter with the
999
+ * runtime and keep the `previewHosts` allowlist in sync with the base decofile.
1000
+ * Registered unconditionally (idempotent), but the feature stays fully inert
1001
+ * until an allowlist names the request's host — see `./draft`.
1002
+ */
1003
+ function installDraftPreviewListener(): void {
1004
+ if (_draftPreviewInstalled) return;
1005
+ _draftPreviewInstalled = true;
1006
+ registerDraftOverride();
1007
+ onChange((blocks) => installPreviewHostsFromBlocks(blocks));
1008
+ installPreviewHostsFromBlocks(loadBlocks());
1009
+ }
1010
+
806
1011
  const IMMUTABLE_HEADERS: Record<string, string> = {
807
1012
  "Cache-Control": `public, max-age=${ONE_YEAR}, immutable`,
808
1013
  Vary: "Accept-Encoding",
@@ -829,6 +1034,39 @@ let _redirectMapRevision: string | null = null;
829
1034
  // Factory
830
1035
  // ---------------------------------------------------------------------------
831
1036
 
1037
+ /**
1038
+ * CORS headers for the `?renderJson` response, from
1039
+ * {@link DecoWorkerEntryOptions.pageJsonCors}. `?renderJson` is public page data
1040
+ * (secrets + request-derived keys are stripped), so the default is `"*"` — any
1041
+ * origin, no credentials (the standard public-API CORS). `false` turns it off;
1042
+ * a list reflects an allow-listed request Origin *with* credentials.
1043
+ */
1044
+ function pageJsonCorsHeaders(
1045
+ request: Request,
1046
+ cors: string[] | "*" | false | undefined,
1047
+ ): Record<string, string> {
1048
+ if (cors === false) return {}; // explicitly disabled
1049
+ const effective = cors ?? "*"; // default: any origin (no credentials)
1050
+ const base = {
1051
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
1052
+ "Access-Control-Allow-Headers": "Content-Type, If-None-Match, Authorization",
1053
+ "Access-Control-Expose-Headers": "ETag",
1054
+ };
1055
+ if (effective === "*") {
1056
+ return { ...base, "Access-Control-Allow-Origin": "*" };
1057
+ }
1058
+ const origin = request.headers.get("origin");
1059
+ if (origin && effective.includes(origin)) {
1060
+ return {
1061
+ ...base,
1062
+ "Access-Control-Allow-Origin": origin,
1063
+ "Access-Control-Allow-Credentials": "true",
1064
+ Vary: "Origin",
1065
+ };
1066
+ }
1067
+ return {}; // origin not allow-listed → no CORS headers
1068
+ }
1069
+
832
1070
  /**
833
1071
  * Creates a Cloudflare Worker fetch handler that wraps a TanStack Start
834
1072
  * server entry with intelligent edge caching.
@@ -857,15 +1095,21 @@ export function createDecoWorkerEntry(
857
1095
  cacheVersionEnv = "BUILD_HASH",
858
1096
  securityHeaders: securityHeadersOpt,
859
1097
  csp: cspOpt,
1098
+ cspMode = "report-only",
860
1099
  autoInjectGeoCookies: geoOpt = true,
861
1100
  geoCacheKey: geoCacheKeyOpt = "auto",
862
1101
  safeCookies: safeCookiesOpt = DEFAULT_SAFE_COOKIES,
863
1102
  staticPaths: staticPathsOpt = DEFAULT_STATIC_PATHS,
864
- cdnCacheControl: cdnCacheControlOpt = "no-store",
1103
+ cdnCacheControl: cdnCacheControlOpt = "serverfn-segment",
865
1104
  observability: observabilityOpt,
866
1105
  outboundUserAgent: outboundUserAgentOpt,
1106
+ speculationRules: speculationRulesOpt,
867
1107
  } = options;
868
1108
 
1109
+ // Speculation Rules — store the site-wide config in the shared singleton so
1110
+ // DecoRootLayout (SSR) emits the tag in <head>. Inert unless the site opts in.
1111
+ setSpeculationRules(speculationRulesOpt);
1112
+
869
1113
  // Patch global fetch once so every outbound request — apps calling
870
1114
  // `fetch` directly, RequestContext.fetch, instrumentedFetch — carries a
871
1115
  // User-Agent. Workers sends none by default, which WAFs on partner
@@ -883,6 +1127,10 @@ export function createDecoWorkerEntry(
883
1127
  installAutoGeoListener();
884
1128
  }
885
1129
 
1130
+ // Draft preview (pull-based): getter DI + previewHosts sync. Inert unless
1131
+ // the site opts in via `previewHosts` / DECO_ALLOWED_PREVIEW_HOSTS.
1132
+ installDraftPreviewListener();
1133
+
886
1134
  /** Resolve the effective geo granularity for the current request. */
887
1135
  function effectiveGeoKey(): "off" | "country" | "region" | "city" {
888
1136
  return geoCacheKeyOpt === "auto" ? _autoGeoKey : geoCacheKeyOpt;
@@ -904,9 +1152,40 @@ export function createDecoWorkerEntry(
904
1152
  return fromHeader || undefined;
905
1153
  }
906
1154
 
1155
+ // Sites generated by older migration templates set `regionId` from the
1156
+ // Cloudflare state on every request, which splits every page's cache ~27x
1157
+ // (one copy per BR state) even when no content varies by location. The
1158
+ // framework already does this backfill below, and only when a location
1159
+ // matcher exists — so flag the redundant, unconditional version once.
1160
+ let warnedGeoSegment = false;
1161
+ /**
1162
+ * Whether `effectiveGeoKey() === "off"` is a real answer or just the
1163
+ * pre-hydration default. Under `geoCacheKey: "auto"` a request that lands
1164
+ * before the decofile loads would otherwise latch the warning below and tell
1165
+ * the team to delete a geo fallback its location matcher actually needs. An
1166
+ * explicit `geoCacheKey` has no such race.
1167
+ */
1168
+ function geoKeySettled(): boolean {
1169
+ return geoCacheKeyOpt !== "auto" || _autoGeoSettled;
1170
+ }
907
1171
  const buildSegment = rawBuildSegment
908
1172
  ? (request: Request): SegmentKey => {
909
1173
  const seg = rawBuildSegment(request);
1174
+ if (
1175
+ !warnedGeoSegment &&
1176
+ seg.regionId &&
1177
+ effectiveGeoKey() === "off" &&
1178
+ geoKeySettled() &&
1179
+ seg.regionId === readRegionFromRequest(request)
1180
+ ) {
1181
+ warnedGeoSegment = true;
1182
+ console.warn(
1183
+ `[deco] buildSegment sets regionId to the Cloudflare region ("${seg.regionId}") ` +
1184
+ "but no website/matchers/location.ts block exists, so every page's edge cache " +
1185
+ "is split per region for nothing. Return only the VTEX regionId from buildSegment " +
1186
+ 'and let geoCacheKey: "auto" backfill the region when a location matcher is added.',
1187
+ );
1188
+ }
910
1189
  if (seg.regionId) return seg;
911
1190
  if (effectiveGeoKey() === "off") return seg;
912
1191
  const region = readRegionFromRequest(request);
@@ -916,6 +1195,10 @@ export function createDecoWorkerEntry(
916
1195
 
917
1196
  const safeCookieSet = new Set(safeCookiesOpt);
918
1197
 
1198
+ // One warning per worker instance, not per request — same pattern as
1199
+ // `warnedLongMaxAge` in @decocms/blocks/sdk/cachedLoader.
1200
+ let warnedMatchProfile = false;
1201
+
919
1202
  // Build the final security headers map (merged defaults + custom + CSP)
920
1203
  const secHeaders: Record<string, string> | null = (() => {
921
1204
  if (securityHeadersOpt === false) return null;
@@ -935,7 +1218,12 @@ export function createDecoWorkerEntry(
935
1218
  return base;
936
1219
  })();
937
1220
 
938
- function applySecurityHeaders(resp: Response): Response {
1221
+ // The enforced frame-ancestors value that ends up on `Content-Security-Policy`
1222
+ // (site override wins, else the deco default). Reused when we build the
1223
+ // enforced nonce CSP so framing protection is preserved.
1224
+ const frameAncestorsCsp = secHeaders?.["Content-Security-Policy"] ?? DEFAULT_FRAME_ANCESTORS_CSP;
1225
+
1226
+ function applySecurityHeaders(resp: Response, nonce?: string): Response {
939
1227
  if (!secHeaders) return resp;
940
1228
  const ct = resp.headers.get("content-type") ?? "";
941
1229
  if (!ct.includes("text/html")) return resp;
@@ -943,10 +1231,45 @@ export function createDecoWorkerEntry(
943
1231
  for (const [k, v] of Object.entries(secHeaders)) {
944
1232
  if (!out.headers.has(k)) out.headers.set(k, v);
945
1233
  }
1234
+ // Enforced nonce CSP: enforce mode + a real nonce + a non-cacheable
1235
+ // response only. On a cacheable response a per-request nonce baked into the
1236
+ // (cached) body would desync from the header on the next HIT and block every
1237
+ // script, so those stay report-only. `CDN-Cache-Control` is decided at the
1238
+ // single response exit just before this call, so `no-store` is reliable here.
1239
+ if (
1240
+ cspMode === "enforce" &&
1241
+ nonce &&
1242
+ cspOpt &&
1243
+ cspOpt.length > 0 &&
1244
+ (out.headers.get("CDN-Cache-Control") ?? "").includes("no-store")
1245
+ ) {
1246
+ out.headers.delete("Content-Security-Policy-Report-Only");
1247
+ out.headers.set(
1248
+ "Content-Security-Policy",
1249
+ buildEnforcedCsp(cspOpt, frameAncestorsCsp, nonce),
1250
+ );
1251
+ }
946
1252
  return out;
947
1253
  }
948
1254
 
949
- const allBypassPaths = [...(bypassPaths ?? DEFAULT_BYPASS_PATHS), ...extraBypassPaths];
1255
+ // DEFAULT_BYPASS_PATHS is always included, even when a site passes its own
1256
+ // `bypassPaths`. It used to be replaced, so a site adding one path silently
1257
+ // lost `/deco/`, `/live/` and `/.decofile` — framework routes that must never
1258
+ // be cached. Cache configuration from a site can tighten, never loosen.
1259
+ const allBypassPaths = [
1260
+ ...new Set([...DEFAULT_BYPASS_PATHS, ...(bypassPaths ?? []), ...extraBypassPaths]),
1261
+ ];
1262
+
1263
+ // The logged-in bypass below reads `segment.loggedIn`, which only ever exists
1264
+ // when the site supplies `buildSegment`. Without it, authenticated and
1265
+ // anonymous visitors share one edge entry.
1266
+ if (!rawBuildSegment) {
1267
+ console.warn(
1268
+ "[deco] createDecoWorkerEntry: no `buildSegment` configured — logged-in " +
1269
+ "visitors share the anonymous edge cache entry. Required before enabling " +
1270
+ "CDN caching.",
1271
+ );
1272
+ }
950
1273
 
951
1274
  // -- Helpers ----------------------------------------------------------------
952
1275
 
@@ -963,7 +1286,12 @@ export function createDecoWorkerEntry(
963
1286
  function isCacheable(request: Request, url: URL): boolean {
964
1287
  if (request.method !== "GET") return false;
965
1288
  if (isBypassPath(url.pathname)) return false;
966
- if (url.searchParams.has("__deco_draft")) return false;
1289
+ // Draft preview: never cache a request in draft mode. Keys on the real
1290
+ // `?__draft=` param OR the navigation cookie (SPA nav after entry), gated
1291
+ // on an allowed preview host — see requestCarriesDraft.
1292
+ if (requestCarriesDraft(request, url)) return false;
1293
+ // A next-major draft (`?__draft=` or the `deco-draft` cookie): same rule.
1294
+ if (draftPointer(request) !== null) return false;
967
1295
  if (url.searchParams.has("__deco_preview")) return false;
968
1296
  if (url.searchParams.has("pathTemplate")) return false;
969
1297
  // Forced matcher results must never be served from (or stored in) the
@@ -990,13 +1318,7 @@ export function createDecoWorkerEntry(
990
1318
  return detectCacheProfile(target);
991
1319
  }
992
1320
 
993
- const KNOWN_SEGMENT_FIELDS = new Set([
994
- "device",
995
- "loggedIn",
996
- "salesChannel",
997
- "regionId",
998
- "flags",
999
- ]);
1321
+ const KNOWN_SEGMENT_FIELDS = new Set(["device", "loggedIn", "salesChannel", "regionId", "flags"]);
1000
1322
 
1001
1323
  function hashSegment(seg: SegmentKey): string {
1002
1324
  const parts: string[] = [seg.device];
@@ -1057,6 +1379,95 @@ export function createDecoWorkerEntry(
1057
1379
  return undefined;
1058
1380
  }
1059
1381
 
1382
+ function isServerFnPathname(pathname: string): boolean {
1383
+ return pathname.startsWith("/_serverFn/") || pathname.startsWith("/_server/");
1384
+ }
1385
+
1386
+ /**
1387
+ * Whether this `/_serverFn` request may be cached by Cloudflare's CDN.
1388
+ *
1389
+ * True only when the URL carries a `__cseg` marker (put there by
1390
+ * `decoServerFnFetch` on the client) AND recomputing the segment from this
1391
+ * request produces the same token. When it matches, the CDN's key — the raw
1392
+ * URL — is equivalent to the Worker's synthetic key, so serving from the CDN
1393
+ * cannot cross segments.
1394
+ *
1395
+ * Fail-closed on every axis: no marker (bot, curl, old client), diverging
1396
+ * marker, forged marker, stale build, logged-in / region / sales channel, a
1397
+ * custom segment dimension, a bot UA, or an A/B cohort cookie all keep
1398
+ * today's `no-store`. The worst case is not caching — never a wrong response.
1399
+ *
1400
+ * `isBot` and the A/B cookie are checked HERE rather than inside
1401
+ * `segmentToken` because they are request-only: the client cannot observe
1402
+ * them, and they are exactly the two dimensions the original site-level
1403
+ * version missed. They must use the same `isBot` / `segmentCacheToken` that
1404
+ * `buildCacheKey` uses — keying and releasing off different predicates is how
1405
+ * the two silently diverge.
1406
+ */
1407
+ function cdnCacheableServerFn(request: Request, url: URL, env: Record<string, unknown>): boolean {
1408
+ if (!isServerFnPathname(url.pathname)) return false;
1409
+
1410
+ const marker = url.searchParams.get(CSEG_PARAM);
1411
+ if (!marker) return false;
1412
+
1413
+ // Without buildSegment the logged-in bypass is inert (see the boot warning),
1414
+ // so there is nothing reliable to verify the marker against.
1415
+ if (!buildSegment) return false;
1416
+
1417
+ // `__bot=1` in buildCacheKey: bots render every section eagerly (~10x
1418
+ // payload). Letting the CDN share one entry would serve that to humans, or
1419
+ // the deferred one to crawlers.
1420
+ if (isBot(request.headers.get("user-agent") ?? undefined)) return false;
1421
+
1422
+ // `__abf` in buildCacheKey: an A/B visitor must not get another cohort's
1423
+ // cached variant.
1424
+ if (segmentCacheToken(parseSegmentCookie(readRequestCookie(request, SEGMENT_COOKIE)))) {
1425
+ return false;
1426
+ }
1427
+
1428
+ // `__cf_geo` in buildCacheKey is a dimension the segment descriptor does not
1429
+ // carry: it comes from `buildGeoCacheParam` (country/region/city) rather
1430
+ // than from `buildSegment`. Fold it in so two visitors keyed differently by
1431
+ // the Worker can never share a URL.
1432
+ const expected = segmentToken(
1433
+ requestSegmentDescriptor(request),
1434
+ buildSegment(request).loggedIn === true,
1435
+ getBuildHash(env),
1436
+ );
1437
+ return expected !== null && marker === expected;
1438
+ }
1439
+
1440
+ /**
1441
+ * The full set of dimensions the Worker keys this request on, as one string.
1442
+ *
1443
+ * MUST stay in step with `buildCacheKey` — it is the same question ("what
1444
+ * makes this response different from another visitor's?") asked in a form
1445
+ * that fits in a URL. A dimension present there and missing here is a
1446
+ * cross-visitor leak, which is why both read the same helpers rather than
1447
+ * re-deriving anything.
1448
+ */
1449
+ function requestSegmentDescriptor(request: Request): string {
1450
+ if (!buildSegment) return "";
1451
+ const parts = [hashSegment(buildSegment(request))];
1452
+
1453
+ const geo = buildGeoCacheParam(
1454
+ (request as unknown as { cf?: Record<string, string> }).cf,
1455
+ effectiveGeoKey(),
1456
+ );
1457
+ if (geo) parts.push(`geo=${geo}`);
1458
+
1459
+ return parts.join("|");
1460
+ }
1461
+
1462
+ function requestCarriesTracking(url: URL): boolean {
1463
+ if (hasTrackingParams(url.toString())) return true;
1464
+ if (url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/")) {
1465
+ const payload = url.searchParams.get("payload");
1466
+ return !!payload && payloadHasTrackingParams(payload);
1467
+ }
1468
+ return false;
1469
+ }
1470
+
1060
1471
  function buildCacheKey(
1061
1472
  request: Request,
1062
1473
  env: Record<string, unknown>,
@@ -1116,8 +1527,13 @@ export function createDecoWorkerEntry(
1116
1527
  // bucket so a fetch-triggered eager response never poisons the navigation
1117
1528
  // (deferred) entry, and vice-versa. MUST use the same `Sec-Fetch-Dest:
1118
1529
  // empty` signal as isProgrammaticFetch so the key and render decisions can't
1119
- // diverge. `/_serverFn` (SPA-nav data) is excluded: it has its own keying
1120
- // and stays eager via isClientNavigation, not this bucket.
1530
+ // diverge. `/_serverFn` (SPA-nav data) is excluded: it sends the same
1531
+ // `Sec-Fetch-Dest: empty` but is NOT a programmatic fetch — it renders
1532
+ // through DecoPageRenderer and so keeps the same deferred split as the
1533
+ // document (isProgrammaticFetch bails on isClientNavigation for the same
1534
+ // reason). It needs no bucket of its own here: its cache key is the
1535
+ // `/_serverFn/...` pathname + canonicalized payload, which can never
1536
+ // collide with a document entry.
1121
1537
  const secFetchDest = request.headers.get("sec-fetch-dest");
1122
1538
  const isServerFnPath =
1123
1539
  url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/");
@@ -1223,14 +1639,10 @@ export function createDecoWorkerEntry(
1223
1639
 
1224
1640
  const geoVariants = body.countries ?? [];
1225
1641
 
1226
- const cache = isDevMode()
1227
- ? null
1228
- : typeof caches !== "undefined"
1229
- ? ((caches as unknown as { default?: Cache }).default ?? null)
1230
- : null;
1642
+ const cache = responseCache(request, env);
1231
1643
 
1232
1644
  if (!cache) {
1233
- return Response.json({ purged: [], total: 0, note: "Cache API unavailable" });
1645
+ return Response.json({ purged: [], total: 0, note: "Cache storage unavailable" });
1234
1646
  }
1235
1647
 
1236
1648
  const baseUrl = new URL(request.url).origin;
@@ -1327,7 +1739,42 @@ export function createDecoWorkerEntry(
1327
1739
  const denied = authorizePurge(request, env);
1328
1740
  if (denied) return denied;
1329
1741
  clearLoaderCache();
1330
- return Response.json({ cleared: true, scope: "isolate" });
1742
+ return Response.json({
1743
+ cleared: true,
1744
+ scope: "isolate",
1745
+ ...(options.cacheStorage
1746
+ ? { sharedStorage: "Entries expire by TTL; this endpoint clears memory only." }
1747
+ : {}),
1748
+ });
1749
+ }
1750
+
1751
+ function responseCache(request: Request, env: Record<string, unknown>) {
1752
+ if (isDevMode()) return null;
1753
+ const context = getCacheStorageContext();
1754
+ if (context?.disabled && new URL(request.url).pathname !== "/_cache/purge") return null;
1755
+ let storage = context?.storage;
1756
+ // Purge is authenticated before this helper runs, and needs storage even though
1757
+ // ordinary Authorization-bearing requests bypass every shared cache.
1758
+ if (options.cacheStorage && new URL(request.url).pathname === "/_cache/purge") {
1759
+ try {
1760
+ storage = options.cacheStorage(env, request);
1761
+ } catch {
1762
+ return null;
1763
+ }
1764
+ }
1765
+ if (!options.cacheStorage) {
1766
+ const native =
1767
+ typeof caches !== "undefined"
1768
+ ? (caches as unknown as { default?: Cache }).default
1769
+ : undefined;
1770
+ storage = native ? createWebCacheStorage(native, new URL(request.url).origin) : null;
1771
+ }
1772
+ return storage
1773
+ ? createResponseCache(
1774
+ storage,
1775
+ JSON.stringify([new URL(request.url).origin, getBuildHash(env), getRevision()]),
1776
+ )
1777
+ : null;
1331
1778
  }
1332
1779
 
1333
1780
  // -- Admin route handler ---------------------------------------------------
@@ -1439,6 +1886,14 @@ export function createDecoWorkerEntry(
1439
1886
  const reqUrl = new URL(request.url);
1440
1887
  const method = request.method;
1441
1888
 
1889
+ // Deco-hosted preview hosts (`<site>.deco.site`,
1890
+ // `<site>.deco-cx.workers.dev`) inferred from the DECO_SITE_NAME
1891
+ // binding. Installed before any draft
1892
+ // gate runs (requestCarriesDraft participates in the cacheability
1893
+ // decision below). The binding is deploy-constant, so this is an
1894
+ // idempotent write.
1895
+ installDecoSiteHostFromEnv(env);
1896
+
1442
1897
  // Inject CF geo data as cookies for location matchers (before anything reads cookies)
1443
1898
  if (geoOpt) {
1444
1899
  request = injectGeoCookies(request);
@@ -1452,6 +1907,18 @@ export function createDecoWorkerEntry(
1452
1907
  // become no-ops.
1453
1908
  const identity = { requestId: "", traceId: "" };
1454
1909
 
1910
+ // Draft-preview decision — resolved inside RequestContext.run (below) so
1911
+ // the resolved decofile binds to the request bag before the render, then
1912
+ // lifted here so the cookie + no-store/noindex headers can be stamped on
1913
+ // the final response. Inert on non-preview builds/hosts.
1914
+ let draftDecision: DraftDecision = { previewing: false, setCookie: null, clearCookie: false };
1915
+
1916
+ // Per-request CSP nonce (enforce mode only). Generated inside the
1917
+ // RequestContext.run below — before the render — so SSR components and the
1918
+ // router can read it from the bag, then lifted here so the enforced CSP
1919
+ // header built after the render carries the same value.
1920
+ let cspNonce: string | undefined;
1921
+
1455
1922
  // Wrap the entire request in a RequestContext so that all code
1456
1923
  // in the call stack (loaders, invoke handlers, vtexFetchWithCookies)
1457
1924
  // can access the request and write response headers.
@@ -1461,6 +1928,32 @@ export function createDecoWorkerEntry(
1461
1928
  // via getRuntimeEnv() in sdk/otelAdapters.ts.
1462
1929
  setRuntimeEnv(env);
1463
1930
 
1931
+ // Fast-deploy: hydrate the in-memory decofile from KV on the first
1932
+ // request per isolate (awaited, ~10-30ms once), then opportunistically
1933
+ // poll for content changes (non-blocking, via ctx.waitUntil). No-op
1934
+ // unless the DECO_KV binding + DECO_FAST_DEPLOY are present —
1935
+ // non-migrated sites are unaffected.
1936
+ //
1937
+ // MUST come before reconfigureAppsOnce() and bindRequestDraft() below.
1938
+ // Both read the decofile, and both are one-shot/latching:
1939
+ // - reconfigureAppsOnce() configures apps from the blocks it sees and
1940
+ // latches `reinitPromise` forever. Running it first means a
1941
+ // fastDeploy build (whose bundled decofile is `{}`) configures ZERO
1942
+ // apps, and the only recovery is setBlocks()'s onChange listener,
1943
+ // whose promise setBlocks drops unawaited — so the request would
1944
+ // proceed with no app loaders registered.
1945
+ // - bindRequestDraft() composes draft overrides into loadBlocks().
1946
+ await ensureBlocksHydrated(env, ctx);
1947
+ maybePollRevision(env, ctx);
1948
+
1949
+ // First-request re-configuration of apps. autoconfigApps() runs at
1950
+ // module init (setup.ts), before `env` — and thus DECO_CRYPTO_KEY —
1951
+ // is available, so encrypted app credentials (VTEX appKey/appToken,
1952
+ // etc.) decrypt to null at startup. Now that env is in the ALS,
1953
+ // getEnvVar() can reach the key; re-run configure once so apps pick up
1954
+ // real credentials. No-op on every request after the first.
1955
+ await reconfigureAppsOnce();
1956
+
1464
1957
  // RequestContext.run already resolved request.id from the
1465
1958
  // inbound headers (precedence: x-request-id → cf-ray → UUID).
1466
1959
  // Lift it into the closure so the response-write path below has
@@ -1480,6 +1973,11 @@ export function createDecoWorkerEntry(
1480
1973
  // production request without changing global sampling rates.
1481
1974
  if (new URL(request.url).searchParams.has("__d")) _setDebugSampled();
1482
1975
 
1976
+ // Resolve + bind any draft preview BEFORE the render, so every
1977
+ // loadBlocks() in the call stack sees the merged draft decofile via
1978
+ // the request bag. Cheap no-op unless the site opts in.
1979
+ draftDecision = await bindRequestDraft(request);
1980
+
1483
1981
  // Wrap inner handler in a single root span carrying our normalized
1484
1982
  // path/method attributes. The framework span flows BOTH ways:
1485
1983
  // - via the OTLP direct-POST tracer (when DECO_OTEL_TRACES_ENDPOINT
@@ -1505,6 +2003,29 @@ export function createDecoWorkerEntry(
1505
2003
 
1506
2004
  // Run app middleware (injects app state into RequestContext.bag,
1507
2005
  // runs registered middleware like VTEX cookie forwarding).
2006
+ // Publish the segment token so the SSR can hand it to the browser
2007
+ // (see sdk/cdnSegment). Only the server can compute this: region
2008
+ // comes from `request.cf`, which the client never sees. Per-request
2009
+ // via the RequestContext bag — a module-level global here would be
2010
+ // the layout-cache race all over again.
2011
+ if (buildSegment && cdnCacheControlOpt === "serverfn-segment") {
2012
+ const token = segmentToken(
2013
+ requestSegmentDescriptor(request),
2014
+ buildSegment(request).loggedIn === true,
2015
+ getBuildHash(env),
2016
+ );
2017
+ if (token) RequestContext.setBag(CSEG_BAG_KEY, token);
2018
+ }
2019
+
2020
+ // CSP nonce (enforce mode only): produce it before the render and
2021
+ // publish it in the bag so `createDecoRouter` (router.options.ssr.nonce)
2022
+ // and the deco layout components pick it up while rendering. Lifted to
2023
+ // `cspNonce` so applySecurityHeaders below stamps the matching header.
2024
+ if (cspMode === "enforce") {
2025
+ cspNonce = generateNonce();
2026
+ RequestContext.setBag(NONCE_BAG_KEY, cspNonce);
2027
+ }
2028
+
1508
2029
  const appMw = getAppMiddleware();
1509
2030
  const innerResponse = appMw
1510
2031
  ? await appMw(request, () => handleRequest(request, env, ctx))
@@ -1536,7 +2057,28 @@ export function createDecoWorkerEntry(
1536
2057
  // invoke handlers, etc.) may independently append the same cookie.
1537
2058
  deduplicateSetCookies(response);
1538
2059
 
1539
- let finalResponse = applySecurityHeaders(response);
2060
+ // `CDN-Cache-Control` is decided here, at the single response exit, so no
2061
+ // branch can forget it. Two cases:
2062
+ //
2063
+ // - `X-Cache: BYPASS` — the worker deliberately declined to cache, so
2064
+ // the CDN must not either. A dozen call sites set this; some deleted
2065
+ // the header, some didn't, and several still emitted the profile's
2066
+ // public `Cache-Control` (`public, s-maxage=900`) on the way out.
2067
+ // - header absent — a branch that returned before `dressResponse` and
2068
+ // expressed no opinion. `?asJson`, `?renderJson`, proxied responses and
2069
+ // the redirect paths all land here. Defaulting these to `no-store` is
2070
+ // what makes the invariant hold: an early return can only ever be
2071
+ // MORE restrictive than the cache layer, never accidentally public.
2072
+ //
2073
+ // A value already set by `dressResponse` (the cacheable path) is left
2074
+ // alone — that is the one branch that has actually reasoned about whether
2075
+ // the CDN key matches the worker key.
2076
+ const bypassed = response.headers.get("X-Cache") === "BYPASS";
2077
+ if (bypassed || !response.headers.has("CDN-Cache-Control")) {
2078
+ response.headers.set("CDN-Cache-Control", "no-store");
2079
+ }
2080
+
2081
+ let finalResponse = applySecurityHeaders(response, cspNonce);
1540
2082
 
1541
2083
  // Echo request.id + trace.id back to the client / tail worker.
1542
2084
  // The CF tail worker reads these headers off the response to
@@ -1591,6 +2133,11 @@ export function createDecoWorkerEntry(
1591
2133
  }
1592
2134
  }
1593
2135
 
2136
+ // Draft preview: persist/clear the navigation cookie and stamp the
2137
+ // no-store/noindex headers so a drafted response is never cached under
2138
+ // its shared (cookie-only) URL. No-op when not previewing.
2139
+ applyDraftCookieAndHeaders(finalResponse, draftDecision);
2140
+
1594
2141
  // Metrics + structured request log. Done after security headers so
1595
2142
  // the recorded status reflects what the client actually receives.
1596
2143
  // Both calls are no-ops when no meter / logger is configured.
@@ -1627,6 +2174,21 @@ export function createDecoWorkerEntry(
1627
2174
  // `DECO_OTEL_*_ENDPOINT` env vars are configured, telemetry flows
1628
2175
  // without any change to the site's worker-entry. When the env vars are
1629
2176
  // absent the wrap is a no-op (no exporters created, no flush calls).
2177
+ // `@decocms/blocks` background work (release checks, telemetry batches)
2178
+ // queued during a request runs after its response, inside ctx.waitUntil.
2179
+ installBackgroundHook();
2180
+ // Next major: upstream GETs made with createInstrumentedFetch are cached
2181
+ // with the Cache API (see ../v8/binding.ts).
2182
+ installUpstreamCache();
2183
+ const respond = handler.fetch;
2184
+ handler.fetch = async (request, env, ctx) => {
2185
+ try {
2186
+ return await respond(request, env, ctx);
2187
+ } finally {
2188
+ runBackgroundTasks(ctx);
2189
+ }
2190
+ };
2191
+
1630
2192
  return observabilityOpt === false
1631
2193
  ? handler
1632
2194
  : instrumentWorker(handler, (observabilityOpt as OtelOptions | undefined) ?? {});
@@ -1638,13 +2200,49 @@ export function createDecoWorkerEntry(
1638
2200
  ): Promise<Response> {
1639
2201
  const url = new URL(request.url);
1640
2202
 
1641
- // Fast-deploy: hydrate the in-memory decofile from KV on the first request
1642
- // per isolate (awaited, ~10-30ms once), then opportunistically poll for
1643
- // content changes (non-blocking, via ctx.waitUntil). No-op unless the
1644
- // DECO_KV binding is present — non-migrated sites are unaffected. Runs
1645
- // before admin routes so /.decofile reads reflect KV too.
1646
- await ensureBlocksHydrated(env, ctx);
1647
- maybePollRevision(env, ctx);
2203
+ // NOTE: fast-deploy hydration does NOT run here. It must happen before
2204
+ // `reconfigureAppsOnce()` and `bindRequestDraft()`, both of which run in
2205
+ // the outer fetch above — see the `ensureBlocksHydrated` call there.
2206
+
2207
+ {
2208
+ const segment = buildSegment?.(request);
2209
+ const privateRequest =
2210
+ requestCarriesDraft(request, url) ||
2211
+ segment?.loggedIn === true ||
2212
+ request.headers.has("authorization") ||
2213
+ url.searchParams.has("__deco_preview") ||
2214
+ url.searchParams.has(DECO_MATCHERS_OVERRIDE_PARAM) ||
2215
+ request.headers.has(DECO_MATCHERS_OVERRIDE_PARAM) ||
2216
+ url.pathname.startsWith("/live/previews") ||
2217
+ url.pathname.startsWith("/deco/render");
2218
+ let storage: CacheStorage | null = null;
2219
+ try {
2220
+ storage =
2221
+ privateRequest || isDevMode() ? null : (options.cacheStorage?.(env, request) ?? null);
2222
+ } catch {
2223
+ /* Missing storage is a cache miss, never a storefront failure. */
2224
+ }
2225
+ bindCacheStorage({
2226
+ storage,
2227
+ disabled: privateRequest || isDevMode(),
2228
+ scope: JSON.stringify([
2229
+ new URL(request.url).origin,
2230
+ getBuildHash(env),
2231
+ getRevision(),
2232
+ segment
2233
+ ? hashSegment(segment)
2234
+ : isMobileUA(request.headers.get("user-agent") ?? "")
2235
+ ? "mobile"
2236
+ : "desktop",
2237
+ buildGeoCacheParam(
2238
+ (request as unknown as { cf?: Record<string, string> }).cf,
2239
+ effectiveGeoKey(),
2240
+ ),
2241
+ privateRequest ? crypto.randomUUID() : null,
2242
+ ]),
2243
+ waitUntil: (work) => ctx.waitUntil(work),
2244
+ });
2245
+ }
1648
2246
 
1649
2247
  // Admin routes (/_meta, /.decofile, /live/previews) — always handled first
1650
2248
  const adminResponse = await tryAdminRoute(request);
@@ -1672,7 +2270,15 @@ export function createDecoWorkerEntry(
1672
2270
  _redirectMap = loadRedirects(loadBlocks());
1673
2271
  _redirectMapRevision = currentRevision;
1674
2272
  }
1675
- const cmsRedirect = matchRedirect(url.pathname, _redirectMap!);
2273
+ // Exact before glob, across BOTH sources. On a fast-deploy site the exact
2274
+ // rules live under their own `redirect:<id>:<path>` KV keys and are absent
2275
+ // from the decofile, so the in-memory exact hit is the non-fast-deploy
2276
+ // case. Going straight to `matchRedirect` and only then to KV would let a
2277
+ // glob win over an exact rule, inverting the precedence.
2278
+ const cmsRedirect =
2279
+ matchExactRedirect(url.pathname, _redirectMap!) ??
2280
+ (await lookupExactRedirect(env as Record<string, unknown>, normalizePath(url.pathname))) ??
2281
+ matchPatternRedirect(url.pathname, _redirectMap!);
1676
2282
  if (cmsRedirect) {
1677
2283
  return new Response(null, {
1678
2284
  status: cmsRedirect.status,
@@ -1680,8 +2286,146 @@ export function createDecoWorkerEntry(
1680
2286
  });
1681
2287
  }
1682
2288
 
2289
+ // ?renderJson — structured JSON of the page for the mobile app. Lean shape
2290
+ // ({ name, path, sections:[{ component, props }] }), each section projected
2291
+ // through its `renderJson` export. Wins over ?asJson when both are present.
2292
+ // CORS preflight for ?renderJson (only emits headers when pageJsonCors is set).
2293
+ if (
2294
+ options.renderJson !== false &&
2295
+ request.method === "OPTIONS" &&
2296
+ url.searchParams.has("renderJson")
2297
+ ) {
2298
+ return new Response(null, {
2299
+ status: 204,
2300
+ headers: pageJsonCorsHeaders(request, options.pageJsonCors),
2301
+ });
2302
+ }
2303
+
2304
+ if (
2305
+ options.renderJson !== false &&
2306
+ url.searchParams.has("renderJson") &&
2307
+ request.method === "GET"
2308
+ ) {
2309
+ const basePath = url.pathname;
2310
+ const cookies: Record<string, string> = {};
2311
+ for (const pair of (request.headers.get("cookie") ?? "").split(";")) {
2312
+ const [k, ...v] = pair.split("=");
2313
+ if (k?.trim()) cookies[k.trim()] = v.join("=").trim();
2314
+ }
2315
+ const matcherCtx: MatcherContext = {
2316
+ userAgent: request.headers.get("user-agent") ?? "",
2317
+ url: url.toString(),
2318
+ path: basePath,
2319
+ cookies,
2320
+ request,
2321
+ };
2322
+
2323
+ // CORS for ?renderJson — off unless the site allow-lists origins via
2324
+ // options.pageJsonCors (native apps need none; browsers get same-origin).
2325
+ const corsHeaders = pageJsonCorsHeaders(request, options.pageJsonCors);
2326
+
2327
+ const page = await resolveDecoPage(basePath, matcherCtx);
2328
+ if (!page) {
2329
+ // Stable error envelope — the app gets a predictable "not found".
2330
+ return Response.json(
2331
+ { status: 404, notFound: true },
2332
+ { status: 404, headers: corsHeaders },
2333
+ );
2334
+ }
2335
+
2336
+ // Drop web-only / app-owned sections BEFORE running their loaders so a
2337
+ // dropped section never triggers its (expensive) VTEX call. Dual-sourced:
2338
+ // - the section's own `export const renderJson = false`
2339
+ // - the website app's renderJson.sectionsToIgnore (resolveType suffix)
2340
+ const rjBlocks = loadBlocks();
2341
+ const rjSite = rjBlocks["Site"] as Record<string, unknown> | undefined;
2342
+ const rawIgnore = (rjSite?.renderJson as { sectionsToIgnore?: unknown })?.sectionsToIgnore;
2343
+ const ignoreSuffixes = (Array.isArray(rawIgnore) ? rawIgnore : [])
2344
+ .filter((s): s is string => typeof s === "string")
2345
+ .map((s) => s.trim())
2346
+ .filter((s) => s.length > 0);
2347
+ const isDropped = (component: string) =>
2348
+ getSectionOptions(component)?.renderJson === false ||
2349
+ ignoreSuffixes.some((suffix) => component.endsWith(suffix));
2350
+ const getSectionModule = (component: string) => ({
2351
+ renderJson: getSectionOptions(component)?.renderJson,
2352
+ });
2353
+
2354
+ const jsonHeaders = {
2355
+ ...corsHeaders,
2356
+ "Content-Type": "application/json; charset=utf-8",
2357
+ "Cache-Control": "public, max-age=0, must-revalidate",
2358
+ };
2359
+ const withEtag = (body: string): Response => {
2360
+ // Contract-stable ETag over the serialized body: identical → 304.
2361
+ const etag = `"rj-${djb2Hex(body)}"`;
2362
+ if (request.headers.get("if-none-match") === etag) {
2363
+ return new Response(null, { status: 304, headers: { ...corsHeaders, ETag: etag } });
2364
+ }
2365
+ return new Response(body, { headers: { ...jsonHeaders, ETag: etag } });
2366
+ };
2367
+
2368
+ // Lazy single-section fetch: `?renderJson&__section=<index>` resolves ONE
2369
+ // deferred section (the placeholder the whole-page envelope emitted) and
2370
+ // returns its serialized `{ component, props }`.
2371
+ const sectionParam = url.searchParams.get("__section");
2372
+ if (sectionParam !== null) {
2373
+ const sectionIndex = Number(sectionParam);
2374
+ const ds = page.deferredSections.find((d) => d.index === sectionIndex);
2375
+ if (!ds || isDropped(ds.component)) {
2376
+ return Response.json(
2377
+ { status: 404, notFound: true },
2378
+ { status: 404, headers: corsHeaders },
2379
+ );
2380
+ }
2381
+ const resolved = await resolveDeferredSectionFull(ds, basePath, request, matcherCtx);
2382
+ if (!resolved) {
2383
+ return Response.json(
2384
+ { status: 404, notFound: true },
2385
+ { status: 404, headers: corsHeaders },
2386
+ );
2387
+ }
2388
+ const [serialized] = serializeRenderJson(
2389
+ [{ component: resolved.component, props: resolved.props }],
2390
+ { getSectionModule, sectionsToIgnore: ignoreSuffixes },
2391
+ );
2392
+ if (!serialized) {
2393
+ return Response.json(
2394
+ { status: 404, notFound: true },
2395
+ { status: 404, headers: corsHeaders },
2396
+ );
2397
+ }
2398
+ return withEtag(stringifyWithoutSecrets(serialized));
2399
+ }
2400
+
2401
+ // Whole page: eager sections are resolved + projected inline; deferred
2402
+ // sections become `{ component, lazyUrl }` placeholders the app fetches on
2403
+ // demand (interleaved by their position in the page).
2404
+ const keptSections = page.resolvedSections.filter((s) => !isDropped(s.component));
2405
+ const enrichedSections = await runSectionLoaders(keptSections, request);
2406
+ const lazyUrlFor = (ref: { index: number }): string => {
2407
+ const u = new URL(url.toString());
2408
+ u.searchParams.set("__section", String(ref.index));
2409
+ return u.pathname + u.search;
2410
+ };
2411
+
2412
+ const sections = serializeRenderJson(
2413
+ enrichedSections.map((s) => ({ component: s.component, props: s.props, index: s.index })),
2414
+ {
2415
+ getSectionModule,
2416
+ // Belt-and-suspenders: keptSections already dropped these, but the
2417
+ // serializer re-checks so a section that slipped through is still cut.
2418
+ sectionsToIgnore: ignoreSuffixes,
2419
+ deferred: page.deferredSections.map((d) => ({ component: d.component, index: d.index })),
2420
+ lazyUrlFor,
2421
+ },
2422
+ );
2423
+
2424
+ return withEtag(stringifyWithoutSecrets({ name: page.name, path: page.path, sections }));
2425
+ }
2426
+
1683
2427
  // ?asJson — return resolved page data as JSON (legacy deco compat)
1684
- if (url.searchParams.has("asJson") && request.method === "GET") {
2428
+ if (options.asJson !== false && url.searchParams.has("asJson") && request.method === "GET") {
1685
2429
  const basePath = url.pathname;
1686
2430
  const cookies: Record<string, string> = {};
1687
2431
  for (const pair of (request.headers.get("cookie") ?? "").split(";")) {
@@ -1716,7 +2460,7 @@ export function createDecoWorkerEntry(
1716
2460
 
1717
2461
  // Merge site-wide SEO defaults into seo props
1718
2462
  const blocks = loadBlocks();
1719
- const site = blocks["Site"] as Record<string, unknown> | undefined;
2463
+ const site = (blocks["Site"] ?? blocks["site"]) as Record<string, unknown> | undefined;
1720
2464
  const fullSiteSeo = (site?.seo as Record<string, unknown>) ?? {};
1721
2465
 
1722
2466
  // When SeoV2 loader ran, use its output as base (preserves key order)
@@ -1823,11 +2567,21 @@ export function createDecoWorkerEntry(
1823
2567
  request.method === "POST" &&
1824
2568
  (url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/"))
1825
2569
  ) {
1826
- const serverFnCache = isDevMode()
1827
- ? null
1828
- : typeof caches !== "undefined"
1829
- ? ((caches as unknown as { default?: Cache }).default ?? null)
1830
- : null;
2570
+ const serverFnCache = responseCache(request, env);
2571
+
2572
+ // Draft preview: a drafted SPA navigation fetches its section data via
2573
+ // this POST, carrying the draft cookie. The draft is already bound to the
2574
+ // request bag (bindRequestDraft, above), so origin renders it — but the
2575
+ // body-hash cache key does not vary on the cookie, so it must bypass the
2576
+ // shared cache entirely or it would leak drafts / serve stale published.
2577
+ if (requestCarriesDraft(request, url)) {
2578
+ const origin = await serverEntry.fetch(request, env, ctx);
2579
+ const resp = new Response(origin.body, origin);
2580
+ resp.headers.set("Cache-Control", "no-store, private");
2581
+ resp.headers.set("X-Cache", "BYPASS");
2582
+ resp.headers.set("X-Cache-Reason", "draft-preview");
2583
+ return resp;
2584
+ }
1831
2585
 
1832
2586
  // Build segment once — used for logged-in check and cache key
1833
2587
  const sfnSegment = buildSegment ? buildSegment(request) : undefined;
@@ -1860,7 +2614,14 @@ export function createDecoWorkerEntry(
1860
2614
  // work correctly. We only read the body for the cache key hash.
1861
2615
  const originClone = request.clone();
1862
2616
  const body = await request.text();
1863
- const bodyHash = await hashText(body);
2617
+ // Key on the body with tracking params stripped (a deferred section's
2618
+ // `pageUrl` carries the landing URL, so `?gclid=…` otherwise mints one
2619
+ // entry per ad click), but never *store* a response rendered from a
2620
+ // tracked body: it embeds that URL, and every later visitor would get it.
2621
+ const sfnCarriesTracking = shouldStripTracking && payloadHasTrackingParams(body);
2622
+ const bodyHash = await hashText(
2623
+ shouldStripTracking ? canonicalizeServerFnPayloadForCacheKey(body, []) : body,
2624
+ );
1864
2625
 
1865
2626
  // Build a synthetic GET cache key from the URL + body hash + segment
1866
2627
  // Includes device, salesChannel, regionId, flags — so users in
@@ -1923,7 +2684,15 @@ export function createDecoWorkerEntry(
1923
2684
  ctx.waitUntil(
1924
2685
  (async () => {
1925
2686
  try {
1926
- const bgReq = new Request(request, { body, method: "POST" });
2687
+ // Revalidate from the canonical body, for the same reason the
2688
+ // HTML path revalidates from the clean URL: the entry is keyed
2689
+ // on it, and a render from the tracked body embeds that landing
2690
+ // URL. Rendering the tracked body would only be discarded,
2691
+ // leaving the entry stale for the rest of the SWR window.
2692
+ const bgBody = sfnCarriesTracking
2693
+ ? canonicalizeServerFnPayloadForCacheKey(body, [])
2694
+ : body;
2695
+ const bgReq = new Request(request, { body: bgBody, method: "POST" });
1927
2696
  const bgOrigin = await serverEntry.fetch(bgReq, env, ctx);
1928
2697
  if (
1929
2698
  bgOrigin.status === 200 &&
@@ -1977,8 +2746,8 @@ export function createDecoWorkerEntry(
1977
2746
  return resp;
1978
2747
  }
1979
2748
 
1980
- // Store in edge cache
1981
- if (serverFnCache) {
2749
+ // Store in edge cache (see sfnCarriesTracking above)
2750
+ if (serverFnCache && !sfnCarriesTracking) {
1982
2751
  try {
1983
2752
  const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
1984
2753
  const toStore = origin.clone();
@@ -2003,6 +2772,9 @@ export function createDecoWorkerEntry(
2003
2772
  for (const [k, v] of Object.entries(hdrs)) resp.headers.set(k, v);
2004
2773
  resp.headers.set("X-Cache", "MISS");
2005
2774
  resp.headers.set("X-Cache-Profile", sfnProfile);
2775
+ // Same label the HTML path emits, so the documented probe works on a
2776
+ // deferred section too.
2777
+ if (sfnCarriesTracking) resp.headers.set("X-Cache-Store", "skipped-tracking");
2006
2778
  return resp;
2007
2779
  }
2008
2780
 
@@ -2048,6 +2820,13 @@ export function createDecoWorkerEntry(
2048
2820
  // Cacheable request — build segment-aware cache key
2049
2821
  const { key: cacheKey, segment } = buildCacheKey(request, env);
2050
2822
 
2823
+ // The key drops tracking params, but the renderer sees the raw URL and
2824
+ // embeds it (page URL, router match id, analytics payloads, pagination
2825
+ // base URLs). Storing that response under the clean key served one ad
2826
+ // visitor's `gclid` to everyone. Tracked requests still READ the clean
2827
+ // entry; only clean requests WRITE it.
2828
+ const skipStoreTracking = shouldStripTracking && requestCarriesTracking(url);
2829
+
2051
2830
  // Logged-in users always bypass the cache (personalized content)
2052
2831
  if (segment?.loggedIn) {
2053
2832
  const origin = await serverEntry.fetch(request, env, ctx);
@@ -2059,11 +2838,7 @@ export function createDecoWorkerEntry(
2059
2838
  }
2060
2839
 
2061
2840
  // Check Cache API — disabled in local dev to avoid stale responses
2062
- const cache = isDevMode()
2063
- ? null
2064
- : typeof caches !== "undefined"
2065
- ? ((caches as unknown as { default?: Cache }).default ?? null)
2066
- : null;
2841
+ const cache = responseCache(request, env);
2067
2842
 
2068
2843
  const profile = getProfile(url);
2069
2844
  const edgeConfig = edgeCacheConfig(profile);
@@ -2080,17 +2855,45 @@ export function createDecoWorkerEntry(
2080
2855
 
2081
2856
  // CDN-Cache-Control: controls Cloudflare's automatic CDN layer
2082
2857
  // (separate from Cache API which the worker manages directly).
2083
- if (cdnCacheControlOpt === "no-store") {
2084
- out.headers.set("CDN-Cache-Control", "no-store");
2858
+ const cdnPublic = `public, max-age=${edgeConfig.fresh}`;
2859
+ const cdnCacheable = edgeConfig.isPublic && edgeConfig.fresh > 0;
2860
+
2861
+ if (cdnCacheControlOpt === "serverfn-segment") {
2862
+ // Only `/_serverFn` requests whose URL carries a verified segment
2863
+ // marker. Everything else — including every HTML document, which has no
2864
+ // client hook to attach a marker on the initial navigation — keeps
2865
+ // `no-store`.
2866
+ out.headers.set(
2867
+ "CDN-Cache-Control",
2868
+ cdnCacheable && cdnCacheableServerFn(request, url, env) ? cdnPublic : "no-store",
2869
+ );
2085
2870
  } else if (cdnCacheControlOpt === "match-profile") {
2086
- if (edgeConfig.isPublic && edgeConfig.fresh > 0) {
2087
- out.headers.set("CDN-Cache-Control", `public, max-age=${edgeConfig.fresh}`);
2088
- } else {
2871
+ // `match-profile` is only sound when the Worker's cache key is the raw
2872
+ // URL. It almost never is: `deviceSpecificKeys` defaults to true, so
2873
+ // every site keys on `__cf_device` even without a `buildSegment`, and a
2874
+ // location matcher adds `__cf_geo`. Honoring the option under those
2875
+ // conditions would serve desktop HTML to mobile, or one region's to
2876
+ // another — silently. Refuse, and say why once.
2877
+ const keyed =
2878
+ buildSegment !== undefined || deviceSpecificKeys || effectiveGeoKey() !== "off";
2879
+ if (keyed) {
2880
+ if (!warnedMatchProfile) {
2881
+ warnedMatchProfile = true;
2882
+ console.warn(
2883
+ '[deco] cdnCacheControl: "match-profile" ignored — the edge cache key is ' +
2884
+ "segmented (buildSegment / deviceSpecificKeys / geoCacheKey), which the CDN " +
2885
+ "cannot reproduce from the URL alone. Keeping CDN-Cache-Control: no-store.",
2886
+ );
2887
+ }
2089
2888
  out.headers.set("CDN-Cache-Control", "no-store");
2889
+ } else {
2890
+ out.headers.set("CDN-Cache-Control", cdnCacheable ? cdnPublic : "no-store");
2090
2891
  }
2091
2892
  } else if (typeof cdnCacheControlOpt === "function") {
2092
2893
  const val = cdnCacheControlOpt(profile);
2093
2894
  out.headers.set("CDN-Cache-Control", val ?? "no-store");
2895
+ } else {
2896
+ out.headers.set("CDN-Cache-Control", "no-store");
2094
2897
  }
2095
2898
 
2096
2899
  out.headers.set("X-Cache", xCache);
@@ -2103,7 +2906,9 @@ export function createDecoWorkerEntry(
2103
2906
  return out;
2104
2907
  }
2105
2908
 
2106
- // Helper: store a response in Cache API with the full retention window
2909
+ // Helper: store a response in Cache API with the full retention window.
2910
+ // Callers own the tracking decision: a response is storable iff it was
2911
+ // rendered from the clean URL this `cacheKey` represents.
2107
2912
  function storeInCache(resp: Response) {
2108
2913
  if (!cache) return;
2109
2914
  try {
@@ -2123,10 +2928,46 @@ export function createDecoWorkerEntry(
2123
2928
  }
2124
2929
  }
2125
2930
 
2931
+ // A tracked request must revalidate from the CLEAN URL. The renderer
2932
+ // embeds the URL it was given, and the entry being refreshed is keyed
2933
+ // clean — so rendering the tracked URL here is either poisoning (what this
2934
+ // used to do) or dead weight (`storeInCache` drops it), which would leave
2935
+ // the entry stale for the whole SWR window while every ad visitor pays a
2936
+ // full SSR that refreshes nothing. Rendering clean costs the same fetch
2937
+ // and actually warms the entry, so paid traffic keeps the cache alive.
2938
+ function revalidationRequest(): Request {
2939
+ if (!skipStoreTracking) return request;
2940
+ const clean = new URL(cleanPathForCacheKey(url.toString()), url.origin);
2941
+ // A GET server fn can carry the tracking inside `payload` (the page it
2942
+ // loads), not in its own query — strip it there too, or this renders and
2943
+ // stores the tracked page under the clean key.
2944
+ const payload = clean.searchParams.get("payload");
2945
+ if (
2946
+ payload &&
2947
+ (clean.pathname.startsWith("/_serverFn/") || clean.pathname.startsWith("/_server/"))
2948
+ ) {
2949
+ clean.searchParams.set("payload", canonicalizeServerFnPayloadForCacheKey(payload, []));
2950
+ }
2951
+ // Build the init field by field instead of passing `request` as init:
2952
+ // whether the `new Request(url, request)` form carries `cf` over is
2953
+ // runtime-dependent, and a location matcher reading a dropped `cf` would
2954
+ // store a wrong-region render under a region-keyed entry. No body — this
2955
+ // path is GET-only (every other method bypasses the cache above) — and
2956
+ // no `signal`, so a client disconnecting mid-revalidation doesn't abort
2957
+ // the refresh that the next visitor is going to read.
2958
+ const cf = (request as unknown as { cf?: unknown }).cf;
2959
+ return new Request(clean.toString(), {
2960
+ method: request.method,
2961
+ headers: request.headers,
2962
+ redirect: request.redirect,
2963
+ ...(cf ? { cf } : {}),
2964
+ } as RequestInit);
2965
+ }
2966
+
2126
2967
  // Helper: background revalidation (fetch origin, store result)
2127
2968
  function revalidateInBackground() {
2128
2969
  ctx.waitUntil(
2129
- Promise.resolve(serverEntry.fetch(request, env, ctx))
2970
+ Promise.resolve(serverEntry.fetch(revalidationRequest(), env, ctx))
2130
2971
  .then((origin) => {
2131
2972
  // Never overwrite a good entry with a degraded 200 (a critical
2132
2973
  // section failed) — that would poison the cache. Leave the stale
@@ -2301,7 +3142,13 @@ export function createDecoWorkerEntry(
2301
3142
  const cacheOrigin = origin.headers.has("set-cookie")
2302
3143
  ? stripSafeCookiesForCache(origin, safeCookieSet)
2303
3144
  : origin;
2304
- storeInCache(cacheOrigin);
2305
- return dressResponse(origin, "MISS");
3145
+ // This render saw the tracked URL and embedded it — never store it under
3146
+ // the clean key. The next clean visitor fills the entry.
3147
+ if (!skipStoreTracking) storeInCache(cacheOrigin);
3148
+ return dressResponse(
3149
+ origin,
3150
+ "MISS",
3151
+ skipStoreTracking ? { "X-Cache-Store": "skipped-tracking" } : undefined,
3152
+ );
2306
3153
  }
2307
3154
  }