@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "5.4.0",
3
+ "version": "5.4.2",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -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
+ });
@@ -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
- const headers = new Headers(request.headers);
229
- headers.delete("host");
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 (isText && response.body) {
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
- request,
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
- request,
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
+ });
@@ -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
- const headers = new Headers(request.headers);
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 });