@sproutboat/runtime 0.6.3 → 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 +36 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +36 -22
- package/src/to-bytes.test.js +114 -0
- package/src/transport-embedded.js +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
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
|
+
|
|
3
39
|
## 0.6.3
|
|
4
40
|
|
|
5
41
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -171,12 +171,14 @@ class __SproutboatURLSearchParams {
|
|
|
171
171
|
return this._keys.length;
|
|
172
172
|
}
|
|
173
173
|
toString() {
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
+
const out = [];
|
|
300
307
|
for (let i = 0; i < input.length; i++) {
|
|
301
308
|
const c = input.charCodeAt(i);
|
|
302
|
-
if (c < 0x80)
|
|
303
|
-
else if (c < 0x800)
|
|
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
|
-
|
|
313
|
+
out.push(
|
|
307
314
|
String.fromCharCode(0xf0 | (cp >> 18)) +
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
320
|
+
out.push(
|
|
313
321
|
String.fromCharCode(0xe0 | (c >> 12)) +
|
|
314
|
-
|
|
315
|
-
|
|
322
|
+
String.fromCharCode(0x80 | ((c >> 6) & 0x3f)) +
|
|
323
|
+
String.fromCharCode(0x80 | (c & 0x3f))
|
|
324
|
+
);
|
|
316
325
|
}
|
|
317
|
-
return
|
|
326
|
+
return out.join("");
|
|
318
327
|
}
|
|
319
328
|
const view = input.length !== undefined && input.buffer !== undefined ? input : new Uint8Array(input);
|
|
320
|
-
|
|
321
|
-
for (let i = 0; i < view.length; i++)
|
|
322
|
-
return
|
|
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);
|
|
@@ -434,15 +443,20 @@ function __sbEqCt(a, b) {
|
|
|
434
443
|
// An even-length all-hex string -> its bytes; anything else -> its raw bytes.
|
|
435
444
|
function __sbHexOrBytes(value) {
|
|
436
445
|
if (__sbIsStr(value) && value.length > 0 && value.length % 2 === 0) {
|
|
437
|
-
|
|
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;
|
|
438
452
|
for (let i = 0; i < value.length; i++) {
|
|
439
453
|
const c = value.charCodeAt(i);
|
|
440
454
|
const d = c >= 48 && c <= 57 ? c - 48 : c >= 97 && c <= 102 ? c - 87 : c >= 65 && c <= 70 ? c - 55 : -1;
|
|
441
455
|
if (d < 0) return __sbToBytes(value);
|
|
442
|
-
if (i % 2 === 0)
|
|
443
|
-
else out
|
|
456
|
+
if (i % 2 === 0) hi = d << 4;
|
|
457
|
+
else out.push(String.fromCharCode(hi | d));
|
|
444
458
|
}
|
|
445
|
-
return out;
|
|
459
|
+
return out.join("");
|
|
446
460
|
}
|
|
447
461
|
return __sbToBytes(value);
|
|
448
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|