@decocms/start 6.2.1 → 6.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "6.2.1",
3
+ "version": "6.3.1",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -89,6 +89,14 @@ const decoWorker = createDecoWorkerEntry(serverEntry, {
89
89
  handleRender,
90
90
  corsHeaders,
91
91
  },
92
+ // Region splits the cache so a RJ-cached response isn't served to SP visitors
93
+ // when pages use the website/matchers/location.ts matcher. Without this, the
94
+ // first geo-resolved response leaks across regions.
95
+ buildSegment: (request) => {
96
+ const cf = (request as unknown as { cf?: { regionCode?: string } }).cf;
97
+ const regionCode = request.headers.get("cf-region-code") ?? cf?.regionCode ?? "";
98
+ return regionCode ? { regionId: regionCode } : {};
99
+ },
92
100
  });
93
101
 
94
102
  export default instrumentWorker(decoWorker, {
@@ -155,11 +163,16 @@ const decoWorker = createDecoWorkerEntry(serverEntry, {
155
163
  csp: CSP_DIRECTIVES,
156
164
  buildSegment: (request) => {
157
165
  const vtx = extractVtexContext(request);
166
+ const cf = (request as unknown as { cf?: { regionCode?: string } }).cf;
167
+ const geoRegion = request.headers.get("cf-region-code") ?? cf?.regionCode ?? "";
158
168
  return {
159
169
  device: MOBILE_RE.test(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop",
160
170
  loggedIn: vtx.isLoggedIn,
161
171
  salesChannel: vtx.salesChannel,
162
- regionId: (vtx as any).regionId ?? undefined,
172
+ // Prefer VTEX regionalization regionId when present; otherwise fall back
173
+ // to Cloudflare geo so the website/matchers/location.ts matcher gets a
174
+ // properly segmented cache.
175
+ regionId: (vtx as any).regionId ?? (geoRegion || undefined),
163
176
  };
164
177
  },
165
178
  admin: {
@@ -301,35 +301,53 @@ registerMatcherSchemas([
301
301
  key: "website/matchers/location.ts",
302
302
  title: "Location",
303
303
  namespace: "website",
304
- propsSchema: {
305
- type: "object",
306
- properties: {
307
- includeLocations: {
308
- type: "array",
309
- title: "Include Locations",
310
- items: {
311
- type: "object",
312
- properties: {
313
- country: { type: "string", title: "Country" },
314
- regionCode: { type: "string", title: "Region" },
315
- city: { type: "string", title: "City" },
316
- },
304
+ propsSchema: (() => {
305
+ const locationOrMapItem = {
306
+ type: "object" as const,
307
+ properties: {
308
+ city: {
309
+ type: "string",
310
+ title: "City",
311
+ examples: ["São Paulo"],
312
+ description: "Exact city name (case-insensitive) as returned by Cloudflare's cf-ipcity header.",
313
+ },
314
+ regionCode: {
315
+ type: "string",
316
+ title: "Region Code",
317
+ examples: ["SP", "RJ", "MG", "47"],
318
+ description:
319
+ "Matches Cloudflare's cf-region-code header. Usually the ISO 3166-2 subdivision code (SP, RJ, MG, …); for some regions Cloudflare returns numeric codes (e.g. 47). Use the same value cf-region-code returns for your audience — full region names like \"São Paulo\" are NOT matched.",
320
+ },
321
+ country: {
322
+ type: "string",
323
+ title: "Country",
324
+ examples: ["BR", "Brasil", "US"],
325
+ description: "ISO 3166-1 alpha-2 code (BR, US, AR, …) or a full country name (Brasil, United States) — the matcher resolves common aliases.",
326
+ },
327
+ coordinates: {
328
+ type: "string",
329
+ title: "Area selection",
330
+ examples: ["-23.5505,-46.6333,5000"],
331
+ description: "\"latitude,longitude,radius_in_meters\" for haversine-radius matching. Set this for the Map mode.",
317
332
  },
318
333
  },
319
- excludeLocations: {
320
- type: "array",
321
- title: "Exclude Locations",
322
- items: {
323
- type: "object",
324
- properties: {
325
- country: { type: "string", title: "Country" },
326
- regionCode: { type: "string", title: "Region" },
327
- city: { type: "string", title: "City" },
328
- },
334
+ };
335
+ return {
336
+ type: "object" as const,
337
+ properties: {
338
+ includeLocations: {
339
+ type: "array",
340
+ title: "Include Locations",
341
+ items: locationOrMapItem,
342
+ },
343
+ excludeLocations: {
344
+ type: "array",
345
+ title: "Exclude Locations",
346
+ items: locationOrMapItem,
329
347
  },
330
348
  },
331
- },
332
- },
349
+ };
350
+ })(),
333
351
  },
334
352
  {
335
353
  key: "website/matchers/userAgent.ts",
@@ -0,0 +1,251 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import type { MatcherContext } from "../cms/resolve";
3
+ import { evaluateMatcher } from "../cms/resolve";
4
+ import { registerBuiltinMatchers } from "./builtins";
5
+
6
+ const LOCATION_KEY = "website/matchers/location.ts";
7
+
8
+ beforeEach(() => {
9
+ registerBuiltinMatchers();
10
+ });
11
+
12
+ function ctxFromCookies(cookies: Record<string, string>): MatcherContext {
13
+ return { cookies };
14
+ }
15
+
16
+ function ctxFromCf(cf: Record<string, unknown>): MatcherContext {
17
+ const request = new Request("https://example.com/");
18
+ Object.defineProperty(request, "cf", { value: cf, configurable: true });
19
+ return { request };
20
+ }
21
+
22
+ function ctxFromHeaders(headers: Record<string, string>): MatcherContext {
23
+ return {
24
+ request: new Request("https://example.com/", { headers }),
25
+ };
26
+ }
27
+
28
+ function match(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
29
+ return evaluateMatcher({ ...rule, __resolveType: LOCATION_KEY }, ctx);
30
+ }
31
+
32
+ describe("locationMatcher — typed mode (Location)", () => {
33
+ it("matches when regionCode equals Cloudflare's cf-region-code (SP)", () => {
34
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP", "cf-ipcountry": "BR" });
35
+ expect(match({ includeLocations: [{ regionCode: "SP" }] }, ctx)).toBe(true);
36
+ });
37
+
38
+ it("matches a raw numeric regionCode (e.g. '47') for parity with deco-cx/apps", () => {
39
+ const ctx = ctxFromHeaders({ "cf-region-code": "47", "cf-ipcountry": "BR" });
40
+ expect(match({ includeLocations: [{ regionCode: "47" }] }, ctx)).toBe(true);
41
+ });
42
+
43
+ it("is case-insensitive on regionCode", () => {
44
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
45
+ expect(match({ includeLocations: [{ regionCode: "sp" }] }, ctx)).toBe(true);
46
+ });
47
+
48
+ it("does NOT match a region NAME against cf-region-code (parity with original)", () => {
49
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
50
+ expect(
51
+ match({ includeLocations: [{ regionCode: "São Paulo" }] }, ctx),
52
+ ).toBe(false);
53
+ });
54
+
55
+ it("does not match when the region differs", () => {
56
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
57
+ expect(match({ includeLocations: [{ regionCode: "RJ" }] }, ctx)).toBe(false);
58
+ });
59
+
60
+ it("AND's multiple fields on the same entry (regionCode + country)", () => {
61
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP", "cf-ipcountry": "BR" });
62
+ expect(
63
+ match(
64
+ { includeLocations: [{ regionCode: "SP", country: "BR" }] },
65
+ ctx,
66
+ ),
67
+ ).toBe(true);
68
+ expect(
69
+ match(
70
+ { includeLocations: [{ regionCode: "SP", country: "AR" }] },
71
+ ctx,
72
+ ),
73
+ ).toBe(false);
74
+ });
75
+
76
+ it("resolves country aliases (Brasil → BR)", () => {
77
+ const ctx = ctxFromHeaders({ "cf-ipcountry": "BR", "cf-region-code": "SP" });
78
+ expect(
79
+ match({ includeLocations: [{ country: "Brasil" }] }, ctx),
80
+ ).toBe(true);
81
+ });
82
+
83
+ it("OR's across multiple include entries", () => {
84
+ const ctx = ctxFromHeaders({ "cf-region-code": "RJ" });
85
+ expect(
86
+ match(
87
+ {
88
+ includeLocations: [{ regionCode: "SP" }, { regionCode: "RJ" }],
89
+ },
90
+ ctx,
91
+ ),
92
+ ).toBe(true);
93
+ });
94
+ });
95
+
96
+ describe("locationMatcher — empty / shape edge cases", () => {
97
+ it("empty includeLocations array → match (no constraint)", () => {
98
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
99
+ expect(match({ includeLocations: [] }, ctx)).toBe(true);
100
+ });
101
+
102
+ it("missing includeLocations → match", () => {
103
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
104
+ expect(match({}, ctx)).toBe(true);
105
+ });
106
+
107
+ it("entry {} inside includeLocations matches everyone (parity with original)", () => {
108
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP", "cf-ipcountry": "BR" });
109
+ expect(match({ includeLocations: [{}] }, ctx)).toBe(true);
110
+ });
111
+
112
+ it("entry {} inside excludeLocations does NOT exclude anyone (parity)", () => {
113
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP", "cf-ipcountry": "BR" });
114
+ expect(match({ excludeLocations: [{}] }, ctx)).toBe(true);
115
+ });
116
+
117
+ it("excludeLocations short-circuits over includeLocations", () => {
118
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
119
+ expect(
120
+ match(
121
+ {
122
+ includeLocations: [{ regionCode: "SP" }],
123
+ excludeLocations: [{ regionCode: "SP" }],
124
+ },
125
+ ctx,
126
+ ),
127
+ ).toBe(false);
128
+ });
129
+
130
+ it("includeLocations with [{regionCode}] fails when geo is empty", () => {
131
+ expect(
132
+ match({ includeLocations: [{ regionCode: "SP" }] }, {}),
133
+ ).toBe(false);
134
+ });
135
+ });
136
+
137
+ describe("locationMatcher — Map mode (haversine)", () => {
138
+ it("matches when source coords are within target radius", () => {
139
+ // Target: São Paulo center, 5km. Source: ~500m away.
140
+ const ctx = ctxFromHeaders({
141
+ "cf-iplatitude": "-23.5510",
142
+ "cf-iplongitude": "-46.6340",
143
+ });
144
+ expect(
145
+ match(
146
+ { includeLocations: [{ coordinates: "-23.5505,-46.6333,5000" }] },
147
+ ctx,
148
+ ),
149
+ ).toBe(true);
150
+ });
151
+
152
+ it("does NOT match when source coords are outside the radius", () => {
153
+ // Target: São Paulo, 5km. Source: Rio (~360km away).
154
+ const ctx = ctxFromHeaders({
155
+ "cf-iplatitude": "-22.9068",
156
+ "cf-iplongitude": "-43.1729",
157
+ });
158
+ expect(
159
+ match(
160
+ { includeLocations: [{ coordinates: "-23.5505,-46.6333,5000" }] },
161
+ ctx,
162
+ ),
163
+ ).toBe(false);
164
+ });
165
+
166
+ it("Map-only rule does NOT match when source has no coordinates", () => {
167
+ // Deliberate divergence from deco-cx/apps: upstream lets coord-only rules
168
+ // vacuously pass when the visitor has no lat/lng, which matches every
169
+ // such visitor — a footgun in production. We require both sides to have
170
+ // coordinates before the haversine check passes.
171
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
172
+ expect(
173
+ match(
174
+ { includeLocations: [{ coordinates: "-23.5505,-46.6333,5000" }] },
175
+ ctx,
176
+ ),
177
+ ).toBe(false);
178
+ });
179
+
180
+ it("AND's coordinates with regionCode on the same entry", () => {
181
+ // Source: SP coords, region=SP.
182
+ const ctx = ctxFromHeaders({
183
+ "cf-region-code": "SP",
184
+ "cf-iplatitude": "-23.5510",
185
+ "cf-iplongitude": "-46.6340",
186
+ });
187
+ // Entry asks for region=SP AND within 5km of SP center — matches.
188
+ expect(
189
+ match(
190
+ {
191
+ includeLocations: [
192
+ { regionCode: "SP", coordinates: "-23.5505,-46.6333,5000" },
193
+ ],
194
+ },
195
+ ctx,
196
+ ),
197
+ ).toBe(true);
198
+ // Entry asks for region=SP AND within 5km of Rio — fails on coords.
199
+ expect(
200
+ match(
201
+ {
202
+ includeLocations: [
203
+ { regionCode: "SP", coordinates: "-22.9068,-43.1729,5000" },
204
+ ],
205
+ },
206
+ ctx,
207
+ ),
208
+ ).toBe(false);
209
+ });
210
+ });
211
+
212
+ describe("locationMatcher — data source fallbacks", () => {
213
+ it("reads from request.cf when headers are absent", () => {
214
+ const ctx = ctxFromCf({ country: "BR", regionCode: "SP" });
215
+ expect(match({ includeLocations: [{ regionCode: "SP" }] }, ctx)).toBe(true);
216
+ });
217
+
218
+ it("reads from __cf_geo_* cookies as fallback", () => {
219
+ const ctx = ctxFromCookies({
220
+ __cf_geo_country: "BR",
221
+ __cf_geo_region_code: "SP",
222
+ });
223
+ expect(match({ includeLocations: [{ regionCode: "SP" }] }, ctx)).toBe(true);
224
+ });
225
+
226
+ it("decodes URL-encoded cookie values (e.g. city with accent)", () => {
227
+ const ctx = ctxFromCookies({
228
+ __cf_geo_country: "BR",
229
+ __cf_geo_city: encodeURIComponent("São Paulo"),
230
+ });
231
+ expect(
232
+ match({ includeLocations: [{ city: "São Paulo" }] }, ctx),
233
+ ).toBe(true);
234
+ });
235
+
236
+ it("headers take precedence over request.cf and cookies", () => {
237
+ const request = new Request("https://example.com/", {
238
+ headers: { "cf-region-code": "SP" },
239
+ });
240
+ Object.defineProperty(request, "cf", {
241
+ value: { regionCode: "RJ", country: "BR" },
242
+ configurable: true,
243
+ });
244
+ const ctx: MatcherContext = {
245
+ request,
246
+ cookies: { __cf_geo_region_code: "MG" },
247
+ };
248
+ expect(match({ includeLocations: [{ regionCode: "SP" }] }, ctx)).toBe(true);
249
+ expect(match({ includeLocations: [{ regionCode: "RJ" }] }, ctx)).toBe(false);
250
+ });
251
+ });
@@ -186,95 +186,168 @@ function queryStringMatcher(rule: Record<string, unknown>, ctx: MatcherContext):
186
186
  }
187
187
 
188
188
  // -------------------------------------------------------------------------
189
- // Location matcher
189
+ // Location matcher — parity with deco-cx/apps/website/matchers/location.ts
190
+ //
191
+ // Each entry in includeLocations/excludeLocations is a union of:
192
+ // Location: { city?, regionCode?, country? }
193
+ // Map: { coordinates?: "lat,lng,radius_in_meters" }
194
+ // Fields may coexist on the same entry; the matcher AND's every constraint
195
+ // that is populated. An entry with zero constraints returns defaultNotMatched
196
+ // (true for includes, false for excludes), matching upstream semantics.
190
197
  // -------------------------------------------------------------------------
191
198
 
192
- interface LocationRule {
199
+ interface LocationOrMap {
193
200
  country?: string;
194
201
  regionCode?: string;
195
202
  city?: string;
203
+ /** "latitude,longitude,radius_in_meters" — e.g. "-23.5505,-46.6333,5000" */
204
+ coordinates?: string;
196
205
  }
197
206
 
198
- interface GeoData {
207
+ interface GeoSource {
199
208
  country: string;
200
209
  regionCode: string;
201
- regionName: string;
202
210
  city: string;
211
+ /** "latitude,longitude" when available */
212
+ coordinates?: string;
213
+ }
214
+
215
+ function decodeCookie(value: string | undefined): string {
216
+ if (!value) return "";
217
+ try {
218
+ return decodeURIComponent(value);
219
+ } catch {
220
+ return value;
221
+ }
203
222
  }
204
223
 
205
224
  /**
206
225
  * Extract geo data from the request context.
207
- * Priority: request.cf (Cloudflare Workers) > geo cookies > geo headers.
226
+ *
227
+ * Read order mirrors the original deco-cx/apps matcher (header-first), with
228
+ * additional fallbacks for environments that don't preserve the cf-* request
229
+ * headers (e.g. when TanStack Start re-wraps the request and drops them):
230
+ * 1. cf-* request headers (cf-region-code, cf-ipcity, ...)
231
+ * 2. request.cf (Cloudflare Workers native)
232
+ * 3. __cf_geo_* cookies (injected by createDecoWorkerEntry)
208
233
  */
209
- function getGeoData(ctx: MatcherContext): GeoData {
210
- // 1. Cloudflare Workers: request.cf has authoritative geo data
211
- const req = ctx.request;
212
- if (req) {
213
- const cf = (req as any).cf as Record<string, unknown> | undefined;
214
- if (cf?.country) {
215
- return {
216
- country: (cf.country as string) ?? "",
217
- regionCode: (cf.regionCode as string) ?? (cf.region as string) ?? "",
218
- regionName: (cf.region as string) ?? "",
219
- city: (cf.city as string) ?? "",
220
- };
234
+ function getGeoData(ctx: MatcherContext): GeoSource {
235
+ const headers = ctx.headers ?? {};
236
+ const reqHeaders = ctx.request?.headers;
237
+ const h = (name: string): string =>
238
+ headers[name] ?? reqHeaders?.get(name) ?? "";
239
+
240
+ let regionCode = h("cf-region-code");
241
+ let country = h("cf-ipcountry") || h("x-vercel-ip-country");
242
+ let city = h("cf-ipcity");
243
+ let latitude = h("cf-iplatitude");
244
+ let longitude = h("cf-iplongitude");
245
+
246
+ if (!country || !regionCode || !city || !latitude || !longitude) {
247
+ const req = ctx.request;
248
+ const cf = req ? ((req as any).cf as Record<string, unknown> | undefined) : undefined;
249
+ if (cf) {
250
+ country = country || ((cf.country as string) ?? "");
251
+ regionCode = regionCode || ((cf.regionCode as string) ?? "");
252
+ city = city || ((cf.city as string) ?? "");
253
+ latitude = latitude || (cf.latitude != null ? String(cf.latitude) : "");
254
+ longitude = longitude || (cf.longitude != null ? String(cf.longitude) : "");
221
255
  }
222
256
  }
223
257
 
224
- // 2. Geo cookies (set by Cloudflare middleware on Fresh/Deno sites)
225
- const cookies = ctx.cookies ?? {};
226
- const cookieCountry = cookies.__cf_geo_country ? decodeURIComponent(cookies.__cf_geo_country) : "";
227
- if (cookieCountry) {
228
- return {
229
- country: cookieCountry,
230
- regionCode: cookies.__cf_geo_region_code ? decodeURIComponent(cookies.__cf_geo_region_code) : "",
231
- regionName: cookies.__cf_geo_region ? decodeURIComponent(cookies.__cf_geo_region) : "",
232
- city: cookies.__cf_geo_city ? decodeURIComponent(cookies.__cf_geo_city) : "",
233
- };
258
+ if (!country || !regionCode || !city || !latitude || !longitude) {
259
+ const cookies = ctx.cookies ?? {};
260
+ country = country || decodeCookie(cookies.__cf_geo_country);
261
+ regionCode = regionCode || decodeCookie(cookies.__cf_geo_region_code);
262
+ city = city || decodeCookie(cookies.__cf_geo_city);
263
+ latitude = latitude || decodeCookie(cookies.__cf_geo_lat);
264
+ longitude = longitude || decodeCookie(cookies.__cf_geo_lng);
234
265
  }
235
266
 
236
- // 3. Fallback: standard geo headers (Vercel, etc.)
237
- const headers = ctx.headers ?? {};
238
- return {
239
- country: headers["cf-ipcountry"] ?? headers["x-vercel-ip-country"] ?? "",
240
- regionCode: headers["cf-region"] ?? headers["x-vercel-ip-country-region"] ?? "",
241
- regionName: "",
242
- city: "",
243
- };
267
+ const coordinates = latitude && longitude ? `${latitude},${longitude}` : undefined;
268
+ return { country, regionCode, city, coordinates };
244
269
  }
245
270
 
246
- function matchesLocationRule(
247
- loc: LocationRule,
248
- geo: GeoData,
249
- ): boolean {
250
- if (loc.country) {
251
- const code = resolveCountryCode(loc.country);
252
- if (code.toUpperCase() !== geo.country.toUpperCase()) return false;
253
- }
254
- if (loc.regionCode) {
255
- // Match against both the short code ("SP") and full name ("São Paulo")
256
- // so rules authored against either format continue working.
257
- const ruleVal = loc.regionCode.toLowerCase();
258
- if (geo.regionCode.toLowerCase() !== ruleVal && geo.regionName.toLowerCase() !== ruleVal) return false;
271
+ /**
272
+ * Haversine "within radius" check.
273
+ *
274
+ * @param source "latitude,longitude" from the request's geo.
275
+ * @param target "latitude,longitude,radius_in_meters" from the rule entry.
276
+ */
277
+ function haversineWithinRadius(source: string, target: string): boolean {
278
+ const [slat, slng] = source.split(",").map(Number);
279
+ const parts = target.split(",").map(Number);
280
+ const [tlat, tlng, radiusMeters] = parts;
281
+ if (
282
+ !Number.isFinite(slat) ||
283
+ !Number.isFinite(slng) ||
284
+ !Number.isFinite(tlat) ||
285
+ !Number.isFinite(tlng) ||
286
+ !Number.isFinite(radiusMeters)
287
+ ) {
288
+ return false;
259
289
  }
260
- if (loc.city && loc.city.toLowerCase() !== geo.city.toLowerCase()) return false;
261
- return true;
290
+ const R = 6371000;
291
+ const toRad = (d: number) => (d * Math.PI) / 180;
292
+ const dLat = toRad(tlat - slat);
293
+ const dLng = toRad(tlng - slng);
294
+ const a =
295
+ Math.sin(dLat / 2) ** 2 +
296
+ Math.cos(toRad(slat)) * Math.cos(toRad(tlat)) * Math.sin(dLng / 2) ** 2;
297
+ const distance = 2 * R * Math.asin(Math.sqrt(a));
298
+ return distance <= radiusMeters;
262
299
  }
263
300
 
264
- function locationMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
265
- const geo = getGeoData(ctx);
266
- if (!geo.country) return !((rule.includeLocations as unknown[] | undefined)?.length);
301
+ function matchLocation(defaultNotMatched: boolean, source: GeoSource) {
302
+ return (target: LocationOrMap): boolean => {
303
+ const hasRegion = !!target.regionCode;
304
+ const hasCity = !!target.city;
305
+ const hasCountry = !!target.country;
306
+ const hasCoords = !!target.coordinates;
307
+
308
+ if (!hasRegion && !hasCity && !hasCountry && !hasCoords) {
309
+ return defaultNotMatched;
310
+ }
311
+
312
+ let result =
313
+ !hasRegion ||
314
+ (target.regionCode!.toLowerCase() === source.regionCode.toLowerCase());
315
+
316
+ // Map-mode entries require the visitor to have coordinates — otherwise
317
+ // we can't tell whether they're inside the target radius, so the
318
+ // conservative default is "no match". This is a deliberate divergence
319
+ // from deco-cx/apps, which let coord-only rules vacuously pass when the
320
+ // visitor had no lat/lng — that behavior matches every visitor without
321
+ // geo data, which is a footgun in production.
322
+ result = result &&
323
+ (!hasCoords ||
324
+ (!!source.coordinates &&
325
+ haversineWithinRadius(source.coordinates, target.coordinates!)));
326
+
327
+ result = result &&
328
+ (!hasCity || target.city!.toLowerCase() === source.city.toLowerCase());
329
+
330
+ result = result &&
331
+ (!hasCountry ||
332
+ resolveCountryCode(target.country!).toUpperCase() ===
333
+ source.country.toUpperCase());
334
+
335
+ return result;
336
+ };
337
+ }
267
338
 
268
- const includeLocations = rule.includeLocations as LocationRule[] | undefined;
269
- const excludeLocations = rule.excludeLocations as LocationRule[] | undefined;
339
+ function locationMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
340
+ const source = getGeoData(ctx);
341
+ const includeLocations = rule.includeLocations as LocationOrMap[] | undefined;
342
+ const excludeLocations = rule.excludeLocations as LocationOrMap[] | undefined;
270
343
 
271
- if (excludeLocations?.some((loc) => matchesLocationRule(loc, geo))) {
344
+ if (excludeLocations?.some(matchLocation(false, source))) {
272
345
  return false;
273
346
  }
274
- if (includeLocations?.length) {
275
- return includeLocations.some((loc) => matchesLocationRule(loc, geo));
347
+ if (!includeLocations || includeLocations.length === 0) {
348
+ return true;
276
349
  }
277
- return true;
350
+ return includeLocations.some(matchLocation(true, source));
278
351
  }
279
352
 
280
353
  // -------------------------------------------------------------------------
@@ -253,6 +253,22 @@ export const MetricNames = {
253
253
  * `MIGRATION_TOOLING_PLAN.md` for the rationale.
254
254
  */
255
255
  COMMERCE_REQUEST_DURATION_MS: "commerce_request_duration_ms",
256
+ /**
257
+ * Per-loader execution duration. Emitted by `cachedLoader` for every
258
+ * loader call — cached or not. The `cache_status` label lets dashboards
259
+ * separate origin latency from in-memory hit latency without needing
260
+ * to join on traces.
261
+ *
262
+ * Canonical labels: `loader`, `cache_status`.
263
+ */
264
+ LOADER_DURATION_MS: "loader_duration_ms",
265
+ /**
266
+ * Counter incremented when a loader throws. Complements
267
+ * `loader_duration_ms` for error-rate dashboards.
268
+ *
269
+ * Canonical labels: `loader`.
270
+ */
271
+ LOADER_ERRORS_TOTAL: "loader_errors_total",
256
272
  } as const;
257
273
 
258
274
  /**
@@ -455,6 +471,35 @@ export function recordCommerceMetric(
455
471
  m.histogramRecord?.(MetricNames.COMMERCE_REQUEST_DURATION_MS, durationMs, merged);
456
472
  }
457
473
 
474
+ /**
475
+ * Record a loader execution sample. Call from `cachedLoader` after the
476
+ * loader resolves or rejects. `cache_status` mirrors `CacheDecision` so
477
+ * dashboards can distinguish HIT (fresh) from STALE-HIT (SWR), STALE-ERROR
478
+ * (SIE fallback), MISS (origin fetch), and BYPASS (dev / no-store).
479
+ */
480
+ export function recordLoaderMetric(
481
+ name: string,
482
+ durationMs: number,
483
+ cacheStatus: CacheDecision | "BYPASS",
484
+ ) {
485
+ const m = getState().meter;
486
+ if (!m) return;
487
+ m.histogramRecord?.(MetricNames.LOADER_DURATION_MS, durationMs, {
488
+ loader: name,
489
+ cache_status: cacheStatus,
490
+ });
491
+ }
492
+
493
+ /**
494
+ * Increment the loader error counter. Call when a loader throws and the
495
+ * error is not swallowed by a SIE fallback.
496
+ */
497
+ export function recordLoaderError(name: string) {
498
+ const m = getState().meter;
499
+ if (!m) return;
500
+ m.counterInc(MetricNames.LOADER_ERRORS_TOTAL, 1, { loader: name });
501
+ }
502
+
458
503
  function normalizePath(path: string): string {
459
504
  // Collapse dynamic segments to reduce cardinality
460
505
  return path
@@ -11,7 +11,12 @@
11
11
  * (e.g. "product") which derives timing from the unified profile system.
12
12
  */
13
13
 
14
- import { recordCacheMetric, withTracing } from "../middleware/observability";
14
+ import {
15
+ recordCacheMetric,
16
+ recordLoaderError,
17
+ recordLoaderMetric,
18
+ withTracing,
19
+ } from "../middleware/observability";
15
20
  import { type CacheProfileName, loaderCacheOptions } from "./cacheHeaders";
16
21
 
17
22
  export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate";
@@ -100,17 +105,32 @@ export function createCachedLoader<TProps, TResult>(
100
105
  if (inflight) {
101
106
  // Treat in-flight dedup as a cache hit — avoided the origin call.
102
107
  recordCacheMetric(true, name, undefined, "cachedLoader");
103
- return inflight as Promise<TResult>;
108
+ const start = performance.now();
109
+ return inflight.then((r) => {
110
+ recordLoaderMetric(name, performance.now() - start, "HIT");
111
+ return r as TResult;
112
+ });
104
113
  }
105
114
 
106
115
  if (isDev) {
107
116
  // Dev mode: no caching, but still useful to count attempts.
108
117
  recordCacheMetric(false, name, undefined, "cachedLoader");
118
+ const devStart = performance.now();
109
119
  const promise = withTracing(
110
120
  "deco.cachedLoader",
111
- () => loaderFn(props).finally(() => inflightRequests.delete(cacheKey)),
121
+ () => loaderFn(props),
112
122
  { "deco.loader": name, "deco.cache.policy": "no-cache-dev" },
113
- );
123
+ )
124
+ .then((r) => {
125
+ recordLoaderMetric(name, performance.now() - devStart, "BYPASS");
126
+ return r;
127
+ })
128
+ .catch((err) => {
129
+ recordLoaderMetric(name, performance.now() - devStart, "BYPASS");
130
+ recordLoaderError(name);
131
+ throw err;
132
+ })
133
+ .finally(() => inflightRequests.delete(cacheKey));
114
134
  inflightRequests.set(cacheKey, promise);
115
135
  return promise;
116
136
  }
@@ -122,6 +142,7 @@ export function createCachedLoader<TProps, TResult>(
122
142
  if (policy === "no-cache") {
123
143
  if (entry && !isStale) {
124
144
  recordCacheMetric(true, name, "HIT", "cachedLoader");
145
+ recordLoaderMetric(name, 0, "HIT");
125
146
  return entry.value;
126
147
  }
127
148
  }
@@ -129,12 +150,14 @@ export function createCachedLoader<TProps, TResult>(
129
150
  if (policy === "stale-while-revalidate") {
130
151
  if (entry && !isStale) {
131
152
  recordCacheMetric(true, name, "HIT", "cachedLoader");
153
+ recordLoaderMetric(name, 0, "HIT");
132
154
  return entry.value;
133
155
  }
134
156
 
135
157
  if (entry && isStale && !entry.refreshing) {
136
158
  // Stale-while-revalidate hit: serve stale, refresh in background.
137
159
  recordCacheMetric(true, name, "STALE-HIT", "cachedLoader");
160
+ recordLoaderMetric(name, 0, "STALE-HIT");
138
161
  entry.refreshing = true;
139
162
  loaderFn(props)
140
163
  .then((result) => {
@@ -160,6 +183,7 @@ export function createCachedLoader<TProps, TResult>(
160
183
  // the decision as STALE-ERROR so dashboards can distinguish
161
184
  // this from healthy SWR.
162
185
  recordCacheMetric(true, name, "STALE-ERROR", "cachedLoader");
186
+ recordLoaderMetric(name, 0, "STALE-ERROR");
163
187
  return entry.value;
164
188
  }
165
189
  }
@@ -167,11 +191,13 @@ export function createCachedLoader<TProps, TResult>(
167
191
  // Cache miss — emit metric, then run loader inside a span so individual
168
192
  // slow loaders are visible in traces.
169
193
  recordCacheMetric(false, name, "MISS", "cachedLoader");
194
+ const loaderStart = performance.now();
170
195
  const promise = withTracing("deco.cachedLoader", () => loaderFn(props), {
171
196
  "deco.loader": name,
172
197
  "deco.cache.policy": policy,
173
198
  })
174
199
  .then((result) => {
200
+ recordLoaderMetric(name, performance.now() - loaderStart, "MISS");
175
201
  cache.set(cacheKey, {
176
202
  value: result,
177
203
  createdAt: Date.now(),
@@ -190,9 +216,12 @@ export function createCachedLoader<TProps, TResult>(
190
216
  console.warn(
191
217
  `[cachedLoader] ${name}: origin error, serving stale entry (age=${Math.round(age / 1000)}s, sie=${Math.round(staleIfError / 1000)}s)`,
192
218
  );
219
+ recordLoaderMetric(name, performance.now() - loaderStart, "STALE-ERROR");
193
220
  return entry.value;
194
221
  }
195
222
  }
223
+ recordLoaderMetric(name, performance.now() - loaderStart, "MISS");
224
+ recordLoaderError(name);
196
225
  throw err;
197
226
  });
198
227
 
@@ -65,6 +65,8 @@ export {
65
65
  MetricNames,
66
66
  recordCacheMetric,
67
67
  recordCommerceMetric,
68
+ recordLoaderError,
69
+ recordLoaderMetric,
68
70
  recordRequestMetric,
69
71
  type RequestMetricLabels,
70
72
  type RequestStore,
@@ -489,6 +489,17 @@ export function injectGeoCookies(request: Request): Request {
489
489
  }
490
490
  headers.set("cookie", combined);
491
491
 
492
+ // Mirror the ASCII-safe geo fields from request.cf into headers so matchers
493
+ // that read `request.headers.get("cf-region-code")` (parity with the
494
+ // upstream deco-cx/apps location matcher) still work even if the inbound
495
+ // request didn't carry them. Non-ASCII fields (region name, city) stay in
496
+ // the cookies above — putting them in headers would re-trigger the
497
+ // non-ASCII warning we strip on the loop above.
498
+ if (cf.country && !headers.has("cf-ipcountry")) headers.set("cf-ipcountry", cf.country);
499
+ if (cf.regionCode && !headers.has("cf-region-code")) headers.set("cf-region-code", cf.regionCode);
500
+ if (cf.latitude && !headers.has("cf-iplatitude")) headers.set("cf-iplatitude", cf.latitude);
501
+ if (cf.longitude && !headers.has("cf-iplongitude")) headers.set("cf-iplongitude", cf.longitude);
502
+
492
503
  return new Request(request, { headers });
493
504
  }
494
505