@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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
- // 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 = [];
174
+ let out = "";
178
175
  for (let i = 0; i < this._keys.length; i++) {
179
- out.push(encodeURIComponent(this._keys[i]) + "=" + encodeURIComponent(this._vals[i]));
176
+ if (i > 0) out += "&";
177
+ out += encodeURIComponent(this._keys[i]) + "=" + encodeURIComponent(this._vals[i]);
180
178
  }
181
- return out.join("&");
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
- // 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).
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
- const out = [];
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) out.push(String.fromCharCode(c));
310
- else if (c < 0x800) out.push(String.fromCharCode(0xc0 | (c >> 6)) + String.fromCharCode(0x80 | (c & 0x3f)));
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
- out.push(
328
+ s +=
314
329
  String.fromCharCode(0xf0 | (cp >> 18)) +
315
- String.fromCharCode(0x80 | ((cp >> 12) & 0x3f)) +
316
- String.fromCharCode(0x80 | ((cp >> 6) & 0x3f)) +
317
- String.fromCharCode(0x80 | (cp & 0x3f))
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
- out.push(
334
+ s +=
321
335
  String.fromCharCode(0xe0 | (c >> 12)) +
322
- String.fromCharCode(0x80 | ((c >> 6) & 0x3f)) +
323
- String.fromCharCode(0x80 | (c & 0x3f))
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
- const out = [];
330
- for (let i = 0; i < view.length; i++) out.push(String.fromCharCode(view[i] & 0xff));
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
- // 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 = [];
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.push(String.fromCharCode(hi | d));
490
+ else out += String.fromCharCode(hi | d);
458
491
  }
459
- return out.join("");
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
- const r = res && __sbIsFn(res.then) ? await res : res;
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
- if (res && __sbIsFn(res.then)) await res;
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
- const __res = handlers.fetch(request, ctx);
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
- const __sres = handlers.scheduled(
1737
- { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} },
1738
- ctx,
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
- const __ares = inst.alarm();
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
- const __qres = handlers.queue(batch, ctx);
1816
- // Was fire-and-forget: an async `queue()` had this default-ack pass run (and
1817
- // the response go out) before its own `await`s did, so any ack()/retry()
1818
- // past the first one never counted.
1819
- if (__qres && __sbIsFn(__qres.then)) await __qres;
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);
@@ -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
- const start = src.indexOf("function __sbToBytes(");
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
- const out = [];
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.push(h.length === 1 ? "0" + h : h);
1198
+ out += h.length === 1 ? "0" + h : h;
1199
1199
  }
1200
- return out.join("");
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
- const headerParts = [];
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
- headerParts.push(pairs[i][0] + ": " + pairs[i][1] + "\r\n");
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(