@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.
- package/CHANGELOG.md +95 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +488 -83
- package/src/source.ts +8 -4
- package/src/transport-broker.js +566 -56
- package/src/transport-embedded.js +4 -0
- package/src/wrap.ts +30 -3
- package/test/native-base64.ts +11 -2
- package/test/native-service-bindings.ts +396 -0
|
@@ -3594,18 +3594,53 @@ if (globalThis.crypto.getRandomValues == null) {
|
|
|
3594
3594
|
Promise.resolve().then(() => callback());
|
|
3595
3595
|
};
|
|
3596
3596
|
}
|
|
3597
|
-
|
|
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
|
-
|
|
3603
|
-
|
|
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
|
-
|
|
3608
|
-
|
|
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
|
-
|
|
5190
|
-
fetch(input, init) {
|
|
5191
|
-
const
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
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
|
-
//
|
|
5219
|
-
//
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
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
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
5482
|
+
const id = setInterval(() => {
|
|
5483
|
+
if (done) return;
|
|
5484
|
+
const raw = __sbHttpAsyncResult(id);
|
|
5485
|
+
if (controller === null) {
|
|
5486
|
+
let reply;
|
|
5252
5487
|
try {
|
|
5253
|
-
|
|
5488
|
+
reply = JSON.parse(raw);
|
|
5254
5489
|
} catch (e) {
|
|
5255
|
-
|
|
5490
|
+
reply = { ok: false, error: String((e && e.message) || e) };
|
|
5256
5491
|
}
|
|
5257
|
-
|
|
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
|
-
|
|
5260
|
-
globalThis.__sbFetchUpstream("fetch", msg);
|
|
5602
|
+
resolve(__sbUpstreamResponse(__sbFetchReply(__sbHttpAsyncResult(id))));
|
|
5261
5603
|
} catch (e) {
|
|
5262
|
-
|
|
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
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
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 (!
|
|
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
|
-
?
|
|
5897
|
-
:
|
|
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 (!
|
|
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") {
|