@decocms/blocks 7.68.0 → 7.69.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 +1 -1
- package/src/sdk/abTesting.test.ts +55 -0
- package/src/sdk/abTesting.ts +60 -3
- package/src/sdk/replaceStream.test.ts +117 -0
package/package.json
CHANGED
|
@@ -48,6 +48,61 @@ describe("proxyToFallback", () => {
|
|
|
48
48
|
vi.unstubAllGlobals();
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
it("streams the hostname rewrite instead of buffering the body", async () => {
|
|
52
|
+
fetchSpy.mockResolvedValue(
|
|
53
|
+
new Response(`<a href="https://${FALLBACK_HOST}/x">go</a>`, {
|
|
54
|
+
status: 200,
|
|
55
|
+
headers: { "content-type": "text/html", "content-length": "999" },
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const res = await proxyToFallback(
|
|
60
|
+
new Request(`https://${REAL_HOST}/foo`),
|
|
61
|
+
makeUrl("/foo"),
|
|
62
|
+
FALLBACK_HOST,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
await expect(res.text()).resolves.toBe(`<a href="https://${REAL_HOST}/x">go</a>`);
|
|
66
|
+
// The rewrite changes the body length, so the upstream content-length is a
|
|
67
|
+
// lie and a streamed body has none to state.
|
|
68
|
+
expect(res.headers.get("content-length")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("still rewrites when content-encoding is retained (workerd keeps the header but yields decoded bytes)", async () => {
|
|
72
|
+
fetchSpy.mockResolvedValue(
|
|
73
|
+
new Response(`see ${FALLBACK_HOST}`, {
|
|
74
|
+
status: 200,
|
|
75
|
+
headers: { "content-type": "text/html", "content-encoding": "gzip" },
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const res = await proxyToFallback(
|
|
80
|
+
new Request(`https://${REAL_HOST}/foo`),
|
|
81
|
+
makeUrl("/foo"),
|
|
82
|
+
FALLBACK_HOST,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
await expect(res.text()).resolves.toBe(`see ${REAL_HOST}`);
|
|
86
|
+
expect(res.headers.get("content-encoding")).toBe("gzip");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("leaves a non-2xx body alone", async () => {
|
|
90
|
+
fetchSpy.mockResolvedValue(
|
|
91
|
+
new Response(`moved to ${FALLBACK_HOST}`, {
|
|
92
|
+
status: 302,
|
|
93
|
+
headers: { "content-type": "text/html" },
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const res = await proxyToFallback(
|
|
98
|
+
new Request(`https://${REAL_HOST}/foo`),
|
|
99
|
+
makeUrl("/foo"),
|
|
100
|
+
FALLBACK_HOST,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
await expect(res.text()).resolves.toBe(`moved to ${FALLBACK_HOST}`);
|
|
104
|
+
});
|
|
105
|
+
|
|
51
106
|
it("always sets redirect:'manual' to avoid replaying streamed bodies on 3xx", async () => {
|
|
52
107
|
fetchSpy.mockResolvedValue(new Response("ok", { status: 200 }));
|
|
53
108
|
|
package/src/sdk/abTesting.ts
CHANGED
|
@@ -272,6 +272,53 @@ export function tagBucket(
|
|
|
272
272
|
return res;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
/**
|
|
276
|
+
* A `TransformStream` that rewrites every occurrence of `search` in a UTF-8 text
|
|
277
|
+
* stream, without ever holding the whole body in memory.
|
|
278
|
+
*
|
|
279
|
+
* Two hazards it exists to handle:
|
|
280
|
+
*
|
|
281
|
+
* - **A match straddling a chunk boundary.** Each pass holds back the last
|
|
282
|
+
* `search.length - 1` characters and prepends them to the next chunk. The
|
|
283
|
+
* held-back slice is taken from the RAW input, never from already-replaced
|
|
284
|
+
* output — otherwise a replacement ending in a prefix of `search` could join
|
|
285
|
+
* the next chunk and be replaced a second time.
|
|
286
|
+
* - **A multi-byte character split across chunks.** `TextDecoder` with
|
|
287
|
+
* `{ stream: true }` carries the partial code point across `decode` calls.
|
|
288
|
+
*
|
|
289
|
+
* Peak memory is one chunk plus `search.length - 1` characters, not the body.
|
|
290
|
+
*/
|
|
291
|
+
export function createReplaceStream(search: string, replace: string): TransformStream<Uint8Array, Uint8Array> {
|
|
292
|
+
const decoder = new TextDecoder("utf-8");
|
|
293
|
+
const encoder = new TextEncoder();
|
|
294
|
+
if (!search) return new TransformStream<Uint8Array, Uint8Array>();
|
|
295
|
+
const keep = search.length - 1;
|
|
296
|
+
let tail = "";
|
|
297
|
+
|
|
298
|
+
return new TransformStream<Uint8Array, Uint8Array>({
|
|
299
|
+
transform(chunk, controller) {
|
|
300
|
+
const buffer = tail + decoder.decode(chunk, { stream: true });
|
|
301
|
+
// Consume complete matches first (left to right, non-overlapping — same
|
|
302
|
+
// as replaceAll), then hold back only the last `search.length - 1` chars
|
|
303
|
+
// of what follows the last match: a not-yet-complete match can only
|
|
304
|
+
// start there. Cutting before consuming matches can slice a complete,
|
|
305
|
+
// self-overlapping match ("aaa" in "xaaaa") in half.
|
|
306
|
+
let end = 0;
|
|
307
|
+
for (let i = buffer.indexOf(search); i !== -1; i = buffer.indexOf(search, end)) {
|
|
308
|
+
end = i + search.length;
|
|
309
|
+
}
|
|
310
|
+
const cut = Math.max(end, buffer.length - keep);
|
|
311
|
+
tail = buffer.slice(cut);
|
|
312
|
+
const head = buffer.slice(0, cut);
|
|
313
|
+
if (head) controller.enqueue(encoder.encode(head.replaceAll(search, replace)));
|
|
314
|
+
},
|
|
315
|
+
flush(controller) {
|
|
316
|
+
const rest = tail + decoder.decode();
|
|
317
|
+
if (rest) controller.enqueue(encoder.encode(rest.replaceAll(search, replace)));
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
275
322
|
/**
|
|
276
323
|
* Proxy a request to the fallback origin with full hostname rewriting.
|
|
277
324
|
*
|
|
@@ -330,18 +377,28 @@ export async function proxyToFallback(
|
|
|
330
377
|
// would consume the stream needlessly and could throw on non-text
|
|
331
378
|
// content. The Location header is rewritten separately further down.
|
|
332
379
|
let body: BodyInit | null = response.body;
|
|
380
|
+
let rewrote = false;
|
|
333
381
|
if (
|
|
334
382
|
isText &&
|
|
335
383
|
response.body &&
|
|
336
384
|
response.status >= 200 &&
|
|
337
|
-
response.status < 300
|
|
385
|
+
response.status < 300 &&
|
|
386
|
+
fallbackOrigin.length > 0
|
|
338
387
|
) {
|
|
339
|
-
|
|
340
|
-
|
|
388
|
+
// Streamed, not buffered. `response.text()` held the entire body as a
|
|
389
|
+
// JS string (two bytes per character) plus the replaced copy — multiple
|
|
390
|
+
// megabytes of a 128MB isolate budget for a hostname substitution.
|
|
391
|
+
body = response.body.pipeThrough(createReplaceStream(fallbackOrigin, url.hostname));
|
|
392
|
+
rewrote = true;
|
|
341
393
|
}
|
|
342
394
|
|
|
343
395
|
const rewritten = new Response(body, response);
|
|
344
396
|
|
|
397
|
+
// The rewrite changes the body length, so the upstream `content-length` is
|
|
398
|
+
// now a lie — and a streamed body has no length to state. (This was already
|
|
399
|
+
// wrong on the buffered path; it just never had to be chunked.)
|
|
400
|
+
if (rewrote) rewritten.headers.delete("content-length");
|
|
401
|
+
|
|
345
402
|
const setCookies = response.headers.getSetCookie?.() ?? [];
|
|
346
403
|
if (setCookies.length > 0) {
|
|
347
404
|
rewritten.headers.delete("set-cookie");
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createReplaceStream } from "./abTesting";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The A/B fallback proxy used to buffer the whole upstream body into a JS
|
|
6
|
+
* string (two bytes per character) plus the replaced copy, just to swap a
|
|
7
|
+
* hostname — megabytes of a 128MB isolate budget. This streams it instead,
|
|
8
|
+
* which is only correct if two hazards are handled: a match straddling a chunk
|
|
9
|
+
* boundary, and a multi-byte character split across chunks.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const enc = new TextEncoder();
|
|
13
|
+
|
|
14
|
+
/** Push `chunks` through the transform and read the whole output back. */
|
|
15
|
+
async function run(search: string, replace: string, chunks: string[] | Uint8Array[]) {
|
|
16
|
+
const source = new ReadableStream<Uint8Array>({
|
|
17
|
+
start(controller) {
|
|
18
|
+
for (const c of chunks) {
|
|
19
|
+
controller.enqueue(typeof c === "string" ? enc.encode(c) : c);
|
|
20
|
+
}
|
|
21
|
+
controller.close();
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const out = source.pipeThrough(createReplaceStream(search, replace));
|
|
25
|
+
return await new Response(out).text();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("createReplaceStream", () => {
|
|
29
|
+
it("replaces within a single chunk", async () => {
|
|
30
|
+
await expect(run("old.site", "new.site", ["go to old.site now"])).resolves.toBe(
|
|
31
|
+
"go to new.site now",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("replaces every occurrence, not just the first", async () => {
|
|
36
|
+
await expect(run("a", "X", ["banana"])).resolves.toBe("bXnXnX");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("replaces a match split across a chunk boundary", async () => {
|
|
40
|
+
// The hazard the hold-back exists for.
|
|
41
|
+
await expect(run("old.site", "new.site", ["go to old.", "site now"])).resolves.toBe(
|
|
42
|
+
"go to new.site now",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("replaces a match split one character at a time", async () => {
|
|
47
|
+
await expect(run("abc", "X", [..."1abc2"])).resolves.toBe("1X2");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not re-replace when the replacement ends in a prefix of the search", async () => {
|
|
51
|
+
// Held-back text is taken from the RAW input, never from replaced output.
|
|
52
|
+
// Otherwise "abc"->"xab" would leave "ab", join the next chunk's "c", and
|
|
53
|
+
// be replaced a second time.
|
|
54
|
+
await expect(run("abc", "xab", ["abc", "c"])).resolves.toBe("xabc");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("carries a multi-byte character split across chunks", async () => {
|
|
58
|
+
// "ã" is two bytes in UTF-8 — split them.
|
|
59
|
+
const bytes = enc.encode("promoção");
|
|
60
|
+
const mid = bytes.indexOf(0xc3); // first byte of "ç"
|
|
61
|
+
const out = await run("x", "y", [bytes.slice(0, mid + 1), bytes.slice(mid + 1)]);
|
|
62
|
+
expect(out).toBe("promoção");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("passes a body through untouched when nothing matches", async () => {
|
|
66
|
+
await expect(run("nope", "!", ["hello ", "world"])).resolves.toBe("hello world");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("handles an empty body", async () => {
|
|
70
|
+
await expect(run("a", "b", [])).resolves.toBe("");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("handles a match at the very end of the stream", async () => {
|
|
74
|
+
// Lands entirely in the held-back tail, so only `flush` can emit it.
|
|
75
|
+
await expect(run("end", "END", ["the ", "end"])).resolves.toBe("the END");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("handles a match at the very start", async () => {
|
|
79
|
+
await expect(run("go", "GO", ["go home"])).resolves.toBe("GO home");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("holds back at most search.length - 1 characters", async () => {
|
|
83
|
+
// Peak memory must be one chunk plus a bounded tail, never the body.
|
|
84
|
+
const big = "z".repeat(10_000);
|
|
85
|
+
await expect(run("old.site", "new.site", [big, "old.site", big])).resolves.toBe(
|
|
86
|
+
`${big}new.site${big}`,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("replaces a self-overlapping match that straddles the hold-back cut", async () => {
|
|
91
|
+
await expect(run("aaa", "Y", ["xaaaa"])).resolves.toBe("xYa");
|
|
92
|
+
await expect(run("aa", "Y", ["aaa", "a"])).resolves.toBe("aaaa".replaceAll("aa", "Y"));
|
|
93
|
+
await expect(run("abab", "Y", ["ababab", "ab"])).resolves.toBe("abababab".replaceAll("abab", "Y"));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("matches native replaceAll over random chunkings", async () => {
|
|
97
|
+
let seed = 1;
|
|
98
|
+
const rnd = (n: number) => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) % n);
|
|
99
|
+
for (let iter = 0; iter < 500; iter++) {
|
|
100
|
+
const alpha = "ab\u00e7";
|
|
101
|
+
const str = (n: number) => Array.from({ length: n }, () => alpha[rnd(alpha.length)]).join("");
|
|
102
|
+
const search = str(1 + rnd(4));
|
|
103
|
+
const replace = str(rnd(4));
|
|
104
|
+
const text = str(rnd(40));
|
|
105
|
+
const bytes = enc.encode(text);
|
|
106
|
+
const chunks: Uint8Array[] = [];
|
|
107
|
+
for (let p = 0; p < bytes.length; ) {
|
|
108
|
+
const n = 1 + rnd(6);
|
|
109
|
+
chunks.push(bytes.slice(p, p + n));
|
|
110
|
+
p += n;
|
|
111
|
+
}
|
|
112
|
+
expect(await run(search, replace, chunks), JSON.stringify({ search, replace, text })).toBe(
|
|
113
|
+
text.replaceAll(search, replace),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|