@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 +76 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +52 -5
- package/src/to-bytes.test.js +140 -0
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
|
@@ -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++)
|
|
322
|
-
|
|
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)
|
|
443
|
-
else out
|
|
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
|
+
});
|