@decocms/blocks 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.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,499 @@
1
+ /**
2
+ * A/B Testing wrapper for Cloudflare Worker entries.
3
+ *
4
+ * Provides KV-driven traffic splitting between the current TanStack Start
5
+ * worker ("worker" bucket) and a fallback origin ("fallback" bucket, e.g.
6
+ * legacy Deco/Fresh site). Designed for migration-period A/B testing.
7
+ *
8
+ * Features:
9
+ * - FNV-1a IP hashing for stable, deterministic bucket assignment
10
+ * - Sticky bucketing via cookie ("bucket:ratioPct" format) — the cookie carries
11
+ * a fingerprint of the KV ratio at the time of assignment. When the ratio
12
+ * changes in KV, all cookies with the old fingerprint are re-evaluated
13
+ * against the new threshold, so the distribution rebalances in one visit
14
+ * per user; once stable, cookies converge and become sticky again.
15
+ * - Query param override for QA (?_deco_bucket=worker)
16
+ * - Circuit breaker: worker errors auto-fallback to legacy origin
17
+ * - Fallback proxy with hostname rewriting (Set-Cookie, Location, body)
18
+ * - Configurable bypass for paths that must always use the worker
19
+ * (e.g. VTEX checkout proxy paths)
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
24
+ * import { withABTesting } from "@decocms/start/sdk/abTesting";
25
+ *
26
+ * const decoWorker = createDecoWorkerEntry(serverEntry, { ... });
27
+ *
28
+ * export default withABTesting(decoWorker, {
29
+ * kvBinding: "SITES_KV",
30
+ * shouldBypassAB: (request, url) => isVtexPath(url.pathname),
31
+ * });
32
+ * ```
33
+ */
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ interface WorkerExecutionContext {
40
+ waitUntil(promise: Promise<unknown>): void;
41
+ passThroughOnException(): void;
42
+ }
43
+
44
+ export interface WorkerHandler {
45
+ fetch(
46
+ request: Request,
47
+ env: Record<string, unknown>,
48
+ ctx: WorkerExecutionContext,
49
+ ): Promise<Response>;
50
+ }
51
+
52
+ interface KVNamespace {
53
+ get<T = string>(key: string, type: "json"): Promise<T | null>;
54
+ get(key: string, type?: "text"): Promise<string | null>;
55
+ }
56
+
57
+ /** KV value shape — same as the original cf-gateway config. */
58
+ export interface SiteConfig {
59
+ workerName: string;
60
+ fallbackOrigin: string;
61
+ abTest?: { ratio: number };
62
+ }
63
+
64
+ export type Bucket = "worker" | "fallback";
65
+
66
+ export interface ABTestConfig {
67
+ /** KV namespace binding name. @default "SITES_KV" */
68
+ kvBinding?: string;
69
+
70
+ /** Cookie name for bucket persistence. @default "_deco_bucket" */
71
+ cookieName?: string;
72
+
73
+ /**
74
+ * Cookie max-age in seconds. @default 31536000 (1 year)
75
+ *
76
+ * Long by design: the cookie's ratio fingerprint handles invalidation
77
+ * (a change in KV ratio implicitly invalidates all old cookies), so
78
+ * there's no need for the browser to expire the cookie to pick up
79
+ * ratio changes. Shorter values just cause more user-bucket churn
80
+ * across sessions without any rebalancing benefit.
81
+ */
82
+ cookieMaxAge?: number;
83
+
84
+ /** Auto-fallback to legacy on worker errors. @default true */
85
+ circuitBreaker?: boolean;
86
+
87
+ /**
88
+ * Return `true` to bypass A/B for this request — always serve from
89
+ * the worker regardless of bucket assignment.
90
+ *
91
+ * Useful for commerce backend paths (checkout, /api/*) that must
92
+ * not be proxied through the fallback origin.
93
+ */
94
+ shouldBypassAB?: (request: Request, url: URL) => boolean;
95
+
96
+ /**
97
+ * Called before the A/B logic runs. Return a `Response` to short-circuit
98
+ * (e.g. for CMS redirects), or `null` to continue with A/B.
99
+ */
100
+ preHandler?: (
101
+ request: Request,
102
+ url: URL,
103
+ ) => Response | Promise<Response | null> | null;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // FNV-1a 32-bit — fast, good distribution for short strings like IPs.
108
+ // Same hash used by the original cf-gateway.
109
+ // ---------------------------------------------------------------------------
110
+
111
+ function fnv1a(str: string): number {
112
+ let hash = 0x811c9dc5;
113
+ for (let i = 0; i < str.length; i++) {
114
+ hash ^= str.charCodeAt(i);
115
+ hash = Math.imul(hash, 0x01000193);
116
+ }
117
+ return Math.abs(hash);
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Header constants
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Hop-by-hop headers per RFC 7230 §6.1 — must not be forwarded through
126
+ * proxies. Plus `host`, which we always rewrite to the target origin.
127
+ */
128
+ const HOP_BY_HOP_HEADERS = new Set([
129
+ "connection",
130
+ "keep-alive",
131
+ "transfer-encoding",
132
+ "te",
133
+ "trailer",
134
+ "upgrade",
135
+ "proxy-authorization",
136
+ "proxy-authenticate",
137
+ ]);
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Cookie helpers
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function parseCookies(header: string): Record<string, string> {
144
+ return Object.fromEntries(
145
+ header.split(";").map((c) => {
146
+ const [k, ...v] = c.trim().split("=");
147
+ return [k, v.join("=")];
148
+ }),
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Convert a 0–1 ratio to a 0–100 integer percentage. Anchors the cookie
154
+ * fingerprint to the resolution at which bucketing decisions are actually
155
+ * made (`hash % 100 < ratio * 100`), so two equivalent ratios like 0.5 and
156
+ * 0.500001 produce the same fingerprint and don't invalidate cookies.
157
+ */
158
+ const RATIO_PCT_MAX = 100;
159
+
160
+ function ratioToPct(ratio: number): number {
161
+ if (!Number.isFinite(ratio) || ratio <= 0) return 0;
162
+ if (ratio >= 1) return RATIO_PCT_MAX;
163
+ return Math.round(ratio * RATIO_PCT_MAX);
164
+ }
165
+
166
+ /**
167
+ * Parse the bucket cookie value.
168
+ *
169
+ * Format: "worker:50" (bucket + ratioPct fingerprint, 0–100). The fingerprint
170
+ * is the KV ratio at the time the cookie was set. Callers must compare it
171
+ * against the current KV ratio and only honor the cookie when they match.
172
+ *
173
+ * Legacy formats ("worker:1711540800" with unix timestamp, or bare
174
+ * "worker"/"fallback") parse to `null` so they're treated as missing and
175
+ * re-evaluated against the current ratio. Those cookies vanish naturally
176
+ * because the legacy default Max-Age was 1 day.
177
+ */
178
+ function parseBucketCookie(
179
+ raw: string | undefined,
180
+ ): { bucket: Bucket; ratioPct: number } | null {
181
+ if (!raw) return null;
182
+
183
+ const colonIdx = raw.indexOf(":");
184
+ if (colonIdx <= 0) return null;
185
+
186
+ const bucket = raw.slice(0, colonIdx);
187
+ if (bucket !== "worker" && bucket !== "fallback") return null;
188
+
189
+ const ratioPct = parseInt(raw.slice(colonIdx + 1), 10);
190
+ if (isNaN(ratioPct) || ratioPct < 0 || ratioPct > RATIO_PCT_MAX) return null;
191
+
192
+ return { bucket, ratioPct };
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Public helpers (exported for custom composition)
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /**
200
+ * Deterministically assign a bucket based on:
201
+ * 1. Query param override (?_deco_bucket=worker)
202
+ * 2. Existing cookie whose ratio fingerprint matches the current KV ratio
203
+ * 3. IP hash against ratio threshold
204
+ *
205
+ * Step 2 is the key correctness property: a cookie set when the KV ratio
206
+ * was X is only trusted while the KV ratio is still X. When the operator
207
+ * changes the ratio in KV, all cookies with the old fingerprint fall
208
+ * through to step 3 and are re-evaluated against the new threshold — the
209
+ * fnv1a IP hash being deterministic means each user converges to the new
210
+ * distribution in a single visit, then the cookie is re-issued with the
211
+ * new fingerprint and becomes sticky again.
212
+ */
213
+ export function getStableBucket(
214
+ request: Request,
215
+ ratio: number,
216
+ url: URL,
217
+ cookieName: string = "_deco_bucket",
218
+ ): Bucket {
219
+ const override = url.searchParams.get(cookieName);
220
+ if (override === "worker" || override === "fallback") return override;
221
+
222
+ const currentRatioPct = ratioToPct(ratio);
223
+ const cookies = parseCookies(request.headers.get("cookie") ?? "");
224
+ const parsed = parseBucketCookie(cookies[cookieName]);
225
+ if (parsed && parsed.ratioPct === currentRatioPct) return parsed.bucket;
226
+
227
+ if (currentRatioPct <= 0) return "fallback";
228
+ if (currentRatioPct >= RATIO_PCT_MAX) return "worker";
229
+
230
+ const ip =
231
+ request.headers.get("cf-connecting-ip") ?? Math.random().toString();
232
+ return fnv1a(ip) % RATIO_PCT_MAX < currentRatioPct ? "worker" : "fallback";
233
+ }
234
+
235
+ /**
236
+ * Tag a response with the bucket assignment and refresh the sticky cookie
237
+ * when the current bucket/ratio differs from what the cookie carries.
238
+ *
239
+ * The cookie value is `<bucket>:<ratioPct>` — see `parseBucketCookie`. The
240
+ * fingerprint is what makes ratio changes in KV propagate automatically:
241
+ * when the operator bumps the ratio, this function rewrites the cookie on
242
+ * the next response so the user starts honoring the new threshold.
243
+ */
244
+ export function tagBucket(
245
+ response: Response,
246
+ bucket: Bucket,
247
+ hostname: string,
248
+ request: Request,
249
+ ratio: number,
250
+ cookieName: string = "_deco_bucket",
251
+ maxAge: number = 60 * 60 * 24 * 365,
252
+ ): Response {
253
+ const res = new Response(response.body, response);
254
+ res.headers.set("x-deco-bucket", bucket);
255
+
256
+ const currentRatioPct = ratioToPct(ratio);
257
+ const cookies = parseCookies(request.headers.get("cookie") ?? "");
258
+ const parsed = parseBucketCookie(cookies[cookieName]);
259
+
260
+ const needsSet =
261
+ !parsed ||
262
+ parsed.bucket !== bucket ||
263
+ parsed.ratioPct !== currentRatioPct;
264
+
265
+ if (needsSet) {
266
+ res.headers.append(
267
+ "set-cookie",
268
+ `${cookieName}=${bucket}:${currentRatioPct}; Path=/; Max-Age=${maxAge}; Domain=${hostname}; SameSite=Lax`,
269
+ );
270
+ }
271
+
272
+ return res;
273
+ }
274
+
275
+ /**
276
+ * Proxy a request to the fallback origin with full hostname rewriting.
277
+ *
278
+ * Rewrites:
279
+ * 1. URL hostname → fallback origin
280
+ * 2. Set-Cookie Domain → real hostname
281
+ * 3. Body text: fallback hostname → real hostname (for Fresh partial URLs)
282
+ * 4. Location header → real hostname
283
+ *
284
+ * **`redirect: "manual"` is critical.** The request body is forwarded as a
285
+ * stream (`duplex: "half"`) and is consumed by this first fetch. If the
286
+ * upstream returns a 301/302 and we let CF auto-follow, the runtime would
287
+ * try to replay the request and throw `Cannot reconstruct a Request with
288
+ * a used body.` Instead we forward the 3xx response to the client so the
289
+ * client (browser/curl) follows it on its own. The Location header is
290
+ * rewritten below so the next hop targets the real hostname.
291
+ */
292
+ export async function proxyToFallback(
293
+ request: Request,
294
+ url: URL,
295
+ fallbackOrigin: string,
296
+ ): Promise<Response> {
297
+ const target = new URL(url.toString());
298
+ target.hostname = fallbackOrigin;
299
+
300
+ // Strip hop-by-hop headers + Host before forwarding. Without this the
301
+ // upstream may close the connection (Connection: close) or get confused
302
+ // by Transfer-Encoding/Upgrade meant for the original CF↔client hop.
303
+ const headers = new Headers();
304
+ for (const [key, value] of request.headers.entries()) {
305
+ const lk = key.toLowerCase();
306
+ if (lk === "host") continue;
307
+ if (HOP_BY_HOP_HEADERS.has(lk)) continue;
308
+ headers.set(key, value);
309
+ }
310
+ headers.set("x-forwarded-host", url.hostname);
311
+
312
+ const init: RequestInit = {
313
+ method: request.method,
314
+ headers,
315
+ redirect: "manual",
316
+ };
317
+ if (request.method !== "GET" && request.method !== "HEAD") {
318
+ init.body = request.body;
319
+ // @ts-expect-error -- needed for streaming body in Workers
320
+ init.duplex = "half";
321
+ }
322
+ const response = await fetch(target.toString(), init);
323
+
324
+ const ct = response.headers.get("content-type") ?? "";
325
+ const isText =
326
+ ct.includes("text/") || ct.includes("json") || ct.includes("javascript");
327
+
328
+ // Only rewrite text bodies on 2xx successful responses. Reading
329
+ // `response.text()` on a 3xx (forwarded redirect) or binary response
330
+ // would consume the stream needlessly and could throw on non-text
331
+ // content. The Location header is rewritten separately further down.
332
+ let body: BodyInit | null = response.body;
333
+ if (
334
+ isText &&
335
+ response.body &&
336
+ response.status >= 200 &&
337
+ response.status < 300
338
+ ) {
339
+ const text = await response.text();
340
+ body = text.replaceAll(fallbackOrigin, url.hostname);
341
+ }
342
+
343
+ const rewritten = new Response(body, response);
344
+
345
+ const setCookies = response.headers.getSetCookie?.() ?? [];
346
+ if (setCookies.length > 0) {
347
+ rewritten.headers.delete("set-cookie");
348
+ for (const cookie of setCookies) {
349
+ rewritten.headers.append(
350
+ "set-cookie",
351
+ cookie.replace(
352
+ new RegExp(
353
+ `Domain=\\.?${fallbackOrigin.replace(/\./g, "\\.")}`,
354
+ "gi",
355
+ ),
356
+ `Domain=.${url.hostname}`,
357
+ ),
358
+ );
359
+ }
360
+ }
361
+
362
+ const location = response.headers.get("location");
363
+ if (location?.includes(fallbackOrigin)) {
364
+ rewritten.headers.set(
365
+ "location",
366
+ location.replaceAll(fallbackOrigin, url.hostname),
367
+ );
368
+ }
369
+
370
+ return rewritten;
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Main wrapper
375
+ // ---------------------------------------------------------------------------
376
+
377
+ /**
378
+ * Wrap a Deco worker entry with A/B testing support.
379
+ *
380
+ * Reads config from Cloudflare KV by hostname, assigns buckets,
381
+ * and proxies fallback traffic to the legacy origin.
382
+ *
383
+ * When no KV binding is available or no config exists for the hostname,
384
+ * all traffic goes directly to the inner handler (no A/B).
385
+ */
386
+ export function withABTesting(
387
+ handler: WorkerHandler,
388
+ config: ABTestConfig = {},
389
+ ): WorkerHandler {
390
+ const {
391
+ kvBinding = "SITES_KV",
392
+ cookieName = "_deco_bucket",
393
+ cookieMaxAge = 60 * 60 * 24 * 365,
394
+ circuitBreaker = true,
395
+ shouldBypassAB,
396
+ preHandler,
397
+ } = config;
398
+
399
+ return {
400
+ async fetch(
401
+ request: Request,
402
+ env: Record<string, unknown>,
403
+ ctx: WorkerExecutionContext,
404
+ ): Promise<Response> {
405
+ const url = new URL(request.url);
406
+
407
+ // Pre-handler (e.g. CMS redirects) — runs before A/B
408
+ if (preHandler) {
409
+ const pre = await preHandler(request, url);
410
+ if (pre) return pre;
411
+ }
412
+
413
+ const kv = env[kvBinding] as KVNamespace | undefined;
414
+ if (!kv) {
415
+ return handler.fetch(request, env, ctx);
416
+ }
417
+
418
+ const siteConfig = await kv.get<SiteConfig>(url.hostname, "json");
419
+ if (!siteConfig?.fallbackOrigin) {
420
+ return handler.fetch(request, env, ctx);
421
+ }
422
+
423
+ // Bypass A/B for certain paths (e.g. checkout, API)
424
+ if (shouldBypassAB?.(request, url)) {
425
+ return handler.fetch(request, env, ctx);
426
+ }
427
+
428
+ const ratio = siteConfig.abTest?.ratio ?? 0;
429
+ const bucket = getStableBucket(request, ratio, url, cookieName);
430
+
431
+ // Tee the request so the outer `catch` below can still pass the
432
+ // original (with body intact) to `handler.fetch` if proxyToFallback
433
+ // somehow throws after consuming the body. `Request.clone()` is
434
+ // safe in CF Workers — it tees the underlying stream.
435
+ const fallbackRequest = request.clone();
436
+
437
+ try {
438
+ if (bucket === "fallback") {
439
+ const response = await proxyToFallback(
440
+ fallbackRequest,
441
+ url,
442
+ siteConfig.fallbackOrigin,
443
+ );
444
+ return tagBucket(
445
+ response,
446
+ bucket,
447
+ url.hostname,
448
+ request,
449
+ ratio,
450
+ cookieName,
451
+ cookieMaxAge,
452
+ );
453
+ }
454
+
455
+ // Worker bucket
456
+ try {
457
+ const response = await handler.fetch(request, env, ctx);
458
+ return tagBucket(
459
+ response,
460
+ bucket,
461
+ url.hostname,
462
+ request,
463
+ ratio,
464
+ cookieName,
465
+ cookieMaxAge,
466
+ );
467
+ } catch (err) {
468
+ if (!circuitBreaker) throw err;
469
+ console.error(
470
+ "[A/B] Worker error, circuit breaker → fallback:",
471
+ err,
472
+ );
473
+ // Use the teed clone — handler.fetch above may have already
474
+ // consumed the original request body before throwing.
475
+ const response = await proxyToFallback(
476
+ fallbackRequest,
477
+ url,
478
+ siteConfig.fallbackOrigin,
479
+ );
480
+ return tagBucket(
481
+ response,
482
+ "fallback",
483
+ url.hostname,
484
+ request,
485
+ ratio,
486
+ cookieName,
487
+ cookieMaxAge,
488
+ );
489
+ }
490
+ } catch (err) {
491
+ console.error(
492
+ "[A/B] Fatal proxy error, passing through to handler:",
493
+ err,
494
+ );
495
+ return handler.fetch(request, env, ctx);
496
+ }
497
+ },
498
+ };
499
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Analytics utilities using the data-event attribute pattern.
3
+ * Compatible with Deco's analytics pipeline and GTM.
4
+ */
5
+
6
+ export interface DataEventParams {
7
+ on: "view" | "click" | "change";
8
+ event: { name: string; params?: Record<string, unknown> };
9
+ }
10
+
11
+ export function useSendEvent({ on, event }: DataEventParams) {
12
+ return {
13
+ "data-event": encodeURIComponent(JSON.stringify(event)),
14
+ "data-event-trigger": on,
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Inline script that observes data-event attributes and dispatches events.
20
+ * Inject once in the root layout via a <script> tag.
21
+ */
22
+ export const ANALYTICS_SCRIPT = `
23
+ (function() {
24
+ function dispatch(event) {
25
+ if (window.dataLayer) {
26
+ window.dataLayer.push({ event: event.name, ...event.params });
27
+ }
28
+ if (window.DECO && window.DECO.events) {
29
+ window.DECO.events.dispatch(event);
30
+ }
31
+ }
32
+
33
+ function getEvent(el) {
34
+ var raw = el.getAttribute("data-event");
35
+ if (!raw) return null;
36
+ try { return JSON.parse(decodeURIComponent(raw)); } catch(e) { return null; }
37
+ }
38
+
39
+ var viewObserver = new IntersectionObserver(function(entries) {
40
+ entries.forEach(function(entry) {
41
+ if (entry.isIntersecting) {
42
+ var event = getEvent(entry.target);
43
+ if (event) dispatch(event);
44
+ viewObserver.unobserve(entry.target);
45
+ }
46
+ });
47
+ }, { threshold: 0.5 });
48
+
49
+ document.addEventListener("click", function(e) {
50
+ var el = e.target.closest("[data-event-trigger='click']");
51
+ if (el) {
52
+ var event = getEvent(el);
53
+ if (event) dispatch(event);
54
+ }
55
+ });
56
+
57
+ function observeAll() {
58
+ document.querySelectorAll("[data-event-trigger='view']").forEach(function(el) {
59
+ viewObserver.observe(el);
60
+ });
61
+ }
62
+
63
+ observeAll();
64
+ var mo = new MutationObserver(observeAll);
65
+ if (typeof requestIdleCallback !== 'undefined') {
66
+ requestIdleCallback(function() { mo.observe(document.body, { childList: true, subtree: true }); });
67
+ } else {
68
+ setTimeout(function() { mo.observe(document.body, { childList: true, subtree: true }); }, 0);
69
+ }
70
+ })();
71
+ `;
72
+
73
+ /** Returns a GTM container snippet. Returns empty string if no containerId. */
74
+ export function gtmScript(containerId?: string): string {
75
+ if (!containerId) return "";
76
+ return `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${containerId}');`;
77
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ canonicalizeServerFnPayloadForCacheKey,
4
+ detectCacheProfile,
5
+ serverFnPagePath,
6
+ } from "./cacheHeaders";
7
+
8
+ const sfn = (payload: unknown): URL => {
9
+ const u = new URL("https://store.com/_serverFn/abc123");
10
+ if (payload !== undefined) u.searchParams.set("payload", JSON.stringify(payload));
11
+ return u;
12
+ };
13
+
14
+ describe("serverFnPagePath — extract page path from GET server-fn payload", () => {
15
+ it("returns null for a non-server-fn URL", () => {
16
+ expect(serverFnPagePath(new URL("https://store.com/escolar/p"))).toBeNull();
17
+ });
18
+
19
+ it("returns null when there is no payload", () => {
20
+ expect(serverFnPagePath(new URL("https://store.com/_serverFn/abc"))).toBeNull();
21
+ });
22
+
23
+ it("extracts the page path from TanStack's serialized envelope", () => {
24
+ // Shape observed from a real loadCmsPage preload request.
25
+ const payload = {
26
+ t: {
27
+ t: 10,
28
+ i: 0,
29
+ p: { k: ["data"], v: [{ t: 1, s: "/mochila-executiva-preta/p" }] },
30
+ o: 0,
31
+ },
32
+ f: 63,
33
+ m: [],
34
+ };
35
+ expect(serverFnPagePath(sfn(payload))).toBe("/mochila-executiva-preta/p");
36
+ });
37
+
38
+ it("extracts the page path from the simple { data } form", () => {
39
+ expect(serverFnPagePath(sfn({ data: "/c/shoes" }))).toBe("/c/shoes");
40
+ });
41
+
42
+ it("returns null on malformed payload (fails safe)", () => {
43
+ const u = new URL("https://store.com/_serverFn/abc");
44
+ u.searchParams.set("payload", "{not json");
45
+ expect(serverFnPagePath(u)).toBeNull();
46
+ });
47
+
48
+ it("ignores protocol-relative-looking values (//) and finds none", () => {
49
+ expect(serverFnPagePath(sfn({ data: "//cdn.example.com/x" }))).toBeNull();
50
+ });
51
+
52
+ it("lets the server-fn response inherit the page's cache profile", () => {
53
+ // The whole point: a PDP data request should profile as "product" (5min),
54
+ // not the "listing" (60s) it would get from the /_serverFn pathname.
55
+ const pdp = serverFnPagePath(sfn({ data: "/bolsa-preta/p" }));
56
+ expect(pdp).toBe("/bolsa-preta/p");
57
+ expect(detectCacheProfile(pdp!)).toBe("product");
58
+
59
+ const home = serverFnPagePath(sfn({ data: "/" }));
60
+ expect(detectCacheProfile(home!)).toBe("static");
61
+ });
62
+ });
63
+
64
+ describe("canonicalizeServerFnPayloadForCacheKey — variant-param cache key", () => {
65
+ // The real envelope shape observed from a loadCmsPage preload.
66
+ const envelope = (path: string) =>
67
+ JSON.stringify({
68
+ t: { t: 10, i: 0, p: { k: ["data"], v: [{ t: 1, s: path }] }, o: 0 },
69
+ f: 63,
70
+ m: [],
71
+ });
72
+
73
+ it("strips skuId so a variant URL keys the same as the canonical path", () => {
74
+ const withSku = canonicalizeServerFnPayloadForCacheKey(
75
+ envelope("/mala-preta/p?skuId=148940"),
76
+ );
77
+ const canonical = canonicalizeServerFnPayloadForCacheKey(envelope("/mala-preta/p"));
78
+ expect(withSku).toBe(canonical);
79
+ });
80
+
81
+ it("strips idsku as well", () => {
82
+ const a = canonicalizeServerFnPayloadForCacheKey(envelope("/x/p?idsku=99"));
83
+ const b = canonicalizeServerFnPayloadForCacheKey(envelope("/x/p"));
84
+ expect(a).toBe(b);
85
+ });
86
+
87
+ it("keeps PLP filter/search params (they DO change the response)", () => {
88
+ const filtered = canonicalizeServerFnPayloadForCacheKey(
89
+ envelope("/c/shoes?filter.size=40"),
90
+ );
91
+ const plain = canonicalizeServerFnPayloadForCacheKey(envelope("/c/shoes"));
92
+ expect(filtered).not.toBe(plain);
93
+ expect(filtered).toContain("filter.size=40");
94
+ });
95
+
96
+ it("keeps a non-ignored param while dropping skuId", () => {
97
+ const out = canonicalizeServerFnPayloadForCacheKey(
98
+ envelope("/c/shoes?q=boot&skuId=1"),
99
+ );
100
+ expect(out).toContain("q=boot");
101
+ expect(out).not.toContain("skuId");
102
+ });
103
+
104
+ it("canonicalizes equivalent payloads to byte-identical strings", () => {
105
+ // Even with no ignored param, both must round-trip to the same string so
106
+ // the cache key is stable regardless of incidental serialization diffs.
107
+ const a = canonicalizeServerFnPayloadForCacheKey(envelope("/a/p"));
108
+ const b = canonicalizeServerFnPayloadForCacheKey(envelope("/a/p"));
109
+ expect(a).toBe(b);
110
+ });
111
+
112
+ it("returns the original string on malformed payload (fail-safe)", () => {
113
+ expect(canonicalizeServerFnPayloadForCacheKey("{not json")).toBe("{not json");
114
+ });
115
+ });