@decocms/start 0.42.3 → 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.
- package/.github/workflows/release.yml +0 -2
- package/package.json +19 -1
- package/scripts/generate-invoke.ts +51 -26
- package/src/hooks/DecoRootLayout.tsx +111 -0
- package/src/hooks/NavigationProgress.tsx +21 -0
- package/src/hooks/StableOutlet.tsx +30 -0
- package/src/hooks/index.ts +3 -0
- package/src/sdk/abTesting.ts +398 -0
- package/src/sdk/otel.ts +104 -0
- package/src/sdk/router.ts +92 -0
- package/src/sdk/workerEntry.ts +91 -2
- package/src/setup.ts +192 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/start",
|
|
3
|
-
"version": "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",
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
"./sdk/crypto": "./src/sdk/crypto.ts",
|
|
28
28
|
"./sdk/invoke": "./src/sdk/invoke.ts",
|
|
29
29
|
"./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts",
|
|
30
|
+
"./sdk/otel": "./src/sdk/otel.ts",
|
|
30
31
|
"./sdk/workerEntry": "./src/sdk/workerEntry.ts",
|
|
32
|
+
"./sdk/abTesting": "./src/sdk/abTesting.ts",
|
|
31
33
|
"./sdk/redirects": "./src/sdk/redirects.ts",
|
|
32
34
|
"./sdk/sitemap": "./src/sdk/sitemap.ts",
|
|
33
35
|
"./sdk/useDevice": "./src/sdk/useDevice.ts",
|
|
@@ -43,11 +45,13 @@
|
|
|
43
45
|
"./sdk/mergeCacheControl": "./src/sdk/mergeCacheControl.ts",
|
|
44
46
|
"./sdk/requestContext": "./src/sdk/requestContext.ts",
|
|
45
47
|
"./sdk/createInvoke": "./src/sdk/createInvoke.ts",
|
|
48
|
+
"./sdk/router": "./src/sdk/router.ts",
|
|
46
49
|
"./matchers/posthog": "./src/matchers/posthog.ts",
|
|
47
50
|
"./apps/autoconfig": "./src/apps/autoconfig.ts",
|
|
48
51
|
"./sdk/setupApps": "./src/sdk/setupApps.ts",
|
|
49
52
|
"./matchers/builtins": "./src/matchers/builtins.ts",
|
|
50
53
|
"./types/widgets": "./src/types/widgets.ts",
|
|
54
|
+
"./setup": "./src/setup.ts",
|
|
51
55
|
"./routes": "./src/routes/index.ts",
|
|
52
56
|
"./scripts/generate-blocks": "./scripts/generate-blocks.ts",
|
|
53
57
|
"./scripts/generate-schema": "./scripts/generate-schema.ts",
|
|
@@ -87,16 +91,30 @@
|
|
|
87
91
|
"tsx": "^4.19.0"
|
|
88
92
|
},
|
|
89
93
|
"peerDependencies": {
|
|
94
|
+
"@microlabs/otel-cf-workers": ">=1.0.0-rc.0",
|
|
95
|
+
"@opentelemetry/api": ">=1.9.0",
|
|
96
|
+
"@tanstack/react-query": ">=5.0.0",
|
|
90
97
|
"@tanstack/react-start": ">=1.0.0",
|
|
91
98
|
"@tanstack/store": ">=0.7.0",
|
|
92
99
|
"react": "^19.0.0",
|
|
93
100
|
"react-dom": "^19.0.0",
|
|
94
101
|
"vite": ">=6.0.0 || >=7.0.0 || >=8.0.0"
|
|
95
102
|
},
|
|
103
|
+
"peerDependenciesMeta": {
|
|
104
|
+
"@microlabs/otel-cf-workers": {
|
|
105
|
+
"optional": true
|
|
106
|
+
},
|
|
107
|
+
"@opentelemetry/api": {
|
|
108
|
+
"optional": true
|
|
109
|
+
}
|
|
110
|
+
},
|
|
96
111
|
"devDependencies": {
|
|
97
112
|
"@biomejs/biome": "^2.4.6",
|
|
113
|
+
"@microlabs/otel-cf-workers": "^1.0.0-rc.52",
|
|
114
|
+
"@opentelemetry/api": "^1.9.1",
|
|
98
115
|
"@semantic-release/exec": "^7.1.0",
|
|
99
116
|
"@semantic-release/git": "^10.0.1",
|
|
117
|
+
"@tanstack/react-query": "^5.96.0",
|
|
100
118
|
"@tanstack/store": "^0.9.1",
|
|
101
119
|
"@types/react": "^19.0.0",
|
|
102
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
|
-
//
|
|
176
|
+
// Recursively unwrap AsExpression chains (e.g. `expr as unknown as Type`)
|
|
177
177
|
let createInvokeFnCall = callExpr;
|
|
178
|
-
|
|
179
|
-
createInvokeFnCall =
|
|
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
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 "
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
338
|
-
|
|
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 (
|
|
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
|
|
366
|
+
// Generate the vtexActions object (for composability with site-specific actions)
|
|
351
367
|
out += `
|
|
352
368
|
// ---------------------------------------------------------------------------
|
|
353
|
-
//
|
|
369
|
+
// Typed VTEX actions map — merge with site-specific actions in your invoke.ts
|
|
354
370
|
// ---------------------------------------------------------------------------
|
|
355
371
|
|
|
356
|
-
export const
|
|
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 += `
|
|
378
|
+
out += ` ${action.name}: ${varName} as unknown as (ctx: { data: ${action.inputType} }) => Promise<${action.returnType}>,\n`;
|
|
365
379
|
} else {
|
|
366
|
-
out += `
|
|
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
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/sdk/otel.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTelemetry integration for Cloudflare Workers via @microlabs/otel-cf-workers.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in module that wraps a Worker handler with auto-instrumentation and
|
|
5
|
+
* wires traces into @decocms/start's pluggable TracerAdapter.
|
|
6
|
+
*
|
|
7
|
+
* Requires peer dependencies:
|
|
8
|
+
* - `@microlabs/otel-cf-workers`
|
|
9
|
+
* - `@opentelemetry/api`
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
|
|
14
|
+
* import { instrumentWorker } from "@decocms/start/sdk/otel";
|
|
15
|
+
*
|
|
16
|
+
* const handler = createDecoWorkerEntry(serverEntry, options);
|
|
17
|
+
*
|
|
18
|
+
* export default instrumentWorker(handler, { serviceName: "my-store" });
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Environment variables (read from `env` at request time):
|
|
22
|
+
* - `OTEL_EXPORTER_OTLP_ENDPOINT` — OTLP endpoint (e.g. `https://in-otel.hyperdx.io`)
|
|
23
|
+
* - `OTEL_EXPORTER_OTLP_HEADERS` — comma-separated `key=value` auth headers
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers";
|
|
27
|
+
import { trace } from "@opentelemetry/api";
|
|
28
|
+
import { configureTracer } from "../middleware/observability";
|
|
29
|
+
|
|
30
|
+
export interface OtelOptions {
|
|
31
|
+
serviceName: string;
|
|
32
|
+
/** OTLP endpoint. Defaults to env.OTEL_EXPORTER_OTLP_ENDPOINT. */
|
|
33
|
+
endpoint?: string;
|
|
34
|
+
/** OTLP auth headers. Defaults to env.OTEL_EXPORTER_OTLP_HEADERS parsed. */
|
|
35
|
+
headers?: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Minimal Cloudflare Worker execution context. */
|
|
39
|
+
interface WorkerExecutionContext {
|
|
40
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
41
|
+
passThroughOnException(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Handler shape returned by createDecoWorkerEntry. */
|
|
45
|
+
interface WorkerHandler {
|
|
46
|
+
fetch(
|
|
47
|
+
request: Request,
|
|
48
|
+
env: Record<string, unknown>,
|
|
49
|
+
ctx: WorkerExecutionContext,
|
|
50
|
+
): Promise<Response>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wraps a Cloudflare Worker handler with OpenTelemetry auto-instrumentation
|
|
55
|
+
* (fetch, KV, D1, waitUntil) and connects to @decocms/start's TracerAdapter
|
|
56
|
+
* so that `withTracing()` / `createInstrumentedFetch()` emit real OTel spans.
|
|
57
|
+
*/
|
|
58
|
+
export function instrumentWorker(
|
|
59
|
+
handler: WorkerHandler,
|
|
60
|
+
options: OtelOptions | ((env: Record<string, unknown>) => OtelOptions),
|
|
61
|
+
) {
|
|
62
|
+
// Bridge @decocms/start TracerAdapter → @opentelemetry/api
|
|
63
|
+
configureTracer({
|
|
64
|
+
startSpan: (name, attrs) => {
|
|
65
|
+
const span = trace.getTracer("deco").startSpan(name, { attributes: attrs });
|
|
66
|
+
return {
|
|
67
|
+
end: () => span.end(),
|
|
68
|
+
setError: (error) => {
|
|
69
|
+
if (error instanceof Error) span.recordException(error);
|
|
70
|
+
},
|
|
71
|
+
setAttribute: (k, v) => span.setAttribute(k, v),
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const resolveConfig: ResolveConfigFn = (env, _trigger) => {
|
|
77
|
+
const opts = typeof options === "function" ? options(env as Record<string, unknown>) : options;
|
|
78
|
+
const endpoint = opts.endpoint || (env.OTEL_EXPORTER_OTLP_ENDPOINT as string);
|
|
79
|
+
const headers = opts.headers || parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS as string | undefined);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
exporter: { url: endpoint, headers },
|
|
83
|
+
service: { name: opts.serviceName },
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Cast through `any` — @microlabs/otel-cf-workers expects Cloudflare's
|
|
88
|
+
// ExportedHandler type, but we avoid depending on @cloudflare/workers-types.
|
|
89
|
+
// deno-lint-ignore no-explicit-any
|
|
90
|
+
return instrument(handler as any, resolveConfig);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseHeaders(str?: string): Record<string, string> {
|
|
94
|
+
if (!str) return {};
|
|
95
|
+
return Object.fromEntries(
|
|
96
|
+
str
|
|
97
|
+
.split(",")
|
|
98
|
+
.map((kv) => {
|
|
99
|
+
const [k, ...v] = kv.split("=");
|
|
100
|
+
return [k.trim(), v.join("=").trim()] as const;
|
|
101
|
+
})
|
|
102
|
+
.filter(([k]) => k.length > 0),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -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
|
+
}
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -256,6 +256,45 @@ export interface DecoWorkerEntryOptions {
|
|
|
256
256
|
* ```
|
|
257
257
|
*/
|
|
258
258
|
cacheVersionEnv?: string | false;
|
|
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;
|
|
259
298
|
}
|
|
260
299
|
|
|
261
300
|
// ---------------------------------------------------------------------------
|
|
@@ -326,6 +365,20 @@ export function injectGeoCookies(request: Request): Request {
|
|
|
326
365
|
|
|
327
366
|
const ONE_YEAR = 31536000;
|
|
328
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
|
+
|
|
329
382
|
const DEFAULT_BYPASS_PATHS = ["/_server", "/_build", "/deco/", "/live/", "/.decofile"];
|
|
330
383
|
|
|
331
384
|
const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+$/;
|
|
@@ -365,8 +418,35 @@ export function createDecoWorkerEntry(
|
|
|
365
418
|
stripTrackingParams: shouldStripTracking = true,
|
|
366
419
|
previewShell: customPreviewShell,
|
|
367
420
|
cacheVersionEnv = "BUILD_HASH",
|
|
421
|
+
securityHeaders: securityHeadersOpt,
|
|
422
|
+
csp: cspOpt,
|
|
423
|
+
autoInjectGeoCookies: geoOpt = true,
|
|
368
424
|
} = options;
|
|
369
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
|
+
|
|
370
450
|
const allBypassPaths = [...(bypassPaths ?? DEFAULT_BYPASS_PATHS), ...extraBypassPaths];
|
|
371
451
|
|
|
372
452
|
// -- Helpers ----------------------------------------------------------------
|
|
@@ -652,16 +732,21 @@ export function createDecoWorkerEntry(
|
|
|
652
732
|
|
|
653
733
|
// -- Main fetch handler -----------------------------------------------------
|
|
654
734
|
|
|
655
|
-
|
|
735
|
+
const handler = {
|
|
656
736
|
async fetch(
|
|
657
737
|
request: Request,
|
|
658
738
|
env: Record<string, unknown>,
|
|
659
739
|
ctx: WorkerExecutionContext,
|
|
660
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
|
+
|
|
661
746
|
// Wrap the entire request in a RequestContext so that all code
|
|
662
747
|
// in the call stack (loaders, invoke handlers, vtexFetchWithCookies)
|
|
663
748
|
// can access the request and write response headers.
|
|
664
|
-
|
|
749
|
+
const response = await RequestContext.run(request, async () => {
|
|
665
750
|
// Run app middleware (injects app state into RequestContext.bag,
|
|
666
751
|
// runs registered middleware like VTEX cookie forwarding).
|
|
667
752
|
const appMw = getAppMiddleware();
|
|
@@ -670,9 +755,13 @@ export function createDecoWorkerEntry(
|
|
|
670
755
|
}
|
|
671
756
|
return handleRequest(request, env, ctx);
|
|
672
757
|
});
|
|
758
|
+
|
|
759
|
+
return applySecurityHeaders(response);
|
|
673
760
|
},
|
|
674
761
|
};
|
|
675
762
|
|
|
763
|
+
return handler;
|
|
764
|
+
|
|
676
765
|
async function handleRequest(
|
|
677
766
|
request: Request,
|
|
678
767
|
env: Record<string, unknown>,
|
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
|
+
}
|