@sproutboat/runtime 0.6.2 → 0.6.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.6.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 8ef3148: Fix (baronunread/sproutboat#180): `__sbToBytes` built its result with repeated
8
+ `s +=` in a loop, the same `O(n^2)` bug class the `__sbFromUtf8` fix in 0.6.3
9
+ addressed, just on the encode side. Porffor's strings have no rope/cons
10
+ optimization, so `+=` in a loop copies the whole accumulated string on every
11
+ append. Both of its branches were affected: the UTF-8 encode loop over string
12
+ input, and the byte-for-byte copy out of an ArrayBuffer or typed array.
13
+
14
+ This one matters because `__sbToBytes` sits behind every `crypto.subtle` call a
15
+ handler makes: `digest`, `importKey`, `sign`, `verify` and the PBKDF2/scrypt
16
+ path all funnel their data, keys, salts and signatures through it. Hashing a
17
+ request body was quadratic in the body's size.
18
+
19
+ `__sbHexOrBytes` had the same shape on a third crypto path (it decodes the
20
+ `expected` side of an HMAC verify) and was worse than a plain `+=` loop: on odd
21
+ iterations it rewrote the last character with `out = out.slice(0, -1) + ...`,
22
+ copying the accumulated string a second time per byte. It now holds the high
23
+ nibble in a local until its pair arrives and pushes one character per byte.
24
+
25
+ Three further accumulators converted to the same array-and-join idiom for
26
+ consistency. These are bounded by header count, id length or parameter count
27
+ rather than by handler input, so they are hygiene rather than a fix:
28
+ `__SproutboatURLSearchParams.prototype.toString()` in the prelude, and `__sbHex`
29
+ plus the outbound-fetch header builder in `transport-embedded.js`.
30
+
31
+ Covered by a new `to-bytes.test.js`, following the `utf8-decode.test.js`
32
+ convention of lifting the pure functions out of the text-only prelude: ASCII,
33
+ 2-byte, 3-byte and surrogate-pair encodes checked byte-identical against
34
+ `TextEncoder`, the existing unpaired-surrogate behaviour pinned, typed-array and
35
+ ArrayBuffer input with high bytes, hex decode in both cases with its non-hex and
36
+ odd-length fallbacks, and long inputs (46KB string, 50,000-byte array, 20,000-byte
37
+ hex) for correctness at the size the quadratic version hurt.
38
+
39
+ ## 0.6.3
40
+
41
+ ### Patch Changes
42
+
43
+ - Fix two compounding performance regressions in the `#181` fix, caught after
44
+ it went live and made sproutboat.com's homepage take 2.6-2.8s per request:
45
+
46
+ 1. `__sbRawBodyResponse` decoded eagerly at construction, paying the cost of
47
+ `__sbFromUtf8` on every asset/proxied response regardless of whether the
48
+ handler ever called `.text()`/`.json()` on it (the common case is a
49
+ response handed straight back to the client, never read as text). Now
50
+ decodes lazily on first call and memoizes.
51
+
52
+ 2. `__sbFromUtf8` built its result with repeated `out +=`. Porffor's strings
53
+ have no rope/cons optimization, so `+=` in a loop copies the whole
54
+ accumulated string on every append, making a large decode `O(n^2)`.
55
+ Measured directly against the real broker-sourced 43KB homepage body,
56
+ each successive 8000-character chunk took visibly longer than the last.
57
+ Now builds into an array and joins once.
58
+
59
+ Together these took the homepage from ~2.7s to ~25ms (measured via the
60
+ runtime's own `x-sb-cpu-ms` header, confirming real CPU time, not I/O wait).
61
+ Every other route was already unaffected by fix 1 alone, since they never
62
+ call `.text()`. Verified against a real compiled `porf native` binary with
63
+ the actual page content, and end to end on sproutboat-site's own deployment.
64
+
3
65
  ## 0.6.2
4
66
 
5
67
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -171,12 +171,14 @@ class __SproutboatURLSearchParams {
171
171
  return this._keys.length;
172
172
  }
173
173
  toString() {
174
- let out = "";
174
+ // Array + one join("&"), not `out +=` in a loop: see __sbToBytes. A query
175
+ // string is usually short, but a params object built from a large form or
176
+ // a paginated cursor list is not, and the fix costs nothing.
177
+ const out = [];
175
178
  for (let i = 0; i < this._keys.length; i++) {
176
- if (i > 0) out += "&";
177
- out += encodeURIComponent(this._keys[i]) + "=" + encodeURIComponent(this._vals[i]);
179
+ out.push(encodeURIComponent(this._keys[i]) + "=" + encodeURIComponent(this._vals[i]));
178
180
  }
179
- return out;
181
+ return out.join("&");
180
182
  }
181
183
  }
182
184
 
@@ -295,31 +297,38 @@ function __sbHashBits(algo) {
295
297
  // TextEncoder); ArrayBuffer / typed-array input is copied byte for byte.
296
298
  function __sbToBytes(input) {
297
299
  if (input == null) return "";
300
+ // Collected into an array and joined once at the end, not built with
301
+ // repeated `out +=` -- Porffor's strings have no rope/cons optimization, so
302
+ // `+=` in a loop copies the whole accumulated string on every append,
303
+ // making the naive version O(n^2). Same fix as __sbFromUtf8 above, on the
304
+ // encode side (sits behind every crypto.subtle call).
298
305
  if (__sbIsStr(input)) {
299
- let s = "";
306
+ const out = [];
300
307
  for (let i = 0; i < input.length; i++) {
301
308
  const c = input.charCodeAt(i);
302
- if (c < 0x80) s += String.fromCharCode(c);
303
- else if (c < 0x800) s += String.fromCharCode(0xc0 | (c >> 6)) + String.fromCharCode(0x80 | (c & 0x3f));
309
+ if (c < 0x80) out.push(String.fromCharCode(c));
310
+ else if (c < 0x800) out.push(String.fromCharCode(0xc0 | (c >> 6)) + String.fromCharCode(0x80 | (c & 0x3f)));
304
311
  else if (c >= 0xd800 && c < 0xdc00 && i + 1 < input.length) {
305
312
  const cp = 0x10000 + ((c - 0xd800) << 10) + (input.charCodeAt(++i) - 0xdc00);
306
- s +=
313
+ out.push(
307
314
  String.fromCharCode(0xf0 | (cp >> 18)) +
308
- String.fromCharCode(0x80 | ((cp >> 12) & 0x3f)) +
309
- String.fromCharCode(0x80 | ((cp >> 6) & 0x3f)) +
310
- String.fromCharCode(0x80 | (cp & 0x3f));
315
+ String.fromCharCode(0x80 | ((cp >> 12) & 0x3f)) +
316
+ String.fromCharCode(0x80 | ((cp >> 6) & 0x3f)) +
317
+ String.fromCharCode(0x80 | (cp & 0x3f))
318
+ );
311
319
  } else
312
- s +=
320
+ out.push(
313
321
  String.fromCharCode(0xe0 | (c >> 12)) +
314
- String.fromCharCode(0x80 | ((c >> 6) & 0x3f)) +
315
- String.fromCharCode(0x80 | (c & 0x3f));
322
+ String.fromCharCode(0x80 | ((c >> 6) & 0x3f)) +
323
+ String.fromCharCode(0x80 | (c & 0x3f))
324
+ );
316
325
  }
317
- return s;
326
+ return out.join("");
318
327
  }
319
328
  const view = input.length !== undefined && input.buffer !== undefined ? input : new Uint8Array(input);
320
- let s = "";
321
- for (let i = 0; i < view.length; i++) s += String.fromCharCode(view[i] & 0xff);
322
- return s;
329
+ const out = [];
330
+ for (let i = 0; i < view.length; i++) out.push(String.fromCharCode(view[i] & 0xff));
331
+ return out.join("");
323
332
  }
324
333
  function __sbBufFrom(latin1) {
325
334
  const b = new Uint8Array(latin1.length);
@@ -339,7 +348,15 @@ function __sbBufFrom(latin1) {
339
348
  // regardless of whether it holds real Latin-1 text or raw bytes, so this
340
349
  // decode is only safe where the caller already knows which one it has.
341
350
  function __sbFromUtf8(bytes) {
342
- let out = "";
351
+ // Collected into an array and joined once at the end, not built with
352
+ // repeated `out +=` -- Porffor's strings have no rope/cons optimization, so
353
+ // `+=` in a loop copies the whole accumulated string on every append,
354
+ // making the naive version O(n^2). Measured on the real 43KB homepage
355
+ // (dynamic, wire-sourced content, not a compile-time constant): each
356
+ // successive 8000-character chunk took visibly longer than the last, ~2.8s
357
+ // total -- a real, shipped performance regression on sproutboat.com.
358
+ // `Array.prototype.join` builds the result in one pass.
359
+ const out = [];
343
360
  const len = bytes.length;
344
361
  const cont = (j) => {
345
362
  const b = bytes.charCodeAt(j) & 0xff;
@@ -348,13 +365,13 @@ function __sbFromUtf8(bytes) {
348
365
  for (let i = 0; i < len; i++) {
349
366
  const b0 = bytes.charCodeAt(i) & 0xff;
350
367
  if (b0 < 0x80) {
351
- out += String.fromCharCode(b0);
368
+ out.push(String.fromCharCode(b0));
352
369
  continue;
353
370
  }
354
371
  if ((b0 & 0xe0) == 0xc0 && i + 1 < len) {
355
372
  const c1 = cont(i + 1);
356
373
  if (c1 >= 0) {
357
- out += String.fromCharCode(((b0 & 0x1f) << 6) | c1);
374
+ out.push(String.fromCharCode(((b0 & 0x1f) << 6) | c1));
358
375
  i += 1;
359
376
  continue;
360
377
  }
@@ -362,7 +379,7 @@ function __sbFromUtf8(bytes) {
362
379
  const c1 = cont(i + 1);
363
380
  const c2 = cont(i + 2);
364
381
  if (c1 >= 0 && c2 >= 0) {
365
- out += String.fromCharCode(((b0 & 0x0f) << 12) | (c1 << 6) | c2);
382
+ out.push(String.fromCharCode(((b0 & 0x0f) << 12) | (c1 << 6) | c2));
366
383
  i += 2;
367
384
  continue;
368
385
  }
@@ -373,16 +390,16 @@ function __sbFromUtf8(bytes) {
373
390
  if (c1 >= 0 && c2 >= 0 && c3 >= 0) {
374
391
  let cp = ((b0 & 0x07) << 18) | (c1 << 12) | (c2 << 6) | c3;
375
392
  cp -= 0x10000;
376
- out += String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
393
+ out.push(String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff)));
377
394
  i += 3;
378
395
  continue;
379
396
  }
380
397
  }
381
398
  // no lead byte matched, or its continuation bytes weren't 10xxxxxx:
382
399
  // pass the single byte through rather than guessing
383
- out += String.fromCharCode(b0);
400
+ out.push(String.fromCharCode(b0));
384
401
  }
385
- return out;
402
+ return out.join("");
386
403
  }
387
404
  // sproutboat #181 — wrap a Response built from raw wire bytes (the assets
388
405
  // binding, outbound fetch(), service-binding fetch(): the three `x-sb-raw-
@@ -395,12 +412,24 @@ function __sbFromUtf8(bytes) {
395
412
  // safely can't decode by default). Overriding just these three instances,
396
413
  // which sproutboat itself builds and knows are bytes, fixes the read side
397
414
  // without touching Porffor's generic Response/Request classes at all.
415
+ // Decoded lazily, on first .text()/.json() call, and memoized after that --
416
+ // most raw-body responses (a static asset, a proxied fetch()) are handed
417
+ // straight back to the client and never read as text at all, and Porffor's
418
+ // strings have no rope/cons optimization: `+=`-building a decoded copy costs
419
+ // real time proportional to the body size (measured ~116ms for a 44KB
420
+ // all-ASCII body). Decoding unconditionally at construction paid that cost
421
+ // on every asset request regardless of whether anything used it -- this was
422
+ // a real, shipped performance regression on sproutboat.com, caught after
423
+ // the fact by unexpectedly slow page loads.
398
424
  function __sbRawBodyResponse(body, init) {
399
425
  const resp = new Response(body == null ? "" : body, init);
400
426
  if (body != null) {
401
- const decoded = __sbFromUtf8(body);
402
- resp.text = () => decoded;
403
- resp.json = () => JSON.parse(decoded);
427
+ let decoded = null;
428
+ resp.text = () => {
429
+ if (decoded === null) decoded = __sbFromUtf8(body);
430
+ return decoded;
431
+ };
432
+ resp.json = () => JSON.parse(resp.text());
404
433
  }
405
434
  return resp;
406
435
  }
@@ -414,15 +443,20 @@ function __sbEqCt(a, b) {
414
443
  // An even-length all-hex string -> its bytes; anything else -> its raw bytes.
415
444
  function __sbHexOrBytes(value) {
416
445
  if (__sbIsStr(value) && value.length > 0 && value.length % 2 === 0) {
417
- let out = "";
446
+ // Array-and-join for the same reason as __sbToBytes above; the old version
447
+ // was worse than a plain `+=` loop, since rewriting the last char with
448
+ // `out.slice(0, -1) + ...` copied the whole string a second time per byte.
449
+ // The high nibble is held in a local until its pair arrives instead.
450
+ const out = [];
451
+ let hi = 0;
418
452
  for (let i = 0; i < value.length; i++) {
419
453
  const c = value.charCodeAt(i);
420
454
  const d = c >= 48 && c <= 57 ? c - 48 : c >= 97 && c <= 102 ? c - 87 : c >= 65 && c <= 70 ? c - 55 : -1;
421
455
  if (d < 0) return __sbToBytes(value);
422
- if (i % 2 === 0) out += String.fromCharCode(d << 4);
423
- else out = out.slice(0, -1) + String.fromCharCode(out.charCodeAt(out.length - 1) | d);
456
+ if (i % 2 === 0) hi = d << 4;
457
+ else out.push(String.fromCharCode(hi | d));
424
458
  }
425
- return out;
459
+ return out.join("");
426
460
  }
427
461
  return __sbToBytes(value);
428
462
  }
@@ -0,0 +1,114 @@
1
+ // sproutboat #180 — __sbToBytes lives inline in native-fetch-prelude.js (the
2
+ // prelude is prepended as text and cannot import), so this test lifts that one
3
+ // pure-JS function (plus its __sbIsStr dependency) out and exercises it
4
+ // directly. If it moves or its boundary markers change, this fails loudly
5
+ // rather than silently testing nothing.
6
+ import { expect, test } from "bun:test";
7
+ import { readFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
11
+ const isStrStart = src.indexOf("function __sbIsStr(");
12
+ const isStrEnd = src.indexOf("function __sbIsFn(");
13
+ const start = src.indexOf("function __sbToBytes(");
14
+ const end = src.indexOf("function __sbBufFrom(");
15
+ const hexStart = src.indexOf("function __sbHexOrBytes(");
16
+ const hexEnd = src.indexOf("\nif (globalThis.crypto.subtle");
17
+ if (isStrStart === -1 || isStrEnd === -1 || start === -1 || end === -1 || end < start) {
18
+ throw new Error("__sbToBytes (or __sbIsStr) block not found in prelude");
19
+ }
20
+ if (hexStart === -1 || hexEnd === -1 || hexEnd < hexStart) throw new Error("__sbHexOrBytes block not found in prelude");
21
+
22
+ // oxlint-disable-next-line no-function-constructor -- lifting pure blocks out of the text-only prelude for test
23
+ const load = new Function(
24
+ `${src.slice(isStrStart, isStrEnd)}\n${src.slice(start, end)}\n${src.slice(hexStart, hexEnd)}\nreturn { __sbToBytes, __sbHexOrBytes };`,
25
+ );
26
+ const { __sbToBytes, __sbHexOrBytes } = load();
27
+
28
+ /** The real bytestring shape (one char per raw byte) as a normal JS array of byte values, for comparison. */
29
+ function bytesOf(bytestring) {
30
+ const out = [];
31
+ for (let i = 0; i < bytestring.length; i++) out.push(bytestring.charCodeAt(i));
32
+ return out;
33
+ }
34
+
35
+ test("ASCII string encodes byte-identically to TextEncoder", () => {
36
+ const text = "hello world 123";
37
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
38
+ });
39
+
40
+ test("2-byte (Latin-1 range) sequences encode byte-identically to TextEncoder", () => {
41
+ const text = "café";
42
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
43
+ });
44
+
45
+ test("3-byte sequences encode byte-identically to TextEncoder", () => {
46
+ const text = "porffor · x86_64 ✓";
47
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
48
+ });
49
+
50
+ test("surrogate-pair (4-byte) sequences encode byte-identically to TextEncoder", () => {
51
+ const text = "🌱 sprout 🌱";
52
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
53
+ });
54
+
55
+ test("a lone unpaired surrogate falls into the 3-byte branch, matching charCodeAt-based encoders", () => {
56
+ const lone = String.fromCharCode(0xd800); // high surrogate with no following low surrogate
57
+ const view = __sbToBytes(lone);
58
+ // WHATWG TextEncoder would replace this with U+FFFD; __sbToBytes intentionally
59
+ // encodes the raw code unit instead (matches the historical/legacy behavior it
60
+ // already had) -- this test only pins that behavior isn't disturbed by the fix.
61
+ expect(bytesOf(view)).toEqual([0xe0 | (0xd800 >> 12), 0x80 | ((0xd800 >> 6) & 0x3f), 0x80 | (0xd800 & 0x3f)]);
62
+ });
63
+
64
+ test("null/undefined input returns empty", () => {
65
+ expect(__sbToBytes(null)).toBe("");
66
+ expect(__sbToBytes(undefined)).toBe("");
67
+ });
68
+
69
+ test("ArrayBuffer / typed-array input is copied byte for byte, high bytes included", () => {
70
+ const bytes = new Uint8Array([0x00, 0x7f, 0x80, 0xff, 0x01, 0xfe]);
71
+ expect(bytesOf(__sbToBytes(bytes))).toEqual([...bytes]);
72
+ expect(bytesOf(__sbToBytes(bytes.buffer))).toEqual([...bytes]);
73
+ });
74
+
75
+ // __sbToBytes builds its result via an array + one join(), not repeated
76
+ // `out +=` / `s +=`. Porffor's strings have no rope/cons optimization, so `+=`
77
+ // in a loop copies the whole accumulated string on every append, making a
78
+ // large encode O(n^2) -- the same bug class __sbFromUtf8 was fixed for
79
+ // (see utf8-decode.test.js). This is a correctness check (long input still
80
+ // round-trips exactly), not a timing assertion.
81
+ test("a long, multi-chunk mixed-width string round-trips byte-identically", () => {
82
+ const chunk = "porffor · src/index.js → 1.25 MB · x86_64-musl ✓ café 🌱 ";
83
+ const text = chunk.repeat(800); // ~46KB, comparable to the __sbFromUtf8 regression case
84
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
85
+ });
86
+
87
+ test("a long high-byte typed array round-trips byte for byte", () => {
88
+ const bytes = new Uint8Array(50000);
89
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37 + 0x80) & 0xff;
90
+ expect(bytesOf(__sbToBytes(bytes))).toEqual([...bytes]);
91
+ });
92
+
93
+ // __sbHexOrBytes had the same O(n^2) shape on a third path (it backs the
94
+ // `expected` side of the HMAC verify), plus a `slice(0, -1) + ...` rewrite that
95
+ // copied the accumulated string a second time per byte. Same array+join fix.
96
+ test("an even-length hex string decodes to its bytes, both cases", () => {
97
+ expect(bytesOf(__sbHexOrBytes("00ff107a"))).toEqual([0x00, 0xff, 0x10, 0x7a]);
98
+ expect(bytesOf(__sbHexOrBytes("00FF107A"))).toEqual([0x00, 0xff, 0x10, 0x7a]);
99
+ expect(bytesOf(__sbHexOrBytes("aAbB"))).toEqual([0xaa, 0xbb]);
100
+ });
101
+
102
+ test("a non-hex or odd-length string falls back to raw bytes", () => {
103
+ expect(__sbHexOrBytes("zz")).toBe(__sbToBytes("zz"));
104
+ expect(__sbHexOrBytes("abc")).toBe(__sbToBytes("abc")); // odd length
105
+ expect(__sbHexOrBytes("")).toBe(__sbToBytes(""));
106
+ expect(bytesOf(__sbHexOrBytes(new Uint8Array([0x80, 0x01])))).toEqual([0x80, 0x01]);
107
+ });
108
+
109
+ test("a long hex string decodes byte for byte", () => {
110
+ const bytes = new Uint8Array(20000);
111
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37 + 0x80) & 0xff;
112
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
113
+ expect(bytesOf(__sbHexOrBytes(hex))).toEqual([...bytes]);
114
+ });
@@ -1191,13 +1191,13 @@ function __sbEnsureSchema() {
1191
1191
  }
1192
1192
 
1193
1193
  function __sbHex(n) {
1194
- let out = "";
1194
+ const out = [];
1195
1195
  const bytes = __sbRandomBytes(String(n));
1196
1196
  for (let i = 0; i < bytes.length; i++) {
1197
1197
  const h = bytes.charCodeAt(i).toString(16);
1198
- out += h.length === 1 ? "0" + h : h;
1198
+ out.push(h.length === 1 ? "0" + h : h);
1199
1199
  }
1200
- return out;
1200
+ return out.join("");
1201
1201
  }
1202
1202
 
1203
1203
  function __sbEmbeddedDispatch(msg) {
@@ -1279,14 +1279,15 @@ function __sbEmbeddedDispatch(msg) {
1279
1279
  if (!tls && url.protocol !== "http:") throw new Error("unsupported protocol: " + url.protocol);
1280
1280
  const allow = bindingsOutbound();
1281
1281
  if (allow.indexOf(url.host) === -1) throw new Error("host not in outbound allowlist: " + url.host);
1282
- let headerText = "";
1282
+ const headerParts = [];
1283
1283
  const pairs = msg.headers || [];
1284
1284
  for (let i = 0; i < pairs.length; i++) {
1285
1285
  const key = String(pairs[i][0]).toLowerCase();
1286
1286
  // Host, Connection and Content-Length are ours to set.
1287
1287
  if (key === "host" || key === "connection" || key === "content-length") continue;
1288
- headerText += pairs[i][0] + ": " + pairs[i][1] + "\r\n";
1288
+ headerParts.push(pairs[i][0] + ": " + pairs[i][1] + "\r\n");
1289
1289
  }
1290
+ const headerText = headerParts.join("");
1290
1291
  const port = url.port ? url.port : tls ? "443" : "80";
1291
1292
  const reply = JSON.parse(
1292
1293
  __sbHttpRaw(
@@ -50,3 +50,57 @@ test("empty and pure-ASCII strings are untouched", () => {
50
50
  expect(__sbFromUtf8("")).toBe("");
51
51
  expect(__sbFromUtf8("hello world")).toBe("hello world");
52
52
  });
53
+
54
+ // __sbFromUtf8 builds its result via an array + one join(),
55
+ // not repeated `out +=`. Porffor's strings have no rope/cons optimization, so
56
+ // `+=` in a loop copies the whole accumulated string on every append,
57
+ // making a large decode O(n^2) -- measured as a real ~2.6s stall decoding a
58
+ // 43KB page on sproutboat.com, gone after the array+join rewrite. This is a
59
+ // correctness check (long input still round-trips exactly), not a timing
60
+ // assertion -- CI timing thresholds are flaky; the real perf verification
61
+ // happened against a real compiled binary, documented in the fix commit.
62
+ test("a long, multi-chunk body round-trips exactly", () => {
63
+ const chunk = "porffor · src/index.js → 1.25 MB · x86_64-musl ✓ café 🌱 ";
64
+ const text = chunk.repeat(800); // ~46KB, comparable to the real homepage
65
+ expect(__sbFromUtf8(toBytestring(text))).toBe(text);
66
+ });
67
+
68
+ // __sbRawBodyResponse must decode lazily: eagerly decoding at construction
69
+ // paid the cost of __sbFromUtf8 on every asset/proxied response regardless of
70
+ // whether the handler ever reads it as text, a real perf regression shipped
71
+ // and caught on sproutboat.com (most such responses are handed straight to
72
+ // the client and .text()/.json() are never called).
73
+ const rbrStart = src.indexOf("function __sbRawBodyResponse(");
74
+ const rbrEnd = src.indexOf("// Constant-time compare of two latin1 byte strings.");
75
+ if (rbrStart === -1 || rbrEnd === -1 || rbrEnd < rbrStart) {
76
+ throw new Error("__sbRawBodyResponse block not found in prelude");
77
+ }
78
+ // oxlint-disable-next-line no-function-constructor -- lifting a pure block out of the text-only prelude for test
79
+ const loadRbr = new Function(
80
+ "Response",
81
+ `${src.slice(start, end)}\n${src.slice(rbrStart, rbrEnd)}\nreturn { __sbRawBodyResponse };`,
82
+ );
83
+ // A minimal Response stand-in: only what __sbRawBodyResponse touches (the
84
+ // constructor call and setting .text/.json on the instance), no Porffor
85
+ // bytestring typing involved.
86
+ class FakeResponse {
87
+ constructor(body, init) {
88
+ this.body = body;
89
+ this.status = (init && init.status) || 200;
90
+ }
91
+ }
92
+ const { __sbRawBodyResponse } = loadRbr(FakeResponse);
93
+
94
+ test("__sbRawBodyResponse's text()/json() decode correctly, callable more than once", () => {
95
+ const bytes = toBytestring("café · 🌱");
96
+ const resp = __sbRawBodyResponse(bytes, { status: 200 });
97
+ expect(resp.text()).toBe("café · 🌱");
98
+ expect(resp.text()).toBe("café · 🌱"); // memoized decode, not re-run per call
99
+ expect(resp.json).toBeInstanceOf(Function);
100
+ });
101
+
102
+ test("__sbRawBodyResponse with a null body sets no text()/json() override", () => {
103
+ const resp = __sbRawBodyResponse(null, { status: 404 });
104
+ expect(resp.text).toBeUndefined();
105
+ expect(resp.json).toBeUndefined();
106
+ });