@sproutboat/runtime 0.6.2 → 0.6.3

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,31 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.6.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix two compounding performance regressions in the `#181` fix, caught after
8
+ it went live and made sproutboat.com's homepage take 2.6-2.8s per request:
9
+
10
+ 1. `__sbRawBodyResponse` decoded eagerly at construction, paying the cost of
11
+ `__sbFromUtf8` on every asset/proxied response regardless of whether the
12
+ handler ever called `.text()`/`.json()` on it (the common case is a
13
+ response handed straight back to the client, never read as text). Now
14
+ decodes lazily on first call and memoizes.
15
+
16
+ 2. `__sbFromUtf8` built its result with repeated `out +=`. Porffor's strings
17
+ have no rope/cons optimization, so `+=` in a loop copies the whole
18
+ accumulated string on every append, making a large decode `O(n^2)`.
19
+ Measured directly against the real broker-sourced 43KB homepage body,
20
+ each successive 8000-character chunk took visibly longer than the last.
21
+ Now builds into an array and joins once.
22
+
23
+ Together these took the homepage from ~2.7s to ~25ms (measured via the
24
+ runtime's own `x-sb-cpu-ms` header, confirming real CPU time, not I/O wait).
25
+ Every other route was already unaffected by fix 1 alone, since they never
26
+ call `.text()`. Verified against a real compiled `porf native` binary with
27
+ the actual page content, and end to end on sproutboat-site's own deployment.
28
+
3
29
  ## 0.6.2
4
30
 
5
31
  ### 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.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -339,7 +339,15 @@ function __sbBufFrom(latin1) {
339
339
  // regardless of whether it holds real Latin-1 text or raw bytes, so this
340
340
  // decode is only safe where the caller already knows which one it has.
341
341
  function __sbFromUtf8(bytes) {
342
- let out = "";
342
+ // Collected into an array and joined once at the end, not built with
343
+ // repeated `out +=` -- Porffor's strings have no rope/cons optimization, so
344
+ // `+=` in a loop copies the whole accumulated string on every append,
345
+ // making the naive version O(n^2). Measured on the real 43KB homepage
346
+ // (dynamic, wire-sourced content, not a compile-time constant): each
347
+ // successive 8000-character chunk took visibly longer than the last, ~2.8s
348
+ // total -- a real, shipped performance regression on sproutboat.com.
349
+ // `Array.prototype.join` builds the result in one pass.
350
+ const out = [];
343
351
  const len = bytes.length;
344
352
  const cont = (j) => {
345
353
  const b = bytes.charCodeAt(j) & 0xff;
@@ -348,13 +356,13 @@ function __sbFromUtf8(bytes) {
348
356
  for (let i = 0; i < len; i++) {
349
357
  const b0 = bytes.charCodeAt(i) & 0xff;
350
358
  if (b0 < 0x80) {
351
- out += String.fromCharCode(b0);
359
+ out.push(String.fromCharCode(b0));
352
360
  continue;
353
361
  }
354
362
  if ((b0 & 0xe0) == 0xc0 && i + 1 < len) {
355
363
  const c1 = cont(i + 1);
356
364
  if (c1 >= 0) {
357
- out += String.fromCharCode(((b0 & 0x1f) << 6) | c1);
365
+ out.push(String.fromCharCode(((b0 & 0x1f) << 6) | c1));
358
366
  i += 1;
359
367
  continue;
360
368
  }
@@ -362,7 +370,7 @@ function __sbFromUtf8(bytes) {
362
370
  const c1 = cont(i + 1);
363
371
  const c2 = cont(i + 2);
364
372
  if (c1 >= 0 && c2 >= 0) {
365
- out += String.fromCharCode(((b0 & 0x0f) << 12) | (c1 << 6) | c2);
373
+ out.push(String.fromCharCode(((b0 & 0x0f) << 12) | (c1 << 6) | c2));
366
374
  i += 2;
367
375
  continue;
368
376
  }
@@ -373,16 +381,16 @@ function __sbFromUtf8(bytes) {
373
381
  if (c1 >= 0 && c2 >= 0 && c3 >= 0) {
374
382
  let cp = ((b0 & 0x07) << 18) | (c1 << 12) | (c2 << 6) | c3;
375
383
  cp -= 0x10000;
376
- out += String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
384
+ out.push(String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff)));
377
385
  i += 3;
378
386
  continue;
379
387
  }
380
388
  }
381
389
  // no lead byte matched, or its continuation bytes weren't 10xxxxxx:
382
390
  // pass the single byte through rather than guessing
383
- out += String.fromCharCode(b0);
391
+ out.push(String.fromCharCode(b0));
384
392
  }
385
- return out;
393
+ return out.join("");
386
394
  }
387
395
  // sproutboat #181 — wrap a Response built from raw wire bytes (the assets
388
396
  // binding, outbound fetch(), service-binding fetch(): the three `x-sb-raw-
@@ -395,12 +403,24 @@ function __sbFromUtf8(bytes) {
395
403
  // safely can't decode by default). Overriding just these three instances,
396
404
  // which sproutboat itself builds and knows are bytes, fixes the read side
397
405
  // without touching Porffor's generic Response/Request classes at all.
406
+ // Decoded lazily, on first .text()/.json() call, and memoized after that --
407
+ // most raw-body responses (a static asset, a proxied fetch()) are handed
408
+ // straight back to the client and never read as text at all, and Porffor's
409
+ // strings have no rope/cons optimization: `+=`-building a decoded copy costs
410
+ // real time proportional to the body size (measured ~116ms for a 44KB
411
+ // all-ASCII body). Decoding unconditionally at construction paid that cost
412
+ // on every asset request regardless of whether anything used it -- this was
413
+ // a real, shipped performance regression on sproutboat.com, caught after
414
+ // the fact by unexpectedly slow page loads.
398
415
  function __sbRawBodyResponse(body, init) {
399
416
  const resp = new Response(body == null ? "" : body, init);
400
417
  if (body != null) {
401
- const decoded = __sbFromUtf8(body);
402
- resp.text = () => decoded;
403
- resp.json = () => JSON.parse(decoded);
418
+ let decoded = null;
419
+ resp.text = () => {
420
+ if (decoded === null) decoded = __sbFromUtf8(body);
421
+ return decoded;
422
+ };
423
+ resp.json = () => JSON.parse(resp.text());
404
424
  }
405
425
  return resp;
406
426
  }
@@ -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
+ });