@decocms/start 5.4.0 → 5.4.2
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/src/sdk/abTesting.test.ts +231 -0
- package/src/sdk/abTesting.ts +58 -5
- package/src/sdk/workerEntry.test.ts +106 -0
- package/src/sdk/workerEntry.ts +21 -1
package/package.json
CHANGED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { proxyToFallback, type SiteConfig, type WorkerHandler, withABTesting } from "./abTesting";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Test helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const REAL_HOST = "www.bagaggio.com.br";
|
|
9
|
+
const FALLBACK_HOST = "lojabagaggio.deco.site";
|
|
10
|
+
|
|
11
|
+
function makeUrl(path = "/x"): URL {
|
|
12
|
+
return new URL(`https://${REAL_HOST}${path}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeFakeKv(value: SiteConfig | null) {
|
|
16
|
+
return {
|
|
17
|
+
get: vi.fn(async () => value),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeCtx() {
|
|
22
|
+
return {
|
|
23
|
+
waitUntil: vi.fn(),
|
|
24
|
+
passThroughOnException: vi.fn(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// proxyToFallback
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
describe("proxyToFallback", () => {
|
|
33
|
+
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
fetchSpy = vi.fn();
|
|
37
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
vi.unstubAllGlobals();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("always sets redirect:'manual' to avoid replaying streamed bodies on 3xx", async () => {
|
|
45
|
+
fetchSpy.mockResolvedValue(new Response("ok", { status: 200 }));
|
|
46
|
+
|
|
47
|
+
const request = new Request(`https://${REAL_HOST}/foo`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
body: "payload",
|
|
50
|
+
});
|
|
51
|
+
await proxyToFallback(request, makeUrl("/foo"), FALLBACK_HOST);
|
|
52
|
+
|
|
53
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
54
|
+
const [, init] = fetchSpy.mock.calls[0];
|
|
55
|
+
expect(init.redirect).toBe("manual");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("strips hop-by-hop headers + host before forwarding", async () => {
|
|
59
|
+
fetchSpy.mockResolvedValue(new Response(null, { status: 204 }));
|
|
60
|
+
|
|
61
|
+
const request = new Request(`https://${REAL_HOST}/foo`, {
|
|
62
|
+
method: "GET",
|
|
63
|
+
headers: {
|
|
64
|
+
host: REAL_HOST,
|
|
65
|
+
connection: "keep-alive",
|
|
66
|
+
"keep-alive": "timeout=5",
|
|
67
|
+
"transfer-encoding": "chunked",
|
|
68
|
+
upgrade: "websocket",
|
|
69
|
+
"proxy-authorization": "Bearer x",
|
|
70
|
+
"x-real-ip": "1.2.3.4",
|
|
71
|
+
cookie: "session=abc",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
await proxyToFallback(request, makeUrl("/foo"), FALLBACK_HOST);
|
|
75
|
+
|
|
76
|
+
const [, init] = fetchSpy.mock.calls[0];
|
|
77
|
+
const fwd = init.headers as Headers;
|
|
78
|
+
expect(fwd.get("host")).toBeNull();
|
|
79
|
+
expect(fwd.get("connection")).toBeNull();
|
|
80
|
+
expect(fwd.get("keep-alive")).toBeNull();
|
|
81
|
+
expect(fwd.get("transfer-encoding")).toBeNull();
|
|
82
|
+
expect(fwd.get("upgrade")).toBeNull();
|
|
83
|
+
expect(fwd.get("proxy-authorization")).toBeNull();
|
|
84
|
+
// Non-hop-by-hop headers are preserved.
|
|
85
|
+
expect(fwd.get("x-real-ip")).toBe("1.2.3.4");
|
|
86
|
+
expect(fwd.get("cookie")).toBe("session=abc");
|
|
87
|
+
expect(fwd.get("x-forwarded-host")).toBe(REAL_HOST);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("forwards a 302 from the upstream without following it, rewriting Location", async () => {
|
|
91
|
+
fetchSpy.mockResolvedValue(
|
|
92
|
+
new Response(null, {
|
|
93
|
+
status: 302,
|
|
94
|
+
headers: { location: `https://${FALLBACK_HOST}/landing` },
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const request = new Request(`https://${REAL_HOST}/go`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
body: "irrelevant",
|
|
101
|
+
});
|
|
102
|
+
const res = await proxyToFallback(request, makeUrl("/go"), FALLBACK_HOST);
|
|
103
|
+
|
|
104
|
+
expect(res.status).toBe(302);
|
|
105
|
+
expect(res.headers.get("location")).toBe(`https://${REAL_HOST}/landing`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("does NOT consume the response body on 3xx (passes it through as stream)", async () => {
|
|
109
|
+
// The text-rewrite block must skip non-2xx so streamed/binary 3xx bodies
|
|
110
|
+
// aren't needlessly drained — that was a latent bug paired with the
|
|
111
|
+
// redirect:"manual" fix.
|
|
112
|
+
const upstream = new Response("redirect-body", {
|
|
113
|
+
status: 301,
|
|
114
|
+
headers: {
|
|
115
|
+
"content-type": "text/html",
|
|
116
|
+
location: `https://${FALLBACK_HOST}/elsewhere`,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const textSpy = vi.spyOn(upstream, "text");
|
|
120
|
+
fetchSpy.mockResolvedValue(upstream);
|
|
121
|
+
|
|
122
|
+
const request = new Request(`https://${REAL_HOST}/r`, { method: "GET" });
|
|
123
|
+
const res = await proxyToFallback(request, makeUrl("/r"), FALLBACK_HOST);
|
|
124
|
+
|
|
125
|
+
expect(textSpy).not.toHaveBeenCalled();
|
|
126
|
+
expect(res.status).toBe(301);
|
|
127
|
+
expect(res.headers.get("location")).toBe(`https://${REAL_HOST}/elsewhere`);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rewrites the hostname in 2xx text bodies (Fresh partial URLs)", async () => {
|
|
131
|
+
fetchSpy.mockResolvedValue(
|
|
132
|
+
new Response(`<a href="https://${FALLBACK_HOST}/produto">veja</a>`, {
|
|
133
|
+
status: 200,
|
|
134
|
+
headers: { "content-type": "text/html" },
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const request = new Request(`https://${REAL_HOST}/p`, { method: "GET" });
|
|
139
|
+
const res = await proxyToFallback(request, makeUrl("/p"), FALLBACK_HOST);
|
|
140
|
+
const body = await res.text();
|
|
141
|
+
|
|
142
|
+
expect(body).toBe(`<a href="https://${REAL_HOST}/produto">veja</a>`);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("does NOT call .text() on 2xx binary responses (image/png passes as stream)", async () => {
|
|
146
|
+
const binary = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a]);
|
|
147
|
+
const upstream = new Response(binary, {
|
|
148
|
+
status: 200,
|
|
149
|
+
headers: { "content-type": "image/png" },
|
|
150
|
+
});
|
|
151
|
+
const textSpy = vi.spyOn(upstream, "text");
|
|
152
|
+
fetchSpy.mockResolvedValue(upstream);
|
|
153
|
+
|
|
154
|
+
const request = new Request(`https://${REAL_HOST}/img.png`, {
|
|
155
|
+
method: "GET",
|
|
156
|
+
});
|
|
157
|
+
const res = await proxyToFallback(request, makeUrl("/img.png"), FALLBACK_HOST);
|
|
158
|
+
|
|
159
|
+
expect(textSpy).not.toHaveBeenCalled();
|
|
160
|
+
expect(res.headers.get("content-type")).toBe("image/png");
|
|
161
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
162
|
+
expect(Array.from(bytes)).toEqual(Array.from(binary));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rewrites Set-Cookie Domain from fallback origin to real hostname", async () => {
|
|
166
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
167
|
+
headers.append("set-cookie", `vtex_segment=abc; Domain=.${FALLBACK_HOST}; Path=/`);
|
|
168
|
+
fetchSpy.mockResolvedValue(new Response("{}", { status: 200, headers }));
|
|
169
|
+
|
|
170
|
+
const request = new Request(`https://${REAL_HOST}/api`, { method: "GET" });
|
|
171
|
+
const res = await proxyToFallback(request, makeUrl("/api"), FALLBACK_HOST);
|
|
172
|
+
|
|
173
|
+
const cookies = res.headers.getSetCookie?.() ?? [];
|
|
174
|
+
expect(cookies).toHaveLength(1);
|
|
175
|
+
expect(cookies[0]).toContain(`Domain=.${REAL_HOST}`);
|
|
176
|
+
expect(cookies[0]).not.toContain(FALLBACK_HOST);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// withABTesting — clone defense
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
describe("withABTesting — outer-catch defense", () => {
|
|
185
|
+
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
186
|
+
|
|
187
|
+
beforeEach(() => {
|
|
188
|
+
fetchSpy = vi.fn();
|
|
189
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
vi.unstubAllGlobals();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("recovers via inner handler with body intact when fallback proxy throws", async () => {
|
|
197
|
+
// Simulate the legacy bug: the first fetch (fallback proxy) blows up
|
|
198
|
+
// *after* the body would have been consumed. The outer catch must be
|
|
199
|
+
// able to read request.body when calling handler.fetch, so withABTesting
|
|
200
|
+
// tees the request with request.clone() before handing it to the proxy.
|
|
201
|
+
fetchSpy.mockRejectedValue(new Error("upstream exploded"));
|
|
202
|
+
|
|
203
|
+
const handler: WorkerHandler = {
|
|
204
|
+
fetch: vi.fn(async (req) => {
|
|
205
|
+
const body = await req.text();
|
|
206
|
+
return new Response(`handler saw: ${body}`, { status: 200 });
|
|
207
|
+
}),
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const kv = makeFakeKv({
|
|
211
|
+
workerName: "test",
|
|
212
|
+
fallbackOrigin: FALLBACK_HOST,
|
|
213
|
+
abTest: { ratio: 0 }, // ratio 0 → always fallback bucket
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const wrapped = withABTesting(handler, { kvBinding: "KV" });
|
|
217
|
+
const request = new Request(`https://${REAL_HOST}/recover`, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
body: "payload",
|
|
220
|
+
});
|
|
221
|
+
const res = await wrapped.fetch(
|
|
222
|
+
request,
|
|
223
|
+
{ KV: kv } as unknown as Record<string, unknown>,
|
|
224
|
+
makeCtx(),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
expect(res.status).toBe(200);
|
|
228
|
+
expect(await res.text()).toBe("handler saw: payload");
|
|
229
|
+
expect(handler.fetch).toHaveBeenCalledTimes(1);
|
|
230
|
+
});
|
|
231
|
+
});
|
package/src/sdk/abTesting.ts
CHANGED
|
@@ -105,6 +105,25 @@ function fnv1a(str: string): number {
|
|
|
105
105
|
return Math.abs(hash);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Header constants
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Hop-by-hop headers per RFC 7230 §6.1 — must not be forwarded through
|
|
114
|
+
* proxies. Plus `host`, which we always rewrite to the target origin.
|
|
115
|
+
*/
|
|
116
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
117
|
+
"connection",
|
|
118
|
+
"keep-alive",
|
|
119
|
+
"transfer-encoding",
|
|
120
|
+
"te",
|
|
121
|
+
"trailer",
|
|
122
|
+
"upgrade",
|
|
123
|
+
"proxy-authorization",
|
|
124
|
+
"proxy-authenticate",
|
|
125
|
+
]);
|
|
126
|
+
|
|
108
127
|
// ---------------------------------------------------------------------------
|
|
109
128
|
// Cookie helpers
|
|
110
129
|
// ---------------------------------------------------------------------------
|
|
@@ -216,6 +235,14 @@ export function tagBucket(
|
|
|
216
235
|
* 2. Set-Cookie Domain → real hostname
|
|
217
236
|
* 3. Body text: fallback hostname → real hostname (for Fresh partial URLs)
|
|
218
237
|
* 4. Location header → real hostname
|
|
238
|
+
*
|
|
239
|
+
* **`redirect: "manual"` is critical.** The request body is forwarded as a
|
|
240
|
+
* stream (`duplex: "half"`) and is consumed by this first fetch. If the
|
|
241
|
+
* upstream returns a 301/302 and we let CF auto-follow, the runtime would
|
|
242
|
+
* try to replay the request and throw `Cannot reconstruct a Request with
|
|
243
|
+
* a used body.` Instead we forward the 3xx response to the client so the
|
|
244
|
+
* client (browser/curl) follows it on its own. The Location header is
|
|
245
|
+
* rewritten below so the next hop targets the real hostname.
|
|
219
246
|
*/
|
|
220
247
|
export async function proxyToFallback(
|
|
221
248
|
request: Request,
|
|
@@ -225,13 +252,22 @@ export async function proxyToFallback(
|
|
|
225
252
|
const target = new URL(url.toString());
|
|
226
253
|
target.hostname = fallbackOrigin;
|
|
227
254
|
|
|
228
|
-
|
|
229
|
-
|
|
255
|
+
// Strip hop-by-hop headers + Host before forwarding. Without this the
|
|
256
|
+
// upstream may close the connection (Connection: close) or get confused
|
|
257
|
+
// by Transfer-Encoding/Upgrade meant for the original CF↔client hop.
|
|
258
|
+
const headers = new Headers();
|
|
259
|
+
for (const [key, value] of request.headers.entries()) {
|
|
260
|
+
const lk = key.toLowerCase();
|
|
261
|
+
if (lk === "host") continue;
|
|
262
|
+
if (HOP_BY_HOP_HEADERS.has(lk)) continue;
|
|
263
|
+
headers.set(key, value);
|
|
264
|
+
}
|
|
230
265
|
headers.set("x-forwarded-host", url.hostname);
|
|
231
266
|
|
|
232
267
|
const init: RequestInit = {
|
|
233
268
|
method: request.method,
|
|
234
269
|
headers,
|
|
270
|
+
redirect: "manual",
|
|
235
271
|
};
|
|
236
272
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
237
273
|
init.body = request.body;
|
|
@@ -244,8 +280,17 @@ export async function proxyToFallback(
|
|
|
244
280
|
const isText =
|
|
245
281
|
ct.includes("text/") || ct.includes("json") || ct.includes("javascript");
|
|
246
282
|
|
|
283
|
+
// Only rewrite text bodies on 2xx successful responses. Reading
|
|
284
|
+
// `response.text()` on a 3xx (forwarded redirect) or binary response
|
|
285
|
+
// would consume the stream needlessly and could throw on non-text
|
|
286
|
+
// content. The Location header is rewritten separately further down.
|
|
247
287
|
let body: BodyInit | null = response.body;
|
|
248
|
-
if (
|
|
288
|
+
if (
|
|
289
|
+
isText &&
|
|
290
|
+
response.body &&
|
|
291
|
+
response.status >= 200 &&
|
|
292
|
+
response.status < 300
|
|
293
|
+
) {
|
|
249
294
|
const text = await response.text();
|
|
250
295
|
body = text.replaceAll(fallbackOrigin, url.hostname);
|
|
251
296
|
}
|
|
@@ -338,10 +383,16 @@ export function withABTesting(
|
|
|
338
383
|
const ratio = siteConfig.abTest?.ratio ?? 0;
|
|
339
384
|
const bucket = getStableBucket(request, ratio, url, cookieName);
|
|
340
385
|
|
|
386
|
+
// Tee the request so the outer `catch` below can still pass the
|
|
387
|
+
// original (with body intact) to `handler.fetch` if proxyToFallback
|
|
388
|
+
// somehow throws after consuming the body. `Request.clone()` is
|
|
389
|
+
// safe in CF Workers — it tees the underlying stream.
|
|
390
|
+
const fallbackRequest = request.clone();
|
|
391
|
+
|
|
341
392
|
try {
|
|
342
393
|
if (bucket === "fallback") {
|
|
343
394
|
const response = await proxyToFallback(
|
|
344
|
-
|
|
395
|
+
fallbackRequest,
|
|
345
396
|
url,
|
|
346
397
|
siteConfig.fallbackOrigin,
|
|
347
398
|
);
|
|
@@ -372,8 +423,10 @@ export function withABTesting(
|
|
|
372
423
|
"[A/B] Worker error, circuit breaker → fallback:",
|
|
373
424
|
err,
|
|
374
425
|
);
|
|
426
|
+
// Use the teed clone — handler.fetch above may have already
|
|
427
|
+
// consumed the original request body before throwing.
|
|
375
428
|
const response = await proxyToFallback(
|
|
376
|
-
|
|
429
|
+
fallbackRequest,
|
|
377
430
|
url,
|
|
378
431
|
siteConfig.fallbackOrigin,
|
|
379
432
|
);
|
|
@@ -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
|
+
});
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -443,7 +443,27 @@ export function injectGeoCookies(request: Request): Request {
|
|
|
443
443
|
|
|
444
444
|
const existing = request.headers.get("cookie") ?? "";
|
|
445
445
|
const combined = existing ? `${existing}; ${parts.join("; ")}` : parts.join("; ");
|
|
446
|
-
|
|
446
|
+
|
|
447
|
+
// Strip CF geo headers that carry non-ASCII values (cf-region: "São Paulo",
|
|
448
|
+
// cf-ipcity: "Brasília", etc.) before building the new Request. The geo
|
|
449
|
+
// data is preserved in the __cf_geo_* cookies we just built, so callers
|
|
450
|
+
// downstream lose no information.
|
|
451
|
+
//
|
|
452
|
+
// Without this strip, the Workers runtime emits a warning on every
|
|
453
|
+
// request because the new Request inherits these UTF-8 headers from the
|
|
454
|
+
// inbound request:
|
|
455
|
+
//
|
|
456
|
+
// "A header value for "cf-region" contains non-ASCII characters: "..."
|
|
457
|
+
//
|
|
458
|
+
// and the warning is logged once per non-ASCII header — for a Brazilian
|
|
459
|
+
// storefront with cities/states full of accents that means ~2 warns per
|
|
460
|
+
// request × every request that hits the worker.
|
|
461
|
+
const headers = new Headers();
|
|
462
|
+
for (const [key, value] of request.headers.entries()) {
|
|
463
|
+
const lk = key.toLowerCase();
|
|
464
|
+
if (lk === "cf-region" || lk === "cf-ipcity") continue;
|
|
465
|
+
headers.set(key, value);
|
|
466
|
+
}
|
|
447
467
|
headers.set("cookie", combined);
|
|
448
468
|
|
|
449
469
|
return new Request(request, { headers });
|