@decocms/start 6.3.0 → 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 +1 -1
- package/scripts/migrate/templates/server-entry.ts +14 -1
- package/src/admin/schema.ts +43 -25
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +132 -59
- package/src/sdk/workerEntry.ts +11 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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: {
|
package/src/admin/schema.ts
CHANGED
|
@@ -301,35 +301,53 @@ registerMatcherSchemas([
|
|
|
301
301
|
key: "website/matchers/location.ts",
|
|
302
302
|
title: "Location",
|
|
303
303
|
namespace: "website",
|
|
304
|
-
propsSchema: {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
+
});
|
package/src/matchers/builtins.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
*
|
|
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):
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
const
|
|
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((
|
|
344
|
+
if (excludeLocations?.some(matchLocation(false, source))) {
|
|
272
345
|
return false;
|
|
273
346
|
}
|
|
274
|
-
if (includeLocations
|
|
275
|
-
return
|
|
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
|
// -------------------------------------------------------------------------
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -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
|
|