@decocms/start 0.43.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Section Loader Mixins
3
+ *
4
+ * Reusable section loader factories for common patterns like device detection,
5
+ * mobile flag injection, and search param extraction. Eliminates repetitive
6
+ * `(props, req) => ({ ...props, device: detectDevice(...) })` boilerplate.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { withDevice, withMobile, compose } from "@decocms/start/cms";
11
+ *
12
+ * registerSectionLoaders({
13
+ * "site/sections/Product/ProductShelf.tsx": withDevice(),
14
+ * "site/sections/Images/Carousel.tsx": withMobile(),
15
+ * "site/sections/Header/Header.tsx": compose(withDevice(), withSearchParam()),
16
+ * });
17
+ * ```
18
+ */
19
+ import { detectDevice } from "../sdk/useDevice";
20
+ import type { SectionLoaderFn } from "./sectionLoaders";
21
+
22
+ /**
23
+ * Injects `device: "mobile" | "desktop" | "tablet"` from the request User-Agent.
24
+ */
25
+ export function withDevice(): SectionLoaderFn {
26
+ return (props, req) => ({
27
+ ...props,
28
+ device: detectDevice(req.headers.get("user-agent") ?? ""),
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Injects `isMobile: boolean` (true for mobile and tablet) from the request User-Agent.
34
+ */
35
+ export function withMobile(): SectionLoaderFn {
36
+ return (props, req) => {
37
+ const d = detectDevice(req.headers.get("user-agent") ?? "");
38
+ return { ...props, isMobile: d === "mobile" || d === "tablet" };
39
+ };
40
+ }
41
+
42
+ const REGEX_QUERY_VALUE = /[?&]q=([^&]*)/;
43
+
44
+ /**
45
+ * Injects `currentSearchParam: string | undefined` extracted from the `?q=` URL parameter.
46
+ */
47
+ export function withSearchParam(): SectionLoaderFn {
48
+ return (props, req) => {
49
+ const match = req.url.match(REGEX_QUERY_VALUE);
50
+ return {
51
+ ...props,
52
+ currentSearchParam: match ? decodeURIComponent(match[1]) : undefined,
53
+ };
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Composes multiple section loader mixins into a single loader.
59
+ * Each mixin's result is merged left-to-right (later mixins override earlier ones).
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * compose(withDevice(), withSearchParam())
64
+ * // Equivalent to: (props, req) => ({ ...props, device: ..., currentSearchParam: ... })
65
+ * ```
66
+ */
67
+ export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
68
+ return async (props, req) => {
69
+ let result = { ...props };
70
+ for (const mixin of mixins) {
71
+ const partial = await mixin(result, req);
72
+ result = { ...result, ...partial };
73
+ }
74
+ return result;
75
+ };
76
+ }
@@ -0,0 +1,111 @@
1
+ import { useEffect, type ReactNode } from "react";
2
+ import { HeadContent, Scripts, ScriptOnce } from "@tanstack/react-router";
3
+ import { LiveControls } from "./LiveControls";
4
+ import { ANALYTICS_SCRIPT } from "../sdk/analytics";
5
+ import { NavigationProgress } from "./NavigationProgress";
6
+ import { StableOutlet } from "./StableOutlet";
7
+
8
+ declare global {
9
+ interface Window {
10
+ __deco_ready?: boolean;
11
+ }
12
+ }
13
+
14
+ function buildDecoEventsBootstrap(account?: string): string {
15
+ const accountJson = JSON.stringify(account ?? "");
16
+ return `
17
+ window.__RUNTIME__ = window.__RUNTIME__ || { account: ${accountJson} };
18
+ window.DECO = window.DECO || {};
19
+ window.DECO.events = window.DECO.events || {
20
+ _q: [],
21
+ _subs: [],
22
+ dispatch: function(e) {
23
+ this._q.push(e);
24
+ for (var i = 0; i < this._subs.length; i++) {
25
+ try { this._subs[i](e); } catch(err) { console.error('[DECO.events]', err); }
26
+ }
27
+ },
28
+ subscribe: function(fn) {
29
+ this._subs.push(fn);
30
+ for (var i = 0; i < this._q.length; i++) {
31
+ try { fn(this._q[i]); } catch(err) {}
32
+ }
33
+ }
34
+ };
35
+ window.dataLayer = window.dataLayer || [];
36
+ `;
37
+ }
38
+
39
+ export interface DecoRootLayoutProps {
40
+ /** Language attribute for the <html> tag. Default: "en" */
41
+ lang?: string;
42
+ /** DaisyUI data-theme attribute. Default: "light" */
43
+ dataTheme?: string;
44
+ /** Site name for LiveControls (admin iframe communication). Required. */
45
+ siteName: string;
46
+ /** Commerce platform account name for analytics bootstrap (e.g. VTEX account). */
47
+ account?: string;
48
+ /** CSS class for <body>. Default: "bg-base-200 text-base-content" */
49
+ bodyClassName?: string;
50
+ /** Delay in ms before firing deco:ready event. Default: 500 */
51
+ decoReadyDelay?: number;
52
+ /**
53
+ * Extra content rendered inside <body> after the main outlet
54
+ * (e.g. Toast, custom analytics components).
55
+ */
56
+ children?: ReactNode;
57
+ }
58
+
59
+ /**
60
+ * Standard Deco root layout component for use in __root.tsx.
61
+ *
62
+ * Provides:
63
+ * - NavigationProgress (loading bar during SPA nav)
64
+ * - StableOutlet (height-preserved content area)
65
+ * - DECO.events bootstrap (via ScriptOnce — runs before hydration, once)
66
+ * - LiveControls for admin
67
+ * - Analytics script (via ScriptOnce)
68
+ * - deco:ready hydration signal
69
+ *
70
+ * QueryClientProvider should be configured via createDecoRouter's `Wrap` option
71
+ * (per TanStack docs — non-DOM providers go on the router, not in components).
72
+ *
73
+ * Sites that need full control should compose from the individual exported
74
+ * pieces (NavigationProgress, StableOutlet, etc.) instead.
75
+ */
76
+ export function DecoRootLayout({
77
+ lang = "en",
78
+ dataTheme = "light",
79
+ siteName,
80
+ account,
81
+ bodyClassName = "bg-base-200 text-base-content",
82
+ decoReadyDelay = 500,
83
+ children,
84
+ }: DecoRootLayoutProps) {
85
+ useEffect(() => {
86
+ const id = setTimeout(() => {
87
+ window.__deco_ready = true;
88
+ document.dispatchEvent(new Event("deco:ready"));
89
+ }, decoReadyDelay);
90
+ return () => clearTimeout(id);
91
+ }, [decoReadyDelay]);
92
+
93
+ return (
94
+ <html lang={lang} data-theme={dataTheme} suppressHydrationWarning>
95
+ <head>
96
+ <HeadContent />
97
+ </head>
98
+ <body className={bodyClassName} suppressHydrationWarning>
99
+ <ScriptOnce children={buildDecoEventsBootstrap(account)} />
100
+ <NavigationProgress />
101
+ <main>
102
+ <StableOutlet />
103
+ </main>
104
+ {children}
105
+ <LiveControls site={siteName} />
106
+ <ScriptOnce children={ANALYTICS_SCRIPT} />
107
+ <Scripts />
108
+ </body>
109
+ </html>
110
+ );
111
+ }
@@ -0,0 +1,21 @@
1
+ import { useRouterState } from "@tanstack/react-router";
2
+
3
+ const PROGRESS_CSS = `
4
+ @keyframes progressSlide { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
5
+ .nav-progress-bar { animation: progressSlide 1s ease-in-out infinite; }
6
+ `;
7
+
8
+ /**
9
+ * Top-of-page loading bar that appears during SPA navigation.
10
+ * Uses the router's isLoading state — no extra dependencies.
11
+ */
12
+ export function NavigationProgress() {
13
+ const isLoading = useRouterState({ select: (s) => s.isLoading });
14
+ if (!isLoading) return null;
15
+ return (
16
+ <div className="fixed top-0 left-0 right-0 z-[9999] h-1 bg-brand-primary-500/20 overflow-hidden">
17
+ <style dangerouslySetInnerHTML={{ __html: PROGRESS_CSS }} />
18
+ <div className="nav-progress-bar h-full w-1/3 bg-brand-primary-500 rounded-full" />
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,30 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { Outlet, useRouterState } from "@tanstack/react-router";
3
+
4
+ /**
5
+ * Preserves the content area height during SPA navigation so the
6
+ * footer doesn't jump up while the new page is loading.
7
+ */
8
+ export function StableOutlet() {
9
+ const isLoading = useRouterState({ select: (s) => s.isLoading });
10
+ const ref = useRef<HTMLDivElement>(null);
11
+ const savedHeight = useRef<number | undefined>(undefined);
12
+
13
+ useEffect(() => {
14
+ if (isLoading && ref.current) {
15
+ savedHeight.current = ref.current.offsetHeight;
16
+ }
17
+ if (!isLoading) {
18
+ savedHeight.current = undefined;
19
+ }
20
+ }, [isLoading]);
21
+
22
+ return (
23
+ <div
24
+ ref={ref}
25
+ style={savedHeight.current ? { minHeight: savedHeight.current } : undefined}
26
+ >
27
+ <Outlet />
28
+ </div>
29
+ );
30
+ }
@@ -6,3 +6,6 @@ export {
6
6
  export { isBelowFold, LazySection, type LazySectionProps } from "./LazySection";
7
7
  export { LiveControls } from "./LiveControls";
8
8
  export { SectionErrorBoundary } from "./SectionErrorFallback";
9
+ export { NavigationProgress } from "./NavigationProgress";
10
+ export { StableOutlet } from "./StableOutlet";
11
+ export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout";
@@ -0,0 +1,398 @@
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:timestamp" format)
11
+ * - Query param override for QA (?_deco_bucket=worker)
12
+ * - Circuit breaker: worker errors auto-fallback to legacy origin
13
+ * - Fallback proxy with hostname rewriting (Set-Cookie, Location, body)
14
+ * - Configurable bypass for paths that must always use the worker
15
+ * (e.g. VTEX checkout proxy paths)
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
20
+ * import { withABTesting } from "@decocms/start/sdk/abTesting";
21
+ *
22
+ * const decoWorker = createDecoWorkerEntry(serverEntry, { ... });
23
+ *
24
+ * export default withABTesting(decoWorker, {
25
+ * kvBinding: "SITES_KV",
26
+ * shouldBypassAB: (request, url) => isVtexPath(url.pathname),
27
+ * });
28
+ * ```
29
+ */
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ interface WorkerExecutionContext {
36
+ waitUntil(promise: Promise<unknown>): void;
37
+ passThroughOnException(): void;
38
+ }
39
+
40
+ export interface WorkerHandler {
41
+ fetch(
42
+ request: Request,
43
+ env: Record<string, unknown>,
44
+ ctx: WorkerExecutionContext,
45
+ ): Promise<Response>;
46
+ }
47
+
48
+ interface KVNamespace {
49
+ get<T = string>(key: string, type: "json"): Promise<T | null>;
50
+ get(key: string, type?: "text"): Promise<string | null>;
51
+ }
52
+
53
+ /** KV value shape — same as the original cf-gateway config. */
54
+ export interface SiteConfig {
55
+ workerName: string;
56
+ fallbackOrigin: string;
57
+ abTest?: { ratio: number };
58
+ }
59
+
60
+ export type Bucket = "worker" | "fallback";
61
+
62
+ export interface ABTestConfig {
63
+ /** KV namespace binding name. @default "SITES_KV" */
64
+ kvBinding?: string;
65
+
66
+ /** Cookie name for bucket persistence. @default "_deco_bucket" */
67
+ cookieName?: string;
68
+
69
+ /** Cookie max-age in seconds. @default 86400 (1 day) */
70
+ cookieMaxAge?: number;
71
+
72
+ /** Auto-fallback to legacy on worker errors. @default true */
73
+ circuitBreaker?: boolean;
74
+
75
+ /**
76
+ * Return `true` to bypass A/B for this request — always serve from
77
+ * the worker regardless of bucket assignment.
78
+ *
79
+ * Useful for commerce backend paths (checkout, /api/*) that must
80
+ * not be proxied through the fallback origin.
81
+ */
82
+ shouldBypassAB?: (request: Request, url: URL) => boolean;
83
+
84
+ /**
85
+ * Called before the A/B logic runs. Return a `Response` to short-circuit
86
+ * (e.g. for CMS redirects), or `null` to continue with A/B.
87
+ */
88
+ preHandler?: (
89
+ request: Request,
90
+ url: URL,
91
+ ) => Response | Promise<Response | null> | null;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // FNV-1a 32-bit — fast, good distribution for short strings like IPs.
96
+ // Same hash used by the original cf-gateway.
97
+ // ---------------------------------------------------------------------------
98
+
99
+ function fnv1a(str: string): number {
100
+ let hash = 0x811c9dc5;
101
+ for (let i = 0; i < str.length; i++) {
102
+ hash ^= str.charCodeAt(i);
103
+ hash = Math.imul(hash, 0x01000193);
104
+ }
105
+ return Math.abs(hash);
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Cookie helpers
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function parseCookies(header: string): Record<string, string> {
113
+ return Object.fromEntries(
114
+ header.split(";").map((c) => {
115
+ const [k, ...v] = c.trim().split("=");
116
+ return [k, v.join("=")];
117
+ }),
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Parse the bucket cookie value.
123
+ *
124
+ * New format: "worker:1711540800" (bucket + unix timestamp).
125
+ * Legacy format: "worker" or "fallback" (no timestamp — old 30d cookie).
126
+ */
127
+ function parseBucketCookie(
128
+ raw: string | undefined,
129
+ ): { bucket: Bucket; ts: number } | null {
130
+ if (!raw) return null;
131
+
132
+ const colonIdx = raw.indexOf(":");
133
+ if (colonIdx > 0) {
134
+ const bucket = raw.slice(0, colonIdx);
135
+ const ts = parseInt(raw.slice(colonIdx + 1), 10);
136
+ if ((bucket === "worker" || bucket === "fallback") && !isNaN(ts)) {
137
+ return { bucket, ts };
138
+ }
139
+ }
140
+
141
+ if (raw === "worker" || raw === "fallback") {
142
+ return { bucket: raw, ts: 0 };
143
+ }
144
+
145
+ return null;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Public helpers (exported for custom composition)
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Deterministically assign a bucket based on:
154
+ * 1. Query param override (?_deco_bucket=worker)
155
+ * 2. Existing cookie (stickiness)
156
+ * 3. IP hash against ratio threshold
157
+ */
158
+ export function getStableBucket(
159
+ request: Request,
160
+ ratio: number,
161
+ url: URL,
162
+ cookieName: string = "_deco_bucket",
163
+ ): Bucket {
164
+ const override = url.searchParams.get(cookieName);
165
+ if (override === "worker" || override === "fallback") return override;
166
+
167
+ const cookies = parseCookies(request.headers.get("cookie") ?? "");
168
+ const parsed = parseBucketCookie(cookies[cookieName]);
169
+ if (parsed) return parsed.bucket;
170
+
171
+ if (ratio <= 0) return "fallback";
172
+ if (ratio >= 1) return "worker";
173
+
174
+ const ip =
175
+ request.headers.get("cf-connecting-ip") ?? Math.random().toString();
176
+ return fnv1a(ip) % 100 < ratio * 100 ? "worker" : "fallback";
177
+ }
178
+
179
+ /**
180
+ * Tag a response with the bucket assignment and refresh the sticky cookie
181
+ * if needed (missing, changed, or stale).
182
+ */
183
+ export function tagBucket(
184
+ response: Response,
185
+ bucket: Bucket,
186
+ hostname: string,
187
+ request: Request,
188
+ cookieName: string = "_deco_bucket",
189
+ maxAge: number = 86400,
190
+ ): Response {
191
+ const res = new Response(response.body, response);
192
+ res.headers.set("x-deco-bucket", bucket);
193
+
194
+ const cookies = parseCookies(request.headers.get("cookie") ?? "");
195
+ const parsed = parseBucketCookie(cookies[cookieName]);
196
+ const now = Math.floor(Date.now() / 1000);
197
+
198
+ const needsSet =
199
+ !parsed || parsed.bucket !== bucket || now - parsed.ts > maxAge;
200
+
201
+ if (needsSet) {
202
+ res.headers.append(
203
+ "set-cookie",
204
+ `${cookieName}=${bucket}:${now}; Path=/; Max-Age=${maxAge}; Domain=${hostname}; SameSite=Lax`,
205
+ );
206
+ }
207
+
208
+ return res;
209
+ }
210
+
211
+ /**
212
+ * Proxy a request to the fallback origin with full hostname rewriting.
213
+ *
214
+ * Rewrites:
215
+ * 1. URL hostname → fallback origin
216
+ * 2. Set-Cookie Domain → real hostname
217
+ * 3. Body text: fallback hostname → real hostname (for Fresh partial URLs)
218
+ * 4. Location header → real hostname
219
+ */
220
+ export async function proxyToFallback(
221
+ request: Request,
222
+ url: URL,
223
+ fallbackOrigin: string,
224
+ ): Promise<Response> {
225
+ const target = new URL(url.toString());
226
+ target.hostname = fallbackOrigin;
227
+
228
+ const headers = new Headers(request.headers);
229
+ headers.delete("host");
230
+ headers.set("x-forwarded-host", url.hostname);
231
+
232
+ const init: RequestInit = {
233
+ method: request.method,
234
+ headers,
235
+ };
236
+ if (request.method !== "GET" && request.method !== "HEAD") {
237
+ init.body = request.body;
238
+ // @ts-expect-error -- needed for streaming body in Workers
239
+ init.duplex = "half";
240
+ }
241
+ const response = await fetch(target.toString(), init);
242
+
243
+ const ct = response.headers.get("content-type") ?? "";
244
+ const isText =
245
+ ct.includes("text/") || ct.includes("json") || ct.includes("javascript");
246
+
247
+ let body: BodyInit | null = response.body;
248
+ if (isText && response.body) {
249
+ const text = await response.text();
250
+ body = text.replaceAll(fallbackOrigin, url.hostname);
251
+ }
252
+
253
+ const rewritten = new Response(body, response);
254
+
255
+ const setCookies = response.headers.getSetCookie?.() ?? [];
256
+ if (setCookies.length > 0) {
257
+ rewritten.headers.delete("set-cookie");
258
+ for (const cookie of setCookies) {
259
+ rewritten.headers.append(
260
+ "set-cookie",
261
+ cookie.replace(
262
+ new RegExp(
263
+ `Domain=\\.?${fallbackOrigin.replace(/\./g, "\\.")}`,
264
+ "gi",
265
+ ),
266
+ `Domain=.${url.hostname}`,
267
+ ),
268
+ );
269
+ }
270
+ }
271
+
272
+ const location = response.headers.get("location");
273
+ if (location?.includes(fallbackOrigin)) {
274
+ rewritten.headers.set(
275
+ "location",
276
+ location.replaceAll(fallbackOrigin, url.hostname),
277
+ );
278
+ }
279
+
280
+ return rewritten;
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Main wrapper
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Wrap a Deco worker entry with A/B testing support.
289
+ *
290
+ * Reads config from Cloudflare KV by hostname, assigns buckets,
291
+ * and proxies fallback traffic to the legacy origin.
292
+ *
293
+ * When no KV binding is available or no config exists for the hostname,
294
+ * all traffic goes directly to the inner handler (no A/B).
295
+ */
296
+ export function withABTesting(
297
+ handler: WorkerHandler,
298
+ config: ABTestConfig = {},
299
+ ): WorkerHandler {
300
+ const {
301
+ kvBinding = "SITES_KV",
302
+ cookieName = "_deco_bucket",
303
+ cookieMaxAge = 86400,
304
+ circuitBreaker = true,
305
+ shouldBypassAB,
306
+ preHandler,
307
+ } = config;
308
+
309
+ return {
310
+ async fetch(
311
+ request: Request,
312
+ env: Record<string, unknown>,
313
+ ctx: WorkerExecutionContext,
314
+ ): Promise<Response> {
315
+ const url = new URL(request.url);
316
+
317
+ // Pre-handler (e.g. CMS redirects) — runs before A/B
318
+ if (preHandler) {
319
+ const pre = await preHandler(request, url);
320
+ if (pre) return pre;
321
+ }
322
+
323
+ const kv = env[kvBinding] as KVNamespace | undefined;
324
+ if (!kv) {
325
+ return handler.fetch(request, env, ctx);
326
+ }
327
+
328
+ const siteConfig = await kv.get<SiteConfig>(url.hostname, "json");
329
+ if (!siteConfig?.fallbackOrigin) {
330
+ return handler.fetch(request, env, ctx);
331
+ }
332
+
333
+ // Bypass A/B for certain paths (e.g. checkout, API)
334
+ if (shouldBypassAB?.(request, url)) {
335
+ return handler.fetch(request, env, ctx);
336
+ }
337
+
338
+ const ratio = siteConfig.abTest?.ratio ?? 0;
339
+ const bucket = getStableBucket(request, ratio, url, cookieName);
340
+
341
+ try {
342
+ if (bucket === "fallback") {
343
+ const response = await proxyToFallback(
344
+ request,
345
+ url,
346
+ siteConfig.fallbackOrigin,
347
+ );
348
+ return tagBucket(
349
+ response,
350
+ bucket,
351
+ url.hostname,
352
+ request,
353
+ cookieName,
354
+ cookieMaxAge,
355
+ );
356
+ }
357
+
358
+ // Worker bucket
359
+ try {
360
+ const response = await handler.fetch(request, env, ctx);
361
+ return tagBucket(
362
+ response,
363
+ bucket,
364
+ url.hostname,
365
+ request,
366
+ cookieName,
367
+ cookieMaxAge,
368
+ );
369
+ } catch (err) {
370
+ if (!circuitBreaker) throw err;
371
+ console.error(
372
+ "[A/B] Worker error, circuit breaker → fallback:",
373
+ err,
374
+ );
375
+ const response = await proxyToFallback(
376
+ request,
377
+ url,
378
+ siteConfig.fallbackOrigin,
379
+ );
380
+ return tagBucket(
381
+ response,
382
+ "fallback",
383
+ url.hostname,
384
+ request,
385
+ cookieName,
386
+ cookieMaxAge,
387
+ );
388
+ }
389
+ } catch (err) {
390
+ console.error(
391
+ "[A/B] Fatal proxy error, passing through to handler:",
392
+ err,
393
+ );
394
+ return handler.fetch(request, env, ctx);
395
+ }
396
+ },
397
+ };
398
+ }