@decocms/start 0.43.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +0 -2
- package/package.json +6 -1
- package/scripts/generate-invoke.ts +51 -26
- package/scripts/generate-loaders.ts +133 -0
- package/scripts/generate-sections.ts +219 -0
- package/src/cms/applySectionConventions.ts +109 -0
- package/src/cms/index.ts +3 -0
- package/src/cms/sectionMixins.ts +76 -0
- 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/router.ts +92 -0
- package/src/sdk/workerEntry.ts +87 -1
- package/src/setup.ts +192 -0
|
@@ -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
|
@@ -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
|
-
|
|
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
|
+
}
|