@decocms/start 0.23.1 → 0.24.1

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.1",
3
+ "version": "0.24.1",
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",
@@ -126,6 +126,13 @@ export async function handleInvoke(request: Request): Promise<Response> {
126
126
 
127
127
  try {
128
128
  const result = await found.handler(body, request);
129
+ // Response passthrough: if the loader/action returns a Response object,
130
+ // forward it as-is (preserving headers like Set-Cookie). This matches
131
+ // deco-cx/deco's invokeToHttpResponse behavior where auth loaders return
132
+ // Response objects with Set-Cookie headers for HttpOnly cookies.
133
+ if (result instanceof Response) {
134
+ return result;
135
+ }
129
136
  const filtered = selectFields(result, select);
130
137
  return new Response(JSON.stringify(filtered), { status: 200, headers: JSON_HEADERS });
131
138
  } catch (error) {
@@ -145,7 +152,13 @@ export async function handleInvoke(request: Request): Promise<Response> {
145
152
 
146
153
  if (found) {
147
154
  try {
148
- const result = await found.handler(payload, request);
155
+ let result = await found.handler(payload, request);
156
+ // If a loader returns a Response, extract its JSON body for batching.
157
+ // Set-Cookie headers from batch items are not forwarded individually
158
+ // (use single invoke for auth loaders that need cookie passthrough).
159
+ if (result instanceof Response) {
160
+ try { result = await result.json(); } catch { result = null; }
161
+ }
149
162
  results[key] = selectFields(result, select);
150
163
  } catch (error) {
151
164
  results[key] = { error: (error as Error).message };
@@ -69,6 +69,13 @@ export interface SegmentKey {
69
69
  loggedIn?: boolean;
70
70
  /** Commerce sales channel / price list. */
71
71
  salesChannel?: string;
72
+ /**
73
+ * VTEX region ID for regionalized pricing/availability.
74
+ * When present, cache entries are segmented per region.
75
+ * Sites without regionalization should omit this field
76
+ * to avoid unnecessary cache fragmentation.
77
+ */
78
+ regionId?: string;
72
79
  /** Sorted list of active A/B flag names for cache cohort splitting. */
73
80
  flags?: string[];
74
81
  }
@@ -128,6 +135,10 @@ export interface DecoWorkerEntryOptions {
128
135
  * device: /mobile|android|iphone/i.test(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop",
129
136
  * loggedIn: vtx.isLoggedIn,
130
137
  * salesChannel: vtx.salesChannel,
138
+ * // Include regionId only if the site uses VTEX regionalization.
139
+ * // When present, cache entries split by region; omit it for
140
+ * // non-regionalized sites to maximize cache sharing.
141
+ * regionId: vtx.regionId ?? undefined,
131
142
  * };
132
143
  * },
133
144
  * });
@@ -366,6 +377,7 @@ export function createDecoWorkerEntry(
366
377
  const parts: string[] = [seg.device];
367
378
  if (seg.loggedIn) parts.push("auth");
368
379
  if (seg.salesChannel) parts.push(`sc=${seg.salesChannel}`);
380
+ if (seg.regionId) parts.push(`r=${seg.regionId}`);
369
381
  if (seg.flags?.length) parts.push(`f=${seg.flags.sort().join(",")}`);
370
382
  return parts.join("|");
371
383
  }
@@ -415,6 +427,32 @@ export function createDecoWorkerEntry(
415
427
 
416
428
  // -- Purge handler ----------------------------------------------------------
417
429
 
430
+ interface PurgeRequestBody {
431
+ paths?: string[];
432
+ countries?: string[];
433
+ /** Sales channels to include in segment combos. Defaults to ["1"]. */
434
+ salesChannels?: string[];
435
+ /** Region IDs to include in segment combos. Each ID generates additional entries. */
436
+ regionIds?: string[];
437
+ }
438
+
439
+ function buildPurgeSegments(body: PurgeRequestBody): SegmentKey[] {
440
+ const devices: Array<"mobile" | "desktop"> = ["mobile", "desktop"];
441
+ const channels = body.salesChannels ?? ["1"];
442
+ const regions: Array<string | undefined> = [undefined, ...(body.regionIds ?? [])];
443
+
444
+ const segments: SegmentKey[] = [];
445
+ for (const device of devices) {
446
+ for (const salesChannel of channels) {
447
+ for (const regionId of regions) {
448
+ segments.push({ device, salesChannel, regionId });
449
+ }
450
+ }
451
+ segments.push({ device });
452
+ }
453
+ return segments;
454
+ }
455
+
418
456
  async function handlePurge(request: Request, env: Record<string, unknown>): Promise<Response> {
419
457
  if (purgeTokenEnv === false) {
420
458
  return new Response("Purge disabled", { status: 404 });
@@ -425,7 +463,7 @@ export function createDecoWorkerEntry(
425
463
  return new Response("Unauthorized", { status: 401 });
426
464
  }
427
465
 
428
- let body: { paths?: string[]; countries?: string[] };
466
+ let body: PurgeRequestBody;
429
467
  try {
430
468
  body = await request.json();
431
469
  } catch {
@@ -437,10 +475,6 @@ export function createDecoWorkerEntry(
437
475
  return new Response('Body must include "paths": ["/", "/page"]', { status: 400 });
438
476
  }
439
477
 
440
- // Geo strings to purge location-specific cache variants.
441
- // Pass ["BR", "BR|São Paulo|Curitiba", ...] to purge specific geo variants.
442
- // Each string must match the __cf_geo param format: "country|region|city".
443
- // When omitted, only the non-geo cache entry is purged.
444
478
  const geoVariants = body.countries ?? [];
445
479
 
446
480
  const cache =
@@ -455,25 +489,18 @@ export function createDecoWorkerEntry(
455
489
  const baseUrl = new URL(request.url).origin;
456
490
  const purged: string[] = [];
457
491
 
458
- // If using segment-based keys, purge requires known segment combos.
459
- // For simplicity, purge common combos: both devices, default sales channel.
460
- const segments: SegmentKey[] = buildSegment
461
- ? [
462
- { device: "mobile" },
463
- { device: "desktop" },
464
- { device: "mobile", salesChannel: "1" },
465
- { device: "desktop", salesChannel: "1" },
466
- ]
467
- : [];
468
-
469
- // Purge both without geo (non-geo-targeted) and with each specified geo variant
470
492
  const geoKeys: (string | null)[] = [null, ...geoVariants];
471
493
 
472
494
  for (const p of paths) {
473
- if (buildSegment && segments.length > 0) {
495
+ if (buildSegment) {
496
+ const segments = buildPurgeSegments(body);
474
497
  for (const seg of segments) {
475
498
  for (const cc of geoKeys) {
476
499
  const url = new URL(p, baseUrl);
500
+ if (cacheVersionEnv !== false) {
501
+ const version = (env[cacheVersionEnv] as string) || "";
502
+ if (version) url.searchParams.set("__v", version);
503
+ }
477
504
  url.searchParams.set("__seg", hashSegment(seg));
478
505
  if (cc) url.searchParams.set("__cf_geo", cc);
479
506
  const key = new Request(url.toString(), { method: "GET" });
@@ -493,6 +520,10 @@ export function createDecoWorkerEntry(
493
520
  for (const device of devices) {
494
521
  for (const cc of geoKeys) {
495
522
  const url = new URL(p, baseUrl);
523
+ if (cacheVersionEnv !== false) {
524
+ const version = (env[cacheVersionEnv] as string) || "";
525
+ if (version) url.searchParams.set("__v", version);
526
+ }
496
527
  if (device) url.searchParams.set("__cf_device", device);
497
528
  if (cc) url.searchParams.set("__cf_geo", cc);
498
529
  const key = new Request(url.toString(), { method: "GET" });
@@ -643,16 +674,22 @@ export function createDecoWorkerEntry(
643
674
  const origin = await serverEntry.fetch(request, env, ctx);
644
675
  const profile = getProfile(url);
645
676
 
646
- // If the profile is private/none/cart, strip any public cache headers
647
- // the route may have set (prevents the search caching bug)
648
677
  if (profile === "private" || profile === "none" || profile === "cart") {
649
678
  const resp = new Response(origin.body, origin);
650
679
  resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
651
680
  resp.headers.delete("CDN-Cache-Control");
681
+ resp.headers.set("X-Cache", "BYPASS");
682
+ resp.headers.set("X-Cache-Reason", `non-cacheable:${profile}`);
652
683
  return resp;
653
684
  }
654
685
 
655
- return origin;
686
+ const resp = new Response(origin.body, origin);
687
+ const reason = request.method !== "GET"
688
+ ? `method:${request.method}`
689
+ : "bypass-path";
690
+ resp.headers.set("X-Cache", "BYPASS");
691
+ resp.headers.set("X-Cache-Reason", reason);
692
+ return resp;
656
693
  }
657
694
 
658
695
  // Cacheable request — build segment-aware cache key
@@ -696,7 +733,10 @@ export function createDecoWorkerEntry(
696
733
  const origin = await serverEntry.fetch(request, env, ctx);
697
734
 
698
735
  if (origin.status !== 200) {
699
- return origin;
736
+ const resp = new Response(origin.body, origin);
737
+ resp.headers.set("X-Cache", "BYPASS");
738
+ resp.headers.set("X-Cache-Reason", `status:${origin.status}`);
739
+ return resp;
700
740
  }
701
741
 
702
742
  // Responses with Set-Cookie must never be cached — they carry
@@ -717,10 +757,11 @@ export function createDecoWorkerEntry(
717
757
  const profile = getProfile(url);
718
758
  const profileConfig = getCacheProfileConfig(profile);
719
759
 
720
- // Don't cache non-public profiles
721
760
  if (!profileConfig.isPublic || profileConfig.sMaxAge === 0) {
722
761
  const resp = new Response(origin.body, origin);
723
762
  resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
763
+ resp.headers.set("X-Cache", "BYPASS");
764
+ resp.headers.set("X-Cache-Reason", `profile:${profile}`);
724
765
  return resp;
725
766
  }
726
767