-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathworkerEntry.ts
More file actions
1813 lines (1641 loc) · 67.5 KB
/
Copy pathworkerEntry.ts
File metadata and controls
1813 lines (1641 loc) · 67.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Factory for creating a cache-aware Cloudflare Worker entry.
*
* Wraps a TanStack Start server entry with:
* - Cloudflare Cache API integration (edge caching)
* - Device-specific cache keys (mobile/desktop split)
* - Per-URL cache profile detection via detectCacheProfile()
* - Immutable caching for fingerprinted static assets
* - Cache purge API endpoint
* - Protection against accidental caching of private/search paths
*
* @example
* ```ts
* // src/worker-entry.ts
* import handler, { createServerEntry } from "@tanstack/react-start/server-entry";
* import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
*
* const serverEntry = createServerEntry({
* async fetch(request) {
* return await handler.fetch(request);
* },
* });
*
* export default createDecoWorkerEntry(serverEntry);
* ```
*/
import { getRenderShellConfig } from "../admin/setup";
import { loadBlocks } from "../cms/loader";
import type { MatcherContext } from "../cms/resolve";
import { resolveDecoPage } from "../cms/resolve";
import { runSectionLoaders, runSingleSectionLoader } from "../cms/sectionLoaders";
import {
type CacheProfileName,
cacheHeaders,
detectCacheProfile,
edgeCacheConfig,
getCacheProfile,
} from "./cacheHeaders";
import { buildHtmlShell } from "./htmlShell";
import {
getActiveSpan,
logRequest,
recordCacheMetric,
recordRequestMetric,
setSpanAttribute,
withTracing,
} from "./observability";
import { _setRequestTraceContext } from "./otel";
import { setRuntimeEnv } from "./otelAdapters";
import { parseTraceparent } from "./otelHttpTracer";
import { RequestContext } from "./requestContext";
import { cleanPathForCacheKey } from "./urlUtils";
import { type Device, isMobileUA } from "./useDevice";
import { getAppMiddleware } from "./setupApps";
import { isDevMode } from "./env";
/**
* Build-time identifier injected by `decoVitePlugin()` (see
* `src/vite/plugin.js`). Falls back to `undefined` if the consuming site
* isn't using the plugin or the symbol wasn't `define`d at bundle time.
*
* The runtime `env.BUILD_HASH` (when explicitly set, e.g. via
* `wrangler deploy --var BUILD_HASH:foo`) takes precedence — see
* `getBuildHash()` below.
*/
declare const __DECO_BUILD_HASH__: string | undefined;
/**
* The five canonical cache-decision strings stamped on the `X-Cache`
* response header (and on the `decision` label of `cache_*_total`
* metrics). Used by the request-metric label enrichment to keep label
* cardinality bounded — anything else (e.g. an upstream proxy that sets
* its own `X-Cache: random-text`) is dropped from the label.
*/
type CacheDecisionString = "HIT" | "STALE-HIT" | "STALE-ERROR" | "MISS" | "BYPASS";
function isCacheDecision(value: string | null): value is CacheDecisionString {
return (
value === "HIT" ||
value === "STALE-HIT" ||
value === "STALE-ERROR" ||
value === "MISS" ||
value === "BYPASS"
);
}
/**
* Append Link preload headers for CSS and fonts so the browser starts
* fetching them before parsing HTML. Only applied to HTML responses.
*/
function appendResourceHints(resp: Response): void {
const ct = resp.headers.get("content-type");
if (!ct || !ct.includes("text/html")) return;
const { cssHref, fontHrefs } = getRenderShellConfig();
if (cssHref) {
resp.headers.append("Link", `<${cssHref}>; rel=preload; as=style`);
}
for (const href of fontHrefs) {
resp.headers.append("Link", `<${href}>; rel=preload; as=font; crossorigin`);
}
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Minimal ExecutionContext interface compatible with Cloudflare Workers.
* Defined here so deco-start doesn't need @cloudflare/workers-types.
*/
interface WorkerExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
interface ServerEntry {
fetch(
request: Request,
env: Record<string, unknown>,
ctx: WorkerExecutionContext,
): Response | Promise<Response>;
}
/**
* Segment dimensions used to differentiate cache entries.
*
* The workerEntry calls `buildSegment` (if provided) to extract these
* from the request. Two requests with the same SegmentKey share a
* cache entry; different segments get different cached responses.
*/
export interface SegmentKey {
/**
* Device class derived from the request User-Agent.
*
* Accepts the full `Device` union (`"mobile" | "desktop" | "tablet"`) so
* that callers can pass `detectDevice(...)` directly without manual
* narrowing. Sites that want to share cache entries between mobile and
* tablet can collapse the value at the call site (e.g.
* `device === "tablet" ? "mobile" : device`).
*/
device: Device;
/** Whether the user is logged in (e.g., has a valid auth cookie). */
loggedIn?: boolean;
/** Commerce sales channel / price list. */
salesChannel?: string;
/**
* VTEX region ID for regionalized pricing/availability.
* When present, cache entries are segmented per region.
* Sites without regionalization should omit this field
* to avoid unnecessary cache fragmentation.
*/
regionId?: string;
/** Sorted list of active A/B flag names for cache cohort splitting. */
flags?: string[];
}
/**
* Admin route handlers injected by the site's worker-entry.ts.
* Kept as a runtime option so the imports only exist in the SSR entry
* (not pulled into the client Vite build).
*/
export interface AdminHandlers {
handleMeta: (request: Request) => Response;
handleDecofileRead: () => Response;
handleDecofileReload: (request: Request) => Response | Promise<Response>;
handleRender: (request: Request) => Response | Promise<Response>;
corsHeaders: (request: Request) => Record<string, string>;
}
export interface DecoWorkerEntryOptions {
/**
* Admin route handlers (/live/_meta, /.decofile, /live/previews).
* Pass the handlers from `@decocms/start/admin` here.
* If not provided, admin routes are not handled.
*/
admin?: AdminHandlers;
/**
* Override the default cache profile detection.
* Return `null` to fall through to the built-in detector.
*/
detectProfile?: (url: URL) => CacheProfileName | null;
/**
* Whether to create device-specific cache keys (mobile vs desktop).
* Useful when server-rendered HTML differs by device.
* @default true
*/
deviceSpecificKeys?: boolean;
/**
* Build a full segment key from the incoming request.
*
* When provided, the segment key replaces the simple device-only
* cache key with a richer key that differentiates by login state,
* sales channel, and A/B flags.
*
* Logged-in segments (`loggedIn: true`) automatically bypass the
* cache (the response is fetched fresh every time).
*
* @example
* ```ts
* import { extractVtexContext } from "@decocms/apps/vtex/middleware";
*
* createDecoWorkerEntry(serverEntry, {
* buildSegment: (request) => {
* const vtx = extractVtexContext(request);
* return {
* device: /mobile|android|iphone/i.test(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop",
* loggedIn: vtx.isLoggedIn,
* salesChannel: vtx.salesChannel,
* // Include regionId only if the site uses VTEX regionalization.
* // When present, cache entries split by region; omit it for
* // non-regionalized sites to maximize cache sharing.
* regionId: vtx.regionId ?? undefined,
* };
* },
* });
* ```
*/
buildSegment?: (request: Request) => SegmentKey;
/**
* Environment variable name holding the cache purge token.
* Set to `false` to disable the purge endpoint.
* @default "PURGE_TOKEN"
*/
purgeTokenEnv?: string | false;
/**
* Paths that should always bypass the edge cache, even if the
* profile detector would otherwise cache them.
* Defaults include `/_build`, `/deco/`, `/live/`, `/.decofile`.
*/
bypassPaths?: string[];
/**
* Additional paths (beyond the defaults) that should bypass caching.
* Merged with the default bypass paths.
*/
extraBypassPaths?: string[];
/**
* Custom HTML shell for the `/live/previews` iframe page.
* If not provided, a shell is generated from the render config
* (theme, CSS, fonts) set via setRenderShell().
*/
previewShell?: string;
/**
* Regex for detecting fingerprinted static assets (content-hashed filenames).
* Matched paths get `immutable, max-age=31536000`.
* @default /\/_build\/assets\/.*-[a-zA-Z0-9]{8,}\.\w+$/
*/
fingerprintedAssetPattern?: RegExp;
/**
* Whether to strip UTM and tracking params from cache keys.
* Two requests differing only in utm_source, fbclid, etc.
* will share the same cache entry.
* @default true
*/
stripTrackingParams?: boolean;
/**
* Optional proxy handler for commerce backend routes
* (checkout, account, API, login, etc.).
*
* Called early in the request pipeline — after admin routes and cache
* purge, but before static assets and edge cache logic. This ensures
* proxy requests never hit TanStack Start or the React SSR pipeline.
*
* Return a `Response` to proxy the request, or `null` to let the
* normal TanStack Start flow handle it.
*
* @example
* ```ts
* import { shouldProxyToVtex, proxyToVtex } from "@decocms/apps/vtex/utils/proxy";
*
* createDecoWorkerEntry(serverEntry, {
* proxyHandler: (request, url) => {
* if (shouldProxyToVtex(url.pathname)) {
* return proxyToVtex(request);
* }
* return null;
* },
* });
* ```
*/
proxyHandler?: (request: Request, url: URL) => Promise<Response | null> | Response | null;
/**
* Environment variable name holding a build version string.
* The value is appended to every cache key so each deploy gets its own
* cache namespace — old entries become orphaned and expire naturally,
* preventing stale HTML that references old CSS/JS fingerprinted filenames.
*
* Set to `false` to disable. When the env var is missing or empty,
* cache keys remain unversioned (backward-compatible).
*
* @default "BUILD_HASH"
*
* @example
* ```yaml
* # CI: pass git hash to wrangler
* - run: npx wrangler deploy --var BUILD_HASH:$(git rev-parse --short HEAD)
* ```
*/
cacheVersionEnv?: string | false;
/**
* Security headers appended to every SSR response (HTML pages).
* Pass `false` to disable entirely.
*
* Default headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
* Permissions-Policy, X-XSS-Protection, HSTS, Cross-Origin-Opener-Policy.
*
* Custom entries are merged with defaults (custom values take precedence).
*
* @default DEFAULT_SECURITY_HEADERS
*/
securityHeaders?: Record<string, string> | false;
/**
* Content Security Policy directives (report-only by default).
* Pass an array of directive strings which are joined with "; ".
* Pass `false` to omit CSP entirely.
*
* @example
* ```ts
* csp: [
* "default-src 'self'",
* "script-src 'self' 'unsafe-inline' https://www.googletagmanager.com",
* "img-src 'self' data: https:",
* ]
* ```
*/
csp?: string[] | false;
/**
* Automatically inject Cloudflare geo data (country, region, city)
* as internal cookies on every request so location matchers can read
* them from MatcherContext.cookies. The cookies are only visible
* within the Worker — they are never sent to the browser.
*
* @default true
*/
autoInjectGeoCookies?: boolean;
/**
* Cookie names considered "safe" for caching — these are public/anonymous
* cookies that do not carry per-user session or auth data.
*
* When a response contains ONLY safe cookies, it is still eligible for
* Cache API storage. The safe cookies are stripped from the cached copy
* but kept on the response served to the current user.
*
* If the response contains ANY cookie NOT in this list, the response
* bypasses caching entirely (existing behavior).
*
* @default DEFAULT_SAFE_COOKIES (vtex_is_session, vtex_is_anonymous, vtex_segment, _deco_bucket)
*
* @example
* ```ts
* createDecoWorkerEntry(serverEntry, {
* safeCookies: [
* ...DEFAULT_SAFE_COOKIES,
* "my_custom_analytics_cookie",
* ],
* });
* ```
*/
safeCookies?: string[];
/**
* Additional static paths (beyond fingerprinted assets) that should
* receive long-lived immutable cache headers.
*
* Useful for non-fingerprinted resources like fonts that live at
* stable URLs (e.g., `/fonts/Lato-Regular.woff2`).
*
* @default ["/fonts/"]
*
* @example
* ```ts
* createDecoWorkerEntry(serverEntry, {
* staticPaths: ["/fonts/", "/static/", "/images/icons/"],
* });
* ```
*/
staticPaths?: string[];
/**
* CDN-Cache-Control header strategy.
*
* - `"no-store"` (default): CDN never caches; every request invokes the Worker.
* Correct when segment-based cache keys differ from the original URL.
* - `"match-profile"`: Set CDN-Cache-Control to a short TTL matching the
* profile's edge.fresh value. Only safe when you are NOT using segment-based
* cache keys (i.e., no `buildSegment` and `deviceSpecificKeys: false`).
* - A function: Return a CDN-Cache-Control value per profile, or `null` for no-store.
*
* @default "no-store"
*/
cdnCacheControl?: "no-store" | "match-profile" | ((profile: CacheProfileName) => string | null);
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PREVIEW_SHELL_SCRIPT = `(function() {
if (window.__DECO_LIVE_CONTROLS__) return;
window.__DECO_LIVE_CONTROLS__ = true;
addEventListener("message", function(event) {
var data = event.data;
if (!data || typeof data !== "object") return;
switch (data.type) {
case "editor::inject":
if (data.args && data.args.script) {
try { eval(data.args.script); } catch(e) { console.error("[deco] inject error:", e); }
}
break;
}
});
})();`;
function buildPreviewShell(): string {
return buildHtmlShell({ script: PREVIEW_SHELL_SCRIPT });
}
// ---------------------------------------------------------------------------
// Cloudflare geo cookie injection
// ---------------------------------------------------------------------------
/**
* Inject Cloudflare geo data as cookies so matchers (location.ts) can
* read them from MatcherContext.cookies without relying on request.cf.
*
* Call this on the incoming request before passing it to the worker entry.
* Only needed in production Cloudflare Workers where `request.cf` is populated.
*
* @example
* ```ts
* export default {
* async fetch(request, env, ctx) {
* return handler.fetch(injectGeoCookies(request), env, ctx);
* }
* };
* ```
*/
export function injectGeoCookies(request: Request): Request {
const cf = (request as unknown as { cf?: Record<string, string> }).cf;
if (!cf) return request;
const parts: string[] = [];
if (cf.region) parts.push(`__cf_geo_region=${encodeURIComponent(cf.region)}`);
if (cf.country) parts.push(`__cf_geo_country=${encodeURIComponent(cf.country)}`);
if (cf.city) parts.push(`__cf_geo_city=${encodeURIComponent(cf.city)}`);
if (cf.latitude) parts.push(`__cf_geo_lat=${encodeURIComponent(cf.latitude)}`);
if (cf.longitude) parts.push(`__cf_geo_lng=${encodeURIComponent(cf.longitude)}`);
if (cf.regionCode) parts.push(`__cf_geo_region_code=${encodeURIComponent(cf.regionCode)}`);
if (!parts.length) return request;
const existing = request.headers.get("cookie") ?? "";
const combined = existing ? `${existing}; ${parts.join("; ")}` : parts.join("; ");
// Strip CF geo headers that carry non-ASCII values (cf-region: "São Paulo",
// cf-ipcity: "Brasília", etc.) before building the new Request. The geo
// data is preserved in the __cf_geo_* cookies we just built, so callers
// downstream lose no information.
//
// Without this strip, the Workers runtime emits a warning on every
// request because the new Request inherits these UTF-8 headers from the
// inbound request:
//
// "A header value for "cf-region" contains non-ASCII characters: "..."
//
// and the warning is logged once per non-ASCII header — for a Brazilian
// storefront with cities/states full of accents that means ~2 warns per
// request × every request that hits the worker.
const headers = new Headers();
for (const [key, value] of request.headers.entries()) {
const lk = key.toLowerCase();
if (lk === "cf-region" || lk === "cf-ipcity") continue;
headers.set(key, value);
}
headers.set("cookie", combined);
// Mirror the ASCII-safe geo fields from request.cf into headers so matchers
// that read `request.headers.get("cf-region-code")` (parity with the
// upstream deco-cx/apps location matcher) still work even if the inbound
// request didn't carry them. Non-ASCII fields (region name, city) stay in
// the cookies above — putting them in headers would re-trigger the
// non-ASCII warning we strip on the loop above.
if (cf.country && !headers.has("cf-ipcountry")) headers.set("cf-ipcountry", cf.country);
if (cf.regionCode && !headers.has("cf-region-code")) headers.set("cf-region-code", cf.regionCode);
if (cf.latitude && !headers.has("cf-iplatitude")) headers.set("cf-iplatitude", cf.latitude);
if (cf.longitude && !headers.has("cf-iplongitude")) headers.set("cf-iplongitude", cf.longitude);
return new Request(request, { headers });
}
const ONE_YEAR = 31536000;
/**
* Sensible security headers for any production storefront.
* CSP is intentionally not included — it's site-specific (third-party script domains).
*/
export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "SAMEORIGIN",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"X-XSS-Protection": "1; mode=block",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
"Cross-Origin-Opener-Policy": "same-origin-allow-popups",
};
const DEFAULT_BYPASS_PATHS = ["/_build", "/deco/", "/live/", "/.decofile"];
/**
* Cookie names that are safe for caching — they carry anonymous/public
* segment data, not per-user auth tokens.
*
* VTEX Intelligent Search sets `vtex_is_session` and `vtex_is_anonymous`
* on every response. `vtex_segment` encodes the sales channel.
* `_deco_bucket` is the A/B test cohort cookie.
*/
export const DEFAULT_SAFE_COOKIES: string[] = [
"vtex_is_session",
"vtex_is_anonymous",
"vtex_segment",
"_deco_bucket",
];
const DEFAULT_STATIC_PATHS = ["/fonts/"];
/**
* Parse Set-Cookie header values and return cookie names.
*/
function parseCookieNames(response: Response): string[] {
const names: string[] = [];
// getSetCookie() returns individual Set-Cookie values (available in Workers runtime)
const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
if (setCookies) {
for (const sc of setCookies) {
const eqIdx = sc.indexOf("=");
if (eqIdx > 0) names.push(sc.slice(0, eqIdx).trim());
}
} else {
// Fallback: parse from combined header (less reliable but covers edge cases)
const combined = response.headers.get("set-cookie") ?? "";
for (const part of combined.split(",")) {
const eqIdx = part.indexOf("=");
if (eqIdx > 0) {
const name = part.slice(0, eqIdx).trim();
// Skip attributes like "Expires=..." that appear after semicolons
if (!name.includes(";") && name.length > 0) names.push(name);
}
}
}
return names;
}
/**
* Check if ALL cookies in a response are in the safe list.
* Returns true if the response has no cookies or only safe cookies.
*/
function hasOnlySafeCookies(response: Response, safeCookieSet: Set<string>): boolean {
if (!response.headers.has("set-cookie")) return true;
const names = parseCookieNames(response);
if (names.length === 0) return true;
return names.every((name) => safeCookieSet.has(name));
}
/**
* Clone a response, stripping Set-Cookie headers that match the safe list.
* Uses response.clone() to preserve the original body for the served response.
* The returned copy is intended for cache storage only.
*/
function stripSafeCookiesForCache(response: Response, safeCookieSet: Set<string>): Response {
const clone = response.clone();
const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
if (!setCookies || setCookies.length === 0) return clone;
// Remove all Set-Cookie headers, then re-add only unsafe ones
clone.headers.delete("set-cookie");
for (const sc of setCookies) {
const eqIdx = sc.indexOf("=");
const name = eqIdx > 0 ? sc.slice(0, eqIdx).trim() : "";
if (name && !safeCookieSet.has(name)) {
clone.headers.append("set-cookie", sc);
}
}
return clone;
}
/**
* Deduplicate Set-Cookie headers — keep only the LAST occurrence of
* each cookie name. Multiple layers (VTEX middleware, invoke handlers,
* etc.) may independently append the same cookie.
*/
function deduplicateSetCookies(response: Response): void {
const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
if (!setCookies || setCookies.length <= 1) return;
// Build map: cookie name → last Set-Cookie value
const seen = new Map<string, string>();
for (const sc of setCookies) {
const eqIdx = sc.indexOf("=");
const name = eqIdx > 0 ? sc.slice(0, eqIdx).trim() : sc;
seen.set(name, sc);
}
// If no duplicates, nothing to do
if (seen.size === setCookies.length) return;
response.headers.delete("set-cookie");
for (const sc of seen.values()) {
response.headers.append("set-cookie", sc);
}
}
const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+$/;
const IMMUTABLE_HEADERS: Record<string, string> = {
"Cache-Control": `public, max-age=${ONE_YEAR}, immutable`,
Vary: "Accept-Encoding",
};
/** SHA-256 hex hash of a string — used for POST body cache keys. */
async function hashText(text: string): Promise<string> {
const data = new TextEncoder().encode(text);
const buf = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Creates a Cloudflare Worker fetch handler that wraps a TanStack Start
* server entry with intelligent edge caching.
*/
export function createDecoWorkerEntry(
serverEntry: ServerEntry,
options: DecoWorkerEntryOptions = {},
): {
fetch(
request: Request,
env: Record<string, unknown>,
ctx: WorkerExecutionContext,
): Promise<Response>;
} {
const {
admin,
detectProfile: customDetect,
deviceSpecificKeys = true,
buildSegment: rawBuildSegment,
purgeTokenEnv = "PURGE_TOKEN",
bypassPaths,
extraBypassPaths = [],
fingerprintedAssetPattern = FINGERPRINTED_ASSET_RE,
stripTrackingParams: shouldStripTracking = true,
previewShell: customPreviewShell,
cacheVersionEnv = "BUILD_HASH",
securityHeaders: securityHeadersOpt,
csp: cspOpt,
autoInjectGeoCookies: geoOpt = true,
safeCookies: safeCookiesOpt = DEFAULT_SAFE_COOKIES,
staticPaths: staticPathsOpt = DEFAULT_STATIC_PATHS,
cdnCacheControl: cdnCacheControlOpt = "no-store",
} = options;
// Backfill `regionId` from Cloudflare geo when the consumer's buildSegment
// doesn't set one. Without this, sites using website/matchers/location.ts
// get a single cached response per device that leaks across regions: the
// first visitor's resolved variant gets served to everyone. With this,
// existing sites get region-segmented cache "for free" on bump — no
// worker-entry.ts edit required.
function readRegionFromRequest(request: Request): string | undefined {
const fromHeader = request.headers.get("cf-region-code");
if (fromHeader) return fromHeader;
const cf = (request as unknown as { cf?: { regionCode?: string } }).cf;
return cf?.regionCode || undefined;
}
const buildSegment = rawBuildSegment
? (request: Request): SegmentKey => {
const seg = rawBuildSegment(request);
if (seg.regionId) return seg;
const region = readRegionFromRequest(request);
return region ? { ...seg, regionId: region } : seg;
}
: undefined;
const safeCookieSet = new Set(safeCookiesOpt);
// Build the final security headers map (merged defaults + custom + CSP)
const secHeaders: Record<string, string> | null = (() => {
if (securityHeadersOpt === false) return null;
const base = { ...DEFAULT_SECURITY_HEADERS };
if (securityHeadersOpt) {
for (const [k, v] of Object.entries(securityHeadersOpt)) base[k] = v;
}
if (cspOpt && cspOpt.length > 0) {
base["Content-Security-Policy-Report-Only"] = cspOpt.join("; ");
}
return base;
})();
function applySecurityHeaders(resp: Response): Response {
if (!secHeaders) return resp;
const ct = resp.headers.get("content-type") ?? "";
if (!ct.includes("text/html")) return resp;
const out = new Response(resp.body, resp);
for (const [k, v] of Object.entries(secHeaders)) {
if (!out.headers.has(k)) out.headers.set(k, v);
}
return out;
}
const allBypassPaths = [...(bypassPaths ?? DEFAULT_BYPASS_PATHS), ...extraBypassPaths];
// -- Helpers ----------------------------------------------------------------
function isBypassPath(pathname: string): boolean {
return allBypassPaths.some((bp) => pathname.startsWith(bp));
}
function isStaticAsset(pathname: string): boolean {
if (fingerprintedAssetPattern.test(pathname)) return true;
// Non-fingerprinted static paths (e.g., /fonts/)
return staticPathsOpt.some((sp) => pathname.startsWith(sp));
}
function isCacheable(request: Request, url: URL): boolean {
if (request.method !== "GET") return false;
if (isBypassPath(url.pathname)) return false;
if (url.searchParams.has("__deco_draft")) return false;
if (url.searchParams.has("__deco_preview")) return false;
if (url.searchParams.has("pathTemplate")) return false;
return true;
}
function getProfile(url: URL): CacheProfileName {
if (customDetect) {
const custom = customDetect(url);
if (custom !== null) return custom;
}
return detectCacheProfile(url);
}
function hashSegment(seg: SegmentKey): string {
const parts: string[] = [seg.device];
if (seg.loggedIn) parts.push("auth");
if (seg.salesChannel) parts.push(`sc=${seg.salesChannel}`);
if (seg.regionId) parts.push(`r=${seg.regionId}`);
if (seg.flags?.length) parts.push(`f=${seg.flags.sort().join(",")}`);
return parts.join("|");
}
/**
* Resolve the per-deploy cache-key version with this priority:
* 1. `env[cacheVersionEnv]` — explicit override (e.g. `wrangler
* deploy --var BUILD_HASH:foo`). Wins so callers can always
* force a specific value.
* 2. `__DECO_BUILD_HASH__` — build-time constant injected by
* `decoVitePlugin()` from WORKERS_CI_COMMIT_SHA / git rev-parse.
* This is the production path on Cloudflare Workers Builds.
* 3. Empty string — versioning disabled (legacy pre-plugin sites).
*/
function getBuildHash(env: Record<string, unknown>): string {
if (cacheVersionEnv === false) return "";
const fromEnv = (env[cacheVersionEnv] as string) || "";
if (fromEnv) return fromEnv;
return typeof __DECO_BUILD_HASH__ !== "undefined" ? __DECO_BUILD_HASH__ : "";
}
function buildCacheKey(
request: Request,
env: Record<string, unknown>,
): { key: Request; segment?: SegmentKey } {
const url = new URL(request.url);
if (shouldStripTracking) {
const cleanPath = cleanPathForCacheKey(url.toString());
const cleanUrl = new URL(cleanPath, url.origin);
url.search = cleanUrl.search;
}
const version = getBuildHash(env);
if (version) {
url.searchParams.set("__v", version);
}
// Include CF geo data in cache key so location matcher results don't leak
// across different geos. Applies to both segment and device-based keys.
const cf = (request as unknown as { cf?: Record<string, string> }).cf;
if (cf) {
const geoParts: string[] = [];
if (cf.country) geoParts.push(cf.country);
if (cf.region) geoParts.push(cf.region);
if (cf.city) geoParts.push(cf.city);
if (geoParts.length) {
url.searchParams.set("__cf_geo", geoParts.join("|"));
}
}
if (buildSegment) {
const segment = buildSegment(request);
url.searchParams.set("__seg", hashSegment(segment));
return { key: new Request(url.toString(), { method: "GET" }), segment };
}
if (deviceSpecificKeys) {
const device = isMobileUA(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
url.searchParams.set("__cf_device", device);
}
return { key: new Request(url.toString(), { method: "GET" }) };
}
// -- Purge handler ----------------------------------------------------------
interface PurgeRequestBody {
paths?: string[];
countries?: string[];
/** Sales channels to include in segment combos. Defaults to ["1"]. */
salesChannels?: string[];
/** Region IDs to include in segment combos. Each ID generates additional entries. */
regionIds?: string[];
}
function buildPurgeSegments(body: PurgeRequestBody): SegmentKey[] {
const devices: Array<"mobile" | "desktop"> = ["mobile", "desktop"];
const channels = body.salesChannels ?? ["1"];
const regions: Array<string | undefined> = [undefined, ...(body.regionIds ?? [])];
const segments: SegmentKey[] = [];
for (const device of devices) {
for (const salesChannel of channels) {
for (const regionId of regions) {
segments.push({ device, salesChannel, regionId });
}
}
segments.push({ device });
}
return segments;
}
async function handlePurge(request: Request, env: Record<string, unknown>): Promise<Response> {
if (purgeTokenEnv === false) {
return new Response("Purge disabled", { status: 404 });
}
const token = (env[purgeTokenEnv] as string) || "";
if (!token || request.headers.get("Authorization") !== `Bearer ${token}`) {
return new Response("Unauthorized", { status: 401 });
}
let body: PurgeRequestBody;
try {
body = await request.json();
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
const paths = body.paths;
if (!Array.isArray(paths) || paths.length === 0) {
return new Response('Body must include "paths": ["/", "/page"]', { status: 400 });
}
const geoVariants = body.countries ?? [];
const cache = isDevMode()
? null
: typeof caches !== "undefined"
? ((caches as unknown as { default?: Cache }).default ?? null)
: null;
if (!cache) {
return Response.json({ purged: [], total: 0, note: "Cache API unavailable" });
}
const baseUrl = new URL(request.url).origin;
const purged: string[] = [];
const geoKeys: (string | null)[] = [null, ...geoVariants];
for (const p of paths) {
if (buildSegment) {
const segments = buildPurgeSegments(body);
for (const seg of segments) {
for (const cc of geoKeys) {
const url = new URL(p, baseUrl);
const purgeVersion = getBuildHash(env);
if (purgeVersion) url.searchParams.set("__v", purgeVersion);
url.searchParams.set("__seg", hashSegment(seg));
if (cc) url.searchParams.set("__cf_geo", cc);
const key = new Request(url.toString(), { method: "GET" });
try {
if (await cache.delete(key)) {
const label = cc
? `${p} (${hashSegment(seg)}, ${cc})`
: `${p} (${hashSegment(seg)})`;
purged.push(label);
}
} catch {
/* ignore */
}
}
}
} else {
const devices = deviceSpecificKeys ? (["mobile", "desktop"] as const) : ([null] as const);
for (const device of devices) {
for (const cc of geoKeys) {
const url = new URL(p, baseUrl);
const purgeVersion = getBuildHash(env);
if (purgeVersion) url.searchParams.set("__v", purgeVersion);
if (device) url.searchParams.set("__cf_device", device);
if (cc) url.searchParams.set("__cf_geo", cc);
const key = new Request(url.toString(), { method: "GET" });
try {
if (await cache.delete(key)) {
const parts = [device, cc].filter(Boolean).join(", ");
purged.push(parts ? `${p} (${parts})` : p);
}
} catch {
/* ignore */
}
}
}
}
}
return Response.json({ purged, total: purged.length });
}
// -- Admin route handler ---------------------------------------------------
const ADMIN_NO_CACHE: Record<string, string> = {
"Cache-Control": "no-store, no-cache, must-revalidate",
"CDN-Cache-Control": "no-store",
"Surrogate-Control": "no-store",
};
function addCors(response: Response, request: Request): Response {
if (!admin) return response;
const cors = admin.corsHeaders(request);
const resp = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: new Headers(response.headers),
});
for (const [k, v] of Object.entries({ ...cors, ...ADMIN_NO_CACHE })) {
resp.headers.set(k, v);
}
return resp;
}
async function tryAdminRoute(request: Request): Promise<Response | null> {
if (!admin) return null;
const url = new URL(request.url);
const { pathname } = url;
const method = request.method;
if (pathname === "/live/_meta") {
if (method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: { ...admin.corsHeaders(request), ...ADMIN_NO_CACHE },
});
}
const resp = await withTracing("deco.admin.meta", async () => admin.handleMeta(request));
return addCors(resp, request);
}
if (pathname === "/.decofile") {
if (method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: { ...admin.corsHeaders(request), ...ADMIN_NO_CACHE },
});
}
if (method === "POST") {
const resp = await withTracing("deco.admin.decofile.reload", () =>
Promise.resolve(admin.handleDecofileReload(request)),
);
return addCors(resp, request);
}
const resp = await withTracing("deco.admin.decofile.read", async () =>