@decocms/start 0.20.2 → 0.21.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.
- package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +59 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/deco-framework/README.md +46 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +18 -1
- package/.cursor/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +16 -49
- package/.cursor/skills/deco-to-tanstack-migration/references/server-functions/README.md +120 -0
- package/README.md +84 -0
- package/package.json +1 -1
- package/src/admin/schema.ts +104 -0
- package/src/cms/index.ts +1 -0
- package/src/cms/registry.ts +47 -2
- package/src/cms/resolve.ts +1 -1
- package/src/hooks/DecoPageRenderer.tsx +51 -5
- package/src/matchers/builtins.ts +177 -2
- package/src/sdk/workerEntry.ts +89 -17
|
@@ -15,6 +15,46 @@ Phase-based playbook for converting `deco-sites/*` storefronts from Fresh/Preact
|
|
|
15
15
|
| **@decocms/apps** | `@decocms/apps` | VTEX/Shopify loaders, commerce types, commerce sdk (useOffer, formatPrice, analytics) | Passthrough HTML components, Preact/Fresh refs |
|
|
16
16
|
| **Site repo** | (not published) | All UI: components, hooks, types, routes, styles | No compat/ layer, no aliases beyond `~` |
|
|
17
17
|
|
|
18
|
+
### What Belongs Where
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
@decocms/start (framework)
|
|
22
|
+
├── src/cms/ # Block loading, page resolution, section registry
|
|
23
|
+
│ └── loader.ts # loadBlocks, setBlocks, AsyncLocalStorage for per-request overrides
|
|
24
|
+
├── src/admin/ # Admin protocol: meta, decofile, invoke, render, schema composition
|
|
25
|
+
│ ├── meta.ts # setMetaData() calls composeMeta() at startup; /deco/meta handler
|
|
26
|
+
│ ├── schema.ts # MetaResponse type, composeMeta(), framework block schemas (pages)
|
|
27
|
+
│ ├── render.ts # /live/previews/* for section + page preview (HTML shell)
|
|
28
|
+
│ ├── setup.ts # Client-safe setup (setMetaData, setInvokeLoaders, setRenderShell)
|
|
29
|
+
│ ├── decofile.ts # /.decofile read/reload
|
|
30
|
+
│ ├── invoke.ts # /deco/invoke for loader/action calls
|
|
31
|
+
│ ├── cors.ts # CORS + admin origin validation
|
|
32
|
+
│ └── liveControls.ts # Admin iframe bridge postMessage script
|
|
33
|
+
├── src/sdk/
|
|
34
|
+
│ ├── workerEntry.ts # createDecoWorkerEntry: outermost Cloudflare Worker wrapper
|
|
35
|
+
│ ├── useScript.ts, signal.ts, clx.ts, cachedLoader.ts, instrumentedFetch.ts
|
|
36
|
+
│ └── ...
|
|
37
|
+
├── src/hooks/ # DecoPageRenderer (uses registry, NOT hardcoded map), LiveControls
|
|
38
|
+
├── src/types/ # FnContext, App, AppContext, Section, SectionProps, Resolved
|
|
39
|
+
└── scripts/ # generate-blocks.ts, generate-schema.ts
|
|
40
|
+
|
|
41
|
+
@decocms/apps (commerce)
|
|
42
|
+
├── commerce/types/ # Product, AnalyticsItem, BreadcrumbList, Filter, etc.
|
|
43
|
+
├── commerce/utils/ # mapProductToAnalyticsItem, parseRange, formatRange
|
|
44
|
+
├── commerce/sdk/ # useOffer, useVariantPossibilities, formatPrice, relative, analytics
|
|
45
|
+
├── vtex/ # Client, loaders (actual VTEX API calls)
|
|
46
|
+
└── shopify/ # Client, loaders (actual Shopify API calls)
|
|
47
|
+
|
|
48
|
+
site repo (UI + business logic)
|
|
49
|
+
├── src/components/ # All UI components (Image, Picture, Seo, Theme, etc.)
|
|
50
|
+
├── src/hooks/ # useCart (real VTEX implementation), useUser, useWishlist
|
|
51
|
+
├── src/types/ # widgets.ts (string aliases), vtex.ts (OrderFormItem, etc.)
|
|
52
|
+
├── src/sdk/ # Site-specific contexts, usePlatform, useUI, useSuggestions
|
|
53
|
+
├── src/sections/ # All CMS-renderable sections
|
|
54
|
+
├── src/routes/ # TanStack Router routes
|
|
55
|
+
└── src/server/ # Server functions (invoke.ts — createServerFn wrappers for @decocms/apps)
|
|
56
|
+
```
|
|
57
|
+
|
|
18
58
|
### Architecture Map
|
|
19
59
|
|
|
20
60
|
| Old Stack | New Stack |
|
|
@@ -278,6 +318,24 @@ npm run dev
|
|
|
278
318
|
# 8. Admin — /deco/meta returns JSON, /live/previews works
|
|
279
319
|
```
|
|
280
320
|
|
|
321
|
+
## Quick Start (Full Migration)
|
|
322
|
+
|
|
323
|
+
1. **Scaffold**: `npm create @tanstack/app@latest -- --template cloudflare-workers`
|
|
324
|
+
2. **Copy source**: Move `src/` from deco-site
|
|
325
|
+
3. **Configure Vite**: See `references/vite-config/`
|
|
326
|
+
4. **Install deps**: `npm install @decocms/start @decocms/apps @tanstack/store @tanstack/react-store`
|
|
327
|
+
5. **Link local libs** (if not published): `cd apps-start && npm link && cd ../deco-start && npm link && cd ../my-store && npm link @decocms/apps @decocms/start`
|
|
328
|
+
6. **Rewrite imports** (parallelizable):
|
|
329
|
+
- Preact -> React: `references/imports/`
|
|
330
|
+
- Signals -> TanStack Store: `references/signals/`
|
|
331
|
+
- Deco framework -> inline: `references/deco-framework/`
|
|
332
|
+
- Commerce/widget types -> @decocms/apps + local: `references/commerce/`
|
|
333
|
+
- SDK utilities -> packages: `~/sdk/useOffer` -> `@decocms/apps/commerce/sdk/useOffer`, `~/sdk/useScript` -> `@decocms/start/sdk/useScript`, etc.
|
|
334
|
+
7. **Create local UI components**: Image.tsx, Picture.tsx, Seo.tsx, Theme.tsx in `~/components/ui/`
|
|
335
|
+
8. **Implement platform hooks**: `references/platform-hooks/`
|
|
336
|
+
9. **Build & verify**: `npm run build`
|
|
337
|
+
10. **Final audit**: Zero `from "apps/"`, `from "$store/"`, `from "preact"`, `from "@preact"`, `from "@deco/deco"`, `from "~/sdk/useOffer"`, `from "~/sdk/format"` etc. -- all sdk utilities should come from packages
|
|
338
|
+
|
|
281
339
|
## Key Principles
|
|
282
340
|
|
|
283
341
|
1. **No compat layer anywhere** -- not in `@decocms/start`, not in `@decocms/apps`, not in the site repo
|
|
@@ -492,6 +550,7 @@ The conductor approach that worked (836 errors → 0 across 213 files) treated e
|
|
|
492
550
|
| Vite configuration | `references/vite-config/` |
|
|
493
551
|
| Automation commands | `references/codemod-commands.md` |
|
|
494
552
|
| Admin schema composition | `src/admin/schema.ts` in `@decocms/start` |
|
|
553
|
+
| Server functions (`createServerFn`) | `references/server-functions/` |
|
|
495
554
|
| Common gotchas (45 items) | `references/gotchas.md` |
|
|
496
555
|
| setup.ts template | `templates/setup-ts.md` |
|
|
497
556
|
| vite.config.ts template | `templates/vite-config.md` |
|
|
@@ -126,3 +126,49 @@ grep -r '@deco/deco' src/ --include='*.ts' --include='*.tsx'
|
|
|
126
126
|
grep -r '\$fresh/' src/ --include='*.ts' --include='*.tsx'
|
|
127
127
|
# Both should return ZERO matches
|
|
128
128
|
```
|
|
129
|
+
|
|
130
|
+
## Deferred Sections and CLS Prevention
|
|
131
|
+
|
|
132
|
+
When a CMS page wraps a section in `website/sections/Rendering/Lazy.tsx`, it becomes a `DeferredSection`. The `DeferredSectionWrapper` renders a skeleton via `getSectionOptions(key).loadingFallback`.
|
|
133
|
+
|
|
134
|
+
**Problem**: `getSectionOptions` returns `undefined` if the section module hasn't loaded yet. The module is loaded async in a `useEffect` (`preloadSectionModule`), so on first paint `skeleton = null` — the space is blank and the footer jumps down as sections load (CLS).
|
|
135
|
+
|
|
136
|
+
**Fix**: Use `registerSection` with `loadingFallback` pre-populated in `setup.ts` instead of including the section in the bulk `registerSections` call:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
import { registerSection, registerSections } from "@decocms/start/cms";
|
|
140
|
+
import PDPSkeleton from "./components/product/PDPSkeleton";
|
|
141
|
+
|
|
142
|
+
registerSections({
|
|
143
|
+
// ... all other sections (NOT the lazy-wrapped one)
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Pre-populate sectionOptions synchronously so DeferredSectionWrapper
|
|
147
|
+
// has loadingFallback on the very first render — no null flash.
|
|
148
|
+
registerSection(
|
|
149
|
+
"site/sections/Product/NotFoundChallenge.tsx",
|
|
150
|
+
() => import("./sections/Product/NotFoundChallenge") as any,
|
|
151
|
+
{ loadingFallback: PDPSkeleton },
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Why `as any`**: `registerSection` expects `Promise<SectionModule>` but the actual module export is wider (includes `LoadingFallback`, `loader`, etc.). The cast is safe.
|
|
156
|
+
|
|
157
|
+
**Composite skeleton**: Create a `PDPSkeleton` that combines all `LoadingFallback` exports from the section's sub-components:
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
// src/components/product/PDPSkeleton.tsx
|
|
161
|
+
import { LoadingFallback as MountedPDPSkeleton } from "~/components/product/MountedPDP";
|
|
162
|
+
import { LoadingFallback as ProductDescriptionSkeleton } from "~/components/product/MountedPDP/ProductDescription";
|
|
163
|
+
import { LoadingFallback as ProductFAQSkeleton } from "~/components/product/MountedPDP/ProductFAQ";
|
|
164
|
+
|
|
165
|
+
export default function PDPSkeleton() {
|
|
166
|
+
return (
|
|
167
|
+
<div>
|
|
168
|
+
<MountedPDPSkeleton />
|
|
169
|
+
<ProductDescriptionSkeleton />
|
|
170
|
+
<ProductFAQSkeleton />
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Migration Gotchas
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
46 pitfalls discovered during real migrations (espacosmart-storefront, osklen).
|
|
4
4
|
|
|
5
5
|
## 1. Section Loaders Don't Execute
|
|
6
6
|
|
|
@@ -717,3 +717,20 @@ Or in project `.npmrc` with an env var (for CI):
|
|
|
717
717
|
```
|
|
718
718
|
|
|
719
719
|
**Tradeoff with `github:` syntax**: No semver resolution — `npm update` is meaningless. Pin to a tag for stability: `github:decocms/deco-start#v0.14.2`. Without a tag, you get HEAD of the default branch.
|
|
720
|
+
|
|
721
|
+
## 46. `export type { X } from "..."` Does Not Scope `X` for Parameter Annotations
|
|
722
|
+
|
|
723
|
+
Re-exporting a type makes it available to importers but does NOT bring it into scope in the same file:
|
|
724
|
+
|
|
725
|
+
```typescript
|
|
726
|
+
// BROKEN — Props is not in scope
|
|
727
|
+
export type { Props } from "~/components/ui/Foo";
|
|
728
|
+
export default function Foo({ bar }: Props) { } // Error: Cannot find name 'Props'
|
|
729
|
+
|
|
730
|
+
// CORRECT — import separately, then re-export
|
|
731
|
+
import type { Props } from "~/components/ui/Foo";
|
|
732
|
+
export type { Props };
|
|
733
|
+
export default function Foo({ bar }: Props) { }
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
This is a common pattern in section wrapper files (e.g. `src/sections/CountDownApp/CountDownApp.tsx`).
|
|
@@ -18,60 +18,27 @@ The storefront domain (e.g., `my-store.deco.site`) differs from the VTEX checkou
|
|
|
18
18
|
|
|
19
19
|
Use TanStack Start `createServerFn` to create server-side proxy functions that the client hook calls transparently.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
> See `references/server-functions/README.md` for the full pattern and all TypeScript pitfalls.
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
import { createServerFn } from "@tanstack/react-start";
|
|
23
|
+
### Server Functions (`src/server/invoke.ts`)
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
const API_KEY = process.env.VTEX_APP_KEY!;
|
|
28
|
-
const API_TOKEN = process.env.VTEX_APP_TOKEN!;
|
|
29
|
-
|
|
30
|
-
export const getOrCreateCart = createServerFn({ method: "GET" })
|
|
31
|
-
.validator((orderFormId: string) => orderFormId)
|
|
32
|
-
.handler(async ({ data: orderFormId }) => {
|
|
33
|
-
const url = orderFormId
|
|
34
|
-
? `https://${ACCOUNT}.vtexcommercestable.com.br/api/checkout/pub/orderForm/${orderFormId}`
|
|
35
|
-
: `https://${ACCOUNT}.vtexcommercestable.com.br/api/checkout/pub/orderForm`;
|
|
36
|
-
const res = await fetch(url, {
|
|
37
|
-
method: "POST",
|
|
38
|
-
headers: {
|
|
39
|
-
"Content-Type": "application/json",
|
|
40
|
-
"X-VTEX-API-AppKey": API_KEY,
|
|
41
|
-
"X-VTEX-API-AppToken": API_TOKEN,
|
|
42
|
-
},
|
|
43
|
-
body: JSON.stringify({ expectedOrderFormSections: ["items", "totalizers", "shippingData", "clientPreferencesData", "storePreferencesData", "marketingData"] }),
|
|
44
|
-
});
|
|
45
|
-
return res.json();
|
|
46
|
-
});
|
|
25
|
+
Wrap `@decocms/apps` VTEX actions in `createServerFn`. Always use `.inputValidator()` (not `.validator()`) and return `Promise<any>` to bypass `ValidateSerializable`:
|
|
47
26
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
);
|
|
59
|
-
return res.json();
|
|
27
|
+
```typescript
|
|
28
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
29
|
+
import { getOrCreateCart } from "@decocms/apps/vtex/actions/checkout";
|
|
30
|
+
|
|
31
|
+
// Preferred: wrap @decocms/apps actions (handles auth + API details internally)
|
|
32
|
+
const _getOrCreateCart = createServerFn({ method: "POST" })
|
|
33
|
+
.inputValidator((data: { orderFormId?: string }) => data)
|
|
34
|
+
.handler(async ({ data }): Promise<any> => {
|
|
35
|
+
const result = await getOrCreateCart(data.orderFormId);
|
|
36
|
+
return (result as any).data;
|
|
60
37
|
});
|
|
61
38
|
|
|
62
|
-
export const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const res = await fetch(
|
|
66
|
-
`https://${ACCOUNT}.vtexcommercestable.com.br/api/checkout/pub/orderForm/${data.orderFormId}/items/update`,
|
|
67
|
-
{
|
|
68
|
-
method: "POST",
|
|
69
|
-
headers: { "Content-Type": "application/json", "X-VTEX-API-AppKey": API_KEY, "X-VTEX-API-AppToken": API_TOKEN },
|
|
70
|
-
body: JSON.stringify({ orderItems: data.items }),
|
|
71
|
-
},
|
|
72
|
-
);
|
|
73
|
-
return res.json();
|
|
74
|
-
});
|
|
39
|
+
export const invoke = {
|
|
40
|
+
vtex: { actions: { getOrCreateCart: _getOrCreateCart } },
|
|
41
|
+
} as const;
|
|
75
42
|
```
|
|
76
43
|
|
|
77
44
|
### Hook (~/hooks/useCart.ts)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Server Functions (`createServerFn`)
|
|
2
|
+
|
|
3
|
+
All VTEX API calls that need credentials (cart, MasterData, session, newsletter, shipping simulation) must run on the Worker — not on the client — to avoid CORS errors and keep secrets server-side.
|
|
4
|
+
|
|
5
|
+
## Pattern: `src/server/invoke.ts`
|
|
6
|
+
|
|
7
|
+
Create a single file that wraps `@decocms/apps` invoke functions in `createServerFn`. This keeps VTEX credentials inside the Worker and gives type-safe RPC from the browser.
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
11
|
+
import {
|
|
12
|
+
getOrCreateCart,
|
|
13
|
+
addItemsToCart,
|
|
14
|
+
updateCartItems,
|
|
15
|
+
addCouponToCart,
|
|
16
|
+
updateOrderFormAttachment,
|
|
17
|
+
simulateCart,
|
|
18
|
+
} from "@decocms/apps/vtex/actions/checkout";
|
|
19
|
+
|
|
20
|
+
// CRITICAL: always chain .inputValidator() before .handler()
|
|
21
|
+
// Without it, ctx.data is typed as `undefined` and every access fails.
|
|
22
|
+
const _getOrCreateCart = createServerFn({ method: "POST" })
|
|
23
|
+
.inputValidator((data: { orderFormId?: string }) => data)
|
|
24
|
+
.handler(async ({ data }): Promise<any> => {
|
|
25
|
+
// Promise<any> bypasses ValidateSerializable on OrderForm.storeId: unknown
|
|
26
|
+
const result = await getOrCreateCart(data.orderFormId);
|
|
27
|
+
return (result as any).data;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const _addItemsToCart = createServerFn({ method: "POST" })
|
|
31
|
+
.inputValidator((data: {
|
|
32
|
+
orderFormId: string;
|
|
33
|
+
orderItems: Array<{ id: string; seller: string; quantity: number }>;
|
|
34
|
+
}) => data)
|
|
35
|
+
.handler(async ({ data }): Promise<any> => {
|
|
36
|
+
const result = await addItemsToCart(data.orderFormId, data.orderItems);
|
|
37
|
+
return (result as any).data;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ... repeat for updateCartItems, addCouponToCart, simulateCart, etc.
|
|
41
|
+
|
|
42
|
+
export const invoke = {
|
|
43
|
+
vtex: {
|
|
44
|
+
actions: {
|
|
45
|
+
getOrCreateCart: _getOrCreateCart,
|
|
46
|
+
addItemsToCart: _addItemsToCart,
|
|
47
|
+
// ...
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
} as const;
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Required: `.inputValidator()`
|
|
54
|
+
|
|
55
|
+
Without `.inputValidator()`, TanStack Start types `ctx.data` as `undefined`:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
// BROKEN — ctx.data is typed as undefined
|
|
59
|
+
createServerFn({ method: "POST" })
|
|
60
|
+
.handler(async (ctx) => {
|
|
61
|
+
ctx.data.orderFormId; // TS Error: ctx.data is undefined
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// CORRECT
|
|
65
|
+
createServerFn({ method: "POST" })
|
|
66
|
+
.inputValidator((data: { orderFormId: string }) => data)
|
|
67
|
+
.handler(async ({ data }) => {
|
|
68
|
+
data.orderFormId; // typed correctly
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Required: `Promise<any>` Return Type
|
|
73
|
+
|
|
74
|
+
TanStack Start validates that return types are serializable via `ValidateSerializable`. Types with `unknown` fields (e.g. `OrderForm.storeId: unknown`) fail this check at compile time:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Type 'OrderForm' does not satisfy the constraint 'Serializable'.
|
|
78
|
+
Types of property 'storeId' are incompatible.
|
|
79
|
+
Type 'unknown' is not assignable to type 'Serializable'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Fix**: Annotate the handler return as `Promise<any>`:
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
.handler(async ({ data }): Promise<any> => {
|
|
86
|
+
return await doSomething(data);
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Stripping Non-Serializable Properties
|
|
91
|
+
|
|
92
|
+
Loader results that include function properties must have those functions removed before being returned — Seroval (TanStack's hydration serializer) cannot serialize functions.
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
.handler(async ({ data }): Promise<any> => {
|
|
96
|
+
const result = await productReviewsLoader(data);
|
|
97
|
+
// Strip functions before serialization
|
|
98
|
+
const { getProductReview: _r, reviewLikeAction: _l, ...serializable } = result as any;
|
|
99
|
+
return serializable;
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Invoke from `useCart`
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// ~/hooks/useCart.ts
|
|
107
|
+
import { invoke } from "~/server/invoke";
|
|
108
|
+
|
|
109
|
+
const updated = await invoke.vtex.actions.addItemsToCart({
|
|
110
|
+
data: { orderFormId, orderItems },
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Verification
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# No direct VTEX API calls from client components
|
|
118
|
+
rg 'vtexcommercestable\.com\.br' src/ --include='*.tsx'
|
|
119
|
+
# Should return ZERO (all calls go through invoke)
|
|
120
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# @decocms/start
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@decocms/start)
|
|
4
|
+
[](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
package/src/admin/schema.ts
CHANGED
|
@@ -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
package/src/cms/registry.ts
CHANGED
|
@@ -132,16 +132,61 @@ 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,
|
|
142
|
-
for (const [key,
|
|
161
|
+
export function registerSectionsSync(sections: Record<string, SyncSectionEntry>): void {
|
|
162
|
+
for (const [key, entry] of Object.entries(sections)) {
|
|
163
|
+
const raw = typeof entry === "function" ? entry : (entry as any).default;
|
|
164
|
+
// Accept functions and React wrapper objects (React.memo, forwardRef, lazy)
|
|
165
|
+
const REACT_WRAPPERS = [
|
|
166
|
+
Symbol.for("react.memo"),
|
|
167
|
+
Symbol.for("react.forward_ref"),
|
|
168
|
+
Symbol.for("react.lazy"),
|
|
169
|
+
];
|
|
170
|
+
const component =
|
|
171
|
+
typeof raw === "function" ||
|
|
172
|
+
(raw != null &&
|
|
173
|
+
typeof raw === "object" &&
|
|
174
|
+
REACT_WRAPPERS.includes((raw as any).$$typeof))
|
|
175
|
+
? raw
|
|
176
|
+
: undefined;
|
|
177
|
+
if (!component) {
|
|
178
|
+
console.warn(`[registerSectionsSync] "${key}" has no callable default export — skipping`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
143
181
|
syncComponents[key] = component;
|
|
144
182
|
resolvedComponents[key] = component;
|
|
183
|
+
|
|
184
|
+
if (typeof entry !== "function") {
|
|
185
|
+
const opts: SectionOptions = { ...sectionOptions[key] };
|
|
186
|
+
if (entry.LoadingFallback) opts.loadingFallback = entry.LoadingFallback;
|
|
187
|
+
if (entry.ErrorFallback) opts.errorFallback = entry.ErrorFallback;
|
|
188
|
+
sectionOptions[key] = opts;
|
|
189
|
+
}
|
|
145
190
|
}
|
|
146
191
|
}
|
|
147
192
|
|
package/src/cms/resolve.ts
CHANGED
|
@@ -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
|
|
195
|
-
const
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
},
|
package/src/matchers/builtins.ts
CHANGED
|
@@ -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
|
}
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -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
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
}
|