@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 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.13.2",
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.19.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 = Number(process.env.SB_FETCH_MAX_BYTES) || 32 * 1024 * 1024;
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
- async function proxyFetch(msg: Frame): Promise<Upstream> {
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(msg: Frame, binary: Uint8Array): Promise<{ reply: Frame; bytes?: Uint8Array }> {
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 = { chunks: [], size: 0, processing: false, closed: false, output: null, offset: 0, written: null };
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
  });