@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.
- package/package.json +12 -5
- package/src/__conformance__/observability.test.ts +221 -0
- package/src/hooks/CdnSegmentMarker.tsx +38 -0
- package/src/hooks/DecoPageRenderer.tsx +91 -29
- package/src/hooks/DecoRootLayout.tsx +139 -58
- package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
- package/src/hooks/DraftPreviewIndicator.tsx +64 -0
- package/src/hooks/NavigationProgress.test.tsx +58 -0
- package/src/hooks/NavigationProgress.tsx +45 -3
- package/src/hooks/PreviewProviders.test.tsx +42 -0
- package/src/hooks/PreviewProviders.tsx +21 -2
- package/src/hooks/deferredObserverTiming.test.tsx +91 -0
- package/src/hooks/index.ts +1 -0
- package/src/index.ts +30 -13
- package/src/routes/cmsRoute.test.ts +168 -1
- package/src/routes/cmsRoute.ts +148 -39
- package/src/routes/pageUrl.test.ts +6 -6
- package/src/routes/pageUrl.ts +5 -4
- package/src/routes/withSiteGlobals.test.ts +37 -0
- package/src/routes/withSiteGlobals.ts +45 -24
- package/src/sdk/backgroundTasks.test.ts +68 -0
- package/src/sdk/backgroundTasks.ts +38 -0
- package/src/sdk/cdnSegment.test.ts +67 -0
- package/src/sdk/cdnSegment.ts +71 -0
- package/src/sdk/deferredSectionLoader.test.ts +1 -1
- package/src/sdk/draft.test.ts +341 -0
- package/src/sdk/draft.ts +277 -0
- package/src/sdk/draftShared.ts +16 -0
- package/src/sdk/kvHydration.test.ts +156 -0
- package/src/sdk/kvHydration.ts +82 -12
- package/src/sdk/kvRedirects.test.ts +132 -0
- package/src/sdk/kvRedirects.ts +125 -0
- package/src/sdk/router.ts +70 -61
- package/src/sdk/serverFnFetch.ts +59 -0
- package/src/sdk/speculationRules.test.ts +82 -0
- package/src/sdk/speculationRules.ts +136 -0
- package/src/sdk/startEntry.ts +31 -0
- package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
- package/src/sdk/workerEntry.test.ts +554 -16
- package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
- package/src/sdk/workerEntry.ts +915 -68
- package/src/v8/__conformance__/delivery.test.ts +100 -0
- package/src/v8/binding.ts +70 -0
- package/src/v8/kvLoader.bundle.test.ts +42 -0
- package/src/v8/kvLoader.test.ts +74 -0
- package/src/v8/kvLoader.ts +43 -0
- package/src/vite/plugin.js +180 -8
- package/src/vite/plugin.test.js +126 -1
- package/src/vite/plugin.test.ts +125 -1
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
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
|
-
*
|
|
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?:
|
|
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
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1120
|
-
//
|
|
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 =
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
-
//
|
|
1642
|
-
//
|
|
1643
|
-
//
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
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 =
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
2084
|
-
|
|
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
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
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(
|
|
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
|
-
|
|
2305
|
-
|
|
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
|
}
|