@decocms/start 0.43.0 → 1.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.
@@ -23,8 +23,6 @@ jobs:
23
23
  node-version: 22
24
24
  registry-url: https://registry.npmjs.org
25
25
 
26
- - run: npm install -g npm@latest
27
-
28
26
  - run: npm install
29
27
 
30
28
  - name: Release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.43.0",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -29,6 +29,7 @@
29
29
  "./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts",
30
30
  "./sdk/otel": "./src/sdk/otel.ts",
31
31
  "./sdk/workerEntry": "./src/sdk/workerEntry.ts",
32
+ "./sdk/abTesting": "./src/sdk/abTesting.ts",
32
33
  "./sdk/redirects": "./src/sdk/redirects.ts",
33
34
  "./sdk/sitemap": "./src/sdk/sitemap.ts",
34
35
  "./sdk/useDevice": "./src/sdk/useDevice.ts",
@@ -44,11 +45,13 @@
44
45
  "./sdk/mergeCacheControl": "./src/sdk/mergeCacheControl.ts",
45
46
  "./sdk/requestContext": "./src/sdk/requestContext.ts",
46
47
  "./sdk/createInvoke": "./src/sdk/createInvoke.ts",
48
+ "./sdk/router": "./src/sdk/router.ts",
47
49
  "./matchers/posthog": "./src/matchers/posthog.ts",
48
50
  "./apps/autoconfig": "./src/apps/autoconfig.ts",
49
51
  "./sdk/setupApps": "./src/sdk/setupApps.ts",
50
52
  "./matchers/builtins": "./src/matchers/builtins.ts",
51
53
  "./types/widgets": "./src/types/widgets.ts",
54
+ "./setup": "./src/setup.ts",
52
55
  "./routes": "./src/routes/index.ts",
53
56
  "./scripts/generate-blocks": "./scripts/generate-blocks.ts",
54
57
  "./scripts/generate-schema": "./scripts/generate-schema.ts",
@@ -90,6 +93,7 @@
90
93
  "peerDependencies": {
91
94
  "@microlabs/otel-cf-workers": ">=1.0.0-rc.0",
92
95
  "@opentelemetry/api": ">=1.9.0",
96
+ "@tanstack/react-query": ">=5.0.0",
93
97
  "@tanstack/react-start": ">=1.0.0",
94
98
  "@tanstack/store": ">=0.7.0",
95
99
  "react": "^19.0.0",
@@ -110,6 +114,7 @@
110
114
  "@opentelemetry/api": "^1.9.1",
111
115
  "@semantic-release/exec": "^7.1.0",
112
116
  "@semantic-release/git": "^10.0.1",
117
+ "@tanstack/react-query": "^5.96.0",
113
118
  "@tanstack/store": "^0.9.1",
114
119
  "@types/react": "^19.0.0",
115
120
  "@types/react-dom": "^19.0.0",
@@ -173,10 +173,10 @@ for (const prop of actionsObj.getProperties()) {
173
173
  let inputType = "any";
174
174
  let callBody = "";
175
175
 
176
- // Navigate through potential type assertion (as ...)
176
+ // Recursively unwrap AsExpression chains (e.g. `expr as unknown as Type`)
177
177
  let createInvokeFnCall = callExpr;
178
- if (callExpr.getKind() === SyntaxKind.AsExpression) {
179
- createInvokeFnCall = callExpr.asKindOrThrow(SyntaxKind.AsExpression).getExpression();
178
+ while (createInvokeFnCall.getKind() === SyntaxKind.AsExpression) {
179
+ createInvokeFnCall = createInvokeFnCall.asKindOrThrow(SyntaxKind.AsExpression).getExpression();
180
180
  }
181
181
 
182
182
  // Now we have createInvokeFn(...) call
@@ -215,15 +215,18 @@ for (const prop of actionsObj.getProperties()) {
215
215
  }
216
216
  }
217
217
 
218
- // Extract the return type from the "as" assertion if present
218
+ // Extract the return type from the outermost "as" assertion.
219
+ // For `expr as unknown as (ctx: ...) => Promise<T>`, the outermost
220
+ // AsExpression has the function type with Promise<T>.
219
221
  let returnType = "any";
220
222
  if (callExpr.getKind() === SyntaxKind.AsExpression) {
221
223
  const asExpr = callExpr.asKindOrThrow(SyntaxKind.AsExpression);
222
224
  const typeText = asExpr.getTypeNode()?.getText() || "";
223
- // Extract Promise<X> from (ctx: {...}) => Promise<X>
224
- const promiseMatch = typeText.match(/Promise<(.+)>$/s);
225
- if (promiseMatch) {
226
- returnType = promiseMatch[1].trim();
225
+ if (typeText !== "unknown") {
226
+ const promiseMatch = typeText.match(/Promise<(.+)>$/s);
227
+ if (promiseMatch) {
228
+ returnType = promiseMatch[1].trim();
229
+ }
227
230
  }
228
231
  }
229
232
 
@@ -277,12 +280,25 @@ for (const action of actions) {
277
280
  }
278
281
  }
279
282
 
283
+ // Count how many actually parsed vs. stubbed
284
+ const parsed = actions.filter((a) => a.callBody && a.importedFn).length;
285
+ const stubbed = actions.length - parsed;
286
+ if (stubbed > 0) {
287
+ console.warn(`⚠ ${stubbed} action(s) could not be parsed — generated as stubs:`);
288
+ for (const a of actions) {
289
+ if (!a.callBody || !a.importedFn) console.warn(` - ${a.name}`);
290
+ }
291
+ }
292
+
280
293
  // Build output
281
294
  let out = `// Auto-generated by @decocms/start/scripts/generate-invoke.ts
282
295
  // Do not edit manually. Re-run the generator to update.
283
296
  //
284
297
  // Each server function is a top-level const so TanStack Start's compiler
285
298
  // can transform createServerFn().handler() into RPC stubs on the client.
299
+ //
300
+ // Site-specific extensions: import { vtexActions } from this file and merge
301
+ // with your own actions in a separate invoke.ts.
286
302
  import { createServerFn } from "@tanstack/react-start";
287
303
  `;
288
304
 
@@ -318,56 +334,65 @@ for (const action of actions) {
318
334
  const varName = `$${action.name}`;
319
335
 
320
336
  if (action.callBody && action.importedFn) {
321
- // Replace "input" references with "ctx.data" in the call body
337
+ // Replace "input" references with "data" in the call body.
338
+ // The handler receives `{ data }` destructured from the validated input.
322
339
  let body = action.callBody;
323
- // The callBody looks like: functionName(input.foo, input.bar)
324
- // We need it to be: functionName(ctx.data.foo, ctx.data.bar)
325
- body = body.replace(/\binput\./g, "ctx.data.");
326
- // Handle cases like functionName(input) without dot
327
- body = body.replace(/\binput\b(?!\.)/g, "ctx.data");
340
+ body = body.replace(/\binput\./g, "data.");
341
+ body = body.replace(/\binput\b(?!\.)/g, "data");
328
342
 
329
343
  if (action.unwrap) {
330
344
  out += `\nconst ${varName} = createServerFn({ method: "POST" })
331
- .handler(async (ctx: { data: ${action.inputType} }) => {
345
+ .inputValidator((data: ${action.inputType}) => data)
346
+ .handler(async ({ data }): Promise<any> => {
332
347
  const result = await ${body};
333
348
  return unwrapResult(result);
334
349
  });\n`;
335
350
  } else {
336
351
  out += `\nconst ${varName} = createServerFn({ method: "POST" })
337
- .handler(async (ctx: { data: ${action.inputType} }) => {
338
- return await ${body};
352
+ .inputValidator((data: ${action.inputType}) => data)
353
+ .handler(async ({ data }): Promise<any> => {
354
+ return ${body};
339
355
  });\n`;
340
356
  }
341
357
  } else {
342
358
  // Fallback: couldn't parse — generate a stub
343
359
  out += `\n// TODO: could not auto-generate ${action.name} — add manually\nconst ${varName} = createServerFn({ method: "POST" })
344
- .handler(async (ctx: { data: any }) => {
360
+ .handler(async () => {
345
361
  throw new Error("${action.name}: not implemented — regenerate invoke");
346
362
  });\n`;
347
363
  }
348
364
  }
349
365
 
350
- // Generate the invoke object
366
+ // Generate the vtexActions object (for composability with site-specific actions)
351
367
  out += `
352
368
  // ---------------------------------------------------------------------------
353
- // Public invoke objectsame DX as @decocms/apps/vtex/invoke
369
+ // Typed VTEX actions map merge with site-specific actions in your invoke.ts
354
370
  // ---------------------------------------------------------------------------
355
371
 
356
- export const invoke = {
357
- vtex: {
358
- actions: {
372
+ export const vtexActions = {
359
373
  `;
360
374
 
361
375
  for (const action of actions) {
362
376
  const varName = `$${action.name}`;
363
377
  if (action.returnType !== "any") {
364
- out += ` ${action.name}: ${varName} as unknown as (ctx: { data: ${action.inputType} }) => Promise<${action.returnType}>,\n`;
378
+ out += ` ${action.name}: ${varName} as unknown as (ctx: { data: ${action.inputType} }) => Promise<${action.returnType}>,\n`;
365
379
  } else {
366
- out += ` ${action.name}: ${varName},\n`;
380
+ out += ` ${action.name}: ${varName},\n`;
367
381
  }
368
382
  }
369
383
 
370
- out += ` },
384
+ out += `} as const;
385
+
386
+ // Re-export OrderForm type (commonly imported from invoke by site components)
387
+ export type { OrderForm } from "@decocms/apps/vtex/types";
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // Default invoke object — import this if you don't need site extensions
391
+ // ---------------------------------------------------------------------------
392
+
393
+ export const invoke = {
394
+ vtex: {
395
+ actions: vtexActions,
371
396
  },
372
397
  } as const;
373
398
  `;
@@ -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
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Deco-flavored TanStack Router factory.
3
+ *
4
+ * Uses standard URLSearchParams serialization instead of TanStack's default
5
+ * JSON-based format. Required because VTEX (and most commerce platforms) uses
6
+ * filter URLs like `?filter.brand=Nike&filter.brand=Adidas` which must
7
+ * round-trip correctly through the router's search system.
8
+ */
9
+ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
10
+ import type {
11
+ SearchSerializer,
12
+ SearchParser,
13
+ AnyRoute,
14
+ TrailingSlashOption,
15
+ } from "@tanstack/react-router";
16
+
17
+ export const decoParseSearch: SearchParser = (searchStr) => {
18
+ const str = searchStr.startsWith("?") ? searchStr.slice(1) : searchStr;
19
+ if (!str) return {};
20
+
21
+ const params = new URLSearchParams(str);
22
+ const result: Record<string, string | string[]> = {};
23
+
24
+ for (const key of new Set(params.keys())) {
25
+ const values = params.getAll(key);
26
+ result[key] = values.length === 1 ? values[0] : values;
27
+ }
28
+ return result;
29
+ };
30
+
31
+ export const decoStringifySearch: SearchSerializer = (search) => {
32
+ const params = new URLSearchParams();
33
+ for (const [key, value] of Object.entries(search)) {
34
+ if (value === undefined || value === null || value === "") continue;
35
+ if (Array.isArray(value)) {
36
+ for (const v of value) params.append(key, String(v));
37
+ } else {
38
+ params.append(key, String(value));
39
+ }
40
+ }
41
+ const str = params.toString();
42
+ return str ? `?${str}` : "";
43
+ };
44
+
45
+ export interface CreateDecoRouterOptions {
46
+ routeTree: AnyRoute;
47
+ scrollRestoration?: boolean;
48
+ defaultPreload?: "intent" | "viewport" | "render" | false;
49
+ trailingSlash?: TrailingSlashOption;
50
+ /**
51
+ * Router context — passed to all route loaders/components via routeContext.
52
+ * Commonly used for { queryClient } per TanStack Query integration docs.
53
+ */
54
+ context?: Record<string, unknown>;
55
+ /**
56
+ * Non-DOM provider component to wrap the entire router.
57
+ * Per TanStack docs, only non-DOM-rendering components (providers) should
58
+ * be used — anything else causes hydration errors.
59
+ *
60
+ * Example: QueryClientProvider wrapping
61
+ * Wrap: ({ children }) => <QueryClientProvider client={qc}>{children}</QueryClientProvider>
62
+ */
63
+ Wrap?: (props: { children: any }) => any;
64
+ }
65
+
66
+ /**
67
+ * Create a TanStack Router with Deco defaults:
68
+ * - URLSearchParams-based search serialization (not JSON)
69
+ * - Scroll restoration enabled
70
+ * - Preload on intent
71
+ */
72
+ export function createDecoRouter(options: CreateDecoRouterOptions) {
73
+ const {
74
+ routeTree,
75
+ scrollRestoration = true,
76
+ defaultPreload = "intent",
77
+ trailingSlash,
78
+ context,
79
+ Wrap,
80
+ } = options;
81
+
82
+ return createTanStackRouter({
83
+ routeTree,
84
+ scrollRestoration,
85
+ defaultPreload,
86
+ trailingSlash,
87
+ context: context as any,
88
+ Wrap,
89
+ parseSearch: decoParseSearch,
90
+ stringifySearch: decoStringifySearch,
91
+ });
92
+ }
@@ -257,6 +257,44 @@ export interface DecoWorkerEntryOptions {
257
257
  */
258
258
  cacheVersionEnv?: string | false;
259
259
 
260
+ /**
261
+ * Security headers appended to every SSR response (HTML pages).
262
+ * Pass `false` to disable entirely.
263
+ *
264
+ * Default headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
265
+ * Permissions-Policy, X-XSS-Protection, HSTS, Cross-Origin-Opener-Policy.
266
+ *
267
+ * Custom entries are merged with defaults (custom values take precedence).
268
+ *
269
+ * @default DEFAULT_SECURITY_HEADERS
270
+ */
271
+ securityHeaders?: Record<string, string> | false;
272
+
273
+ /**
274
+ * Content Security Policy directives (report-only by default).
275
+ * Pass an array of directive strings which are joined with "; ".
276
+ * Pass `false` to omit CSP entirely.
277
+ *
278
+ * @example
279
+ * ```ts
280
+ * csp: [
281
+ * "default-src 'self'",
282
+ * "script-src 'self' 'unsafe-inline' https://www.googletagmanager.com",
283
+ * "img-src 'self' data: https:",
284
+ * ]
285
+ * ```
286
+ */
287
+ csp?: string[] | false;
288
+
289
+ /**
290
+ * Automatically inject Cloudflare geo data (country, region, city)
291
+ * as internal cookies on every request so location matchers can read
292
+ * them from MatcherContext.cookies. The cookies are only visible
293
+ * within the Worker — they are never sent to the browser.
294
+ *
295
+ * @default true
296
+ */
297
+ autoInjectGeoCookies?: boolean;
260
298
  }
261
299
 
262
300
  // ---------------------------------------------------------------------------
@@ -327,6 +365,20 @@ export function injectGeoCookies(request: Request): Request {
327
365
 
328
366
  const ONE_YEAR = 31536000;
329
367
 
368
+ /**
369
+ * Sensible security headers for any production storefront.
370
+ * CSP is intentionally not included — it's site-specific (third-party script domains).
371
+ */
372
+ export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
373
+ "X-Content-Type-Options": "nosniff",
374
+ "X-Frame-Options": "SAMEORIGIN",
375
+ "Referrer-Policy": "strict-origin-when-cross-origin",
376
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
377
+ "X-XSS-Protection": "1; mode=block",
378
+ "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
379
+ "Cross-Origin-Opener-Policy": "same-origin-allow-popups",
380
+ };
381
+
330
382
  const DEFAULT_BYPASS_PATHS = ["/_server", "/_build", "/deco/", "/live/", "/.decofile"];
331
383
 
332
384
  const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+$/;
@@ -366,8 +418,35 @@ export function createDecoWorkerEntry(
366
418
  stripTrackingParams: shouldStripTracking = true,
367
419
  previewShell: customPreviewShell,
368
420
  cacheVersionEnv = "BUILD_HASH",
421
+ securityHeaders: securityHeadersOpt,
422
+ csp: cspOpt,
423
+ autoInjectGeoCookies: geoOpt = true,
369
424
  } = options;
370
425
 
426
+ // Build the final security headers map (merged defaults + custom + CSP)
427
+ const secHeaders: Record<string, string> | null = (() => {
428
+ if (securityHeadersOpt === false) return null;
429
+ const base = { ...DEFAULT_SECURITY_HEADERS };
430
+ if (securityHeadersOpt) {
431
+ for (const [k, v] of Object.entries(securityHeadersOpt)) base[k] = v;
432
+ }
433
+ if (cspOpt && cspOpt.length > 0) {
434
+ base["Content-Security-Policy-Report-Only"] = cspOpt.join("; ");
435
+ }
436
+ return base;
437
+ })();
438
+
439
+ function applySecurityHeaders(resp: Response): Response {
440
+ if (!secHeaders) return resp;
441
+ const ct = resp.headers.get("content-type") ?? "";
442
+ if (!ct.includes("text/html")) return resp;
443
+ const out = new Response(resp.body, resp);
444
+ for (const [k, v] of Object.entries(secHeaders)) {
445
+ if (!out.headers.has(k)) out.headers.set(k, v);
446
+ }
447
+ return out;
448
+ }
449
+
371
450
  const allBypassPaths = [...(bypassPaths ?? DEFAULT_BYPASS_PATHS), ...extraBypassPaths];
372
451
 
373
452
  // -- Helpers ----------------------------------------------------------------
@@ -659,10 +738,15 @@ export function createDecoWorkerEntry(
659
738
  env: Record<string, unknown>,
660
739
  ctx: WorkerExecutionContext,
661
740
  ): Promise<Response> {
741
+ // Inject CF geo data as cookies for location matchers (before anything reads cookies)
742
+ if (geoOpt) {
743
+ request = injectGeoCookies(request);
744
+ }
745
+
662
746
  // Wrap the entire request in a RequestContext so that all code
663
747
  // in the call stack (loaders, invoke handlers, vtexFetchWithCookies)
664
748
  // can access the request and write response headers.
665
- return RequestContext.run(request, async () => {
749
+ const response = await RequestContext.run(request, async () => {
666
750
  // Run app middleware (injects app state into RequestContext.bag,
667
751
  // runs registered middleware like VTEX cookie forwarding).
668
752
  const appMw = getAppMiddleware();
@@ -671,6 +755,8 @@ export function createDecoWorkerEntry(
671
755
  }
672
756
  return handleRequest(request, env, ctx);
673
757
  });
758
+
759
+ return applySecurityHeaders(response);
674
760
  },
675
761
  };
676
762
 
package/src/setup.ts ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * One-call site bootstrap that composes framework registration functions.
3
+ *
4
+ * Sites pass their Vite-resolved globs, generated blocks, meta, CSS, fonts,
5
+ * and optional platform hooks. createSiteSetup wires them into the CMS engine,
6
+ * admin protocol, matchers, and rendering infrastructure.
7
+ *
8
+ * Everything site-specific (section loaders, cacheable sections, async rendering,
9
+ * layout sections, commerce loaders, sync sections) remains in the site's own
10
+ * setup file — createSiteSetup only handles the framework-generic wiring.
11
+ */
12
+
13
+ import {
14
+ loadBlocks,
15
+ onBeforeResolve,
16
+ registerSections,
17
+ setBlocks,
18
+ setDanglingReferenceHandler,
19
+ setResolveErrorHandler,
20
+ } from "./cms/index";
21
+ import { registerBuiltinMatchers } from "./matchers/builtins";
22
+ import { registerProductionOrigins } from "./sdk/normalizeUrls";
23
+ import {
24
+ setInvokeLoaders,
25
+ setMetaData,
26
+ setPreviewWrapper,
27
+ setRenderShell,
28
+ } from "./admin/index";
29
+
30
+ export interface SiteSetupOptions {
31
+ /**
32
+ * Section glob from Vite — pass `import.meta.glob("./sections/**\/*.tsx")`.
33
+ * Keys are transformed from `./sections/X.tsx` → `site/sections/X.tsx`.
34
+ */
35
+ sections: Record<string, () => Promise<any>>;
36
+
37
+ /**
38
+ * Generated blocks object — import and pass directly:
39
+ * `import { blocks } from "./server/cms/blocks.gen";`
40
+ */
41
+ blocks: Record<string, unknown>;
42
+
43
+ /**
44
+ * Lazy loader for admin meta schema — only fetched when admin requests it:
45
+ * `() => import("./server/admin/meta.gen.json").then(m => m.default)`
46
+ */
47
+ meta: () => Promise<any>;
48
+
49
+ /** CSS file URL from Vite `?url` import. */
50
+ css: string;
51
+
52
+ /** Font URLs to preload in admin preview shell. */
53
+ fonts?: string[];
54
+
55
+ /** Production origins for URL normalization. */
56
+ productionOrigins?: string[];
57
+
58
+ /**
59
+ * Custom matcher registrations to run alongside builtins.
60
+ * Each function is called once during setup.
61
+ */
62
+ customMatchers?: Array<() => void>;
63
+
64
+ /** Preview wrapper component for admin preview iframe. */
65
+ previewWrapper?: React.ComponentType<any>;
66
+
67
+ /** Error handler for CMS resolution errors. */
68
+ onResolveError?: (
69
+ error: unknown,
70
+ resolveType: string,
71
+ context: string,
72
+ ) => void;
73
+
74
+ /** Handler for dangling CMS references (missing __resolveType targets). */
75
+ onDanglingReference?: (resolveType: string) => any;
76
+
77
+ /**
78
+ * Called after blocks are loaded — use for platform initialization.
79
+ * Also called on every onBeforeResolve (decofile hot-reload).
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * import { initVtexFromBlocks } from "@decocms/apps/vtex";
84
+ * { initPlatform: (blocks) => initVtexFromBlocks(blocks) }
85
+ * ```
86
+ */
87
+ initPlatform?: (blocks: any) => void;
88
+
89
+ /**
90
+ * Commerce loaders getter — passed to `setInvokeLoaders`.
91
+ * Use a thunk so the full map (including site-specific loaders
92
+ * defined after createSiteSetup) is captured.
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * { getCommerceLoaders: () => COMMERCE_LOADERS }
97
+ * ```
98
+ */
99
+ getCommerceLoaders?: () => Record<string, (props: any) => Promise<any>>;
100
+ }
101
+
102
+ /**
103
+ * Bootstrap a Deco site — registers sections, matchers, blocks, meta,
104
+ * render shell, preview wrapper, error handlers, and platform hooks.
105
+ *
106
+ * Call once at the top of your `setup.ts`, before site-specific registrations.
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * import "./cache-config";
111
+ * import { createSiteSetup } from "@decocms/start/setup";
112
+ * import { blocks } from "./server/cms/blocks.gen";
113
+ * import PreviewProviders from "./components/PreviewProviders";
114
+ * import appCss from "./styles/app.css?url";
115
+ * import { initVtexFromBlocks } from "@decocms/apps/vtex";
116
+ *
117
+ * createSiteSetup({
118
+ * sections: import.meta.glob("./sections/**\/*.tsx"),
119
+ * blocks,
120
+ * meta: () => import("./server/admin/meta.gen.json").then(m => m.default),
121
+ * css: appCss,
122
+ * fonts: ["/fonts/Lato-Regular.woff2", "/fonts/Lato-Bold.woff2"],
123
+ * productionOrigins: ["https://www.example.com"],
124
+ * previewWrapper: PreviewProviders,
125
+ * initPlatform: (blocks) => initVtexFromBlocks(blocks),
126
+ * });
127
+ * ```
128
+ */
129
+ export function createSiteSetup(options: SiteSetupOptions): void {
130
+ // 1. Error handlers (set first so they catch issues during registration)
131
+ if (options.onResolveError) {
132
+ setResolveErrorHandler(options.onResolveError);
133
+ }
134
+ if (options.onDanglingReference) {
135
+ setDanglingReferenceHandler(options.onDanglingReference);
136
+ }
137
+
138
+ // 2. Section glob registration — transform Vite paths to CMS keys
139
+ const sections: Record<string, () => Promise<any>> = {};
140
+ for (const [path, loader] of Object.entries(options.sections)) {
141
+ sections[`site/${path.slice(2)}`] = loader;
142
+ }
143
+ registerSections(sections);
144
+
145
+ // 3. Matchers
146
+ registerBuiltinMatchers();
147
+ if (options.customMatchers) {
148
+ for (const register of options.customMatchers) {
149
+ register();
150
+ }
151
+ }
152
+
153
+ // 4. Production origins
154
+ if (options.productionOrigins?.length) {
155
+ registerProductionOrigins(options.productionOrigins);
156
+ }
157
+
158
+ // 5. Blocks + platform init (server-only)
159
+ if (typeof document === "undefined") {
160
+ setBlocks(options.blocks);
161
+ if (options.initPlatform) {
162
+ options.initPlatform(loadBlocks());
163
+ }
164
+ }
165
+
166
+ // 6. onBeforeResolve — re-init platform on decofile hot-reload
167
+ if (options.initPlatform) {
168
+ const init = options.initPlatform;
169
+ onBeforeResolve(() => {
170
+ init(loadBlocks());
171
+ });
172
+ }
173
+
174
+ // 7. Admin meta schema (lazy)
175
+ options.meta().then((data) => setMetaData(data));
176
+
177
+ // 8. Render shell
178
+ setRenderShell({
179
+ css: options.css,
180
+ fonts: options.fonts,
181
+ });
182
+
183
+ // 9. Preview wrapper
184
+ if (options.previewWrapper) {
185
+ setPreviewWrapper(options.previewWrapper);
186
+ }
187
+
188
+ // 10. Commerce loaders → invoke
189
+ if (options.getCommerceLoaders) {
190
+ setInvokeLoaders(options.getCommerceLoaders);
191
+ }
192
+ }