@decocms/start 0.39.0 → 0.40.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/.agents/skills/deco-migrate-script/SKILL.md +434 -0
- package/.agents/skills/deco-to-tanstack-migration/SKILL.md +382 -0
- package/.agents/skills/deco-to-tanstack-migration/references/admin-cms.md +154 -0
- package/{.cursor/skills/deco-async-rendering-site-guide/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/async-rendering.md} +296 -31
- package/.agents/skills/deco-to-tanstack-migration/references/codemod-commands.md +174 -0
- package/.agents/skills/deco-to-tanstack-migration/references/commerce/README.md +78 -0
- package/.agents/skills/deco-to-tanstack-migration/references/css-styling.md +156 -0
- package/.agents/skills/deco-to-tanstack-migration/references/deco-framework/README.md +128 -0
- package/.agents/skills/deco-to-tanstack-migration/references/gotchas.md +13 -0
- package/{.cursor/skills/deco-tanstack-hydration-fixes/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/hydration-fixes.md} +139 -4
- package/.agents/skills/deco-to-tanstack-migration/references/imports/README.md +70 -0
- package/{.cursor/skills/deco-islands-migration/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/islands.md} +0 -14
- package/.agents/skills/deco-to-tanstack-migration/references/jsx-migration.md +80 -0
- package/.agents/skills/deco-to-tanstack-migration/references/matchers.md +1064 -0
- package/{.cursor/skills/deco-tanstack-navigation/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/navigation.md} +1 -16
- package/.agents/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +154 -0
- package/.agents/skills/deco-to-tanstack-migration/references/react-hooks-patterns.md +142 -0
- package/.agents/skills/deco-to-tanstack-migration/references/react-signals-state.md +72 -0
- package/{.cursor/skills/deco-tanstack-search/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/search.md} +1 -13
- package/.agents/skills/deco-to-tanstack-migration/references/signals/README.md +220 -0
- package/{.cursor/skills/deco-tanstack-storefront-patterns/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/storefront-patterns.md} +1 -137
- package/.agents/skills/deco-to-tanstack-migration/references/vite-config/README.md +78 -0
- package/.agents/skills/deco-to-tanstack-migration/references/vtex-commerce.md +165 -0
- package/.agents/skills/deco-to-tanstack-migration/references/worker-cloudflare.md +209 -0
- package/.agents/skills/deco-to-tanstack-migration/templates/package-json.md +55 -0
- package/.agents/skills/deco-to-tanstack-migration/templates/root-route.md +110 -0
- package/.agents/skills/deco-to-tanstack-migration/templates/router.md +96 -0
- package/.agents/skills/deco-to-tanstack-migration/templates/setup-ts.md +167 -0
- package/.agents/skills/deco-to-tanstack-migration/templates/vite-config.md +122 -0
- package/.agents/skills/deco-to-tanstack-migration/templates/worker-entry.md +67 -0
- package/README.md +45 -0
- package/package.json +1 -1
- package/src/routes/cmsRoute.ts +7 -4
- package/.cursor/skills/deco-async-rendering-architecture/SKILL.md +0 -270
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
# Matchers: Architecture & Migration
|
|
2
|
+
|
|
3
|
+
## Part 1: Architecture
|
|
4
|
+
|
|
5
|
+
Internal documentation for the matcher system in `@decocms/start` + `deco-cx/apps`.
|
|
6
|
+
|
|
7
|
+
### When to Use This Reference
|
|
8
|
+
|
|
9
|
+
- Creating a new custom matcher (server-side or client-side)
|
|
10
|
+
- Debugging A/B tests or flags not activating
|
|
11
|
+
- Fixing CLS caused by unregistered/unknown matchers
|
|
12
|
+
- Understanding how flags evaluate variants (first-match wins)
|
|
13
|
+
- Implementing geo-targeted content with Cloudflare Workers
|
|
14
|
+
- Adding location, device, or session-based content variants
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
### What Is a Matcher?
|
|
19
|
+
|
|
20
|
+
A **matcher** is a serializable function registered in the CMS that evaluates boolean conditions about the current HTTP request. Matchers power:
|
|
21
|
+
|
|
22
|
+
1. **A/B testing** — show Variant A to 50% of users, Variant B to the rest
|
|
23
|
+
2. **Feature flags** — enable features by device, region, cookie, or query param
|
|
24
|
+
3. **Personalization** — different content per location, session, or user segment
|
|
25
|
+
|
|
26
|
+
In the CMS JSON (decofile), a matcher looks like:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"__resolveType": "website/flags/multivariate.ts",
|
|
31
|
+
"variants": [
|
|
32
|
+
{
|
|
33
|
+
"rule": { "__resolveType": "website/matchers/device.ts", "mobile": true },
|
|
34
|
+
"value": { "__resolveType": "site/sections/HeroMobile.tsx" }
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"rule": null,
|
|
38
|
+
"value": { "__resolveType": "site/sections/HeroDesktop.tsx" }
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The `rule` is evaluated server-side during page resolution. **First variant that matches wins.**
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
### Architecture Overview
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
Request
|
|
52
|
+
└─ resolveDecoPage()
|
|
53
|
+
└─ For each multivariate flag:
|
|
54
|
+
└─ evaluateMatcher(rule, matcherCtx)
|
|
55
|
+
├─ Inline matchers: always, never, device, random, date
|
|
56
|
+
├─ Custom registry: registerMatcher(key, fn) ← setup.ts
|
|
57
|
+
└─ Unknown key → returns false + console.warn ← CLS risk!
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Critical insight**: Unknown matchers return `false`. If `website/matchers/location.ts` is not registered, ALL location-based variants default to `false`, causing every user to see the "no match" variant — which can trigger React Reconnect events and severe CLS.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
### MatcherContext
|
|
65
|
+
|
|
66
|
+
Defined in `@decocms/start/src/cms/resolve.ts`:
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
export interface MatcherContext {
|
|
70
|
+
userAgent?: string; // User-Agent header value
|
|
71
|
+
url?: string; // Full URL with query string
|
|
72
|
+
path?: string; // URL pathname only
|
|
73
|
+
cookies?: Record<string, string>; // Parsed cookies (key → value)
|
|
74
|
+
headers?: Record<string, string>; // All request headers
|
|
75
|
+
request?: Request; // Raw Request object
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Important**: `cookies` is the most reliable field for custom matchers. The `request` object is not always populated by `cmsRoute.ts`. When injecting data from Cloudflare Workers, use the **cookie injection pattern** (see custom-matcher-guide.md).
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### Evaluation Engine: `evaluateMatcher()`
|
|
84
|
+
|
|
85
|
+
Located in `@decocms/start/src/cms/resolve.ts`:
|
|
86
|
+
|
|
87
|
+
1. If no rule → returns `true` (default variant, always matches)
|
|
88
|
+
2. Extracts `__resolveType` from the rule object
|
|
89
|
+
3. **Inline matchers** (hardcoded switch): `always`, `never`, `device`, `random`, `date`
|
|
90
|
+
4. **Named block references**: recursively resolves the block, then evaluates
|
|
91
|
+
5. **Custom registry** (`G.__deco.customMatchers`): looks up `customMatchers[resolveType]` and calls it
|
|
92
|
+
6. **Unknown type**: returns `false` + logs a `console.warn`
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
// Pseudo-code of evaluateMatcher:
|
|
96
|
+
function evaluateMatcher(rule, ctx) {
|
|
97
|
+
if (!rule) return true; // no rule = default
|
|
98
|
+
const type = rule.__resolveType;
|
|
99
|
+
switch (type) {
|
|
100
|
+
case "website/matchers/always.ts": return true;
|
|
101
|
+
case "website/matchers/never.ts": return false;
|
|
102
|
+
case "website/matchers/device.ts": return evaluateDevice(rule, ctx);
|
|
103
|
+
case "website/matchers/random.ts": return Math.random() < rule.traffic;
|
|
104
|
+
case "website/matchers/date.ts": return evaluateDate(rule);
|
|
105
|
+
default:
|
|
106
|
+
const fn = G.__deco.customMatchers[type];
|
|
107
|
+
if (fn) return fn(rule, ctx);
|
|
108
|
+
console.warn(`Unknown matcher: ${type}`); // → returns false!
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
### Registering Matchers: Setup
|
|
117
|
+
|
|
118
|
+
Call `registerBuiltinMatchers()` + any custom matchers in `src/setup.ts`, before the app serves requests:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
// src/setup.ts
|
|
122
|
+
import { registerBuiltinMatchers } from "@decocms/start/matchers/builtins";
|
|
123
|
+
import { registerLocationMatcher } from "./matchers/location";
|
|
124
|
+
|
|
125
|
+
registerBuiltinMatchers(); // registers cookie, cron, host, pathname, queryString
|
|
126
|
+
registerLocationMatcher(); // registers website/matchers/location.ts
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**`registerMatcher(key, fn)` API:**
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
133
|
+
|
|
134
|
+
registerMatcher("website/matchers/my-custom.ts", (rule, ctx) => {
|
|
135
|
+
// rule: the CMS config object for this matcher
|
|
136
|
+
// ctx: MatcherContext with { cookies, path, userAgent, url, headers }
|
|
137
|
+
return Boolean(ctx.cookies?.["feature-flag"] === "enabled");
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
### Built-in Matchers Reference
|
|
144
|
+
|
|
145
|
+
| Matcher | Key | What it checks |
|
|
146
|
+
|---------|-----|----------------|
|
|
147
|
+
| Always | `website/matchers/always.ts` | Always `true` |
|
|
148
|
+
| Never | `website/matchers/never.ts` | Always `false` |
|
|
149
|
+
| Device | `website/matchers/device.ts` | Mobile/desktop detection via User-Agent |
|
|
150
|
+
| Random | `website/matchers/random.ts` | `Math.random() < traffic` (A/B split) |
|
|
151
|
+
| Date | `website/matchers/date.ts` | Current time within start/end window |
|
|
152
|
+
| Cookie | `website/matchers/cookie.ts` | Cookie name === value |
|
|
153
|
+
| Cron | `website/matchers/cron.ts` | Cron schedule window |
|
|
154
|
+
| Host | `website/matchers/host.ts` | Hostname matches |
|
|
155
|
+
| Pathname | `website/matchers/pathname.ts` | Path regex include/exclude lists |
|
|
156
|
+
| QueryString | `website/matchers/queryString.ts` | Query param (equals/greater/includes/exists) |
|
|
157
|
+
| Location | `website/matchers/location.ts` | Country, region, city (**needs registration!**) |
|
|
158
|
+
| UserAgent | `website/matchers/userAgent.ts` | User-Agent regex |
|
|
159
|
+
| Environment | `website/matchers/environment.ts` | Production vs development |
|
|
160
|
+
| Multi | `website/matchers/multi.ts` | AND/OR combinator of other matchers |
|
|
161
|
+
| Negate | `website/matchers/negate.ts` | Inverts another matcher result |
|
|
162
|
+
| PostHog | `posthog/matchers/featureFlag.ts` | PostHog feature flags |
|
|
163
|
+
|
|
164
|
+
### Device Matcher
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
// Rule props:
|
|
168
|
+
{ mobile?: boolean; desktop?: boolean }
|
|
169
|
+
|
|
170
|
+
// Evaluates via User-Agent detection
|
|
171
|
+
// Example: show section only on mobile
|
|
172
|
+
{ "__resolveType": "website/matchers/device.ts", "mobile": true }
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Random Matcher (A/B Testing)
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
// Rule props:
|
|
179
|
+
{ traffic: number; sticky?: "session" }
|
|
180
|
+
|
|
181
|
+
// traffic: 0.0 to 1.0 (e.g., 0.5 = 50%)
|
|
182
|
+
// sticky: "session" → stores result in cookie for 30 days (consistent UX)
|
|
183
|
+
{ "__resolveType": "website/matchers/random.ts", "traffic": 0.5, "sticky": "session" }
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Cookie Matcher
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
// Rule props:
|
|
190
|
+
{ name: string; value: string }
|
|
191
|
+
|
|
192
|
+
// Checks if ctx.cookies[name] === value
|
|
193
|
+
{ "__resolveType": "website/matchers/cookie.ts", "name": "ab-test", "value": "variant-a" }
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Pathname Matcher
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
// Rule props:
|
|
200
|
+
{ includePatterns?: string[]; excludePatterns?: string[] }
|
|
201
|
+
|
|
202
|
+
// Each pattern is a regex tested against ctx.path
|
|
203
|
+
{ "__resolveType": "website/matchers/pathname.ts", "includePatterns": ["^/categoria/"] }
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### QueryString Matcher
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
// Rule props:
|
|
210
|
+
{ name: string; value?: string; operator: "equals" | "greater" | "includes" | "exists" }
|
|
211
|
+
|
|
212
|
+
{ "__resolveType": "website/matchers/queryString.ts", "name": "preview", "operator": "exists" }
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Location Matcher (requires registration)
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// Rule props from deco-cx/apps:
|
|
219
|
+
interface LocationRule {
|
|
220
|
+
includeLocations?: Array<{ country?: string; regionCode?: string; city?: string }>;
|
|
221
|
+
excludeLocations?: Array<{ country?: string; regionCode?: string; city?: string }>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// country: full country name (e.g., "Brasil", "Brazil")
|
|
225
|
+
// regionCode: full state/region name from Cloudflare (e.g., "São Paulo", "Paraná")
|
|
226
|
+
// city: city name (case-insensitive)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
### Creating Custom Matchers
|
|
232
|
+
|
|
233
|
+
#### Pattern 1: Cookie-based (simplest)
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
// src/matchers/preview-mode.ts
|
|
237
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
238
|
+
|
|
239
|
+
export function registerPreviewMatcher(): void {
|
|
240
|
+
registerMatcher("website/matchers/preview-mode.ts", (rule, ctx) => {
|
|
241
|
+
return ctx.cookies?.["preview"] === "true";
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
#### Pattern 2: Header-based
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
250
|
+
|
|
251
|
+
export function registerInternalMatcher(): void {
|
|
252
|
+
registerMatcher("website/matchers/internal.ts", (rule, ctx) => {
|
|
253
|
+
const ip = ctx.headers?.["cf-connecting-ip"] ?? "";
|
|
254
|
+
const allowedIPs = (rule as { ips?: string[] }).ips ?? [];
|
|
255
|
+
return allowedIPs.includes(ip);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
#### Pattern 3: Cloudflare Geo (server-side location)
|
|
261
|
+
|
|
262
|
+
This pattern solves the #1 CLS cause: `website/matchers/location.ts` returning `false` for all users because it isn't registered.
|
|
263
|
+
|
|
264
|
+
**Step 1**: Inject CF geo data as cookies in `worker-entry.ts`:
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
// src/worker-entry.ts
|
|
268
|
+
function injectGeoCookies(request: Request): Request {
|
|
269
|
+
const cf = (request as unknown as { cf?: Record<string, string> }).cf;
|
|
270
|
+
if (!cf) return request;
|
|
271
|
+
const parts: string[] = [];
|
|
272
|
+
if (cf.region) parts.push(`__cf_geo_region=${encodeURIComponent(cf.region)}`);
|
|
273
|
+
if (cf.country) parts.push(`__cf_geo_country=${encodeURIComponent(cf.country)}`);
|
|
274
|
+
if (cf.city) parts.push(`__cf_geo_city=${encodeURIComponent(cf.city)}`);
|
|
275
|
+
if (!parts.length) return request;
|
|
276
|
+
const existing = request.headers.get("cookie") ?? "";
|
|
277
|
+
const combined = existing ? `${existing}; ${parts.join("; ")}` : parts.join("; ");
|
|
278
|
+
const headers = new Headers(request.headers);
|
|
279
|
+
headers.set("cookie", combined);
|
|
280
|
+
return new Request(request, { headers });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// In export default { fetch }:
|
|
284
|
+
return await handler.fetch(injectGeoCookies(request));
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
**Step 2**: Read cookies in the matcher (`src/matchers/location.ts`):
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
291
|
+
|
|
292
|
+
// Cloudflare cf.country gives ISO code (e.g., "BR")
|
|
293
|
+
// CMS stores full names (e.g., "Brasil") — need mapping
|
|
294
|
+
const COUNTRY_NAME_TO_CODE: Record<string, string> = {
|
|
295
|
+
Brasil: "BR", Brazil: "BR",
|
|
296
|
+
Argentina: "AR", Chile: "CL",
|
|
297
|
+
Colombia: "CO", Mexico: "MX",
|
|
298
|
+
Peru: "PE", Uruguay: "UY",
|
|
299
|
+
"United States": "US", USA: "US",
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
interface LocationRule {
|
|
303
|
+
country?: string;
|
|
304
|
+
regionCode?: string;
|
|
305
|
+
city?: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function matchesRule(loc: LocationRule, region: string, country: string, city: string): boolean {
|
|
309
|
+
if (loc.country) {
|
|
310
|
+
const code = COUNTRY_NAME_TO_CODE[loc.country] ?? loc.country;
|
|
311
|
+
if (code !== country) return false;
|
|
312
|
+
}
|
|
313
|
+
if (loc.regionCode && loc.regionCode !== region) return false;
|
|
314
|
+
if (loc.city && loc.city.toLowerCase() !== city.toLowerCase()) return false;
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function registerLocationMatcher(): void {
|
|
319
|
+
registerMatcher("website/matchers/location.ts", (rule, ctx) => {
|
|
320
|
+
const cookies = ctx.cookies ?? {};
|
|
321
|
+
const region = cookies.__cf_geo_region ? decodeURIComponent(cookies.__cf_geo_region) : "";
|
|
322
|
+
const country = cookies.__cf_geo_country ? decodeURIComponent(cookies.__cf_geo_country) : "";
|
|
323
|
+
const city = cookies.__cf_geo_city ? decodeURIComponent(cookies.__cf_geo_city) : "";
|
|
324
|
+
|
|
325
|
+
const r = rule as { includeLocations?: LocationRule[]; excludeLocations?: LocationRule[] };
|
|
326
|
+
|
|
327
|
+
if (r.excludeLocations?.some(loc => matchesRule(loc, region, country, city))) return false;
|
|
328
|
+
if (r.includeLocations?.length) {
|
|
329
|
+
return r.includeLocations.some(loc => matchesRule(loc, region, country, city));
|
|
330
|
+
}
|
|
331
|
+
return true;
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**Step 3**: Register in `src/setup.ts`:
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
import { registerBuiltinMatchers } from "@decocms/start/matchers/builtins";
|
|
340
|
+
import { registerLocationMatcher } from "./matchers/location";
|
|
341
|
+
|
|
342
|
+
registerBuiltinMatchers();
|
|
343
|
+
registerLocationMatcher();
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
### Flags & Variants: How Resolution Works
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
resolveDecoPage()
|
|
352
|
+
└─ resolveVariants() for each multivariate block
|
|
353
|
+
└─ for (const variant of variants):
|
|
354
|
+
if (evaluateMatcher(variant.rule, matcherCtx)):
|
|
355
|
+
return variant.value // FIRST MATCH WINS, stops here
|
|
356
|
+
└─ return undefined (no match — section not rendered)
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
**Variant ordering rules:**
|
|
360
|
+
- More specific rules go first (e.g., device + location combined)
|
|
361
|
+
- Default/fallback variant goes last with `rule: null`
|
|
362
|
+
- If NO variant matches (all rules `false`, no `null` rule), the entire section is omitted
|
|
363
|
+
|
|
364
|
+
**Session stickiness** (Random matcher):
|
|
365
|
+
- `sticky: "session"` stores the matched variant in a cookie (`deco_sticky_<hash>`)
|
|
366
|
+
- Cookie expires in 30 days
|
|
367
|
+
- Ensures the same user always sees the same variant
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
### Debugging Matchers
|
|
372
|
+
|
|
373
|
+
#### Check if matcher is registered
|
|
374
|
+
|
|
375
|
+
Look for console warnings at startup:
|
|
376
|
+
```
|
|
377
|
+
[deco] Unknown matcher: website/matchers/location.ts
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
#### Diagnose from Chrome Trace
|
|
381
|
+
|
|
382
|
+
- Open `chrome://tracing` and load a recorded trace
|
|
383
|
+
- Look for **"React Reconnect"** events — each one = a subtree remount
|
|
384
|
+
- 10+ reconnects on the same page → likely an unregistered matcher causing all variants to resolve to `false`, then the client re-renders when JS loads and re-evaluates
|
|
385
|
+
|
|
386
|
+
#### Force a variant via query string
|
|
387
|
+
|
|
388
|
+
Use `?__deco_matcher_override[key]=true` (if supported by the framework version) to test specific variants without real traffic.
|
|
389
|
+
|
|
390
|
+
#### Verify Cloudflare geo data
|
|
391
|
+
|
|
392
|
+
Add a debug endpoint or check cookies in DevTools:
|
|
393
|
+
- `__cf_geo_country` → e.g., `BR`
|
|
394
|
+
- `__cf_geo_region` → e.g., `S%C3%A3o%20Paulo` (URL-encoded "São Paulo")
|
|
395
|
+
- `__cf_geo_city` → e.g., `Curitiba`
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
### Common Pitfalls
|
|
400
|
+
|
|
401
|
+
| Problem | Root cause | Fix |
|
|
402
|
+
|---------|-----------|-----|
|
|
403
|
+
| All location variants show wrong content | `location.ts` not registered → always `false` | Register in `setup.ts` |
|
|
404
|
+
| CLS score spike + React Reconnect events | Unregistered matcher → server renders variant A, client re-renders variant B | Register all matchers used in CMS |
|
|
405
|
+
| Random A/B test inconsistent per page load | Not using `sticky: "session"` | Add `"sticky": "session"` to rule |
|
|
406
|
+
| Geo matcher works locally but not in production | CF geo data not available in local dev | Mock `__cf_geo_*` cookies locally |
|
|
407
|
+
| Pathname matcher regex syntax error | REDOS vulnerability check rejects pattern | Simplify regex, avoid catastrophic backtracking |
|
|
408
|
+
| `request` undefined in MatcherContext | `cmsRoute.ts` doesn't populate it | Use `ctx.cookies` or `ctx.headers` instead |
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
### Key Source Files
|
|
413
|
+
|
|
414
|
+
| File | Location |
|
|
415
|
+
|------|----------|
|
|
416
|
+
| `evaluateMatcher`, `registerMatcher`, `MatcherContext` | `@decocms/start/src/cms/resolve.ts` |
|
|
417
|
+
| `registerBuiltinMatchers` | `@decocms/start/src/matchers/builtins.ts` |
|
|
418
|
+
| Built-in matcher implementations | `deco-cx/apps/website/matchers/*.ts` |
|
|
419
|
+
| PostHog matcher | `@decocms/start/src/matchers/posthog.ts` |
|
|
420
|
+
| Example: server-side location matcher | `src/matchers/location.ts` |
|
|
421
|
+
| Example: CF geo cookie injection | `src/worker-entry.ts` → `injectGeoCookies()` |
|
|
422
|
+
| Matcher registration call site | `src/setup.ts` |
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## Part 2: Migration Guide
|
|
427
|
+
|
|
428
|
+
Complete guide to migrate ALL matcher types from Fresh/Deno to TanStack Start / Cloudflare Workers.
|
|
429
|
+
|
|
430
|
+
### When to Use This Reference
|
|
431
|
+
|
|
432
|
+
- Migrating a deco site from Fresh/Deno to TanStack Start
|
|
433
|
+
- Setting up matchers in a new TanStack storefront
|
|
434
|
+
- A/B tests or feature flags stopped working after migration
|
|
435
|
+
- Location matcher returns `false` for everyone (most common post-migration bug)
|
|
436
|
+
- Need to implement a matcher that isn't in `registerBuiltinMatchers()`
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
### Key Architecture Differences
|
|
441
|
+
|
|
442
|
+
| Aspect | Fresh/Deno | TanStack Start |
|
|
443
|
+
|--------|-----------|----------------|
|
|
444
|
+
| Context type | `MatchContext` | `MatcherContext` |
|
|
445
|
+
| Request access | `ctx.request: Request` | `ctx.request?` (unreliable, often undefined) |
|
|
446
|
+
| CF geo data | Read directly from CF headers | Inject as cookies in `worker-entry.ts` |
|
|
447
|
+
| CF headers available | `cf-ipcountry`, `cf-ipcity`, `cf-region-code` | Not in `MatcherContext`, inject via cookies |
|
|
448
|
+
| Device detection | `ctx.device: "mobile"\|"tablet"\|"desktop"` | Parse from `ctx.userAgent` (string) |
|
|
449
|
+
| Environment check | `Deno.env.get("DENO_DEPLOYMENT_ID")` | `process.env.NODE_ENV` or `ctx.headers` |
|
|
450
|
+
| Cron parsing | `https://deno.land/x/croner@6.0.3` | Inline date-range check (no cron lib) |
|
|
451
|
+
| Registration | Automatic via block manifest | Must call `registerMatcher(key, fn)` in setup.ts |
|
|
452
|
+
| Builtins | Included by default | Must call `registerBuiltinMatchers()` in setup.ts |
|
|
453
|
+
| MatchContext fields | `{ request, device, siteId }` | `{ userAgent, url, path, cookies, headers, request? }` |
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
### MatcherContext Interface (TanStack Start)
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
// @decocms/start/src/cms/resolve.ts
|
|
461
|
+
export interface MatcherContext {
|
|
462
|
+
userAgent?: string; // User-Agent header value
|
|
463
|
+
url?: string; // Full URL with query string (e.g. "https://site.com/path?q=1")
|
|
464
|
+
path?: string; // Pathname only (e.g. "/path")
|
|
465
|
+
cookies?: Record<string, string>; // Parsed cookies
|
|
466
|
+
headers?: Record<string, string>; // All request headers
|
|
467
|
+
request?: Request; // Raw Request (not always populated!)
|
|
468
|
+
}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
### Migration Status per Matcher
|
|
474
|
+
|
|
475
|
+
| Matcher | In `registerBuiltinMatchers()`? | Migration needed? | Notes |
|
|
476
|
+
|---------|--------------------------------|-------------------|-------|
|
|
477
|
+
| `always.ts` | No (inline) | No | Hardcoded in `evaluateMatcher` |
|
|
478
|
+
| `never.ts` | No (inline) | No | Hardcoded in `evaluateMatcher` |
|
|
479
|
+
| `device.ts` | No (inline) | No | Hardcoded, uses `ctx.userAgent` |
|
|
480
|
+
| `random.ts` | No (inline) | No | Hardcoded |
|
|
481
|
+
| `date.ts` | Yes (as cron alias) | No | Registered in builtins |
|
|
482
|
+
| `cron.ts` | Yes | No | Registered in builtins |
|
|
483
|
+
| `cookie.ts` | Yes | No | Registered in builtins |
|
|
484
|
+
| `host.ts` | Yes | No | Registered in builtins |
|
|
485
|
+
| `pathname.ts` | Yes | Partial | Props shape changed (see below) |
|
|
486
|
+
| `queryString.ts` | Yes | Partial | Props shape changed (see below) |
|
|
487
|
+
| `location.ts` | **No** | **YES** | Biggest CLS risk — see full guide |
|
|
488
|
+
| `userAgent.ts` | **No** | **YES** | Must register manually |
|
|
489
|
+
| `environment.ts` | **No** | **YES** | Deno API → Node/CF equivalent |
|
|
490
|
+
| `multi.ts` | **No** | **YES** | Must register manually |
|
|
491
|
+
| `negate.ts` | **No** | **YES** | Must register manually |
|
|
492
|
+
| `site.ts` | **No** | **Not needed** | Site-specific, skip or skip if unused |
|
|
493
|
+
| PostHog | Yes (posthog.ts) | Partial | Needs adapter config |
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
### Step 1: Setup File
|
|
498
|
+
|
|
499
|
+
All matchers must be registered before any request is served:
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
// src/setup.ts
|
|
503
|
+
import { registerBuiltinMatchers } from "@decocms/start/matchers/builtins";
|
|
504
|
+
import { registerLocationMatcher } from "./matchers/location";
|
|
505
|
+
import { registerUserAgentMatcher } from "./matchers/userAgent";
|
|
506
|
+
import { registerEnvironmentMatcher } from "./matchers/environment";
|
|
507
|
+
import { registerMultiMatcher } from "./matchers/multi";
|
|
508
|
+
import { registerNegateMatcher } from "./matchers/negate";
|
|
509
|
+
|
|
510
|
+
// Always call this first — registers cookie, cron/date, host, pathname, queryString
|
|
511
|
+
registerBuiltinMatchers();
|
|
512
|
+
|
|
513
|
+
// Register any matchers used in CMS that aren't in builtins:
|
|
514
|
+
registerLocationMatcher();
|
|
515
|
+
registerUserAgentMatcher();
|
|
516
|
+
registerEnvironmentMatcher();
|
|
517
|
+
registerMultiMatcher();
|
|
518
|
+
registerNegateMatcher();
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
**Critical**: If any matcher used in the CMS is not registered, `evaluateMatcher` returns `false` + logs a warning. This causes ALL variants using that matcher to show the wrong content and can trigger React Reconnect events (CLS).
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
### Step 2: Cloudflare Geo Cookie Injection
|
|
526
|
+
|
|
527
|
+
The Fresh version of `location.ts` reads CF headers directly from `ctx.request`. In TanStack Start, `MatcherContext` doesn't expose headers reliably — inject CF geo data as cookies in `worker-entry.ts` **before** TanStack Start processes the request.
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
// src/worker-entry.ts
|
|
531
|
+
function injectGeoCookies(request: Request): Request {
|
|
532
|
+
const cf = (request as unknown as { cf?: Record<string, string> }).cf;
|
|
533
|
+
if (!cf) return request;
|
|
534
|
+
const parts: string[] = [];
|
|
535
|
+
if (cf.region) parts.push(`__cf_geo_region=${encodeURIComponent(cf.region)}`);
|
|
536
|
+
if (cf.country) parts.push(`__cf_geo_country=${encodeURIComponent(cf.country)}`);
|
|
537
|
+
if (cf.city) parts.push(`__cf_geo_city=${encodeURIComponent(cf.city)}`);
|
|
538
|
+
if (cf.latitude) parts.push(`__cf_geo_lat=${encodeURIComponent(cf.latitude)}`);
|
|
539
|
+
if (cf.longitude) parts.push(`__cf_geo_lng=${encodeURIComponent(cf.longitude)}`);
|
|
540
|
+
if (!parts.length) return request;
|
|
541
|
+
const existing = request.headers.get("cookie") ?? "";
|
|
542
|
+
const combined = existing ? `${existing}; ${parts.join("; ")}` : parts.join("; ");
|
|
543
|
+
const headers = new Headers(request.headers);
|
|
544
|
+
headers.set("cookie", combined);
|
|
545
|
+
return new Request(request, { headers });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export default {
|
|
549
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
|
550
|
+
return await handler.fetch(injectGeoCookies(request), env, ctx);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
**CF data available** (from `request.cf` in Cloudflare Workers):
|
|
556
|
+
- `cf.country` — ISO 2-letter code (`"BR"`, `"US"`)
|
|
557
|
+
- `cf.region` — Full state/region name (`"São Paulo"`, `"Paraná"`)
|
|
558
|
+
- `cf.city` — City name (`"Curitiba"`)
|
|
559
|
+
- `cf.latitude` — Decimal latitude
|
|
560
|
+
- `cf.longitude` — Decimal longitude
|
|
561
|
+
- `cf.regionCode` — Short region code (some regions) (`"SP"`)
|
|
562
|
+
|
|
563
|
+
> Note: Fresh used CF **headers** (`cf-ipcountry`, `cf-ipcity`, etc.). TanStack Start uses `request.cf` object injected as cookies. Both come from Cloudflare but via different mechanisms.
|
|
564
|
+
|
|
565
|
+
---
|
|
566
|
+
|
|
567
|
+
### Step 3: Implement Each Missing Matcher
|
|
568
|
+
|
|
569
|
+
#### `location.ts` — Geographic targeting
|
|
570
|
+
|
|
571
|
+
**Fresh behavior**: Reads `cf-ipcity`, `cf-ipcountry`, `cf-region-code`, lat/lng headers. Supports coordinate radius matching.
|
|
572
|
+
|
|
573
|
+
**TanStack behavior**: Reads from injected cookies `__cf_geo_*`. CMS stores country as full names ("Brasil"), CF provides ISO codes ("BR") — needs a mapping table.
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
// src/matchers/location.ts
|
|
577
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
578
|
+
|
|
579
|
+
// CF country codes → CMS country name mapping
|
|
580
|
+
const COUNTRY_NAME_TO_CODE: Record<string, string> = {
|
|
581
|
+
Brasil: "BR", Brazil: "BR",
|
|
582
|
+
Argentina: "AR", Chile: "CL",
|
|
583
|
+
Colombia: "CO", Mexico: "MX",
|
|
584
|
+
Peru: "PE", Uruguay: "UY",
|
|
585
|
+
Paraguay: "PY", Bolivia: "BO",
|
|
586
|
+
Ecuador: "EC", Venezuela: "VE",
|
|
587
|
+
"United States": "US", USA: "US",
|
|
588
|
+
Spain: "ES", Portugal: "PT",
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
interface LocationRule {
|
|
592
|
+
country?: string;
|
|
593
|
+
regionCode?: string; // Full region name (e.g., "São Paulo", "Paraná")
|
|
594
|
+
city?: string;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function matchesRule(
|
|
598
|
+
loc: LocationRule,
|
|
599
|
+
region: string,
|
|
600
|
+
country: string,
|
|
601
|
+
city: string
|
|
602
|
+
): boolean {
|
|
603
|
+
if (loc.country) {
|
|
604
|
+
const code = COUNTRY_NAME_TO_CODE[loc.country] ?? loc.country;
|
|
605
|
+
if (code !== country) return false;
|
|
606
|
+
}
|
|
607
|
+
if (loc.regionCode && loc.regionCode !== region) return false;
|
|
608
|
+
if (loc.city && loc.city.toLowerCase() !== city.toLowerCase()) return false;
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function registerLocationMatcher(): void {
|
|
613
|
+
registerMatcher("website/matchers/location.ts", (rule, ctx) => {
|
|
614
|
+
const cookies = ctx.cookies ?? {};
|
|
615
|
+
const region = cookies.__cf_geo_region ? decodeURIComponent(cookies.__cf_geo_region) : "";
|
|
616
|
+
const country = cookies.__cf_geo_country ? decodeURIComponent(cookies.__cf_geo_country) : "";
|
|
617
|
+
const city = cookies.__cf_geo_city ? decodeURIComponent(cookies.__cf_geo_city) : "";
|
|
618
|
+
|
|
619
|
+
const r = rule as {
|
|
620
|
+
includeLocations?: LocationRule[];
|
|
621
|
+
excludeLocations?: LocationRule[];
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
// Exclude list takes priority
|
|
625
|
+
if (r.excludeLocations?.some(loc => matchesRule(loc, region, country, city))) {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
// Include list: must match at least one
|
|
629
|
+
if (r.includeLocations?.length) {
|
|
630
|
+
return r.includeLocations.some(loc => matchesRule(loc, region, country, city));
|
|
631
|
+
}
|
|
632
|
+
// No rules = match all
|
|
633
|
+
return true;
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
> **Coordinate radius matching** (from Fresh): The Fresh version supports `Map { coordinates: "lat,lng,radius_m" }` using haversine distance. This is rare in practice — skip unless the CMS actually uses coordinate rules.
|
|
639
|
+
|
|
640
|
+
---
|
|
641
|
+
|
|
642
|
+
#### `userAgent.ts` — Browser/OS targeting
|
|
643
|
+
|
|
644
|
+
**Fresh behavior**: Reads `ctx.request.headers.get("user-agent")`. Supports `includes` (string) and `match` (regex).
|
|
645
|
+
|
|
646
|
+
**TanStack behavior**: Read from `ctx.userAgent`.
|
|
647
|
+
|
|
648
|
+
```typescript
|
|
649
|
+
// src/matchers/userAgent.ts
|
|
650
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
651
|
+
|
|
652
|
+
export function registerUserAgentMatcher(): void {
|
|
653
|
+
registerMatcher("website/matchers/userAgent.ts", (rule, ctx) => {
|
|
654
|
+
const ua = ctx.userAgent ?? "";
|
|
655
|
+
const r = rule as { includes?: string; match?: string };
|
|
656
|
+
|
|
657
|
+
if (r.includes && !ua.includes(r.includes)) return false;
|
|
658
|
+
if (r.match) {
|
|
659
|
+
try {
|
|
660
|
+
if (!new RegExp(r.match, "i").test(ua)) return false;
|
|
661
|
+
} catch {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return true;
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
---
|
|
671
|
+
|
|
672
|
+
#### `environment.ts` — Production vs development
|
|
673
|
+
|
|
674
|
+
**Fresh behavior**: Checks `Deno.env.get("DENO_DEPLOYMENT_ID")`.
|
|
675
|
+
|
|
676
|
+
**TanStack behavior**: Check `process.env.NODE_ENV` or a custom env var. In Cloudflare Workers, use a binding or `ctx.headers`.
|
|
677
|
+
|
|
678
|
+
```typescript
|
|
679
|
+
// src/matchers/environment.ts
|
|
680
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
681
|
+
|
|
682
|
+
function isProduction(): boolean {
|
|
683
|
+
// Cloudflare Workers: check for production deployment
|
|
684
|
+
// Adjust based on how your site sets this
|
|
685
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV) {
|
|
686
|
+
return process.env.NODE_ENV === "production";
|
|
687
|
+
}
|
|
688
|
+
// Fallback: check for a custom header or env var
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export function registerEnvironmentMatcher(): void {
|
|
693
|
+
registerMatcher("website/matchers/environment.ts", (rule, ctx) => {
|
|
694
|
+
const r = rule as { environment: "production" | "development" };
|
|
695
|
+
const prod = isProduction();
|
|
696
|
+
if (r.environment === "production") return prod;
|
|
697
|
+
if (r.environment === "development") return !prod;
|
|
698
|
+
return false;
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
> Alternatively, inject an `__env` cookie in `worker-entry.ts` with the environment name.
|
|
704
|
+
|
|
705
|
+
---
|
|
706
|
+
|
|
707
|
+
#### `multi.ts` — AND/OR combinator
|
|
708
|
+
|
|
709
|
+
**Fresh behavior**: Recursively calls each `Matcher` function with `MatchContext`.
|
|
710
|
+
|
|
711
|
+
**TanStack behavior**: Must recursively call `evaluateMatcher` for each sub-matcher. Import it from the framework.
|
|
712
|
+
|
|
713
|
+
```typescript
|
|
714
|
+
// src/matchers/multi.ts
|
|
715
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
716
|
+
// Import the internal evaluateMatcher — check if exported by your version
|
|
717
|
+
// If not exported, implement inline evaluation
|
|
718
|
+
|
|
719
|
+
export function registerMultiMatcher(): void {
|
|
720
|
+
registerMatcher("website/matchers/multi.ts", (rule, ctx) => {
|
|
721
|
+
const r = rule as {
|
|
722
|
+
op: "or" | "and";
|
|
723
|
+
matchers: Array<Record<string, unknown>>;
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
if (!r.matchers?.length) return true;
|
|
727
|
+
|
|
728
|
+
// Evaluate each sub-matcher rule using the same ctx
|
|
729
|
+
// We need to call evaluateMatcher recursively — check your @decocms/start version
|
|
730
|
+
// If evaluateMatcher is exported:
|
|
731
|
+
// import { evaluateMatcher } from "@decocms/start/cms";
|
|
732
|
+
// const results = r.matchers.map(m => evaluateMatcher(m, ctx));
|
|
733
|
+
|
|
734
|
+
// Fallback: evaluate inline using registered matchers
|
|
735
|
+
const G = globalThis as unknown as {
|
|
736
|
+
__deco?: { customMatchers?: Record<string, (r: unknown, c: unknown) => boolean> }
|
|
737
|
+
};
|
|
738
|
+
const registry = G.__deco?.customMatchers ?? {};
|
|
739
|
+
|
|
740
|
+
const results = r.matchers.map(m => {
|
|
741
|
+
const subRule = m as Record<string, unknown>;
|
|
742
|
+
const type = subRule.__resolveType as string | undefined;
|
|
743
|
+
if (!type) return true;
|
|
744
|
+
const fn = registry[type];
|
|
745
|
+
if (!fn) return false;
|
|
746
|
+
return fn(subRule, ctx);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
return r.op === "and" ? results.every(Boolean) : results.some(Boolean);
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
> Check your `@decocms/start` version — newer versions may export `evaluateMatcher` for reuse.
|
|
755
|
+
|
|
756
|
+
---
|
|
757
|
+
|
|
758
|
+
#### `negate.ts` — Invert any matcher
|
|
759
|
+
|
|
760
|
+
**Fresh behavior**: Calls inner `Matcher` function and inverts.
|
|
761
|
+
|
|
762
|
+
**TanStack behavior**: Same recursive evaluation as multi.
|
|
763
|
+
|
|
764
|
+
```typescript
|
|
765
|
+
// src/matchers/negate.ts
|
|
766
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
767
|
+
|
|
768
|
+
export function registerNegateMatcher(): void {
|
|
769
|
+
registerMatcher("website/matchers/negate.ts", (rule, ctx) => {
|
|
770
|
+
const r = rule as { matcher?: Record<string, unknown> };
|
|
771
|
+
if (!r.matcher) return false;
|
|
772
|
+
|
|
773
|
+
const G = globalThis as unknown as {
|
|
774
|
+
__deco?: { customMatchers?: Record<string, (r: unknown, c: unknown) => boolean> }
|
|
775
|
+
};
|
|
776
|
+
const registry = G.__deco?.customMatchers ?? {};
|
|
777
|
+
const type = r.matcher.__resolveType as string | undefined;
|
|
778
|
+
if (!type) return false;
|
|
779
|
+
const fn = registry[type];
|
|
780
|
+
if (!fn) return false;
|
|
781
|
+
return !fn(r.matcher, ctx);
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
---
|
|
787
|
+
|
|
788
|
+
### Step 4: Props Shape Differences for Builtins
|
|
789
|
+
|
|
790
|
+
Even registered matchers have prop shape differences between Fresh and TanStack Start. Check CMS JSON against the TanStack implementation.
|
|
791
|
+
|
|
792
|
+
#### `pathname.ts` — Props changed
|
|
793
|
+
|
|
794
|
+
**Fresh Props**:
|
|
795
|
+
```typescript
|
|
796
|
+
{
|
|
797
|
+
case: {
|
|
798
|
+
type: "Equals" | "Includes" | "Template";
|
|
799
|
+
pathname: string;
|
|
800
|
+
negate?: boolean;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
**TanStack Props** (builtins.ts):
|
|
806
|
+
```typescript
|
|
807
|
+
{
|
|
808
|
+
pattern?: string; // Regex pattern
|
|
809
|
+
includes?: string[]; // Exact paths or wildcard (path/* prefix)
|
|
810
|
+
excludes?: string[]; // Exact paths or wildcard
|
|
811
|
+
}
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
**Migration**: The CMS JSON uses the Fresh props shape. The TanStack builtins handle a different (simpler) shape. If your CMS has the Fresh-style `case` object, the TanStack builtin won't match it — you must register a custom implementation matching the Fresh shape.
|
|
815
|
+
|
|
816
|
+
```typescript
|
|
817
|
+
// src/matchers/pathname-compat.ts — Fresh-compatible pathname matcher
|
|
818
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
819
|
+
|
|
820
|
+
function templateToRegex(template: string): RegExp {
|
|
821
|
+
const pattern = template
|
|
822
|
+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape
|
|
823
|
+
.replace(/\\:\w+/g, '[^/]+') // :slug → [^/]+
|
|
824
|
+
.replace(/\\*/g, '.*'); // * → .*
|
|
825
|
+
return new RegExp(`^${pattern}$`);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export function registerPathnameCompatMatcher(): void {
|
|
829
|
+
registerMatcher("website/matchers/pathname.ts", (rule, ctx) => {
|
|
830
|
+
const r = rule as { case?: { type: string; pathname?: string; negate?: boolean } };
|
|
831
|
+
const path = ctx.path ?? "";
|
|
832
|
+
|
|
833
|
+
if (!r.case?.pathname) return true;
|
|
834
|
+
const { type, pathname, negate } = r.case;
|
|
835
|
+
|
|
836
|
+
let match = false;
|
|
837
|
+
switch (type) {
|
|
838
|
+
case "Equals": match = path === pathname; break;
|
|
839
|
+
case "Includes": match = path.includes(pathname); break;
|
|
840
|
+
case "Template": match = templateToRegex(pathname).test(path); break;
|
|
841
|
+
default: match = false;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return negate ? !match : match;
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
> Only add this compatibility shim if your CMS actually uses the Fresh-style `case` object. Check your decofile JSON first.
|
|
850
|
+
|
|
851
|
+
#### `queryString.ts` — Props changed
|
|
852
|
+
|
|
853
|
+
**Fresh Props**:
|
|
854
|
+
```typescript
|
|
855
|
+
{
|
|
856
|
+
conditions: Array<{
|
|
857
|
+
param: string;
|
|
858
|
+
case: {
|
|
859
|
+
type: "Equals" | "Greater" | "Lesser" | "GreaterOrEquals" | "LesserOrEquals" | "Includes" | "Exists";
|
|
860
|
+
value?: string;
|
|
861
|
+
}
|
|
862
|
+
}>
|
|
863
|
+
}
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
**TanStack Props** (builtins.ts):
|
|
867
|
+
```typescript
|
|
868
|
+
{
|
|
869
|
+
key?: string; // or "param"
|
|
870
|
+
param?: string; // query param name
|
|
871
|
+
value?: string; // optional value to match
|
|
872
|
+
}
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
If your CMS uses the `conditions` array shape, register a compatibility matcher:
|
|
876
|
+
|
|
877
|
+
```typescript
|
|
878
|
+
// src/matchers/querystring-compat.ts
|
|
879
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
880
|
+
|
|
881
|
+
export function registerQueryStringCompatMatcher(): void {
|
|
882
|
+
registerMatcher("website/matchers/queryString.ts", (rule, ctx) => {
|
|
883
|
+
const r = rule as { conditions?: Array<{ param: string; case: { type: string; value?: string } }> };
|
|
884
|
+
if (!r.conditions?.length) return true;
|
|
885
|
+
|
|
886
|
+
const url = ctx.url ? new URL(ctx.url) : null;
|
|
887
|
+
if (!url) return false;
|
|
888
|
+
|
|
889
|
+
return r.conditions.every(({ param, case: c }) => {
|
|
890
|
+
const raw = url.searchParams.get(param);
|
|
891
|
+
const val = raw ?? "";
|
|
892
|
+
const cval = c.value ?? "";
|
|
893
|
+
switch (c.type) {
|
|
894
|
+
case "Exists": return raw !== null;
|
|
895
|
+
case "Equals": return val === cval;
|
|
896
|
+
case "Includes": return val.includes(cval);
|
|
897
|
+
case "Greater": return Number(val) > Number(cval);
|
|
898
|
+
case "Lesser": return Number(val) < Number(cval);
|
|
899
|
+
case "GreaterOrEquals": return Number(val) >= Number(cval);
|
|
900
|
+
case "LesserOrEquals": return Number(val) <= Number(cval);
|
|
901
|
+
default: return false;
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
---
|
|
909
|
+
|
|
910
|
+
### Step 5: PostHog Matcher
|
|
911
|
+
|
|
912
|
+
The TanStack Start framework ships a PostHog bridge in `@decocms/start/src/matchers/posthog.ts`. Configure it with a server-side adapter:
|
|
913
|
+
|
|
914
|
+
```typescript
|
|
915
|
+
// src/setup.ts
|
|
916
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
917
|
+
import {
|
|
918
|
+
configurePostHogMatcher,
|
|
919
|
+
createPostHogMatcher,
|
|
920
|
+
createServerPostHogAdapter,
|
|
921
|
+
} from "@decocms/start/matchers/posthog";
|
|
922
|
+
import { PostHog } from "posthog-node";
|
|
923
|
+
|
|
924
|
+
// Create a PostHog Node client (server-side)
|
|
925
|
+
const posthogClient = new PostHog(process.env.POSTHOG_API_KEY!, {
|
|
926
|
+
host: "https://app.posthog.com",
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
export async function setupPostHogMatcher(distinctId: string): Promise<void> {
|
|
930
|
+
const adapter = createServerPostHogAdapter(posthogClient, distinctId);
|
|
931
|
+
configurePostHogMatcher(adapter);
|
|
932
|
+
registerMatcher("posthog/matchers/featureFlag.ts", createPostHogMatcher());
|
|
933
|
+
}
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
---
|
|
937
|
+
|
|
938
|
+
### Complete `src/setup.ts` Example
|
|
939
|
+
|
|
940
|
+
```typescript
|
|
941
|
+
// src/setup.ts — register ALL matchers before serving requests
|
|
942
|
+
import { setAsyncRenderingConfig } from "@decocms/start/async-rendering";
|
|
943
|
+
import { registerBuiltinMatchers } from "@decocms/start/matchers/builtins";
|
|
944
|
+
import { registerLocationMatcher } from "./matchers/location";
|
|
945
|
+
import { registerUserAgentMatcher } from "./matchers/userAgent";
|
|
946
|
+
import { registerEnvironmentMatcher } from "./matchers/environment";
|
|
947
|
+
import { registerMultiMatcher } from "./matchers/multi";
|
|
948
|
+
import { registerNegateMatcher } from "./matchers/negate";
|
|
949
|
+
|
|
950
|
+
// Register all built-in matchers (cookie, cron/date, host, pathname, queryString)
|
|
951
|
+
registerBuiltinMatchers();
|
|
952
|
+
|
|
953
|
+
// Register matchers not in builtins — add only those used in your CMS
|
|
954
|
+
registerLocationMatcher(); // Critical if CMS uses location-based content
|
|
955
|
+
registerUserAgentMatcher(); // If CMS uses userAgent rules
|
|
956
|
+
registerEnvironmentMatcher(); // If CMS uses production/development flags
|
|
957
|
+
registerMultiMatcher(); // If CMS uses AND/OR combinator rules
|
|
958
|
+
registerNegateMatcher(); // If CMS uses negated matcher rules
|
|
959
|
+
|
|
960
|
+
// Async rendering config
|
|
961
|
+
setAsyncRenderingConfig({
|
|
962
|
+
respectCmsLazy: true,
|
|
963
|
+
alwaysEager: ["Header", "Footer", "Theme"],
|
|
964
|
+
});
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
---
|
|
968
|
+
|
|
969
|
+
### Complete `src/worker-entry.ts` Example
|
|
970
|
+
|
|
971
|
+
```typescript
|
|
972
|
+
// src/worker-entry.ts — inject CF geo data before TanStack Start processes request
|
|
973
|
+
import { createWorkerEntry } from "@decocms/start";
|
|
974
|
+
import "../src/setup"; // Ensure matchers are registered
|
|
975
|
+
|
|
976
|
+
const handler = createWorkerEntry();
|
|
977
|
+
|
|
978
|
+
function injectGeoCookies(request: Request): Request {
|
|
979
|
+
const cf = (request as unknown as { cf?: Record<string, string> }).cf;
|
|
980
|
+
if (!cf) return request;
|
|
981
|
+
const parts: string[] = [];
|
|
982
|
+
if (cf.region) parts.push(`__cf_geo_region=${encodeURIComponent(cf.region)}`);
|
|
983
|
+
if (cf.country) parts.push(`__cf_geo_country=${encodeURIComponent(cf.country)}`);
|
|
984
|
+
if (cf.city) parts.push(`__cf_geo_city=${encodeURIComponent(cf.city)}`);
|
|
985
|
+
if (cf.latitude) parts.push(`__cf_geo_lat=${encodeURIComponent(cf.latitude)}`);
|
|
986
|
+
if (cf.longitude) parts.push(`__cf_geo_lng=${encodeURIComponent(cf.longitude)}`);
|
|
987
|
+
if (!parts.length) return request;
|
|
988
|
+
const existing = request.headers.get("cookie") ?? "";
|
|
989
|
+
const combined = existing ? `${existing}; ${parts.join("; ")}` : parts.join("; ");
|
|
990
|
+
const headers = new Headers(request.headers);
|
|
991
|
+
headers.set("cookie", combined);
|
|
992
|
+
return new Request(request, { headers });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
export default {
|
|
996
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
|
997
|
+
return await handler.fetch(injectGeoCookies(request), env, ctx);
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
---
|
|
1003
|
+
|
|
1004
|
+
### Debugging After Migration
|
|
1005
|
+
|
|
1006
|
+
#### 1. Check startup logs for unregistered matchers
|
|
1007
|
+
|
|
1008
|
+
```
|
|
1009
|
+
console.warn: Unknown matcher type: website/matchers/location.ts
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
Every warning = one matcher returning `false` for all users = potential CLS.
|
|
1013
|
+
|
|
1014
|
+
#### 2. Check Chrome Trace for React Reconnect
|
|
1015
|
+
|
|
1016
|
+
- Record a trace at `chrome://tracing`
|
|
1017
|
+
- Look for **"React Reconnect"** events — each = a React subtree that remounted
|
|
1018
|
+
- Cause: server renders variant A (matcher returns false) → client JS evaluates differently → remounts
|
|
1019
|
+
- Fix: register all matchers so server and client agree
|
|
1020
|
+
|
|
1021
|
+
#### 3. Verify CF geo cookies
|
|
1022
|
+
|
|
1023
|
+
Open DevTools → Application → Cookies:
|
|
1024
|
+
- `__cf_geo_country` = `BR` (ISO code)
|
|
1025
|
+
- `__cf_geo_region` = `S%C3%A3o%20Paulo` (URL-encoded "São Paulo")
|
|
1026
|
+
- `__cf_geo_city` = `Curitiba`
|
|
1027
|
+
|
|
1028
|
+
If cookies are missing → `injectGeoCookies()` isn't running or CF isn't populating `request.cf` (only available in production Cloudflare Workers, not `wrangler dev`).
|
|
1029
|
+
|
|
1030
|
+
#### 4. Test locally with mock cookies
|
|
1031
|
+
|
|
1032
|
+
For local development, set mock geo cookies in `injectGeoCookies()` when `cf` is undefined:
|
|
1033
|
+
|
|
1034
|
+
```typescript
|
|
1035
|
+
function injectGeoCookies(request: Request): Request {
|
|
1036
|
+
const cf = (request as unknown as { cf?: Record<string, string> }).cf;
|
|
1037
|
+
// In local dev, simulate a location
|
|
1038
|
+
const geoData = cf ?? {
|
|
1039
|
+
country: "BR",
|
|
1040
|
+
region: "Paraná",
|
|
1041
|
+
city: "Curitiba",
|
|
1042
|
+
};
|
|
1043
|
+
// ... rest of implementation
|
|
1044
|
+
}
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
---
|
|
1048
|
+
|
|
1049
|
+
### Matcher Feature Parity Checklist
|
|
1050
|
+
|
|
1051
|
+
Use this checklist when migrating a site:
|
|
1052
|
+
|
|
1053
|
+
- [ ] `registerBuiltinMatchers()` called in `setup.ts`
|
|
1054
|
+
- [ ] `injectGeoCookies()` added to `worker-entry.ts`
|
|
1055
|
+
- [ ] `registerLocationMatcher()` added to `setup.ts` (if site uses location rules)
|
|
1056
|
+
- [ ] `registerUserAgentMatcher()` added (if site uses userAgent rules)
|
|
1057
|
+
- [ ] `registerEnvironmentMatcher()` added (if site uses env flags)
|
|
1058
|
+
- [ ] `registerMultiMatcher()` added (if site uses AND/OR combinator)
|
|
1059
|
+
- [ ] `registerNegateMatcher()` added (if site uses negate)
|
|
1060
|
+
- [ ] Check CMS decofiles for `pathname.ts` prop shape (Fresh uses `case` object, TanStack uses `pattern`/`includes`)
|
|
1061
|
+
- [ ] Check CMS decofiles for `queryString.ts` prop shape (Fresh uses `conditions[]`, TanStack uses `key`/`value`)
|
|
1062
|
+
- [ ] Verify no "Unknown matcher" warnings in startup logs
|
|
1063
|
+
- [ ] Verify no "React Reconnect" events in Chrome Trace
|
|
1064
|
+
- [ ] Test geo-targeted content from different locations (or mock cookies locally)
|