@sproutboat/runtime 0.27.1 → 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 CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## 0.27.1
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4745,7 +4745,7 @@ static size_t sb_utf8_encode_units(const uint16_t* s, size_t len, unsigned char*
4745
4745
 
4746
4746
  `;
4747
4747
 
4748
- // sproutd #22: __sbFromUtf8 for a string of bytes (one byte per unit), in C.
4748
+ // sproutd #22: __sbFromUtf8 for a string of bytes (one byte per unit) or a Uint8Array, in C.
4749
4749
  // Returns "" when it cannot (not a string, or out of memory), so the caller
4750
4750
  // falls back to the JS loop. All-ASCII byte strings are already their text.
4751
4751
  // oxlint-disable-next-line no-unused-vars -- `bytes` is read inside the RawC block below, not by JS.
@@ -4753,9 +4753,10 @@ function __sbFromUtf8Native(bytes) {
4753
4753
  let out = "";
4754
4754
  // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4755
4755
  Porffor.c`
4756
- if (bytes.type == 195 || bytes.type == 67) {
4757
- const u32 __p = (u32)bytes.val;
4758
- const size_t __n = (size_t)*(u32*)(MEM + __p);
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);
4759
4760
  int __ascii = bytes.type == 195;
4760
4761
  if (__ascii) {
4761
4762
  const unsigned char* __s = (const unsigned char*)(MEM + __p + 4);
@@ -4766,7 +4767,7 @@ function __sbFromUtf8Native(bytes) {
4766
4767
  unsigned char* __b = (unsigned char*)malloc(__n + 1);
4767
4768
  uint16_t* __u = (uint16_t*)malloc(__n * 2 + 2);
4768
4769
  if (__b && __u) {
4769
- if (bytes.type == 195) memcpy(__b, MEM + __p + 4, __n);
4770
+ if (bytes.type != 67) memcpy(__b, MEM + __p + 4, __n);
4770
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); }
4771
4772
  int __wide = 0;
4772
4773
  const size_t __k = sb_utf8_decode_units(__b, __n, __u, &__wide);
@@ -6010,9 +6011,14 @@ globalThis.__sbInstallBindings = function (target, bindings) {
6010
6011
  async function __sbServiceFetch(binding, input, init) {
6011
6012
  const parts = __sbFetchParts(input, init);
6012
6013
  if (parts.signal) parts.signal.throwIfAborted();
6013
- // A stream body is read to the end first: the wire carries a whole 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.
6014
6016
  const body = __sbUnwrapBody(parts.body);
6015
- const wire = body instanceof ReadableStream ? __sbRequestBody(await __sbCollectBody(body)) : __sbPartsBody({ ...parts, body });
6017
+ const wire = __sbStreamsBody(body)
6018
+ ? { bodyStream: true, __sbBodySource: body }
6019
+ : body instanceof ReadableStream
6020
+ ? __sbRequestBody(await __sbCollectBody(body))
6021
+ : __sbPartsBody({ ...parts, body });
6016
6022
  const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...wire };
6017
6023
  const response = await __sbFetchFollowing("service.fetch", msg, "sproutboat service " + binding, parts.redirect, parts.signal);
6018
6024
  // The callee threw: as in workerd, its error is thrown here.
@@ -6270,7 +6276,52 @@ function __sbUpstreamResponse(r) {
6270
6276
  // whole (a broker from before streaming, or sproutd's embedded transport) is handled
6271
6277
  // as before. Cancelling the body, or the signal, drops the exchange, which stops the
6272
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
+
6273
6322
  function __sbUpstreamStreamed(op, msg, label, signal) {
6323
+ const source = msg.__sbBodySource;
6324
+ if (source !== undefined) delete msg.__sbBodySource;
6274
6325
  return new Promise((resolve, reject) => {
6275
6326
  let controller = null;
6276
6327
  let onAbort = null;
@@ -6338,6 +6389,7 @@ function __sbUpstreamStreamed(op, msg, label, signal) {
6338
6389
  msg.asyncId = id;
6339
6390
  msg.stream = true;
6340
6391
  globalThis.__sbFetchUpstream(op, msg);
6392
+ if (source !== undefined) __sbPumpRequestBody(id, source, () => done);
6341
6393
  } catch (e) {
6342
6394
  finish();
6343
6395
  reject(e);
@@ -6375,7 +6427,8 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
6375
6427
  next.method = "GET";
6376
6428
  next.body = null;
6377
6429
  delete next.bodyBinary;
6378
- }
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");
6379
6432
  return __sbFollowHop(op, next, label, mode, signal, hops + 1);
6380
6433
  };
6381
6434
  const out = __sbUpstream(op, msg, label, signal);
@@ -6435,6 +6488,7 @@ function __sbUpstream(op, msg, label, signal, always) {
6435
6488
  // Worker's fetch. Installed by the generated module (wrap.ts), not here: the
6436
6489
  // prelude must not replace fetch just by being evaluated.
6437
6490
  globalThis.__sbInstallFetch = function () {
6491
+ __sbIngressStreamingSetup();
6438
6492
  globalThis.fetch = function (input, init) {
6439
6493
  let parts;
6440
6494
  try {
@@ -6448,9 +6502,11 @@ globalThis.__sbInstallFetch = function () {
6448
6502
  const msg = { url: parts.url, method: parts.method, headers: parts.headers, ...body };
6449
6503
  return __sbFetchFollowing("fetch", msg, "sproutboat fetch", parts.redirect, parts.signal);
6450
6504
  };
6451
- // A stream body is read to the end first, as for a service binding: the wire carries a
6452
- // whole body. It was sent as the text "[object ReadableStream]".
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]".
6453
6508
  const stream = __sbUnwrapBody(parts.body);
6509
+ if (__sbStreamsBody(stream)) return send({ bodyStream: true, __sbBodySource: stream });
6454
6510
  if (stream instanceof ReadableStream) return __sbCollectBody(stream).then((bytes) => send(__sbRequestBody(bytes)));
6455
6511
  return send(__sbPartsBody(parts));
6456
6512
  };
@@ -7031,11 +7087,133 @@ async function __sbQueueWithDrain(result, tasks) {
7031
7087
  return new Response(JSON.stringify(r), { headers: { "content-type": "application/json" } });
7032
7088
  }
7033
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
+
7034
7207
  // Native HTTP ingress stores the body as one character per raw byte. Keep
7035
7208
  // that representation for binary consumers, but decode text lazily before
7036
7209
  // handing the request to user code. Request.text() in Porffor otherwise treats
7037
7210
  // the raw bytes as already-decoded characters and corrupts Unicode/HMAC input.
7038
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
+ }
7039
7217
  const raw = __sbRawBody(request);
7040
7218
  if (raw == null) return;
7041
7219
  // A GET or HEAD has no body, as in workerd (from 2026-10-23): request.body is null, and
@@ -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) { j->out_off += (size_t)n; continue; }
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)) return 0;
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
- if (*idle_n < SB_IO_IDLE) idle[(*idle_n)++] = j->fd; else close(j->fd);
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 || j->out_off < j->out_len ? POLLOUT : POLLIN;
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
- __sbCallAsync(JSON.stringify(req), "", msg.asyncId, msg.stream === true ? "1" : "0");
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] == '1';
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;
@@ -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
+ }