@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.
- package/package.json +48 -0
- package/src/cms/kvBlockSource.test.ts +67 -0
- package/src/cms/kvBlockSource.ts +54 -0
- package/src/daemon/auth.ts +204 -0
- package/src/daemon/fs.ts +238 -0
- package/src/daemon/index.ts +8 -0
- package/src/daemon/middleware.ts +156 -0
- package/src/daemon/tunnel.ts +129 -0
- package/src/daemon/volumes.ts +366 -0
- package/src/daemon/watch.ts +272 -0
- package/src/hooks/DecoPageRenderer.tsx +685 -0
- package/src/hooks/DecoRootLayout.tsx +111 -0
- package/src/hooks/NavigationProgress.tsx +21 -0
- package/src/hooks/PreviewProviders.tsx +26 -0
- package/src/hooks/StableOutlet.tsx +30 -0
- package/src/hooks/index.ts +9 -0
- package/src/index.ts +30 -0
- package/src/routes/adminRoutes.ts +114 -0
- package/src/routes/cmsRoute.ts +706 -0
- package/src/routes/components.tsx +57 -0
- package/src/routes/index.ts +24 -0
- package/src/routes/pageUrl.test.ts +70 -0
- package/src/routes/pageUrl.ts +54 -0
- package/src/routes/withSiteGlobals.test.ts +206 -0
- package/src/routes/withSiteGlobals.ts +222 -0
- package/src/sdk/cookiePassthrough.ts +58 -0
- package/src/sdk/createInvoke.ts +57 -0
- package/src/sdk/kvHydration.test.ts +171 -0
- package/src/sdk/kvHydration.ts +176 -0
- package/src/sdk/router.ts +92 -0
- package/src/sdk/useHydrated.ts +19 -0
- package/src/sdk/workerEntry.test.ts +106 -0
- package/src/sdk/workerEntry.ts +1967 -0
- package/src/setupFastDeploy.ts +13 -0
- package/src/vite/plugin.js +646 -0
- package/src/vite/plugin.test.js +113 -0
- package/tsconfig.json +7 -0
|
@@ -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
|
+
}
|