@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.
- package/package.json +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- 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
|
+
});
|