@decocms/start 5.4.0 → 5.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "5.4.0",
3
+ "version": "5.4.1",
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
  );