@decocms/start 0.19.0 → 0.21.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,72 +41,41 @@ 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 } from "@tanstack/react-router";
45
- import { cmsRouteConfig, loadDeferredSection } from "@decocms/start/routes";
44
+ import { createFileRoute, notFound } from "@tanstack/react-router";
45
+ import { cmsRouteConfig, NotFoundPage } 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";
50
47
 
51
- const routeConfig = cmsRouteConfig({
48
+ const config = cmsRouteConfig({
52
49
  siteName: "My Store",
53
50
  defaultTitle: "My Store - Default Title",
54
51
  ignoreSearchParams: ["skuId"],
55
52
  });
56
53
 
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
-
66
54
  export const Route = createFileRoute("/$")({
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
- };
55
+ loaderDeps: config.loaderDeps,
56
+ loader: async (ctx) => {
57
+ const page = await config.loader(ctx);
58
+ if (!page) throw notFound();
59
+ return page;
85
60
  },
86
61
  component: CmsPage,
87
62
  notFoundComponent: NotFoundPage,
63
+ staleTime: config.staleTime,
64
+ gcTime: config.gcTime,
65
+ headers: config.headers,
66
+ head: config.head,
88
67
  });
89
68
 
90
69
  function CmsPage() {
91
- const data = Route.useLoaderData() as PageData;
92
- const { _splat } = Route.useParams();
93
- const actualPath = `/${_splat ?? ""}`;
94
-
95
- if (!data) return <NotFoundPage />;
96
-
70
+ const page = Route.useLoaderData();
97
71
  return (
98
- <DecoPageRenderer
99
- sections={data.resolvedSections ?? []}
100
- deferredSections={data.deferredSections ?? []}
101
- pagePath={actualPath}
102
- loadDeferredSectionFn={(d) => loadDeferredSection({ data: d }) as Promise<ResolvedSection | null>}
103
- />
72
+ <div>
73
+ <DecoPageRenderer sections={page.resolvedSections} />
74
+ </div>
104
75
  );
105
76
  }
106
77
  ```
107
78
 
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
-
110
79
  ### `cmsRouteConfig` Options
111
80
 
112
81
  ```typescript
@@ -183,41 +152,27 @@ Hardcoded to `/` path — no params, no deps.
183
152
  ```typescript
184
153
  // src/routes/index.tsx
185
154
  import { createFileRoute } from "@tanstack/react-router";
186
- import { cmsHomeRouteConfig, loadDeferredSection } from "@decocms/start/routes";
155
+ import { cmsHomeRouteConfig } from "@decocms/start/routes";
187
156
  import { DecoPageRenderer } from "@decocms/start/hooks";
188
- import type { ResolvedSection, DeferredSection } from "@decocms/start/cms";
189
157
 
190
- const homeConfig = cmsHomeRouteConfig({
158
+ const config = cmsHomeRouteConfig({
191
159
  defaultTitle: "My Store - Homepage",
192
160
  });
193
161
 
194
- type HomeData = {
195
- resolvedSections: ResolvedSection[];
196
- deferredSections: DeferredSection[];
197
- } | null;
198
-
199
162
  export const Route = createFileRoute("/")({
200
- ...homeConfig,
163
+ ...config,
201
164
  component: HomePage,
202
165
  });
203
166
 
204
167
  function HomePage() {
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
- );
168
+ const page = Route.useLoaderData();
169
+ if (!page) {
170
+ return <div>Loading...</div>;
171
+ }
172
+ return <DecoPageRenderer sections={page.resolvedSections} />;
216
173
  }
217
174
  ```
218
175
 
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
-
221
176
  ### `cmsHomeRouteConfig` Options
222
177
 
223
178
  ```typescript
@@ -353,17 +308,15 @@ The root route contains site-specific elements that should NOT be in the framewo
353
308
 
354
309
  ### Production
355
310
 
356
- Set by `routeCacheDefaults(profile)` based on page type (from `cacheHeaders.ts`):
311
+ Set by `routeCacheDefaults(profile)` based on page type:
357
312
 
358
313
  | Profile | staleTime | gcTime |
359
314
  |---------|-----------|--------|
360
315
  | static | 5 min | 30 min |
361
- | product | 1 min | 5 min |
362
- | listing | 1 min | 5 min |
363
- | search | 30s | 2 min |
364
- | cart | 0 | 0 |
316
+ | product | 5 min | 30 min |
317
+ | listing | 2 min | 10 min |
318
+ | search | 60s | 5 min |
365
319
  | private | 0 | 0 |
366
- | none | 0 | 0 |
367
320
 
368
321
  ### Development
369
322
 
@@ -181,116 +181,6 @@ 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
-
294
184
  ## Key Constraints
295
185
 
296
186
  - **Cache API ignores `s-maxage`** — the factory uses `max-age` equal to `sMaxAge` when storing in Cache API
@@ -298,8 +188,6 @@ When migrating to TanStack Workers, compare cache metrics:
298
188
  - **Device keys add a query param** — `__cf_device=mobile|desktop` is appended to cache keys, so purging must clear both
299
189
  - **Non-200 responses are never cached** — only 200 OK goes into Cache API
300
190
  - **`/_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
303
191
 
304
192
  ## Package Exports
305
193
 
@@ -6,7 +6,7 @@ on:
6
6
 
7
7
  permissions:
8
8
  contents: write
9
- packages: write
9
+ id-token: write
10
10
  issues: write
11
11
  pull-requests: write
12
12
 
@@ -23,10 +23,11 @@ jobs:
23
23
  node-version: 22
24
24
  registry-url: https://registry.npmjs.org
25
25
 
26
+ - run: npm install -g npm@latest
27
+
26
28
  - run: npm install
27
29
 
28
30
  - name: Release
29
31
  run: npx semantic-release
30
32
  env:
31
33
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/.releaserc.json CHANGED
@@ -15,7 +15,10 @@
15
15
  ]
16
16
  }],
17
17
  "@semantic-release/release-notes-generator",
18
- "@semantic-release/npm",
18
+ ["@semantic-release/exec", {
19
+ "prepareCmd": "npm version ${nextRelease.version} --no-git-tag-version",
20
+ "publishCmd": "npm publish --access public"
21
+ }],
19
22
  "@semantic-release/github",
20
23
  ["@semantic-release/git", {
21
24
  "assets": ["package.json"],
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # @decocms/start
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@decocms/start.svg)](https://www.npmjs.com/package/@decocms/start)
4
+ [![license](https://img.shields.io/npm/l/@decocms/start.svg)](https://github.com/decocms/deco-start/blob/main/LICENSE)
5
+
6
+ Framework layer for [Deco](https://deco.cx) storefronts built on **TanStack Start + React 19 + Cloudflare Workers**.
7
+
8
+ Provides CMS block resolution, admin protocol handlers, section rendering, schema generation, edge caching, and SDK utilities. This is **not** a storefront — it's the npm package that storefronts depend on.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @decocms/start
14
+ ```
15
+
16
+ ## Architecture
17
+
18
+ ```
19
+ @decocms/start ← Framework (this package)
20
+ └─ @decocms/apps ← Commerce integrations (VTEX, Shopify)
21
+ └─ site repo ← UI components, routes, styles
22
+ ```
23
+
24
+ ### Package Exports
25
+
26
+ | Import | Purpose |
27
+ |--------|---------|
28
+ | `@decocms/start` | Barrel export |
29
+ | `@decocms/start/cms` | Block loading, page resolution, section registry |
30
+ | `@decocms/start/admin` | Admin protocol (meta, decofile, invoke, render, schema) |
31
+ | `@decocms/start/hooks` | DecoPageRenderer, LiveControls, LazySection |
32
+ | `@decocms/start/routes` | CMS route config, admin routes |
33
+ | `@decocms/start/middleware` | Observability, deco state, liveness probe |
34
+ | `@decocms/start/sdk/workerEntry` | Cloudflare Worker entry with edge caching |
35
+ | `@decocms/start/sdk/cacheHeaders` | URL-to-profile cache detection |
36
+ | `@decocms/start/sdk/cachedLoader` | In-flight dedup for loaders |
37
+ | `@decocms/start/sdk/useScript` | Inline `<script>` with minification |
38
+ | `@decocms/start/sdk/useDevice` | SSR-safe device detection |
39
+ | `@decocms/start/sdk/analytics` | Analytics event types |
40
+ | `@decocms/start/matchers/*` | Feature flag matchers (PostHog, built-ins) |
41
+ | `@decocms/start/types` | Section, App, FnContext type definitions |
42
+ | `@decocms/start/scripts/*` | Code generation (blocks, schema, invoke) |
43
+
44
+ ### Worker Entry Request Flow
45
+
46
+ ```
47
+ Request → createDecoWorkerEntry()
48
+ ├─ Admin routes (/live/_meta, /.decofile, /deco/render, /deco/invoke)
49
+ ├─ Cache purge check
50
+ ├─ Static asset bypass (/assets/*, favicon)
51
+ ├─ Cloudflare edge cache (profile-based TTLs)
52
+ └─ TanStack Start server entry
53
+ ```
54
+
55
+ ### Edge Cache Profiles
56
+
57
+ | URL Pattern | Profile | Edge TTL |
58
+ |-------------|---------|----------|
59
+ | `/` | static | 1 day |
60
+ | `*/p` | product | 5 min |
61
+ | `/s`, `?q=` | search | 60s |
62
+ | `/cart`, `/checkout` | private | none |
63
+ | Everything else | listing | 2 min |
64
+
65
+ ## Peer Dependencies
66
+
67
+ - `@tanstack/react-start` >= 1.0.0
68
+ - `@tanstack/store` >= 0.7.0
69
+ - `react` ^19.0.0
70
+ - `react-dom` ^19.0.0
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ npm run typecheck # tsc --noEmit
76
+ npm run lint # biome check
77
+ npm run check # typecheck + lint + unused exports
78
+ ```
79
+
80
+ This is a library — no dev server. Consumer sites run their own `vite dev`.
81
+
82
+ ## License
83
+
84
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.19.0",
3
+ "version": "0.21.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",
@@ -76,6 +76,7 @@
76
76
  },
77
77
  "devDependencies": {
78
78
  "@biomejs/biome": "^2.4.6",
79
+ "@semantic-release/exec": "^7.1.0",
79
80
  "@semantic-release/git": "^10.0.1",
80
81
  "@tanstack/store": "^0.9.1",
81
82
  "@types/react": "^19.0.0",
@@ -238,6 +238,110 @@ registerMatcherSchemas([
238
238
  },
239
239
  },
240
240
  },
241
+ {
242
+ key: "website/matchers/location.ts",
243
+ title: "Location",
244
+ namespace: "website",
245
+ propsSchema: {
246
+ type: "object",
247
+ properties: {
248
+ includeLocations: {
249
+ type: "array",
250
+ title: "Include Locations",
251
+ items: {
252
+ type: "object",
253
+ properties: {
254
+ country: { type: "string", title: "Country" },
255
+ regionCode: { type: "string", title: "Region" },
256
+ city: { type: "string", title: "City" },
257
+ },
258
+ },
259
+ },
260
+ excludeLocations: {
261
+ type: "array",
262
+ title: "Exclude Locations",
263
+ items: {
264
+ type: "object",
265
+ properties: {
266
+ country: { type: "string", title: "Country" },
267
+ regionCode: { type: "string", title: "Region" },
268
+ city: { type: "string", title: "City" },
269
+ },
270
+ },
271
+ },
272
+ },
273
+ },
274
+ },
275
+ {
276
+ key: "website/matchers/userAgent.ts",
277
+ title: "User Agent",
278
+ namespace: "website",
279
+ propsSchema: {
280
+ type: "object",
281
+ properties: {
282
+ includes: { type: "string", title: "Includes (substring)" },
283
+ match: { type: "string", title: "Match (regex)" },
284
+ },
285
+ },
286
+ },
287
+ {
288
+ key: "website/matchers/environment.ts",
289
+ title: "Environment",
290
+ namespace: "website",
291
+ propsSchema: {
292
+ type: "object",
293
+ properties: {
294
+ environment: {
295
+ type: "string",
296
+ title: "Environment",
297
+ enum: ["production", "development"],
298
+ },
299
+ },
300
+ },
301
+ },
302
+ {
303
+ key: "website/matchers/multi.ts",
304
+ title: "Multi (AND/OR)",
305
+ namespace: "website",
306
+ propsSchema: {
307
+ type: "object",
308
+ properties: {
309
+ op: {
310
+ type: "string",
311
+ title: "Operator",
312
+ enum: ["and", "or"],
313
+ default: "and",
314
+ },
315
+ matchers: {
316
+ type: "array",
317
+ title: "Matchers",
318
+ items: {
319
+ type: "object",
320
+ required: ["__resolveType"],
321
+ properties: { __resolveType: { type: "string" } },
322
+ additionalProperties: true,
323
+ },
324
+ },
325
+ },
326
+ },
327
+ },
328
+ {
329
+ key: "website/matchers/negate.ts",
330
+ title: "Negate (NOT)",
331
+ namespace: "website",
332
+ propsSchema: {
333
+ type: "object",
334
+ properties: {
335
+ matcher: {
336
+ type: "object",
337
+ title: "Matcher",
338
+ required: ["__resolveType"],
339
+ properties: { __resolveType: { type: "string" } },
340
+ additionalProperties: true,
341
+ },
342
+ },
343
+ },
344
+ },
241
345
  ]);
242
346
 
243
347
  function buildLoaderDefinitions() {
package/src/cms/index.ts CHANGED
@@ -35,6 +35,7 @@ export type {
35
35
  } from "./resolve";
36
36
  export {
37
37
  addSkipResolveType,
38
+ evaluateMatcher,
38
39
  getAsyncRenderingConfig,
39
40
  onBeforeResolve,
40
41
  registerCommerceLoader,
@@ -132,16 +132,44 @@ export async function preloadSectionComponents(keys: string[]): Promise<void> {
132
132
  );
133
133
  }
134
134
 
135
+ /**
136
+ * A sync section entry: either a plain component reference or a full module
137
+ * object with optional LoadingFallback and ErrorFallback.
138
+ * Providing the full module allows DeferredSectionWrapper to show the correct
139
+ * skeleton immediately (optionsReady=true on first render) without waiting for
140
+ * the async preloadSectionModule() call.
141
+ */
142
+ export type SyncSectionEntry =
143
+ | ComponentType<any>
144
+ | {
145
+ default: ComponentType<any>;
146
+ LoadingFallback?: ComponentType<any>;
147
+ ErrorFallback?: ComponentType<{ error: Error }>;
148
+ };
149
+
135
150
  /**
136
151
  * Register sections with their already-imported component references.
137
152
  * These are available synchronously on both server and client — no dynamic
138
153
  * import, no React.lazy, no Suspense. Use for critical above-the-fold
139
154
  * sections that must never flash during hydration.
155
+ *
156
+ * Accepts either a plain component or a full module object (with optional
157
+ * LoadingFallback / ErrorFallback). Providing the module object populates
158
+ * sectionOptions immediately, so DeferredSectionWrapper can show the correct
159
+ * skeleton without an extra async preloadSectionModule() round-trip.
140
160
  */
141
- export function registerSectionsSync(sections: Record<string, ComponentType<any>>): void {
142
- for (const [key, component] of Object.entries(sections)) {
161
+ export function registerSectionsSync(sections: Record<string, SyncSectionEntry>): void {
162
+ for (const [key, entry] of Object.entries(sections)) {
163
+ const component = typeof entry === "function" ? entry : entry.default;
143
164
  syncComponents[key] = component;
144
165
  resolvedComponents[key] = component;
166
+
167
+ if (typeof entry !== "function") {
168
+ const opts: SectionOptions = { ...sectionOptions[key] };
169
+ if (entry.LoadingFallback) opts.loadingFallback = entry.LoadingFallback;
170
+ if (entry.ErrorFallback) opts.errorFallback = entry.ErrorFallback;
171
+ sectionOptions[key] = opts;
172
+ }
145
173
  }
146
174
  }
147
175
 
@@ -237,7 +237,7 @@ function ensureInitialized() {
237
237
  // Matcher evaluation
238
238
  // ---------------------------------------------------------------------------
239
239
 
240
- function evaluateMatcher(rule: Record<string, unknown> | undefined, ctx: MatcherContext): boolean {
240
+ export function evaluateMatcher(rule: Record<string, unknown> | undefined, ctx: MatcherContext): boolean {
241
241
  if (!rule) return true;
242
242
 
243
243
  const resolveType = rule.__resolveType as string | undefined;
@@ -81,6 +81,40 @@ import { isDevMode } from "../sdk/env";
81
81
 
82
82
  const isDev = isDevMode();
83
83
 
84
+ // ---------------------------------------------------------------------------
85
+ // Deferred section data cache — persists resolved section props across SPA
86
+ // navigations so navigating back to a page doesn't re-fetch already-loaded
87
+ // sections. TTL is aligned with cmsRouteConfig staleTime (5 min prod / 5s dev).
88
+ // ---------------------------------------------------------------------------
89
+
90
+ const DEFERRED_CACHE_TTL = isDev ? 5_000 : 5 * 60 * 1000;
91
+
92
+ interface DeferredCacheEntry {
93
+ section: ResolvedSection;
94
+ ts: number;
95
+ }
96
+
97
+ const deferredSectionCache = new Map<string, DeferredCacheEntry>();
98
+
99
+ function getCachedDeferredSection(stableKey: string): ResolvedSection | null {
100
+ const entry = deferredSectionCache.get(stableKey);
101
+ if (!entry) return null;
102
+ if (Date.now() - entry.ts > DEFERRED_CACHE_TTL) {
103
+ deferredSectionCache.delete(stableKey);
104
+ return null;
105
+ }
106
+ return entry.section;
107
+ }
108
+
109
+ /** Fast DJB2 hash for cache key differentiation. */
110
+ function djb2(str: string): string {
111
+ let hash = 5381;
112
+ for (let i = 0; i < str.length; i++) {
113
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
114
+ }
115
+ return (hash >>> 0).toString(36);
116
+ }
117
+
84
118
  const DEFERRED_FADE_CSS = `@keyframes decoFadeIn{from{opacity:0}to{opacity:1}}`;
85
119
 
86
120
  function FadeInStyle() {
@@ -191,8 +225,11 @@ function DeferredSectionWrapper({
191
225
  errorFallback,
192
226
  loadFn,
193
227
  }: DeferredSectionWrapperProps) {
194
- const stableKey = `${pagePath}::${deferred.component}::${deferred.index}`;
195
- const [section, setSection] = useState<ResolvedSection | null>(null);
228
+ const propsHash = djb2(JSON.stringify(deferred.rawProps));
229
+ const stableKey = `${pagePath}::${deferred.component}::${deferred.index}::${propsHash}`;
230
+ const [section, setSection] = useState<ResolvedSection | null>(() =>
231
+ typeof document === "undefined" ? null : getCachedDeferredSection(stableKey),
232
+ );
196
233
  const [error, setError] = useState<Error | null>(null);
197
234
  const [loadedOptions, setLoadedOptions] = useState<SectionOptions | undefined>(() =>
198
235
  getSectionOptions(deferred.component),
@@ -208,7 +245,8 @@ function DeferredSectionWrapper({
208
245
  if (prevKeyRef.current !== stableKey) {
209
246
  prevKeyRef.current = stableKey;
210
247
  triggered.current = false;
211
- if (section) setSection(null);
248
+ const cached = getCachedDeferredSection(stableKey);
249
+ if (section !== cached) setSection(cached);
212
250
  if (error) setError(null);
213
251
  }
214
252
 
@@ -240,12 +278,16 @@ function DeferredSectionWrapper({
240
278
 
241
279
  if (typeof IntersectionObserver === "undefined") {
242
280
  triggered.current = true;
281
+ const key0 = stableKey;
243
282
  loadFn({
244
283
  component: deferred.component,
245
284
  rawProps: deferred.rawProps,
246
285
  pagePath,
247
286
  })
248
- .then(setSection)
287
+ .then((result) => {
288
+ if (result) deferredSectionCache.set(key0, { section: result, ts: Date.now() });
289
+ setSection(result);
290
+ })
249
291
  .catch((e) => setError(e));
250
292
  return;
251
293
  }
@@ -255,12 +297,16 @@ function DeferredSectionWrapper({
255
297
  if (entry?.isIntersecting && !triggered.current) {
256
298
  triggered.current = true;
257
299
  observer.disconnect();
300
+ const key1 = stableKey;
258
301
  loadFn({
259
302
  component: deferred.component,
260
303
  rawProps: deferred.rawProps,
261
304
  pagePath,
262
305
  })
263
- .then(setSection)
306
+ .then((result) => {
307
+ if (result) deferredSectionCache.set(key1, { section: result, ts: Date.now() });
308
+ setSection(result);
309
+ })
264
310
  .catch((e) => setError(e));
265
311
  }
266
312
  },
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * These augment the matchers already handled inline in resolve.ts
5
5
  * (always, never, device, random, utm) with the additional matchers
6
- * that deco supported: cookie, cron, host, pathname, queryString.
6
+ * that deco supported: cookie, cron, host, pathname, queryString,
7
+ * location, userAgent, environment, multi, negate.
7
8
  *
8
9
  * Register these at startup:
9
10
  *
@@ -15,7 +16,7 @@
15
16
  */
16
17
 
17
18
  import type { MatcherContext } from "../cms/resolve";
18
- import { registerMatcher } from "../cms/resolve";
19
+ import { evaluateMatcher, registerMatcher } from "../cms/resolve";
19
20
 
20
21
  // -------------------------------------------------------------------------
21
22
  // Cookie matcher
@@ -163,6 +164,175 @@ function queryStringMatcher(rule: Record<string, unknown>, ctx: MatcherContext):
163
164
  }
164
165
  }
165
166
 
167
+ // -------------------------------------------------------------------------
168
+ // Location matcher
169
+ // -------------------------------------------------------------------------
170
+
171
+ /**
172
+ * CF country codes -> CMS country name mapping.
173
+ * The CMS stores country as full names ("Brasil"), CF provides ISO codes ("BR").
174
+ */
175
+ const COUNTRY_NAME_TO_CODE: Record<string, string> = {
176
+ Brasil: "BR", Brazil: "BR",
177
+ Argentina: "AR", Chile: "CL",
178
+ Colombia: "CO", Mexico: "MX", "México": "MX",
179
+ Peru: "PE", "Perú": "PE",
180
+ Uruguay: "UY", Paraguay: "PY",
181
+ Bolivia: "BO", Ecuador: "EC",
182
+ Venezuela: "VE",
183
+ "United States": "US", USA: "US",
184
+ "Estados Unidos": "US",
185
+ Spain: "ES", "España": "ES",
186
+ Portugal: "PT",
187
+ Canada: "CA", "Canadá": "CA",
188
+ Germany: "DE", Alemania: "DE", Deutschland: "DE",
189
+ France: "FR", Francia: "FR",
190
+ Italy: "IT", Italia: "IT",
191
+ "United Kingdom": "GB", UK: "GB",
192
+ Japan: "JP", "Japón": "JP",
193
+ China: "CN",
194
+ Australia: "AU",
195
+ "South Korea": "KR",
196
+ India: "IN",
197
+ Netherlands: "NL",
198
+ Switzerland: "CH",
199
+ Sweden: "SE",
200
+ Norway: "NO",
201
+ Denmark: "DK",
202
+ Finland: "FI",
203
+ Belgium: "BE",
204
+ Austria: "AT",
205
+ Ireland: "IE",
206
+ "New Zealand": "NZ",
207
+ "South Africa": "ZA",
208
+ Israel: "IL",
209
+ "Saudi Arabia": "SA",
210
+ "United Arab Emirates": "AE",
211
+ Turkey: "TR", "Türkiye": "TR",
212
+ Poland: "PL",
213
+ "Czech Republic": "CZ", Czechia: "CZ",
214
+ Romania: "RO",
215
+ Hungary: "HU",
216
+ Greece: "GR",
217
+ Croatia: "HR",
218
+ "Costa Rica": "CR",
219
+ Panama: "PA", "Panamá": "PA",
220
+ "Dominican Republic": "DO",
221
+ Guatemala: "GT",
222
+ Honduras: "HN",
223
+ "El Salvador": "SV",
224
+ Nicaragua: "NI",
225
+ Cuba: "CU",
226
+ "Puerto Rico": "PR",
227
+ };
228
+
229
+ interface LocationRule {
230
+ country?: string;
231
+ regionCode?: string;
232
+ city?: string;
233
+ }
234
+
235
+ function matchesLocationRule(
236
+ loc: LocationRule,
237
+ regionName: string,
238
+ regionCode: string,
239
+ country: string,
240
+ city: string,
241
+ ): boolean {
242
+ if (loc.country) {
243
+ const code = COUNTRY_NAME_TO_CODE[loc.country] ?? loc.country;
244
+ if (code.toUpperCase() !== country.toUpperCase()) return false;
245
+ }
246
+ if (loc.regionCode) {
247
+ // Match against both the short code ("SP") and full name ("São Paulo")
248
+ // so rules authored against either format continue working.
249
+ const ruleVal = loc.regionCode.toLowerCase();
250
+ if (regionCode.toLowerCase() !== ruleVal && regionName.toLowerCase() !== ruleVal) return false;
251
+ }
252
+ if (loc.city && loc.city.toLowerCase() !== city.toLowerCase()) return false;
253
+ return true;
254
+ }
255
+
256
+ function locationMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
257
+ const cookies = ctx.cookies ?? {};
258
+ const regionName = cookies.__cf_geo_region ? decodeURIComponent(cookies.__cf_geo_region) : "";
259
+ const regionCode = cookies.__cf_geo_region_code ? decodeURIComponent(cookies.__cf_geo_region_code) : "";
260
+ const country = cookies.__cf_geo_country ? decodeURIComponent(cookies.__cf_geo_country) : "";
261
+ const city = cookies.__cf_geo_city ? decodeURIComponent(cookies.__cf_geo_city) : "";
262
+
263
+ const includeLocations = rule.includeLocations as LocationRule[] | undefined;
264
+ const excludeLocations = rule.excludeLocations as LocationRule[] | undefined;
265
+
266
+ if (excludeLocations?.some((loc) => matchesLocationRule(loc, regionName, regionCode, country, city))) {
267
+ return false;
268
+ }
269
+ if (includeLocations?.length) {
270
+ return includeLocations.some((loc) => matchesLocationRule(loc, regionName, regionCode, country, city));
271
+ }
272
+ return true;
273
+ }
274
+
275
+ // -------------------------------------------------------------------------
276
+ // User Agent matcher
277
+ // -------------------------------------------------------------------------
278
+
279
+ function userAgentMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
280
+ const ua = ctx.userAgent ?? "";
281
+ const includes = rule.includes as string | undefined;
282
+ const match = rule.match as string | undefined;
283
+
284
+ if (includes && !ua.includes(includes)) return false;
285
+ if (match) {
286
+ if (!isSafePattern(match)) return false;
287
+ try {
288
+ if (!new RegExp(match, "i").test(ua)) return false;
289
+ } catch {
290
+ return false;
291
+ }
292
+ }
293
+ return true;
294
+ }
295
+
296
+ // -------------------------------------------------------------------------
297
+ // Environment matcher
298
+ // -------------------------------------------------------------------------
299
+
300
+ function environmentMatcher(rule: Record<string, unknown>, _ctx: MatcherContext): boolean {
301
+ const environment = rule.environment as string | undefined;
302
+ if (!environment) return true;
303
+
304
+ const isProd =
305
+ typeof process !== "undefined" && process.env?.NODE_ENV === "production";
306
+
307
+ if (environment === "production") return isProd;
308
+ if (environment === "development") return !isProd;
309
+ return false;
310
+ }
311
+
312
+ // -------------------------------------------------------------------------
313
+ // Multi matcher (AND/OR combinator)
314
+ // -------------------------------------------------------------------------
315
+
316
+ function multiMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
317
+ const op = (rule.op as string) ?? "and";
318
+ const matchers = rule.matchers as Array<Record<string, unknown>> | undefined;
319
+
320
+ if (!matchers?.length) return true;
321
+
322
+ const results = matchers.map((m) => evaluateMatcher(m, ctx));
323
+ return op === "or" ? results.some(Boolean) : results.every(Boolean);
324
+ }
325
+
326
+ // -------------------------------------------------------------------------
327
+ // Negate matcher
328
+ // -------------------------------------------------------------------------
329
+
330
+ function negateMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
331
+ const matcher = rule.matcher as Record<string, unknown> | undefined;
332
+ if (!matcher) return false;
333
+ return !evaluateMatcher(matcher, ctx);
334
+ }
335
+
166
336
  // -------------------------------------------------------------------------
167
337
  // Registration
168
338
  // -------------------------------------------------------------------------
@@ -181,4 +351,9 @@ export function registerBuiltinMatchers(): void {
181
351
  registerMatcher("website/matchers/host.ts", hostMatcher);
182
352
  registerMatcher("website/matchers/pathname.ts", pathnameMatcher);
183
353
  registerMatcher("website/matchers/queryString.ts", queryStringMatcher);
354
+ registerMatcher("website/matchers/location.ts", locationMatcher);
355
+ registerMatcher("website/matchers/userAgent.ts", userAgentMatcher);
356
+ registerMatcher("website/matchers/environment.ts", environmentMatcher);
357
+ registerMatcher("website/matchers/multi.ts", multiMatcher);
358
+ registerMatcher("website/matchers/negate.ts", negateMatcher);
184
359
  }
@@ -274,6 +274,48 @@ function buildPreviewShell(): string {
274
274
  </html>`;
275
275
  }
276
276
 
277
+ // ---------------------------------------------------------------------------
278
+ // Cloudflare geo cookie injection
279
+ // ---------------------------------------------------------------------------
280
+
281
+ /**
282
+ * Inject Cloudflare geo data as cookies so matchers (location.ts) can
283
+ * read them from MatcherContext.cookies without relying on request.cf.
284
+ *
285
+ * Call this on the incoming request before passing it to the worker entry.
286
+ * Only needed in production Cloudflare Workers where `request.cf` is populated.
287
+ *
288
+ * @example
289
+ * ```ts
290
+ * export default {
291
+ * async fetch(request, env, ctx) {
292
+ * return handler.fetch(injectGeoCookies(request), env, ctx);
293
+ * }
294
+ * };
295
+ * ```
296
+ */
297
+ export function injectGeoCookies(request: Request): Request {
298
+ const cf = (request as unknown as { cf?: Record<string, string> }).cf;
299
+ if (!cf) return request;
300
+
301
+ const parts: string[] = [];
302
+ if (cf.region) parts.push(`__cf_geo_region=${encodeURIComponent(cf.region)}`);
303
+ if (cf.country) parts.push(`__cf_geo_country=${encodeURIComponent(cf.country)}`);
304
+ if (cf.city) parts.push(`__cf_geo_city=${encodeURIComponent(cf.city)}`);
305
+ if (cf.latitude) parts.push(`__cf_geo_lat=${encodeURIComponent(cf.latitude)}`);
306
+ if (cf.longitude) parts.push(`__cf_geo_lng=${encodeURIComponent(cf.longitude)}`);
307
+ if (cf.regionCode) parts.push(`__cf_geo_region_code=${encodeURIComponent(cf.regionCode)}`);
308
+
309
+ if (!parts.length) return request;
310
+
311
+ const existing = request.headers.get("cookie") ?? "";
312
+ const combined = existing ? `${existing}; ${parts.join("; ")}` : parts.join("; ");
313
+ const headers = new Headers(request.headers);
314
+ headers.set("cookie", combined);
315
+
316
+ return new Request(request, { headers });
317
+ }
318
+
277
319
  const MOBILE_RE = /mobile|android|iphone|ipad|ipod/i;
278
320
  const ONE_YEAR = 31536000;
279
321
 
@@ -371,6 +413,19 @@ export function createDecoWorkerEntry(
371
413
  }
372
414
  }
373
415
 
416
+ // Include CF geo data in cache key so location matcher results don't leak
417
+ // across different geos. Applies to both segment and device-based keys.
418
+ const cf = (request as unknown as { cf?: Record<string, string> }).cf;
419
+ if (cf) {
420
+ const geoParts: string[] = [];
421
+ if (cf.country) geoParts.push(cf.country);
422
+ if (cf.region) geoParts.push(cf.region);
423
+ if (cf.city) geoParts.push(cf.city);
424
+ if (geoParts.length) {
425
+ url.searchParams.set("__cf_geo", geoParts.join("|"));
426
+ }
427
+ }
428
+
374
429
  if (buildSegment) {
375
430
  const segment = buildSegment(request);
376
431
  url.searchParams.set("__seg", hashSegment(segment));
@@ -397,7 +452,7 @@ export function createDecoWorkerEntry(
397
452
  return new Response("Unauthorized", { status: 401 });
398
453
  }
399
454
 
400
- let body: { paths?: string[] };
455
+ let body: { paths?: string[]; countries?: string[] };
401
456
  try {
402
457
  body = await request.json();
403
458
  } catch {
@@ -409,6 +464,12 @@ export function createDecoWorkerEntry(
409
464
  return new Response('Body must include "paths": ["/", "/page"]', { status: 400 });
410
465
  }
411
466
 
467
+ // Geo strings to purge location-specific cache variants.
468
+ // Pass ["BR", "BR|São Paulo|Curitiba", ...] to purge specific geo variants.
469
+ // Each string must match the __cf_geo param format: "country|region|city".
470
+ // When omitted, only the non-geo cache entry is purged.
471
+ const geoVariants = body.countries ?? [];
472
+
412
473
  const cache =
413
474
  typeof caches !== "undefined"
414
475
  ? ((caches as unknown as { default?: Cache }).default ?? null)
@@ -432,33 +493,44 @@ export function createDecoWorkerEntry(
432
493
  ]
433
494
  : [];
434
495
 
496
+ // Purge both without geo (non-geo-targeted) and with each specified geo variant
497
+ const geoKeys: (string | null)[] = [null, ...geoVariants];
498
+
435
499
  for (const p of paths) {
436
500
  if (buildSegment && segments.length > 0) {
437
501
  for (const seg of segments) {
438
- const url = new URL(p, baseUrl);
439
- url.searchParams.set("__seg", hashSegment(seg));
440
- const key = new Request(url.toString(), { method: "GET" });
441
- try {
442
- if (await cache.delete(key)) {
443
- purged.push(`${p} (${hashSegment(seg)})`);
502
+ for (const cc of geoKeys) {
503
+ const url = new URL(p, baseUrl);
504
+ url.searchParams.set("__seg", hashSegment(seg));
505
+ if (cc) url.searchParams.set("__cf_geo", cc);
506
+ const key = new Request(url.toString(), { method: "GET" });
507
+ try {
508
+ if (await cache.delete(key)) {
509
+ const label = cc ? `${p} (${hashSegment(seg)}, ${cc})` : `${p} (${hashSegment(seg)})`;
510
+ purged.push(label);
511
+ }
512
+ } catch {
513
+ /* ignore */
444
514
  }
445
- } catch {
446
- /* ignore */
447
515
  }
448
516
  }
449
517
  } else {
450
518
  const devices = deviceSpecificKeys ? (["mobile", "desktop"] as const) : ([null] as const);
451
519
 
452
520
  for (const device of devices) {
453
- const url = new URL(p, baseUrl);
454
- if (device) url.searchParams.set("__cf_device", device);
455
- const key = new Request(url.toString(), { method: "GET" });
456
- try {
457
- if (await cache.delete(key)) {
458
- purged.push(device ? `${p} (${device})` : p);
521
+ for (const cc of geoKeys) {
522
+ const url = new URL(p, baseUrl);
523
+ if (device) url.searchParams.set("__cf_device", device);
524
+ if (cc) url.searchParams.set("__cf_geo", cc);
525
+ const key = new Request(url.toString(), { method: "GET" });
526
+ try {
527
+ if (await cache.delete(key)) {
528
+ const parts = [device, cc].filter(Boolean).join(", ");
529
+ purged.push(parts ? `${p} (${parts})` : p);
530
+ }
531
+ } catch {
532
+ /* ignore */
459
533
  }
460
- } catch {
461
- /* ignore */
462
534
  }
463
535
  }
464
536
  }