@sproutboat/runtime 0.6.3 → 0.6.5

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,81 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.6.5
4
+
5
+ ### Patch Changes
6
+
7
+ - d798206: Fix (baronunread/sproutboat#181): 0.6.4's `__sbToBytes` rewrite fixed the large
8
+ input it targeted and badly regressed the small one. An app hashing ~150-byte
9
+ values through `crypto.subtle.digest` on every request lost **64% of its
10
+ throughput** (4,700 to 1,680 req/s), tripled its p50 (4.6ms to 13.4ms) and grew
11
+ 41% in RSS (66.5 to 93.8 MB). 0.6.4 replaced a `s +=` loop with an
12
+ array-push-then-join unconditionally, and below about 512 bytes the array is
13
+ pure overhead: one boxed element per input byte, allocated and joined, where
14
+ the string append had nothing to allocate at all.
15
+
16
+ `__sbToBytes` now accumulates into a window and only creates an array once a
17
+ window fills. An input under 512 bytes touches no array and runs exactly like
18
+ the pre-0.6.4 code; a larger one caps the quadratic copy at one window and
19
+ joins the windows once, keeping the #180 fix (a 43KB body encodes in ~3ms
20
+ instead of ~52ms). Both branches flush only on a complete character, so a
21
+ surrogate pair can never be split across a window boundary. 512 is where the
22
+ two costs cross on a `porf native` build, and the curve is flat either side, so
23
+ it is a plateau rather than a tuned constant.
24
+
25
+ Measured end to end on the reporter's real workload, not just a microbench
26
+ (which under-predicted the regression by two orders of magnitude and would have
27
+ shipped the wrong fix): 4,690 req/s, p50 4.62ms, RSS 66.4 MB, matching the
28
+ pre-regression baseline on every axis.
29
+
30
+ Three other 0.6.4 changes are reverted to byte-identical 0.6.3, since they were
31
+ made for consistency rather than against a measured problem, and #181 is
32
+ evidence that array allocation at small sizes is the wrong trade in this
33
+ runtime: `__sbHex` and the outbound-fetch header builder in
34
+ `transport-embedded.js`, and `__SproutboatURLSearchParams.prototype.toString()`.
35
+ `__sbHexOrBytes` keeps its nibble fix, which drops a `slice(0, -1)` rewrite that
36
+ copied the accumulator twice per byte, but goes back to `+=`: it decodes an HMAC
37
+ signature, which is tens of bytes.
38
+
39
+ `to-bytes.test.js` gains the small-input coverage this needed: sizes either side
40
+ of the window boundary (0, 1, 150, 511, 512, 513, 1024, 1025) on both branches,
41
+ and a surrogate pair walked across the boundary.
42
+
43
+ ## 0.6.4
44
+
45
+ ### Patch Changes
46
+
47
+ - 8ef3148: Fix (baronunread/sproutboat#180): `__sbToBytes` built its result with repeated
48
+ `s +=` in a loop, the same `O(n^2)` bug class the `__sbFromUtf8` fix in 0.6.3
49
+ addressed, just on the encode side. Porffor's strings have no rope/cons
50
+ optimization, so `+=` in a loop copies the whole accumulated string on every
51
+ append. Both of its branches were affected: the UTF-8 encode loop over string
52
+ input, and the byte-for-byte copy out of an ArrayBuffer or typed array.
53
+
54
+ This one matters because `__sbToBytes` sits behind every `crypto.subtle` call a
55
+ handler makes: `digest`, `importKey`, `sign`, `verify` and the PBKDF2/scrypt
56
+ path all funnel their data, keys, salts and signatures through it. Hashing a
57
+ request body was quadratic in the body's size.
58
+
59
+ `__sbHexOrBytes` had the same shape on a third crypto path (it decodes the
60
+ `expected` side of an HMAC verify) and was worse than a plain `+=` loop: on odd
61
+ iterations it rewrote the last character with `out = out.slice(0, -1) + ...`,
62
+ copying the accumulated string a second time per byte. It now holds the high
63
+ nibble in a local until its pair arrives and pushes one character per byte.
64
+
65
+ Three further accumulators converted to the same array-and-join idiom for
66
+ consistency. These are bounded by header count, id length or parameter count
67
+ rather than by handler input, so they are hygiene rather than a fix:
68
+ `__SproutboatURLSearchParams.prototype.toString()` in the prelude, and `__sbHex`
69
+ plus the outbound-fetch header builder in `transport-embedded.js`.
70
+
71
+ Covered by a new `to-bytes.test.js`, following the `utf8-decode.test.js`
72
+ convention of lifting the pure functions out of the text-only prelude: ASCII,
73
+ 2-byte, 3-byte and surrogate-pair encodes checked byte-identical against
74
+ `TextEncoder`, the existing unpaired-surrogate behaviour pinned, typed-array and
75
+ ArrayBuffer input with high bytes, hex decode in both cases with its non-hex and
76
+ odd-length fallbacks, and long inputs (46KB string, 50,000-byte array, 20,000-byte
77
+ hex) for correctness at the size the quadratic version hurt.
78
+
3
79
  ## 0.6.3
4
80
 
5
81
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -291,11 +291,33 @@ function __sbHashBits(algo) {
291
291
  if (n === "SHA-512" || n === "SHA512") return "512";
292
292
  return "";
293
293
  }
294
+ // Where __sbToBytes stops growing one string and starts a new window. 512 is
295
+ // where the two costs cross on a `porf native` build; the curve is flat either
296
+ // side of it, so this is a plateau, not a tuned constant.
297
+ const __SB_BYTES_WINDOW = 512;
294
298
  // -> a latin1 string, one char per byte. Strings are UTF-8 encoded (matching
295
299
  // TextEncoder); ArrayBuffer / typed-array input is copied byte for byte.
296
300
  function __sbToBytes(input) {
297
301
  if (input == null) return "";
302
+ // Windowed accumulation (#180, then #181). Plain `s +=` in a loop is O(n^2)
303
+ // here, since Porffor's strings have no rope/cons optimization and every
304
+ // append copies the whole accumulated string -- that's #180, a 43KB body
305
+ // taking ~52ms to encode. But 0.6.4's unconditional array-push-then-join
306
+ // regressed the common case badly: a real app hashing ~150-byte inputs per
307
+ // request lost 64% of its throughput and gained 41% RSS (#181), far more
308
+ // than the per-call cost of the array ops measures in isolation.
309
+ //
310
+ // So: append into a window, and only once a window fills does an array come
311
+ // into existence to hold it. An input below __SB_BYTES_WINDOW allocates no
312
+ // array at all and runs exactly like the pre-#180 code; a large one caps the
313
+ // quadratic copy at one window and joins the windows once. Measured on a
314
+ // `porf native` build, this ties `+=` at 100-512 bytes and ties array+join
315
+ // at 8-43KB, with no threshold branch to keep in sync.
316
+ //
317
+ // Both branches flush only on a complete character, never mid-sequence, so a
318
+ // surrogate pair can't be split across two windows.
298
319
  if (__sbIsStr(input)) {
320
+ let out = null;
299
321
  let s = "";
300
322
  for (let i = 0; i < input.length; i++) {
301
323
  const c = input.charCodeAt(i);
@@ -313,13 +335,30 @@ function __sbToBytes(input) {
313
335
  String.fromCharCode(0xe0 | (c >> 12)) +
314
336
  String.fromCharCode(0x80 | ((c >> 6) & 0x3f)) +
315
337
  String.fromCharCode(0x80 | (c & 0x3f));
338
+ if (s.length >= __SB_BYTES_WINDOW) {
339
+ if (out === null) out = [];
340
+ out.push(s);
341
+ s = "";
342
+ }
316
343
  }
317
- return s;
344
+ if (out === null) return s;
345
+ if (s.length > 0) out.push(s);
346
+ return out.join("");
318
347
  }
319
348
  const view = input.length !== undefined && input.buffer !== undefined ? input : new Uint8Array(input);
349
+ let out = null;
320
350
  let s = "";
321
- for (let i = 0; i < view.length; i++) s += String.fromCharCode(view[i] & 0xff);
322
- return s;
351
+ for (let i = 0; i < view.length; i++) {
352
+ s += String.fromCharCode(view[i] & 0xff);
353
+ if (s.length >= __SB_BYTES_WINDOW) {
354
+ if (out === null) out = [];
355
+ out.push(s);
356
+ s = "";
357
+ }
358
+ }
359
+ if (out === null) return s;
360
+ if (s.length > 0) out.push(s);
361
+ return out.join("");
323
362
  }
324
363
  function __sbBufFrom(latin1) {
325
364
  const b = new Uint8Array(latin1.length);
@@ -434,13 +473,21 @@ function __sbEqCt(a, b) {
434
473
  // An even-length all-hex string -> its bytes; anything else -> its raw bytes.
435
474
  function __sbHexOrBytes(value) {
436
475
  if (__sbIsStr(value) && value.length > 0 && value.length % 2 === 0) {
476
+ // The high nibble waits in a local until its pair arrives, instead of the
477
+ // old `out = out.slice(0, -1) + ...` rewrite, which copied the whole
478
+ // accumulated string a second time per byte. Deliberately still `+=` and
479
+ // not __sbToBytes' windowing: this decodes an HMAC signature, which is
480
+ // tens of bytes, and #181 showed array allocation is the expensive part at
481
+ // that size. ponytail: O(n^2) above a few KB, window it like __sbToBytes
482
+ // if a caller ever passes something that big.
437
483
  let out = "";
484
+ let hi = 0;
438
485
  for (let i = 0; i < value.length; i++) {
439
486
  const c = value.charCodeAt(i);
440
487
  const d = c >= 48 && c <= 57 ? c - 48 : c >= 97 && c <= 102 ? c - 87 : c >= 65 && c <= 70 ? c - 55 : -1;
441
488
  if (d < 0) return __sbToBytes(value);
442
- if (i % 2 === 0) out += String.fromCharCode(d << 4);
443
- else out = out.slice(0, -1) + String.fromCharCode(out.charCodeAt(out.length - 1) | d);
489
+ if (i % 2 === 0) hi = d << 4;
490
+ else out += String.fromCharCode(hi | d);
444
491
  }
445
492
  return out;
446
493
  }
@@ -0,0 +1,140 @@
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
+ // Starts at the window constant, not the function: __sbToBytes closes over it.
14
+ const start = src.indexOf("const __SB_BYTES_WINDOW");
15
+ const end = src.indexOf("function __sbBufFrom(");
16
+ const hexStart = src.indexOf("function __sbHexOrBytes(");
17
+ const hexEnd = src.indexOf("\nif (globalThis.crypto.subtle");
18
+ if (isStrStart === -1 || isStrEnd === -1 || start === -1 || end === -1 || end < start) {
19
+ throw new Error("__sbToBytes (or __sbIsStr / __SB_BYTES_WINDOW) block not found in prelude");
20
+ }
21
+ if (hexStart === -1 || hexEnd === -1 || hexEnd < hexStart) throw new Error("__sbHexOrBytes block not found in prelude");
22
+
23
+ // oxlint-disable-next-line no-function-constructor -- lifting pure blocks out of the text-only prelude for test
24
+ const load = new Function(
25
+ `${src.slice(isStrStart, isStrEnd)}\n${src.slice(start, end)}\n${src.slice(hexStart, hexEnd)}\nreturn { __sbToBytes, __sbHexOrBytes };`,
26
+ );
27
+ const { __sbToBytes, __sbHexOrBytes } = load();
28
+
29
+ /** The real bytestring shape (one char per raw byte) as a normal JS array of byte values, for comparison. */
30
+ function bytesOf(bytestring) {
31
+ const out = [];
32
+ for (let i = 0; i < bytestring.length; i++) out.push(bytestring.charCodeAt(i));
33
+ return out;
34
+ }
35
+
36
+ test("ASCII string encodes byte-identically to TextEncoder", () => {
37
+ const text = "hello world 123";
38
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
39
+ });
40
+
41
+ test("2-byte (Latin-1 range) sequences encode byte-identically to TextEncoder", () => {
42
+ const text = "café";
43
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
44
+ });
45
+
46
+ test("3-byte sequences encode byte-identically to TextEncoder", () => {
47
+ const text = "porffor · x86_64 ✓";
48
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
49
+ });
50
+
51
+ test("surrogate-pair (4-byte) sequences encode byte-identically to TextEncoder", () => {
52
+ const text = "🌱 sprout 🌱";
53
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
54
+ });
55
+
56
+ test("a lone unpaired surrogate falls into the 3-byte branch, matching charCodeAt-based encoders", () => {
57
+ const lone = String.fromCharCode(0xd800); // high surrogate with no following low surrogate
58
+ const view = __sbToBytes(lone);
59
+ // WHATWG TextEncoder would replace this with U+FFFD; __sbToBytes intentionally
60
+ // encodes the raw code unit instead (matches the historical/legacy behavior it
61
+ // already had) -- this test only pins that behavior isn't disturbed by the fix.
62
+ expect(bytesOf(view)).toEqual([0xe0 | (0xd800 >> 12), 0x80 | ((0xd800 >> 6) & 0x3f), 0x80 | (0xd800 & 0x3f)]);
63
+ });
64
+
65
+ test("null/undefined input returns empty", () => {
66
+ expect(__sbToBytes(null)).toBe("");
67
+ expect(__sbToBytes(undefined)).toBe("");
68
+ });
69
+
70
+ test("ArrayBuffer / typed-array input is copied byte for byte, high bytes included", () => {
71
+ const bytes = new Uint8Array([0x00, 0x7f, 0x80, 0xff, 0x01, 0xfe]);
72
+ expect(bytesOf(__sbToBytes(bytes))).toEqual([...bytes]);
73
+ expect(bytesOf(__sbToBytes(bytes.buffer))).toEqual([...bytes]);
74
+ });
75
+
76
+ // __sbToBytes builds its result via an array + one join(), not repeated
77
+ // `out +=` / `s +=`. Porffor's strings have no rope/cons optimization, so `+=`
78
+ // in a loop copies the whole accumulated string on every append, making a
79
+ // large encode O(n^2) -- the same bug class __sbFromUtf8 was fixed for
80
+ // (see utf8-decode.test.js). This is a correctness check (long input still
81
+ // round-trips exactly), not a timing assertion.
82
+ test("a long, multi-chunk mixed-width string round-trips byte-identically", () => {
83
+ const chunk = "porffor · src/index.js → 1.25 MB · x86_64-musl ✓ café 🌱 ";
84
+ const text = chunk.repeat(800); // ~46KB, comparable to the __sbFromUtf8 regression case
85
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
86
+ });
87
+
88
+ test("a long high-byte typed array round-trips byte for byte", () => {
89
+ const bytes = new Uint8Array(50000);
90
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37 + 0x80) & 0xff;
91
+ expect(bytesOf(__sbToBytes(bytes))).toEqual([...bytes]);
92
+ });
93
+
94
+ // sproutboat#181: 0.6.4 encoded every size through one array-push-then-join
95
+ // path, which cost a real app 64% of its throughput on the ~150-byte inputs
96
+ // crypto.subtle sees per request. The encoder now only reaches for an array
97
+ // once a 512-byte window fills, so these sizes exercise both sides of that
98
+ // boundary and the seam between them. Correctness only; the performance claim
99
+ // lives in the benchmark, not here.
100
+ test("inputs either side of the window boundary encode byte-identically", () => {
101
+ for (const n of [0, 1, 150, 511, 512, 513, 1024, 1025]) {
102
+ const text = "a".repeat(n);
103
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
104
+ const bytes = new Uint8Array(n);
105
+ for (let i = 0; i < n; i++) bytes[i] = (i * 37 + 0x80) & 0xff;
106
+ expect(bytesOf(__sbToBytes(bytes))).toEqual([...bytes]);
107
+ }
108
+ });
109
+
110
+ test("a surrogate pair straddling the window boundary is not split", () => {
111
+ // Land the 4-byte sequence so its bytes would cross a 512-byte flush if the
112
+ // encoder ever flushed mid-character.
113
+ for (let pad = 508; pad <= 515; pad++) {
114
+ const text = "a".repeat(pad) + "🌱" + "b".repeat(4);
115
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
116
+ }
117
+ });
118
+
119
+ // __sbHexOrBytes had the same O(n^2) shape on a third path (it backs the
120
+ // `expected` side of the HMAC verify), plus a `slice(0, -1) + ...` rewrite that
121
+ // copied the accumulated string a second time per byte. Same array+join fix.
122
+ test("an even-length hex string decodes to its bytes, both cases", () => {
123
+ expect(bytesOf(__sbHexOrBytes("00ff107a"))).toEqual([0x00, 0xff, 0x10, 0x7a]);
124
+ expect(bytesOf(__sbHexOrBytes("00FF107A"))).toEqual([0x00, 0xff, 0x10, 0x7a]);
125
+ expect(bytesOf(__sbHexOrBytes("aAbB"))).toEqual([0xaa, 0xbb]);
126
+ });
127
+
128
+ test("a non-hex or odd-length string falls back to raw bytes", () => {
129
+ expect(__sbHexOrBytes("zz")).toBe(__sbToBytes("zz"));
130
+ expect(__sbHexOrBytes("abc")).toBe(__sbToBytes("abc")); // odd length
131
+ expect(__sbHexOrBytes("")).toBe(__sbToBytes(""));
132
+ expect(bytesOf(__sbHexOrBytes(new Uint8Array([0x80, 0x01])))).toEqual([0x80, 0x01]);
133
+ });
134
+
135
+ test("a long hex string decodes byte for byte", () => {
136
+ const bytes = new Uint8Array(20000);
137
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37 + 0x80) & 0xff;
138
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
139
+ expect(bytesOf(__sbHexOrBytes(hex))).toEqual([...bytes]);
140
+ });