@sproutboat/runtime 0.6.2 → 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 +62 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +66 -32
- package/src/to-bytes.test.js +114 -0
- package/src/transport-embedded.js +6 -5
- package/src/utf8-decode.test.js +54 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
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
|
+
|
|
39
|
+
## 0.6.3
|
|
40
|
+
|
|
41
|
+
### Patch Changes
|
|
42
|
+
|
|
43
|
+
- Fix two compounding performance regressions in the `#181` fix, caught after
|
|
44
|
+
it went live and made sproutboat.com's homepage take 2.6-2.8s per request:
|
|
45
|
+
|
|
46
|
+
1. `__sbRawBodyResponse` decoded eagerly at construction, paying the cost of
|
|
47
|
+
`__sbFromUtf8` on every asset/proxied response regardless of whether the
|
|
48
|
+
handler ever called `.text()`/`.json()` on it (the common case is a
|
|
49
|
+
response handed straight back to the client, never read as text). Now
|
|
50
|
+
decodes lazily on first call and memoizes.
|
|
51
|
+
|
|
52
|
+
2. `__sbFromUtf8` built its result with repeated `out +=`. Porffor's strings
|
|
53
|
+
have no rope/cons optimization, so `+=` in a loop copies the whole
|
|
54
|
+
accumulated string on every append, making a large decode `O(n^2)`.
|
|
55
|
+
Measured directly against the real broker-sourced 43KB homepage body,
|
|
56
|
+
each successive 8000-character chunk took visibly longer than the last.
|
|
57
|
+
Now builds into an array and joins once.
|
|
58
|
+
|
|
59
|
+
Together these took the homepage from ~2.7s to ~25ms (measured via the
|
|
60
|
+
runtime's own `x-sb-cpu-ms` header, confirming real CPU time, not I/O wait).
|
|
61
|
+
Every other route was already unaffected by fix 1 alone, since they never
|
|
62
|
+
call `.text()`. Verified against a real compiled `porf native` binary with
|
|
63
|
+
the actual page content, and end to end on sproutboat-site's own deployment.
|
|
64
|
+
|
|
3
65
|
## 0.6.2
|
|
4
66
|
|
|
5
67
|
### 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);
|
|
@@ -339,7 +348,15 @@ function __sbBufFrom(latin1) {
|
|
|
339
348
|
// regardless of whether it holds real Latin-1 text or raw bytes, so this
|
|
340
349
|
// decode is only safe where the caller already knows which one it has.
|
|
341
350
|
function __sbFromUtf8(bytes) {
|
|
342
|
-
|
|
351
|
+
// Collected into an array and joined once at the end, not built with
|
|
352
|
+
// repeated `out +=` -- Porffor's strings have no rope/cons optimization, so
|
|
353
|
+
// `+=` in a loop copies the whole accumulated string on every append,
|
|
354
|
+
// making the naive version O(n^2). Measured on the real 43KB homepage
|
|
355
|
+
// (dynamic, wire-sourced content, not a compile-time constant): each
|
|
356
|
+
// successive 8000-character chunk took visibly longer than the last, ~2.8s
|
|
357
|
+
// total -- a real, shipped performance regression on sproutboat.com.
|
|
358
|
+
// `Array.prototype.join` builds the result in one pass.
|
|
359
|
+
const out = [];
|
|
343
360
|
const len = bytes.length;
|
|
344
361
|
const cont = (j) => {
|
|
345
362
|
const b = bytes.charCodeAt(j) & 0xff;
|
|
@@ -348,13 +365,13 @@ function __sbFromUtf8(bytes) {
|
|
|
348
365
|
for (let i = 0; i < len; i++) {
|
|
349
366
|
const b0 = bytes.charCodeAt(i) & 0xff;
|
|
350
367
|
if (b0 < 0x80) {
|
|
351
|
-
out
|
|
368
|
+
out.push(String.fromCharCode(b0));
|
|
352
369
|
continue;
|
|
353
370
|
}
|
|
354
371
|
if ((b0 & 0xe0) == 0xc0 && i + 1 < len) {
|
|
355
372
|
const c1 = cont(i + 1);
|
|
356
373
|
if (c1 >= 0) {
|
|
357
|
-
out
|
|
374
|
+
out.push(String.fromCharCode(((b0 & 0x1f) << 6) | c1));
|
|
358
375
|
i += 1;
|
|
359
376
|
continue;
|
|
360
377
|
}
|
|
@@ -362,7 +379,7 @@ function __sbFromUtf8(bytes) {
|
|
|
362
379
|
const c1 = cont(i + 1);
|
|
363
380
|
const c2 = cont(i + 2);
|
|
364
381
|
if (c1 >= 0 && c2 >= 0) {
|
|
365
|
-
out
|
|
382
|
+
out.push(String.fromCharCode(((b0 & 0x0f) << 12) | (c1 << 6) | c2));
|
|
366
383
|
i += 2;
|
|
367
384
|
continue;
|
|
368
385
|
}
|
|
@@ -373,16 +390,16 @@ function __sbFromUtf8(bytes) {
|
|
|
373
390
|
if (c1 >= 0 && c2 >= 0 && c3 >= 0) {
|
|
374
391
|
let cp = ((b0 & 0x07) << 18) | (c1 << 12) | (c2 << 6) | c3;
|
|
375
392
|
cp -= 0x10000;
|
|
376
|
-
out
|
|
393
|
+
out.push(String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff)));
|
|
377
394
|
i += 3;
|
|
378
395
|
continue;
|
|
379
396
|
}
|
|
380
397
|
}
|
|
381
398
|
// no lead byte matched, or its continuation bytes weren't 10xxxxxx:
|
|
382
399
|
// pass the single byte through rather than guessing
|
|
383
|
-
out
|
|
400
|
+
out.push(String.fromCharCode(b0));
|
|
384
401
|
}
|
|
385
|
-
return out;
|
|
402
|
+
return out.join("");
|
|
386
403
|
}
|
|
387
404
|
// sproutboat #181 — wrap a Response built from raw wire bytes (the assets
|
|
388
405
|
// binding, outbound fetch(), service-binding fetch(): the three `x-sb-raw-
|
|
@@ -395,12 +412,24 @@ function __sbFromUtf8(bytes) {
|
|
|
395
412
|
// safely can't decode by default). Overriding just these three instances,
|
|
396
413
|
// which sproutboat itself builds and knows are bytes, fixes the read side
|
|
397
414
|
// without touching Porffor's generic Response/Request classes at all.
|
|
415
|
+
// Decoded lazily, on first .text()/.json() call, and memoized after that --
|
|
416
|
+
// most raw-body responses (a static asset, a proxied fetch()) are handed
|
|
417
|
+
// straight back to the client and never read as text at all, and Porffor's
|
|
418
|
+
// strings have no rope/cons optimization: `+=`-building a decoded copy costs
|
|
419
|
+
// real time proportional to the body size (measured ~116ms for a 44KB
|
|
420
|
+
// all-ASCII body). Decoding unconditionally at construction paid that cost
|
|
421
|
+
// on every asset request regardless of whether anything used it -- this was
|
|
422
|
+
// a real, shipped performance regression on sproutboat.com, caught after
|
|
423
|
+
// the fact by unexpectedly slow page loads.
|
|
398
424
|
function __sbRawBodyResponse(body, init) {
|
|
399
425
|
const resp = new Response(body == null ? "" : body, init);
|
|
400
426
|
if (body != null) {
|
|
401
|
-
|
|
402
|
-
resp.text = () =>
|
|
403
|
-
|
|
427
|
+
let decoded = null;
|
|
428
|
+
resp.text = () => {
|
|
429
|
+
if (decoded === null) decoded = __sbFromUtf8(body);
|
|
430
|
+
return decoded;
|
|
431
|
+
};
|
|
432
|
+
resp.json = () => JSON.parse(resp.text());
|
|
404
433
|
}
|
|
405
434
|
return resp;
|
|
406
435
|
}
|
|
@@ -414,15 +443,20 @@ function __sbEqCt(a, b) {
|
|
|
414
443
|
// An even-length all-hex string -> its bytes; anything else -> its raw bytes.
|
|
415
444
|
function __sbHexOrBytes(value) {
|
|
416
445
|
if (__sbIsStr(value) && value.length > 0 && value.length % 2 === 0) {
|
|
417
|
-
|
|
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;
|
|
418
452
|
for (let i = 0; i < value.length; i++) {
|
|
419
453
|
const c = value.charCodeAt(i);
|
|
420
454
|
const d = c >= 48 && c <= 57 ? c - 48 : c >= 97 && c <= 102 ? c - 87 : c >= 65 && c <= 70 ? c - 55 : -1;
|
|
421
455
|
if (d < 0) return __sbToBytes(value);
|
|
422
|
-
if (i % 2 === 0)
|
|
423
|
-
else out
|
|
456
|
+
if (i % 2 === 0) hi = d << 4;
|
|
457
|
+
else out.push(String.fromCharCode(hi | d));
|
|
424
458
|
}
|
|
425
|
-
return out;
|
|
459
|
+
return out.join("");
|
|
426
460
|
}
|
|
427
461
|
return __sbToBytes(value);
|
|
428
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(
|
package/src/utf8-decode.test.js
CHANGED
|
@@ -50,3 +50,57 @@ test("empty and pure-ASCII strings are untouched", () => {
|
|
|
50
50
|
expect(__sbFromUtf8("")).toBe("");
|
|
51
51
|
expect(__sbFromUtf8("hello world")).toBe("hello world");
|
|
52
52
|
});
|
|
53
|
+
|
|
54
|
+
// __sbFromUtf8 builds its result via an array + one join(),
|
|
55
|
+
// not repeated `out +=`. Porffor's strings have no rope/cons optimization, so
|
|
56
|
+
// `+=` in a loop copies the whole accumulated string on every append,
|
|
57
|
+
// making a large decode O(n^2) -- measured as a real ~2.6s stall decoding a
|
|
58
|
+
// 43KB page on sproutboat.com, gone after the array+join rewrite. This is a
|
|
59
|
+
// correctness check (long input still round-trips exactly), not a timing
|
|
60
|
+
// assertion -- CI timing thresholds are flaky; the real perf verification
|
|
61
|
+
// happened against a real compiled binary, documented in the fix commit.
|
|
62
|
+
test("a long, multi-chunk body round-trips exactly", () => {
|
|
63
|
+
const chunk = "porffor · src/index.js → 1.25 MB · x86_64-musl ✓ café 🌱 ";
|
|
64
|
+
const text = chunk.repeat(800); // ~46KB, comparable to the real homepage
|
|
65
|
+
expect(__sbFromUtf8(toBytestring(text))).toBe(text);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// __sbRawBodyResponse must decode lazily: eagerly decoding at construction
|
|
69
|
+
// paid the cost of __sbFromUtf8 on every asset/proxied response regardless of
|
|
70
|
+
// whether the handler ever reads it as text, a real perf regression shipped
|
|
71
|
+
// and caught on sproutboat.com (most such responses are handed straight to
|
|
72
|
+
// the client and .text()/.json() are never called).
|
|
73
|
+
const rbrStart = src.indexOf("function __sbRawBodyResponse(");
|
|
74
|
+
const rbrEnd = src.indexOf("// Constant-time compare of two latin1 byte strings.");
|
|
75
|
+
if (rbrStart === -1 || rbrEnd === -1 || rbrEnd < rbrStart) {
|
|
76
|
+
throw new Error("__sbRawBodyResponse block not found in prelude");
|
|
77
|
+
}
|
|
78
|
+
// oxlint-disable-next-line no-function-constructor -- lifting a pure block out of the text-only prelude for test
|
|
79
|
+
const loadRbr = new Function(
|
|
80
|
+
"Response",
|
|
81
|
+
`${src.slice(start, end)}\n${src.slice(rbrStart, rbrEnd)}\nreturn { __sbRawBodyResponse };`,
|
|
82
|
+
);
|
|
83
|
+
// A minimal Response stand-in: only what __sbRawBodyResponse touches (the
|
|
84
|
+
// constructor call and setting .text/.json on the instance), no Porffor
|
|
85
|
+
// bytestring typing involved.
|
|
86
|
+
class FakeResponse {
|
|
87
|
+
constructor(body, init) {
|
|
88
|
+
this.body = body;
|
|
89
|
+
this.status = (init && init.status) || 200;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const { __sbRawBodyResponse } = loadRbr(FakeResponse);
|
|
93
|
+
|
|
94
|
+
test("__sbRawBodyResponse's text()/json() decode correctly, callable more than once", () => {
|
|
95
|
+
const bytes = toBytestring("café · 🌱");
|
|
96
|
+
const resp = __sbRawBodyResponse(bytes, { status: 200 });
|
|
97
|
+
expect(resp.text()).toBe("café · 🌱");
|
|
98
|
+
expect(resp.text()).toBe("café · 🌱"); // memoized decode, not re-run per call
|
|
99
|
+
expect(resp.json).toBeInstanceOf(Function);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("__sbRawBodyResponse with a null body sets no text()/json() override", () => {
|
|
103
|
+
const resp = __sbRawBodyResponse(null, { status: 404 });
|
|
104
|
+
expect(resp.text).toBeUndefined();
|
|
105
|
+
expect(resp.json).toBeUndefined();
|
|
106
|
+
});
|