@decocms/tanstack 7.0.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.
@@ -0,0 +1,1967 @@
1
+ /**
2
+ * Factory for creating a cache-aware Cloudflare Worker entry.
3
+ *
4
+ * Wraps a TanStack Start server entry with:
5
+ * - Cloudflare Cache API integration (edge caching)
6
+ * - Device-specific cache keys (mobile/desktop split)
7
+ * - Per-URL cache profile detection via detectCacheProfile()
8
+ * - Immutable caching for fingerprinted static assets
9
+ * - Cache purge API endpoint
10
+ * - Protection against accidental caching of private/search paths
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // src/worker-entry.ts
15
+ * import handler, { createServerEntry } from "@tanstack/react-start/server-entry";
16
+ * import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
17
+ *
18
+ * const serverEntry = createServerEntry({
19
+ * async fetch(request) {
20
+ * return await handler.fetch(request);
21
+ * },
22
+ * });
23
+ *
24
+ * export default createDecoWorkerEntry(serverEntry);
25
+ * ```
26
+ */
27
+
28
+ import { getRenderShellConfig } from "@decocms/blocks-admin/admin/setup";
29
+ import { getAppMiddleware } from "@decocms/blocks-admin/sdk/setupApps";
30
+ import {
31
+ isBot,
32
+ loadBlocks,
33
+ type MatcherContext,
34
+ resolveDecoPage,
35
+ runSectionLoaders,
36
+ runSingleSectionLoader,
37
+ } from "@decocms/blocks/cms";
38
+ import { DECO_MATCHERS_OVERRIDE_PARAM } from "@decocms/blocks/matchers/override";
39
+ import {
40
+ type CacheProfileName,
41
+ cacheHeaders,
42
+ canonicalizeServerFnPayloadForCacheKey,
43
+ detectCacheProfile,
44
+ edgeCacheConfig,
45
+ getCacheProfile,
46
+ serverFnPagePath,
47
+ } from "@decocms/blocks/sdk/cacheHeaders";
48
+ import { buildHtmlShell } from "@decocms/blocks-admin/sdk/htmlShell";
49
+ import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration";
50
+ import {
51
+ getActiveSpan,
52
+ logRequest,
53
+ recordCacheMetric,
54
+ recordRequestMetric,
55
+ setSpanAttribute,
56
+ withTracing,
57
+ } from "@decocms/blocks/sdk/observability";
58
+ import {
59
+ _setDebugSampled,
60
+ _setRequestTraceContext,
61
+ instrumentWorker,
62
+ type OtelOptions,
63
+ } from "@decocms/blocks/sdk/otel";
64
+ import { setRuntimeEnv } from "@decocms/blocks/sdk/otelAdapters";
65
+ import { parseTraceparent } from "@decocms/blocks/sdk/otelHttpTracer";
66
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
67
+ import { cleanPathForCacheKey } from "@decocms/blocks/sdk/urlUtils";
68
+ import { type Device, isMobileUA } from "@decocms/blocks/sdk/useDevice";
69
+ import { isDevMode } from "@decocms/blocks/sdk/env";
70
+
71
+ /**
72
+ * Build-time identifier injected by `decoVitePlugin()` (see
73
+ * `src/vite/plugin.js`). Falls back to `undefined` if the consuming site
74
+ * isn't using the plugin or the symbol wasn't `define`d at bundle time.
75
+ *
76
+ * The runtime `env.BUILD_HASH` (when explicitly set, e.g. via
77
+ * `wrangler deploy --var BUILD_HASH:foo`) takes precedence — see
78
+ * `getBuildHash()` below.
79
+ */
80
+ declare const __DECO_BUILD_HASH__: string | undefined;
81
+
82
+ /**
83
+ * The five canonical cache-decision strings stamped on the `X-Cache`
84
+ * response header (and on the `decision` label of `cache_*_total`
85
+ * metrics). Used by the request-metric label enrichment to keep label
86
+ * cardinality bounded — anything else (e.g. an upstream proxy that sets
87
+ * its own `X-Cache: random-text`) is dropped from the label.
88
+ */
89
+ type CacheDecisionString = "HIT" | "STALE-HIT" | "STALE-ERROR" | "MISS" | "BYPASS";
90
+
91
+ function isCacheDecision(value: string | null): value is CacheDecisionString {
92
+ return (
93
+ value === "HIT" ||
94
+ value === "STALE-HIT" ||
95
+ value === "STALE-ERROR" ||
96
+ value === "MISS" ||
97
+ value === "BYPASS"
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Append Link preload headers for CSS and fonts so the browser starts
103
+ * fetching them before parsing HTML. Only applied to HTML responses.
104
+ */
105
+ function appendResourceHints(resp: Response): void {
106
+ const ct = resp.headers.get("content-type");
107
+ if (!ct || !ct.includes("text/html")) return;
108
+ const { cssHref, fontHrefs } = getRenderShellConfig();
109
+ if (cssHref) {
110
+ resp.headers.append("Link", `<${cssHref}>; rel=preload; as=style`);
111
+ }
112
+ for (const href of fontHrefs) {
113
+ resp.headers.append("Link", `<${href}>; rel=preload; as=font; crossorigin`);
114
+ }
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Types
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Minimal ExecutionContext interface compatible with Cloudflare Workers.
123
+ * Defined here so deco-start doesn't need @cloudflare/workers-types.
124
+ */
125
+ interface WorkerExecutionContext {
126
+ waitUntil(promise: Promise<unknown>): void;
127
+ passThroughOnException(): void;
128
+ }
129
+
130
+ interface ServerEntry {
131
+ fetch(
132
+ request: Request,
133
+ env: Record<string, unknown>,
134
+ ctx: WorkerExecutionContext,
135
+ ): Response | Promise<Response>;
136
+ }
137
+
138
+ /**
139
+ * Segment dimensions used to differentiate cache entries.
140
+ *
141
+ * The workerEntry calls `buildSegment` (if provided) to extract these
142
+ * from the request. Two requests with the same SegmentKey share a
143
+ * cache entry; different segments get different cached responses.
144
+ */
145
+ export interface SegmentKey {
146
+ /**
147
+ * Device class derived from the request User-Agent.
148
+ *
149
+ * Accepts the full `Device` union (`"mobile" | "desktop" | "tablet"`) so
150
+ * that callers can pass `detectDevice(...)` directly without manual
151
+ * narrowing. Sites that want to share cache entries between mobile and
152
+ * tablet can collapse the value at the call site (e.g.
153
+ * `device === "tablet" ? "mobile" : device`).
154
+ */
155
+ device: Device;
156
+ /** Whether the user is logged in (e.g., has a valid auth cookie). */
157
+ loggedIn?: boolean;
158
+ /** Commerce sales channel / price list. */
159
+ salesChannel?: string;
160
+ /**
161
+ * VTEX region ID for regionalized pricing/availability.
162
+ * When present, cache entries are segmented per region.
163
+ * Sites without regionalization should omit this field
164
+ * to avoid unnecessary cache fragmentation.
165
+ */
166
+ regionId?: string;
167
+ /** Sorted list of active A/B flag names for cache cohort splitting. */
168
+ flags?: string[];
169
+ }
170
+
171
+ /**
172
+ * Admin route handlers injected by the site's worker-entry.ts.
173
+ * Kept as a runtime option so the imports only exist in the SSR entry
174
+ * (not pulled into the client Vite build).
175
+ */
176
+ export interface AdminHandlers {
177
+ handleMeta: (request: Request) => Response;
178
+ handleDecofileRead: () => Response;
179
+ handleDecofileReload: (request: Request) => Response | Promise<Response>;
180
+ handleRender: (request: Request) => Response | Promise<Response>;
181
+ corsHeaders: (request: Request) => Record<string, string>;
182
+ }
183
+
184
+ export interface DecoWorkerEntryOptions {
185
+ /**
186
+ * Admin route handlers (/live/_meta, /.decofile, /live/previews).
187
+ * Pass the handlers from `@decocms/start/admin` here.
188
+ * If not provided, admin routes are not handled.
189
+ */
190
+ admin?: AdminHandlers;
191
+
192
+ /**
193
+ * Override the default cache profile detection.
194
+ * Return `null` to fall through to the built-in detector.
195
+ */
196
+ detectProfile?: (url: URL) => CacheProfileName | null;
197
+
198
+ /**
199
+ * Whether to create device-specific cache keys (mobile vs desktop).
200
+ * Useful when server-rendered HTML differs by device.
201
+ * @default true
202
+ */
203
+ deviceSpecificKeys?: boolean;
204
+
205
+ /**
206
+ * Build a full segment key from the incoming request.
207
+ *
208
+ * When provided, the segment key replaces the simple device-only
209
+ * cache key with a richer key that differentiates by login state,
210
+ * sales channel, and A/B flags.
211
+ *
212
+ * Logged-in segments (`loggedIn: true`) automatically bypass the
213
+ * cache (the response is fetched fresh every time).
214
+ *
215
+ * @example
216
+ * ```ts
217
+ * import { extractVtexContext } from "@decocms/apps/vtex/middleware";
218
+ *
219
+ * createDecoWorkerEntry(serverEntry, {
220
+ * buildSegment: (request) => {
221
+ * const vtx = extractVtexContext(request);
222
+ * return {
223
+ * device: /mobile|android|iphone/i.test(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop",
224
+ * loggedIn: vtx.isLoggedIn,
225
+ * salesChannel: vtx.salesChannel,
226
+ * // Include regionId only if the site uses VTEX regionalization.
227
+ * // When present, cache entries split by region; omit it for
228
+ * // non-regionalized sites to maximize cache sharing.
229
+ * regionId: vtx.regionId ?? undefined,
230
+ * };
231
+ * },
232
+ * });
233
+ * ```
234
+ */
235
+ buildSegment?: (request: Request) => SegmentKey;
236
+
237
+ /**
238
+ * Environment variable name holding the cache purge token.
239
+ * Set to `false` to disable the purge endpoint.
240
+ * @default "PURGE_TOKEN"
241
+ */
242
+ purgeTokenEnv?: string | false;
243
+
244
+ /**
245
+ * Paths that should always bypass the edge cache, even if the
246
+ * profile detector would otherwise cache them.
247
+ * Defaults include `/_build`, `/deco/`, `/live/`, `/.decofile`.
248
+ */
249
+ bypassPaths?: string[];
250
+
251
+ /**
252
+ * Additional paths (beyond the defaults) that should bypass caching.
253
+ * Merged with the default bypass paths.
254
+ */
255
+ extraBypassPaths?: string[];
256
+
257
+ /**
258
+ * Custom HTML shell for the `/live/previews` iframe page.
259
+ * If not provided, a shell is generated from the render config
260
+ * (theme, CSS, fonts) set via setRenderShell().
261
+ */
262
+ previewShell?: string;
263
+
264
+ /**
265
+ * Regex for detecting fingerprinted static assets (content-hashed filenames).
266
+ * Matched paths get `immutable, max-age=31536000`.
267
+ * @default /\/_build\/assets\/.*-[a-zA-Z0-9]{8,}\.\w+$/
268
+ */
269
+ fingerprintedAssetPattern?: RegExp;
270
+
271
+ /**
272
+ * Whether to strip UTM and tracking params from cache keys.
273
+ * Two requests differing only in utm_source, fbclid, etc.
274
+ * will share the same cache entry.
275
+ * @default true
276
+ */
277
+ stripTrackingParams?: boolean;
278
+
279
+ /**
280
+ * Optional proxy handler for commerce backend routes
281
+ * (checkout, account, API, login, etc.).
282
+ *
283
+ * Called early in the request pipeline — after admin routes and cache
284
+ * purge, but before static assets and edge cache logic. This ensures
285
+ * proxy requests never hit TanStack Start or the React SSR pipeline.
286
+ *
287
+ * Return a `Response` to proxy the request, or `null` to let the
288
+ * normal TanStack Start flow handle it.
289
+ *
290
+ * @example
291
+ * ```ts
292
+ * import { shouldProxyToVtex, proxyToVtex } from "@decocms/apps/vtex/utils/proxy";
293
+ *
294
+ * createDecoWorkerEntry(serverEntry, {
295
+ * proxyHandler: (request, url) => {
296
+ * if (shouldProxyToVtex(url.pathname)) {
297
+ * return proxyToVtex(request);
298
+ * }
299
+ * return null;
300
+ * },
301
+ * });
302
+ * ```
303
+ */
304
+ proxyHandler?: (request: Request, url: URL) => Promise<Response | null> | Response | null;
305
+
306
+ /**
307
+ * Environment variable name holding a build version string.
308
+ * The value is appended to every cache key so each deploy gets its own
309
+ * cache namespace — old entries become orphaned and expire naturally,
310
+ * preventing stale HTML that references old CSS/JS fingerprinted filenames.
311
+ *
312
+ * Set to `false` to disable. When the env var is missing or empty,
313
+ * cache keys remain unversioned (backward-compatible).
314
+ *
315
+ * @default "BUILD_HASH"
316
+ *
317
+ * @example
318
+ * ```yaml
319
+ * # CI: pass git hash to wrangler
320
+ * - run: npx wrangler deploy --var BUILD_HASH:$(git rev-parse --short HEAD)
321
+ * ```
322
+ */
323
+ cacheVersionEnv?: string | false;
324
+
325
+ /**
326
+ * Security headers appended to every SSR response (HTML pages).
327
+ * Pass `false` to disable entirely.
328
+ *
329
+ * Default headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
330
+ * Permissions-Policy, X-XSS-Protection, HSTS, Cross-Origin-Opener-Policy.
331
+ *
332
+ * Custom entries are merged with defaults (custom values take precedence).
333
+ *
334
+ * @default DEFAULT_SECURITY_HEADERS
335
+ */
336
+ securityHeaders?: Record<string, string> | false;
337
+
338
+ /**
339
+ * Content Security Policy directives (report-only by default).
340
+ * Pass an array of directive strings which are joined with "; ".
341
+ * Pass `false` to omit CSP entirely.
342
+ *
343
+ * @example
344
+ * ```ts
345
+ * csp: [
346
+ * "default-src 'self'",
347
+ * "script-src 'self' 'unsafe-inline' https://www.googletagmanager.com",
348
+ * "img-src 'self' data: https:",
349
+ * ]
350
+ * ```
351
+ */
352
+ csp?: string[] | false;
353
+
354
+ /**
355
+ * Automatically inject Cloudflare geo data (country, region, city)
356
+ * as internal cookies on every request so location matchers can read
357
+ * them from MatcherContext.cookies. The cookies are only visible
358
+ * within the Worker — they are never sent to the browser.
359
+ *
360
+ * @default true
361
+ */
362
+ autoInjectGeoCookies?: boolean;
363
+
364
+ /**
365
+ * Cookie names considered "safe" for caching — these are public/anonymous
366
+ * cookies that do not carry per-user session or auth data.
367
+ *
368
+ * When a response contains ONLY safe cookies, it is still eligible for
369
+ * Cache API storage. The safe cookies are stripped from the cached copy
370
+ * but kept on the response served to the current user.
371
+ *
372
+ * If the response contains ANY cookie NOT in this list, the response
373
+ * bypasses caching entirely (existing behavior).
374
+ *
375
+ * @default DEFAULT_SAFE_COOKIES (vtex_is_session, vtex_is_anonymous, vtex_segment, _deco_bucket)
376
+ *
377
+ * @example
378
+ * ```ts
379
+ * createDecoWorkerEntry(serverEntry, {
380
+ * safeCookies: [
381
+ * ...DEFAULT_SAFE_COOKIES,
382
+ * "my_custom_analytics_cookie",
383
+ * ],
384
+ * });
385
+ * ```
386
+ */
387
+ safeCookies?: string[];
388
+
389
+ /**
390
+ * Additional static paths (beyond fingerprinted assets) that should
391
+ * receive long-lived immutable cache headers.
392
+ *
393
+ * Useful for non-fingerprinted resources like fonts that live at
394
+ * stable URLs (e.g., `/fonts/Lato-Regular.woff2`).
395
+ *
396
+ * @default ["/fonts/"]
397
+ *
398
+ * @example
399
+ * ```ts
400
+ * createDecoWorkerEntry(serverEntry, {
401
+ * staticPaths: ["/fonts/", "/static/", "/images/icons/"],
402
+ * });
403
+ * ```
404
+ */
405
+ staticPaths?: string[];
406
+
407
+ /**
408
+ * CDN-Cache-Control header strategy.
409
+ *
410
+ * - `"no-store"` (default): CDN never caches; every request invokes the Worker.
411
+ * Correct when segment-based cache keys differ from the original URL.
412
+ * - `"match-profile"`: Set CDN-Cache-Control to a short TTL matching the
413
+ * profile's edge.fresh value. Only safe when you are NOT using segment-based
414
+ * cache keys (i.e., no `buildSegment` and `deviceSpecificKeys: false`).
415
+ * - A function: Return a CDN-Cache-Control value per profile, or `null` for no-store.
416
+ *
417
+ * @default "no-store"
418
+ */
419
+ cdnCacheControl?: "no-store" | "match-profile" | ((profile: CacheProfileName) => string | null);
420
+ /**
421
+ * Auto-instrumentation via `instrumentWorker` is enabled by default. The
422
+ * framework wraps the returned handler so that, when OTel env vars
423
+ * (`DECO_OTEL_*_ENDPOINT`, `DECO_OTEL_TRACES_SAMPLING_RATE`,
424
+ * `DECO_OTEL_LOGS_MIN_LEVEL`) are set on the Worker's `env`, telemetry
425
+ * starts flowing without the site having to touch its worker entry.
426
+ *
427
+ * - Pass an `OtelOptions` object to override defaults (serviceName,
428
+ * sampling, custom env var names, etc.).
429
+ * - Pass `false` to disable framework-side instrumentation entirely
430
+ * (e.g., when the site applies its own `instrumentWorker` wrap or
431
+ * uses a custom transport).
432
+ *
433
+ * When the OTel endpoint env vars are absent the wrap is effectively a
434
+ * no-op — no buffers, no flushes, no network calls.
435
+ */
436
+ observability?: OtelOptions | false;
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Constants
441
+ // ---------------------------------------------------------------------------
442
+
443
+ const PREVIEW_SHELL_SCRIPT = `(function() {
444
+ if (window.__DECO_LIVE_CONTROLS__) return;
445
+ window.__DECO_LIVE_CONTROLS__ = true;
446
+ addEventListener("message", function(event) {
447
+ var data = event.data;
448
+ if (!data || typeof data !== "object") return;
449
+ switch (data.type) {
450
+ case "editor::inject":
451
+ if (data.args && data.args.script) {
452
+ try { eval(data.args.script); } catch(e) { console.error("[deco] inject error:", e); }
453
+ }
454
+ break;
455
+ }
456
+ });
457
+ })();`;
458
+
459
+ function buildPreviewShell(): string {
460
+ return buildHtmlShell({ script: PREVIEW_SHELL_SCRIPT });
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // Cloudflare geo cookie injection
465
+ // ---------------------------------------------------------------------------
466
+
467
+ /**
468
+ * Inject Cloudflare geo data as cookies so matchers (location.ts) can
469
+ * read them from MatcherContext.cookies without relying on request.cf.
470
+ *
471
+ * Call this on the incoming request before passing it to the worker entry.
472
+ * Only needed in production Cloudflare Workers where `request.cf` is populated.
473
+ *
474
+ * @example
475
+ * ```ts
476
+ * export default {
477
+ * async fetch(request, env, ctx) {
478
+ * return handler.fetch(injectGeoCookies(request), env, ctx);
479
+ * }
480
+ * };
481
+ * ```
482
+ */
483
+ export function injectGeoCookies(request: Request): Request {
484
+ const cf = (request as unknown as { cf?: Record<string, string> }).cf;
485
+ if (!cf) return request;
486
+
487
+ const parts: string[] = [];
488
+ if (cf.region) parts.push(`__cf_geo_region=${encodeURIComponent(cf.region)}`);
489
+ if (cf.country) parts.push(`__cf_geo_country=${encodeURIComponent(cf.country)}`);
490
+ if (cf.city) parts.push(`__cf_geo_city=${encodeURIComponent(cf.city)}`);
491
+ if (cf.latitude) parts.push(`__cf_geo_lat=${encodeURIComponent(cf.latitude)}`);
492
+ if (cf.longitude) parts.push(`__cf_geo_lng=${encodeURIComponent(cf.longitude)}`);
493
+ if (cf.regionCode) parts.push(`__cf_geo_region_code=${encodeURIComponent(cf.regionCode)}`);
494
+
495
+ if (!parts.length) return request;
496
+
497
+ const existing = request.headers.get("cookie") ?? "";
498
+ const combined = existing ? `${existing}; ${parts.join("; ")}` : parts.join("; ");
499
+
500
+ // Strip CF geo headers that carry non-ASCII values (cf-region: "São Paulo",
501
+ // cf-ipcity: "Brasília", etc.) before building the new Request. The geo
502
+ // data is preserved in the __cf_geo_* cookies we just built, so callers
503
+ // downstream lose no information.
504
+ //
505
+ // Without this strip, the Workers runtime emits a warning on every
506
+ // request because the new Request inherits these UTF-8 headers from the
507
+ // inbound request:
508
+ //
509
+ // "A header value for "cf-region" contains non-ASCII characters: "..."
510
+ //
511
+ // and the warning is logged once per non-ASCII header — for a Brazilian
512
+ // storefront with cities/states full of accents that means ~2 warns per
513
+ // request × every request that hits the worker.
514
+ const headers = new Headers();
515
+ for (const [key, value] of request.headers.entries()) {
516
+ const lk = key.toLowerCase();
517
+ if (lk === "cf-region" || lk === "cf-ipcity") continue;
518
+ headers.set(key, value);
519
+ }
520
+ headers.set("cookie", combined);
521
+
522
+ // Mirror the ASCII-safe geo fields from request.cf into headers so matchers
523
+ // that read `request.headers.get("cf-region-code")` (parity with the
524
+ // upstream deco-cx/apps location matcher) still work even if the inbound
525
+ // request didn't carry them. Non-ASCII fields (region name, city) stay in
526
+ // the cookies above — putting them in headers would re-trigger the
527
+ // non-ASCII warning we strip on the loop above.
528
+ if (cf.country && !headers.has("cf-ipcountry")) headers.set("cf-ipcountry", cf.country);
529
+ if (cf.regionCode && !headers.has("cf-region-code")) headers.set("cf-region-code", cf.regionCode);
530
+ if (cf.latitude && !headers.has("cf-iplatitude")) headers.set("cf-iplatitude", cf.latitude);
531
+ if (cf.longitude && !headers.has("cf-iplongitude")) headers.set("cf-iplongitude", cf.longitude);
532
+
533
+ return new Request(request, { headers });
534
+ }
535
+
536
+ const ONE_YEAR = 31536000;
537
+
538
+ /**
539
+ * Sensible security headers for any production storefront.
540
+ * CSP is intentionally not included — it's site-specific (third-party script domains).
541
+ */
542
+ export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
543
+ "X-Content-Type-Options": "nosniff",
544
+ "X-Frame-Options": "SAMEORIGIN",
545
+ "Referrer-Policy": "strict-origin-when-cross-origin",
546
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
547
+ "X-XSS-Protection": "1; mode=block",
548
+ "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
549
+ "Cross-Origin-Opener-Policy": "same-origin-allow-popups",
550
+ };
551
+
552
+ const DEFAULT_BYPASS_PATHS = ["/_build", "/deco/", "/live/", "/.decofile"];
553
+
554
+ /**
555
+ * Cookie names that are safe for caching — they carry anonymous/public
556
+ * segment data, not per-user auth tokens.
557
+ *
558
+ * VTEX Intelligent Search sets `vtex_is_session` and `vtex_is_anonymous`
559
+ * on every response. `vtex_segment` encodes the sales channel.
560
+ * `_deco_bucket` is the A/B test cohort cookie.
561
+ */
562
+ export const DEFAULT_SAFE_COOKIES: string[] = [
563
+ "vtex_is_session",
564
+ "vtex_is_anonymous",
565
+ "vtex_segment",
566
+ "_deco_bucket",
567
+ ];
568
+
569
+ const DEFAULT_STATIC_PATHS = ["/fonts/"];
570
+
571
+ /**
572
+ * Parse Set-Cookie header values and return cookie names.
573
+ */
574
+ function parseCookieNames(response: Response): string[] {
575
+ const names: string[] = [];
576
+ // getSetCookie() returns individual Set-Cookie values (available in Workers runtime)
577
+ const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
578
+ if (setCookies) {
579
+ for (const sc of setCookies) {
580
+ const eqIdx = sc.indexOf("=");
581
+ if (eqIdx > 0) names.push(sc.slice(0, eqIdx).trim());
582
+ }
583
+ } else {
584
+ // Fallback: parse from combined header (less reliable but covers edge cases)
585
+ const combined = response.headers.get("set-cookie") ?? "";
586
+ for (const part of combined.split(",")) {
587
+ const eqIdx = part.indexOf("=");
588
+ if (eqIdx > 0) {
589
+ const name = part.slice(0, eqIdx).trim();
590
+ // Skip attributes like "Expires=..." that appear after semicolons
591
+ if (!name.includes(";") && name.length > 0) names.push(name);
592
+ }
593
+ }
594
+ }
595
+ return names;
596
+ }
597
+
598
+ /**
599
+ * Check if ALL cookies in a response are in the safe list.
600
+ * Returns true if the response has no cookies or only safe cookies.
601
+ */
602
+ function hasOnlySafeCookies(response: Response, safeCookieSet: Set<string>): boolean {
603
+ if (!response.headers.has("set-cookie")) return true;
604
+ const names = parseCookieNames(response);
605
+ if (names.length === 0) return true;
606
+ return names.every((name) => safeCookieSet.has(name));
607
+ }
608
+
609
+ /**
610
+ * Clone a response, stripping Set-Cookie headers that match the safe list.
611
+ * Uses response.clone() to preserve the original body for the served response.
612
+ * The returned copy is intended for cache storage only.
613
+ */
614
+ function stripSafeCookiesForCache(response: Response, safeCookieSet: Set<string>): Response {
615
+ const clone = response.clone();
616
+ const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
617
+ if (!setCookies || setCookies.length === 0) return clone;
618
+
619
+ // Remove all Set-Cookie headers, then re-add only unsafe ones
620
+ clone.headers.delete("set-cookie");
621
+ for (const sc of setCookies) {
622
+ const eqIdx = sc.indexOf("=");
623
+ const name = eqIdx > 0 ? sc.slice(0, eqIdx).trim() : "";
624
+ if (name && !safeCookieSet.has(name)) {
625
+ clone.headers.append("set-cookie", sc);
626
+ }
627
+ }
628
+ return clone;
629
+ }
630
+
631
+ /**
632
+ * Deduplicate Set-Cookie headers — keep only the LAST occurrence of
633
+ * each cookie name. Multiple layers (VTEX middleware, invoke handlers,
634
+ * etc.) may independently append the same cookie.
635
+ */
636
+ function deduplicateSetCookies(response: Response): void {
637
+ const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
638
+ if (!setCookies || setCookies.length <= 1) return;
639
+
640
+ // Build map: cookie name → last Set-Cookie value
641
+ const seen = new Map<string, string>();
642
+ for (const sc of setCookies) {
643
+ const eqIdx = sc.indexOf("=");
644
+ const name = eqIdx > 0 ? sc.slice(0, eqIdx).trim() : sc;
645
+ seen.set(name, sc);
646
+ }
647
+
648
+ // If no duplicates, nothing to do
649
+ if (seen.size === setCookies.length) return;
650
+
651
+ response.headers.delete("set-cookie");
652
+ for (const sc of seen.values()) {
653
+ response.headers.append("set-cookie", sc);
654
+ }
655
+ }
656
+
657
+ const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+$/;
658
+
659
+ const IMMUTABLE_HEADERS: Record<string, string> = {
660
+ "Cache-Control": `public, max-age=${ONE_YEAR}, immutable`,
661
+ Vary: "Accept-Encoding",
662
+ };
663
+
664
+ /** SHA-256 hex hash of a string — used for POST body cache keys. */
665
+ async function hashText(text: string): Promise<string> {
666
+ const data = new TextEncoder().encode(text);
667
+ const buf = await crypto.subtle.digest("SHA-256", data);
668
+ return Array.from(new Uint8Array(buf))
669
+ .map((b) => b.toString(16).padStart(2, "0"))
670
+ .join("");
671
+ }
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // Factory
675
+ // ---------------------------------------------------------------------------
676
+
677
+ /**
678
+ * Creates a Cloudflare Worker fetch handler that wraps a TanStack Start
679
+ * server entry with intelligent edge caching.
680
+ */
681
+ export function createDecoWorkerEntry(
682
+ serverEntry: ServerEntry,
683
+ options: DecoWorkerEntryOptions = {},
684
+ ): {
685
+ fetch(
686
+ request: Request,
687
+ env: Record<string, unknown>,
688
+ ctx: WorkerExecutionContext,
689
+ ): Promise<Response>;
690
+ } {
691
+ const {
692
+ admin,
693
+ detectProfile: customDetect,
694
+ deviceSpecificKeys = true,
695
+ buildSegment: rawBuildSegment,
696
+ purgeTokenEnv = "PURGE_TOKEN",
697
+ bypassPaths,
698
+ extraBypassPaths = [],
699
+ fingerprintedAssetPattern = FINGERPRINTED_ASSET_RE,
700
+ stripTrackingParams: shouldStripTracking = true,
701
+ previewShell: customPreviewShell,
702
+ cacheVersionEnv = "BUILD_HASH",
703
+ securityHeaders: securityHeadersOpt,
704
+ csp: cspOpt,
705
+ autoInjectGeoCookies: geoOpt = true,
706
+ safeCookies: safeCookiesOpt = DEFAULT_SAFE_COOKIES,
707
+ staticPaths: staticPathsOpt = DEFAULT_STATIC_PATHS,
708
+ cdnCacheControl: cdnCacheControlOpt = "no-store",
709
+ observability: observabilityOpt,
710
+ } = options;
711
+
712
+ // Backfill `regionId` from Cloudflare geo when the consumer's buildSegment
713
+ // doesn't set one. Without this, sites using website/matchers/location.ts
714
+ // get a single cached response per device that leaks across regions: the
715
+ // first visitor's resolved variant gets served to everyone. With this,
716
+ // existing sites get region-segmented cache "for free" on bump — no
717
+ // worker-entry.ts edit required.
718
+ function readRegionFromRequest(request: Request): string | undefined {
719
+ // Trust the Cloudflare-injected `request.cf` first — it can't be spoofed
720
+ // by clients. Fall back to the `cf-region-code` header for environments
721
+ // that surface geo only via headers (e.g. tests, non-CF proxies).
722
+ const cf = (request as unknown as { cf?: { regionCode?: string } }).cf;
723
+ if (cf?.regionCode) return cf.regionCode;
724
+ const fromHeader = request.headers.get("cf-region-code");
725
+ return fromHeader || undefined;
726
+ }
727
+
728
+ const buildSegment = rawBuildSegment
729
+ ? (request: Request): SegmentKey => {
730
+ const seg = rawBuildSegment(request);
731
+ if (seg.regionId) return seg;
732
+ const region = readRegionFromRequest(request);
733
+ return region ? { ...seg, regionId: region } : seg;
734
+ }
735
+ : undefined;
736
+
737
+ const safeCookieSet = new Set(safeCookiesOpt);
738
+
739
+ // Build the final security headers map (merged defaults + custom + CSP)
740
+ const secHeaders: Record<string, string> | null = (() => {
741
+ if (securityHeadersOpt === false) return null;
742
+ const base = { ...DEFAULT_SECURITY_HEADERS };
743
+ if (securityHeadersOpt) {
744
+ for (const [k, v] of Object.entries(securityHeadersOpt)) base[k] = v;
745
+ }
746
+ if (cspOpt && cspOpt.length > 0) {
747
+ base["Content-Security-Policy-Report-Only"] = cspOpt.join("; ");
748
+ }
749
+ return base;
750
+ })();
751
+
752
+ function applySecurityHeaders(resp: Response): Response {
753
+ if (!secHeaders) return resp;
754
+ const ct = resp.headers.get("content-type") ?? "";
755
+ if (!ct.includes("text/html")) return resp;
756
+ const out = new Response(resp.body, resp);
757
+ for (const [k, v] of Object.entries(secHeaders)) {
758
+ if (!out.headers.has(k)) out.headers.set(k, v);
759
+ }
760
+ return out;
761
+ }
762
+
763
+ const allBypassPaths = [...(bypassPaths ?? DEFAULT_BYPASS_PATHS), ...extraBypassPaths];
764
+
765
+ // -- Helpers ----------------------------------------------------------------
766
+
767
+ function isBypassPath(pathname: string): boolean {
768
+ return allBypassPaths.some((bp) => pathname.startsWith(bp));
769
+ }
770
+
771
+ function isStaticAsset(pathname: string): boolean {
772
+ if (fingerprintedAssetPattern.test(pathname)) return true;
773
+ // Non-fingerprinted static paths (e.g., /fonts/)
774
+ return staticPathsOpt.some((sp) => pathname.startsWith(sp));
775
+ }
776
+
777
+ function isCacheable(request: Request, url: URL): boolean {
778
+ if (request.method !== "GET") return false;
779
+ if (isBypassPath(url.pathname)) return false;
780
+ if (url.searchParams.has("__deco_draft")) return false;
781
+ if (url.searchParams.has("__deco_preview")) return false;
782
+ if (url.searchParams.has("pathTemplate")) return false;
783
+ // Forced matcher results must never be served from (or stored in) the
784
+ // shared edge cache.
785
+ if (url.searchParams.has(DECO_MATCHERS_OVERRIDE_PARAM)) return false;
786
+ if (request.headers.has(DECO_MATCHERS_OVERRIDE_PARAM)) return false;
787
+ return true;
788
+ }
789
+
790
+ function getProfile(url: URL): CacheProfileName {
791
+ // For TanStack GET server-fn requests, resolve the page path embedded in
792
+ // the payload so the data request inherits the PAGE's profile (product /
793
+ // search / static) instead of the generic "listing" derived from the
794
+ // `/_serverFn/...` pathname. This is what makes SPA-navigation data
795
+ // requests cache as long as their HTML documents (e.g. PDP 5min) and hit
796
+ // the edge like full reloads do. Falls back to the request URL when no
797
+ // embedded path is found.
798
+ const pagePath = serverFnPagePath(url);
799
+ const target = pagePath ? new URL(pagePath, url.origin) : url;
800
+ if (customDetect) {
801
+ const custom = customDetect(target);
802
+ if (custom !== null) return custom;
803
+ }
804
+ return detectCacheProfile(target);
805
+ }
806
+
807
+ function hashSegment(seg: SegmentKey): string {
808
+ const parts: string[] = [seg.device];
809
+ if (seg.loggedIn) parts.push("auth");
810
+ if (seg.salesChannel) parts.push(`sc=${seg.salesChannel}`);
811
+ if (seg.regionId) parts.push(`r=${seg.regionId}`);
812
+ if (seg.flags?.length) parts.push(`f=${seg.flags.sort().join(",")}`);
813
+ return parts.join("|");
814
+ }
815
+
816
+ /**
817
+ * Resolve the per-deploy cache-key version with this priority:
818
+ * 1. `env[cacheVersionEnv]` — explicit override (e.g. `wrangler
819
+ * deploy --var BUILD_HASH:foo`). Wins so callers can always
820
+ * force a specific value.
821
+ * 2. `__DECO_BUILD_HASH__` — build-time constant injected by
822
+ * `decoVitePlugin()` from WORKERS_CI_COMMIT_SHA / git rev-parse.
823
+ * This is the production path on Cloudflare Workers Builds.
824
+ * 3. Empty string — versioning disabled (legacy pre-plugin sites).
825
+ */
826
+ function getBuildHash(env: Record<string, unknown>): string {
827
+ if (cacheVersionEnv === false) return "";
828
+ const fromEnv = (env[cacheVersionEnv] as string) || "";
829
+ if (fromEnv) return fromEnv;
830
+ return typeof __DECO_BUILD_HASH__ !== "undefined" ? __DECO_BUILD_HASH__ : "";
831
+ }
832
+
833
+ function buildCacheKey(
834
+ request: Request,
835
+ env: Record<string, unknown>,
836
+ ): { key: Request; segment?: SegmentKey } {
837
+ const url = new URL(request.url);
838
+
839
+ if (shouldStripTracking) {
840
+ const cleanPath = cleanPathForCacheKey(url.toString());
841
+ const cleanUrl = new URL(cleanPath, url.origin);
842
+ url.search = cleanUrl.search;
843
+ }
844
+
845
+ // For GET server-fn requests (SPA navigation data), the page being loaded
846
+ // is encoded in the `payload` arg. Canonicalize it and strip variant params
847
+ // (skuId/idsku) that the loader ignores — otherwise `/p?skuId=X` and `/p`
848
+ // get distinct keys and every variant-carrying PDP→PDP nav MISSes, even
849
+ // though the resolved response is identical. Keeps PLP filter params intact.
850
+ if (
851
+ url.pathname.startsWith("/_serverFn/") ||
852
+ url.pathname.startsWith("/_server/")
853
+ ) {
854
+ const payload = url.searchParams.get("payload");
855
+ if (payload) {
856
+ url.searchParams.set("payload", canonicalizeServerFnPayloadForCacheKey(payload));
857
+ }
858
+ }
859
+
860
+ const version = getBuildHash(env);
861
+ if (version) {
862
+ url.searchParams.set("__v", version);
863
+ }
864
+
865
+ // Include CF geo data in cache key so location matcher results don't leak
866
+ // across different geos. Applies to both segment and device-based keys.
867
+ const cf = (request as unknown as { cf?: Record<string, string> }).cf;
868
+ if (cf) {
869
+ const geoParts: string[] = [];
870
+ if (cf.country) geoParts.push(cf.country);
871
+ if (cf.region) geoParts.push(cf.region);
872
+ if (cf.city) geoParts.push(cf.city);
873
+ if (geoParts.length) {
874
+ url.searchParams.set("__cf_geo", geoParts.join("|"));
875
+ }
876
+ }
877
+
878
+ // Bots render every section eagerly (shouldDeferSection short-circuits in
879
+ // resolve.ts), producing a ~10x larger HTML payload (all eager-section
880
+ // props serialized into the SSR hydration blob). Key bots into a SEPARATE
881
+ // bucket so a crawler / Lighthouse / PageSpeed request can never poison the
882
+ // shared human cache entry (and vice-versa). This MUST use the same `isBot`
883
+ // predicate that gates shouldDeferSection — keying off a different bot
884
+ // regex (e.g. requestContext's BOT_RE, which misses Lighthouse/Semrush)
885
+ // would let the key and render decisions diverge and re-introduce poisoning.
886
+ if (isBot(request.headers.get("user-agent") ?? undefined)) {
887
+ url.searchParams.set("__bot", "1");
888
+ }
889
+
890
+ // Programmatic, non-navigation fetches (the PLP "Ver mais"/load-more AJAX,
891
+ // embeds, server-to-server) read the static SSR HTML and can't run the
892
+ // client-side deferred-section resolution, so the origin renders every
893
+ // section eagerly for them (isProgrammaticFetch in cms/resolve.ts) — the
894
+ // same ~10x larger payload as bots. Key them into a SEPARATE `__fetch=1`
895
+ // bucket so a fetch-triggered eager response never poisons the navigation
896
+ // (deferred) entry, and vice-versa. MUST use the same `Sec-Fetch-Dest:
897
+ // empty` signal as isProgrammaticFetch so the key and render decisions can't
898
+ // diverge. `/_serverFn` (SPA-nav data) is excluded: it has its own keying
899
+ // and stays eager via isClientNavigation, not this bucket.
900
+ const secFetchDest = request.headers.get("sec-fetch-dest");
901
+ const isServerFnPath = url.pathname.startsWith("/_serverFn/") ||
902
+ url.pathname.startsWith("/_server/");
903
+ if (!isServerFnPath && secFetchDest === "empty") {
904
+ url.searchParams.set("__fetch", "1");
905
+ }
906
+
907
+ if (buildSegment) {
908
+ const segment = buildSegment(request);
909
+ url.searchParams.set("__seg", hashSegment(segment));
910
+ return { key: new Request(url.toString(), { method: "GET" }), segment };
911
+ }
912
+
913
+ if (deviceSpecificKeys) {
914
+ const device = isMobileUA(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
915
+ url.searchParams.set("__cf_device", device);
916
+ }
917
+
918
+ return { key: new Request(url.toString(), { method: "GET" }) };
919
+ }
920
+
921
+ // -- Purge handler ----------------------------------------------------------
922
+
923
+ interface PurgeRequestBody {
924
+ paths?: string[];
925
+ countries?: string[];
926
+ /** Sales channels to include in segment combos. Defaults to ["1"]. */
927
+ salesChannels?: string[];
928
+ /** Region IDs to include in segment combos. Each ID generates additional entries. */
929
+ regionIds?: string[];
930
+ }
931
+
932
+ function buildPurgeSegments(body: PurgeRequestBody): SegmentKey[] {
933
+ const devices: Array<"mobile" | "desktop"> = ["mobile", "desktop"];
934
+ const channels = body.salesChannels ?? ["1"];
935
+ const regions: Array<string | undefined> = [undefined, ...(body.regionIds ?? [])];
936
+
937
+ const segments: SegmentKey[] = [];
938
+ for (const device of devices) {
939
+ for (const salesChannel of channels) {
940
+ for (const regionId of regions) {
941
+ segments.push({ device, salesChannel, regionId });
942
+ }
943
+ }
944
+ segments.push({ device });
945
+ }
946
+ return segments;
947
+ }
948
+
949
+ async function handlePurge(request: Request, env: Record<string, unknown>): Promise<Response> {
950
+ if (purgeTokenEnv === false) {
951
+ return new Response("Purge disabled", { status: 404 });
952
+ }
953
+
954
+ const token = (env[purgeTokenEnv] as string) || "";
955
+ if (!token || request.headers.get("Authorization") !== `Bearer ${token}`) {
956
+ return new Response("Unauthorized", { status: 401 });
957
+ }
958
+
959
+ let body: PurgeRequestBody;
960
+ try {
961
+ body = await request.json();
962
+ } catch {
963
+ return new Response("Invalid JSON body", { status: 400 });
964
+ }
965
+
966
+ const paths = body.paths;
967
+ if (!Array.isArray(paths) || paths.length === 0) {
968
+ return new Response('Body must include "paths": ["/", "/page"]', { status: 400 });
969
+ }
970
+
971
+ const geoVariants = body.countries ?? [];
972
+
973
+ const cache = isDevMode()
974
+ ? null
975
+ : typeof caches !== "undefined"
976
+ ? ((caches as unknown as { default?: Cache }).default ?? null)
977
+ : null;
978
+
979
+ if (!cache) {
980
+ return Response.json({ purged: [], total: 0, note: "Cache API unavailable" });
981
+ }
982
+
983
+ const baseUrl = new URL(request.url).origin;
984
+ const purged: string[] = [];
985
+
986
+ const geoKeys: (string | null)[] = [null, ...geoVariants];
987
+
988
+ // Bots (`__bot=1`) and programmatic fetches (`__fetch=1`) are each keyed
989
+ // into a separate bucket (see buildCacheKey), so purge every combination.
990
+ // The param-set order below MUST mirror buildCacheKey exactly (__v,
991
+ // __cf_geo, __bot, __fetch, then __seg/__cf_device) so the purge key
992
+ // byte-matches the stored key.
993
+ const botVariants = [false, true] as const;
994
+ const fetchVariants = [false, true] as const;
995
+
996
+ for (const p of paths) {
997
+ if (buildSegment) {
998
+ const segments = buildPurgeSegments(body);
999
+ for (const seg of segments) {
1000
+ for (const cc of geoKeys) {
1001
+ for (const bot of botVariants) {
1002
+ for (const fetchReq of fetchVariants) {
1003
+ const url = new URL(p, baseUrl);
1004
+ const purgeVersion = getBuildHash(env);
1005
+ if (purgeVersion) url.searchParams.set("__v", purgeVersion);
1006
+ if (cc) url.searchParams.set("__cf_geo", cc);
1007
+ if (bot) url.searchParams.set("__bot", "1");
1008
+ if (fetchReq) url.searchParams.set("__fetch", "1");
1009
+ url.searchParams.set("__seg", hashSegment(seg));
1010
+ const key = new Request(url.toString(), { method: "GET" });
1011
+ try {
1012
+ if (await cache.delete(key)) {
1013
+ const tags = [
1014
+ hashSegment(seg),
1015
+ cc,
1016
+ bot ? "bot" : null,
1017
+ fetchReq ? "fetch" : null,
1018
+ ]
1019
+ .filter(Boolean)
1020
+ .join(", ");
1021
+ purged.push(`${p} (${tags})`);
1022
+ }
1023
+ } catch {
1024
+ /* ignore */
1025
+ }
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ } else {
1031
+ const devices = deviceSpecificKeys ? (["mobile", "desktop"] as const) : ([null] as const);
1032
+
1033
+ for (const device of devices) {
1034
+ for (const cc of geoKeys) {
1035
+ for (const bot of botVariants) {
1036
+ for (const fetchReq of fetchVariants) {
1037
+ const url = new URL(p, baseUrl);
1038
+ const purgeVersion = getBuildHash(env);
1039
+ if (purgeVersion) url.searchParams.set("__v", purgeVersion);
1040
+ if (cc) url.searchParams.set("__cf_geo", cc);
1041
+ if (bot) url.searchParams.set("__bot", "1");
1042
+ if (fetchReq) url.searchParams.set("__fetch", "1");
1043
+ if (device) url.searchParams.set("__cf_device", device);
1044
+ const key = new Request(url.toString(), { method: "GET" });
1045
+ try {
1046
+ if (await cache.delete(key)) {
1047
+ const parts = [device, cc, bot ? "bot" : null, fetchReq ? "fetch" : null]
1048
+ .filter(Boolean)
1049
+ .join(", ");
1050
+ purged.push(parts ? `${p} (${parts})` : p);
1051
+ }
1052
+ } catch {
1053
+ /* ignore */
1054
+ }
1055
+ }
1056
+ }
1057
+ }
1058
+ }
1059
+ }
1060
+ }
1061
+
1062
+ return Response.json({ purged, total: purged.length });
1063
+ }
1064
+
1065
+ // -- Admin route handler ---------------------------------------------------
1066
+
1067
+ const ADMIN_NO_CACHE: Record<string, string> = {
1068
+ "Cache-Control": "no-store, no-cache, must-revalidate",
1069
+ "CDN-Cache-Control": "no-store",
1070
+ "Surrogate-Control": "no-store",
1071
+ };
1072
+
1073
+ function addCors(response: Response, request: Request): Response {
1074
+ if (!admin) return response;
1075
+ const cors = admin.corsHeaders(request);
1076
+ const resp = new Response(response.body, {
1077
+ status: response.status,
1078
+ statusText: response.statusText,
1079
+ headers: new Headers(response.headers),
1080
+ });
1081
+ for (const [k, v] of Object.entries({ ...cors, ...ADMIN_NO_CACHE })) {
1082
+ resp.headers.set(k, v);
1083
+ }
1084
+ return resp;
1085
+ }
1086
+
1087
+ async function tryAdminRoute(request: Request): Promise<Response | null> {
1088
+ if (!admin) return null;
1089
+
1090
+ const url = new URL(request.url);
1091
+ const { pathname } = url;
1092
+ const method = request.method;
1093
+
1094
+ if (pathname === "/live/_meta") {
1095
+ if (method === "OPTIONS") {
1096
+ return new Response(null, {
1097
+ status: 204,
1098
+ headers: { ...admin.corsHeaders(request), ...ADMIN_NO_CACHE },
1099
+ });
1100
+ }
1101
+ const resp = await withTracing("deco.admin.meta", async () => admin.handleMeta(request));
1102
+ return addCors(resp, request);
1103
+ }
1104
+
1105
+ if (pathname === "/.decofile") {
1106
+ if (method === "OPTIONS") {
1107
+ return new Response(null, {
1108
+ status: 204,
1109
+ headers: { ...admin.corsHeaders(request), ...ADMIN_NO_CACHE },
1110
+ });
1111
+ }
1112
+ if (method === "POST") {
1113
+ const resp = await withTracing("deco.admin.decofile.reload", () =>
1114
+ Promise.resolve(admin.handleDecofileReload(request)),
1115
+ );
1116
+ return addCors(resp, request);
1117
+ }
1118
+ const resp = await withTracing("deco.admin.decofile.read", async () =>
1119
+ admin.handleDecofileRead(),
1120
+ );
1121
+ return addCors(resp, request);
1122
+ }
1123
+
1124
+ if (pathname === "/deco/_liveness") {
1125
+ return new Response("OK", {
1126
+ status: 200,
1127
+ headers: { "Content-Type": "text/plain", ...ADMIN_NO_CACHE },
1128
+ });
1129
+ }
1130
+
1131
+ if ((pathname === "/live/previews" || pathname === "/live/previews/") && method === "GET") {
1132
+ const shell = customPreviewShell ?? buildPreviewShell();
1133
+ return new Response(shell, {
1134
+ status: 200,
1135
+ headers: {
1136
+ "Content-Type": "text/html; charset=utf-8",
1137
+ ...admin.corsHeaders(request),
1138
+ ...ADMIN_NO_CACHE,
1139
+ },
1140
+ });
1141
+ }
1142
+
1143
+ if (pathname.startsWith("/live/previews/") && pathname !== "/live/previews/") {
1144
+ if (method === "OPTIONS") {
1145
+ return new Response(null, {
1146
+ status: 204,
1147
+ headers: { ...admin.corsHeaders(request), ...ADMIN_NO_CACHE },
1148
+ });
1149
+ }
1150
+ const pathComponent = pathname.slice("/live/previews/".length);
1151
+ const resp = await withTracing(
1152
+ "deco.admin.render",
1153
+ () => Promise.resolve(admin.handleRender(request)),
1154
+ { "cms.component": pathComponent || "(page)" },
1155
+ );
1156
+ return addCors(resp, request);
1157
+ }
1158
+
1159
+ return null;
1160
+ }
1161
+
1162
+ // -- Main fetch handler -----------------------------------------------------
1163
+
1164
+ const handler = {
1165
+ async fetch(
1166
+ request: Request,
1167
+ env: Record<string, unknown>,
1168
+ ctx: WorkerExecutionContext,
1169
+ ): Promise<Response> {
1170
+ const startedAt = performance.now();
1171
+ const reqUrl = new URL(request.url);
1172
+ const method = request.method;
1173
+
1174
+ // Inject CF geo data as cookies for location matchers (before anything reads cookies)
1175
+ if (geoOpt) {
1176
+ request = injectGeoCookies(request);
1177
+ }
1178
+
1179
+ // Captured inside the withTracing scope so the outer post-response
1180
+ // path (response headers, metric labels, log attrs) can stamp the
1181
+ // trace ID without re-entering AsyncLocalStorage. The closure
1182
+ // captures whatever the framework span saw; if the bridge tracer
1183
+ // is a no-op, both stay empty strings and the header writes below
1184
+ // become no-ops.
1185
+ const identity = { requestId: "", traceId: "" };
1186
+
1187
+ // Wrap the entire request in a RequestContext so that all code
1188
+ // in the call stack (loaders, invoke handlers, vtexFetchWithCookies)
1189
+ // can access the request and write response headers.
1190
+ const response = await RequestContext.run(request, async () => {
1191
+ // Stash env so request-scoped adapters (Workers Analytics Engine,
1192
+ // future binding-driven destinations) can resolve their bindings
1193
+ // via getRuntimeEnv() in sdk/otelAdapters.ts.
1194
+ setRuntimeEnv(env);
1195
+
1196
+ // RequestContext.run already resolved request.id from the
1197
+ // inbound headers (precedence: x-request-id → cf-ray → UUID).
1198
+ // Lift it into the closure so the response-write path below has
1199
+ // access without going back through ALS.
1200
+ identity.requestId = RequestContext.requestId ?? "";
1201
+
1202
+ // W3C tracecontext propagation — parse the inbound `traceparent`
1203
+ // header so the OTLP trace exporter creates root spans under
1204
+ // the caller's trace ID. No-op when the header is absent or
1205
+ // malformed; the exporter falls back to a fresh trace ID.
1206
+ const incomingTraceparent = request.headers.get("traceparent");
1207
+ const remoteTrace = parseTraceparent(incomingTraceparent);
1208
+ if (remoteTrace) _setRequestTraceContext(remoteTrace);
1209
+
1210
+ // Debug sampling: ?__d=<any> forces this request to be fully sampled
1211
+ // regardless of headSamplingRate. Lets operators trace a specific
1212
+ // production request without changing global sampling rates.
1213
+ if (new URL(request.url).searchParams.has("__d")) _setDebugSampled();
1214
+
1215
+ // Wrap inner handler in a single root span carrying our normalized
1216
+ // path/method attributes. The framework span flows BOTH ways:
1217
+ // - via the OTLP direct-POST tracer (when DECO_OTEL_TRACES_ENDPOINT
1218
+ // is set) → ClickHouse `otel_traces`,
1219
+ // - via the @opentelemetry/api bridge → CF Workers Observability
1220
+ // when `observability.traces.enabled = true` in wrangler.jsonc.
1221
+ // See `configureTracerStack` in `otel.ts` for the composition.
1222
+ return withTracing(
1223
+ "deco.http.request",
1224
+ async () => {
1225
+ // Stamp identity on the root span so every child span +
1226
+ // every log emitted under the same active span carries
1227
+ // them. Done inside the span scope so getActiveSpan()
1228
+ // returns the right span. Cheap no-op for any of these
1229
+ // when no tracer is configured.
1230
+ if (identity.requestId) {
1231
+ setSpanAttribute("request.id", identity.requestId);
1232
+ }
1233
+ const spanCtx = getActiveSpan()?.spanContext?.();
1234
+ if (spanCtx?.traceId) {
1235
+ identity.traceId = spanCtx.traceId;
1236
+ }
1237
+
1238
+ // Run app middleware (injects app state into RequestContext.bag,
1239
+ // runs registered middleware like VTEX cookie forwarding).
1240
+ const appMw = getAppMiddleware();
1241
+ const innerResponse = appMw
1242
+ ? await appMw(request, () => handleRequest(request, env, ctx))
1243
+ : await handleRequest(request, env, ctx);
1244
+
1245
+ // Access log at debug — no-op in prod (default warn minLevel),
1246
+ // visible in dev with DECO_OTEL_LOGS_MIN_LEVEL=debug.
1247
+ // Must run inside the span so getSpan() returns the active root
1248
+ // span and trace_id is stamped on the log record.
1249
+ try {
1250
+ logRequest(request, innerResponse.status, performance.now() - startedAt, {
1251
+ ...(identity.requestId ? { "request.id": identity.requestId } : {}),
1252
+ ...(identity.traceId ? { "trace.id": identity.traceId } : {}),
1253
+ });
1254
+ } catch {
1255
+ /* swallow — observability must never fail the request */
1256
+ }
1257
+
1258
+ return innerResponse;
1259
+ },
1260
+ {
1261
+ "http.method": method,
1262
+ "url.path": reqUrl.pathname,
1263
+ },
1264
+ );
1265
+ });
1266
+
1267
+ // Deduplicate Set-Cookie headers — multiple layers (VTEX middleware,
1268
+ // invoke handlers, etc.) may independently append the same cookie.
1269
+ deduplicateSetCookies(response);
1270
+
1271
+ let finalResponse = applySecurityHeaders(response);
1272
+
1273
+ // Echo request.id + trace.id back to the client / tail worker.
1274
+ // The CF tail worker reads these headers off the response to
1275
+ // stamp `request.id` / `trace.id` on tail rows — they're the
1276
+ // join key against direct-POST logs/metrics (which carry the
1277
+ // same values via the logger + span attributes).
1278
+ //
1279
+ // Headers are written defensively: if a downstream component
1280
+ // already set them (e.g. a proxy upstream), the existing value
1281
+ // wins. That's intentional — a load-balancer-supplied request.id
1282
+ // is more useful for cross-system correlation than ours.
1283
+ if (identity.requestId || identity.traceId) {
1284
+ // applySecurityHeaders may return either the original Response
1285
+ // (HTML path, fresh Headers) or the same Response (non-HTML
1286
+ // path). Either way Response.headers is mutable on Workers, so
1287
+ // we can set on it directly.
1288
+ if (identity.requestId && !finalResponse.headers.has("x-request-id")) {
1289
+ try {
1290
+ finalResponse.headers.set("x-request-id", identity.requestId);
1291
+ } catch {
1292
+ // Some intermediaries seal response headers (e.g. cached
1293
+ // responses replayed from the Cache API). Fall back to
1294
+ // building a new Response.
1295
+ const headers = new Headers(finalResponse.headers);
1296
+ if (identity.requestId) headers.set("x-request-id", identity.requestId);
1297
+ if (identity.traceId) headers.set("x-trace-id", identity.traceId);
1298
+ finalResponse = new Response(finalResponse.body, {
1299
+ status: finalResponse.status,
1300
+ statusText: finalResponse.statusText,
1301
+ headers,
1302
+ });
1303
+ }
1304
+ }
1305
+ if (identity.traceId && !finalResponse.headers.has("x-trace-id")) {
1306
+ try {
1307
+ finalResponse.headers.set("x-trace-id", identity.traceId);
1308
+ } catch {
1309
+ /* see above — sealed header case already handled */
1310
+ }
1311
+ }
1312
+ }
1313
+
1314
+ // Metrics + structured request log. Done after security headers so
1315
+ // the recorded status reflects what the client actually receives.
1316
+ // Both calls are no-ops when no meter / logger is configured.
1317
+ const durationMs = performance.now() - startedAt;
1318
+ try {
1319
+ // Phase 2 / D-11 canonical labels — lift the cache decision +
1320
+ // profile + region off the response we just built so dashboards
1321
+ // can answer "cache hit rate per route" from `http_requests_total`
1322
+ // alone, no join to `cache_*_total` required.
1323
+ const xCacheRaw = finalResponse.headers.get("X-Cache");
1324
+ const cacheDecision = isCacheDecision(xCacheRaw) ? xCacheRaw : undefined;
1325
+ const colo = (request as unknown as { cf?: { colo?: string } }).cf?.colo;
1326
+ // NOTE: `request.id` and `trace.id` are intentionally NOT stamped
1327
+ // on the metric. They are per-request identifiers and would
1328
+ // collapse aggregation (every request → its own histogram data
1329
+ // point). They are stamped on the span; use traces for
1330
+ // request-level correlation.
1331
+ recordRequestMetric(method, reqUrl.pathname, finalResponse.status, durationMs, {
1332
+ ...(cacheDecision ? { cache_decision: cacheDecision } : {}),
1333
+ ...(cacheDecision ? { cache_layer: "edge" as const } : {}),
1334
+ ...(typeof colo === "string" && colo.length > 0 ? { region: colo } : {}),
1335
+ });
1336
+ } catch {
1337
+ /* swallow — observability must never fail the request */
1338
+ }
1339
+ return finalResponse;
1340
+ },
1341
+ };
1342
+
1343
+ // Auto-instrument with `instrumentWorker` unless explicitly opted out.
1344
+ // When `observabilityOpt === false` the site is taking control of its own
1345
+ // OTel wiring; otherwise the framework wraps the handler so that, when
1346
+ // `DECO_OTEL_*_ENDPOINT` env vars are configured, telemetry flows
1347
+ // without any change to the site's worker-entry. When the env vars are
1348
+ // absent the wrap is a no-op (no exporters created, no flush calls).
1349
+ return observabilityOpt === false
1350
+ ? handler
1351
+ : instrumentWorker(handler, (observabilityOpt as OtelOptions | undefined) ?? {});
1352
+
1353
+ async function handleRequest(
1354
+ request: Request,
1355
+ env: Record<string, unknown>,
1356
+ ctx: WorkerExecutionContext,
1357
+ ): Promise<Response> {
1358
+ const url = new URL(request.url);
1359
+
1360
+ // Fast-deploy: hydrate the in-memory decofile from KV on the first request
1361
+ // per isolate (awaited, ~10-30ms once), then opportunistically poll for
1362
+ // content changes (non-blocking, via ctx.waitUntil). No-op unless the
1363
+ // DECO_KV binding is present — non-migrated sites are unaffected. Runs
1364
+ // before admin routes so /.decofile reads reflect KV too.
1365
+ await ensureBlocksHydrated(env, ctx);
1366
+ maybePollRevision(env, ctx);
1367
+
1368
+ // Admin routes (/_meta, /.decofile, /live/previews) — always handled first
1369
+ const adminResponse = await tryAdminRoute(request);
1370
+ if (adminResponse) return adminResponse;
1371
+
1372
+ // Purge endpoint
1373
+ if (url.pathname === "/_cache/purge" && request.method === "POST") {
1374
+ return handlePurge(request, env);
1375
+ }
1376
+
1377
+ // ?asJson — return resolved page data as JSON (legacy deco compat)
1378
+ if (url.searchParams.has("asJson") && request.method === "GET") {
1379
+ const basePath = url.pathname;
1380
+ const cookies: Record<string, string> = {};
1381
+ for (const pair of (request.headers.get("cookie") ?? "").split(";")) {
1382
+ const [k, ...v] = pair.split("=");
1383
+ if (k?.trim()) cookies[k.trim()] = v.join("=").trim();
1384
+ }
1385
+ const matcherCtx: MatcherContext = {
1386
+ userAgent: request.headers.get("user-agent") ?? "",
1387
+ url: url.toString(),
1388
+ path: basePath,
1389
+ cookies,
1390
+ request,
1391
+ };
1392
+ const page = await resolveDecoPage(basePath, matcherCtx);
1393
+ if (!page) {
1394
+ return Response.json(null, {
1395
+ status: 404,
1396
+ headers: { "Access-Control-Allow-Origin": "*" },
1397
+ });
1398
+ }
1399
+ const enrichedSections = await runSectionLoaders(page.resolvedSections, request);
1400
+
1401
+ // Run SEO section loader if registered
1402
+ let seoResult = page.seoSection;
1403
+ if (seoResult) {
1404
+ try {
1405
+ seoResult = await runSingleSectionLoader(seoResult, request);
1406
+ } catch {
1407
+ // use unloaded seoSection
1408
+ }
1409
+ }
1410
+
1411
+ // Merge site-wide SEO defaults into seo props
1412
+ const blocks = loadBlocks();
1413
+ const site = blocks["Site"] as Record<string, unknown> | undefined;
1414
+ const fullSiteSeo = (site?.seo as Record<string, unknown>) ?? {};
1415
+
1416
+ // When SeoV2 loader ran, use its output as base (preserves key order)
1417
+ // and only fill in missing fields from the site-wide SEO config.
1418
+ const loaderProps = seoResult?.props ?? {};
1419
+ const seoProps: Record<string, unknown> = { ...loaderProps };
1420
+ for (const [k, v] of Object.entries(fullSiteSeo)) {
1421
+ if (!(k in seoProps)) seoProps[k] = v;
1422
+ }
1423
+ // Strip internal template fields
1424
+ delete seoProps.titleTemplate;
1425
+ delete seoProps.descriptionTemplate;
1426
+
1427
+ // Build resolveChain statically to match legacy deco-cx/deco format.
1428
+ type FieldResolver = { type: string; value: string | number };
1429
+ const rawKey = page.blockKey ?? `pages-${page.name}`;
1430
+ const encodedKey = rawKey.replace(
1431
+ /^(pages-)(.+)$/,
1432
+ (_m, prefix, rest) => prefix + encodeURIComponent(rest),
1433
+ );
1434
+ const pageChain: FieldResolver[] = [
1435
+ { type: "resolver", value: "website/handlers/fresh.ts" },
1436
+ { type: "prop", value: "page" },
1437
+ { type: "resolver", value: "resolved" },
1438
+ { type: "resolvable", value: encodedKey },
1439
+ { type: "resolver", value: "website/pages/Page.tsx" },
1440
+ ];
1441
+
1442
+ const seoChain: FieldResolver[] = [
1443
+ ...pageChain,
1444
+ { type: "prop", value: "seo" },
1445
+ { type: "resolver", value: seoResult?.component ?? "website/sections/Seo/SeoV2.tsx" },
1446
+ ];
1447
+
1448
+ const result = {
1449
+ props: {
1450
+ name: page.name,
1451
+ path: page.path,
1452
+ seo: {
1453
+ props: seoProps,
1454
+ metadata: {
1455
+ resolveChain: seoChain,
1456
+ component: seoResult?.component ?? "website/sections/Seo/SeoV2.tsx",
1457
+ },
1458
+ },
1459
+ sections: enrichedSections.map((s, i) => ({
1460
+ props: s.props,
1461
+ metadata: {
1462
+ resolveChain: [
1463
+ ...pageChain,
1464
+ { type: "prop", value: "sections" },
1465
+ { type: "prop", value: String(i) },
1466
+ { type: "resolver", value: s.component },
1467
+ ],
1468
+ component: s.component,
1469
+ },
1470
+ })),
1471
+ devMode: false,
1472
+ unindexedDomain: false,
1473
+ },
1474
+ metadata: {
1475
+ resolveChain: pageChain,
1476
+ component: "website/pages/Page.tsx",
1477
+ },
1478
+ };
1479
+ return Response.json(result, {
1480
+ headers: {
1481
+ "Access-Control-Allow-Origin": "*",
1482
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
1483
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
1484
+ },
1485
+ });
1486
+ }
1487
+
1488
+ // Commerce proxy (checkout, account, API, etc.)
1489
+ if (options.proxyHandler) {
1490
+ const proxyResponse = await options.proxyHandler(request, url);
1491
+ if (proxyResponse) return proxyResponse;
1492
+ }
1493
+
1494
+ // Static fingerprinted assets — serve from origin with immutable headers
1495
+ if (isStaticAsset(url.pathname)) {
1496
+ const origin = await serverEntry.fetch(request, env, ctx);
1497
+ if (origin.status === 200) {
1498
+ const ct = origin.headers.get("content-type") ?? "";
1499
+ if (ct.includes("text/html")) {
1500
+ return new Response("Not Found", { status: 404 });
1501
+ }
1502
+ const resp = new Response(origin.body, origin);
1503
+ for (const [k, v] of Object.entries(IMMUTABLE_HEADERS)) {
1504
+ resp.headers.set(k, v);
1505
+ }
1506
+ return resp;
1507
+ }
1508
+ return origin;
1509
+ }
1510
+
1511
+ // -----------------------------------------------------------------
1512
+ // POST _serverFn — edge-cacheable using body-hash as cache key.
1513
+ // These carry public CMS section data (shelves, deferred sections)
1514
+ // that benefits from edge caching despite being POST requests.
1515
+ // -----------------------------------------------------------------
1516
+ if (
1517
+ request.method === "POST" &&
1518
+ (url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/"))
1519
+ ) {
1520
+ const serverFnCache = isDevMode()
1521
+ ? null
1522
+ : typeof caches !== "undefined"
1523
+ ? ((caches as unknown as { default?: Cache }).default ?? null)
1524
+ : null;
1525
+
1526
+ // Build segment once — used for logged-in check and cache key
1527
+ const sfnSegment = buildSegment ? buildSegment(request) : undefined;
1528
+
1529
+ // Logged-in users always bypass — personalized content must not leak
1530
+ if (sfnSegment?.loggedIn) {
1531
+ const origin = await serverEntry.fetch(request, env, ctx);
1532
+ const resp = new Response(origin.body, origin);
1533
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1534
+ resp.headers.set("X-Cache", "BYPASS");
1535
+ resp.headers.set("X-Cache-Reason", "logged-in");
1536
+ return resp;
1537
+ }
1538
+
1539
+ // Matcher overrides via header don't vary the body-hash cache key —
1540
+ // bypass so forced variants are neither stored nor served from cache.
1541
+ // (QS overrides travel inside the payload's pageUrl, so they already
1542
+ // produce a distinct body hash.)
1543
+ if (request.headers.has(DECO_MATCHERS_OVERRIDE_PARAM)) {
1544
+ const origin = await serverEntry.fetch(request, env, ctx);
1545
+ const resp = new Response(origin.body, origin);
1546
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1547
+ resp.headers.set("X-Cache", "BYPASS");
1548
+ resp.headers.set("X-Cache-Reason", "matchers-override");
1549
+ return resp;
1550
+ }
1551
+
1552
+ // Clone request before consuming body — the clone goes to origin
1553
+ // untouched so TanStack Start internals (cookie passthrough, etc.)
1554
+ // work correctly. We only read the body for the cache key hash.
1555
+ const originClone = request.clone();
1556
+ const body = await request.text();
1557
+ const bodyHash = await hashText(body);
1558
+
1559
+ // Build a synthetic GET cache key from the URL + body hash + segment
1560
+ // Includes device, salesChannel, regionId, flags — so users in
1561
+ // different regions or channels get separate cache entries.
1562
+ const cacheKeyUrl = new URL(request.url);
1563
+ cacheKeyUrl.searchParams.set("__body", bodyHash);
1564
+ const sfnVersion = getBuildHash(env);
1565
+ if (sfnVersion) cacheKeyUrl.searchParams.set("__v", sfnVersion);
1566
+ if (sfnSegment) {
1567
+ cacheKeyUrl.searchParams.set("__seg", hashSegment(sfnSegment));
1568
+ } else if (deviceSpecificKeys) {
1569
+ const device = isMobileUA(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
1570
+ cacheKeyUrl.searchParams.set("__cf_device", device);
1571
+ }
1572
+ // Include CF geo data so location-based content doesn't leak across geos
1573
+ const cf = (request as unknown as { cf?: Record<string, string> }).cf;
1574
+ if (cf) {
1575
+ const geoParts: string[] = [];
1576
+ if (cf.country) geoParts.push(cf.country);
1577
+ if (cf.region) geoParts.push(cf.region);
1578
+ if (cf.city) geoParts.push(cf.city);
1579
+ if (geoParts.length) cacheKeyUrl.searchParams.set("__cf_geo", geoParts.join("|"));
1580
+ }
1581
+ const sfnCacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" });
1582
+
1583
+ // Use "listing" profile for server function responses
1584
+ const sfnProfile: CacheProfileName = "listing";
1585
+ const sfnEdge = edgeCacheConfig(sfnProfile);
1586
+
1587
+ // Check edge cache
1588
+ let sfnCached: Response | undefined;
1589
+ if (serverFnCache) {
1590
+ try {
1591
+ sfnCached = await withTracing(
1592
+ "deco.cache.lookup",
1593
+ async () => (await serverFnCache.match(sfnCacheKey)) ?? undefined,
1594
+ { "cache.profile": sfnProfile, "cache.kind": "serverFn" },
1595
+ );
1596
+ } catch {
1597
+ /* Cache API unavailable */
1598
+ }
1599
+ }
1600
+
1601
+ if (sfnCached && sfnEdge.fresh > 0) {
1602
+ const storedAt = Number(sfnCached.headers.get("X-Deco-Stored-At") || "0");
1603
+ const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
1604
+
1605
+ if (ageSec < sfnEdge.fresh) {
1606
+ recordCacheMetric(true, sfnProfile, "HIT", "edge");
1607
+ const out = new Response(sfnCached.body, sfnCached);
1608
+ const hdrs = cacheHeaders(sfnProfile);
1609
+ for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
1610
+ out.headers.set("X-Cache", "HIT");
1611
+ out.headers.set("X-Cache-Profile", sfnProfile);
1612
+ return out;
1613
+ }
1614
+
1615
+ if (ageSec < sfnEdge.fresh + sfnEdge.swr) {
1616
+ recordCacheMetric(true, sfnProfile, "STALE-HIT", "edge");
1617
+ // Stale-while-revalidate: serve stale, refresh in background
1618
+ ctx.waitUntil(
1619
+ (async () => {
1620
+ try {
1621
+ const bgReq = new Request(request, { body, method: "POST" });
1622
+ const bgOrigin = await serverEntry.fetch(bgReq, env, ctx);
1623
+ if (
1624
+ bgOrigin.status === 200 &&
1625
+ bgOrigin.headers.get("X-Deco-Cacheable") === "true" &&
1626
+ !bgOrigin.headers.has("set-cookie") &&
1627
+ serverFnCache
1628
+ ) {
1629
+ const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
1630
+ const toStore = bgOrigin.clone();
1631
+ toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
1632
+ toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
1633
+ toStore.headers.delete("CDN-Cache-Control");
1634
+ toStore.headers.delete("X-Deco-Cacheable");
1635
+ await serverFnCache.put(sfnCacheKey, toStore);
1636
+ }
1637
+ } catch {
1638
+ /* background revalidation failed */
1639
+ }
1640
+ })(),
1641
+ );
1642
+ const out = new Response(sfnCached.body, sfnCached);
1643
+ const hdrs = cacheHeaders(sfnProfile);
1644
+ for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
1645
+ out.headers.set("X-Cache", "STALE-HIT");
1646
+ out.headers.set("X-Cache-Profile", sfnProfile);
1647
+ out.headers.set("X-Cache-Age", String(Math.round(ageSec)));
1648
+ return out;
1649
+ }
1650
+ }
1651
+
1652
+ // Cache MISS — fetch origin with the body we already read
1653
+ recordCacheMetric(false, sfnProfile, "MISS", "edge");
1654
+ const origin = await serverEntry.fetch(originClone, env, ctx);
1655
+
1656
+ // Only cache responses explicitly marked as cacheable by the handler
1657
+ // (loadDeferredSection sets X-Deco-Cacheable: true). Checkout actions,
1658
+ // invoke mutations, and other server functions are passed through.
1659
+ const isCacheableResponse =
1660
+ origin.headers.get("X-Deco-Cacheable") === "true" &&
1661
+ !origin.headers.has("set-cookie") &&
1662
+ origin.status === 200;
1663
+
1664
+ if (!isCacheableResponse) {
1665
+ const resp = new Response(origin.body, origin);
1666
+ resp.headers.delete("X-Deco-Cacheable");
1667
+ resp.headers.set("X-Cache", "BYPASS");
1668
+ resp.headers.set(
1669
+ "X-Cache-Reason",
1670
+ origin.headers.has("set-cookie") ? "set-cookie" : "not-cacheable",
1671
+ );
1672
+ return resp;
1673
+ }
1674
+
1675
+ // Store in edge cache
1676
+ if (serverFnCache) {
1677
+ try {
1678
+ const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
1679
+ const toStore = origin.clone();
1680
+ toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
1681
+ toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
1682
+ toStore.headers.delete("CDN-Cache-Control");
1683
+ toStore.headers.delete("X-Deco-Cacheable");
1684
+ ctx.waitUntil(
1685
+ withTracing("deco.cache.store", () => serverFnCache.put(sfnCacheKey, toStore), {
1686
+ "cache.profile": sfnProfile,
1687
+ "cache.kind": "serverFn",
1688
+ }),
1689
+ );
1690
+ } catch {
1691
+ /* Cache API unavailable */
1692
+ }
1693
+ }
1694
+
1695
+ const resp = new Response(origin.body, origin);
1696
+ resp.headers.delete("X-Deco-Cacheable");
1697
+ const hdrs = cacheHeaders(sfnProfile);
1698
+ for (const [k, v] of Object.entries(hdrs)) resp.headers.set(k, v);
1699
+ resp.headers.set("X-Cache", "MISS");
1700
+ resp.headers.set("X-Cache-Profile", sfnProfile);
1701
+ return resp;
1702
+ }
1703
+
1704
+ // Non-cacheable requests — pass through but protect against accidental caching
1705
+ if (!isCacheable(request, url)) {
1706
+ const origin = await serverEntry.fetch(request, env, ctx);
1707
+ const profile = getProfile(url);
1708
+
1709
+ if (profile === "private" || profile === "none" || profile === "cart") {
1710
+ const resp = new Response(origin.body, origin);
1711
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1712
+ resp.headers.delete("CDN-Cache-Control");
1713
+ resp.headers.set("X-Cache", "BYPASS");
1714
+ resp.headers.set("X-Cache-Reason", `non-cacheable:${profile}`);
1715
+ return resp;
1716
+ }
1717
+
1718
+ const resp = new Response(origin.body, origin);
1719
+
1720
+ // Responses with private Set-Cookie headers carry per-user tokens —
1721
+ // never expose them with public cache headers.
1722
+ // Safe/public cookies (e.g., vtex_is_session) are allowed through.
1723
+ if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
1724
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1725
+ resp.headers.delete("CDN-Cache-Control");
1726
+ resp.headers.set("X-Cache", "BYPASS");
1727
+ resp.headers.set("X-Cache-Reason", "private-set-cookie");
1728
+ return resp;
1729
+ }
1730
+
1731
+ // Set cache headers from the detected profile so the response
1732
+ // is explicit about cacheability (avoids ambiguous empty header).
1733
+ const hdrsNc = cacheHeaders(profile);
1734
+ for (const [k, v] of Object.entries(hdrsNc)) resp.headers.set(k, v);
1735
+
1736
+ const reason = request.method !== "GET" ? `method:${request.method}` : "bypass-path";
1737
+ resp.headers.set("X-Cache", "BYPASS");
1738
+ resp.headers.set("X-Cache-Profile", profile);
1739
+ resp.headers.set("X-Cache-Reason", reason);
1740
+ return resp;
1741
+ }
1742
+
1743
+ // Cacheable request — build segment-aware cache key
1744
+ const { key: cacheKey, segment } = buildCacheKey(request, env);
1745
+
1746
+ // Logged-in users always bypass the cache (personalized content)
1747
+ if (segment?.loggedIn) {
1748
+ const origin = await serverEntry.fetch(request, env, ctx);
1749
+ const resp = new Response(origin.body, origin);
1750
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1751
+ resp.headers.set("X-Cache", "BYPASS");
1752
+ resp.headers.set("X-Cache-Reason", "logged-in");
1753
+ return resp;
1754
+ }
1755
+
1756
+ // Check Cache API — disabled in local dev to avoid stale responses
1757
+ const cache = isDevMode()
1758
+ ? null
1759
+ : typeof caches !== "undefined"
1760
+ ? ((caches as unknown as { default?: Cache }).default ?? null)
1761
+ : null;
1762
+
1763
+ const profile = getProfile(url);
1764
+ const edgeConfig = edgeCacheConfig(profile);
1765
+
1766
+ // Helper: dress a response with proper client-facing headers
1767
+ function dressResponse(
1768
+ resp: Response,
1769
+ xCache: string,
1770
+ extra?: Record<string, string>,
1771
+ ): Response {
1772
+ const out = new Response(resp.body, resp);
1773
+ const hdrs = cacheHeaders(profile);
1774
+ for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
1775
+
1776
+ // CDN-Cache-Control: controls Cloudflare's automatic CDN layer
1777
+ // (separate from Cache API which the worker manages directly).
1778
+ if (cdnCacheControlOpt === "no-store") {
1779
+ out.headers.set("CDN-Cache-Control", "no-store");
1780
+ } else if (cdnCacheControlOpt === "match-profile") {
1781
+ if (edgeConfig.isPublic && edgeConfig.fresh > 0) {
1782
+ out.headers.set("CDN-Cache-Control", `public, max-age=${edgeConfig.fresh}`);
1783
+ } else {
1784
+ out.headers.set("CDN-Cache-Control", "no-store");
1785
+ }
1786
+ } else if (typeof cdnCacheControlOpt === "function") {
1787
+ const val = cdnCacheControlOpt(profile);
1788
+ out.headers.set("CDN-Cache-Control", val ?? "no-store");
1789
+ }
1790
+
1791
+ out.headers.set("X-Cache", xCache);
1792
+ out.headers.set("X-Cache-Profile", profile);
1793
+ if (segment) out.headers.set("X-Cache-Segment", hashSegment(segment));
1794
+ const headerVersion = getBuildHash(env);
1795
+ if (headerVersion) out.headers.set("X-Cache-Version", headerVersion);
1796
+ if (extra) for (const [k, v] of Object.entries(extra)) out.headers.set(k, v);
1797
+ appendResourceHints(out);
1798
+ return out;
1799
+ }
1800
+
1801
+ // Helper: store a response in Cache API with the full retention window
1802
+ function storeInCache(resp: Response) {
1803
+ if (!cache) return;
1804
+ try {
1805
+ const storageTtl = edgeConfig.fresh + Math.max(edgeConfig.swr, edgeConfig.sie);
1806
+ const toStore = resp.clone();
1807
+ toStore.headers.set("Cache-Control", `public, max-age=${storageTtl}`);
1808
+ toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
1809
+ toStore.headers.delete("CDN-Cache-Control");
1810
+ ctx.waitUntil(
1811
+ withTracing("deco.cache.store", () => cache.put(cacheKey, toStore), {
1812
+ "cache.profile": profile,
1813
+ "cache.kind": "html",
1814
+ }),
1815
+ );
1816
+ } catch {
1817
+ // Cache API unavailable
1818
+ }
1819
+ }
1820
+
1821
+ // Helper: background revalidation (fetch origin, store result)
1822
+ function revalidateInBackground() {
1823
+ ctx.waitUntil(
1824
+ Promise.resolve(serverEntry.fetch(request, env, ctx))
1825
+ .then((origin) => {
1826
+ if (origin.status === 200) {
1827
+ // Only cache if response has no cookies or only safe cookies.
1828
+ // Strip safe cookies from the cached copy.
1829
+ if (hasOnlySafeCookies(origin, safeCookieSet)) {
1830
+ const cleanOrigin = origin.headers.has("set-cookie")
1831
+ ? stripSafeCookiesForCache(origin, safeCookieSet)
1832
+ : origin;
1833
+ storeInCache(cleanOrigin);
1834
+ }
1835
+ }
1836
+ })
1837
+ .catch(() => {
1838
+ // Background revalidation failed — stale entry stays until SIE expires
1839
+ }),
1840
+ );
1841
+ }
1842
+
1843
+ // --- Edge cache check with SWR + SIE ---
1844
+ let cached: Response | undefined;
1845
+ if (cache) {
1846
+ try {
1847
+ cached = await withTracing(
1848
+ "deco.cache.lookup",
1849
+ async () => (await cache.match(cacheKey)) ?? undefined,
1850
+ { "cache.profile": profile, "cache.kind": "html" },
1851
+ );
1852
+ } catch {
1853
+ // Cache API unavailable
1854
+ }
1855
+ }
1856
+
1857
+ if (cached && edgeConfig.isPublic && edgeConfig.fresh > 0) {
1858
+ const storedAtStr = cached.headers.get("X-Deco-Stored-At");
1859
+ const storedAt = storedAtStr ? Number(storedAtStr) : 0;
1860
+ const ageMs = storedAt > 0 ? Date.now() - storedAt : Infinity;
1861
+ const ageSec = ageMs / 1000;
1862
+
1863
+ if (ageSec < edgeConfig.fresh) {
1864
+ // FRESH HIT — serve immediately
1865
+ recordCacheMetric(true, profile, "HIT", "edge");
1866
+ return dressResponse(cached, "HIT");
1867
+ }
1868
+
1869
+ if (ageSec < edgeConfig.fresh + edgeConfig.swr) {
1870
+ // STALE-HIT within SWR window — serve stale, revalidate in background
1871
+ recordCacheMetric(true, profile, "STALE-HIT", "edge");
1872
+ revalidateInBackground();
1873
+ return dressResponse(cached, "STALE-HIT", { "X-Cache-Age": String(Math.round(ageSec)) });
1874
+ }
1875
+
1876
+ // Past SWR window but still in cache (within SIE window) — keep reference
1877
+ // for potential error fallback below
1878
+ }
1879
+
1880
+ // Cache MISS or past SWR window — fetch from origin
1881
+ recordCacheMetric(false, profile, "MISS", "edge");
1882
+ let origin: Response;
1883
+ try {
1884
+ origin = await serverEntry.fetch(request, env, ctx);
1885
+ } catch (err) {
1886
+ // Origin fetch threw — SIE fallback if we have a stale entry
1887
+ if (cached && edgeConfig.sie > 0) {
1888
+ const storedAtStr = cached.headers.get("X-Deco-Stored-At");
1889
+ const storedAt = storedAtStr ? Number(storedAtStr) : 0;
1890
+ const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
1891
+ if (ageSec < edgeConfig.fresh + edgeConfig.sie) {
1892
+ console.warn(
1893
+ `[edge-cache] Origin threw, serving stale (age=${Math.round(ageSec)}s, sie=${edgeConfig.sie}s)`,
1894
+ );
1895
+ recordCacheMetric(true, profile, "STALE-ERROR", "edge");
1896
+ return dressResponse(cached, "STALE-ERROR", {
1897
+ "X-Cache-Age": String(Math.round(ageSec)),
1898
+ });
1899
+ }
1900
+ }
1901
+ throw err;
1902
+ }
1903
+
1904
+ if (origin.status !== 200) {
1905
+ // Non-200 origin — SIE fallback on 5xx/429
1906
+ if (origin.status >= 500 || origin.status === 429) {
1907
+ if (cached && edgeConfig.sie > 0) {
1908
+ const storedAtStr = cached.headers.get("X-Deco-Stored-At");
1909
+ const storedAt = storedAtStr ? Number(storedAtStr) : 0;
1910
+ const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
1911
+ if (ageSec < edgeConfig.fresh + edgeConfig.sie) {
1912
+ console.warn(
1913
+ `[edge-cache] Origin ${origin.status}, serving stale (age=${Math.round(ageSec)}s)`,
1914
+ );
1915
+ recordCacheMetric(true, profile, "STALE-ERROR", "edge");
1916
+ return dressResponse(cached, "STALE-ERROR", {
1917
+ "X-Cache-Age": String(Math.round(ageSec)),
1918
+ "X-Cache-Origin-Status": String(origin.status),
1919
+ });
1920
+ }
1921
+ }
1922
+ }
1923
+ const resp = new Response(origin.body, origin);
1924
+ resp.headers.set("X-Cache", "BYPASS");
1925
+ resp.headers.set("X-Cache-Reason", `status:${origin.status}`);
1926
+ appendResourceHints(resp);
1927
+ return resp;
1928
+ }
1929
+
1930
+ // Responses with private Set-Cookie headers must never be cached —
1931
+ // they carry per-user session/auth tokens that would leak to other users.
1932
+ // Safe/public cookies (IS session, segment, etc.) are stripped from the
1933
+ // cached copy but kept on the response served to the current user.
1934
+ if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
1935
+ const resp = new Response(origin.body, origin);
1936
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1937
+ resp.headers.delete("CDN-Cache-Control");
1938
+ resp.headers.set("X-Cache", "BYPASS");
1939
+ resp.headers.set("X-Cache-Reason", "private-set-cookie");
1940
+ appendResourceHints(resp);
1941
+ return resp;
1942
+ }
1943
+
1944
+ const profileConfig = getCacheProfile(profile);
1945
+
1946
+ if (!profileConfig.isPublic || profileConfig.edge.fresh === 0) {
1947
+ const resp = new Response(origin.body, origin);
1948
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1949
+ resp.headers.set("X-Cache", "BYPASS");
1950
+ resp.headers.set("X-Cache-Reason", `profile:${profile}`);
1951
+ appendResourceHints(resp);
1952
+ return resp;
1953
+ }
1954
+
1955
+ // Clone for cache BEFORE dressResponse consumes the body stream.
1956
+ // dressResponse() calls new Response(resp.body, resp) which locks
1957
+ // the ReadableStream. Calling clone() on a locked body corrupts
1958
+ // the stream in Workers runtime, causing Error 1101.
1959
+ // Strip safe cookies from the cached copy so they don't leak
1960
+ // to other users, but the current user still gets them.
1961
+ const cacheOrigin = origin.headers.has("set-cookie")
1962
+ ? stripSafeCookiesForCache(origin, safeCookieSet)
1963
+ : origin;
1964
+ storeInCache(cacheOrigin);
1965
+ return dressResponse(origin, "MISS");
1966
+ }
1967
+ }