@sproutboat/runtime 0.19.1 → 0.20.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,75 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 85aa74d: Service-binding calls and outbound `fetch()` no longer block a deployed sprout. In a deployed sprout, each call runs off the event loop on one I/O thread, so the sprout keeps answering other requests while a call is in flight. Concurrent calls overlap: eight 500 ms calls finish in about 510 ms, where they used to queue for about 4 s. A deployment can also call itself through a binding, to any depth, where this used to deadlock. The toolchain installs the loop hook the runtime uses for this; with an older runtime it does nothing.
8
+
9
+ `env.SERVICE.fetch()` now always returns a promise, as in Workers, and reads its arguments as `fetch()` does:
10
+
11
+ - a `URL` object works as input;
12
+ - `fetch(request, init)` keeps the request's body when `init` sets no body of its own;
13
+ - a `ReadableStream` body is sent whole, where it used to arrive as the text `[object ReadableStream]`.
14
+
15
+ Responses from bindings and from `fetch()` keep every `Set-Cookie` header, where they used to keep only the last one.
16
+
17
+ A broker exchange is now resent only when the broker cannot have acted on it: the request never fully left, or the connection had gone stale. A `fetch` or service call that the broker may already have run is no longer repeated.
18
+ - f2d2b72: Service-binding calls behave as in workerd in two more ways, on a platform edge that vouches for them:
19
+
20
+ - **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.
21
+ - **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.
22
+
23
+ How the platform vouches for a call:
24
+
25
+ 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.
26
+ 2. The edge forwards the call to the callee with that sprout's own token (`SB_EDGE_TOKEN`).
27
+ 3. The callee trusts a call only when the token matches.
28
+
29
+ A client can forge none of this. The edge removes its headers from every other request, and the error marker never reaches a client.
30
+
31
+ 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.
32
+
33
+ In `@sproutboat/wire`, the broker reads `SB_EDGE_SECRET` and takes an `edgeSecret` option.
34
+ - b287117: `fetch()` and service bindings honour an abort signal, set as `init.signal` or a `Request`'s `signal`:
35
+
36
+ - A signal that is already aborted rejects the call at once.
37
+ - Aborting it later, by hand or through `AbortSignal.timeout()`, rejects the pending call with the signal's reason.
38
+
39
+ 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.
40
+
41
+ In sproutd the request still runs to the end, and its late reply is dropped.
42
+
43
+ In `@sproutboat/wire`, `Broker.handleFrame` takes an optional `AbortSignal`, which the broker server aborts when the connection closes.
44
+ - 9773e45: From compatibility date `2026-10-22`, `fetch()` and service bindings follow redirects as Fetch does:
45
+
46
+ - Under `redirect: "follow"`, the default, the request goes again to the new location, up to 20 times. Each outbound hop is checked against the egress rules like the first.
47
+ - A 303 turns the request into a GET with no body, and so does a 301 or 302 that answers a POST.
48
+ - A hop to another origin drops `Authorization`.
49
+ - A service binding follows through the same binding, as in workerd.
50
+ - `redirect: "error"` rejects with a `TypeError`.
51
+ - `redirect: "manual"` returns the 3xx as it is.
52
+
53
+ Before this date, a 3xx came back to the handler whatever the mode, and still does for apps with an earlier `compatibility_date`.
54
+
55
+ A response from `fetch()` or a service binding that has no `Content-Type` no longer gains `text/plain;charset=UTF-8`.
56
+ - 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.
57
+
58
+ 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.
59
+
60
+ 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.
61
+
62
+ In `@sproutboat/wire`:
63
+ - 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`;
64
+ - the final frame ends the response;
65
+ - a request that doesn't ask is answered whole, as before.
66
+
67
+ ## 0.19.2
68
+
69
+ ### Patch Changes
70
+
71
+ - bc3fd25: `Response.body` and `Request.body` are `ReadableStream`s only from compatibility date `2026-10-21`. 0.19.0 changed this for every app, which broke handlers that read `request.body` as a string, such as `JSON.parse(request.body)` in the CLI's own D1 example. An app with an earlier `compatibility_date` keeps the raw body, as before. To opt in, move `compatibility_date` to `2026-10-21` or later and rebuild.
72
+
3
73
  ## 0.19.1
4
74
 
5
75
  ### Patch Changes
package/WEB_STREAMS.md CHANGED
@@ -20,8 +20,10 @@ runtime links no zlib. Compression writes one fixed-Huffman block per chunk as i
20
20
  (close to zlib on text: 252 bytes against 221 for 22.5 KB); decompression inflates all
21
21
  block types once the input ends and checks the CRC-32 or Adler-32. Response and Request
22
22
  bodies that are ReadableStreams are read by text(), json(), arrayBuffer() and blob(), and
23
- their chunks must be bytes, as in workerd. `body` is a ReadableStream for every body type,
24
- as in Fetch: a stream body itself, otherwise a stream made once over the bytes.
23
+ their chunks must be bytes, as in workerd. From compatibility date 2026-10-21, `body` is a
24
+ ReadableStream for every body type, as in Fetch: a stream body itself, otherwise a stream made
25
+ once over the bytes. Before that date it is the value as given, as apps written for earlier
26
+ releases read it (`JSON.parse(request.body)`).
25
27
  BYOB buffer detachment requires transfer support from the underlying engine;
26
28
  the polyfill cannot supply that engine primitive. Full Web Platform Tests
27
29
  conformance has not been established for Porffor.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -5183,86 +5183,261 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5183
5183
 
5184
5184
  // #48 — worker-to-worker. Same wire shape as outbound fetch, but the broker
5185
5185
  // resolves the target itself and forwards it internally, so this is not
5186
- // egress and is not subject to the private-address block.
5186
+ // egress and is not subject to the private-address block. Always a promise, as
5187
+ // in Workers; on an async host the caller keeps serving while the call runs,
5188
+ // which is also what lets a deployment call itself without deadlocking.
5187
5189
  for (let i = 0; i < (bindings.services || []).length; i++) {
5188
5190
  const binding = bindings.services[i].binding;
5189
5191
  target[binding] = {
5190
- fetch(input, init) {
5191
- const url = __sbIsStr(input) ? input : String((input && input.url) || "https://service/");
5192
- const opts = init || (!__sbIsStr(input) && input) || {};
5193
- const headers = [];
5194
- if (opts.headers) {
5195
- if (__sbIsFn(opts.headers.forEach)) opts.headers.forEach((v, k) => headers.push([k, v]));
5196
- else for (const k in opts.headers) headers.push([k, opts.headers[k]]);
5197
- }
5198
- const r = globalThis.__sbFetchUpstream("service.fetch", {
5199
- binding,
5200
- url,
5201
- method: opts.method || "GET",
5202
- headers,
5203
- ...__sbRequestBody(opts instanceof Request ? __sbRawBody(opts) : opts.body),
5204
- });
5205
- const respHeaders = new Headers();
5206
- for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
5207
- // #176: wire bytes from another deployment, not a string this handler
5208
- // built -- must not be re-encoded if the handler proxies it straight
5209
- // through (see the assets binding for the same reasoning).
5210
- if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
5211
- return __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
5192
+ async fetch(input, init) {
5193
+ const parts = __sbFetchParts(input, init);
5194
+ if (parts.signal) parts.signal.throwIfAborted();
5195
+ // A stream body is read to the end first: the wire carries a whole body.
5196
+ let body = __sbUnwrapBody(parts.body);
5197
+ if (body instanceof ReadableStream) body = await __sbCollectBody(body);
5198
+ const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(body) };
5199
+ const response = await __sbFetchFollowing("service.fetch", msg, "sproutboat service " + binding, parts.redirect, parts.signal);
5200
+ // The callee threw: as in workerd, its error is thrown here.
5201
+ const uncaught = response.headers.get("x-sb-uncaught");
5202
+ if (uncaught != null) throw __sbRemoteError(uncaught);
5203
+ return response;
5212
5204
  },
5213
5205
  };
5214
5206
  }
5215
5207
 
5216
5208
  };
5217
5209
 
5218
- // fetch() reaches any public address; the transport refuses private and
5219
- // reserved ones at connect (baronunread/sproutboat#174). No config, like a
5220
- // Worker's fetch. Installed by the generated module (wrap.ts), not here: the
5221
- // prelude must not replace fetch just by being evaluated.
5222
- globalThis.__sbInstallFetch = function () {
5223
- globalThis.fetch = function (input, init) {
5224
- // A Request's method, headers and body apply unless init overrides them; a URL
5225
- // object stands for its href.
5226
- const req = input != null && !__sbIsStr(input) && input.url != null ? input : null;
5227
- const url = req ? String(req.url) : input != null && input.href != null ? String(input.href) : String(input);
5228
- const opts = init || {};
5229
- const source = opts.headers != null ? opts.headers : req ? req.headers : null;
5230
- const headers = [];
5231
- if (source) {
5232
- if (__sbIsFn(source.forEach)) source.forEach((v, k) => headers.push([k, v]));
5233
- else for (const k in source) headers.push([k, source[k]]);
5234
- }
5235
- const body = opts.body !== undefined && !(opts instanceof Request) ? opts.body : req && __sbRawBody(req) !== "" ? __sbRawBody(req) : null;
5236
- const method = opts.method || (req && req.method) || "GET";
5237
- const msg = { url, method, headers, ...__sbRequestBody(body) };
5238
- const respond = (r) => {
5239
- const respHeaders = new Headers();
5240
- for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
5241
- // #176: bytes from an outbound HTTP response, not a string this handler
5242
- // built -- must not be re-encoded if the handler proxies it straight
5243
- // through (see the assets binding for the same reasoning).
5244
- if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
5245
- return __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
5210
+ // The error a service-binding callee threw, from its x-sb-uncaught marker: the same
5211
+ // standard class where there is one, else an Error with the original name.
5212
+ function __sbRemoteError(marker) {
5213
+ let name = "Error";
5214
+ let message = "";
5215
+ try {
5216
+ const parsed = JSON.parse(decodeURIComponent(marker));
5217
+ name = String(parsed.name || "Error");
5218
+ message = String(parsed.message == null ? "" : parsed.message);
5219
+ } catch {
5220
+ /* a marker we cannot read still means the callee threw */
5221
+ }
5222
+ const error =
5223
+ name === "TypeError" ? new TypeError(message)
5224
+ : name === "RangeError" ? new RangeError(message)
5225
+ : name === "SyntaxError" ? new SyntaxError(message)
5226
+ : name === "ReferenceError" ? new ReferenceError(message)
5227
+ : new Error(message);
5228
+ if (error.name !== name) error.name = name;
5229
+ return error;
5230
+ }
5231
+
5232
+ // fetch(input, init) as Fetch reads it: a Request's method, headers, body, redirect mode
5233
+ // and signal apply unless init overrides them; a URL object stands for its href.
5234
+ function __sbFetchParts(input, init) {
5235
+ const req = input != null && !__sbIsStr(input) && input.url != null ? input : null;
5236
+ const url = req ? String(req.url) : input != null && input.href != null ? String(input.href) : String(input);
5237
+ const opts = init || {};
5238
+ const source = opts.headers != null ? opts.headers : req ? req.headers : null;
5239
+ const headers = [];
5240
+ if (source) {
5241
+ if (__sbIsFn(source.forEach)) source.forEach((v, k) => headers.push([k, v]));
5242
+ else for (const k in source) headers.push([k, source[k]]);
5243
+ }
5244
+ const body = opts.body !== undefined && !(opts instanceof Request) ? opts.body : req && __sbRawBody(req) !== "" ? __sbRawBody(req) : null;
5245
+ const method = opts.method || (req && req.method) || "GET";
5246
+ const redirect = opts.redirect || (req && req.redirect) || "follow";
5247
+ const signal = opts.signal || (req && req.signal) || null;
5248
+ return { url, method, headers, body, redirect, signal };
5249
+ }
5250
+
5251
+ // An upstream reply (fetch or service.fetch) -> a Response. Headers are appended,
5252
+ // so each Set-Cookie survives, and a reply without a content type keeps none: the
5253
+ // body reaches Response as a string, which would otherwise default to text/plain.
5254
+ function __sbUpstreamResponse(r) {
5255
+ const respHeaders = new Headers();
5256
+ let typed = false;
5257
+ for (let j = 0; j < (r.headers || []).length; j++) {
5258
+ respHeaders.append(r.headers[j][0], r.headers[j][1]);
5259
+ if (String(r.headers[j][0]).toLowerCase() === "content-type") typed = true;
5260
+ }
5261
+ // #176: bytes from another server or deployment, not a string this handler
5262
+ // built -- must not be re-encoded if the handler proxies it straight
5263
+ // through (see the assets binding for the same reasoning).
5264
+ if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
5265
+ const response = __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
5266
+ if (!typed) response.headers.delete("content-type");
5267
+ return response;
5268
+ }
5269
+
5270
+ // A streamed upstream response (from compatibility date 2026-10-21, where body is a
5271
+ // stream anyway): the call resolves as soon as the head arrives, and the body is a
5272
+ // ReadableStream fed chunk by chunk as the broker passes them on. Each frame fires the
5273
+ // same callback, so it is an interval that the last frame clears. A reply that comes
5274
+ // whole (a broker from before streaming, or sproutd's embedded transport) is handled
5275
+ // as before. Cancelling the body, or the signal, drops the exchange, which stops the
5276
+ // upstream request.
5277
+ function __sbUpstreamStreamed(op, msg, label, signal) {
5278
+ return new Promise((resolve, reject) => {
5279
+ let controller = null;
5280
+ let onAbort = null;
5281
+ let done = false;
5282
+ const finish = () => {
5283
+ done = true;
5284
+ clearInterval(id);
5285
+ if (onAbort) signal.removeEventListener("abort", onAbort);
5246
5286
  };
5247
- if (!__sbHttpAsyncEnabled()) return respond(globalThis.__sbFetchUpstream("fetch", msg));
5248
- // B18: the host runs the request off the event loop. Its reply fires a timer
5249
- // that never fires on its own; the timer table keeps the callback alive.
5250
- return new Promise((resolve, reject) => {
5251
- const id = setTimeout(() => {
5287
+ const id = setInterval(() => {
5288
+ if (done) return;
5289
+ const raw = __sbHttpAsyncResult(id);
5290
+ if (controller === null) {
5291
+ let reply;
5252
5292
  try {
5253
- resolve(respond(__sbFetchReply(__sbHttpAsyncResult(id))));
5293
+ reply = JSON.parse(raw);
5254
5294
  } catch (e) {
5255
- reject(new Error("sproutboat fetch: " + ((e && e.message) || e)));
5295
+ reply = { ok: false, error: String((e && e.message) || e) };
5296
+ }
5297
+ if (reply.ok === false || !reply.stream) {
5298
+ finish();
5299
+ try {
5300
+ resolve(__sbUpstreamResponse(__sbFetchReply(raw)));
5301
+ } catch (e) {
5302
+ reject(new Error(label + ": " + ((e && e.message) || e)));
5303
+ }
5304
+ return;
5256
5305
  }
5257
- }, 2147483647);
5306
+ const headers = new Headers();
5307
+ for (let j = 0; j < (reply.headers || []).length; j++) headers.append(reply.headers[j][0], reply.headers[j][1]);
5308
+ const body = new ReadableStream({
5309
+ start(c) {
5310
+ controller = c;
5311
+ },
5312
+ cancel() {
5313
+ if (done) return;
5314
+ finish();
5315
+ __sbCancelAsync(id);
5316
+ },
5317
+ });
5318
+ resolve(new Response(body, { status: reply.status || 502, headers }));
5319
+ return;
5320
+ }
5321
+ const frame = JSON.parse(raw);
5322
+ const bytes = __sbTakeBin();
5323
+ if (frame.chunk) {
5324
+ if (bytes.length) controller.enqueue(new Uint8Array(__sbBufFrom(bytes)));
5325
+ return;
5326
+ }
5327
+ finish();
5328
+ if (frame.ok === false) controller.error(new Error(label + ": " + (frame.error || "failed")));
5329
+ else controller.close();
5330
+ }, 2147483647);
5331
+ if (signal) {
5332
+ onAbort = () => {
5333
+ if (done) return;
5334
+ finish();
5335
+ __sbCancelAsync(id);
5336
+ if (controller === null) reject(signal.reason);
5337
+ else controller.error(signal.reason);
5338
+ };
5339
+ signal.addEventListener("abort", onAbort);
5340
+ }
5341
+ try {
5342
+ msg.asyncId = id;
5343
+ msg.stream = true;
5344
+ globalThis.__sbFetchUpstream(op, msg);
5345
+ } catch (e) {
5346
+ finish();
5347
+ reject(e);
5348
+ }
5349
+ });
5350
+ }
5351
+
5352
+ // Fetch's redirect handling, from compatibility date 2026-10-22 (before it, a 3xx came
5353
+ // back as it was, whatever the mode). "follow" sends the request again to the new
5354
+ // location, up to 20 times; each hop goes back through the broker, so outbound hops
5355
+ // are vetted like the first. A 303, or a 301/302 to a POST, becomes a bodiless GET;
5356
+ // a hop to another origin drops Authorization. A service binding follows through
5357
+ // the same binding, as in workerd. "error" rejects; "manual" returns the 3xx.
5358
+ function __sbFetchFollowing(op, msg, label, mode, signal) {
5359
+ return __sbFollowHop(op, msg, label, mode, signal, 0);
5360
+ }
5361
+ function __sbFollowHop(op, msg, label, mode, signal, hops) {
5362
+ const step = (res) => {
5363
+ const location = res.headers.get("location");
5364
+ const redirect = res.status === 301 || res.status === 302 || res.status === 303 || res.status === 307 || res.status === 308;
5365
+ if (!(globalThis.__sbCompat >= "2026-10-22") || mode === "manual" || !redirect || location == null) return res;
5366
+ if (mode === "error") throw new TypeError(label + ": redirect not allowed (redirect: \"error\")");
5367
+ if (hops >= 20) throw new TypeError(label + ": too many redirects");
5368
+ const dropped = __sbRawBody(res);
5369
+ if (dropped instanceof ReadableStream) dropped.cancel();
5370
+ const from = new URL(msg.url);
5371
+ const to = new URL(location, from);
5372
+ const next = { ...msg, url: to.href };
5373
+ delete next.asyncId;
5374
+ const toGet = res.status === 303 ? msg.method !== "GET" && msg.method !== "HEAD" : res.status !== 307 && res.status !== 308 && msg.method === "POST";
5375
+ const drop = (name) =>
5376
+ (toGet && /^content-(type|length|encoding|language|location)$/.test(name)) || (to.origin !== from.origin && name === "authorization");
5377
+ next.headers = msg.headers.filter((pair) => !drop(String(pair[0]).toLowerCase()));
5378
+ if (toGet) {
5379
+ next.method = "GET";
5380
+ next.body = null;
5381
+ delete next.bodyBinary;
5382
+ }
5383
+ return __sbFollowHop(op, next, label, mode, signal, hops + 1);
5384
+ };
5385
+ const out = __sbUpstream(op, msg, label, signal);
5386
+ return out instanceof Promise ? out.then(step) : step(out);
5387
+ }
5388
+
5389
+ // Sends `msg` as `op` and answers with the Response: at once on a synchronous
5390
+ // transport, or (B18) as a promise when the host runs requests off its event loop.
5391
+ // The reply fires a timer that never fires on its own; the timer table keeps the
5392
+ // callback alive until then. An aborted `signal` rejects with its reason and cancels
5393
+ // the exchange, which closes its broker connection: the broker stops the upstream
5394
+ // request, and the edge in turn stops the callee's.
5395
+ function __sbUpstream(op, msg, label, signal) {
5396
+ if (signal) signal.throwIfAborted();
5397
+ if (!__sbHttpAsyncEnabled()) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
5398
+ if (globalThis.__sbCompat >= "2026-10-21") return __sbUpstreamStreamed(op, msg, label, signal);
5399
+ return new Promise((resolve, reject) => {
5400
+ let onAbort = null;
5401
+ const settled = () => {
5402
+ if (onAbort) signal.removeEventListener("abort", onAbort);
5403
+ };
5404
+ const id = setTimeout(() => {
5405
+ settled();
5258
5406
  try {
5259
- msg.asyncId = id;
5260
- globalThis.__sbFetchUpstream("fetch", msg);
5407
+ resolve(__sbUpstreamResponse(__sbFetchReply(__sbHttpAsyncResult(id))));
5261
5408
  } catch (e) {
5262
- clearTimeout(id);
5263
- reject(e);
5409
+ reject(new Error(label + ": " + ((e && e.message) || e)));
5264
5410
  }
5265
- });
5411
+ }, 2147483647);
5412
+ if (signal) {
5413
+ onAbort = () => {
5414
+ settled();
5415
+ clearTimeout(id);
5416
+ __sbCancelAsync(id);
5417
+ reject(signal.reason);
5418
+ };
5419
+ signal.addEventListener("abort", onAbort);
5420
+ }
5421
+ try {
5422
+ msg.asyncId = id;
5423
+ globalThis.__sbFetchUpstream(op, msg);
5424
+ } catch (e) {
5425
+ settled();
5426
+ clearTimeout(id);
5427
+ reject(e);
5428
+ }
5429
+ });
5430
+ }
5431
+
5432
+ // fetch() reaches any public address; the transport refuses private and
5433
+ // reserved ones at connect (baronunread/sproutboat#174). No config, like a
5434
+ // Worker's fetch. Installed by the generated module (wrap.ts), not here: the
5435
+ // prelude must not replace fetch just by being evaluated.
5436
+ globalThis.__sbInstallFetch = function () {
5437
+ globalThis.fetch = function (input, init) {
5438
+ const parts = __sbFetchParts(input, init);
5439
+ const msg = { url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(parts.body) };
5440
+ return __sbFetchFollowing("fetch", msg, "sproutboat fetch", parts.redirect, parts.signal);
5266
5441
  };
5267
5442
  };
5268
5443
 
@@ -5744,7 +5919,7 @@ function __sbStreamResponse(response) {
5744
5919
  return response;
5745
5920
  }
5746
5921
 
5747
- async function __sbFetchWithDrain(res, tasks) {
5922
+ async function __sbFetchWithDrain(res, tasks, bindingCall) {
5748
5923
  let r;
5749
5924
  try {
5750
5925
  r = res && __sbIsFn(res.then) ? await res : res;
@@ -5754,7 +5929,7 @@ async function __sbFetchWithDrain(res, tasks) {
5754
5929
  }
5755
5930
  } catch (error) {
5756
5931
  // #179 — a rejected handler promise must not take the process down with it.
5757
- r = __sbErrorResponse(error);
5932
+ r = __sbErrorResponse(error, bindingCall);
5758
5933
  }
5759
5934
  // The response goes out now and ctx.waitUntil work keeps running after it, as in
5760
5935
  // workerd; it was awaited first, so a 1.5 s background task delayed the response by
@@ -5767,14 +5942,42 @@ async function __sbFetchWithDrain(res, tasks) {
5767
5942
  // propagate past __sbEntry and crash the whole server. Logged first, the way a
5768
5943
  // Worker logs "Uncaught Error: ...": a swallowed exception left a 500 with
5769
5944
  // nothing on stderr, which hid #168 entirely.
5770
- function __sbErrorResponse(error) {
5945
+ function __sbErrorResponse(error, bindingCall) {
5771
5946
  __sbLogUncaught("fetch", error);
5772
5947
  // Untyped, as workerd sends it: the text/plain default is for handlers' bodies.
5773
5948
  const response = new Response("Internal Server Error", { status: 500 });
5774
5949
  response.headers.delete("content-type");
5950
+ // A service-binding caller rethrows the error, as in workerd. Only on a call the
5951
+ // edge vouched for: the marker carries the message, and the edge strips it from
5952
+ // anything that goes to a client.
5953
+ if (bindingCall) {
5954
+ const name = (error && error.name) || "Error";
5955
+ const message = error && error.message != null ? String(error.message) : String(error);
5956
+ response.headers.set("x-sb-uncaught", encodeURIComponent(JSON.stringify({ name: String(name), message: message.slice(0, 1000) })));
5957
+ }
5775
5958
  return response;
5776
5959
  }
5777
5960
 
5961
+ // A service-binding call the platform's edge vouched for: it carries the token the edge
5962
+ // gave this sprout (SB_EDGE_TOKEN), so no client can claim one. Such a call sees the URL
5963
+ // its caller passed (x-sb-url), not the deployment's hostname, and no remote address,
5964
+ // as in workerd. The edge's headers are removed from every request before the handler
5965
+ // sees it. Returns whether the edge vouched for this one.
5966
+ function __sbBindingCallIngress(request) {
5967
+ const headers = request.headers;
5968
+ const want = __sbEnv("SB_EDGE_TOKEN");
5969
+ const vouched = !!want && headers.get("x-sb-binding-call") === "1" && __sbEqCt(headers.get("x-sb-edge-token") || "", want);
5970
+ if (vouched) {
5971
+ const url = headers.get("x-sb-url");
5972
+ if (url && URL.canParse(url)) request.url = url;
5973
+ headers.delete("x-sb-remote-addr");
5974
+ }
5975
+ for (const name of ["x-sb-binding-call", "x-sb-url", "x-sb-edge-token"]) {
5976
+ if (headers.get(name) != null) headers.delete(name);
5977
+ }
5978
+ return vouched;
5979
+ }
5980
+
5778
5981
  function __sbLogUncaught(where, error) {
5779
5982
  try {
5780
5983
  let text = "";
@@ -5830,6 +6033,7 @@ globalThis.__sbEntry = function (handlers, request) {
5830
6033
  __sbDecodeIngress(request);
5831
6034
  const trigger = request.headers.get("x-sb-trigger");
5832
6035
  if (!trigger) {
6036
+ const bindingCall = __sbBindingCallIngress(request);
5833
6037
  // #163/#128 — expose the request metadata the edge already has, the
5834
6038
  // Workers way, before the handler runs. httpProtocol/tlsVersion/tlsCipher
5835
6039
  // are only set when a trusted front proxy forwarded them; colo, ASN and
@@ -5868,11 +6072,11 @@ globalThis.__sbEntry = function (handlers, request) {
5868
6072
  } catch (error) {
5869
6073
  // #179 — a synchronous throw anywhere in the handler must not take the
5870
6074
  // whole process (and every other in-flight request) down with it.
5871
- return __sbErrorResponse(error);
6075
+ return __sbErrorResponse(error, bindingCall);
5872
6076
  }
5873
6077
  // Same promise-identity rule as above applies to `__sbFetchWithDrain`'s
5874
6078
  // own return, which is why it's tail-called rather than chained on here.
5875
- if (__sbTasks.length || (__res && __sbIsFn(__res.then)) || __sbHasStreamBody(__res)) return __sbFetchWithDrain(__res, __sbTasks);
6079
+ if (__sbTasks.length || (__res && __sbIsFn(__res.then)) || __sbHasStreamBody(__res)) return __sbFetchWithDrain(__res, __sbTasks, bindingCall);
5876
6080
  return __sbTagCpu(__res, __t0);
5877
6081
  }
5878
6082
  if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });