@sproutboat/runtime 0.19.2 → 0.21.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.
@@ -3594,18 +3594,53 @@ if (globalThis.crypto.getRandomValues == null) {
3594
3594
  Promise.resolve().then(() => callback());
3595
3595
  };
3596
3596
  }
3597
- const nativeAtob = globalThis.atob, nativeBtoa = globalThis.btoa;
3597
+ // Base64 in JavaScript rather than Porffor's atob/btoa: those read a string's storage,
3598
+ // so a string held as UTF-16 (one from JSON.parse or TextDecoder, say) decoded to the
3599
+ // wrong bytes, and btoa encoded its UTF-16 units.
3600
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3598
3601
  globalThis.atob = function (data) {
3599
3602
  let text = String(data).replace(/[\t\n\f\r ]/g, "");
3600
3603
  if (text.length % 4 === 0) text = text.replace(/==?$/, "");
3601
3604
  if (text.length % 4 === 1 || !/^[A-Za-z0-9+/]*$/.test(text)) throw invalidCharacter("atob: the string is not valid base64");
3602
- while (text.length % 4 !== 0) text += "=";
3603
- return nativeAtob(text);
3605
+ const parts = [];
3606
+ let chunk = "";
3607
+ let bits = 0;
3608
+ let count = 0;
3609
+ for (let i = 0; i < text.length; i++) {
3610
+ bits = (bits << 6) | alphabet.indexOf(text[i]);
3611
+ count += 6;
3612
+ if (count >= 8) {
3613
+ count -= 8;
3614
+ chunk += String.fromCharCode((bits >> count) & 0xff);
3615
+ if (chunk.length === 512) {
3616
+ parts.push(chunk);
3617
+ chunk = "";
3618
+ }
3619
+ }
3620
+ }
3621
+ parts.push(chunk);
3622
+ return parts.join("");
3604
3623
  };
3605
3624
  globalThis.btoa = function (data) {
3606
3625
  const text = String(data);
3607
- for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) > 0xff) throw invalidCharacter("btoa: the string has characters outside Latin-1");
3608
- return nativeBtoa(text);
3626
+ const parts = [];
3627
+ let chunk = "";
3628
+ for (let i = 0; i < text.length; i += 3) {
3629
+ const a = text.charCodeAt(i);
3630
+ const b = i + 1 < text.length ? text.charCodeAt(i + 1) : 0;
3631
+ const c = i + 2 < text.length ? text.charCodeAt(i + 2) : 0;
3632
+ if (a > 0xff || b > 0xff || c > 0xff) throw invalidCharacter("btoa: the string has characters outside Latin-1");
3633
+ const n = (a << 16) | (b << 8) | c;
3634
+ chunk += alphabet[(n >> 18) & 63] + alphabet[(n >> 12) & 63];
3635
+ chunk += i + 1 < text.length ? alphabet[(n >> 6) & 63] : "=";
3636
+ chunk += i + 2 < text.length ? alphabet[n & 63] : "=";
3637
+ if (chunk.length >= 512) {
3638
+ parts.push(chunk);
3639
+ chunk = "";
3640
+ }
3641
+ }
3642
+ parts.push(chunk);
3643
+ return parts.join("");
3609
3644
  };
3610
3645
  }
3611
3646
 
@@ -5183,86 +5218,421 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5183
5218
 
5184
5219
  // #48 — worker-to-worker. Same wire shape as outbound fetch, but the broker
5185
5220
  // resolves the target itself and forwards it internally, so this is not
5186
- // egress and is not subject to the private-address block.
5221
+ // egress and is not subject to the private-address block. Always a promise, as
5222
+ // in Workers; on an async host the caller keeps serving while the call runs,
5223
+ // which is also what lets a deployment call itself without deadlocking.
5187
5224
  for (let i = 0; i < (bindings.services || []).length; i++) {
5188
5225
  const binding = bindings.services[i].binding;
5189
- 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 });
5226
+ const fetcher = {
5227
+ async fetch(input, init) {
5228
+ const parts = __sbFetchParts(input, init);
5229
+ if (parts.signal) parts.signal.throwIfAborted();
5230
+ // A stream body is read to the end first: the wire carries a whole body.
5231
+ let body = __sbUnwrapBody(parts.body);
5232
+ if (body instanceof ReadableStream) body = await __sbCollectBody(body);
5233
+ const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(body) };
5234
+ const response = await __sbFetchFollowing("service.fetch", msg, "sproutboat service " + binding, parts.redirect, parts.signal);
5235
+ // The callee threw: as in workerd, its error is thrown here.
5236
+ const uncaught = response.headers.get("x-sb-uncaught");
5237
+ if (uncaught != null) throw __sbRemoteError(uncaught);
5238
+ return response;
5212
5239
  },
5213
5240
  };
5241
+ // Any other name is an RPC method of the callee's WorkerEntrypoint (see __sbRpcCall).
5242
+ // "then" stays undefined, so awaiting the binding itself is not a call.
5243
+ target[binding] = new Proxy(fetcher, {
5244
+ get(t, prop) {
5245
+ if (prop in t) return t[prop];
5246
+ if (!__sbIsStr(prop) || prop === "then") return undefined;
5247
+ return (...args) => __sbRpcCall(binding, prop, args);
5248
+ },
5249
+ });
5214
5250
  }
5215
5251
 
5216
5252
  };
5217
5253
 
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 });
5254
+ // The error a service-binding callee threw, from its x-sb-uncaught marker: the same
5255
+ // standard class where there is one, else an Error with the original name.
5256
+ function __sbRemoteError(marker) {
5257
+ let name = "Error";
5258
+ let message = "";
5259
+ try {
5260
+ const parsed = JSON.parse(decodeURIComponent(marker));
5261
+ name = String(parsed.name || "Error");
5262
+ message = String(parsed.message == null ? "" : parsed.message);
5263
+ } catch {
5264
+ /* a marker we cannot read still means the callee threw */
5265
+ }
5266
+ const error =
5267
+ name === "TypeError" ? new TypeError(message)
5268
+ : name === "RangeError" ? new RangeError(message)
5269
+ : name === "SyntaxError" ? new SyntaxError(message)
5270
+ : name === "ReferenceError" ? new ReferenceError(message)
5271
+ : new Error(message);
5272
+ if (error.name !== name) error.name = name;
5273
+ return error;
5274
+ }
5275
+
5276
+ // Workers RPC (cloudflare:workers). A service binding's methods other than fetch() call
5277
+ // the callee's WorkerEntrypoint: env.SVC.add(1, 2) runs `add(1, 2)` on an instance of
5278
+ // the callee's default export and resolves to what it returns. The call travels as a
5279
+ // service-binding request the platform's edge vouched for (x-sb-rpc names the method),
5280
+ // so no client can invoke a method. Arguments and results are copied as structured
5281
+ // clone does (__sbRpcEncode); functions and RPC stubs are not supported yet. An error
5282
+ // the method throws rejects the call with the same class and message, as fetch does.
5283
+ if (globalThis.WorkerEntrypoint == null) {
5284
+ class WorkerEntrypoint {
5285
+ constructor(ctx, env) {
5286
+ this.ctx = ctx;
5287
+ this.env = env;
5288
+ }
5289
+ }
5290
+ class DurableObject {
5291
+ constructor(ctx, env) {
5292
+ this.ctx = ctx;
5293
+ this.env = env;
5294
+ }
5295
+ }
5296
+ class RpcTarget {}
5297
+ globalThis.WorkerEntrypoint = WorkerEntrypoint;
5298
+ globalThis.DurableObject = DurableObject;
5299
+ globalThis.RpcTarget = RpcTarget;
5300
+ }
5301
+
5302
+ // A value -> JSON that keeps what structured clone keeps: undefined, NaN, ±Infinity
5303
+ // and -0, Dates, RegExps, binary data, Maps, Sets and Errors are tagged ("$"); a plain
5304
+ // object that has its own "$" key is wrapped so it is not read as a tag.
5305
+ function __sbRpcEncode(value) {
5306
+ if (value === undefined) return { $: "u" };
5307
+ if (value === null || __sbIsStr(value) || value === true || value === false) return value;
5308
+ // Number.isNaN is true only for NaN itself; strings and booleans returned above.
5309
+ if (Number.isNaN(value)) return { $: "n", v: "NaN" };
5310
+ if (value === Number(value)) {
5311
+ if (value === Infinity) return { $: "n", v: "Infinity" };
5312
+ if (value === -Infinity) return { $: "n", v: "-Infinity" };
5313
+ if (value === 0 && 1 / value < 0) return { $: "n", v: "-0" };
5314
+ return value;
5315
+ }
5316
+ if (__sbIsFn(value)) {
5317
+ throw new TypeError("Could not serialize a function for an RPC call: passing functions is not supported yet.");
5318
+ }
5319
+ if (Array.isArray(value)) return value.map(__sbRpcEncode);
5320
+ if (value instanceof Date) return { $: "d", v: value.getTime() };
5321
+ if (value instanceof RegExp) return { $: "r", s: value.source, f: value.flags };
5322
+ if (value instanceof ArrayBuffer) return { $: "ab", v: btoa(__sbToBytes(new Uint8Array(value))) };
5323
+ if (ArrayBuffer.isView(value)) {
5324
+ const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
5325
+ return { $: "ta", t: value.constructor.name, v: btoa(__sbToBytes(bytes)) };
5326
+ }
5327
+ if (value instanceof Map) {
5328
+ const entries = [];
5329
+ value.forEach((v, k) => entries.push([__sbRpcEncode(k), __sbRpcEncode(v)]));
5330
+ return { $: "m", v: entries };
5331
+ }
5332
+ if (value instanceof Set) {
5333
+ const items = [];
5334
+ value.forEach((v) => items.push(__sbRpcEncode(v)));
5335
+ return { $: "s", v: items };
5336
+ }
5337
+ if (value instanceof Error) return { $: "e", n: value.name, m: String(value.message) };
5338
+ const out = {};
5339
+ for (const key of Object.keys(value)) out[key] = __sbRpcEncode(value[key]);
5340
+ return Object.prototype.hasOwnProperty.call(value, "$") ? { $: "o", v: out } : out;
5341
+ }
5342
+
5343
+ function __sbRpcDecode(value) {
5344
+ if (value === null || !(value instanceof Object)) return value;
5345
+ if (Array.isArray(value)) return value.map(__sbRpcDecode);
5346
+ const tag = value.$;
5347
+ if (tag === undefined) {
5348
+ const out = {};
5349
+ for (const key of Object.keys(value)) out[key] = __sbRpcDecode(value[key]);
5350
+ return out;
5351
+ }
5352
+ if (tag === "u") return undefined;
5353
+ if (tag === "n") return value.v === "-0" ? -0 : Number(value.v);
5354
+ if (tag === "d") return new Date(value.v);
5355
+ if (tag === "r") return new RegExp(value.s, value.f);
5356
+ if (tag === "ab") return __sbBufFrom(atob(value.v));
5357
+ if (tag === "ta") {
5358
+ const buffer = __sbBufFrom(atob(value.v));
5359
+ const View = globalThis[value.t];
5360
+ if (View === globalThis.DataView) return new DataView(buffer);
5361
+ return View && View.BYTES_PER_ELEMENT ? new View(buffer) : new Uint8Array(buffer);
5362
+ }
5363
+ if (tag === "m") return new Map(value.v.map((pair) => [__sbRpcDecode(pair[0]), __sbRpcDecode(pair[1])]));
5364
+ if (tag === "s") return new Set(value.v.map(__sbRpcDecode));
5365
+ if (tag === "e") return __sbRemoteError(encodeURIComponent(JSON.stringify({ name: value.n, message: value.m })));
5366
+ if (tag === "o") return __sbRpcDecode(value.v);
5367
+ return value;
5368
+ }
5369
+
5370
+ // Caller: one RPC call through service binding `binding`.
5371
+ async function __sbRpcCall(binding, method, args) {
5372
+ const label = "sproutboat service " + binding;
5373
+ const body = JSON.stringify(args.map(__sbRpcEncode));
5374
+ const msg = {
5375
+ binding,
5376
+ url: "https://rpc.sproutboat.internal/" + method,
5377
+ method: "POST",
5378
+ headers: [
5379
+ ["x-sb-rpc", method],
5380
+ ["content-type", "application/json"],
5381
+ ],
5382
+ body,
5383
+ };
5384
+ const response = await __sbUpstream("service.fetch", msg, label, null);
5385
+ const uncaught = response.headers.get("x-sb-uncaught");
5386
+ if (uncaught != null) throw __sbRemoteError(uncaught);
5387
+ if (response.headers.get("x-sb-rpc-result") !== "1") {
5388
+ throw new TypeError(
5389
+ label + ": the target answered " + response.status + " instead of an RPC result. RPC needs a platform edge " +
5390
+ "that vouches for service-binding calls and a target whose default export extends WorkerEntrypoint.",
5391
+ );
5392
+ }
5393
+ return __sbRpcDecode(JSON.parse(await response.text()));
5394
+ }
5395
+
5396
+ // Whether the default export handles `name` (fetch, scheduled, queue): a method of the
5397
+ // object, or of the class's instances.
5398
+ function __sbHandles(handlers, name) {
5399
+ return __sbIsFn(handlers) ? !!handlers.prototype && __sbIsFn(handlers.prototype[name]) : __sbIsFn(handlers[name]);
5400
+ }
5401
+
5402
+ // Callee: the receiver of one request. A default export that is a class (a
5403
+ // WorkerEntrypoint) is instantiated per request with its ctx and env, as in workerd.
5404
+ function __sbReceiver(handlers, ctx) {
5405
+ return __sbIsFn(handlers) ? new handlers(ctx, globalThis.env) : handlers;
5406
+ }
5407
+
5408
+ // Callee: runs RPC method `method` of `receiver` with the arguments in the request body.
5409
+ // Only methods the receiver's class defines can be called, not ones every object has.
5410
+ async function __sbRpcDispatch(handlers, receiver, method, request) {
5411
+ if (!__sbIsFn(handlers)) {
5412
+ throw new TypeError(
5413
+ "The receiving Worker does not allow its default export to be invoked over RPC, because it does not extend WorkerEntrypoint.",
5414
+ );
5415
+ }
5416
+ const fn = receiver[method];
5417
+ if (!__sbIsFn(fn) || method === "constructor" || method in Object.prototype) {
5418
+ throw new TypeError('The RPC receiver does not implement the method "' + method + '".');
5419
+ }
5420
+ const args = JSON.parse(await request.text()).map(__sbRpcDecode);
5421
+ const result = await fn.apply(receiver, args);
5422
+ return new Response(JSON.stringify(__sbRpcEncode(result)), {
5423
+ headers: { "content-type": "application/json", "x-sb-rpc-result": "1" },
5424
+ });
5425
+ }
5426
+
5427
+ // fetch(input, init) as Fetch reads it: a Request's method, headers, body, redirect mode
5428
+ // and signal apply unless init overrides them; a URL object stands for its href.
5429
+ function __sbFetchParts(input, init) {
5430
+ const req = input != null && !__sbIsStr(input) && input.url != null ? input : null;
5431
+ const url = req ? String(req.url) : input != null && input.href != null ? String(input.href) : String(input);
5432
+ const opts = init || {};
5433
+ const source = opts.headers != null ? opts.headers : req ? req.headers : null;
5434
+ const headers = [];
5435
+ if (source) {
5436
+ if (__sbIsFn(source.forEach)) source.forEach((v, k) => headers.push([k, v]));
5437
+ else for (const k in source) headers.push([k, source[k]]);
5438
+ }
5439
+ const body = opts.body !== undefined && !(opts instanceof Request) ? opts.body : req && __sbRawBody(req) !== "" ? __sbRawBody(req) : null;
5440
+ const method = opts.method || (req && req.method) || "GET";
5441
+ const redirect = opts.redirect || (req && req.redirect) || "follow";
5442
+ const signal = opts.signal || (req && req.signal) || null;
5443
+ return { url, method, headers, body, redirect, signal };
5444
+ }
5445
+
5446
+ // An upstream reply (fetch or service.fetch) -> a Response. Headers are appended,
5447
+ // so each Set-Cookie survives, and a reply without a content type keeps none: the
5448
+ // body reaches Response as a string, which would otherwise default to text/plain.
5449
+ function __sbUpstreamResponse(r) {
5450
+ const respHeaders = new Headers();
5451
+ let typed = false;
5452
+ for (let j = 0; j < (r.headers || []).length; j++) {
5453
+ respHeaders.append(r.headers[j][0], r.headers[j][1]);
5454
+ if (String(r.headers[j][0]).toLowerCase() === "content-type") typed = true;
5455
+ }
5456
+ // #176: bytes from another server or deployment, not a string this handler
5457
+ // built -- must not be re-encoded if the handler proxies it straight
5458
+ // through (see the assets binding for the same reasoning).
5459
+ if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
5460
+ const response = __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
5461
+ if (!typed) response.headers.delete("content-type");
5462
+ return response;
5463
+ }
5464
+
5465
+ // A streamed upstream response (from compatibility date 2026-10-21, where body is a
5466
+ // stream anyway): the call resolves as soon as the head arrives, and the body is a
5467
+ // ReadableStream fed chunk by chunk as the broker passes them on. Each frame fires the
5468
+ // same callback, so it is an interval that the last frame clears. A reply that comes
5469
+ // whole (a broker from before streaming, or sproutd's embedded transport) is handled
5470
+ // as before. Cancelling the body, or the signal, drops the exchange, which stops the
5471
+ // upstream request.
5472
+ function __sbUpstreamStreamed(op, msg, label, signal) {
5473
+ return new Promise((resolve, reject) => {
5474
+ let controller = null;
5475
+ let onAbort = null;
5476
+ let done = false;
5477
+ const finish = () => {
5478
+ done = true;
5479
+ clearInterval(id);
5480
+ if (onAbort) signal.removeEventListener("abort", onAbort);
5246
5481
  };
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(() => {
5482
+ const id = setInterval(() => {
5483
+ if (done) return;
5484
+ const raw = __sbHttpAsyncResult(id);
5485
+ if (controller === null) {
5486
+ let reply;
5252
5487
  try {
5253
- resolve(respond(__sbFetchReply(__sbHttpAsyncResult(id))));
5488
+ reply = JSON.parse(raw);
5254
5489
  } catch (e) {
5255
- reject(new Error("sproutboat fetch: " + ((e && e.message) || e)));
5490
+ reply = { ok: false, error: String((e && e.message) || e) };
5256
5491
  }
5257
- }, 2147483647);
5492
+ if (reply.ok === false || !reply.stream) {
5493
+ finish();
5494
+ try {
5495
+ resolve(__sbUpstreamResponse(__sbFetchReply(raw)));
5496
+ } catch (e) {
5497
+ reject(new Error(label + ": " + ((e && e.message) || e)));
5498
+ }
5499
+ return;
5500
+ }
5501
+ const headers = new Headers();
5502
+ for (let j = 0; j < (reply.headers || []).length; j++) headers.append(reply.headers[j][0], reply.headers[j][1]);
5503
+ const body = new ReadableStream({
5504
+ start(c) {
5505
+ controller = c;
5506
+ },
5507
+ cancel() {
5508
+ if (done) return;
5509
+ finish();
5510
+ __sbCancelAsync(id);
5511
+ },
5512
+ });
5513
+ resolve(new Response(body, { status: reply.status || 502, headers }));
5514
+ return;
5515
+ }
5516
+ const frame = JSON.parse(raw);
5517
+ const bytes = __sbTakeBin();
5518
+ if (frame.chunk) {
5519
+ if (bytes.length) controller.enqueue(new Uint8Array(__sbBufFrom(bytes)));
5520
+ return;
5521
+ }
5522
+ finish();
5523
+ if (frame.ok === false) controller.error(new Error(label + ": " + (frame.error || "failed")));
5524
+ else controller.close();
5525
+ }, 2147483647);
5526
+ if (signal) {
5527
+ onAbort = () => {
5528
+ if (done) return;
5529
+ finish();
5530
+ __sbCancelAsync(id);
5531
+ if (controller === null) reject(signal.reason);
5532
+ else controller.error(signal.reason);
5533
+ };
5534
+ signal.addEventListener("abort", onAbort);
5535
+ }
5536
+ try {
5537
+ msg.asyncId = id;
5538
+ msg.stream = true;
5539
+ globalThis.__sbFetchUpstream(op, msg);
5540
+ } catch (e) {
5541
+ finish();
5542
+ reject(e);
5543
+ }
5544
+ });
5545
+ }
5546
+
5547
+ // Fetch's redirect handling, from compatibility date 2026-10-22 (before it, a 3xx came
5548
+ // back as it was, whatever the mode). "follow" sends the request again to the new
5549
+ // location, up to 20 times; each hop goes back through the broker, so outbound hops
5550
+ // are vetted like the first. A 303, or a 301/302 to a POST, becomes a bodiless GET;
5551
+ // a hop to another origin drops Authorization. A service binding follows through
5552
+ // the same binding, as in workerd. "error" rejects; "manual" returns the 3xx.
5553
+ function __sbFetchFollowing(op, msg, label, mode, signal) {
5554
+ return __sbFollowHop(op, msg, label, mode, signal, 0);
5555
+ }
5556
+ function __sbFollowHop(op, msg, label, mode, signal, hops) {
5557
+ const step = (res) => {
5558
+ const location = res.headers.get("location");
5559
+ const redirect = res.status === 301 || res.status === 302 || res.status === 303 || res.status === 307 || res.status === 308;
5560
+ if (!(globalThis.__sbCompat >= "2026-10-22") || mode === "manual" || !redirect || location == null) return res;
5561
+ if (mode === "error") throw new TypeError(label + ": redirect not allowed (redirect: \"error\")");
5562
+ if (hops >= 20) throw new TypeError(label + ": too many redirects");
5563
+ const dropped = __sbRawBody(res);
5564
+ if (dropped instanceof ReadableStream) dropped.cancel();
5565
+ const from = new URL(msg.url);
5566
+ const to = new URL(location, from);
5567
+ const next = { ...msg, url: to.href };
5568
+ delete next.asyncId;
5569
+ const toGet = res.status === 303 ? msg.method !== "GET" && msg.method !== "HEAD" : res.status !== 307 && res.status !== 308 && msg.method === "POST";
5570
+ const drop = (name) =>
5571
+ (toGet && /^content-(type|length|encoding|language|location)$/.test(name)) || (to.origin !== from.origin && name === "authorization");
5572
+ next.headers = msg.headers.filter((pair) => !drop(String(pair[0]).toLowerCase()));
5573
+ if (toGet) {
5574
+ next.method = "GET";
5575
+ next.body = null;
5576
+ delete next.bodyBinary;
5577
+ }
5578
+ return __sbFollowHop(op, next, label, mode, signal, hops + 1);
5579
+ };
5580
+ const out = __sbUpstream(op, msg, label, signal);
5581
+ return out instanceof Promise ? out.then(step) : step(out);
5582
+ }
5583
+
5584
+ // Sends `msg` as `op` and answers with the Response: at once on a synchronous
5585
+ // transport, or (B18) as a promise when the host runs requests off its event loop.
5586
+ // The reply fires a timer that never fires on its own; the timer table keeps the
5587
+ // callback alive until then. An aborted `signal` rejects with its reason and cancels
5588
+ // the exchange, which closes its broker connection: the broker stops the upstream
5589
+ // request, and the edge in turn stops the callee's.
5590
+ function __sbUpstream(op, msg, label, signal) {
5591
+ if (signal) signal.throwIfAborted();
5592
+ if (!__sbHttpAsyncEnabled()) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
5593
+ if (globalThis.__sbCompat >= "2026-10-21") return __sbUpstreamStreamed(op, msg, label, signal);
5594
+ return new Promise((resolve, reject) => {
5595
+ let onAbort = null;
5596
+ const settled = () => {
5597
+ if (onAbort) signal.removeEventListener("abort", onAbort);
5598
+ };
5599
+ const id = setTimeout(() => {
5600
+ settled();
5258
5601
  try {
5259
- msg.asyncId = id;
5260
- globalThis.__sbFetchUpstream("fetch", msg);
5602
+ resolve(__sbUpstreamResponse(__sbFetchReply(__sbHttpAsyncResult(id))));
5261
5603
  } catch (e) {
5262
- clearTimeout(id);
5263
- reject(e);
5604
+ reject(new Error(label + ": " + ((e && e.message) || e)));
5264
5605
  }
5265
- });
5606
+ }, 2147483647);
5607
+ if (signal) {
5608
+ onAbort = () => {
5609
+ settled();
5610
+ clearTimeout(id);
5611
+ __sbCancelAsync(id);
5612
+ reject(signal.reason);
5613
+ };
5614
+ signal.addEventListener("abort", onAbort);
5615
+ }
5616
+ try {
5617
+ msg.asyncId = id;
5618
+ globalThis.__sbFetchUpstream(op, msg);
5619
+ } catch (e) {
5620
+ settled();
5621
+ clearTimeout(id);
5622
+ reject(e);
5623
+ }
5624
+ });
5625
+ }
5626
+
5627
+ // fetch() reaches any public address; the transport refuses private and
5628
+ // reserved ones at connect (baronunread/sproutboat#174). No config, like a
5629
+ // Worker's fetch. Installed by the generated module (wrap.ts), not here: the
5630
+ // prelude must not replace fetch just by being evaluated.
5631
+ globalThis.__sbInstallFetch = function () {
5632
+ globalThis.fetch = function (input, init) {
5633
+ const parts = __sbFetchParts(input, init);
5634
+ const msg = { url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(parts.body) };
5635
+ return __sbFetchFollowing("fetch", msg, "sproutboat fetch", parts.redirect, parts.signal);
5266
5636
  };
5267
5637
  };
5268
5638
 
@@ -5744,7 +6114,7 @@ function __sbStreamResponse(response) {
5744
6114
  return response;
5745
6115
  }
5746
6116
 
5747
- async function __sbFetchWithDrain(res, tasks) {
6117
+ async function __sbFetchWithDrain(res, tasks, bindingCall) {
5748
6118
  let r;
5749
6119
  try {
5750
6120
  r = res && __sbIsFn(res.then) ? await res : res;
@@ -5754,7 +6124,7 @@ async function __sbFetchWithDrain(res, tasks) {
5754
6124
  }
5755
6125
  } catch (error) {
5756
6126
  // #179 — a rejected handler promise must not take the process down with it.
5757
- r = __sbErrorResponse(error);
6127
+ r = __sbErrorResponse(error, bindingCall);
5758
6128
  }
5759
6129
  // The response goes out now and ctx.waitUntil work keeps running after it, as in
5760
6130
  // workerd; it was awaited first, so a 1.5 s background task delayed the response by
@@ -5767,14 +6137,42 @@ async function __sbFetchWithDrain(res, tasks) {
5767
6137
  // propagate past __sbEntry and crash the whole server. Logged first, the way a
5768
6138
  // Worker logs "Uncaught Error: ...": a swallowed exception left a 500 with
5769
6139
  // nothing on stderr, which hid #168 entirely.
5770
- function __sbErrorResponse(error) {
6140
+ function __sbErrorResponse(error, bindingCall) {
5771
6141
  __sbLogUncaught("fetch", error);
5772
6142
  // Untyped, as workerd sends it: the text/plain default is for handlers' bodies.
5773
6143
  const response = new Response("Internal Server Error", { status: 500 });
5774
6144
  response.headers.delete("content-type");
6145
+ // A service-binding caller rethrows the error, as in workerd. Only on a call the
6146
+ // edge vouched for: the marker carries the message, and the edge strips it from
6147
+ // anything that goes to a client.
6148
+ if (bindingCall) {
6149
+ const name = (error && error.name) || "Error";
6150
+ const message = error && error.message != null ? String(error.message) : String(error);
6151
+ response.headers.set("x-sb-uncaught", encodeURIComponent(JSON.stringify({ name: String(name), message: message.slice(0, 1000) })));
6152
+ }
5775
6153
  return response;
5776
6154
  }
5777
6155
 
6156
+ // A service-binding call the platform's edge vouched for: it carries the token the edge
6157
+ // gave this sprout (SB_EDGE_TOKEN), so no client can claim one. Such a call sees the URL
6158
+ // its caller passed (x-sb-url), not the deployment's hostname, and no remote address,
6159
+ // as in workerd. The edge's headers are removed from every request before the handler
6160
+ // sees it. Returns whether the edge vouched for this one.
6161
+ function __sbBindingCallIngress(request) {
6162
+ const headers = request.headers;
6163
+ const want = __sbEnv("SB_EDGE_TOKEN");
6164
+ const vouched = !!want && headers.get("x-sb-binding-call") === "1" && __sbEqCt(headers.get("x-sb-edge-token") || "", want);
6165
+ if (vouched) {
6166
+ const url = headers.get("x-sb-url");
6167
+ if (url && URL.canParse(url)) request.url = url;
6168
+ headers.delete("x-sb-remote-addr");
6169
+ }
6170
+ for (const name of ["x-sb-binding-call", "x-sb-url", "x-sb-edge-token"]) {
6171
+ if (headers.get(name) != null) headers.delete(name);
6172
+ }
6173
+ return vouched;
6174
+ }
6175
+
5778
6176
  function __sbLogUncaught(where, error) {
5779
6177
  try {
5780
6178
  let text = "";
@@ -5830,6 +6228,7 @@ globalThis.__sbEntry = function (handlers, request) {
5830
6228
  __sbDecodeIngress(request);
5831
6229
  const trigger = request.headers.get("x-sb-trigger");
5832
6230
  if (!trigger) {
6231
+ const bindingCall = __sbBindingCallIngress(request);
5833
6232
  // #163/#128 — expose the request metadata the edge already has, the
5834
6233
  // Workers way, before the handler runs. httpProtocol/tlsVersion/tlsCipher
5835
6234
  // are only set when a trusted front proxy forwarded them; colo, ASN and
@@ -5862,23 +6261,28 @@ globalThis.__sbEntry = function (handlers, request) {
5862
6261
  };
5863
6262
  let __res;
5864
6263
  try {
5865
- __res = globalThis.__sbCompat >= "2026-09-28"
5866
- ? handlers.fetch(request, globalThis.env, ctx)
5867
- : handlers.fetch(request, ctx);
6264
+ const receiver = __sbReceiver(handlers, ctx);
6265
+ // An RPC call (a vouched binding call naming a method) runs that method instead.
6266
+ const rpc = bindingCall ? request.headers.get("x-sb-rpc") : null;
6267
+ __res = rpc != null
6268
+ ? __sbRpcDispatch(handlers, receiver, rpc, request)
6269
+ : globalThis.__sbCompat >= "2026-09-28"
6270
+ ? receiver.fetch(request, globalThis.env, ctx)
6271
+ : receiver.fetch(request, ctx);
5868
6272
  } catch (error) {
5869
6273
  // #179 — a synchronous throw anywhere in the handler must not take the
5870
6274
  // whole process (and every other in-flight request) down with it.
5871
- return __sbErrorResponse(error);
6275
+ return __sbErrorResponse(error, bindingCall);
5872
6276
  }
5873
6277
  // Same promise-identity rule as above applies to `__sbFetchWithDrain`'s
5874
6278
  // 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);
6279
+ if (__sbTasks.length || (__res && __sbIsFn(__res.then)) || __sbHasStreamBody(__res)) return __sbFetchWithDrain(__res, __sbTasks, bindingCall);
5876
6280
  return __sbTagCpu(__res, __t0);
5877
6281
  }
5878
6282
  if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });
5879
6283
 
5880
6284
  if (trigger === "scheduled") {
5881
- if (!__sbIsFn(handlers.scheduled)) return new Response("no scheduled handler", { status: 404 });
6285
+ if (!__sbHandles(handlers, "scheduled")) return new Response("no scheduled handler", { status: 404 });
5882
6286
  const body = __sbReadJson(request);
5883
6287
  // #171 — ctx.waitUntil, same shape as fetch's. Was also fire-and-forget
5884
6288
  // before this: an async `scheduled()`'s own promise was dropped the
@@ -5892,9 +6296,10 @@ globalThis.__sbEntry = function (handlers, request) {
5892
6296
  let __sres;
5893
6297
  try {
5894
6298
  const event = { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} };
6299
+ const receiver = __sbReceiver(handlers, ctx);
5895
6300
  __sres = globalThis.__sbCompat >= "2026-09-28"
5896
- ? handlers.scheduled(event, globalThis.env, ctx)
5897
- : handlers.scheduled(event, ctx);
6301
+ ? receiver.scheduled(event, globalThis.env, ctx)
6302
+ : receiver.scheduled(event, ctx);
5898
6303
  } catch (error) {
5899
6304
  // #179 — same rule as fetch: don't let a throw here crash the process.
5900
6305
  __sbLogUncaught("scheduled", error);
@@ -5905,7 +6310,7 @@ globalThis.__sbEntry = function (handlers, request) {
5905
6310
  }
5906
6311
 
5907
6312
  if (trigger === "queue") {
5908
- if (!__sbIsFn(handlers.queue)) return new Response("no queue handler", { status: 404 });
6313
+ if (!__sbHandles(handlers, "queue")) return new Response("no queue handler", { status: 404 });
5909
6314
  // #171 — same ctx.waitUntil shape. `__sbRunQueueBatch` was also
5910
6315
  // fire-and-forget for an async `queue()`: it computed the default
5911
6316
  // ack/retry and answered the broker before the handler's own awaits ran,
@@ -5920,7 +6325,7 @@ globalThis.__sbEntry = function (handlers, request) {
5920
6325
  // Always a promise now (`__sbRunQueueBatch` is async, to await the
5921
6326
  // handler above), so always tail-call the drain wrapper — same
5922
6327
  // promise-identity reasoning as `__sbFetchWithDrain`.
5923
- return __sbQueueWithDrain(__sbRunQueueBatch(handlers, __sbReadJson(request), ctx), __sbTasks);
6328
+ return __sbQueueWithDrain(__sbRunQueueBatch(__sbReceiver(handlers, ctx), __sbReadJson(request), ctx), __sbTasks);
5924
6329
  }
5925
6330
 
5926
6331
  if (trigger === "alarm") {