@decocms/blocks 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,91 @@
1
+ import { type ComponentType, lazy, Suspense } from "react";
2
+ import { getSection, getSyncComponent } from "../cms/registry";
3
+
4
+ const componentCache = new Map<string, ComponentType<any>>();
5
+
6
+ function getOrCreateLazy(resolveType: string): ComponentType<any> | null {
7
+ if (componentCache.has(resolveType)) {
8
+ return componentCache.get(resolveType)!;
9
+ }
10
+
11
+ const loader = getSection(resolveType);
12
+ if (!loader) return null;
13
+
14
+ const LazyComponent = lazy(async () => {
15
+ const mod = await loader();
16
+ return { default: mod.default };
17
+ });
18
+
19
+ componentCache.set(resolveType, LazyComponent);
20
+ return LazyComponent;
21
+ }
22
+
23
+ interface SectionLike {
24
+ __resolveType?: string;
25
+ Component?: ComponentType<any>;
26
+ props?: Record<string, unknown>;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ /**
31
+ * Renders a Section-type prop from the CMS.
32
+ *
33
+ * Handles both the old Deco format ({ Component, props }) and
34
+ * the new @decocms/start format ({ __resolveType, ...props }).
35
+ *
36
+ * Sync-first: sections registered as sync (`export const sync = true`,
37
+ * bundled in the main chunk) render DIRECTLY — no React.lazy / Suspense.
38
+ * This mirrors DecoPageRenderer's sync-first path and matters twice:
39
+ *
40
+ * - SSR streaming: React 19 omits the `<!--$-->` markers when a Suspense
41
+ * boundary resolves synchronously, which triggers a hydration mismatch
42
+ * (minified #418) — same reason DecoPageRenderer bifurcates.
43
+ * - SPA commit: the lazy path suspends with `fallback ?? null` while the
44
+ * section chunk loads, so a route commit that renders content through
45
+ * RenderSection (e.g. a gate section like NotFoundChallenge) paints the
46
+ * page with a BLANK middle until the chunk resolves.
47
+ *
48
+ * Sites without registered sync components are unaffected:
49
+ * `getSyncComponent()` returns undefined and the lazy path runs as before.
50
+ */
51
+ export default function RenderSection({
52
+ section,
53
+ fallback,
54
+ }: {
55
+ section: SectionLike | null | undefined;
56
+ fallback?: React.ReactNode;
57
+ }) {
58
+ if (!section) return null;
59
+
60
+ if (section.Component) {
61
+ const { Component, props = {}, ...overrides } = section;
62
+ const mergedProps = { ...(props as Record<string, unknown>), ...overrides };
63
+ if (typeof Component === "string") {
64
+ const Sync = getSyncComponent(Component);
65
+ if (Sync) return <Sync {...mergedProps} />;
66
+ const Comp = getOrCreateLazy(Component);
67
+ if (!Comp) return <>{fallback ?? null}</>;
68
+ return (
69
+ <Suspense fallback={fallback ?? null}>
70
+ <Comp {...mergedProps} />
71
+ </Suspense>
72
+ );
73
+ }
74
+ return <Component {...mergedProps} />;
75
+ }
76
+
77
+ if (section.__resolveType) {
78
+ const { __resolveType: _, ...props } = section;
79
+ const Sync = getSyncComponent(section.__resolveType);
80
+ if (Sync) return <Sync {...props} />;
81
+ const Comp = getOrCreateLazy(section.__resolveType);
82
+ if (!Comp) return <>{fallback ?? null}</>;
83
+ return (
84
+ <Suspense fallback={fallback ?? null}>
85
+ <Comp {...props} />
86
+ </Suspense>
87
+ );
88
+ }
89
+
90
+ return null;
91
+ }
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ // `"use client"` added by Phase 2 Task 9 (@decocms/nextjs's next-smoke
4
+ // fixture): `SectionErrorBoundary` is a class component with
5
+ // `componentDidCatch`/`getDerivedStateFromError` — error boundaries require
6
+ // a Client Component in Next's App Router. `SectionRenderer.tsx` wraps
7
+ // every rendered section in this boundary, so without the directive, any
8
+ // Server Component that ends up importing `@decocms/blocks/hooks`
9
+ // transitively fails Next's build. Server Component children passed via
10
+ // `props.children` still render fine — passing already-rendered Server
11
+ // Component output into a Client Component's `children` is a supported RSC
12
+ // composition pattern.
13
+ import { Component, type ErrorInfo, type ReactNode } from "react";
14
+
15
+ interface ErrorBoundaryProps {
16
+ sectionKey: string;
17
+ fallback?: ReactNode;
18
+ children: ReactNode;
19
+ }
20
+
21
+ interface ErrorBoundaryState {
22
+ error: Error | null;
23
+ }
24
+
25
+ /**
26
+ * Per-section error boundary that prevents a single broken section
27
+ * from crashing the entire page.
28
+ *
29
+ * In development, shows the error message + stack trace.
30
+ * In production, renders a silent empty placeholder.
31
+ */
32
+ export class SectionErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
33
+ constructor(props: ErrorBoundaryProps) {
34
+ super(props);
35
+ this.state = { error: null };
36
+ }
37
+
38
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
39
+ return { error };
40
+ }
41
+
42
+ componentDidCatch(error: Error, info: ErrorInfo) {
43
+ console.error(`[CMS] Section "${this.props.sectionKey}" crashed:`, error, info.componentStack);
44
+ }
45
+
46
+ render() {
47
+ if (this.state.error) {
48
+ if (this.props.fallback) return this.props.fallback;
49
+ return (
50
+ <DefaultSectionErrorFallback error={this.state.error} sectionKey={this.props.sectionKey} />
51
+ );
52
+ }
53
+ return this.props.children;
54
+ }
55
+ }
56
+
57
+ function DefaultSectionErrorFallback({ error, sectionKey }: { error: Error; sectionKey: string }) {
58
+ const isDev = typeof process !== "undefined" && process.env.NODE_ENV === "development";
59
+
60
+ if (!isDev) {
61
+ return <div data-section-error={sectionKey} className="hidden" />;
62
+ }
63
+
64
+ return (
65
+ <div
66
+ data-section-error={sectionKey}
67
+ style={{
68
+ padding: "16px",
69
+ margin: "8px 0",
70
+ border: "2px solid #ef4444",
71
+ borderRadius: "8px",
72
+ background: "#fef2f2",
73
+ fontFamily: "monospace",
74
+ fontSize: "13px",
75
+ }}
76
+ >
77
+ <div style={{ fontWeight: "bold", color: "#dc2626", marginBottom: "8px" }}>
78
+ Section Error: {sectionKey}
79
+ </div>
80
+ <div style={{ color: "#991b1b" }}>{error.message}</div>
81
+ {error.stack && (
82
+ <pre
83
+ style={{
84
+ marginTop: "8px",
85
+ fontSize: "11px",
86
+ color: "#6b7280",
87
+ whiteSpace: "pre-wrap",
88
+ overflow: "auto",
89
+ maxHeight: "200px",
90
+ }}
91
+ >
92
+ {error.stack}
93
+ </pre>
94
+ )}
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,4 @@
1
+ export { isBelowFold, LazySection, type LazySectionProps } from "./LazySection";
2
+ export { LiveControls } from "./LiveControls";
3
+ export { SectionErrorBoundary } from "./SectionErrorFallback";
4
+ export { default as RenderSection } from "./RenderSection";
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // @decocms/blocks — framework-agnostic core
2
+ export * from "./cms/index";
3
+ export * from "./hooks/index";
4
+ export * from "./middleware/index";
5
+ // Observability surface — logger + instrumentWorker live behind their own
6
+ // granular imports too (see `@decocms/blocks/sdk/logger`, `.../observability`).
7
+ export { type Logger, type LogLevel, logger, setLogLevel } from "./sdk/logger";
8
+ export * from "./types/index";
@@ -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
+ });