@decocms/tanstack 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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Generic bridge that turns any async function into a TanStack Start server function.
3
+ *
4
+ * Used by @decocms/apps to expose commerce actions/loaders as typed
5
+ * `invoke.*` calls that execute on the server with full credentials.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { createInvokeFn } from "@decocms/start/sdk/createInvoke";
10
+ * import { addItemsToCart } from "./actions/checkout";
11
+ *
12
+ * export const invoke = {
13
+ * vtex: {
14
+ * actions: {
15
+ * addItemsToCart: createInvokeFn(
16
+ * (input: { orderFormId: string; orderItems: CartItem[] }) =>
17
+ * addItemsToCart(input.orderFormId, input.orderItems),
18
+ * { unwrap: true },
19
+ * ),
20
+ * },
21
+ * },
22
+ * };
23
+ *
24
+ * // Client-side usage:
25
+ * await invoke.vtex.actions.addItemsToCart({ data: { orderFormId, orderItems } });
26
+ * ```
27
+ */
28
+ import { createServerFn } from "@tanstack/react-start";
29
+
30
+ export interface InvokeFnOpts {
31
+ /**
32
+ * When true, extracts `.data` from the result before returning.
33
+ * Use for VTEX checkout functions that return VtexFetchResult<T>
34
+ * (i.e. `{ data: T, setCookies: string[] }`).
35
+ */
36
+ unwrap?: boolean;
37
+ }
38
+
39
+ /**
40
+ * Transforms an async function into a `createServerFn` wrapper.
41
+ *
42
+ * - Client calls: `fn({ data: input })`
43
+ * - Server executes: `action(input)`
44
+ * - If `unwrap: true`, extracts `.data` from VtexFetchResult-shaped results
45
+ */
46
+ export function createInvokeFn<TInput, TOutput>(
47
+ action: (input: TInput) => Promise<TOutput>,
48
+ opts?: InvokeFnOpts,
49
+ ): (ctx: { data: TInput }) => Promise<TOutput> {
50
+ return createServerFn({ method: "POST" }).handler(async (ctx) => {
51
+ const result = await action(ctx.data as TInput);
52
+ if (opts?.unwrap && result && typeof result === "object" && "data" in result) {
53
+ return (result as any).data;
54
+ }
55
+ return result;
56
+ }) as unknown as (ctx: { data: TInput }) => Promise<TOutput>;
57
+ }
@@ -0,0 +1,171 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { computeRevision, getRevision, KV_KEYS, type KVNamespace, loadBlocks, setBlocks } from "@decocms/blocks/cms";
3
+ import {
4
+ __resetKvHydrationStateForTests,
5
+ ensureBlocksHydrated,
6
+ isFastDeployEnabled,
7
+ maybePollRevision,
8
+ } from "./kvHydration";
9
+
10
+ const BUNDLED = { Site: { name: "bundled" } };
11
+
12
+ /** KV stub that counts get() calls so we can assert throttling / single-load. */
13
+ function makeKV(initial: Record<string, string> = {}) {
14
+ const store = new Map<string, string>(Object.entries(initial));
15
+ let getCalls = 0;
16
+ const kv: KVNamespace = {
17
+ get: (k) => {
18
+ getCalls++;
19
+ return Promise.resolve(store.get(k) ?? null);
20
+ },
21
+ put: (k, v) => {
22
+ store.set(k, v);
23
+ return Promise.resolve();
24
+ },
25
+ delete: (k) => {
26
+ store.delete(k);
27
+ return Promise.resolve();
28
+ },
29
+ };
30
+ return { kv, store, getCalls: () => getCalls };
31
+ }
32
+
33
+ function snapshotEnv(blocks: Record<string, unknown>, extra: Record<string, unknown> = {}) {
34
+ const { kv, ...rest } = makeKV({
35
+ [KV_KEYS.SNAPSHOT]: JSON.stringify(blocks),
36
+ [KV_KEYS.REVISION]: computeRevision(blocks),
37
+ });
38
+ // DECO_FAST_DEPLOY="1" is the explicit opt-in required alongside the binding.
39
+ return { env: { DECO_KV: kv, DECO_FAST_DEPLOY: "1", ...extra }, kv, ...rest };
40
+ }
41
+
42
+ /** Collects ctx.waitUntil promises so tests can await background polls. */
43
+ function makeCtx() {
44
+ const promises: Promise<unknown>[] = [];
45
+ return {
46
+ ctx: { waitUntil: (p: Promise<unknown>) => promises.push(p) },
47
+ settle: () => Promise.allSettled(promises),
48
+ };
49
+ }
50
+
51
+ beforeEach(() => {
52
+ __resetKvHydrationStateForTests();
53
+ setBlocks({ ...BUNDLED }); // reset in-memory decofile to a known bundled state
54
+ vi.restoreAllMocks();
55
+ });
56
+
57
+ describe("isFastDeployEnabled", () => {
58
+ it("is false without a KV binding even when the flag is set", () => {
59
+ expect(isFastDeployEnabled({ DECO_FAST_DEPLOY: "1" })).toBe(false);
60
+ });
61
+
62
+ it("is false when a non-KV value is named DECO_KV", () => {
63
+ expect(isFastDeployEnabled({ DECO_KV: "some-secret-string", DECO_FAST_DEPLOY: "1" })).toBe(false);
64
+ });
65
+
66
+ it("is false when bound but DECO_FAST_DEPLOY is not set (explicit opt-in required)", () => {
67
+ const { kv } = makeKV();
68
+ expect(isFastDeployEnabled({ DECO_KV: kv })).toBe(false);
69
+ });
70
+
71
+ it("is true when bound AND DECO_FAST_DEPLOY=1", () => {
72
+ const { kv } = makeKV();
73
+ expect(isFastDeployEnabled({ DECO_KV: kv, DECO_FAST_DEPLOY: "1" })).toBe(true);
74
+ });
75
+
76
+ it("accepts DECO_FAST_DEPLOY=true as well", () => {
77
+ const { kv } = makeKV();
78
+ expect(isFastDeployEnabled({ DECO_KV: kv, DECO_FAST_DEPLOY: "true" })).toBe(true);
79
+ });
80
+
81
+ it("is false when DECO_FAST_DEPLOY=0 even if bound", () => {
82
+ const { kv } = makeKV();
83
+ expect(isFastDeployEnabled({ DECO_KV: kv, DECO_FAST_DEPLOY: "0" })).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe("ensureBlocksHydrated", () => {
88
+ it("is a no-op when fast-deploy is disabled (keeps bundled blocks)", async () => {
89
+ await ensureBlocksHydrated({});
90
+ expect(loadBlocks()).toEqual(BUNDLED);
91
+ });
92
+
93
+ it("swaps the in-memory decofile with the KV snapshot", async () => {
94
+ const kvBlocks = { Site: { name: "from-kv" }, "pages-home": { path: "/" } };
95
+ const { env } = snapshotEnv(kvBlocks);
96
+ await ensureBlocksHydrated(env);
97
+ expect(loadBlocks()).toEqual(kvBlocks);
98
+ expect(getRevision()).toBe(computeRevision(kvBlocks));
99
+ });
100
+
101
+ it("loads only once even across concurrent first requests", async () => {
102
+ const kvBlocks = { Site: { name: "from-kv" } };
103
+ const { env, getCalls } = snapshotEnv(kvBlocks);
104
+ await Promise.all([
105
+ ensureBlocksHydrated(env),
106
+ ensureBlocksHydrated(env),
107
+ ensureBlocksHydrated(env),
108
+ ]);
109
+ // SNAPSHOT + REVISION = 2 gets for a single load (not 6).
110
+ expect(getCalls()).toBe(2);
111
+ });
112
+
113
+ it("keeps the bundled snapshot when the snapshot key is absent", async () => {
114
+ const { kv } = makeKV({ [KV_KEYS.REVISION]: "r" }); // no SNAPSHOT
115
+ await ensureBlocksHydrated({ DECO_KV: kv });
116
+ expect(loadBlocks()).toEqual(BUNDLED);
117
+ });
118
+
119
+ it("falls back to bundled (and does not throw) when KV errors", async () => {
120
+ const kv: KVNamespace = {
121
+ get: () => Promise.reject(new Error("KV down")),
122
+ put: () => Promise.resolve(),
123
+ delete: () => Promise.resolve(),
124
+ };
125
+ vi.spyOn(console, "warn").mockImplementation(() => {});
126
+ await expect(ensureBlocksHydrated({ DECO_KV: kv })).resolves.toBeUndefined();
127
+ expect(loadBlocks()).toEqual(BUNDLED);
128
+ });
129
+ });
130
+
131
+ describe("maybePollRevision", () => {
132
+ it("is a no-op before cold-start hydration has run", async () => {
133
+ const { env, getCalls } = snapshotEnv({ Site: { name: "x" } });
134
+ const { ctx, settle } = makeCtx();
135
+ maybePollRevision(env, ctx); // kvHydrated is false
136
+ await settle();
137
+ expect(getCalls()).toBe(0);
138
+ });
139
+
140
+ it("reloads the decofile when the KV revision changed", async () => {
141
+ const initial = { Site: { name: "v1" } };
142
+ const { env, store } = snapshotEnv(initial);
143
+ await ensureBlocksHydrated(env);
144
+ expect(loadBlocks()).toEqual(initial);
145
+
146
+ // Simulate a publish from another isolate: KV now holds v2.
147
+ const updated = { Site: { name: "v2" }, "pages-x": { path: "/x" } };
148
+ store.set(KV_KEYS.SNAPSHOT, JSON.stringify(updated));
149
+ store.set(KV_KEYS.REVISION, computeRevision(updated));
150
+
151
+ const { ctx, settle } = makeCtx();
152
+ maybePollRevision(env, ctx);
153
+ await settle();
154
+ expect(loadBlocks()).toEqual(updated);
155
+ });
156
+
157
+ it("throttles to one probe per interval", async () => {
158
+ const { env, getCalls } = snapshotEnv({ Site: { name: "x" } });
159
+ await ensureBlocksHydrated(env);
160
+ const callsAfterHydrate = getCalls();
161
+
162
+ const { ctx, settle } = makeCtx();
163
+ maybePollRevision(env, ctx); // fires (revision unchanged → 1 get)
164
+ maybePollRevision(env, ctx); // throttled → no get
165
+ maybePollRevision(env, ctx); // throttled → no get
166
+ await settle();
167
+
168
+ // Exactly one extra getRevision() beyond the hydrate calls.
169
+ expect(getCalls()).toBe(callsAfterHydrate + 1);
170
+ });
171
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Fast-deploy KV hydration — the runtime read path.
3
+ *
4
+ * Bridges a Cloudflare KV namespace to the in-memory decofile so CMS content
5
+ * edits propagate WITHOUT a `wrangler deploy`. Two entry points, both called
6
+ * from `workerEntry.ts`:
7
+ *
8
+ * - `ensureBlocksHydrated(env, ctx)` — on the FIRST request per isolate, load
9
+ * the whole snapshot from KV and swap it in via `setBlocks()`. Awaited, so it
10
+ * adds one ~10-30ms cold-start hit per isolate but guarantees fresh content
11
+ * (the bundled `blocks.gen` snapshot is frozen at the last code deploy).
12
+ *
13
+ * - `maybePollRevision(env, ctx)` — on EVERY request, opportunistically (gated
14
+ * to once per `POLL_INTERVAL_MS`) probe `index:revision` via `ctx.waitUntil`
15
+ * so it never blocks the response; reload + swap when it changed.
16
+ *
17
+ * Everything is a no-op unless fast-deploy is enabled (`isFastDeployEnabled`),
18
+ * so non-migrated sites behave exactly as before.
19
+ *
20
+ * Why whole-snapshot swap (not per-block async): the resolver reads
21
+ * `loadBlocks()` synchronously in dozens of places. Loading the entire decofile
22
+ * once and swapping the map keeps that hot path synchronous — KV is touched
23
+ * only on cold start and during the throttled poll. Mirrors the
24
+ * `DecofileProvider` pattern from the deco-cx/deco Fresh runtime.
25
+ */
26
+
27
+ import { getRevision, setBlocks } from "@decocms/blocks/cms";
28
+ import { KVBlockSource } from "../cms/kvBlockSource";
29
+ import type { KVNamespace } from "@decocms/blocks/cms";
30
+ import { setSpanAttribute } from "@decocms/blocks/sdk/observability";
31
+
32
+ /** How often (ms) an isolate re-probes `index:revision`. */
33
+ export const POLL_INTERVAL_MS = 10_000;
34
+
35
+ /** KV binding name expected on the Worker `env`. */
36
+ export const KV_BINDING = "DECO_KV";
37
+
38
+ /** Opt-in env var — set to "1" (or "true") to enable fast-deploy. The DECO_KV
39
+ * binding must also be present. */
40
+ export const FAST_DEPLOY_ENV = "DECO_FAST_DEPLOY";
41
+
42
+ // globalThis-backed state so all Vite server-function split-module copies share
43
+ // the same hydration flags (same pattern as `loader.ts`).
44
+ const G = globalThis as unknown as {
45
+ __deco?: {
46
+ kvHydrated?: boolean;
47
+ kvHydration?: Promise<void> | null;
48
+ kvLastPolledAt?: number;
49
+ };
50
+ };
51
+ if (!G.__deco) G.__deco = {};
52
+
53
+ /** Minimal Cloudflare ExecutionContext shape (matches workerEntry.ts). */
54
+ interface ExecutionContextLike {
55
+ waitUntil(promise: Promise<unknown>): void;
56
+ }
57
+
58
+ type Env = Record<string, unknown>;
59
+
60
+ function getKV(env: Env): KVNamespace | null {
61
+ const binding = env[KV_BINDING];
62
+ // Duck-type the binding: a real KVNamespace has get/put. Guards against a
63
+ // string/secret accidentally named DECO_KV.
64
+ if (binding && typeof (binding as KVNamespace).get === "function") {
65
+ return binding as KVNamespace;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Fast-deploy is active only when BOTH hold: `DECO_FAST_DEPLOY` is set to "1"
72
+ * (or "true") — an explicit, per-site opt-in — AND the `DECO_KV` binding is
73
+ * present. Either missing ⇒ bundled-snapshot behavior, identical to
74
+ * pre-fast-deploy. Requiring the explicit flag means simply binding a KV
75
+ * namespace can't silently flip a site onto the KV read/write path.
76
+ */
77
+ export function isFastDeployEnabled(env: Env): boolean {
78
+ const flag = env[FAST_DEPLOY_ENV];
79
+ if (flag !== "1" && flag !== "true") return false;
80
+ return getKV(env) !== null;
81
+ }
82
+
83
+ /**
84
+ * Resolve the KV namespace for the write-through path, or `null` when
85
+ * fast-deploy is disabled. Shared by `decofile.ts` so the enablement rule
86
+ * lives in exactly one place.
87
+ */
88
+ export function getFastDeployKV(env: Env): KVNamespace | null {
89
+ if (!isFastDeployEnabled(env)) return null;
90
+ return getKV(env);
91
+ }
92
+
93
+ /**
94
+ * Cold-start hydration. Awaits the KV snapshot once per isolate and swaps it
95
+ * into the in-memory block map. Concurrent first requests share a single
96
+ * in-flight load. On any error (KV outage, bad JSON) we keep the bundled
97
+ * snapshot and mark hydration done — recovery happens via `maybePollRevision`
98
+ * once KV is reachable again (its revision will differ from the bundled one).
99
+ */
100
+ export function ensureBlocksHydrated(env: Env, _ctx?: ExecutionContextLike): Promise<void> {
101
+ if (!isFastDeployEnabled(env)) return Promise.resolve();
102
+ if (G.__deco!.kvHydrated) return Promise.resolve();
103
+ if (G.__deco!.kvHydration) return G.__deco!.kvHydration;
104
+
105
+ const kv = getKV(env);
106
+ if (!kv) return Promise.resolve();
107
+
108
+ const load = (async () => {
109
+ try {
110
+ const snapshot = await new KVBlockSource(kv).loadSnapshot();
111
+ if (snapshot) {
112
+ setBlocks(snapshot.blocks);
113
+ setSpanAttribute("deco.block.source", "kv");
114
+ } else {
115
+ setSpanAttribute("deco.block.source", "bundled");
116
+ }
117
+ } catch (e) {
118
+ // Non-fatal: serve the bundled snapshot. The poll loop recovers later.
119
+ console.warn("[CMS/KV] cold-start hydration failed, using bundled snapshot:", e);
120
+ setSpanAttribute("deco.block.source", "bundled");
121
+ } finally {
122
+ G.__deco!.kvHydrated = true;
123
+ G.__deco!.kvHydration = null;
124
+ }
125
+ })();
126
+
127
+ G.__deco!.kvHydration = load;
128
+ return load;
129
+ }
130
+
131
+ /**
132
+ * Opportunistic revision poll. Throttled to once per `POLL_INTERVAL_MS` and run
133
+ * through `ctx.waitUntil` so it never adds latency to the response. Reloads the
134
+ * snapshot and swaps it in when KV's revision differs from the in-memory one.
135
+ */
136
+ export function maybePollRevision(env: Env, ctx?: ExecutionContextLike): void {
137
+ if (!isFastDeployEnabled(env)) return;
138
+ if (!G.__deco!.kvHydrated) return; // wait until cold-start hydration finished
139
+
140
+ const now = Date.now();
141
+ if (now - (G.__deco!.kvLastPolledAt ?? 0) < POLL_INTERVAL_MS) return;
142
+ G.__deco!.kvLastPolledAt = now;
143
+
144
+ const kv = getKV(env);
145
+ if (!kv) return;
146
+
147
+ const poll = pollRevisionOnce(kv);
148
+ // Prefer waitUntil so the work outlives the response; fall back to a
149
+ // fire-and-forget promise (dev / tests) with its rejection swallowed.
150
+ if (ctx?.waitUntil) ctx.waitUntil(poll);
151
+ else void poll.catch(() => {});
152
+ }
153
+
154
+ async function pollRevisionOnce(kv: KVNamespace): Promise<void> {
155
+ try {
156
+ const source = new KVBlockSource(kv);
157
+ const remoteRevision = await source.getRevision();
158
+ if (!remoteRevision || remoteRevision === getRevision()) return;
159
+
160
+ const snapshot = await source.loadSnapshot();
161
+ if (snapshot) {
162
+ setBlocks(snapshot.blocks);
163
+ console.info(`[CMS/KV] decofile refreshed → revision ${snapshot.revision}`);
164
+ }
165
+ } catch (e) {
166
+ // Swallow — a failed poll must never affect the request. Next tick retries.
167
+ console.warn("[CMS/KV] revision poll failed:", e);
168
+ }
169
+ }
170
+
171
+ /** Test-only: reset the isolate-level hydration flags. */
172
+ export function __resetKvHydrationStateForTests(): void {
173
+ G.__deco!.kvHydrated = false;
174
+ G.__deco!.kvHydration = null;
175
+ G.__deco!.kvLastPolledAt = 0;
176
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Deco-flavored TanStack Router factory.
3
+ *
4
+ * Uses standard URLSearchParams serialization instead of TanStack's default
5
+ * JSON-based format. Required because VTEX (and most commerce platforms) uses
6
+ * filter URLs like `?filter.brand=Nike&filter.brand=Adidas` which must
7
+ * round-trip correctly through the router's search system.
8
+ */
9
+ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
10
+ import type {
11
+ SearchSerializer,
12
+ SearchParser,
13
+ AnyRoute,
14
+ TrailingSlashOption,
15
+ } from "@tanstack/react-router";
16
+
17
+ export const decoParseSearch: SearchParser = (searchStr) => {
18
+ const str = searchStr.startsWith("?") ? searchStr.slice(1) : searchStr;
19
+ if (!str) return {};
20
+
21
+ const params = new URLSearchParams(str);
22
+ const result: Record<string, string | string[]> = {};
23
+
24
+ for (const key of new Set(params.keys())) {
25
+ const values = params.getAll(key);
26
+ result[key] = values.length === 1 ? values[0] : values;
27
+ }
28
+ return result;
29
+ };
30
+
31
+ export const decoStringifySearch: SearchSerializer = (search) => {
32
+ const params = new URLSearchParams();
33
+ for (const [key, value] of Object.entries(search)) {
34
+ if (value === undefined || value === null || value === "") continue;
35
+ if (Array.isArray(value)) {
36
+ for (const v of value) params.append(key, String(v));
37
+ } else {
38
+ params.append(key, String(value));
39
+ }
40
+ }
41
+ const str = params.toString();
42
+ return str ? `?${str}` : "";
43
+ };
44
+
45
+ export interface CreateDecoRouterOptions {
46
+ routeTree: AnyRoute;
47
+ scrollRestoration?: boolean;
48
+ defaultPreload?: "intent" | "viewport" | "render" | false;
49
+ trailingSlash?: TrailingSlashOption;
50
+ /**
51
+ * Router context — passed to all route loaders/components via routeContext.
52
+ * Commonly used for { queryClient } per TanStack Query integration docs.
53
+ */
54
+ context?: Record<string, unknown>;
55
+ /**
56
+ * Non-DOM provider component to wrap the entire router.
57
+ * Per TanStack docs, only non-DOM-rendering components (providers) should
58
+ * be used — anything else causes hydration errors.
59
+ *
60
+ * Example: QueryClientProvider wrapping
61
+ * Wrap: ({ children }) => <QueryClientProvider client={qc}>{children}</QueryClientProvider>
62
+ */
63
+ Wrap?: (props: { children: any }) => any;
64
+ }
65
+
66
+ /**
67
+ * Create a TanStack Router with Deco defaults:
68
+ * - URLSearchParams-based search serialization (not JSON)
69
+ * - Scroll restoration enabled
70
+ * - Preload on intent
71
+ */
72
+ export function createDecoRouter(options: CreateDecoRouterOptions) {
73
+ const {
74
+ routeTree,
75
+ scrollRestoration = true,
76
+ defaultPreload = "intent",
77
+ trailingSlash,
78
+ context,
79
+ Wrap,
80
+ } = options;
81
+
82
+ return createTanStackRouter({
83
+ routeTree,
84
+ scrollRestoration,
85
+ defaultPreload,
86
+ trailingSlash,
87
+ context: context as any,
88
+ Wrap,
89
+ parseSearch: decoParseSearch,
90
+ stringifySearch: decoStringifySearch,
91
+ });
92
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Re-export of TanStack Router's `useHydrated` hook.
3
+ *
4
+ * Returns `false` during SSR and on the first client render (before hydration),
5
+ * then `true` for all subsequent renders. Use this instead of
6
+ * `typeof document === "undefined"` checks for conditional rendering.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * import { useHydrated } from "@decocms/start/sdk/useHydrated";
11
+ *
12
+ * function CartButton() {
13
+ * const hydrated = useHydrated();
14
+ * if (!hydrated) return <CartSkeleton />;
15
+ * return <InteractiveCart />;
16
+ * }
17
+ * ```
18
+ */
19
+ export { useHydrated } from "@tanstack/react-router";
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { injectGeoCookies } from "./workerEntry";
3
+
4
+ function parseCookies(header: string): Record<string, string> {
5
+ return Object.fromEntries(
6
+ header.split("; ").map((c) => {
7
+ const [k, ...v] = c.split("=");
8
+ return [k, v.join("=")];
9
+ }),
10
+ );
11
+ }
12
+
13
+ function makeRequest(
14
+ cf: Record<string, string> | undefined,
15
+ headers: Record<string, string> = {},
16
+ ): Request {
17
+ const req = new Request("https://example.com/", { headers });
18
+ if (cf) {
19
+ Object.defineProperty(req, "cf", { value: cf, configurable: true });
20
+ }
21
+ return req;
22
+ }
23
+
24
+ describe("injectGeoCookies", () => {
25
+ it("strips cf-region from the outgoing Request headers while preserving the value in __cf_geo_region cookie", () => {
26
+ const req = makeRequest(
27
+ { region: "São Paulo", country: "BR" },
28
+ { "cf-region": "São Paulo", "cf-ipcountry": "BR" },
29
+ );
30
+
31
+ const out = injectGeoCookies(req);
32
+
33
+ expect(out.headers.get("cf-region")).toBeNull();
34
+ // ASCII CF headers (cf-ipcountry) are still forwarded
35
+ expect(out.headers.get("cf-ipcountry")).toBe("BR");
36
+ // Geo data is preserved as cookies for matchers
37
+ const cookies = parseCookies(out.headers.get("cookie") ?? "");
38
+ expect(cookies.__cf_geo_region).toBe(encodeURIComponent("São Paulo"));
39
+ expect(cookies.__cf_geo_country).toBe("BR");
40
+ });
41
+
42
+ it("strips cf-ipcity from the outgoing Request headers while preserving the value in __cf_geo_city cookie", () => {
43
+ const req = makeRequest(
44
+ { city: "Brasília", country: "BR" },
45
+ { "cf-ipcity": "Brasília" },
46
+ );
47
+
48
+ const out = injectGeoCookies(req);
49
+
50
+ expect(out.headers.get("cf-ipcity")).toBeNull();
51
+ const cookies = parseCookies(out.headers.get("cookie") ?? "");
52
+ expect(cookies.__cf_geo_city).toBe(encodeURIComponent("Brasília"));
53
+ });
54
+
55
+ it("returns the original request unchanged when there is no cf object", () => {
56
+ const req = makeRequest(undefined, { "cf-region": "São Paulo" });
57
+
58
+ const out = injectGeoCookies(req);
59
+
60
+ // Without cf, we don't build cookies, and we return the original request
61
+ // untouched (so the cf-region header is still present — but that's the
62
+ // caller's pre-existing state, not something we re-introduced).
63
+ expect(out).toBe(req);
64
+ });
65
+
66
+ it("returns the original request unchanged when cf has no relevant geo fields", () => {
67
+ const req = makeRequest({ asn: "12345" }, { "cf-region": "São Paulo" });
68
+
69
+ const out = injectGeoCookies(req);
70
+
71
+ expect(out).toBe(req);
72
+ });
73
+
74
+ it("preserves a pre-existing cookie header", () => {
75
+ const req = makeRequest(
76
+ { region: "São Paulo" },
77
+ { cookie: "vtex_segment=abc; another=xyz" },
78
+ );
79
+
80
+ const out = injectGeoCookies(req);
81
+
82
+ const raw = out.headers.get("cookie") ?? "";
83
+ expect(raw).toContain("vtex_segment=abc");
84
+ expect(raw).toContain("another=xyz");
85
+ expect(raw).toContain("__cf_geo_region=");
86
+ });
87
+
88
+ it("forwards non-geo headers untouched", () => {
89
+ const req = makeRequest(
90
+ { region: "Paraná" },
91
+ {
92
+ "user-agent": "test-agent",
93
+ accept: "*/*",
94
+ "x-custom": "value",
95
+ "cf-ray": "9ff5b26cf9bc067a",
96
+ },
97
+ );
98
+
99
+ const out = injectGeoCookies(req);
100
+
101
+ expect(out.headers.get("user-agent")).toBe("test-agent");
102
+ expect(out.headers.get("accept")).toBe("*/*");
103
+ expect(out.headers.get("x-custom")).toBe("value");
104
+ expect(out.headers.get("cf-ray")).toBe("9ff5b26cf9bc067a");
105
+ });
106
+ });