@decocms/start 0.23.0 → 0.24.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.
@@ -41,41 +41,72 @@ The catch-all route handles all CMS-managed pages (PDP, PLP, institutional pages
41
41
 
42
42
  ```typescript
43
43
  // src/routes/$.tsx
44
- import { createFileRoute, notFound } from "@tanstack/react-router";
45
- import { cmsRouteConfig, NotFoundPage } from "@decocms/start/routes";
44
+ import { createFileRoute } from "@tanstack/react-router";
45
+ import { cmsRouteConfig, loadDeferredSection } from "@decocms/start/routes";
46
46
  import { DecoPageRenderer } from "@decocms/start/hooks";
47
+ import type { ResolvedSection, DeferredSection } from "@decocms/start/cms";
48
+ import type { CacheProfile } from "@decocms/start/sdk/cacheHeaders";
49
+ import { cacheHeaders, routeCacheDefaults } from "@decocms/start/sdk/cacheHeaders";
47
50
 
48
- const config = cmsRouteConfig({
51
+ const routeConfig = cmsRouteConfig({
49
52
  siteName: "My Store",
50
53
  defaultTitle: "My Store - Default Title",
51
54
  ignoreSearchParams: ["skuId"],
52
55
  });
53
56
 
57
+ type PageData = {
58
+ resolvedSections: ResolvedSection[];
59
+ deferredSections: DeferredSection[];
60
+ cacheProfile: CacheProfile;
61
+ name: string;
62
+ path: string;
63
+ params: Record<string, string>;
64
+ } | null;
65
+
54
66
  export const Route = createFileRoute("/$")({
55
- loaderDeps: config.loaderDeps,
56
- loader: async (ctx) => {
57
- const page = await config.loader(ctx);
58
- if (!page) throw notFound();
59
- return page;
67
+ ...routeCacheDefaults("listing"),
68
+ loaderDeps: routeConfig.loaderDeps,
69
+ loader: routeConfig.loader as any,
70
+ headers: ({ loaderData }) => {
71
+ const data = loaderData as PageData;
72
+ return cacheHeaders(data?.cacheProfile ?? "listing");
73
+ },
74
+ head: ({ loaderData }) => {
75
+ const data = loaderData as PageData;
76
+ return {
77
+ meta: [
78
+ {
79
+ title: data?.name
80
+ ? `${data.name} | My Store`
81
+ : "My Store - Default Title",
82
+ },
83
+ ],
84
+ };
60
85
  },
61
86
  component: CmsPage,
62
87
  notFoundComponent: NotFoundPage,
63
- staleTime: config.staleTime,
64
- gcTime: config.gcTime,
65
- headers: config.headers,
66
- head: config.head,
67
88
  });
68
89
 
69
90
  function CmsPage() {
70
- const page = Route.useLoaderData();
91
+ const data = Route.useLoaderData() as PageData;
92
+ const { _splat } = Route.useParams();
93
+ const actualPath = `/${_splat ?? ""}`;
94
+
95
+ if (!data) return <NotFoundPage />;
96
+
71
97
  return (
72
- <div>
73
- <DecoPageRenderer sections={page.resolvedSections} />
74
- </div>
98
+ <DecoPageRenderer
99
+ sections={data.resolvedSections ?? []}
100
+ deferredSections={data.deferredSections ?? []}
101
+ pagePath={actualPath}
102
+ loadDeferredSectionFn={(d) => loadDeferredSection({ data: d }) as Promise<ResolvedSection | null>}
103
+ />
75
104
  );
76
105
  }
77
106
  ```
78
107
 
108
+ **CRITICAL**: The `...routeCacheDefaults("listing")` spread is essential. Without it, every SPA navigation triggers a full server re-fetch even when the data was just loaded seconds ago. This is the most common cause of perceived slow navigation.
109
+
79
110
  ### `cmsRouteConfig` Options
80
111
 
81
112
  ```typescript
@@ -152,27 +183,41 @@ Hardcoded to `/` path — no params, no deps.
152
183
  ```typescript
153
184
  // src/routes/index.tsx
154
185
  import { createFileRoute } from "@tanstack/react-router";
155
- import { cmsHomeRouteConfig } from "@decocms/start/routes";
186
+ import { cmsHomeRouteConfig, loadDeferredSection } from "@decocms/start/routes";
156
187
  import { DecoPageRenderer } from "@decocms/start/hooks";
188
+ import type { ResolvedSection, DeferredSection } from "@decocms/start/cms";
157
189
 
158
- const config = cmsHomeRouteConfig({
190
+ const homeConfig = cmsHomeRouteConfig({
159
191
  defaultTitle: "My Store - Homepage",
160
192
  });
161
193
 
194
+ type HomeData = {
195
+ resolvedSections: ResolvedSection[];
196
+ deferredSections: DeferredSection[];
197
+ } | null;
198
+
162
199
  export const Route = createFileRoute("/")({
163
- ...config,
200
+ ...homeConfig,
164
201
  component: HomePage,
165
202
  });
166
203
 
167
204
  function HomePage() {
168
- const page = Route.useLoaderData();
169
- if (!page) {
170
- return <div>Loading...</div>;
171
- }
172
- return <DecoPageRenderer sections={page.resolvedSections} />;
205
+ const data = Route.useLoaderData() as HomeData;
206
+ if (!data) return null;
207
+
208
+ return (
209
+ <DecoPageRenderer
210
+ sections={data.resolvedSections ?? []}
211
+ deferredSections={data.deferredSections ?? []}
212
+ pagePath="/"
213
+ loadDeferredSectionFn={(d) => loadDeferredSection({ data: d }) as Promise<ResolvedSection | null>}
214
+ />
215
+ );
173
216
  }
174
217
  ```
175
218
 
219
+ `cmsHomeRouteConfig` already includes `routeCacheDefaults("static")` and `cacheHeaders("static")`, giving the homepage a 5-min client staleTime and 24h edge TTL. Do NOT add additional cache config.
220
+
176
221
  ### `cmsHomeRouteConfig` Options
177
222
 
178
223
  ```typescript
@@ -308,15 +353,17 @@ The root route contains site-specific elements that should NOT be in the framewo
308
353
 
309
354
  ### Production
310
355
 
311
- Set by `routeCacheDefaults(profile)` based on page type:
356
+ Set by `routeCacheDefaults(profile)` based on page type (from `cacheHeaders.ts`):
312
357
 
313
358
  | Profile | staleTime | gcTime |
314
359
  |---------|-----------|--------|
315
360
  | static | 5 min | 30 min |
316
- | product | 5 min | 30 min |
317
- | listing | 2 min | 10 min |
318
- | search | 60s | 5 min |
361
+ | product | 1 min | 5 min |
362
+ | listing | 1 min | 5 min |
363
+ | search | 30s | 2 min |
364
+ | cart | 0 | 0 |
319
365
  | private | 0 | 0 |
366
+ | none | 0 | 0 |
320
367
 
321
368
  ### Development
322
369
 
@@ -181,6 +181,116 @@ const cachedPLP = createCachedLoader("vtex/plp", vtexPLP, {
181
181
 
182
182
  This is per-isolate in-memory cache (V8 Map). Resets on cold start. Includes request deduplication (single-flight) and LRU eviction at 500 entries.
183
183
 
184
+ ## Cache Versioning with BUILD_HASH
185
+
186
+ Deploy-time cache busting uses a `BUILD_HASH` environment variable (typically the git short SHA) passed to `wrangler deploy`. The worker-entry appends this to cache keys so deploying a new version automatically serves fresh content.
187
+
188
+ ```yaml
189
+ # .github/workflows/deploy.yml
190
+ - name: Deploy to Cloudflare Workers
191
+ run: npx wrangler deploy
192
+ env:
193
+ BUILD_HASH: ${{ github.sha }}
194
+ ```
195
+
196
+ The worker-entry reads `env.BUILD_HASH` and injects it into cache keys. On new deploys, old cache entries simply expire naturally — no purge needed.
197
+
198
+ ## Site-Level Cache Pattern Registration
199
+
200
+ For sites with known institutional/static pages that would otherwise get the conservative 2-min "listing" TTL, register explicit patterns in `setup.ts`:
201
+
202
+ ```ts
203
+ // setup.ts
204
+ import { registerCachePattern } from "@decocms/start/sdk/cacheHeaders";
205
+
206
+ // Institutional pages — content changes rarely, promote to 24h edge TTL
207
+ registerCachePattern({
208
+ test: (p) =>
209
+ p.startsWith("/institucional") ||
210
+ p.startsWith("/central-de-atendimento") ||
211
+ p.startsWith("/politica-de-") ||
212
+ p.startsWith("/termos-") ||
213
+ p === "/fale-conosco" ||
214
+ p === "/trabalhe-conosco" ||
215
+ p === "/cadastro" ||
216
+ p === "/televendas",
217
+ profile: "static",
218
+ });
219
+
220
+ // Promotional/collection pages — already listing-like, but explicit is better
221
+ registerCachePattern({
222
+ test: (p) =>
223
+ p.startsWith("/ofertas") ||
224
+ p.startsWith("/b/") ||
225
+ p.startsWith("/festival-"),
226
+ profile: "listing",
227
+ });
228
+ ```
229
+
230
+ Custom patterns are evaluated before built-in ones. This is the recommended way to tune caching per-site without modifying the framework.
231
+
232
+ ## Client-Side Route Caching (routeCacheDefaults)
233
+
234
+ Without `routeCacheDefaults`, every SPA navigation triggers a fresh server request even if the data was just loaded. This is the most common cause of "slow navigation" reports.
235
+
236
+ The catch-all route `$.tsx` MUST include `routeCacheDefaults`:
237
+
238
+ ```ts
239
+ export const Route = createFileRoute("/$")({
240
+ ...routeCacheDefaults("listing"), // <-- client-side cache: 1min stale, 5min gc
241
+ loaderDeps: routeConfig.loaderDeps,
242
+ loader: routeConfig.loader,
243
+ headers: ({ loaderData }) => {
244
+ return cacheHeaders(loaderData?.cacheProfile ?? "listing");
245
+ },
246
+ component: CmsPage,
247
+ });
248
+ ```
249
+
250
+ The homepage should use `cmsHomeRouteConfig` which already includes `routeCacheDefaults("static")`:
251
+
252
+ ```ts
253
+ export const Route = createFileRoute("/")({
254
+ ...cmsHomeRouteConfig({ defaultTitle: "My Store" }),
255
+ component: HomePage,
256
+ });
257
+ ```
258
+
259
+ ## Cache Analysis & Debugging with Stats Lake
260
+
261
+ Deco sites emit CDN usage data to a ClickHouse stats-lake. This enables cache performance analysis:
262
+
263
+ ```sql
264
+ -- Cache status breakdown for a site
265
+ SELECT
266
+ JSONExtractString(extra, 'cacheStatus') AS cache_status,
267
+ count() AS requests,
268
+ round(count() * 100.0 / sum(count()) OVER (), 2) AS pct
269
+ FROM fact_usage_daily
270
+ WHERE site_id = <site_id>
271
+ AND date >= today() - 7
272
+ GROUP BY cache_status
273
+ ORDER BY requests DESC;
274
+ ```
275
+
276
+ ### Understanding "unknown" Cache Status
277
+
278
+ When the Cloudflare Worker uses `caches.default.match()/put()` to serve cached responses internally, the outer CDN reports `cf-cache-status: DYNAMIC` because the Worker is the origin. The stats-lake logs this as "unknown" or empty.
279
+
280
+ This means a high "unknown" percentage does NOT indicate a caching problem — it means the Worker's internal Cache API is handling the request before it reaches the origin CDN layer. This is expected and desirable behavior.
281
+
282
+ To verify actual cache performance:
283
+ 1. Check `X-Cache: HIT|MISS` header (set by the worker-entry)
284
+ 2. Check `X-Cache-Profile` header (shows which profile was detected)
285
+ 3. Query stats-lake grouping by `cacheStatus` AND response status codes
286
+
287
+ ### Comparing Staging vs Production Cache
288
+
289
+ When migrating to TanStack Workers, compare cache metrics:
290
+ - Production (Deno/Fresh on Kubernetes) typically shows high HIT rates because traffic volume keeps caches warm
291
+ - Staging Workers may show lower HIT rates due to lower traffic, plus "unknown" status from internal Cache API
292
+ - The "unknown" requests on Workers are functionally equivalent to HITs — they're served from the Worker's Cache API without hitting the origin server function
293
+
184
294
  ## Key Constraints
185
295
 
186
296
  - **Cache API ignores `s-maxage`** — the factory uses `max-age` equal to `sMaxAge` when storing in Cache API
@@ -188,6 +298,8 @@ This is per-isolate in-memory cache (V8 Map). Resets on cold start. Includes req
188
298
  - **Device keys add a query param** — `__cf_device=mobile|desktop` is appended to cache keys, so purging must clear both
189
299
  - **Non-200 responses are never cached** — only 200 OK goes into Cache API
190
300
  - **`/_server` paths always bypass cache** — TanStack Start RPC requests are never edge-cached
301
+ - **UTM parameters are stripped** — `utm_*`, `gclid`, `fbclid` are removed from cache keys to improve hit rates
302
+ - **Segment hashing** — user segments (from matchers/flags) are hashed into the cache key so different audiences get different cached responses
191
303
 
192
304
  ## Package Exports
193
305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.23.0",
3
+ "version": "0.24.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",
package/src/admin/cors.ts CHANGED
@@ -1,4 +1,4 @@
1
- const ADMIN_ORIGINS = [
1
+ const ADMIN_ORIGINS = new Set([
2
2
  "https://admin.deco.cx",
3
3
  "https://v0-admin.deco.cx",
4
4
  "https://play.deco.cx",
@@ -6,7 +6,24 @@ const ADMIN_ORIGINS = [
6
6
  "https://deco.chat",
7
7
  "https://admin.decocms.com",
8
8
  "https://decocms.com",
9
- ];
9
+ ]);
10
+
11
+ /**
12
+ * Register additional allowed admin origins.
13
+ * Useful for self-hosted admin UIs or custom dashboards.
14
+ */
15
+ export function registerAdminOrigin(origin: string): void {
16
+ ADMIN_ORIGINS.add(origin);
17
+ }
18
+
19
+ /**
20
+ * Register multiple additional allowed admin origins.
21
+ */
22
+ export function registerAdminOrigins(origins: string[]): void {
23
+ for (const origin of origins) {
24
+ ADMIN_ORIGINS.add(origin);
25
+ }
26
+ }
10
27
 
11
28
  export function isAdminOrLocalhost(request: Request): boolean {
12
29
  const origin = request.headers.get("origin") || request.headers.get("referer") || "";
@@ -15,7 +32,10 @@ export function isAdminOrLocalhost(request: Request): boolean {
15
32
  return true;
16
33
  }
17
34
 
18
- return ADMIN_ORIGINS.some((domain) => origin.startsWith(domain));
35
+ for (const domain of ADMIN_ORIGINS) {
36
+ if (origin.startsWith(domain)) return true;
37
+ }
38
+ return false;
19
39
  }
20
40
 
21
41
  export function corsHeaders(request: Request): Record<string, string> {
@@ -1,4 +1,4 @@
1
- export { corsHeaders, isAdminOrLocalhost } from "./cors";
1
+ export { corsHeaders, isAdminOrLocalhost, registerAdminOrigin, registerAdminOrigins } from "./cors";
2
2
  export { handleDecofileRead, handleDecofileReload } from "./decofile";
3
3
  export {
4
4
  handleInvoke,
package/src/admin/meta.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { djb2Hex } from "../sdk/djb2";
1
2
  import { composeMeta, type MetaResponse } from "./schema";
2
3
 
3
4
  let metaData: MetaResponse | null = null;
@@ -26,18 +27,13 @@ export function setMetaData(data: MetaResponse) {
26
27
 
27
28
  /**
28
29
  * Content-based hash for ETag.
29
- * Uses a simple DJB2-style hash over the serialised JSON so any
30
- * definition change results in a different ETag, forcing admin to
31
- * re-fetch rather than use stale cached meta.
30
+ * Uses DJB2 over the serialised JSON so any definition change
31
+ * results in a different ETag, forcing admin to re-fetch.
32
32
  */
33
33
  function getEtag(): string {
34
34
  if (!cachedEtag) {
35
35
  const str = JSON.stringify(metaData || {});
36
- let hash = 5381;
37
- for (let i = 0; i < str.length; i++) {
38
- hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
39
- }
40
- cachedEtag = `"meta-${hash.toString(36)}"`;
36
+ cachedEtag = `"meta-${djb2Hex(str)}"`;
41
37
  }
42
38
  return cachedEtag;
43
39
  }
@@ -1,35 +1,14 @@
1
1
  import { createElement } from "react";
2
2
  import { loadBlocks, withBlocksOverride } from "../cms/loader";
3
3
  import { getSection } from "../cms/registry";
4
- import { resolveValue } from "../cms/resolve";
4
+ import { resolveValue, WELL_KNOWN_TYPES } from "../cms/resolve";
5
+ import { buildHtmlShell } from "../sdk/htmlShell";
5
6
  import { LIVE_CONTROLS_SCRIPT } from "./liveControls";
6
- import { getRenderShellConfig } from "./setup";
7
7
 
8
8
  export { setRenderShell } from "./setup";
9
9
 
10
10
  function wrapInHtmlShell(sectionHtml: string): string {
11
- const { cssHref, fontHrefs, themeName, bodyClass, htmlLang } = getRenderShellConfig();
12
- const stylesheets = [
13
- ...fontHrefs.map((href) => `<link rel="stylesheet" href="${href}" />`),
14
- cssHref ? `<link rel="stylesheet" href="${cssHref}" />` : "",
15
- ].join("\n ");
16
-
17
- const themeAttr = themeName ? ` data-theme="${themeName}"` : "";
18
- const langAttr = htmlLang ? ` lang="${htmlLang}"` : "";
19
- const bodyAttr = bodyClass ? ` class="${bodyClass}"` : "";
20
-
21
- return `<!DOCTYPE html>
22
- <html${langAttr}${themeAttr}>
23
- <head>
24
- <meta charset="utf-8" />
25
- <meta name="viewport" content="width=device-width, initial-scale=1" />
26
- ${stylesheets}
27
- <script>${LIVE_CONTROLS_SCRIPT}</script>
28
- </head>
29
- <body${bodyAttr}>
30
- ${sectionHtml}
31
- </body>
32
- </html>`;
11
+ return buildHtmlShell({ body: sectionHtml, script: LIVE_CONTROLS_SCRIPT });
33
12
  }
34
13
 
35
14
  /**
@@ -139,7 +118,7 @@ export async function handleRender(request: Request): Promise<Response> {
139
118
  }
140
119
 
141
120
  // Page compositor: resolve + render all child sections
142
- if (component === "website/pages/Page.tsx") {
121
+ if (component === WELL_KNOWN_TYPES.PAGE) {
143
122
  const rawSections = props.sections;
144
123
  const resolvedSections = await resolveValue(rawSections);
145
124
  const sectionsList = Array.isArray(resolvedSections)
@@ -21,9 +21,9 @@ export {
21
21
 
22
22
  let cssHref: string | null = null;
23
23
  let fontHrefs: string[] = [];
24
- let themeName = "light";
25
- let bodyClass = "bg-base-100 text-base-content";
26
- let htmlLang = "pt-BR";
24
+ let themeName = "";
25
+ let bodyClass = "";
26
+ let htmlLang = "en";
27
27
 
28
28
  export function setRenderShell(opts: {
29
29
  css?: string;
package/src/cms/index.ts CHANGED
@@ -38,6 +38,7 @@ export {
38
38
  evaluateMatcher,
39
39
  getAsyncRenderingConfig,
40
40
  onBeforeResolve,
41
+ registerBotPattern,
41
42
  registerCommerceLoader,
42
43
  registerCommerceLoaders,
43
44
  registerMatcher,
@@ -47,6 +48,7 @@ export {
47
48
  setAsyncRenderingConfig,
48
49
  setDanglingReferenceHandler,
49
50
  setResolveErrorHandler,
51
+ WELL_KNOWN_TYPES,
50
52
  } from "./resolve";
51
53
  export type { SectionLoaderFn } from "./sectionLoaders";
52
54
  export {
package/src/cms/loader.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as asyncHooks from "node:async_hooks";
2
+ import { djb2Hex } from "../sdk/djb2";
2
3
 
3
4
  export type Resolvable = {
4
5
  __resolveType?: string;
@@ -54,12 +55,7 @@ export function onChange(listener: ChangeListener) {
54
55
  // ---------------------------------------------------------------------------
55
56
 
56
57
  function computeRevision(blocks: Record<string, unknown>): string {
57
- const str = JSON.stringify(blocks);
58
- let hash = 5381;
59
- for (let i = 0; i < str.length; i++) {
60
- hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
61
- }
62
- return hash.toString(36);
58
+ return djb2Hex(JSON.stringify(blocks));
63
59
  }
64
60
 
65
61
  // ---------------------------------------------------------------------------
@@ -83,7 +83,8 @@ export async function preloadSectionModule(
83
83
  if (mod.ErrorFallback) opts.errorFallback = mod.ErrorFallback;
84
84
  sectionOptions[resolveType] = opts;
85
85
  return opts;
86
- } catch {
86
+ } catch (e) {
87
+ console.warn(`[Registry] Failed to preload section module "${resolveType}":`, e);
87
88
  return existing;
88
89
  }
89
90
  }
@@ -125,8 +126,8 @@ export async function preloadSectionComponents(keys: string[]): Promise<void> {
125
126
  if (mod.LoadingFallback) opts.loadingFallback = mod.LoadingFallback;
126
127
  if (mod.ErrorFallback) opts.errorFallback = mod.ErrorFallback;
127
128
  sectionOptions[key] = opts;
128
- } catch {
129
- /* ignore will fall back to React.lazy */
129
+ } catch (e) {
130
+ console.warn(`[Registry] Failed to preload component "${key}":`, e);
130
131
  }
131
132
  }),
132
133
  );