@sproutboat/wire 0.13.2 → 0.14.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 +50 -0
- package/package.json +2 -2
- package/src/broker.ts +80 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# @sproutboat/wire
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f2d2b72: Service-binding calls behave as in workerd in two more ways, on a platform edge that vouches for them:
|
|
8
|
+
|
|
9
|
+
- **The callee sees the caller's URL.** `request.url` is the URL the caller passed (`https://peer/path`), where it used to be the callee deployment's own hostname.
|
|
10
|
+
- **A callee that throws makes the caller throw.** The caller's `fetch()` rejects with the callee's error, keeping its class for standard errors and its message, where it used to get a plain 500 response.
|
|
11
|
+
|
|
12
|
+
How the platform vouches for a call:
|
|
13
|
+
|
|
14
|
+
1. The broker sends each service call with the edge's secret (`SB_EDGE_SECRET`, which the edge gives only to brokers) and the caller's URL.
|
|
15
|
+
2. The edge forwards the call to the callee with that sprout's own token (`SB_EDGE_TOKEN`).
|
|
16
|
+
3. The callee trusts a call only when the token matches.
|
|
17
|
+
|
|
18
|
+
A client can forge none of this. The edge removes its headers from every other request, and the error marker never reaches a client.
|
|
19
|
+
|
|
20
|
+
A vouched call also no longer shows the broker's loopback address as `x-sb-remote-addr`. The edge's internal headers are removed before any handler sees a request.
|
|
21
|
+
|
|
22
|
+
In `@sproutboat/wire`, the broker reads `SB_EDGE_SECRET` and takes an `edgeSecret` option.
|
|
23
|
+
- b287117: `fetch()` and service bindings honour an abort signal, set as `init.signal` or a `Request`'s `signal`:
|
|
24
|
+
|
|
25
|
+
- A signal that is already aborted rejects the call at once.
|
|
26
|
+
- Aborting it later, by hand or through `AbortSignal.timeout()`, rejects the pending call with the signal's reason.
|
|
27
|
+
|
|
28
|
+
In a deployed sprout the abort also stops the work. The sprout closes the call's broker connection, the broker aborts its upstream request when a sprout's connection closes, and the platform's edge stops the callee's request in turn. Before, an abandoned call ran to the end anyway.
|
|
29
|
+
|
|
30
|
+
In sproutd the request still runs to the end, and its late reply is dropped.
|
|
31
|
+
|
|
32
|
+
In `@sproutboat/wire`, `Broker.handleFrame` takes an optional `AbortSignal`, which the broker server aborts when the connection closes.
|
|
33
|
+
- 3798994: Responses to `fetch()` and service-binding calls stream. In a deployed sprout with compatibility date `2026-10-21` or later, the call resolves as soon as the status and headers arrive. `response.body` is then a `ReadableStream` that receives each chunk as the upstream sends it.
|
|
34
|
+
|
|
35
|
+
Before, the whole body had to arrive first. A response streamed over several seconds, such as server-sent events or a long download, reached the caller only at the end.
|
|
36
|
+
|
|
37
|
+
The 32 MiB `SB_FETCH_MAX_BYTES` cap still applies to the total. Cancelling the body, or aborting the call's signal, stops the upstream request.
|
|
38
|
+
|
|
39
|
+
In `@sproutboat/wire`:
|
|
40
|
+
- the broker streams a `fetch` or `service.fetch` that asks for it (`stream: true`), sending the head and each chunk as `FRAME_V1_MORE` frames through the new `emit` argument of `handleFrame`;
|
|
41
|
+
- the final frame ends the response;
|
|
42
|
+
- a request that doesn't ask is answered whole, as before.
|
|
43
|
+
|
|
44
|
+
### Patch Changes
|
|
45
|
+
|
|
46
|
+
- Updated dependencies [85aa74d]
|
|
47
|
+
- Updated dependencies [f2d2b72]
|
|
48
|
+
- Updated dependencies [b287117]
|
|
49
|
+
- Updated dependencies [9773e45]
|
|
50
|
+
- Updated dependencies [3798994]
|
|
51
|
+
- @sproutboat/runtime@0.20.0
|
|
52
|
+
|
|
3
53
|
## 0.13.2
|
|
4
54
|
|
|
5
55
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutboat/wire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"types": "./src/index.ts",
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@sproutboat/assets": "^0.3.0",
|
|
15
|
-
"@sproutboat/runtime": "^0.
|
|
15
|
+
"@sproutboat/runtime": "^0.20.0"
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
|
18
18
|
".": "./src/index.ts",
|
package/src/broker.ts
CHANGED
|
@@ -76,6 +76,12 @@ export type BrokerOptions = {
|
|
|
76
76
|
* of where a deployment is running.
|
|
77
77
|
*/
|
|
78
78
|
edgeUrl?: string;
|
|
79
|
+
/**
|
|
80
|
+
* The edge's secret for service-binding calls (`SB_EDGE_SECRET`). With it, a call
|
|
81
|
+
* carries the secret and the caller's URL, so the edge can vouch for the call to the
|
|
82
|
+
* callee: the callee then sees that URL, and a callee that throws throws in the caller.
|
|
83
|
+
*/
|
|
84
|
+
edgeSecret?: string;
|
|
79
85
|
/**
|
|
80
86
|
* #48 — binding name -> the target's hostname, resolved by the control plane
|
|
81
87
|
* at activation. Not part of the artifact: the same binary deploys to boxes
|
|
@@ -185,9 +191,13 @@ export type Broker = {
|
|
|
185
191
|
* v1 starts with a 0x01 marker and carries `[u32 LE json length][json][bytes]`,
|
|
186
192
|
* so an object body never has to be escaped into the JSON. A token's first
|
|
187
193
|
* character is never 0x01, which is what makes the two tellable apart without
|
|
188
|
-
* negotiation.
|
|
194
|
+
* negotiation. `signal` aborts when the sprout's connection closes: a `fetch` or
|
|
195
|
+
* `service.fetch` in flight then stops its upstream request too. With `emit`, a
|
|
196
|
+
* `fetch` or `service.fetch` that asks to stream (`stream: true`) sends its head and
|
|
197
|
+
* each body chunk through it as they arrive (FRAME_V1_MORE frames); the returned
|
|
198
|
+
* frame ends the response.
|
|
189
199
|
*/
|
|
190
|
-
handleFrame(payload: Buffer): Promise<Buffer>;
|
|
200
|
+
handleFrame(payload: Buffer, signal?: AbortSignal, emit?: FrameEmitter): Promise<Buffer>;
|
|
191
201
|
/** Serve one reserved direct R2 upload or download request. */
|
|
192
202
|
transfer(request: Request): Promise<Response>;
|
|
193
203
|
close(): void;
|
|
@@ -195,6 +205,10 @@ export type Broker = {
|
|
|
195
205
|
|
|
196
206
|
/** First byte of a v1 payload. */
|
|
197
207
|
export const FRAME_V1 = 1;
|
|
208
|
+
/** A v1 frame with more to follow for the same request: a streamed response's head and chunks. */
|
|
209
|
+
export const FRAME_V1_MORE = 2;
|
|
210
|
+
/** Sends one frame for the request being handled, ahead of its final one. */
|
|
211
|
+
export type FrameEmitter = (frame: Buffer) => Promise<void>;
|
|
198
212
|
|
|
199
213
|
/** Wrap a payload in the [u32 LE length] header every frame carries. */
|
|
200
214
|
export function frameOf(payload: Buffer): Buffer {
|
|
@@ -205,11 +219,11 @@ export function frameOf(payload: Buffer): Buffer {
|
|
|
205
219
|
}
|
|
206
220
|
|
|
207
221
|
/** Build a v1 payload: marker, json length, json, then the bytes. */
|
|
208
|
-
export function encodeV1(json: Frame, binary?: Uint8Array): Buffer {
|
|
222
|
+
export function encodeV1(json: Frame, binary?: Uint8Array, more = false): Buffer {
|
|
209
223
|
const body = Buffer.from(JSON.stringify(json), "utf8");
|
|
210
224
|
const bin = binary ?? new Uint8Array(0);
|
|
211
225
|
const out = Buffer.allocUnsafe(1 + 4 + body.length + bin.length);
|
|
212
|
-
out.writeUInt8(FRAME_V1, 0);
|
|
226
|
+
out.writeUInt8(more ? FRAME_V1_MORE : FRAME_V1, 0);
|
|
213
227
|
out.writeUInt32LE(body.length, 1);
|
|
214
228
|
body.copy(out, 5);
|
|
215
229
|
if (bin.length) Buffer.from(bin.buffer, bin.byteOffset, bin.length).copy(out, 5 + body.length);
|
|
@@ -1095,8 +1109,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1095
1109
|
* end the process. 32 MiB by default, and the same variable the embedded
|
|
1096
1110
|
* transport reads so both backends agree.
|
|
1097
1111
|
*/
|
|
1112
|
+
const fetchMaxBytes = () => Number(process.env.SB_FETCH_MAX_BYTES) || 32 * 1024 * 1024;
|
|
1098
1113
|
async function readCapped(response: Response, host: string): Promise<Buffer> {
|
|
1099
|
-
const cap =
|
|
1114
|
+
const cap = fetchMaxBytes();
|
|
1100
1115
|
const declared = Number(response.headers.get("content-length") || 0);
|
|
1101
1116
|
if (declared > cap) throw new Error(`response exceeds SB_FETCH_MAX_BYTES from ${host}`);
|
|
1102
1117
|
const reader = response.body?.getReader();
|
|
@@ -1148,7 +1163,32 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1148
1163
|
return res;
|
|
1149
1164
|
}
|
|
1150
1165
|
|
|
1151
|
-
|
|
1166
|
+
/**
|
|
1167
|
+
* An upstream response, streamed: its head now, then each body chunk as it arrives,
|
|
1168
|
+
* under the same cap as a whole body. `emit` resolves once the sprout's socket has
|
|
1169
|
+
* taken the frame, so a slow reader slows the upstream read too.
|
|
1170
|
+
*/
|
|
1171
|
+
async function streamUpstream(res: Response, host: string, emit: FrameEmitter): Promise<Upstream> {
|
|
1172
|
+
const headers: Array<[string, string]> = [];
|
|
1173
|
+
res.headers.forEach((v, k) => headers.push([k, v]));
|
|
1174
|
+
await emit(frameOf(encodeV1({ ok: true, status: res.status, headers, stream: true }, undefined, true)));
|
|
1175
|
+
const reader = res.body?.getReader();
|
|
1176
|
+
if (!reader) return { reply: { ok: true, end: true }, bytes: Buffer.alloc(0) };
|
|
1177
|
+
const cap = fetchMaxBytes();
|
|
1178
|
+
let total = 0;
|
|
1179
|
+
for (;;) {
|
|
1180
|
+
const { done, value } = await reader.read();
|
|
1181
|
+
if (done) return { reply: { ok: true, end: true }, bytes: Buffer.alloc(0) };
|
|
1182
|
+
total += value.byteLength;
|
|
1183
|
+
if (total > cap) {
|
|
1184
|
+
await reader.cancel();
|
|
1185
|
+
throw new Error(`response exceeds SB_FETCH_MAX_BYTES from ${host}`);
|
|
1186
|
+
}
|
|
1187
|
+
await emit(frameOf(encodeV1({ ok: true, chunk: true }, value, true)));
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
async function proxyFetch(msg: Frame, signal?: AbortSignal, emit?: FrameEmitter): Promise<Upstream> {
|
|
1152
1192
|
let url: URL;
|
|
1153
1193
|
try {
|
|
1154
1194
|
url = new URL(str(msg.url));
|
|
@@ -1169,12 +1209,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1169
1209
|
headers,
|
|
1170
1210
|
body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
|
|
1171
1211
|
redirect: "manual",
|
|
1212
|
+
signal,
|
|
1172
1213
|
};
|
|
1173
1214
|
const res = egressUrl
|
|
1174
1215
|
? await viaEgressService(url, init)
|
|
1175
1216
|
: await vettedFetch(url, init, { resolve: resolveHost, allow: egressAllow, fetchImpl: doFetch });
|
|
1176
1217
|
const outHeaders: Array<[string, string]> = [];
|
|
1177
1218
|
res.headers.forEach((v, k) => outHeaders.push([k, v]));
|
|
1219
|
+
if (msg.stream === true && emit) return streamUpstream(res, url.host, emit);
|
|
1178
1220
|
return { reply: { ok: true, status: res.status, headers: outHeaders }, bytes: await readCapped(res, url.host) };
|
|
1179
1221
|
}
|
|
1180
1222
|
|
|
@@ -1186,7 +1228,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1186
1228
|
* minus the trip through the network (and therefore minus TLS and the
|
|
1187
1229
|
* default-deny egress rules, which is why this is not `fetch`).
|
|
1188
1230
|
*/
|
|
1189
|
-
async function serviceFetch(msg: Frame): Promise<Upstream> {
|
|
1231
|
+
async function serviceFetch(msg: Frame, signal?: AbortSignal, emit?: FrameEmitter): Promise<Upstream> {
|
|
1190
1232
|
const binding = str(msg.binding);
|
|
1191
1233
|
const declared = bindings.services.find((entry) => entry.binding === binding);
|
|
1192
1234
|
if (!declared) throw new Error(`service not bound: ${binding}`);
|
|
@@ -1211,6 +1253,10 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1211
1253
|
}
|
|
1212
1254
|
}
|
|
1213
1255
|
headers.set("host", host);
|
|
1256
|
+
if (opts.edgeSecret) {
|
|
1257
|
+
headers.set("x-sb-edge-secret", opts.edgeSecret);
|
|
1258
|
+
headers.set("x-sb-url", str(msg.url));
|
|
1259
|
+
}
|
|
1214
1260
|
const method = str(msg.method || "GET").toUpperCase();
|
|
1215
1261
|
const target = new URL(path, opts.edgeUrl);
|
|
1216
1262
|
const res = await doFetch(target, {
|
|
@@ -1218,9 +1264,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1218
1264
|
headers,
|
|
1219
1265
|
body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
|
|
1220
1266
|
redirect: "manual",
|
|
1267
|
+
signal,
|
|
1221
1268
|
});
|
|
1222
1269
|
const outHeaders: Array<[string, string]> = [];
|
|
1223
1270
|
res.headers.forEach((v, k) => outHeaders.push([k, v]));
|
|
1271
|
+
if (msg.stream === true && emit) return streamUpstream(res, host, emit);
|
|
1224
1272
|
return { reply: { ok: true, status: res.status, headers: outHeaders }, bytes: await readCapped(res, host) };
|
|
1225
1273
|
}
|
|
1226
1274
|
|
|
@@ -1658,7 +1706,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1658
1706
|
* #63 — the ops that carry bytes rather than escaping them into the frame.
|
|
1659
1707
|
* Everything else goes through `dispatch` untouched.
|
|
1660
1708
|
*/
|
|
1661
|
-
async function dispatchBinary(
|
|
1709
|
+
async function dispatchBinary(
|
|
1710
|
+
msg: Frame,
|
|
1711
|
+
binary: Uint8Array,
|
|
1712
|
+
signal?: AbortSignal,
|
|
1713
|
+
emit?: FrameEmitter,
|
|
1714
|
+
): Promise<{ reply: Frame; bytes?: Uint8Array }> {
|
|
1662
1715
|
if (msg.op === "r2.put") {
|
|
1663
1716
|
// The body arrived as bytes; write it straight to its own file rather
|
|
1664
1717
|
// than into SQLite (#56) — no escape, no parse, no re-encode, and no
|
|
@@ -1708,8 +1761,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1708
1761
|
}
|
|
1709
1762
|
|
|
1710
1763
|
// #232 — a v1 fetch gets the upstream body as raw bytes, not decoded text.
|
|
1711
|
-
if (msg.op === "fetch") return proxyFetch(msg);
|
|
1712
|
-
if (msg.op === "service.fetch") return serviceFetch(msg);
|
|
1764
|
+
if (msg.op === "fetch") return proxyFetch(msg, signal, emit);
|
|
1765
|
+
if (msg.op === "service.fetch") return serviceFetch(msg, signal, emit);
|
|
1713
1766
|
|
|
1714
1767
|
if (msg.op === "assets.get") {
|
|
1715
1768
|
if (!bindings.assets) throw new Error("assets not bound");
|
|
@@ -1741,7 +1794,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1741
1794
|
}
|
|
1742
1795
|
|
|
1743
1796
|
/** One wire frame in, one out. See `handleFrame` on the Broker type. */
|
|
1744
|
-
async function handleFrame(payload: Buffer): Promise<Buffer> {
|
|
1797
|
+
async function handleFrame(payload: Buffer, signal?: AbortSignal, emit?: FrameEmitter): Promise<Buffer> {
|
|
1745
1798
|
if (payload.length === 0 || payload[0] !== FRAME_V1) {
|
|
1746
1799
|
return encodeFrame(await handlePayload(payload.toString("utf8")));
|
|
1747
1800
|
}
|
|
@@ -1757,7 +1810,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1757
1810
|
const seen = replayed.get(replayKey);
|
|
1758
1811
|
if (seen) return frameOf(encodeV1(seen));
|
|
1759
1812
|
}
|
|
1760
|
-
const { reply, bytes } = await dispatchBinary(msg, binary);
|
|
1813
|
+
const { reply, bytes } = await dispatchBinary(msg, binary, signal, emit);
|
|
1761
1814
|
if (replayKey && REPLAYABLE.has(str(msg.op))) rememberReplay(replayKey, reply);
|
|
1762
1815
|
return frameOf(encodeV1(reply, bytes));
|
|
1763
1816
|
} catch (e) {
|
|
@@ -2124,6 +2177,8 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
|
|
|
2124
2177
|
output: Buffer | null;
|
|
2125
2178
|
offset: number;
|
|
2126
2179
|
written: (() => void) | null;
|
|
2180
|
+
/** Aborted when the connection closes, so upstream requests it started stop. */
|
|
2181
|
+
abort: AbortController;
|
|
2127
2182
|
};
|
|
2128
2183
|
const flush = (socket: Bun.Socket<SocketState>): void => {
|
|
2129
2184
|
const state = socket.data;
|
|
@@ -2152,12 +2207,22 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
|
|
|
2152
2207
|
port,
|
|
2153
2208
|
socket: {
|
|
2154
2209
|
open(socket) {
|
|
2155
|
-
socket.data = {
|
|
2210
|
+
socket.data = {
|
|
2211
|
+
chunks: [],
|
|
2212
|
+
size: 0,
|
|
2213
|
+
processing: false,
|
|
2214
|
+
closed: false,
|
|
2215
|
+
output: null,
|
|
2216
|
+
offset: 0,
|
|
2217
|
+
written: null,
|
|
2218
|
+
abort: new AbortController(),
|
|
2219
|
+
};
|
|
2156
2220
|
},
|
|
2157
2221
|
drain: flush,
|
|
2158
2222
|
close(socket) {
|
|
2159
2223
|
const state = socket.data;
|
|
2160
2224
|
state.closed = true;
|
|
2225
|
+
state.abort.abort();
|
|
2161
2226
|
state.chunks = [];
|
|
2162
2227
|
state.size = 0;
|
|
2163
2228
|
state.output = null;
|
|
@@ -2193,7 +2258,7 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
|
|
|
2193
2258
|
state.size = rest.length;
|
|
2194
2259
|
// Bun does not buffer unwritten bytes. Serialize replies and retain
|
|
2195
2260
|
// the unsent suffix until drain, including binary R2 responses.
|
|
2196
|
-
await send(socket, await broker.handleFrame(payload));
|
|
2261
|
+
await send(socket, await broker.handleFrame(payload, state.abort.signal, (frame) => send(socket, frame)));
|
|
2197
2262
|
} } finally { state.processing = false; }
|
|
2198
2263
|
},
|
|
2199
2264
|
},
|
|
@@ -2287,6 +2352,7 @@ export function startBroker(argv: string[], d1Pool?: D1Pool) {
|
|
|
2287
2352
|
sproutUrl: values["sprout-url"] ?? process.env.SB_SPROUT_URL,
|
|
2288
2353
|
assetsDir: values["assets-dir"],
|
|
2289
2354
|
edgeUrl: values["edge-url"],
|
|
2355
|
+
edgeSecret: process.env.SB_EDGE_SECRET || undefined,
|
|
2290
2356
|
services,
|
|
2291
2357
|
dispatchEnabled: () => dispatchEnabled,
|
|
2292
2358
|
});
|