@decocms/start 0.43.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }