@sproutboat/wire 0.16.0 → 0.17.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,22 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.17.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
+ ### Patch Changes
16
+
17
+ - Updated dependencies [829ed12]
18
+ - @sproutboat/runtime@0.28.0
19
+
3
20
  ## 0.16.0
4
21
 
5
22
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,6 +23,6 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@sproutboat/assets": "^0.3.0",
26
- "@sproutboat/runtime": "^0.27.0"
26
+ "@sproutboat/runtime": "^0.28.0"
27
27
  }
28
28
  }
@@ -1537,3 +1537,60 @@ test("R2 direct download: If-None-Match supports wildcard, lists and weak compar
1537
1537
  expect((await broker.transfer(new Request(url))).status).toBe(410);
1538
1538
  } finally { broker.close(); rmSync(dir, { recursive: true, force: true }); }
1539
1539
  });
1540
+
1541
+ test("listen(): a service.fetch body streamed in FRAME_V1_MORE frames reaches the upstream as it arrives", async () => {
1542
+ const arrivals: { text: string; at: number }[] = [];
1543
+ const fetchImpl: FetchLike = async (_url, init) => {
1544
+ // SAFETY: the broker passes the BodySink's ReadableStream as the body of a streamed request.
1545
+ const reader = (init?.body as ReadableStream<Uint8Array>).getReader();
1546
+ for (;;) {
1547
+ const { value, done } = await reader.read();
1548
+ if (done) break;
1549
+ arrivals.push({ text: new TextDecoder().decode(value), at: Date.now() });
1550
+ }
1551
+ return new Response(arrivals.map((a) => a.text).join(""));
1552
+ };
1553
+ const b = make({
1554
+ token: "tok",
1555
+ bindings: { services: [{ binding: "PEER", service: "peer" }] },
1556
+ services: { PEER: "peer.example.com" },
1557
+ edgeUrl: "http://127.0.0.1:8080/",
1558
+ fetchImpl,
1559
+ });
1560
+ const server = listen(b, "127.0.0.1", 0);
1561
+ const socket = createConnection({ host: "127.0.0.1", port: server.port });
1562
+ const replies: Buffer[] = [];
1563
+ let pending = Buffer.alloc(0);
1564
+ socket.on("data", (data: Buffer) => {
1565
+ pending = Buffer.concat([pending, data]);
1566
+ while (pending.length >= 4 && pending.length >= 4 + pending.readUInt32LE(0)) {
1567
+ replies.push(pending.subarray(4, 4 + pending.readUInt32LE(0)));
1568
+ pending = pending.subarray(4 + pending.readUInt32LE(0));
1569
+ }
1570
+ });
1571
+ await new Promise((done) => socket.once("connect", done));
1572
+ const head = {
1573
+ v: 1,
1574
+ token: "tok",
1575
+ op: "service.fetch",
1576
+ binding: "PEER",
1577
+ url: "https://peer/up",
1578
+ method: "POST",
1579
+ bodyStream: true,
1580
+ };
1581
+ socket.write(frameOf(encodeV1(head)));
1582
+ socket.write(frameOf(encodeV1({ chunk: true }, new TextEncoder().encode("first "), true)));
1583
+ await Bun.sleep(300);
1584
+ expect(arrivals.map((a) => a.text)).toEqual(["first "]);
1585
+ socket.write(frameOf(encodeV1({ chunk: true }, new TextEncoder().encode("second"), true)));
1586
+ socket.write(frameOf(encodeV1({ end: true }, undefined, true)));
1587
+ for (let i = 0; i < 100 && replies.length === 0; i++) await Bun.sleep(20);
1588
+ const jsonLength = replies[0].readUInt32LE(1);
1589
+ expect(JSON.parse(replies[0].subarray(5, 5 + jsonLength).toString())).toMatchObject({ ok: true, status: 200 });
1590
+ expect(replies[0].subarray(5 + jsonLength).toString()).toBe("first second");
1591
+ expect(arrivals[1].at - arrivals[0].at).toBeGreaterThan(200);
1592
+ // The broker says it takes streamed bodies.
1593
+ expect(await b.dispatch({ op: "ping" })).toMatchObject({ bodyStream: true });
1594
+ socket.destroy();
1595
+ server.stop();
1596
+ });
package/src/broker.ts CHANGED
@@ -196,9 +196,11 @@ export type Broker = {
196
196
  * `service.fetch` in flight then stops its upstream request too. With `emit`, a
197
197
  * `fetch` or `service.fetch` that asks to stream (`stream: true`) sends its head and
198
198
  * each body chunk through it as they arrive (FRAME_V1_MORE frames); the returned
199
- * frame ends the response.
199
+ * frame ends the response. With `body`, a `fetch` or `service.fetch` whose head asked
200
+ * for it (`bodyStream: true`) sends that stream as its request body: the listener feeds
201
+ * it from the FRAME_V1_MORE frames that follow the head on the same connection.
200
202
  */
201
- handleFrame(payload: Buffer, signal?: AbortSignal, emit?: FrameEmitter): Promise<Buffer>;
203
+ handleFrame(payload: Buffer, signal?: AbortSignal, emit?: FrameEmitter, body?: ReadableStream<Uint8Array>): Promise<Buffer>;
202
204
  /** Serve one reserved direct R2 upload or download request. */
203
205
  transfer(request: Request): Promise<Response>;
204
206
  close(): void;
@@ -1228,7 +1230,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1228
1230
  }
1229
1231
  }
1230
1232
 
1231
- async function proxyFetch(msg: Frame, signal?: AbortSignal, emit?: FrameEmitter): Promise<Upstream> {
1233
+ async function proxyFetch(msg: Frame, signal?: AbortSignal, emit?: FrameEmitter, body?: ReadableStream<Uint8Array>): Promise<Upstream> {
1232
1234
  let url: URL;
1233
1235
  try {
1234
1236
  url = new URL(str(msg.url));
@@ -1247,7 +1249,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1247
1249
  const init: RequestInit = {
1248
1250
  method,
1249
1251
  headers,
1250
- body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
1252
+ body: method === "GET" || method === "HEAD" ? undefined : (body ?? upstreamBody(msg)),
1251
1253
  redirect: "manual",
1252
1254
  signal,
1253
1255
  };
@@ -1268,7 +1270,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1268
1270
  * minus the trip through the network (and therefore minus TLS and the
1269
1271
  * default-deny egress rules, which is why this is not `fetch`).
1270
1272
  */
1271
- async function serviceFetch(msg: Frame, signal?: AbortSignal, emit?: FrameEmitter): Promise<Upstream> {
1273
+ async function serviceFetch(msg: Frame, signal?: AbortSignal, emit?: FrameEmitter, body?: ReadableStream<Uint8Array>): Promise<Upstream> {
1272
1274
  const binding = str(msg.binding);
1273
1275
  const declared = bindings.services.find((entry) => entry.binding === binding);
1274
1276
  if (!declared) throw new Error(`service not bound: ${binding}`);
@@ -1302,7 +1304,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1302
1304
  const res = await doFetch(target, {
1303
1305
  method,
1304
1306
  headers,
1305
- body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
1307
+ body: method === "GET" || method === "HEAD" ? undefined : (body ?? upstreamBody(msg)),
1306
1308
  redirect: "manual",
1307
1309
  signal,
1308
1310
  });
@@ -1315,7 +1317,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1315
1317
  async function dispatch(msg: Frame): Promise<Frame> {
1316
1318
  switch (msg.op) {
1317
1319
  case "ping":
1318
- return { ok: true, op: "pong", echo: msg.msg ?? null, pid: process.pid };
1320
+ // bodyStream: this broker takes streamed request bodies (FRAME_V1_MORE frames after a head).
1321
+ return { ok: true, op: "pong", echo: msg.msg ?? null, pid: process.pid, bodyStream: true };
1319
1322
  case "kv.get": {
1320
1323
  const { store, part } = storeFor("kv", requireKv(msg.ns));
1321
1324
  const stmts = kvStmts(store);
@@ -1785,6 +1788,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1785
1788
  binary: Uint8Array,
1786
1789
  signal?: AbortSignal,
1787
1790
  emit?: FrameEmitter,
1791
+ body?: ReadableStream<Uint8Array>,
1788
1792
  ): Promise<{ reply: Frame; bytes?: Uint8Array }> {
1789
1793
  if (msg.op === "r2.put") {
1790
1794
  // The body arrived as bytes; write it straight to its own file rather
@@ -1835,8 +1839,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1835
1839
  }
1836
1840
 
1837
1841
  // #232 — a v1 fetch gets the upstream body as raw bytes, not decoded text.
1838
- if (msg.op === "fetch") return proxyFetch(msg, signal, emit);
1839
- if (msg.op === "service.fetch") return serviceFetch(msg, signal, emit);
1842
+ if (msg.op === "fetch") return proxyFetch(msg, signal, emit, body);
1843
+ if (msg.op === "service.fetch") return serviceFetch(msg, signal, emit, body);
1840
1844
 
1841
1845
  if (msg.op === "assets.get") {
1842
1846
  if (!bindings.assets) throw new Error("assets not bound");
@@ -1868,7 +1872,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1868
1872
  }
1869
1873
 
1870
1874
  /** One wire frame in, one out. See `handleFrame` on the Broker type. */
1871
- async function handleFrame(payload: Buffer, signal?: AbortSignal, emit?: FrameEmitter): Promise<Buffer> {
1875
+ async function handleFrame(payload: Buffer, signal?: AbortSignal, emit?: FrameEmitter, body?: ReadableStream<Uint8Array>): Promise<Buffer> {
1872
1876
  if (payload.length === 0 || payload[0] !== FRAME_V1) {
1873
1877
  return encodeFrame(await handlePayload(payload.toString("utf8")));
1874
1878
  }
@@ -1884,7 +1888,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1884
1888
  const seen = replayed.get(replayKey);
1885
1889
  if (seen) return frameOf(encodeV1(seen));
1886
1890
  }
1887
- const { reply, bytes } = await dispatchBinary(msg, binary, signal, emit);
1891
+ const { reply, bytes } = await dispatchBinary(msg, binary, signal, emit, body);
1888
1892
  if (replayKey && REPLAYABLE.has(str(msg.op))) rememberReplay(replayKey, reply);
1889
1893
  return frameOf(encodeV1(reply, bytes));
1890
1894
  } catch (e) {
@@ -2238,6 +2242,85 @@ export function encodeFrame(obj: Frame): Buffer {
2238
2242
  /** The bound TCP listener: the port actually assigned, and its shutdown handle. */
2239
2243
  export type BrokerServer = { port: number; stop(): void };
2240
2244
 
2245
+ /** Whether a payload is the head of a fetch whose request body follows in FRAME_V1_MORE frames. */
2246
+ function bodyStreamHead(payload: Buffer): boolean {
2247
+ if (payload.length < 5 || payload[0] !== FRAME_V1) return false;
2248
+ const jsonLen = payload.readUInt32LE(1);
2249
+ if (5 + jsonLen > payload.length) return false;
2250
+ const head = payload.subarray(5, 5 + jsonLen).toString("utf8");
2251
+ // Cheap first: most frames are not this.
2252
+ if (!head.includes('"bodyStream":true')) return false;
2253
+ const msg = jsonObject(parseJsonValue(head));
2254
+ return msg?.bodyStream === true;
2255
+ }
2256
+
2257
+ /** The request body of a streamed-body request, fed by the frames after its head. */
2258
+ type BodySink = {
2259
+ stream: ReadableStream<Uint8Array>;
2260
+ /** A FRAME_V1_MORE payload: `{ chunk: true }` plus bytes, `{ end: true }`, or `{ error }`. */
2261
+ frame(payload: Buffer): void;
2262
+ fail(reason: string): void;
2263
+ };
2264
+ /** Bytes a streamed request body may hold unread before the sprout's connection is paused. */
2265
+ const BODY_SINK_HIGH = 4 * 1024 * 1024;
2266
+ function bodySink(socket: { pause(): void; resume(): void }): BodySink {
2267
+ let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
2268
+ let finished = false;
2269
+ let paused = false;
2270
+ const resume = () => {
2271
+ if (paused) {
2272
+ paused = false;
2273
+ socket.resume();
2274
+ }
2275
+ };
2276
+ const finish = () => {
2277
+ finished = true;
2278
+ resume();
2279
+ };
2280
+ const stream = new ReadableStream<Uint8Array>(
2281
+ {
2282
+ start(c) {
2283
+ controller = c;
2284
+ },
2285
+ pull() {
2286
+ resume();
2287
+ },
2288
+ cancel() {
2289
+ finish();
2290
+ },
2291
+ },
2292
+ new ByteLengthQueuingStrategy({ highWaterMark: BODY_SINK_HIGH }),
2293
+ );
2294
+ return {
2295
+ stream,
2296
+ frame(payload) {
2297
+ if (finished || !controller || payload.length < 5) return;
2298
+ const jsonLen = payload.readUInt32LE(1);
2299
+ const msg = jsonObject(parseJsonValue(payload.subarray(5, 5 + jsonLen).toString("utf8")));
2300
+ if (msg?.chunk === true) {
2301
+ const bytes = payload.subarray(5 + jsonLen);
2302
+ if (bytes.length) controller.enqueue(new Uint8Array(bytes));
2303
+ if ((controller.desiredSize ?? 1) <= 0 && !paused) {
2304
+ paused = true;
2305
+ socket.pause();
2306
+ }
2307
+ return;
2308
+ }
2309
+ finish();
2310
+ if (msg?.end === true) controller.close();
2311
+ else {
2312
+ const error = msg?.error;
2313
+ controller.error(new Error(isString(error) ? error : "the request body failed"));
2314
+ }
2315
+ },
2316
+ fail(reason) {
2317
+ if (finished) return;
2318
+ finish();
2319
+ controller?.error(new Error(reason));
2320
+ },
2321
+ };
2322
+ }
2323
+
2241
2324
  /** Start the TCP listener. Returns the bound port. */
2242
2325
  export function listen(broker: Broker, hostname: string, port: number): BrokerServer {
2243
2326
  // Chunks are held in a list and joined once, when a whole frame has arrived.
@@ -2254,6 +2337,8 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
2254
2337
  written: (() => void) | null;
2255
2338
  /** Aborted when the connection closes, so upstream requests it started stop. */
2256
2339
  abort: AbortController;
2340
+ /** The request body a streamed-body request on this connection is reading, if any. */
2341
+ sink: BodySink | null;
2257
2342
  };
2258
2343
  const flush = (socket: Bun.Socket<SocketState>): void => {
2259
2344
  const state = socket.data;
@@ -2291,6 +2376,7 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
2291
2376
  offset: 0,
2292
2377
  written: null,
2293
2378
  abort: new AbortController(),
2379
+ sink: null,
2294
2380
  };
2295
2381
  },
2296
2382
  drain: flush,
@@ -2301,6 +2387,7 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
2301
2387
  state.chunks = [];
2302
2388
  state.size = 0;
2303
2389
  state.output = null;
2390
+ state.sink?.fail("the sprout closed the connection");
2304
2391
  const done = state.written;
2305
2392
  state.written = null;
2306
2393
  done?.();
@@ -2331,6 +2418,23 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
2331
2418
  const rest = buf.subarray(4 + len);
2332
2419
  state.chunks = rest.length ? [Buffer.from(rest)] : [];
2333
2420
  state.size = rest.length;
2421
+ // A frame of a streamed request body: to its request's stream.
2422
+ if (payload.length > 0 && payload[0] === FRAME_V1_MORE) {
2423
+ state.sink?.frame(payload);
2424
+ continue;
2425
+ }
2426
+ // A request whose body follows in frames: it runs while this loop feeds them.
2427
+ if (bodyStreamHead(payload)) {
2428
+ const sink = bodySink(socket);
2429
+ state.sink = sink;
2430
+ void (async () => {
2431
+ const reply = await broker.handleFrame(payload, state.abort.signal, (frame) => send(socket, frame), sink.stream);
2432
+ await send(socket, reply);
2433
+ // Body frames the sprout still sends (the upstream answered early) are dropped.
2434
+ sink.fail("the request already ended");
2435
+ })();
2436
+ continue;
2437
+ }
2334
2438
  // Bun does not buffer unwritten bytes. Serialize replies and retain
2335
2439
  // the unsent suffix until drain, including binary R2 responses.
2336
2440
  await send(socket, await broker.handleFrame(payload, state.abort.signal, (frame) => send(socket, frame)));