@sproutboat/runtime 0.27.0 → 0.28.0
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 +31 -0
- package/package.json +1 -1
- package/src/ingress-utf8.test.js +5 -7
- package/src/native-fetch-prelude.js +381 -10
- package/src/r2-object.test.js +16 -7
- package/src/to-bytes.test.js +1 -1
- package/src/transport-broker.js +159 -8
- package/src/utf8-decode.test.js +4 -2
- package/test/native-byte-text.ts +165 -0
- package/test/native-request-streaming.ts +221 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.28.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 829ed12: Request bodies stream, from compatibility date `2026-10-27`, as in workerd:
|
|
8
|
+
|
|
9
|
+
- **Ingress.** The native server runs the handler as soon as a request's headers arrive. `request.body` hands out the bytes as they come in, and the server pauses the socket while more than 1 MiB waits unread, so a large upload read through the stream keeps memory flat (a 128 MiB upload peaks at 38 MiB RSS). `text()`, `json()`, `arrayBuffer()` and `bytes()` read the whole body and return Promises.
|
|
10
|
+
- **Limit.** `SB_REQUEST_BODY_MAX` defaults to 100 MiB for streamed bodies (it stays 1 MiB before the date, where the body is held in memory whole). A larger `Content-Length` gets `413 Payload Too Large` before the handler runs; a chunked body that grows past it closes the connection, and the handler's stream fails.
|
|
11
|
+
- **Service bindings and `fetch()`.** On the broker transport, a `ReadableStream` request body goes out as it is read, in `FRAME_V1_MORE` frames after the request's head, instead of being read to the end first. The broker feeds them to the upstream request as a stream (with backpressure) and says so in its `ping` reply (`bodyStream: true`); against an older broker, bodies are sent whole as before. A streamed body can't follow a 307 or 308 redirect.
|
|
12
|
+
|
|
13
|
+
Internal trigger and RPC calls keep buffered bodies. Before `2026-10-27` nothing changes: the handler gets the whole body at once and `request.text()` answers synchronously, which a deployed handler may rely on, hence the new date.
|
|
14
|
+
|
|
15
|
+
## 0.27.1
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 0d2d079: Bodies of megabytes no longer take hundreds of megabytes of memory (sproutd #22). Converting bytes to text and text to bytes made one string per character, so reading a 5 MiB R2 object with `text()` peaked at about 400 MiB, and R2 multipart uploads with real (5 MiB) parts ran sproutd's workers out of memory. The conversions now run in one C pass and give exactly the same results. On a standalone build, at 5 MiB:
|
|
20
|
+
|
|
21
|
+
| | before | after |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `put()` of a string | 82 MiB | 16 MiB |
|
|
24
|
+
| `put()` then `get()` and `text()` | 400 MiB | 26 MiB |
|
|
25
|
+
| multipart upload, then `get()` and `text()` | 396 MiB | 21 MiB |
|
|
26
|
+
| `request.text()` of the request body | 402 MiB | 24 MiB |
|
|
27
|
+
|
|
28
|
+
Also:
|
|
29
|
+
|
|
30
|
+
- At every date, `uploadPart()` stores an `ArrayBuffer` or a typed array as its bytes. It stored the text `[object ArrayBuffer]`.
|
|
31
|
+
- From `2026-10-26`, `uploadPart()` takes what `put()` takes (a `ReadableStream` or a `Blob` too), and stores text as its UTF-8 bytes, as in workerd. It stored text whose characters all fit in one byte as Latin-1.
|
|
32
|
+
- From `2026-10-26`, an R2 object body's `body` stream is made when first read, and a whole-object `get()` keeps one copy of the bytes, not two.
|
|
33
|
+
|
|
3
34
|
## 0.27.0
|
|
4
35
|
|
|
5
36
|
### Minor Changes
|
package/package.json
CHANGED
package/src/ingress-utf8.test.js
CHANGED
|
@@ -2,17 +2,15 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
const src = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
|
|
4
4
|
const decoder = src.slice(src.indexOf("function __sbFromUtf8("), src.indexOf("// sproutboat #181"));
|
|
5
|
-
const ingress = src.slice(
|
|
6
|
-
src.indexOf("function __sbDecodeIngress("),
|
|
7
|
-
src.indexOf("globalThis.__sbEntry ="),
|
|
8
|
-
);
|
|
5
|
+
const ingress = src.slice(src.indexOf("function __sbDecodeIngress("), src.indexOf("globalThis.__sbEntry ="));
|
|
9
6
|
const rawBody = src.slice(src.indexOf("function __sbRawBody("), src.indexOf("function __sbHasStreamBody("));
|
|
10
7
|
if (!decoder || !ingress || !rawBody) throw new Error("Ingress decoder source not found");
|
|
11
8
|
// oxlint-disable-next-line no-function-constructor -- Exercise the text-only runtime prelude.
|
|
12
|
-
const load = new Function(
|
|
9
|
+
const load = new Function(
|
|
10
|
+
`// The C fast paths (Porffor.c) cannot run here: the stubs make the JS fallback run.\nfunction __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\n${decoder}\n${rawBody}\n${ingress}\nreturn __sbDecodeIngress;`,
|
|
11
|
+
);
|
|
13
12
|
const decode = load();
|
|
14
|
-
const raw = (text) =>
|
|
15
|
-
Array.from(new TextEncoder().encode(text), (byte) => String.fromCharCode(byte)).join("");
|
|
13
|
+
const raw = (text) => Array.from(new TextEncoder().encode(text), (byte) => String.fromCharCode(byte)).join("");
|
|
16
14
|
|
|
17
15
|
test("native ingress text and JSON decode Unicode without changing raw bytes", async () => {
|
|
18
16
|
const body = raw('{"id":"caffè-🚤"}');
|
|
@@ -3034,7 +3034,10 @@ if (globalThis.clearInterval == null) globalThis.clearInterval = clearInterval;
|
|
|
3034
3034
|
// these express the same spec-mandated checks and are verified under Porffor
|
|
3035
3035
|
// alpha-10 by examples/kitchen-sink/harness.ts).
|
|
3036
3036
|
function __sbIsStr(v) {
|
|
3037
|
-
|
|
3037
|
+
// sproutd #22: not `Object(v) !== v`, which wraps (copies) a string in Porffor: two such
|
|
3038
|
+
// checks on a 5 MiB part took 20 MiB.
|
|
3039
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- the one check that does not copy its argument
|
|
3040
|
+
return typeof v === "string";
|
|
3038
3041
|
}
|
|
3039
3042
|
function __sbIsFn(v) {
|
|
3040
3043
|
return v instanceof Function;
|
|
@@ -3936,6 +3939,9 @@ const __SB_BYTES_WINDOW = 512;
|
|
|
3936
3939
|
// TextEncoder); ArrayBuffer / typed-array input is copied byte for byte.
|
|
3937
3940
|
function __sbToBytes(input) {
|
|
3938
3941
|
if (input == null) return "";
|
|
3942
|
+
// sproutd #22: one C pass when the input allows it (see __sbToBytesNative).
|
|
3943
|
+
const native = __sbToBytesNative(input);
|
|
3944
|
+
if (native !== "") return native;
|
|
3939
3945
|
// Windowed accumulation (#180, then #181). Plain `s +=` in a loop is O(n^2)
|
|
3940
3946
|
// here, since Porffor's strings have no rope/cons optimization and every
|
|
3941
3947
|
// append copies the whole accumulated string -- that's #180, a 43KB body
|
|
@@ -4015,6 +4021,9 @@ function __sbBufFrom(latin1) {
|
|
|
4015
4021
|
// regardless of whether it holds real Latin-1 text or raw bytes, so this
|
|
4016
4022
|
// decode is only safe where the caller already knows which one it has.
|
|
4017
4023
|
function __sbFromUtf8(bytes) {
|
|
4024
|
+
// sproutd #22: one C pass for a string of bytes (see __sbFromUtf8Native).
|
|
4025
|
+
const native = __sbFromUtf8Native(bytes);
|
|
4026
|
+
if (native !== "") return native;
|
|
4018
4027
|
// Collected into an array and joined once at the end, not built with
|
|
4019
4028
|
// repeated `out +=` -- Porffor's strings have no rope/cons optimization, so
|
|
4020
4029
|
// `+=` in a loop copies the whole accumulated string on every append,
|
|
@@ -4672,8 +4681,168 @@ static int sb_os_random(unsigned char* buf, size_t n) {
|
|
|
4672
4681
|
return 0;
|
|
4673
4682
|
}
|
|
4674
4683
|
|
|
4684
|
+
// sproutd #22: text and bytes converted in one C pass. The JS loops in
|
|
4685
|
+
// __sbFromUtf8 and __sbToBytes made one string per character, so a 5 MiB R2
|
|
4686
|
+
// object read with text() took about 400 MiB of heap before the next collection.
|
|
4687
|
+
u32 porf_native_fetch_alloc(u32 bytes, u32 type);
|
|
4688
|
+
|
|
4689
|
+
// Lenient UTF-8 decode, exactly as __sbFromUtf8's JS: a lead byte whose
|
|
4690
|
+
// continuation bytes are not all 10xxxxxx passes through as its own unit.
|
|
4691
|
+
// OUT has room for LEN units. Returns the unit count; *WIDE is set when a unit
|
|
4692
|
+
// is above 0xff (the result then needs a UTF-16 string).
|
|
4693
|
+
static size_t sb_utf8_decode_units(const unsigned char* b, size_t len, uint16_t* out, int* wide) {
|
|
4694
|
+
size_t k = 0;
|
|
4695
|
+
*wide = 0;
|
|
4696
|
+
for (size_t i = 0; i < len; i++) {
|
|
4697
|
+
const unsigned b0 = b[i];
|
|
4698
|
+
if (b0 >= 0x80) {
|
|
4699
|
+
if ((b0 & 0xe0) == 0xc0 && i + 1 < len && (b[i + 1] & 0xc0) == 0x80) {
|
|
4700
|
+
out[k] = (uint16_t)(((b0 & 0x1f) << 6) | (b[i + 1] & 0x3f));
|
|
4701
|
+
if (out[k] > 0xff) *wide = 1;
|
|
4702
|
+
k++; i += 1; continue;
|
|
4703
|
+
}
|
|
4704
|
+
if ((b0 & 0xf0) == 0xe0 && i + 2 < len && (b[i + 1] & 0xc0) == 0x80 && (b[i + 2] & 0xc0) == 0x80) {
|
|
4705
|
+
out[k] = (uint16_t)(((b0 & 0x0f) << 12) | ((b[i + 1] & 0x3f) << 6) | (b[i + 2] & 0x3f));
|
|
4706
|
+
if (out[k] > 0xff) *wide = 1;
|
|
4707
|
+
k++; i += 2; continue;
|
|
4708
|
+
}
|
|
4709
|
+
if ((b0 & 0xf8) == 0xf0 && i + 3 < len && (b[i + 1] & 0xc0) == 0x80 && (b[i + 2] & 0xc0) == 0x80 && (b[i + 3] & 0xc0) == 0x80) {
|
|
4710
|
+
int32_t cp = (int32_t)(((b0 & 0x07) << 18) | ((b[i + 1] & 0x3f) << 12) | ((b[i + 2] & 0x3f) << 6) | (b[i + 3] & 0x3f));
|
|
4711
|
+
cp -= 0x10000;
|
|
4712
|
+
out[k++] = (uint16_t)(0xd800 + (cp >> 10));
|
|
4713
|
+
out[k++] = (uint16_t)(0xdc00 + (cp & 0x3ff));
|
|
4714
|
+
*wide = 1;
|
|
4715
|
+
i += 3; continue;
|
|
4716
|
+
}
|
|
4717
|
+
}
|
|
4718
|
+
out[k++] = (uint16_t)b0;
|
|
4719
|
+
}
|
|
4720
|
+
return k;
|
|
4721
|
+
}
|
|
4722
|
+
|
|
4723
|
+
// UTF-8 encode, exactly as __sbToBytes's JS: a high surrogate takes the next
|
|
4724
|
+
// unit as its pair. OUT has room for 3 bytes per unit. Returns the byte count.
|
|
4725
|
+
static size_t sb_utf8_encode_units(const uint16_t* s, size_t len, unsigned char* out) {
|
|
4726
|
+
size_t k = 0;
|
|
4727
|
+
for (size_t i = 0; i < len; i++) {
|
|
4728
|
+
const uint32_t c = s[i];
|
|
4729
|
+
if (c < 0x80) out[k++] = (unsigned char)c;
|
|
4730
|
+
else if (c < 0x800) { out[k++] = (unsigned char)(0xc0 | (c >> 6)); out[k++] = (unsigned char)(0x80 | (c & 0x3f)); }
|
|
4731
|
+
else if (c >= 0xd800 && c < 0xdc00 && i + 1 < len) {
|
|
4732
|
+
const int32_t cp = 0x10000 + (int32_t)((c - 0xd800) << 10) + ((int32_t)s[++i] - 0xdc00);
|
|
4733
|
+
out[k++] = (unsigned char)(0xf0 | ((cp >> 18) & 0xff));
|
|
4734
|
+
out[k++] = (unsigned char)(0x80 | ((cp >> 12) & 0x3f));
|
|
4735
|
+
out[k++] = (unsigned char)(0x80 | ((cp >> 6) & 0x3f));
|
|
4736
|
+
out[k++] = (unsigned char)(0x80 | (cp & 0x3f));
|
|
4737
|
+
} else {
|
|
4738
|
+
out[k++] = (unsigned char)(0xe0 | ((c >> 12) & 0xff));
|
|
4739
|
+
out[k++] = (unsigned char)(0x80 | ((c >> 6) & 0x3f));
|
|
4740
|
+
out[k++] = (unsigned char)(0x80 | (c & 0x3f));
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
return k;
|
|
4744
|
+
}
|
|
4745
|
+
|
|
4675
4746
|
`;
|
|
4676
4747
|
|
|
4748
|
+
// sproutd #22: __sbFromUtf8 for a string of bytes (one byte per unit) or a Uint8Array, in C.
|
|
4749
|
+
// Returns "" when it cannot (not a string, or out of memory), so the caller
|
|
4750
|
+
// falls back to the JS loop. All-ASCII byte strings are already their text.
|
|
4751
|
+
// oxlint-disable-next-line no-unused-vars -- `bytes` is read inside the RawC block below, not by JS.
|
|
4752
|
+
function __sbFromUtf8Native(bytes) {
|
|
4753
|
+
let out = "";
|
|
4754
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
4755
|
+
Porffor.c`
|
|
4756
|
+
const int __view = bytes.type == 81 && *(int32_t*)(MEM + (u32)bytes.val) >= 0;
|
|
4757
|
+
if (bytes.type == 195 || bytes.type == 67 || __view) {
|
|
4758
|
+
const u32 __p = __view ? *(u32*)(MEM + (u32)bytes.val + 4) : (u32)bytes.val;
|
|
4759
|
+
const size_t __n = __view ? (size_t)*(int32_t*)(MEM + (u32)bytes.val) : (size_t)*(u32*)(MEM + __p);
|
|
4760
|
+
int __ascii = bytes.type == 195;
|
|
4761
|
+
if (__ascii) {
|
|
4762
|
+
const unsigned char* __s = (const unsigned char*)(MEM + __p + 4);
|
|
4763
|
+
for (size_t __i = 0; __i < __n; __i++) if (__s[__i] >= 0x80) { __ascii = 0; break; }
|
|
4764
|
+
}
|
|
4765
|
+
if (__ascii) out = bytes;
|
|
4766
|
+
else {
|
|
4767
|
+
unsigned char* __b = (unsigned char*)malloc(__n + 1);
|
|
4768
|
+
uint16_t* __u = (uint16_t*)malloc(__n * 2 + 2);
|
|
4769
|
+
if (__b && __u) {
|
|
4770
|
+
if (bytes.type != 67) memcpy(__b, MEM + __p + 4, __n);
|
|
4771
|
+
else { const uint16_t* __w = (const uint16_t*)(MEM + __p + 4); for (size_t __i = 0; __i < __n; __i++) __b[__i] = (unsigned char)(__w[__i] & 0xff); }
|
|
4772
|
+
int __wide = 0;
|
|
4773
|
+
const size_t __k = sb_utf8_decode_units(__b, __n, __u, &__wide);
|
|
4774
|
+
if (__wide) {
|
|
4775
|
+
const u32 __s = porf_native_fetch_alloc((u32)(4 + __k * 2), 67);
|
|
4776
|
+
*(u32*)(MEM + __s) = (u32)__k;
|
|
4777
|
+
memcpy(MEM + __s + 4, __u, __k * 2);
|
|
4778
|
+
out = porf_box((f64)__s, 67);
|
|
4779
|
+
} else {
|
|
4780
|
+
for (size_t __i = 0; __i < __k; __i++) __b[__i] = (unsigned char)__u[__i];
|
|
4781
|
+
out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__b, __k), 195);
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
free(__b); free(__u);
|
|
4785
|
+
}
|
|
4786
|
+
}
|
|
4787
|
+
`;
|
|
4788
|
+
return out;
|
|
4789
|
+
}
|
|
4790
|
+
|
|
4791
|
+
// sproutd #22: __sbToBytes for a string, a Uint8Array or an ArrayBuffer, in C.
|
|
4792
|
+
// Returns "" when it cannot (another type, a length-tracking view, out of
|
|
4793
|
+
// memory), so the caller falls back to the JS loop.
|
|
4794
|
+
// oxlint-disable-next-line no-unused-vars -- `input` is read inside the RawC block below, not by JS.
|
|
4795
|
+
function __sbToBytesNative(input) {
|
|
4796
|
+
let out = "";
|
|
4797
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
4798
|
+
Porffor.c`
|
|
4799
|
+
const u32 __p = (u32)input.val;
|
|
4800
|
+
if (input.type == 195) {
|
|
4801
|
+
const size_t __n = (size_t)*(u32*)(MEM + __p);
|
|
4802
|
+
const unsigned char* __s = (const unsigned char*)(MEM + __p + 4);
|
|
4803
|
+
size_t __high = 0;
|
|
4804
|
+
for (size_t __i = 0; __i < __n; __i++) if (__s[__i] >= 0x80) __high++;
|
|
4805
|
+
if (!__high) out = input;
|
|
4806
|
+
else {
|
|
4807
|
+
unsigned char* __o = (unsigned char*)malloc(__n + __high + 1);
|
|
4808
|
+
if (__o) {
|
|
4809
|
+
const unsigned char* __src = (const unsigned char*)(MEM + __p + 4);
|
|
4810
|
+
size_t __k = 0;
|
|
4811
|
+
for (size_t __i = 0; __i < __n; __i++) {
|
|
4812
|
+
const unsigned char __c = __src[__i];
|
|
4813
|
+
if (__c < 0x80) __o[__k++] = __c;
|
|
4814
|
+
else { __o[__k++] = (unsigned char)(0xc0 | (__c >> 6)); __o[__k++] = (unsigned char)(0x80 | (__c & 0x3f)); }
|
|
4815
|
+
}
|
|
4816
|
+
out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__o, __k), 195);
|
|
4817
|
+
free(__o);
|
|
4818
|
+
}
|
|
4819
|
+
}
|
|
4820
|
+
} else if (input.type == 67) {
|
|
4821
|
+
const size_t __n = (size_t)*(u32*)(MEM + __p);
|
|
4822
|
+
uint16_t* __w = (uint16_t*)malloc(__n * 2 + 2);
|
|
4823
|
+
unsigned char* __o = (unsigned char*)malloc(__n * 3 + 1);
|
|
4824
|
+
if (__w && __o) {
|
|
4825
|
+
memcpy(__w, MEM + __p + 4, __n * 2);
|
|
4826
|
+
const size_t __k = sb_utf8_encode_units(__w, __n, __o);
|
|
4827
|
+
out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__o, __k), 195);
|
|
4828
|
+
}
|
|
4829
|
+
free(__w); free(__o);
|
|
4830
|
+
} else if (input.type == 13 || input.type == 81) {
|
|
4831
|
+
const int32_t __len = *(int32_t*)(MEM + __p);
|
|
4832
|
+
if (__len > 0) {
|
|
4833
|
+
const u32 __data = input.type == 13 ? __p + 8 : *(u32*)(MEM + __p + 4) + 4;
|
|
4834
|
+
unsigned char* __o = (unsigned char*)malloc((size_t)__len);
|
|
4835
|
+
if (__o) {
|
|
4836
|
+
memcpy(__o, MEM + __data, (size_t)__len);
|
|
4837
|
+
out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__o, (size_t)__len), 195);
|
|
4838
|
+
free(__o);
|
|
4839
|
+
}
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
`;
|
|
4843
|
+
return out;
|
|
4844
|
+
}
|
|
4845
|
+
|
|
4677
4846
|
// TRANSPORT: wrap.ts splices one of transport-broker.js / transport-embedded.js here.
|
|
4678
4847
|
|
|
4679
4848
|
// `nStr` is the decimal byte count as a string (same string-param pattern as
|
|
@@ -5218,6 +5387,15 @@ function __sbR2Object(meta, body) {
|
|
|
5218
5387
|
return obj;
|
|
5219
5388
|
}
|
|
5220
5389
|
|
|
5390
|
+
// A part's value for the transport: an ArrayBuffer or a view as its bytes (String() of one
|
|
5391
|
+
// stored "[object ArrayBuffer]", at every date), anything else as text, as before.
|
|
5392
|
+
function __sbR2PartValue(value) {
|
|
5393
|
+
value = __sbUnwrapBody(value);
|
|
5394
|
+
if (value == null) return "";
|
|
5395
|
+
if (!__sbIsStr(value) && value.byteLength !== undefined)
|
|
5396
|
+
return __sbToBytes(value.buffer !== undefined ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value));
|
|
5397
|
+
return String(value);
|
|
5398
|
+
}
|
|
5221
5399
|
function __sbR2Multipart(bucket, key, uploadId) {
|
|
5222
5400
|
return {
|
|
5223
5401
|
key,
|
|
@@ -5226,7 +5404,7 @@ function __sbR2Multipart(bucket, key, uploadId) {
|
|
|
5226
5404
|
const number = Number(partNumber);
|
|
5227
5405
|
if (!Number.isInteger(number) || number < 1 || number > 10000)
|
|
5228
5406
|
throw new TypeError("partNumber must be an integer between 1 and 10000");
|
|
5229
|
-
return __sbR2MultipartPut(bucket, key, uploadId, number,
|
|
5407
|
+
return __sbR2MultipartPut(bucket, key, uploadId, number, __sbR2PartValue(value)).part;
|
|
5230
5408
|
},
|
|
5231
5409
|
complete(parts) {
|
|
5232
5410
|
const r = __sbRpc("r2.multipart.complete", { bucket, key, uploadId, parts: parts || [] });
|
|
@@ -5361,7 +5539,16 @@ function __sbR2StrictObject(meta, bytes, range, include) {
|
|
|
5361
5539
|
used = true;
|
|
5362
5540
|
return bytes;
|
|
5363
5541
|
};
|
|
5364
|
-
|
|
5542
|
+
// The stream is made when first asked for: text() and friends read the bytes directly,
|
|
5543
|
+
// so an object read as text never holds a second copy (sproutd #22).
|
|
5544
|
+
let stream = null;
|
|
5545
|
+
Object.defineProperty(obj, "body", {
|
|
5546
|
+
get: () => {
|
|
5547
|
+
if (stream === null) stream = new Response(__sbBufFrom(bytes)).body;
|
|
5548
|
+
return stream;
|
|
5549
|
+
},
|
|
5550
|
+
enumerable: true,
|
|
5551
|
+
});
|
|
5365
5552
|
Object.defineProperty(obj, "bodyUsed", { get: () => used, enumerable: true });
|
|
5366
5553
|
obj.text = async () => __sbFromUtf8(take());
|
|
5367
5554
|
obj.json = async () => JSON.parse(__sbFromUtf8(take()));
|
|
@@ -5392,7 +5579,13 @@ function __sbMakeR2(name) {
|
|
|
5392
5579
|
return {
|
|
5393
5580
|
key,
|
|
5394
5581
|
uploadId,
|
|
5395
|
-
|
|
5582
|
+
// A part is any value put() takes; text is stored as its UTF-8 bytes, as in workerd.
|
|
5583
|
+
uploadPart: async (partNumber, value) => {
|
|
5584
|
+
value = __sbUnwrapBody(value);
|
|
5585
|
+
if (value instanceof Blob) value = await value.arrayBuffer();
|
|
5586
|
+
else if (value != null && __sbIsFn(value.getReader)) value = await new Response(value).arrayBuffer();
|
|
5587
|
+
return inner.uploadPart(partNumber, __sbIsStr(value) ? __sbToBytes(value) : value);
|
|
5588
|
+
},
|
|
5396
5589
|
// ponytail: a multipart object's etag stays the transport's sha256; workerd's is
|
|
5397
5590
|
// MD5-of-part-MD5s-N. Add when an app compares multipart etags.
|
|
5398
5591
|
complete: (parts) => attempt(() => __sbR2StrictObject(__sbRpc("r2.multipart.complete", { bucket: name, key, uploadId, parts: parts || [] }).object, null)),
|
|
@@ -5415,7 +5608,8 @@ function __sbMakeR2(name) {
|
|
|
5415
5608
|
if (!__sbR2OnlyIf(o.onlyIf, meta.md5 || meta.etag, meta.uploaded)) return __sbR2StrictObject(meta, null, { offset: 0, length: meta.size });
|
|
5416
5609
|
const bytes = r.body == null ? "" : r.body;
|
|
5417
5610
|
const range = o.range == null ? { offset: 0, length: meta.size } : __sbR2Range(o.range, meta.size);
|
|
5418
|
-
|
|
5611
|
+
const whole = range.offset === 0 && range.length >= bytes.length;
|
|
5612
|
+
return __sbR2StrictObject(meta, whole ? bytes : bytes.slice(range.offset, range.offset + range.length), range);
|
|
5419
5613
|
});
|
|
5420
5614
|
},
|
|
5421
5615
|
async put(key, value, options) {
|
|
@@ -5817,9 +6011,14 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
5817
6011
|
async function __sbServiceFetch(binding, input, init) {
|
|
5818
6012
|
const parts = __sbFetchParts(input, init);
|
|
5819
6013
|
if (parts.signal) parts.signal.throwIfAborted();
|
|
5820
|
-
// A stream body
|
|
6014
|
+
// A stream body goes out as it is read where the transport can (__sbStreamsBody), else it
|
|
6015
|
+
// is read to the end first.
|
|
5821
6016
|
const body = __sbUnwrapBody(parts.body);
|
|
5822
|
-
const wire =
|
|
6017
|
+
const wire = __sbStreamsBody(body)
|
|
6018
|
+
? { bodyStream: true, __sbBodySource: body }
|
|
6019
|
+
: body instanceof ReadableStream
|
|
6020
|
+
? __sbRequestBody(await __sbCollectBody(body))
|
|
6021
|
+
: __sbPartsBody({ ...parts, body });
|
|
5823
6022
|
const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...wire };
|
|
5824
6023
|
const response = await __sbFetchFollowing("service.fetch", msg, "sproutboat service " + binding, parts.redirect, parts.signal);
|
|
5825
6024
|
// The callee threw: as in workerd, its error is thrown here.
|
|
@@ -6077,7 +6276,52 @@ function __sbUpstreamResponse(r) {
|
|
|
6077
6276
|
// whole (a broker from before streaming, or sproutd's embedded transport) is handled
|
|
6078
6277
|
// as before. Cancelling the body, or the signal, drops the exchange, which stops the
|
|
6079
6278
|
// upstream request.
|
|
6279
|
+
// Streamed request bodies, from compatibility date 2026-10-27, on a transport that sends
|
|
6280
|
+
// them in frames (the broker's, on an async host, against a broker that takes them):
|
|
6281
|
+
// fetch() and service bindings send a ReadableStream body as it is read, instead of
|
|
6282
|
+
// reading it to the end first. As in workerd, a streamed body can't follow a 307 or 308.
|
|
6283
|
+
function __sbStreamsBody(body) {
|
|
6284
|
+
return (
|
|
6285
|
+
body instanceof ReadableStream &&
|
|
6286
|
+
globalThis.__sbCompat >= "2026-10-27" &&
|
|
6287
|
+
globalThis.__sbBodyStreamable != null &&
|
|
6288
|
+
globalThis.__sbBodyStreamable()
|
|
6289
|
+
);
|
|
6290
|
+
}
|
|
6291
|
+
// Reads source into the exchange under timer id `id`, a frame per chunk, while ended()
|
|
6292
|
+
// is false. Above 4 MiB not yet written it waits for the socket to catch up.
|
|
6293
|
+
async function __sbPumpRequestBody(id, source, ended) {
|
|
6294
|
+
const reader = source.getReader();
|
|
6295
|
+
try {
|
|
6296
|
+
for (;;) {
|
|
6297
|
+
const { value, done } = await reader.read();
|
|
6298
|
+
if (done) break;
|
|
6299
|
+
if (ended()) {
|
|
6300
|
+
reader.cancel();
|
|
6301
|
+
return;
|
|
6302
|
+
}
|
|
6303
|
+
let bytes = null;
|
|
6304
|
+
if (value instanceof Uint8Array) bytes = value;
|
|
6305
|
+
else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value);
|
|
6306
|
+
else if (ArrayBuffer.isView(value)) bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
6307
|
+
else throw new TypeError("This ReadableStream did not return bytes.");
|
|
6308
|
+
if (bytes.length === 0) continue;
|
|
6309
|
+
let backlog = Number(globalThis.__sbAsyncBody(id, "c", __sbToBytes(bytes)));
|
|
6310
|
+
while (backlog > 4 * 1024 * 1024 && !ended()) {
|
|
6311
|
+
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
6312
|
+
backlog = Number(globalThis.__sbAsyncBody(id, "q", ""));
|
|
6313
|
+
}
|
|
6314
|
+
}
|
|
6315
|
+
globalThis.__sbAsyncBody(id, "e", "");
|
|
6316
|
+
} catch (error) {
|
|
6317
|
+
globalThis.__sbAsyncBody(id, "x", "");
|
|
6318
|
+
__sbLogUncaught("fetch", error);
|
|
6319
|
+
}
|
|
6320
|
+
}
|
|
6321
|
+
|
|
6080
6322
|
function __sbUpstreamStreamed(op, msg, label, signal) {
|
|
6323
|
+
const source = msg.__sbBodySource;
|
|
6324
|
+
if (source !== undefined) delete msg.__sbBodySource;
|
|
6081
6325
|
return new Promise((resolve, reject) => {
|
|
6082
6326
|
let controller = null;
|
|
6083
6327
|
let onAbort = null;
|
|
@@ -6145,6 +6389,7 @@ function __sbUpstreamStreamed(op, msg, label, signal) {
|
|
|
6145
6389
|
msg.asyncId = id;
|
|
6146
6390
|
msg.stream = true;
|
|
6147
6391
|
globalThis.__sbFetchUpstream(op, msg);
|
|
6392
|
+
if (source !== undefined) __sbPumpRequestBody(id, source, () => done);
|
|
6148
6393
|
} catch (e) {
|
|
6149
6394
|
finish();
|
|
6150
6395
|
reject(e);
|
|
@@ -6182,7 +6427,8 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
|
|
|
6182
6427
|
next.method = "GET";
|
|
6183
6428
|
next.body = null;
|
|
6184
6429
|
delete next.bodyBinary;
|
|
6185
|
-
|
|
6430
|
+
delete next.bodyStream;
|
|
6431
|
+
} else if (msg.bodyStream === true) throw new TypeError(label + ": a streamed request body can't follow a " + res.status + " redirect");
|
|
6186
6432
|
return __sbFollowHop(op, next, label, mode, signal, hops + 1);
|
|
6187
6433
|
};
|
|
6188
6434
|
const out = __sbUpstream(op, msg, label, signal);
|
|
@@ -6242,6 +6488,7 @@ function __sbUpstream(op, msg, label, signal, always) {
|
|
|
6242
6488
|
// Worker's fetch. Installed by the generated module (wrap.ts), not here: the
|
|
6243
6489
|
// prelude must not replace fetch just by being evaluated.
|
|
6244
6490
|
globalThis.__sbInstallFetch = function () {
|
|
6491
|
+
__sbIngressStreamingSetup();
|
|
6245
6492
|
globalThis.fetch = function (input, init) {
|
|
6246
6493
|
let parts;
|
|
6247
6494
|
try {
|
|
@@ -6255,9 +6502,11 @@ globalThis.__sbInstallFetch = function () {
|
|
|
6255
6502
|
const msg = { url: parts.url, method: parts.method, headers: parts.headers, ...body };
|
|
6256
6503
|
return __sbFetchFollowing("fetch", msg, "sproutboat fetch", parts.redirect, parts.signal);
|
|
6257
6504
|
};
|
|
6258
|
-
// A stream body
|
|
6259
|
-
//
|
|
6505
|
+
// A stream body goes out as it is read, from 2026-10-27 where the transport can
|
|
6506
|
+
// (__sbStreamsBody); else it is read to the end first. It was sent as the text
|
|
6507
|
+
// "[object ReadableStream]".
|
|
6260
6508
|
const stream = __sbUnwrapBody(parts.body);
|
|
6509
|
+
if (__sbStreamsBody(stream)) return send({ bodyStream: true, __sbBodySource: stream });
|
|
6261
6510
|
if (stream instanceof ReadableStream) return __sbCollectBody(stream).then((bytes) => send(__sbRequestBody(bytes)));
|
|
6262
6511
|
return send(__sbPartsBody(parts));
|
|
6263
6512
|
};
|
|
@@ -6838,11 +7087,133 @@ async function __sbQueueWithDrain(result, tasks) {
|
|
|
6838
7087
|
return new Response(JSON.stringify(r), { headers: { "content-type": "application/json" } });
|
|
6839
7088
|
}
|
|
6840
7089
|
|
|
7090
|
+
// Streamed request bodies, from compatibility date 2026-10-27, on a native server that
|
|
7091
|
+
// streams them (a standalone or platform sprout; request-streaming.ts in the toolchain).
|
|
7092
|
+
// The handler runs once the headers are in, and request.body hands out the bytes as they
|
|
7093
|
+
// arrive: the server pauses the socket while more than 1 MiB waits unread. Before that
|
|
7094
|
+
// date the server buffers the whole body and the handler gets it at once, as before.
|
|
7095
|
+
// op: "e" enable (at startup), "t" take the current request's body id, "p" poll (a byte
|
|
7096
|
+
// count, "0" nothing yet, "-1" ended, "-2" failed), "i" copy into the Uint8Array in data,
|
|
7097
|
+
// "w" wait on the timer id in data, "c" close. Returns a short string.
|
|
7098
|
+
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block, not by JS.
|
|
7099
|
+
function __sbReqBodyOp(op, id, data) {
|
|
7100
|
+
let out = "";
|
|
7101
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
7102
|
+
Porffor.c`
|
|
7103
|
+
extern int sb_reqbody_enable(void);
|
|
7104
|
+
extern unsigned int sb_reqbody_take_current(void);
|
|
7105
|
+
extern long long sb_reqbody_poll(unsigned int);
|
|
7106
|
+
extern size_t sb_reqbody_take(unsigned int, char*, size_t);
|
|
7107
|
+
extern void sb_reqbody_wait(unsigned int, unsigned int);
|
|
7108
|
+
extern void sb_reqbody_close(unsigned int);
|
|
7109
|
+
const char* __o; size_t __ol; char* __oo = 0;
|
|
7110
|
+
porf_native_fetch_read_value(op, &__o, &__ol, &__oo);
|
|
7111
|
+
char __op = __ol ? __o[0] : 0;
|
|
7112
|
+
if (__oo) free(__oo);
|
|
7113
|
+
const unsigned int __id = id.type == 1 && id.val > 0 ? (unsigned int)id.val : 0;
|
|
7114
|
+
char __r[32]; __r[0] = 0;
|
|
7115
|
+
if (__op == 'e') snprintf(__r, sizeof(__r), "%d", sb_reqbody_enable());
|
|
7116
|
+
else if (__op == 't') snprintf(__r, sizeof(__r), "%u", sb_reqbody_take_current());
|
|
7117
|
+
else if (__op == 'p') snprintf(__r, sizeof(__r), "%lld", sb_reqbody_poll(__id));
|
|
7118
|
+
else if (__op == 'i' && data.type == 81) {
|
|
7119
|
+
const u32 __ta = (u32)data.val;
|
|
7120
|
+
const int32_t __len = *(int32_t*)(MEM + __ta);
|
|
7121
|
+
if (__len > 0) {
|
|
7122
|
+
const u32 __at = *(u32*)(MEM + __ta + 4) + 4;
|
|
7123
|
+
snprintf(__r, sizeof(__r), "%zu", sb_reqbody_take(__id, (char*)(MEM + __at), (size_t)__len));
|
|
7124
|
+
}
|
|
7125
|
+
} else if (__op == 'w' && data.type == 1 && data.val > 0) sb_reqbody_wait(__id, (unsigned int)data.val);
|
|
7126
|
+
else if (__op == 'c') sb_reqbody_close(__id);
|
|
7127
|
+
out = porf_box((f64)porf_native_fetch_alloc_bytestring(__r, strlen(__r)), 195);
|
|
7128
|
+
`;
|
|
7129
|
+
return out;
|
|
7130
|
+
}
|
|
7131
|
+
|
|
7132
|
+
// Turns streaming on in the native server, from 2026-10-27. Called once at startup, after
|
|
7133
|
+
// the compatibility date is set (__sbInstallFetch).
|
|
7134
|
+
function __sbIngressStreamingSetup() {
|
|
7135
|
+
if (globalThis.__sbCompat >= "2026-10-27") __sbReqBodyOp("e", 0, "");
|
|
7136
|
+
}
|
|
7137
|
+
|
|
7138
|
+
// request.body for a streamed request: chunks as the server receives them.
|
|
7139
|
+
function __sbIngressStream(request, id) {
|
|
7140
|
+
let closed = false;
|
|
7141
|
+
const close = () => {
|
|
7142
|
+
if (!closed) {
|
|
7143
|
+
closed = true;
|
|
7144
|
+
__sbReqBodyOp("c", id, "");
|
|
7145
|
+
}
|
|
7146
|
+
};
|
|
7147
|
+
const step = (controller, resolve) => {
|
|
7148
|
+
const state = Number(__sbReqBodyOp("p", id, ""));
|
|
7149
|
+
if (state === 0) {
|
|
7150
|
+
// Nothing yet: the server fires this timer when bytes, the end or an error arrive.
|
|
7151
|
+
const timer = setTimeout(() => step(controller, resolve), 2147483647);
|
|
7152
|
+
__sbReqBodyOp("w", id, timer);
|
|
7153
|
+
return;
|
|
7154
|
+
}
|
|
7155
|
+
if (state > 0) {
|
|
7156
|
+
const chunk = new Uint8Array(state);
|
|
7157
|
+
__sbReqBodyOp("i", id, chunk);
|
|
7158
|
+
controller.enqueue(chunk);
|
|
7159
|
+
} else {
|
|
7160
|
+
close();
|
|
7161
|
+
if (state === -1) controller.close();
|
|
7162
|
+
else controller.error(new TypeError("Network connection lost."));
|
|
7163
|
+
}
|
|
7164
|
+
resolve();
|
|
7165
|
+
};
|
|
7166
|
+
const stream = new ReadableStream({
|
|
7167
|
+
pull(controller) {
|
|
7168
|
+
return new Promise((resolve) => step(controller, resolve));
|
|
7169
|
+
},
|
|
7170
|
+
cancel() {
|
|
7171
|
+
close();
|
|
7172
|
+
},
|
|
7173
|
+
});
|
|
7174
|
+
request.body = stream;
|
|
7175
|
+
// The bytes of the whole body, read through the stream (so bodyUsed and locking hold).
|
|
7176
|
+
const all = async () => {
|
|
7177
|
+
const body = request.body;
|
|
7178
|
+
if (request.bodyUsed) throw new TypeError("Body has already been used. It can only be used once. Use tee() first if you need to read it twice.");
|
|
7179
|
+
request._bodyUsed = true;
|
|
7180
|
+
const reader = body.getReader();
|
|
7181
|
+
const chunks = [];
|
|
7182
|
+
let size = 0;
|
|
7183
|
+
for (;;) {
|
|
7184
|
+
const { value, done } = await reader.read();
|
|
7185
|
+
if (done) break;
|
|
7186
|
+
chunks.push(value);
|
|
7187
|
+
size += value.length;
|
|
7188
|
+
}
|
|
7189
|
+
if (chunks.length === 1) return chunks[0];
|
|
7190
|
+
const out = new Uint8Array(size);
|
|
7191
|
+
let at = 0;
|
|
7192
|
+
for (const chunk of chunks) {
|
|
7193
|
+
out.set(chunk, at);
|
|
7194
|
+
at += chunk.length;
|
|
7195
|
+
}
|
|
7196
|
+
return out;
|
|
7197
|
+
};
|
|
7198
|
+
request.text = async () => __sbFromUtf8(await all());
|
|
7199
|
+
request.json = async () => JSON.parse(__sbFromUtf8(await all()));
|
|
7200
|
+
request.arrayBuffer = async () => {
|
|
7201
|
+
const bytes = await all();
|
|
7202
|
+
return bytes.byteLength === bytes.buffer.byteLength ? bytes.buffer : bytes.slice().buffer;
|
|
7203
|
+
};
|
|
7204
|
+
request.bytes = async () => await all();
|
|
7205
|
+
}
|
|
7206
|
+
|
|
6841
7207
|
// Native HTTP ingress stores the body as one character per raw byte. Keep
|
|
6842
7208
|
// that representation for binary consumers, but decode text lazily before
|
|
6843
7209
|
// handing the request to user code. Request.text() in Porffor otherwise treats
|
|
6844
7210
|
// the raw bytes as already-decoded characters and corrupts Unicode/HMAC input.
|
|
6845
7211
|
function __sbDecodeIngress(request) {
|
|
7212
|
+
const streamed = globalThis.__sbCompat >= "2026-10-27" ? Number(__sbReqBodyOp("t", 0, "")) : 0;
|
|
7213
|
+
if (streamed > 0) {
|
|
7214
|
+
__sbIngressStream(request, streamed);
|
|
7215
|
+
return;
|
|
7216
|
+
}
|
|
6846
7217
|
const raw = __sbRawBody(request);
|
|
6847
7218
|
if (raw == null) return;
|
|
6848
7219
|
// A GET or HEAD has no body, as in workerd (from 2026-10-23): request.body is null, and
|
package/src/r2-object.test.js
CHANGED
|
@@ -18,6 +18,9 @@ const rawStart = src.indexOf("function __sbRawBodyResponse(");
|
|
|
18
18
|
const utf8End = rawStart;
|
|
19
19
|
const rawEnd = src.indexOf("function __sbEqCt(");
|
|
20
20
|
const r2Start = src.indexOf("function __sbR2Object(");
|
|
21
|
+
const isStrStart = src.indexOf("function __sbIsStr(");
|
|
22
|
+
const isStrEnd = src.indexOf("function __sbIsFn(");
|
|
23
|
+
const bytesStart = src.indexOf("const __SB_BYTES_WINDOW");
|
|
21
24
|
const r2End = src.indexOf("// Installed only when the project declares bindings.");
|
|
22
25
|
if (utf8Start === -1 || utf8End === -1) throw new Error("__sbFromUtf8 block not found in prelude");
|
|
23
26
|
if (rawStart === -1 || rawEnd === -1) throw new Error("__sbRawBodyResponse block not found in prelude");
|
|
@@ -27,7 +30,8 @@ if (r2Start === -1 || r2End === -1 || r2End < r2Start) throw new Error("__sbR2Ob
|
|
|
27
30
|
const load = new Function(
|
|
28
31
|
"__sbRpc",
|
|
29
32
|
"__sbR2MultipartPut",
|
|
30
|
-
|
|
33
|
+
// __sbIsStr and __sbToBytes (for uploadPart's values), with the C fast paths stubbed out.
|
|
34
|
+
`function __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\n${src.slice(isStrStart, isStrEnd)}\n${src.slice(bytesStart, utf8Start)}\n${src.slice(utf8Start, utf8End)}\n${src.slice(rawStart, rawEnd)}\n${src.slice(r2Start, r2End)}\nreturn { __sbR2Object, __sbR2Multipart };`,
|
|
31
35
|
);
|
|
32
36
|
const calls = [];
|
|
33
37
|
const rpc = (op, value) => {
|
|
@@ -70,7 +74,7 @@ test("toResponse() lets caller-supplied headers/status override the defaults", (
|
|
|
70
74
|
expect(resp.headers.get("content-type")).toBe("application/octet-stream");
|
|
71
75
|
});
|
|
72
76
|
|
|
73
|
-
test(
|
|
77
|
+
test('toResponse() on an empty body still returns bytes, not the string ""', async () => {
|
|
74
78
|
const obj = __sbR2Object(meta, "");
|
|
75
79
|
const resp = obj.toResponse();
|
|
76
80
|
const buf = new Uint8Array(await resp.arrayBuffer());
|
|
@@ -84,9 +88,14 @@ test("multipart uploads expose the Cloudflare-shaped uploadPart, complete, and a
|
|
|
84
88
|
expect(upload.uploadPart(1, "first")).toEqual({ partNumber: 1, etag: "part-1" });
|
|
85
89
|
expect(upload.complete([{ partNumber: 1, etag: "part-1" }])).toMatchObject(meta);
|
|
86
90
|
upload.abort();
|
|
87
|
-
expect(calls.map((call) => call.op)).toEqual([
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
expect(calls.map((call) => call.op)).toEqual(["r2.multipart.put", "r2.multipart.complete", "r2.multipart.abort"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("uploadPart stores an ArrayBuffer or a view as its bytes, not as [object ArrayBuffer]", () => {
|
|
95
|
+
calls.length = 0;
|
|
96
|
+
const upload = __sbR2Multipart("FILES", "archive.bin", "upload-1");
|
|
97
|
+
upload.uploadPart(1, new Uint8Array([0, 255, 65]).buffer);
|
|
98
|
+
upload.uploadPart(2, new Uint8Array([9, 8, 7, 6]).subarray(1, 3));
|
|
99
|
+
upload.uploadPart(3, "text");
|
|
100
|
+
expect(calls.map((call) => call.value.body)).toEqual(["\u0000ÿA", "\u0008\u0007", "text"]);
|
|
92
101
|
});
|
package/src/to-bytes.test.js
CHANGED
|
@@ -22,7 +22,7 @@ if (hexStart === -1 || hexEnd === -1 || hexEnd < hexStart) throw new Error("__sb
|
|
|
22
22
|
|
|
23
23
|
// oxlint-disable-next-line no-function-constructor -- lifting pure blocks out of the text-only prelude for test
|
|
24
24
|
const load = new Function(
|
|
25
|
-
|
|
25
|
+
`// The C fast paths (Porffor.c) cannot run here: the stubs make the JS fallback run.\nfunction __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\n${src.slice(isStrStart, isStrEnd)}\n${src.slice(start, end)}\n${src.slice(hexStart, hexEnd)}\nreturn { __sbToBytes, __sbHexOrBytes };`,
|
|
26
26
|
);
|
|
27
27
|
const { __sbToBytes, __sbHexOrBytes } = load();
|
|
28
28
|
|
package/src/transport-broker.js
CHANGED
|
@@ -335,6 +335,11 @@ typedef struct sb_async_job {
|
|
|
335
335
|
int fd, reused, attempt, connecting;
|
|
336
336
|
// Asked for a streamed response; and has delivered part of one, so never retried.
|
|
337
337
|
int stream, delivered;
|
|
338
|
+
// Its request body follows the head in frames (sb_async_body), from compatibility date
|
|
339
|
+
// 2026-10-27: never retried, never pooled, and it reads replies while it still writes.
|
|
340
|
+
int body_stream;
|
|
341
|
+
// Bytes of body frames in out not yet written, counted in sb_async_body_backlog.
|
|
342
|
+
size_t body_unsent;
|
|
338
343
|
long long retry_at; // ms; 0 = not waiting to retry
|
|
339
344
|
char* json; size_t json_len;
|
|
340
345
|
char* bin; size_t bin_len;
|
|
@@ -357,6 +362,15 @@ static int sb_io_started = 0, sb_io_wake[2] = { -1, -1 };
|
|
|
357
362
|
static u32* sb_async_cancels = 0;
|
|
358
363
|
static size_t sb_async_cancels_n = 0, sb_async_cancels_cap = 0;
|
|
359
364
|
static void (*sb_async_waker)(void) = 0;
|
|
365
|
+
// Request-body frames from the loop (sb_async_body), for the I/O thread to append to
|
|
366
|
+
// their exchanges; and the bytes of them not yet written, which the JS pump keeps low.
|
|
367
|
+
typedef struct sb_async_bodyframe {
|
|
368
|
+
struct sb_async_bodyframe* next;
|
|
369
|
+
u32 id;
|
|
370
|
+
unsigned char* data; size_t len;
|
|
371
|
+
} sb_async_bodyframe;
|
|
372
|
+
static sb_async_bodyframe *sb_async_bodyq = 0, *sb_async_bodyq_tail = 0;
|
|
373
|
+
static size_t sb_async_body_backlog = 0;
|
|
360
374
|
|
|
361
375
|
void sb_async_set_waker(void (*wake)(void)) { sb_async_waker = wake; }
|
|
362
376
|
int sb_async_enabled(void) { return sb_async_waker != 0; }
|
|
@@ -392,6 +406,11 @@ static void sb_async_deliver(u32 id, int final, char* json, size_t json_len, cha
|
|
|
392
406
|
|
|
393
407
|
// Hands a finished job (json set) back to the loop, and frees it.
|
|
394
408
|
static void sb_async_finish(sb_async_job* j) {
|
|
409
|
+
if (j->body_unsent) {
|
|
410
|
+
pthread_mutex_lock(&sb_async_lock);
|
|
411
|
+
sb_async_body_backlog -= j->body_unsent;
|
|
412
|
+
pthread_mutex_unlock(&sb_async_lock);
|
|
413
|
+
}
|
|
395
414
|
sb_async_deliver(j->id, 1, j->json, j->json_len, j->bin, j->bin_len);
|
|
396
415
|
free(j->out);
|
|
397
416
|
free(j->in);
|
|
@@ -440,7 +459,7 @@ static void sb_io_begin(sb_async_job* j, int* idle, int* idle_n) {
|
|
|
440
459
|
static int sb_io_failed(sb_async_job* j, int rc) {
|
|
441
460
|
if (j->fd >= 0) { close(j->fd); j->fd = -1; }
|
|
442
461
|
int unsent = j->out_off < j->out_len;
|
|
443
|
-
if (!j->delivered && (unsent || j->reused) && ++j->attempt < 4) {
|
|
462
|
+
if (!j->delivered && !j->body_stream && (unsent || j->reused) && ++j->attempt < 4) {
|
|
444
463
|
static const long ms[4] = { 0, 5, 25, 100 };
|
|
445
464
|
j->retry_at = sb_now_ms() + ms[j->attempt];
|
|
446
465
|
return 0;
|
|
@@ -458,9 +477,26 @@ static int sb_io_step(sb_async_job* j, int* idle, int* idle_n) {
|
|
|
458
477
|
}
|
|
459
478
|
while (j->out_off < j->out_len) {
|
|
460
479
|
long n = write(j->fd, j->out + j->out_off, j->out_len - j->out_off);
|
|
461
|
-
if (n > 0) {
|
|
480
|
+
if (n > 0) {
|
|
481
|
+
j->out_off += (size_t)n;
|
|
482
|
+
if (j->body_stream) {
|
|
483
|
+
// Body bytes sit at the end of out: whatever is left unsent beyond them was the head.
|
|
484
|
+
size_t left = j->out_len - j->out_off;
|
|
485
|
+
if (left < j->body_unsent) {
|
|
486
|
+
pthread_mutex_lock(&sb_async_lock);
|
|
487
|
+
sb_async_body_backlog -= j->body_unsent - left;
|
|
488
|
+
pthread_mutex_unlock(&sb_async_lock);
|
|
489
|
+
j->body_unsent = left;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
462
494
|
if (n < 0 && errno == EINTR) continue;
|
|
463
|
-
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
|
|
495
|
+
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
|
|
496
|
+
// A streamed body: the reply may come before the body is all out, so read too.
|
|
497
|
+
if (j->body_stream) break;
|
|
498
|
+
return 0;
|
|
499
|
+
}
|
|
464
500
|
return sb_io_failed(j, -3);
|
|
465
501
|
}
|
|
466
502
|
for (;;) {
|
|
@@ -497,7 +533,8 @@ static int sb_io_step(sb_async_job* j, int* idle, int* idle_n) {
|
|
|
497
533
|
sb_async_deliver(j->id, 0, json, json_len, bin, bin_len);
|
|
498
534
|
continue;
|
|
499
535
|
}
|
|
500
|
-
|
|
536
|
+
// A streamed body may still have frames on the way: that connection is not reused.
|
|
537
|
+
if (*idle_n < SB_IO_IDLE && !j->body_stream) idle[(*idle_n)++] = j->fd; else close(j->fd);
|
|
501
538
|
j->fd = -1;
|
|
502
539
|
j->json = sb_io_reply_json(payload, payload_len, &j->json_len, &j->bin, &j->bin_len);
|
|
503
540
|
if (!j->json) sb_async_error(j, -4);
|
|
@@ -525,6 +562,35 @@ static void* sb_io_thread(void* unused) {
|
|
|
525
562
|
j->next = active;
|
|
526
563
|
active = j;
|
|
527
564
|
}
|
|
565
|
+
// Request-body frames: appended to their exchanges (an ended one drops them).
|
|
566
|
+
pthread_mutex_lock(&sb_async_lock);
|
|
567
|
+
sb_async_bodyframe* frames = sb_async_bodyq;
|
|
568
|
+
sb_async_bodyq = sb_async_bodyq_tail = 0;
|
|
569
|
+
pthread_mutex_unlock(&sb_async_lock);
|
|
570
|
+
while (frames) {
|
|
571
|
+
sb_async_bodyframe* bf = frames;
|
|
572
|
+
frames = bf->next;
|
|
573
|
+
sb_async_job* j = active;
|
|
574
|
+
while (j && j->id != bf->id) j = j->next;
|
|
575
|
+
if (j && j->body_stream) {
|
|
576
|
+
if (j->out_off > 0) {
|
|
577
|
+
memmove(j->out, j->out + j->out_off, j->out_len - j->out_off);
|
|
578
|
+
j->out_len -= j->out_off;
|
|
579
|
+
j->out_off = 0;
|
|
580
|
+
}
|
|
581
|
+
j->out = (unsigned char*)realloc(j->out, j->out_len + bf->len);
|
|
582
|
+
if (!j->out) abort();
|
|
583
|
+
memcpy(j->out + j->out_len, bf->data, bf->len);
|
|
584
|
+
j->out_len += bf->len;
|
|
585
|
+
j->body_unsent += bf->len;
|
|
586
|
+
} else {
|
|
587
|
+
pthread_mutex_lock(&sb_async_lock);
|
|
588
|
+
sb_async_body_backlog -= bf->len;
|
|
589
|
+
pthread_mutex_unlock(&sb_async_lock);
|
|
590
|
+
}
|
|
591
|
+
free(bf->data);
|
|
592
|
+
free(bf);
|
|
593
|
+
}
|
|
528
594
|
// Exchanges given up on: their connections close, which tells the broker to stop.
|
|
529
595
|
pthread_mutex_lock(&sb_async_lock);
|
|
530
596
|
u32* cancels = sb_async_cancels;
|
|
@@ -572,7 +638,9 @@ static void* sb_io_thread(void* unused) {
|
|
|
572
638
|
for (sb_async_job* j = active; j; j = j->next) {
|
|
573
639
|
if (j->retry_at) continue;
|
|
574
640
|
fds[n].fd = j->fd;
|
|
575
|
-
fds[n].events = j->connecting
|
|
641
|
+
fds[n].events = j->connecting ? POLLOUT
|
|
642
|
+
: j->body_stream ? (short)(POLLIN | (j->out_off < j->out_len ? POLLOUT : 0))
|
|
643
|
+
: j->out_off < j->out_len ? POLLOUT : POLLIN;
|
|
576
644
|
fds[n].revents = 0;
|
|
577
645
|
owners[n++] = j;
|
|
578
646
|
}
|
|
@@ -595,11 +663,13 @@ static void* sb_io_thread(void* unused) {
|
|
|
595
663
|
|
|
596
664
|
// The inline C of __sbCallAsync calls this by prototype: Porffor may emit a
|
|
597
665
|
// function's body before this block.
|
|
666
|
+
// stream: bit 1 a streamed response may come; bit 2 the request body follows in frames.
|
|
598
667
|
void sb_async_submit_v1(u32 id, int stream, const char* json, size_t json_len, const char* body, size_t body_len) {
|
|
599
668
|
sb_async_job* j = (sb_async_job*)calloc(1, sizeof(sb_async_job));
|
|
600
669
|
if (!j) abort();
|
|
601
670
|
j->id = id;
|
|
602
|
-
j->stream = stream;
|
|
671
|
+
j->stream = stream & 1;
|
|
672
|
+
j->body_stream = (stream & 2) != 0;
|
|
603
673
|
j->fd = -1;
|
|
604
674
|
size_t req_len = 0;
|
|
605
675
|
char* req = sb_v1_request(json, json_len, body, body_len, &req_len);
|
|
@@ -652,6 +722,44 @@ void sb_async_submit_v1(u32 id, int stream, const char* json, size_t json_len, c
|
|
|
652
722
|
while (write(sb_io_wake[1], &one, 1) < 0 && errno == EINTR) {}
|
|
653
723
|
}
|
|
654
724
|
|
|
725
|
+
// One frame of exchange id's streamed request body: kind 0 a chunk of data, 1 the end,
|
|
726
|
+
// 2 a failure; 3 queues nothing. Returns the bytes of body frames not yet written, all
|
|
727
|
+
// exchanges together.
|
|
728
|
+
size_t sb_async_body(u32 id, int kind, const char* data, size_t len) {
|
|
729
|
+
if (kind == 3) {
|
|
730
|
+
pthread_mutex_lock(&sb_async_lock);
|
|
731
|
+
size_t backlog = sb_async_body_backlog;
|
|
732
|
+
pthread_mutex_unlock(&sb_async_lock);
|
|
733
|
+
return backlog;
|
|
734
|
+
}
|
|
735
|
+
const char* json = kind == 0 ? "{\"chunk\":true}" : kind == 1 ? "{\"end\":true}" : "{\"error\":\"the request body stream failed\"}";
|
|
736
|
+
const size_t json_len = strlen(json), payload = 1 + 4 + json_len + (kind == 0 ? len : 0);
|
|
737
|
+
sb_async_bodyframe* bf = (sb_async_bodyframe*)calloc(1, sizeof(sb_async_bodyframe));
|
|
738
|
+
if (!bf) abort();
|
|
739
|
+
bf->id = id;
|
|
740
|
+
bf->len = 4 + payload;
|
|
741
|
+
bf->data = (unsigned char*)malloc(bf->len);
|
|
742
|
+
if (!bf->data) abort();
|
|
743
|
+
for (int k = 0; k < 4; k++) bf->data[k] = (unsigned char)((payload >> (8 * k)) & 0xff);
|
|
744
|
+
bf->data[4] = 2; // FRAME_V1_MORE
|
|
745
|
+
for (int k = 0; k < 4; k++) bf->data[5 + k] = (unsigned char)((json_len >> (8 * k)) & 0xff);
|
|
746
|
+
memcpy(bf->data + 9, json, json_len);
|
|
747
|
+
if (kind == 0 && len) memcpy(bf->data + 9 + json_len, data, len);
|
|
748
|
+
pthread_mutex_lock(&sb_async_lock);
|
|
749
|
+
int started = sb_io_started;
|
|
750
|
+
if (started) {
|
|
751
|
+
if (sb_async_bodyq_tail) sb_async_bodyq_tail->next = bf; else sb_async_bodyq = bf;
|
|
752
|
+
sb_async_bodyq_tail = bf;
|
|
753
|
+
sb_async_body_backlog += bf->len;
|
|
754
|
+
}
|
|
755
|
+
size_t backlog = sb_async_body_backlog;
|
|
756
|
+
pthread_mutex_unlock(&sb_async_lock);
|
|
757
|
+
if (!started) { free(bf->data); free(bf); return 0; }
|
|
758
|
+
char one = 1;
|
|
759
|
+
while (write(sb_io_wake[1], &one, 1) < 0 && errno == EINTR) {}
|
|
760
|
+
return backlog;
|
|
761
|
+
}
|
|
762
|
+
|
|
655
763
|
// The caller gave up on exchange id: the I/O thread drops it and closes its
|
|
656
764
|
// connection. One that already finished is dropped by the drain instead, since its
|
|
657
765
|
// placeholder timer is gone.
|
|
@@ -849,7 +957,8 @@ globalThis.__sbFetchUpstream = function (op, msg) {
|
|
|
849
957
|
// Queued (async host); the reply reaches the callback registered under asyncId, in
|
|
850
958
|
// several frames when msg.stream asks for a streamed response.
|
|
851
959
|
if (msg.asyncId > 0) {
|
|
852
|
-
|
|
960
|
+
// "1" a streamed response may come, "2" the body follows in frames (__sbAsyncBody).
|
|
961
|
+
__sbCallAsync(JSON.stringify(req), "", msg.asyncId, String((msg.stream === true ? 1 : 0) + (msg.bodyStream === true ? 2 : 0)));
|
|
853
962
|
return { ok: true, pending: true };
|
|
854
963
|
}
|
|
855
964
|
const reply = JSON.parse(__sbCallBin(JSON.stringify(req), ""));
|
|
@@ -876,7 +985,7 @@ function __sbCallAsync(reqJson, body, asyncId, stream) {
|
|
|
876
985
|
}
|
|
877
986
|
const char* __s; size_t __sl; char* __so = 0;
|
|
878
987
|
porf_native_fetch_read_value(stream, &__s, &__sl, &__so);
|
|
879
|
-
const int __stream = __sl > 0 && __s[0]
|
|
988
|
+
const int __stream = __sl > 0 && __s[0] >= '0' && __s[0] <= '3' ? __s[0] - '0' : 0;
|
|
880
989
|
if (__so) free(__so);
|
|
881
990
|
if (asyncId.type == 1 && asyncId.val > 0) sb_async_submit_v1((u32)asyncId.val, __stream, __j, __jl, __b, __bl);
|
|
882
991
|
if (__jo) free(__jo);
|
|
@@ -895,6 +1004,48 @@ function __sbCancelAsync(asyncId) {
|
|
|
895
1004
|
`;
|
|
896
1005
|
}
|
|
897
1006
|
|
|
1007
|
+
// One frame of the streamed request body of the exchange under timer id `asyncId`:
|
|
1008
|
+
// kind "c" a chunk (data, one char per byte), "e" the end, "x" a failure, "q" nothing. Returns the
|
|
1009
|
+
// bytes of body frames not yet written, as a string.
|
|
1010
|
+
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
1011
|
+
function __sbAsyncBody(asyncId, kind, data) {
|
|
1012
|
+
let res = "";
|
|
1013
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
1014
|
+
Porffor.c`
|
|
1015
|
+
size_t sb_async_body(u32, int, const char*, size_t);
|
|
1016
|
+
const char* __k; size_t __kl; char* __ko = 0;
|
|
1017
|
+
porf_native_fetch_read_value(kind, &__k, &__kl, &__ko);
|
|
1018
|
+
const int __kind = __kl && __k[0] == 'c' ? 0 : __kl && __k[0] == 'e' ? 1 : __kl && __k[0] == 'q' ? 3 : 2;
|
|
1019
|
+
if (__ko) free(__ko);
|
|
1020
|
+
const char* __d = 0; size_t __dl = 0;
|
|
1021
|
+
if (__kind == 0 && porf_native_fetch_read_raw_bytes(data, &__d, &__dl) != 0) { __d = 0; __dl = 0; }
|
|
1022
|
+
size_t __backlog = asyncId.type == 1 && asyncId.val > 0 ? sb_async_body((u32)asyncId.val, __kind, __d, __dl) : 0;
|
|
1023
|
+
char __r[32];
|
|
1024
|
+
int __n = snprintf(__r, sizeof(__r), "%zu", __backlog);
|
|
1025
|
+
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__r, (size_t)__n), 195);
|
|
1026
|
+
`;
|
|
1027
|
+
return res;
|
|
1028
|
+
}
|
|
1029
|
+
globalThis.__sbAsyncBody = __sbAsyncBody;
|
|
1030
|
+
|
|
1031
|
+
// Whether request bodies can stream to the broker (2026-10-27): an async host, and a
|
|
1032
|
+
// broker that says it takes them (bodyStream in its pong). Asked once.
|
|
1033
|
+
let __sbBodyStreamCache = -1;
|
|
1034
|
+
globalThis.__sbBodyStreamable = function () {
|
|
1035
|
+
if (__sbBodyStreamCache < 0) {
|
|
1036
|
+
__sbBodyStreamCache = 0;
|
|
1037
|
+
if (__sbHttpAsyncEnabled()) {
|
|
1038
|
+
try {
|
|
1039
|
+
const pong = JSON.parse(__sbCall(JSON.stringify({ op: "ping" })));
|
|
1040
|
+
if (pong.bodyStream === true) __sbBodyStreamCache = 1;
|
|
1041
|
+
} catch {
|
|
1042
|
+
// An old broker or none: bodies are sent whole.
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
return __sbBodyStreamCache === 1;
|
|
1047
|
+
};
|
|
1048
|
+
|
|
898
1049
|
// Before compatibility date 2026-10-22, fetch() through the broker answered at once.
|
|
899
1050
|
function __sbAsyncAtAnyDate() {
|
|
900
1051
|
return false;
|
package/src/utf8-decode.test.js
CHANGED
|
@@ -12,7 +12,9 @@ const end = src.indexOf("// sproutboat #181 — wrap a Response");
|
|
|
12
12
|
if (start === -1 || end === -1 || end < start) throw new Error("__sbFromUtf8 block not found in prelude");
|
|
13
13
|
|
|
14
14
|
// oxlint-disable-next-line no-function-constructor -- lifting a pure block out of the text-only prelude for test
|
|
15
|
-
const load = new Function(
|
|
15
|
+
const load = new Function(
|
|
16
|
+
`// The C fast paths (Porffor.c) cannot run here: the stubs make the JS fallback run.\nfunction __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\n${src.slice(start, end)}\nreturn { __sbFromUtf8 };`,
|
|
17
|
+
);
|
|
16
18
|
const { __sbFromUtf8 } = load();
|
|
17
19
|
|
|
18
20
|
/** Build a bytestring (one char per raw byte) the way __sbToBytes/porf_native_fetch_alloc_bytestring would, from real UTF-8 bytes. */
|
|
@@ -78,7 +80,7 @@ if (rbrStart === -1 || rbrEnd === -1 || rbrEnd < rbrStart) {
|
|
|
78
80
|
// oxlint-disable-next-line no-function-constructor -- lifting a pure block out of the text-only prelude for test
|
|
79
81
|
const loadRbr = new Function(
|
|
80
82
|
"Response",
|
|
81
|
-
|
|
83
|
+
`// The C fast paths (Porffor.c) cannot run here: the stubs make the JS fallback run.\nfunction __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\n${src.slice(start, end)}\n${src.slice(rbrStart, rbrEnd)}\nreturn { __sbRawBodyResponse };`,
|
|
82
84
|
);
|
|
83
85
|
// A minimal Response stand-in: only what __sbRawBodyResponse touches (the
|
|
84
86
|
// constructor call and setting .text/.json on the instance), no Porffor
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
|
+
|
|
8
|
+
// sproutd #22: __sbFromUtf8 and __sbToBytes run in C (__sbFromUtf8Native, __sbToBytesNative).
|
|
9
|
+
// They must give exactly what the JS loops gave, and a body of megabytes must not take
|
|
10
|
+
// hundreds of megabytes of heap (the JS made one string per character: a 5 MiB R2 object
|
|
11
|
+
// read with text() took about 400 MiB).
|
|
12
|
+
const handler = `const bytes = (list) => { let s = ""; for (const b of list) s += String.fromCharCode(b); return s; };
|
|
13
|
+
const codes = (s) => { const out = []; for (let i = 0; i < s.length; i++) out.push(s.charCodeAt(i)); return out; };
|
|
14
|
+
export default {fetch(request) {
|
|
15
|
+
if (new URL(request.url).pathname === "/big") {
|
|
16
|
+
const text = __sbFromUtf8("\\u00c3\\u00a9".repeat(4 * 1024 * 1024));
|
|
17
|
+
const utf8 = __sbToBytes("\\u2713".repeat(1024 * 1024));
|
|
18
|
+
const ascii = __sbToBytes("a".repeat(8 * 1024 * 1024));
|
|
19
|
+
return new Response(JSON.stringify([text.length, text.charCodeAt(0), utf8.length, utf8.charCodeAt(2), ascii.length]));
|
|
20
|
+
}
|
|
21
|
+
const decode = [
|
|
22
|
+
"hello", bytes([0x63, 0x61, 0x66, 0xc3, 0xa9, 0x20, 0xf0, 0x9f, 0x8c, 0xb1, 0x20, 0xe2, 0x9c, 0x93]),
|
|
23
|
+
bytes([0xc2]), bytes([0xe9, 0x41]), bytes([0xf0, 0x9f, 0x8c]), bytes([0xe2, 0x28, 0xa1]), bytes([0xc0, 0x80]),
|
|
24
|
+
bytes([0xf8, 0x88, 0x80, 0x80, 0x80]), bytes([0xed, 0xa0, 0x80]), bytes([0xf4, 0x90, 0x80, 0x80]), bytes([0xff, 0xfe, 0x00]),
|
|
25
|
+
JSON.parse('"caf\\\\u00c3\\\\u00a9 \\\\u0100"'), JSON.parse('"\\\\u00e2\\\\u009c\\\\u0093"'), "",
|
|
26
|
+
];
|
|
27
|
+
const encode = [
|
|
28
|
+
"hello", "caf\\u00e9", "\\u2713 \\ud83c\\udf31", "\\ud800x", "\\udc00", "a\\ud800", "\\u0100\\u07ff\\u0800\\uffff", "",
|
|
29
|
+
new Uint8Array([0, 255, 128]), new Uint8Array([1, 2, 3]).buffer, new Uint8Array(new Uint8Array([9, 8, 7, 6]).buffer, 1, 2), new Uint8Array(0),
|
|
30
|
+
];
|
|
31
|
+
return new Response(JSON.stringify({ decode: decode.map((s) => codes(__sbFromUtf8(s))), encode: encode.map((v) => codes(__sbToBytes(v))) }));
|
|
32
|
+
}};`;
|
|
33
|
+
|
|
34
|
+
// The JS the C replaces, lifted from the prelude with the C stubbed out, as the reference.
|
|
35
|
+
const core = await readFile(preludePath, "utf8");
|
|
36
|
+
const lift = (from: string, to: string) => {
|
|
37
|
+
const start = core.indexOf(from);
|
|
38
|
+
const end = core.indexOf(to, start);
|
|
39
|
+
assert(start >= 0 && end > start, `${from} block not found in prelude`);
|
|
40
|
+
return core.slice(start, end);
|
|
41
|
+
};
|
|
42
|
+
// oxlint-disable-next-line no-function-constructor -- lifting pure blocks out of the text-only prelude for the reference
|
|
43
|
+
const reference = new Function(
|
|
44
|
+
`function __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\n` +
|
|
45
|
+
lift("function __sbIsStr(", "function __sbIsFn(") +
|
|
46
|
+
lift("const __SB_BYTES_WINDOW", "function __sbBufFrom(") +
|
|
47
|
+
lift("function __sbFromUtf8(", "// sproutboat #181") +
|
|
48
|
+
"return { __sbFromUtf8, __sbToBytes };",
|
|
49
|
+
)();
|
|
50
|
+
const bytes = (list: number[]) => String.fromCharCode(...list);
|
|
51
|
+
const codes = (s: string) => [...s].flatMap((c) => [...Array(c.length).keys()].map((i) => c.charCodeAt(i)));
|
|
52
|
+
const want = {
|
|
53
|
+
decode: [
|
|
54
|
+
"hello",
|
|
55
|
+
bytes([0x63, 0x61, 0x66, 0xc3, 0xa9, 0x20, 0xf0, 0x9f, 0x8c, 0xb1, 0x20, 0xe2, 0x9c, 0x93]),
|
|
56
|
+
bytes([0xc2]),
|
|
57
|
+
bytes([0xe9, 0x41]),
|
|
58
|
+
bytes([0xf0, 0x9f, 0x8c]),
|
|
59
|
+
bytes([0xe2, 0x28, 0xa1]),
|
|
60
|
+
bytes([0xc0, 0x80]),
|
|
61
|
+
bytes([0xf8, 0x88, 0x80, 0x80, 0x80]),
|
|
62
|
+
bytes([0xed, 0xa0, 0x80]),
|
|
63
|
+
bytes([0xf4, 0x90, 0x80, 0x80]),
|
|
64
|
+
bytes([0xff, 0xfe, 0x00]),
|
|
65
|
+
"café Ā",
|
|
66
|
+
"â\u009c\u0093",
|
|
67
|
+
"",
|
|
68
|
+
].map((s) => codes(reference.__sbFromUtf8(s))),
|
|
69
|
+
encode: [
|
|
70
|
+
"hello",
|
|
71
|
+
"café",
|
|
72
|
+
"✓ 🌱",
|
|
73
|
+
"\ud800x",
|
|
74
|
+
"\udc00",
|
|
75
|
+
"a\ud800",
|
|
76
|
+
"Ā߿ࠀ",
|
|
77
|
+
"",
|
|
78
|
+
new Uint8Array([0, 255, 128]),
|
|
79
|
+
new Uint8Array([1, 2, 3]).buffer,
|
|
80
|
+
new Uint8Array(new Uint8Array([9, 8, 7, 6]).buffer, 1, 2),
|
|
81
|
+
new Uint8Array(0),
|
|
82
|
+
].map((v) => codes(reference.__sbToBytes(v))),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function freePort(): number {
|
|
86
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
87
|
+
const port = listener.port;
|
|
88
|
+
listener.stop(true);
|
|
89
|
+
return port;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-byte-text-"));
|
|
93
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
94
|
+
try {
|
|
95
|
+
const porffor = await ensurePorffor();
|
|
96
|
+
const node = Bun.which("node");
|
|
97
|
+
if (!node) throw new Error("native byte-text test needs node on PATH");
|
|
98
|
+
const broker = await readFile(transportPath("broker"), "utf8");
|
|
99
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
100
|
+
const generated = join(workdir, "bytes.generated.js");
|
|
101
|
+
const binary = join(workdir, "bytes-native");
|
|
102
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker)));
|
|
103
|
+
const compile = Bun.spawn(
|
|
104
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
105
|
+
{
|
|
106
|
+
cwd: workdir,
|
|
107
|
+
stdout: "pipe",
|
|
108
|
+
stderr: "pipe",
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
112
|
+
compile.exited,
|
|
113
|
+
new Response(compile.stdout).text(),
|
|
114
|
+
new Response(compile.stderr).text(),
|
|
115
|
+
]);
|
|
116
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
117
|
+
|
|
118
|
+
const port = freePort();
|
|
119
|
+
// Peak RSS: /proc's high-water mark on Linux, time(1) on macOS.
|
|
120
|
+
const linux = process.platform === "linux";
|
|
121
|
+
server = Bun.spawn(linux ? [binary] : ["/usr/bin/time", "-l", binary], {
|
|
122
|
+
cwd: workdir,
|
|
123
|
+
env: { ...process.env, PORT: String(port) },
|
|
124
|
+
stdout: "ignore",
|
|
125
|
+
stderr: "pipe",
|
|
126
|
+
});
|
|
127
|
+
let ready = false;
|
|
128
|
+
for (let attempt = 0; attempt < 50 && !ready; attempt++) {
|
|
129
|
+
try {
|
|
130
|
+
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
|
|
131
|
+
ready = true;
|
|
132
|
+
} catch {
|
|
133
|
+
if (server.exitCode !== null) break;
|
|
134
|
+
await Bun.sleep(100);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
assert(ready, "compiled handler did not start");
|
|
138
|
+
const got = await (await fetch(`http://127.0.0.1:${port}/`)).json();
|
|
139
|
+
assert.deepEqual(got.decode, want.decode, "__sbFromUtf8 in C decodes as the JS did");
|
|
140
|
+
assert.deepEqual(got.encode, want.encode, "__sbToBytes in C encodes as the JS did");
|
|
141
|
+
console.log("C byte/text conversions match the JS ones");
|
|
142
|
+
|
|
143
|
+
const big = await (await fetch(`http://127.0.0.1:${port}/big`)).json();
|
|
144
|
+
assert.deepEqual(big, [4 * 1024 * 1024, 0xe9, 3 * 1024 * 1024, 0x93, 8 * 1024 * 1024]);
|
|
145
|
+
let peak = 0;
|
|
146
|
+
if (linux) {
|
|
147
|
+
const status = await readFile(`/proc/${server.pid}/status`, "utf8");
|
|
148
|
+
peak = Number(/VmHWM:\s+(\d+) kB/.exec(status)?.[1] ?? 0) * 1024;
|
|
149
|
+
}
|
|
150
|
+
server.kill("SIGINT");
|
|
151
|
+
const timing = linux ? "" : await new Response(server.stderr).text();
|
|
152
|
+
await server.exited;
|
|
153
|
+
server = undefined;
|
|
154
|
+
if (!linux) peak = Number(/(\d+)\s+maximum resident set size/.exec(timing)?.[1] ?? 0);
|
|
155
|
+
assert(peak > 0, "peak RSS was measured");
|
|
156
|
+
// The JS took over 600 MiB for these; the C needs the strings plus one scratch copy.
|
|
157
|
+
assert(peak < 96 * 1024 * 1024, `8 MiB conversions peaked at ${(peak / 1048576).toFixed(1)} MiB`);
|
|
158
|
+
console.log(`8 MiB conversions peak at ${(peak / 1048576).toFixed(1)} MiB RSS`);
|
|
159
|
+
} finally {
|
|
160
|
+
if (server) {
|
|
161
|
+
server.kill(9);
|
|
162
|
+
await server.exited;
|
|
163
|
+
}
|
|
164
|
+
await rm(workdir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
|
+
|
|
8
|
+
// Streamed request bodies, from compatibility date 2026-10-27: the handler runs once the
|
|
9
|
+
// headers are in, request.body hands out chunks as they arrive, text()/json()/arrayBuffer()
|
|
10
|
+
// still read the whole body, and a large upload read through the stream keeps memory flat
|
|
11
|
+
// (the server pauses the socket while 1 MiB waits unread).
|
|
12
|
+
const handler = `export default {async fetch(request) {
|
|
13
|
+
const path = new URL(request.url).pathname;
|
|
14
|
+
if (path === "/timing") {
|
|
15
|
+
const reader = request.body.getReader();
|
|
16
|
+
let first = 0, last = 0, text = "", chunks = 0;
|
|
17
|
+
for (;;) {
|
|
18
|
+
const { value, done } = await reader.read();
|
|
19
|
+
if (done) break;
|
|
20
|
+
last = Date.now();
|
|
21
|
+
if (!first) first = last;
|
|
22
|
+
chunks++;
|
|
23
|
+
text += new TextDecoder().decode(value);
|
|
24
|
+
}
|
|
25
|
+
return Response.json({ text, gapMs: last - first, chunks });
|
|
26
|
+
}
|
|
27
|
+
if (path === "/count") {
|
|
28
|
+
const reader = request.body.getReader();
|
|
29
|
+
let bytes = 0, sum = 0;
|
|
30
|
+
for (;;) {
|
|
31
|
+
const { value, done } = await reader.read();
|
|
32
|
+
if (done) break;
|
|
33
|
+
// Every 4096th byte of the whole body, wherever the server split it into chunks.
|
|
34
|
+
for (let i = (4096 - (bytes % 4096)) % 4096; i < value.length; i += 4096) sum = (sum + value[i]) % 65521;
|
|
35
|
+
bytes += value.length;
|
|
36
|
+
}
|
|
37
|
+
return Response.json({ bytes, sum });
|
|
38
|
+
}
|
|
39
|
+
if (path === "/text") { const t = await request.text(); return Response.json({ length: t.length, head: t.slice(0, 8) }); }
|
|
40
|
+
if (path === "/json") return Response.json(await request.json());
|
|
41
|
+
if (path === "/ab") { const b = new Uint8Array(await request.arrayBuffer()); return Response.json({ length: b.length, last: b[b.length - 1] }); }
|
|
42
|
+
if (path === "/twice") { await request.text(); try { await request.text(); return new Response("read twice"); } catch (e) { return new Response(e.name); } }
|
|
43
|
+
if (path === "/echo") return new Response(request.body);
|
|
44
|
+
if (path === "/ignore") return new Response("ignored");
|
|
45
|
+
return new Response("no route " + path, { status: 404 });
|
|
46
|
+
}};`;
|
|
47
|
+
|
|
48
|
+
function freePort(): number {
|
|
49
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
50
|
+
const port = listener.port;
|
|
51
|
+
listener.stop(true);
|
|
52
|
+
return port;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** An upload of `total` bytes in 64 KiB chunks, as a stream (chunked transfer encoding). */
|
|
56
|
+
function upload(total: number, gapMs = 0): ReadableStream<Uint8Array> {
|
|
57
|
+
let sent = 0;
|
|
58
|
+
return new ReadableStream({
|
|
59
|
+
async pull(controller) {
|
|
60
|
+
if (sent >= total) return controller.close();
|
|
61
|
+
const n = Math.min(65536, total - sent);
|
|
62
|
+
const chunk = new Uint8Array(n);
|
|
63
|
+
for (let i = 0; i < n; i += 4096) chunk[i] = (sent + i) % 251;
|
|
64
|
+
sent += n;
|
|
65
|
+
controller.enqueue(chunk);
|
|
66
|
+
if (gapMs) await Bun.sleep(gapMs);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const expectedSum = (total: number) => {
|
|
71
|
+
let sum = 0;
|
|
72
|
+
for (let at = 0; at < total; at += 65536) {
|
|
73
|
+
const n = Math.min(65536, total - at);
|
|
74
|
+
for (let i = 0; i < n; i += 4096) sum = (sum + ((at + i) % 251)) % 65521;
|
|
75
|
+
}
|
|
76
|
+
return sum;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-request-streaming-"));
|
|
80
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
81
|
+
try {
|
|
82
|
+
const porffor = await ensurePorffor();
|
|
83
|
+
const node = Bun.which("node");
|
|
84
|
+
if (!node) throw new Error("native request-streaming test needs node on PATH");
|
|
85
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
86
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
87
|
+
const generated = join(workdir, "stream.generated.js");
|
|
88
|
+
const binary = join(workdir, "stream-native");
|
|
89
|
+
const source = wrapNativeFetchHandler(
|
|
90
|
+
handler,
|
|
91
|
+
core.replace(TRANSPORT_MARKER, broker),
|
|
92
|
+
{},
|
|
93
|
+
undefined,
|
|
94
|
+
undefined,
|
|
95
|
+
"2026-10-27",
|
|
96
|
+
);
|
|
97
|
+
await writeFile(generated, source);
|
|
98
|
+
const compile = Bun.spawn(
|
|
99
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
100
|
+
{
|
|
101
|
+
cwd: workdir,
|
|
102
|
+
stdout: "pipe",
|
|
103
|
+
stderr: "pipe",
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
107
|
+
compile.exited,
|
|
108
|
+
new Response(compile.stdout).text(),
|
|
109
|
+
new Response(compile.stderr).text(),
|
|
110
|
+
]);
|
|
111
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
112
|
+
|
|
113
|
+
const port = freePort();
|
|
114
|
+
const linux = process.platform === "linux";
|
|
115
|
+
server = Bun.spawn(linux ? [binary] : ["/usr/bin/time", "-l", binary], {
|
|
116
|
+
cwd: workdir,
|
|
117
|
+
env: { ...process.env, PORT: String(port), SB_REQUEST_BODY_MAX: String(256 * 1024 * 1024) },
|
|
118
|
+
stdout: "ignore",
|
|
119
|
+
stderr: "pipe",
|
|
120
|
+
});
|
|
121
|
+
const base = `http://127.0.0.1:${port}`;
|
|
122
|
+
let ready = false;
|
|
123
|
+
for (let attempt = 0; attempt < 50 && !ready; attempt++) {
|
|
124
|
+
try {
|
|
125
|
+
await fetch(`${base}/ignore`, { signal: AbortSignal.timeout(500) });
|
|
126
|
+
ready = true;
|
|
127
|
+
} catch {
|
|
128
|
+
if (server.exitCode !== null) break;
|
|
129
|
+
await Bun.sleep(100);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
assert(ready, "compiled handler did not start");
|
|
133
|
+
const post = (path: string, body: BodyInit, headers?: HeadersInit) => {
|
|
134
|
+
const init: RequestInit & { duplex: string } = { method: "POST", body, headers, duplex: "half" };
|
|
135
|
+
return fetch(`${base}${path}`, init);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// The handler sees the first chunk before the second is sent.
|
|
139
|
+
const encoder = new TextEncoder();
|
|
140
|
+
const timed = new ReadableStream({
|
|
141
|
+
async start(controller) {
|
|
142
|
+
controller.enqueue(encoder.encode("first "));
|
|
143
|
+
await Bun.sleep(500);
|
|
144
|
+
controller.enqueue(encoder.encode("second"));
|
|
145
|
+
controller.close();
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
const timing = await (await post("/timing", timed)).json();
|
|
149
|
+
assert.equal(timing.text, "first second");
|
|
150
|
+
assert(timing.gapMs > 300, `the body arrived in parts (${timing.gapMs} ms apart)`);
|
|
151
|
+
console.log(`request.body streams: the second chunk arrived ${timing.gapMs} ms after the first`);
|
|
152
|
+
|
|
153
|
+
// Whole-body readers still work, on a fixed-length and on a chunked body.
|
|
154
|
+
assert.deepEqual(await (await post("/text", "héllo wörld ✓")).json(), { length: 13, head: "héllo wö" });
|
|
155
|
+
assert.deepEqual(await (await post("/json", JSON.stringify({ a: [1, "ü"] }))).json(), { a: [1, "ü"] });
|
|
156
|
+
const big = new Uint8Array(3 * 1024 * 1024 + 7).fill(65);
|
|
157
|
+
big[big.length - 1] = 66;
|
|
158
|
+
assert.deepEqual(await (await post("/ab", big)).json(), { length: big.length, last: 66 });
|
|
159
|
+
assert.deepEqual(await (await post("/ab", upload(5 * 1024 * 1024 + 1))).json(), {
|
|
160
|
+
length: 5 * 1024 * 1024 + 1,
|
|
161
|
+
last: (5 * 1024 * 1024) % 251,
|
|
162
|
+
});
|
|
163
|
+
assert.equal(await (await post("/twice", "x")).text(), "TypeError");
|
|
164
|
+
assert.equal(await (await post("/echo", "echo me")).text(), "echo me");
|
|
165
|
+
// A body the handler never reads doesn't stall the connection or the next request.
|
|
166
|
+
assert.equal(await (await post("/ignore", new Uint8Array(4 * 1024 * 1024))).text(), "ignored");
|
|
167
|
+
assert.equal(await (await post("/text", "after")).text(), JSON.stringify({ length: 5, head: "after" }));
|
|
168
|
+
console.log("text(), json(), arrayBuffer(), echo and an unread body work");
|
|
169
|
+
|
|
170
|
+
// Over the limit by Content-Length: 413 before the handler runs.
|
|
171
|
+
// (Raw, since fetch() sets Content-Length from the body it actually sends.)
|
|
172
|
+
const statusLine = await new Promise<string>((done, fail) => {
|
|
173
|
+
let got = "";
|
|
174
|
+
Bun.connect({
|
|
175
|
+
hostname: "127.0.0.1",
|
|
176
|
+
port,
|
|
177
|
+
socket: {
|
|
178
|
+
open(socket) {
|
|
179
|
+
socket.write(`POST /count HTTP/1.1\r\nHost: x\r\nContent-Length: ${300 * 1024 * 1024}\r\n\r\n`);
|
|
180
|
+
},
|
|
181
|
+
data(socket, data) {
|
|
182
|
+
got += data.toString();
|
|
183
|
+
if (got.includes("\r\n")) {
|
|
184
|
+
socket.end();
|
|
185
|
+
done(got.slice(0, got.indexOf("\r\n")));
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
error(_socket, error) {
|
|
189
|
+
fail(error);
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
assert.equal(statusLine, "HTTP/1.1 413 Payload Too Large");
|
|
195
|
+
console.log("a body over SB_REQUEST_BODY_MAX by Content-Length gets 413 before the handler runs");
|
|
196
|
+
|
|
197
|
+
// Memory stays flat as the upload grows: 128 MiB read through the stream.
|
|
198
|
+
const total = 128 * 1024 * 1024;
|
|
199
|
+
const counted = await (await post("/count", upload(total))).json();
|
|
200
|
+
assert.deepEqual(counted, { bytes: total, sum: expectedSum(total) });
|
|
201
|
+
let peak = 0;
|
|
202
|
+
if (linux) {
|
|
203
|
+
const status = await readFile(`/proc/${server.pid}/status`, "utf8");
|
|
204
|
+
peak = Number(/VmHWM:\s+(\d+) kB/.exec(status)?.[1] ?? 0) * 1024;
|
|
205
|
+
}
|
|
206
|
+
server.kill("SIGINT");
|
|
207
|
+
const timingOut = linux ? "" : await new Response(server.stderr).text();
|
|
208
|
+
await server.exited;
|
|
209
|
+
server = undefined;
|
|
210
|
+
if (!linux) peak = Number(/(\d+)\s+maximum resident set size/.exec(timingOut)?.[1] ?? 0);
|
|
211
|
+
assert(peak > 0, "peak RSS was measured");
|
|
212
|
+
// Buffered, the 128 MiB body alone took 128 MiB three times over.
|
|
213
|
+
assert(peak < 64 * 1024 * 1024, `a 128 MiB streamed upload peaked at ${(peak / 1048576).toFixed(1)} MiB`);
|
|
214
|
+
console.log(`a 128 MiB upload read through request.body peaks at ${(peak / 1048576).toFixed(1)} MiB RSS`);
|
|
215
|
+
} finally {
|
|
216
|
+
if (server) {
|
|
217
|
+
server.kill(9);
|
|
218
|
+
await server.exited;
|
|
219
|
+
}
|
|
220
|
+
await rm(workdir, { recursive: true, force: true });
|
|
221
|
+
}
|