@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 +17 -0
- package/package.json +2 -2
- package/src/broker.test.ts +57 -0
- package/src/broker.ts +115 -11
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.
|
|
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.
|
|
26
|
+
"@sproutboat/runtime": "^0.28.0"
|
|
27
27
|
}
|
|
28
28
|
}
|
package/src/broker.test.ts
CHANGED
|
@@ -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
|
-
|
|
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)));
|