@sproutboat/runtime 0.27.1 → 0.29.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 +59 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +677 -35
- package/src/transport-broker.js +159 -8
- package/src/transport-embedded.js +23 -5
- package/test/native-request-streaming.ts +221 -0
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;
|
|
@@ -4194,12 +4194,17 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
4194
4194
|
}
|
|
4195
4195
|
|
|
4196
4196
|
if (op === "queue.send" || op === "queue.send_batch") {
|
|
4197
|
-
|
|
4197
|
+
// send_batch's items are { body, delaySeconds } objects, like the broker's. They were
|
|
4198
|
+
// stored with String(item), so every sendBatch() body arrived as "[object Object]"
|
|
4199
|
+
// (data loss), and delaySeconds was ignored on both calls.
|
|
4200
|
+
const items = op === "queue.send" ? [{ body: msg.body, delaySeconds: msg.delaySeconds }] : msg.messages || [];
|
|
4198
4201
|
for (let i = 0; i < items.length; i++) {
|
|
4202
|
+
const item = items[i] || {};
|
|
4203
|
+
const delay = Math.max(0, Number(item.delaySeconds) || 0) * 1000;
|
|
4199
4204
|
__sbSql(
|
|
4200
4205
|
store,
|
|
4201
4206
|
"INSERT INTO mq (queue, id, body, visible_at, attempts, dead) VALUES (?1,?2,?3,?4,0,0)",
|
|
4202
|
-
[msg.queue, __sbHex(12), String(
|
|
4207
|
+
[msg.queue, __sbHex(12), String(item.body), Date.now() + delay],
|
|
4203
4208
|
);
|
|
4204
4209
|
}
|
|
4205
4210
|
return { ok: true };
|
|
@@ -4240,8 +4245,21 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
4240
4245
|
store,
|
|
4241
4246
|
// Same clamp as the broker: an unbounded list builds the whole result in
|
|
4242
4247
|
// memory before the caller sees any of it.
|
|
4243
|
-
|
|
4244
|
-
|
|
4248
|
+
// start, startAfter, end and reverse come from 2026-10-28 storage (null when unset).
|
|
4249
|
+
// The prefix match is exact: LIKE also matched _ and % as wildcards, case-insensitively.
|
|
4250
|
+
"SELECT key, value FROM do_storage WHERE cls = ?1 AND id = ?2 AND substr(key, 1, length(?3)) = ?3" +
|
|
4251
|
+
" AND (?5 IS NULL OR key >= ?5) AND (?6 IS NULL OR key > ?6) AND (?7 IS NULL OR key < ?7)" +
|
|
4252
|
+
(msg.reverse ? " ORDER BY key DESC" : " ORDER BY key") +
|
|
4253
|
+
" LIMIT ?4",
|
|
4254
|
+
[
|
|
4255
|
+
msg.cls,
|
|
4256
|
+
msg.id,
|
|
4257
|
+
msg.prefix || "",
|
|
4258
|
+
Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000),
|
|
4259
|
+
msg.start == null ? null : String(msg.start),
|
|
4260
|
+
msg.startAfter == null ? null : String(msg.startAfter),
|
|
4261
|
+
msg.end == null ? null : String(msg.end),
|
|
4262
|
+
],
|
|
4245
4263
|
);
|
|
4246
4264
|
const entries = [];
|
|
4247
4265
|
for (let i = 0; i < r.rows.length; i++) entries.push([r.rows[i][0], r.rows[i][1]]);
|
|
@@ -4799,7 +4817,7 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
|
|
|
4799
4817
|
try {
|
|
4800
4818
|
const instance = __sbGetDOInstance(cls, id);
|
|
4801
4819
|
if (__sbIsFn(instance.alarm)) {
|
|
4802
|
-
const res = instance.alarm();
|
|
4820
|
+
const res = __sbDOGated() ? __sbDOEnter(instance.__sbGate, () => instance.alarm()) : instance.alarm();
|
|
4803
4821
|
if (res && __sbIsFn(res.then)) await res;
|
|
4804
4822
|
if (instance.__sbTasks.length) await __sbDrainWaitUntil(instance.__sbTasks);
|
|
4805
4823
|
}
|
|
@@ -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
|
+
}
|