@sproutboat/runtime 0.6.4 → 0.6.6
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 +61 -0
- package/package.json +1 -1
- package/src/entry-crash.test.js +63 -0
- package/src/native-fetch-prelude.js +120 -45
- package/src/to-bytes.test.js +28 -2
- package/src/transport-embedded.js +5 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.6.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c2b0359: Fix (baronunread/sproutboat#179): `__sbEntry` called `handlers.fetch()` (and
|
|
8
|
+
the `scheduled`/`queue`/`alarm` trigger paths) with no try/catch at all. Any
|
|
9
|
+
synchronous throw, or an async handler's rejected promise, propagated all the
|
|
10
|
+
way up and took down the entire process — not just the request that hit it,
|
|
11
|
+
but every other in-flight and future request on that sprout, until whatever
|
|
12
|
+
supervises it restarts the binary.
|
|
13
|
+
|
|
14
|
+
A throw from `fetch()` now returns a 500 instead. `scheduled()` and `alarm()`
|
|
15
|
+
still reply 204 either way (they had no failure signal before this either).
|
|
16
|
+
A throw from `queue()` falls through to the existing default-ack pass, the
|
|
17
|
+
same outcome as a handler that returns without calling `ack()`/`retry()` on
|
|
18
|
+
every message.
|
|
19
|
+
|
|
20
|
+
`entry-crash.test.js` covers the fetch path: a sync throw, an async
|
|
21
|
+
rejection, and that a throwing request doesn't stop the next one from being
|
|
22
|
+
served.
|
|
23
|
+
|
|
24
|
+
## 0.6.5
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- d798206: Fix (baronunread/sproutboat#181): 0.6.4's `__sbToBytes` rewrite fixed the large
|
|
29
|
+
input it targeted and badly regressed the small one. An app hashing ~150-byte
|
|
30
|
+
values through `crypto.subtle.digest` on every request lost **64% of its
|
|
31
|
+
throughput** (4,700 to 1,680 req/s), tripled its p50 (4.6ms to 13.4ms) and grew
|
|
32
|
+
41% in RSS (66.5 to 93.8 MB). 0.6.4 replaced a `s +=` loop with an
|
|
33
|
+
array-push-then-join unconditionally, and below about 512 bytes the array is
|
|
34
|
+
pure overhead: one boxed element per input byte, allocated and joined, where
|
|
35
|
+
the string append had nothing to allocate at all.
|
|
36
|
+
|
|
37
|
+
`__sbToBytes` now accumulates into a window and only creates an array once a
|
|
38
|
+
window fills. An input under 512 bytes touches no array and runs exactly like
|
|
39
|
+
the pre-0.6.4 code; a larger one caps the quadratic copy at one window and
|
|
40
|
+
joins the windows once, keeping the #180 fix (a 43KB body encodes in ~3ms
|
|
41
|
+
instead of ~52ms). Both branches flush only on a complete character, so a
|
|
42
|
+
surrogate pair can never be split across a window boundary. 512 is where the
|
|
43
|
+
two costs cross on a `porf native` build, and the curve is flat either side, so
|
|
44
|
+
it is a plateau rather than a tuned constant.
|
|
45
|
+
|
|
46
|
+
Measured end to end on the reporter's real workload, not just a microbench
|
|
47
|
+
(which under-predicted the regression by two orders of magnitude and would have
|
|
48
|
+
shipped the wrong fix): 4,690 req/s, p50 4.62ms, RSS 66.4 MB, matching the
|
|
49
|
+
pre-regression baseline on every axis.
|
|
50
|
+
|
|
51
|
+
Three other 0.6.4 changes are reverted to byte-identical 0.6.3, since they were
|
|
52
|
+
made for consistency rather than against a measured problem, and #181 is
|
|
53
|
+
evidence that array allocation at small sizes is the wrong trade in this
|
|
54
|
+
runtime: `__sbHex` and the outbound-fetch header builder in
|
|
55
|
+
`transport-embedded.js`, and `__SproutboatURLSearchParams.prototype.toString()`.
|
|
56
|
+
`__sbHexOrBytes` keeps its nibble fix, which drops a `slice(0, -1)` rewrite that
|
|
57
|
+
copied the accumulator twice per byte, but goes back to `+=`: it decodes an HMAC
|
|
58
|
+
signature, which is tens of bytes.
|
|
59
|
+
|
|
60
|
+
`to-bytes.test.js` gains the small-input coverage this needed: sizes either side
|
|
61
|
+
of the window boundary (0, 1, 150, 511, 512, 513, 1024, 1025) on both branches,
|
|
62
|
+
and a surrogate pair walked across the boundary.
|
|
63
|
+
|
|
3
64
|
## 0.6.4
|
|
4
65
|
|
|
5
66
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// #179 — an uncaught handler exception must turn into a 500 (or, for
|
|
2
|
+
// scheduled/queue/alarm, a swallowed failure), never a process-crashing
|
|
3
|
+
// throw/rejection out of __sbEntry. Same lift-the-prelude-and-eval approach
|
|
4
|
+
// as client-ip.test.js: the prelude is prepended as text and cannot import.
|
|
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
|
+
|
|
11
|
+
// oxlint-disable-next-line no-function-constructor -- lifting the whole text-only prelude for test
|
|
12
|
+
const load = new Function(
|
|
13
|
+
"Porffor",
|
|
14
|
+
"__envMap",
|
|
15
|
+
`${src}
|
|
16
|
+
return { __sbEntry, __sbRegisterDO };`,
|
|
17
|
+
);
|
|
18
|
+
// Every Porffor.c`...` block in the prelude only runs inside a function body
|
|
19
|
+
// (never at load time), so a no-op tag covers all of them: __sbCpuMs and
|
|
20
|
+
// __sbEnv just see an empty result, same as "native op returned nothing."
|
|
21
|
+
const H = load(
|
|
22
|
+
{ c: () => "" },
|
|
23
|
+
new Proxy({}, { get: () => undefined }),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const req = (overrides = {}) => ({
|
|
27
|
+
headers: { get: (k) => (overrides.headers && overrides.headers[k.toLowerCase()]) ?? null },
|
|
28
|
+
cf: undefined,
|
|
29
|
+
...overrides,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a synchronously-throwing fetch handler returns 500, not a throw", async () => {
|
|
33
|
+
const handlers = {
|
|
34
|
+
fetch() {
|
|
35
|
+
throw new Error("boom");
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const res = await H.__sbEntry(handlers, req());
|
|
39
|
+
expect(res.status).toBe(500);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an async fetch handler whose promise rejects returns 500, not a rejection", async () => {
|
|
43
|
+
const handlers = {
|
|
44
|
+
async fetch() {
|
|
45
|
+
throw new Error("boom");
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const res = await H.__sbEntry(handlers, req());
|
|
49
|
+
expect(res.status).toBe(500);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a throwing fetch handler doesn't stop the next request from being served", async () => {
|
|
53
|
+
const handlers = {
|
|
54
|
+
fetch(request) {
|
|
55
|
+
if (request.boom) throw new Error("boom");
|
|
56
|
+
return new Response("ok");
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const first = await H.__sbEntry(handlers, req({ boom: true }));
|
|
60
|
+
expect(first.status).toBe(500);
|
|
61
|
+
const second = await H.__sbEntry(handlers, req({ boom: false }));
|
|
62
|
+
expect(await second.text()).toBe("ok");
|
|
63
|
+
});
|
|
@@ -171,14 +171,12 @@ class __SproutboatURLSearchParams {
|
|
|
171
171
|
return this._keys.length;
|
|
172
172
|
}
|
|
173
173
|
toString() {
|
|
174
|
-
|
|
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 = [];
|
|
174
|
+
let out = "";
|
|
178
175
|
for (let i = 0; i < this._keys.length; i++) {
|
|
179
|
-
|
|
176
|
+
if (i > 0) out += "&";
|
|
177
|
+
out += encodeURIComponent(this._keys[i]) + "=" + encodeURIComponent(this._vals[i]);
|
|
180
178
|
}
|
|
181
|
-
return out
|
|
179
|
+
return out;
|
|
182
180
|
}
|
|
183
181
|
}
|
|
184
182
|
|
|
@@ -293,41 +291,73 @@ function __sbHashBits(algo) {
|
|
|
293
291
|
if (n === "SHA-512" || n === "SHA512") return "512";
|
|
294
292
|
return "";
|
|
295
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;
|
|
296
298
|
// -> a latin1 string, one char per byte. Strings are UTF-8 encoded (matching
|
|
297
299
|
// TextEncoder); ArrayBuffer / typed-array input is copied byte for byte.
|
|
298
300
|
function __sbToBytes(input) {
|
|
299
301
|
if (input == null) return "";
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
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.
|
|
305
319
|
if (__sbIsStr(input)) {
|
|
306
|
-
|
|
320
|
+
let out = null;
|
|
321
|
+
let s = "";
|
|
307
322
|
for (let i = 0; i < input.length; i++) {
|
|
308
323
|
const c = input.charCodeAt(i);
|
|
309
|
-
if (c < 0x80)
|
|
310
|
-
else if (c < 0x800)
|
|
324
|
+
if (c < 0x80) s += String.fromCharCode(c);
|
|
325
|
+
else if (c < 0x800) s += String.fromCharCode(0xc0 | (c >> 6)) + String.fromCharCode(0x80 | (c & 0x3f));
|
|
311
326
|
else if (c >= 0xd800 && c < 0xdc00 && i + 1 < input.length) {
|
|
312
327
|
const cp = 0x10000 + ((c - 0xd800) << 10) + (input.charCodeAt(++i) - 0xdc00);
|
|
313
|
-
|
|
328
|
+
s +=
|
|
314
329
|
String.fromCharCode(0xf0 | (cp >> 18)) +
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
);
|
|
330
|
+
String.fromCharCode(0x80 | ((cp >> 12) & 0x3f)) +
|
|
331
|
+
String.fromCharCode(0x80 | ((cp >> 6) & 0x3f)) +
|
|
332
|
+
String.fromCharCode(0x80 | (cp & 0x3f));
|
|
319
333
|
} else
|
|
320
|
-
|
|
334
|
+
s +=
|
|
321
335
|
String.fromCharCode(0xe0 | (c >> 12)) +
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
336
|
+
String.fromCharCode(0x80 | ((c >> 6) & 0x3f)) +
|
|
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
|
+
}
|
|
325
343
|
}
|
|
344
|
+
if (out === null) return s;
|
|
345
|
+
if (s.length > 0) out.push(s);
|
|
326
346
|
return out.join("");
|
|
327
347
|
}
|
|
328
348
|
const view = input.length !== undefined && input.buffer !== undefined ? input : new Uint8Array(input);
|
|
329
|
-
|
|
330
|
-
|
|
349
|
+
let out = null;
|
|
350
|
+
let 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);
|
|
331
361
|
return out.join("");
|
|
332
362
|
}
|
|
333
363
|
function __sbBufFrom(latin1) {
|
|
@@ -443,20 +473,23 @@ function __sbEqCt(a, b) {
|
|
|
443
473
|
// An even-length all-hex string -> its bytes; anything else -> its raw bytes.
|
|
444
474
|
function __sbHexOrBytes(value) {
|
|
445
475
|
if (__sbIsStr(value) && value.length > 0 && value.length % 2 === 0) {
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
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.
|
|
483
|
+
let out = "";
|
|
451
484
|
let hi = 0;
|
|
452
485
|
for (let i = 0; i < value.length; i++) {
|
|
453
486
|
const c = value.charCodeAt(i);
|
|
454
487
|
const d = c >= 48 && c <= 57 ? c - 48 : c >= 97 && c <= 102 ? c - 87 : c >= 65 && c <= 70 ? c - 55 : -1;
|
|
455
488
|
if (d < 0) return __sbToBytes(value);
|
|
456
489
|
if (i % 2 === 0) hi = d << 4;
|
|
457
|
-
else out
|
|
490
|
+
else out += String.fromCharCode(hi | d);
|
|
458
491
|
}
|
|
459
|
-
return out
|
|
492
|
+
return out;
|
|
460
493
|
}
|
|
461
494
|
return __sbToBytes(value);
|
|
462
495
|
}
|
|
@@ -1670,15 +1703,31 @@ async function __sbDrainAll(pending) {
|
|
|
1670
1703
|
// one *this* async function creates, not a `.then()` derived from someone
|
|
1671
1704
|
// else's — see the note below on why that distinction matters here.
|
|
1672
1705
|
async function __sbFetchWithDrain(res, tasks) {
|
|
1673
|
-
|
|
1706
|
+
let r;
|
|
1707
|
+
try {
|
|
1708
|
+
r = res && __sbIsFn(res.then) ? await res : res;
|
|
1709
|
+
} catch {
|
|
1710
|
+
// #179 — a rejected handler promise must not take the process down with it.
|
|
1711
|
+
r = __sbErrorResponse();
|
|
1712
|
+
}
|
|
1674
1713
|
await __sbDrainWaitUntil(tasks);
|
|
1675
1714
|
return r;
|
|
1676
1715
|
}
|
|
1677
1716
|
|
|
1717
|
+
// #179 — turn an uncaught handler exception into a 500 instead of letting it
|
|
1718
|
+
// propagate past __sbEntry and crash the whole server.
|
|
1719
|
+
function __sbErrorResponse() {
|
|
1720
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1678
1723
|
// Shared by alarm and scheduled: both reply with an empty 204 once their
|
|
1679
1724
|
// handler (and anything it queued via `waitUntil`) has settled.
|
|
1680
1725
|
async function __sb204WithDrain(res, tasks) {
|
|
1681
|
-
|
|
1726
|
+
try {
|
|
1727
|
+
if (res && __sbIsFn(res.then)) await res;
|
|
1728
|
+
} catch {
|
|
1729
|
+
// #179 — same rule as fetch: a rejected scheduled/alarm promise must not crash the process.
|
|
1730
|
+
}
|
|
1682
1731
|
await __sbDrainWaitUntil(tasks);
|
|
1683
1732
|
return new Response("", { status: 204 });
|
|
1684
1733
|
}
|
|
@@ -1713,7 +1762,14 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
1713
1762
|
if (p && __sbIsFn(p.then)) __sbTasks.push(p);
|
|
1714
1763
|
},
|
|
1715
1764
|
};
|
|
1716
|
-
|
|
1765
|
+
let __res;
|
|
1766
|
+
try {
|
|
1767
|
+
__res = handlers.fetch(request, ctx);
|
|
1768
|
+
} catch {
|
|
1769
|
+
// #179 — a synchronous throw anywhere in the handler must not take the
|
|
1770
|
+
// whole process (and every other in-flight request) down with it.
|
|
1771
|
+
return __sbErrorResponse();
|
|
1772
|
+
}
|
|
1717
1773
|
// Same promise-identity rule as above applies to `__sbFetchWithDrain`'s
|
|
1718
1774
|
// own return, which is why it's tail-called rather than chained on here.
|
|
1719
1775
|
if (__sbTasks.length || (__res && __sbIsFn(__res.then))) return __sbFetchWithDrain(__res, __sbTasks);
|
|
@@ -1733,10 +1789,16 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
1733
1789
|
if (p && __sbIsFn(p.then)) __sbTasks.push(p);
|
|
1734
1790
|
},
|
|
1735
1791
|
};
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1792
|
+
let __sres;
|
|
1793
|
+
try {
|
|
1794
|
+
__sres = handlers.scheduled(
|
|
1795
|
+
{ cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} },
|
|
1796
|
+
ctx,
|
|
1797
|
+
);
|
|
1798
|
+
} catch {
|
|
1799
|
+
// #179 — same rule as fetch: don't let a throw here crash the process.
|
|
1800
|
+
return new Response("", { status: 204 });
|
|
1801
|
+
}
|
|
1740
1802
|
if (__sbTasks.length || (__sres && __sbIsFn(__sres.then))) return __sb204WithDrain(__sres, __sbTasks);
|
|
1741
1803
|
return new Response("", { status: 204 });
|
|
1742
1804
|
}
|
|
@@ -1764,7 +1826,13 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
1764
1826
|
const body = __sbReadJson(request);
|
|
1765
1827
|
const inst = __sbGetDOInstance(String(body.cls || ""), String(body.id || ""));
|
|
1766
1828
|
if (!__sbIsFn(inst.alarm)) return new Response("no alarm handler", { status: 404 });
|
|
1767
|
-
|
|
1829
|
+
let __ares;
|
|
1830
|
+
try {
|
|
1831
|
+
__ares = inst.alarm();
|
|
1832
|
+
} catch {
|
|
1833
|
+
// #179 — same rule as fetch: don't let a throw here crash the process.
|
|
1834
|
+
return new Response("", { status: 204 });
|
|
1835
|
+
}
|
|
1768
1836
|
// Was fire-and-forget before: an async `alarm()` and any `state.waitUntil()`
|
|
1769
1837
|
// it queued were both dropped the moment 204 went out. Await both.
|
|
1770
1838
|
if (inst.__sbTasks.length || (__ares && __sbIsFn(__ares.then))) return __sb204WithDrain(__ares, inst.__sbTasks);
|
|
@@ -1812,11 +1880,18 @@ async function __sbRunQueueBatch(handlers, body, ctx) {
|
|
|
1812
1880
|
for (let i = 0; i < messages.length; i++) messages[i].retry();
|
|
1813
1881
|
},
|
|
1814
1882
|
};
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1883
|
+
try {
|
|
1884
|
+
const __qres = handlers.queue(batch, ctx);
|
|
1885
|
+
// Was fire-and-forget: an async `queue()` had this default-ack pass run (and
|
|
1886
|
+
// the response go out) before its own `await`s did, so any ack()/retry()
|
|
1887
|
+
// past the first one never counted.
|
|
1888
|
+
if (__qres && __sbIsFn(__qres.then)) await __qres;
|
|
1889
|
+
} catch {
|
|
1890
|
+
// #179 — a throw here must not crash the process. Whatever ack()/retry()
|
|
1891
|
+
// calls the handler made before throwing still count; anything it never
|
|
1892
|
+
// touched falls through to the default-ack pass below, same as a handler
|
|
1893
|
+
// that returns without calling ack()/retry() on every message.
|
|
1894
|
+
}
|
|
1820
1895
|
// default: any message neither acked nor retried is treated as acked
|
|
1821
1896
|
for (let i = 0; i < messages.length; i++) {
|
|
1822
1897
|
if (acked.indexOf(messages[i].id) === -1 && retried.indexOf(messages[i].id) === -1) acked.push(messages[i].id);
|
package/src/to-bytes.test.js
CHANGED
|
@@ -10,12 +10,13 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
|
|
11
11
|
const isStrStart = src.indexOf("function __sbIsStr(");
|
|
12
12
|
const isStrEnd = src.indexOf("function __sbIsFn(");
|
|
13
|
-
|
|
13
|
+
// Starts at the window constant, not the function: __sbToBytes closes over it.
|
|
14
|
+
const start = src.indexOf("const __SB_BYTES_WINDOW");
|
|
14
15
|
const end = src.indexOf("function __sbBufFrom(");
|
|
15
16
|
const hexStart = src.indexOf("function __sbHexOrBytes(");
|
|
16
17
|
const hexEnd = src.indexOf("\nif (globalThis.crypto.subtle");
|
|
17
18
|
if (isStrStart === -1 || isStrEnd === -1 || start === -1 || end === -1 || end < start) {
|
|
18
|
-
throw new Error("__sbToBytes (or __sbIsStr) block not found in prelude");
|
|
19
|
+
throw new Error("__sbToBytes (or __sbIsStr / __SB_BYTES_WINDOW) block not found in prelude");
|
|
19
20
|
}
|
|
20
21
|
if (hexStart === -1 || hexEnd === -1 || hexEnd < hexStart) throw new Error("__sbHexOrBytes block not found in prelude");
|
|
21
22
|
|
|
@@ -90,6 +91,31 @@ test("a long high-byte typed array round-trips byte for byte", () => {
|
|
|
90
91
|
expect(bytesOf(__sbToBytes(bytes))).toEqual([...bytes]);
|
|
91
92
|
});
|
|
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
|
+
|
|
93
119
|
// __sbHexOrBytes had the same O(n^2) shape on a third path (it backs the
|
|
94
120
|
// `expected` side of the HMAC verify), plus a `slice(0, -1) + ...` rewrite that
|
|
95
121
|
// copied the accumulated string a second time per byte. Same array+join fix.
|
|
@@ -1191,13 +1191,13 @@ function __sbEnsureSchema() {
|
|
|
1191
1191
|
}
|
|
1192
1192
|
|
|
1193
1193
|
function __sbHex(n) {
|
|
1194
|
-
|
|
1194
|
+
let 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 += h.length === 1 ? "0" + h : h;
|
|
1199
1199
|
}
|
|
1200
|
-
return out
|
|
1200
|
+
return out;
|
|
1201
1201
|
}
|
|
1202
1202
|
|
|
1203
1203
|
function __sbEmbeddedDispatch(msg) {
|
|
@@ -1279,15 +1279,14 @@ 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
|
+
let headerText = "";
|
|
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
|
+
headerText += pairs[i][0] + ": " + pairs[i][1] + "\r\n";
|
|
1289
1289
|
}
|
|
1290
|
-
const headerText = headerParts.join("");
|
|
1291
1290
|
const port = url.port ? url.port : tls ? "443" : "80";
|
|
1292
1291
|
const reply = JSON.parse(
|
|
1293
1292
|
__sbHttpRaw(
|