@sproutboat/runtime 0.6.1 → 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,65 @@
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
+
29
+ ## 0.6.2
30
+
31
+ ### Patch Changes
32
+
33
+ - bd06ce7: Fix (baronunread/sproutboat#181): a handler calling `.text()`/`.json()` on a
34
+ Response built from raw wire bytes — the assets binding, outbound `fetch()`,
35
+ or a service-binding `fetch()`, the three places that set the `x-sb-raw-body`
36
+ marker for #176 — got the bytes back undecoded instead of as real text. Those
37
+ Response bodies are Porffor `bytestring`s (one raw byte per code unit), and
38
+ `Response.prototype.text()` returns that verbatim rather than UTF-8-decoding
39
+ it, so any multi-byte UTF-8 sequence came back as multiple wrong Latin-1
40
+ characters. A handler that transforms and re-emits that text (sproutboat-
41
+ site's homepage does this to inline its live counter into the fetched
42
+ `index.html`) then re-encodes the corruption as ordinary UTF-8 on the way
43
+ out — this is what turned `·` into `·` live on sproutboat.com.
44
+
45
+ Fixed at the sproutboat glue layer, not in Porffor: the three `x-sb-raw-body`
46
+ producers in `native-fetch-prelude.js` now build their Response through a new
47
+ `__sbRawBodyResponse()`, which overrides `.text()`/`.json()` on just that
48
+ instance with a proper UTF-8 decode (`__sbFromUtf8`, validates each
49
+ continuation byte before consuming it). This is deliberately *not* a patch to
50
+ Porffor's generic `Response`/`Request` classes — Porffor represents any
51
+ string whose characters are all <= 0xFF as this same `bytestring` shape,
52
+ whether it's opaque bytes or a handler's own Latin-1-range text (e.g.
53
+ `"café"`), and the type alone can't tell those apart. Decoding generically
54
+ would corrupt the second case; scoping the fix to the three call sites that
55
+ know for certain their body is bytes avoids that entirely (verified: a
56
+ genuine `"héllo"`/`"café"` string passes through `__sbFromUtf8` unchanged).
57
+
58
+ Verified against a real compiled native-fetch binary (`porf native`, not just
59
+ plain Bun): the override pattern works as expected, the decode round-trips
60
+ real multi-byte and 4-byte UTF-8 correctly, and genuine Latin-1 text is left
61
+ alone. Also verified end to end on sproutboat-site's own deployment.
62
+
3
63
  ## 0.6.1
4
64
 
5
65
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -326,6 +326,104 @@ function __sbBufFrom(latin1) {
326
326
  for (let i = 0; i < latin1.length; i++) b[i] = latin1.charCodeAt(i) & 0xff;
327
327
  return b.buffer;
328
328
  }
329
+ // The reverse of __sbToBytes: a latin1 string holding raw UTF-8 bytes (one
330
+ // byte per char) -> a real JS string, decoding multi-byte sequences into
331
+ // single characters. Validates each continuation byte (10xxxxxx) before
332
+ // consuming it, so a lead byte with no valid continuation -- the common case
333
+ // for genuine Latin-1 text, e.g. a lone "é" -- passes through unchanged
334
+ // rather than swallowing following characters into a garbage code point.
335
+ // Used only where sproutboat's own glue knows for certain the string is
336
+ // opaque bytes off the wire (the `x-sb-raw-body` producers below), never on
337
+ // a generic string a handler might have built -- Porffor represents any
338
+ // string whose chars are all <= 0xff as this same "bytestring" shape
339
+ // regardless of whether it holds real Latin-1 text or raw bytes, so this
340
+ // decode is only safe where the caller already knows which one it has.
341
+ function __sbFromUtf8(bytes) {
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 = [];
351
+ const len = bytes.length;
352
+ const cont = (j) => {
353
+ const b = bytes.charCodeAt(j) & 0xff;
354
+ return (b & 0xc0) == 0x80 ? b & 0x3f : -1;
355
+ };
356
+ for (let i = 0; i < len; i++) {
357
+ const b0 = bytes.charCodeAt(i) & 0xff;
358
+ if (b0 < 0x80) {
359
+ out.push(String.fromCharCode(b0));
360
+ continue;
361
+ }
362
+ if ((b0 & 0xe0) == 0xc0 && i + 1 < len) {
363
+ const c1 = cont(i + 1);
364
+ if (c1 >= 0) {
365
+ out.push(String.fromCharCode(((b0 & 0x1f) << 6) | c1));
366
+ i += 1;
367
+ continue;
368
+ }
369
+ } else if ((b0 & 0xf0) == 0xe0 && i + 2 < len) {
370
+ const c1 = cont(i + 1);
371
+ const c2 = cont(i + 2);
372
+ if (c1 >= 0 && c2 >= 0) {
373
+ out.push(String.fromCharCode(((b0 & 0x0f) << 12) | (c1 << 6) | c2));
374
+ i += 2;
375
+ continue;
376
+ }
377
+ } else if ((b0 & 0xf8) == 0xf0 && i + 3 < len) {
378
+ const c1 = cont(i + 1);
379
+ const c2 = cont(i + 2);
380
+ const c3 = cont(i + 3);
381
+ if (c1 >= 0 && c2 >= 0 && c3 >= 0) {
382
+ let cp = ((b0 & 0x07) << 18) | (c1 << 12) | (c2 << 6) | c3;
383
+ cp -= 0x10000;
384
+ out.push(String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff)));
385
+ i += 3;
386
+ continue;
387
+ }
388
+ }
389
+ // no lead byte matched, or its continuation bytes weren't 10xxxxxx:
390
+ // pass the single byte through rather than guessing
391
+ out.push(String.fromCharCode(b0));
392
+ }
393
+ return out.join("");
394
+ }
395
+ // sproutboat #181 — wrap a Response built from raw wire bytes (the assets
396
+ // binding, outbound fetch(), service-binding fetch(): the three `x-sb-raw-
397
+ // body` producers) so a handler calling `.text()`/`.json()` on it gets the
398
+ // bytes properly UTF-8-decoded. The wire write path already handles the
399
+ // C-level byte-for-byte passthrough via that same header (#176) -- this is
400
+ // the separate, missing half: Porffor's own `Response.prototype.text()`
401
+ // returns a `bytestring` body verbatim instead of decoding it (it can't tell
402
+ // "opaque bytes" from "a handler's own Latin-1-range string" apart, so it
403
+ // safely can't decode by default). Overriding just these three instances,
404
+ // which sproutboat itself builds and knows are bytes, fixes the read side
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.
415
+ function __sbRawBodyResponse(body, init) {
416
+ const resp = new Response(body == null ? "" : body, init);
417
+ if (body != null) {
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());
424
+ }
425
+ return resp;
426
+ }
329
427
  // Constant-time compare of two latin1 byte strings.
330
428
  function __sbEqCt(a, b) {
331
429
  if (a.length !== b.length) return false;
@@ -1145,7 +1243,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1145
1243
  // tells the C write path to pass them through untouched instead of
1146
1244
  // re-encoding as if this were a JS string a handler built.
1147
1245
  if (r.body != null) headers["x-sb-raw-body"] = "1";
1148
- return new Response(r.body == null ? "" : r.body, { status: r.status || (r.found ? 200 : 404), headers });
1246
+ return __sbRawBodyResponse(r.body, { status: r.status || (r.found ? 200 : 404), headers });
1149
1247
  },
1150
1248
  };
1151
1249
  }
@@ -1177,7 +1275,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1177
1275
  // built -- must not be re-encoded if the handler proxies it straight
1178
1276
  // through (see the assets binding for the same reasoning).
1179
1277
  if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
1180
- return new Response(r.body == null ? "" : r.body, { status: r.status || 502, headers: respHeaders });
1278
+ return __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
1181
1279
  },
1182
1280
  };
1183
1281
  }
@@ -1203,7 +1301,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1203
1301
  // built -- must not be re-encoded if the handler proxies it straight
1204
1302
  // through (see the assets binding for the same reasoning).
1205
1303
  if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
1206
- return new Response(r.body == null ? "" : r.body, { status: r.status || 502, headers: respHeaders });
1304
+ return __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
1207
1305
  };
1208
1306
  }
1209
1307
  };
@@ -0,0 +1,106 @@
1
+ // sproutboat #181 — __sbFromUtf8 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 out and exercises it directly. If it moves or its boundary
4
+ // markers change, this fails loudly rather than silently testing nothing.
5
+ import { expect, test } from "bun:test";
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
10
+ const start = src.indexOf("function __sbFromUtf8(");
11
+ const end = src.indexOf("// sproutboat #181 — wrap a Response");
12
+ if (start === -1 || end === -1 || end < start) throw new Error("__sbFromUtf8 block not found in prelude");
13
+
14
+ // oxlint-disable-next-line no-function-constructor -- lifting a pure block out of the text-only prelude for test
15
+ const load = new Function(`${src.slice(start, end)}\nreturn { __sbFromUtf8 };`);
16
+ const { __sbFromUtf8 } = load();
17
+
18
+ /** Build a bytestring (one char per raw byte) the way __sbToBytes/porf_native_fetch_alloc_bytestring would, from real UTF-8 bytes. */
19
+ function toBytestring(text) {
20
+ const bytes = new TextEncoder().encode(text);
21
+ let out = "";
22
+ for (const b of bytes) out += String.fromCharCode(b);
23
+ return out;
24
+ }
25
+
26
+ test("decodes real UTF-8 bytes back to the original text", () => {
27
+ const text = "porffor · src/index.js → 1.25 MB · x86_64-musl ✓";
28
+ expect(__sbFromUtf8(toBytestring(text))).toBe(text);
29
+ });
30
+
31
+ test("multi-byte and 4-byte (surrogate-pair) sequences round-trip", () => {
32
+ expect(__sbFromUtf8(toBytestring("café"))).toBe("café");
33
+ expect(__sbFromUtf8(toBytestring("🌱"))).toBe("🌱");
34
+ });
35
+
36
+ test("a genuine Latin-1-range string with no real UTF-8 sequences passes through unchanged", () => {
37
+ // "héllo" here is a real JS string (one code point per character), not
38
+ // re-encoded bytes -- exactly what a handler-built bytestring looks like.
39
+ // This must NOT be decoded, or it corrupts ordinary accented text.
40
+ expect(__sbFromUtf8("héllo")).toBe("héllo");
41
+ expect(__sbFromUtf8("café")).toBe("café");
42
+ });
43
+
44
+ test("a lead byte with an invalid or missing continuation passes through raw", () => {
45
+ expect(__sbFromUtf8(String.fromCharCode(0xe9))).toBe(String.fromCharCode(0xe9));
46
+ expect(__sbFromUtf8(String.fromCharCode(0xc2))).toBe(String.fromCharCode(0xc2)); // truncated 2-byte lead
47
+ });
48
+
49
+ test("empty and pure-ASCII strings are untouched", () => {
50
+ expect(__sbFromUtf8("")).toBe("");
51
+ expect(__sbFromUtf8("hello world")).toBe("hello world");
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
+ });