@sproutboat/runtime 0.20.0 → 0.21.1

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,58 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.21.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 98800ca: The asynchronous `fetch()` and service-binding behaviour from 0.20.0 now starts at compatibility date `2026-10-22`. Before, it applied at every date, which broke apps that use the result without `await`, such as the CLI's kitchen-sink: `const r = fetch(url); r.body`.
8
+
9
+ From `2026-10-22`, in a deployed sprout:
10
+
11
+ - `fetch()` and `env.SERVICE.fetch()` return promises and run off the event loop;
12
+ - responses stream;
13
+ - redirects are followed;
14
+ - a callee's error is thrown in the caller;
15
+ - a callee sees its caller's URL.
16
+
17
+ With an earlier `compatibility_date`, a deployed sprout's `fetch()` and binding calls answer at once with a `Response`, as they did before 0.20.0. To get the new behaviour, move `compatibility_date` to `2026-10-22` and rebuild.
18
+
19
+ These stay ungated:
20
+
21
+ - sproutd answered with promises at every date, and still does;
22
+ - RPC calls are always asynchronous;
23
+ - abort signals, `Set-Cookie` and `Content-Type` work at every date.
24
+
25
+ ## 0.21.0
26
+
27
+ ### Minor Changes
28
+
29
+ - 6f253b9: Service bindings support Workers RPC.
30
+
31
+ **Callee.** A deployment whose default export extends `WorkerEntrypoint`, imported from `cloudflare:workers`, exposes its methods:
32
+
33
+ - `env.SERVICE.add(2, 3)` runs `add(2, 3)` on a fresh instance, with `this.env` and `this.ctx` set, and resolves to its result.
34
+ - Arguments and results are copied as structured clone does: `undefined`, `NaN`, `-0`, `Date`, `RegExp`, binary data, `Map`, `Set` and errors survive the trip.
35
+ - An error the method throws rejects the call with the same class and message.
36
+ - An unknown method rejects with workerd's `TypeError`.
37
+ - Passing functions or RPC stubs is not supported yet.
38
+
39
+ **Where it works.** RPC travels over the service-binding calls a platform edge vouches for, so no client can invoke a method.
40
+
41
+ **Imports.** `cloudflare:workers` provides `WorkerEntrypoint`, `DurableObject`, `RpcTarget` and `env`. The runtime turns the import into globals, so a handler that imports it runs both bundled and as plain ESM.
42
+
43
+ **Default exports.**
44
+ - A class default export is instantiated per request, for `fetch`, `scheduled` and `queue`.
45
+ - A `WorkerEntrypoint` may have no `fetch` at all.
46
+
47
+ ### Patch Changes
48
+
49
+ - 581c2f4: `atob()` and `btoa()` give the right result for any string. They used Porffor's own implementations, which read how a string is stored, so they went wrong on strings held as UTF-16, such as ones from `JSON.parse` or `TextDecoder`:
50
+
51
+ - `atob(JSON.parse('"AQL/"'))` returned two wrong bytes instead of `01 02 ff`;
52
+ - `btoa` of a parsed `"héllo"` encoded the UTF-16 storage.
53
+
54
+ Both are now plain JavaScript, with the same validation and errors as before.
55
+
3
56
  ## 0.20.0
4
57
 
5
58
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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,30 +5218,49 @@ 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. 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.
5221
+ // egress and is not subject to the private-address block. From compatibility date
5222
+ // 2026-10-22 a promise, as in Workers: on an async host the caller keeps serving
5223
+ // while the call runs, which is also what lets a deployment call itself without
5224
+ // deadlocking. Before it, the Response comes back at once, as it always did, so a
5225
+ // handler that uses it without awaiting keeps working.
5189
5226
  for (let i = 0; i < (bindings.services || []).length; i++) {
5190
5227
  const binding = bindings.services[i].binding;
5191
- target[binding] = {
5192
- async fetch(input, init) {
5228
+ const fetcher = {
5229
+ fetch(input, init) {
5230
+ if (globalThis.__sbCompat >= "2026-10-22") return __sbServiceFetch(binding, input, init);
5193
5231
  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;
5232
+ const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(parts.body) };
5233
+ return __sbUpstreamResponse(globalThis.__sbFetchUpstream("service.fetch", msg));
5204
5234
  },
5205
5235
  };
5236
+ // Any other name is an RPC method of the callee's WorkerEntrypoint (see __sbRpcCall).
5237
+ // "then" stays undefined, so awaiting the binding itself is not a call.
5238
+ target[binding] = new Proxy(fetcher, {
5239
+ get(t, prop) {
5240
+ if (prop in t) return t[prop];
5241
+ if (!__sbIsStr(prop) || prop === "then") return undefined;
5242
+ return (...args) => __sbRpcCall(binding, prop, args);
5243
+ },
5244
+ });
5206
5245
  }
5207
-
5208
5246
  };
5209
5247
 
5248
+ // A service-binding fetch from compatibility date 2026-10-22: a promise, off the event
5249
+ // loop on an async host, following redirects, and throwing the callee's error.
5250
+ async function __sbServiceFetch(binding, input, init) {
5251
+ const parts = __sbFetchParts(input, init);
5252
+ if (parts.signal) parts.signal.throwIfAborted();
5253
+ // A stream body is read to the end first: the wire carries a whole body.
5254
+ let body = __sbUnwrapBody(parts.body);
5255
+ if (body instanceof ReadableStream) body = await __sbCollectBody(body);
5256
+ const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(body) };
5257
+ const response = await __sbFetchFollowing("service.fetch", msg, "sproutboat service " + binding, parts.redirect, parts.signal);
5258
+ // The callee threw: as in workerd, its error is thrown here.
5259
+ const uncaught = response.headers.get("x-sb-uncaught");
5260
+ if (uncaught != null) throw __sbRemoteError(uncaught);
5261
+ return response;
5262
+ }
5263
+
5210
5264
  // The error a service-binding callee threw, from its x-sb-uncaught marker: the same
5211
5265
  // standard class where there is one, else an Error with the original name.
5212
5266
  function __sbRemoteError(marker) {
@@ -5229,6 +5283,157 @@ function __sbRemoteError(marker) {
5229
5283
  return error;
5230
5284
  }
5231
5285
 
5286
+ // Workers RPC (cloudflare:workers). A service binding's methods other than fetch() call
5287
+ // the callee's WorkerEntrypoint: env.SVC.add(1, 2) runs `add(1, 2)` on an instance of
5288
+ // the callee's default export and resolves to what it returns. The call travels as a
5289
+ // service-binding request the platform's edge vouched for (x-sb-rpc names the method),
5290
+ // so no client can invoke a method. Arguments and results are copied as structured
5291
+ // clone does (__sbRpcEncode); functions and RPC stubs are not supported yet. An error
5292
+ // the method throws rejects the call with the same class and message, as fetch does.
5293
+ if (globalThis.WorkerEntrypoint == null) {
5294
+ class WorkerEntrypoint {
5295
+ constructor(ctx, env) {
5296
+ this.ctx = ctx;
5297
+ this.env = env;
5298
+ }
5299
+ }
5300
+ class DurableObject {
5301
+ constructor(ctx, env) {
5302
+ this.ctx = ctx;
5303
+ this.env = env;
5304
+ }
5305
+ }
5306
+ class RpcTarget {}
5307
+ globalThis.WorkerEntrypoint = WorkerEntrypoint;
5308
+ globalThis.DurableObject = DurableObject;
5309
+ globalThis.RpcTarget = RpcTarget;
5310
+ }
5311
+
5312
+ // A value -> JSON that keeps what structured clone keeps: undefined, NaN, ±Infinity
5313
+ // and -0, Dates, RegExps, binary data, Maps, Sets and Errors are tagged ("$"); a plain
5314
+ // object that has its own "$" key is wrapped so it is not read as a tag.
5315
+ function __sbRpcEncode(value) {
5316
+ if (value === undefined) return { $: "u" };
5317
+ if (value === null || __sbIsStr(value) || value === true || value === false) return value;
5318
+ // Number.isNaN is true only for NaN itself; strings and booleans returned above.
5319
+ if (Number.isNaN(value)) return { $: "n", v: "NaN" };
5320
+ if (value === Number(value)) {
5321
+ if (value === Infinity) return { $: "n", v: "Infinity" };
5322
+ if (value === -Infinity) return { $: "n", v: "-Infinity" };
5323
+ if (value === 0 && 1 / value < 0) return { $: "n", v: "-0" };
5324
+ return value;
5325
+ }
5326
+ if (__sbIsFn(value)) {
5327
+ throw new TypeError("Could not serialize a function for an RPC call: passing functions is not supported yet.");
5328
+ }
5329
+ if (Array.isArray(value)) return value.map(__sbRpcEncode);
5330
+ if (value instanceof Date) return { $: "d", v: value.getTime() };
5331
+ if (value instanceof RegExp) return { $: "r", s: value.source, f: value.flags };
5332
+ if (value instanceof ArrayBuffer) return { $: "ab", v: btoa(__sbToBytes(new Uint8Array(value))) };
5333
+ if (ArrayBuffer.isView(value)) {
5334
+ const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
5335
+ return { $: "ta", t: value.constructor.name, v: btoa(__sbToBytes(bytes)) };
5336
+ }
5337
+ if (value instanceof Map) {
5338
+ const entries = [];
5339
+ value.forEach((v, k) => entries.push([__sbRpcEncode(k), __sbRpcEncode(v)]));
5340
+ return { $: "m", v: entries };
5341
+ }
5342
+ if (value instanceof Set) {
5343
+ const items = [];
5344
+ value.forEach((v) => items.push(__sbRpcEncode(v)));
5345
+ return { $: "s", v: items };
5346
+ }
5347
+ if (value instanceof Error) return { $: "e", n: value.name, m: String(value.message) };
5348
+ const out = {};
5349
+ for (const key of Object.keys(value)) out[key] = __sbRpcEncode(value[key]);
5350
+ return Object.prototype.hasOwnProperty.call(value, "$") ? { $: "o", v: out } : out;
5351
+ }
5352
+
5353
+ function __sbRpcDecode(value) {
5354
+ if (value === null || !(value instanceof Object)) return value;
5355
+ if (Array.isArray(value)) return value.map(__sbRpcDecode);
5356
+ const tag = value.$;
5357
+ if (tag === undefined) {
5358
+ const out = {};
5359
+ for (const key of Object.keys(value)) out[key] = __sbRpcDecode(value[key]);
5360
+ return out;
5361
+ }
5362
+ if (tag === "u") return undefined;
5363
+ if (tag === "n") return value.v === "-0" ? -0 : Number(value.v);
5364
+ if (tag === "d") return new Date(value.v);
5365
+ if (tag === "r") return new RegExp(value.s, value.f);
5366
+ if (tag === "ab") return __sbBufFrom(atob(value.v));
5367
+ if (tag === "ta") {
5368
+ const buffer = __sbBufFrom(atob(value.v));
5369
+ const View = globalThis[value.t];
5370
+ if (View === globalThis.DataView) return new DataView(buffer);
5371
+ return View && View.BYTES_PER_ELEMENT ? new View(buffer) : new Uint8Array(buffer);
5372
+ }
5373
+ if (tag === "m") return new Map(value.v.map((pair) => [__sbRpcDecode(pair[0]), __sbRpcDecode(pair[1])]));
5374
+ if (tag === "s") return new Set(value.v.map(__sbRpcDecode));
5375
+ if (tag === "e") return __sbRemoteError(encodeURIComponent(JSON.stringify({ name: value.n, message: value.m })));
5376
+ if (tag === "o") return __sbRpcDecode(value.v);
5377
+ return value;
5378
+ }
5379
+
5380
+ // Caller: one RPC call through service binding `binding`.
5381
+ async function __sbRpcCall(binding, method, args) {
5382
+ const label = "sproutboat service " + binding;
5383
+ const body = JSON.stringify(args.map(__sbRpcEncode));
5384
+ const msg = {
5385
+ binding,
5386
+ url: "https://rpc.sproutboat.internal/" + method,
5387
+ method: "POST",
5388
+ headers: [
5389
+ ["x-sb-rpc", method],
5390
+ ["content-type", "application/json"],
5391
+ ],
5392
+ body,
5393
+ };
5394
+ const response = await __sbUpstream("service.fetch", msg, label, null, true);
5395
+ const uncaught = response.headers.get("x-sb-uncaught");
5396
+ if (uncaught != null) throw __sbRemoteError(uncaught);
5397
+ if (response.headers.get("x-sb-rpc-result") !== "1") {
5398
+ throw new TypeError(
5399
+ label + ": the target answered " + response.status + " instead of an RPC result. RPC needs a platform edge " +
5400
+ "that vouches for service-binding calls and a target whose default export extends WorkerEntrypoint.",
5401
+ );
5402
+ }
5403
+ return __sbRpcDecode(JSON.parse(await response.text()));
5404
+ }
5405
+
5406
+ // Whether the default export handles `name` (fetch, scheduled, queue): a method of the
5407
+ // object, or of the class's instances.
5408
+ function __sbHandles(handlers, name) {
5409
+ return __sbIsFn(handlers) ? !!handlers.prototype && __sbIsFn(handlers.prototype[name]) : __sbIsFn(handlers[name]);
5410
+ }
5411
+
5412
+ // Callee: the receiver of one request. A default export that is a class (a
5413
+ // WorkerEntrypoint) is instantiated per request with its ctx and env, as in workerd.
5414
+ function __sbReceiver(handlers, ctx) {
5415
+ return __sbIsFn(handlers) ? new handlers(ctx, globalThis.env) : handlers;
5416
+ }
5417
+
5418
+ // Callee: runs RPC method `method` of `receiver` with the arguments in the request body.
5419
+ // Only methods the receiver's class defines can be called, not ones every object has.
5420
+ async function __sbRpcDispatch(handlers, receiver, method, request) {
5421
+ if (!__sbIsFn(handlers)) {
5422
+ throw new TypeError(
5423
+ "The receiving Worker does not allow its default export to be invoked over RPC, because it does not extend WorkerEntrypoint.",
5424
+ );
5425
+ }
5426
+ const fn = receiver[method];
5427
+ if (!__sbIsFn(fn) || method === "constructor" || method in Object.prototype) {
5428
+ throw new TypeError('The RPC receiver does not implement the method "' + method + '".');
5429
+ }
5430
+ const args = JSON.parse(await request.text()).map(__sbRpcDecode);
5431
+ const result = await fn.apply(receiver, args);
5432
+ return new Response(JSON.stringify(__sbRpcEncode(result)), {
5433
+ headers: { "content-type": "application/json", "x-sb-rpc-result": "1" },
5434
+ });
5435
+ }
5436
+
5232
5437
  // fetch(input, init) as Fetch reads it: a Request's method, headers, body, redirect mode
5233
5438
  // and signal apply unless init overrides them; a URL object stands for its href.
5234
5439
  function __sbFetchParts(input, init) {
@@ -5267,8 +5472,8 @@ function __sbUpstreamResponse(r) {
5267
5472
  return response;
5268
5473
  }
5269
5474
 
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
5475
+ // A streamed upstream response (from compatibility date 2026-10-22, with the other
5476
+ // asynchronous fetch() behaviour; body is a stream from 2026-10-21 anyway): the call resolves as soon as the head arrives, and the body is a
5272
5477
  // ReadableStream fed chunk by chunk as the broker passes them on. Each frame fires the
5273
5478
  // same callback, so it is an interval that the last frame clears. A reply that comes
5274
5479
  // whole (a broker from before streaming, or sproutd's embedded transport) is handled
@@ -5392,10 +5597,15 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
5392
5597
  // callback alive until then. An aborted `signal` rejects with its reason and cancels
5393
5598
  // the exchange, which closes its broker connection: the broker stops the upstream
5394
5599
  // request, and the edge in turn stops the callee's.
5395
- function __sbUpstream(op, msg, label, signal) {
5600
+ // Promises from compatibility date 2026-10-22. Before it, a transport that always
5601
+ // answered at once (the broker's) still does, so handlers that use fetch()'s result
5602
+ // without awaiting keep working; sproutd's answered with promises at any date
5603
+ // (__sbAsyncAtAnyDate). `always` is for RPC, which has no older behaviour to keep.
5604
+ function __sbUpstream(op, msg, label, signal, always) {
5396
5605
  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);
5606
+ const promised = always || globalThis.__sbCompat >= "2026-10-22" || __sbAsyncAtAnyDate();
5607
+ if (!__sbHttpAsyncEnabled() || !promised) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
5608
+ if (globalThis.__sbCompat >= "2026-10-22") return __sbUpstreamStreamed(op, msg, label, signal);
5399
5609
  return new Promise((resolve, reject) => {
5400
5610
  let onAbort = null;
5401
5611
  const settled = () => {
@@ -5969,7 +6179,8 @@ function __sbBindingCallIngress(request) {
5969
6179
  const vouched = !!want && headers.get("x-sb-binding-call") === "1" && __sbEqCt(headers.get("x-sb-edge-token") || "", want);
5970
6180
  if (vouched) {
5971
6181
  const url = headers.get("x-sb-url");
5972
- if (url && URL.canParse(url)) request.url = url;
6182
+ // The callee sees its caller's URL from compatibility date 2026-10-22, as in workerd.
6183
+ if (url && URL.canParse(url) && globalThis.__sbCompat >= "2026-10-22") request.url = url;
5973
6184
  headers.delete("x-sb-remote-addr");
5974
6185
  }
5975
6186
  for (const name of ["x-sb-binding-call", "x-sb-url", "x-sb-edge-token"]) {
@@ -6066,9 +6277,14 @@ globalThis.__sbEntry = function (handlers, request) {
6066
6277
  };
6067
6278
  let __res;
6068
6279
  try {
6069
- __res = globalThis.__sbCompat >= "2026-09-28"
6070
- ? handlers.fetch(request, globalThis.env, ctx)
6071
- : handlers.fetch(request, ctx);
6280
+ const receiver = __sbReceiver(handlers, ctx);
6281
+ // An RPC call (a vouched binding call naming a method) runs that method instead.
6282
+ const rpc = bindingCall ? request.headers.get("x-sb-rpc") : null;
6283
+ __res = rpc != null
6284
+ ? __sbRpcDispatch(handlers, receiver, rpc, request)
6285
+ : globalThis.__sbCompat >= "2026-09-28"
6286
+ ? receiver.fetch(request, globalThis.env, ctx)
6287
+ : receiver.fetch(request, ctx);
6072
6288
  } catch (error) {
6073
6289
  // #179 — a synchronous throw anywhere in the handler must not take the
6074
6290
  // whole process (and every other in-flight request) down with it.
@@ -6082,7 +6298,7 @@ globalThis.__sbEntry = function (handlers, request) {
6082
6298
  if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });
6083
6299
 
6084
6300
  if (trigger === "scheduled") {
6085
- if (!__sbIsFn(handlers.scheduled)) return new Response("no scheduled handler", { status: 404 });
6301
+ if (!__sbHandles(handlers, "scheduled")) return new Response("no scheduled handler", { status: 404 });
6086
6302
  const body = __sbReadJson(request);
6087
6303
  // #171 — ctx.waitUntil, same shape as fetch's. Was also fire-and-forget
6088
6304
  // before this: an async `scheduled()`'s own promise was dropped the
@@ -6096,9 +6312,10 @@ globalThis.__sbEntry = function (handlers, request) {
6096
6312
  let __sres;
6097
6313
  try {
6098
6314
  const event = { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} };
6315
+ const receiver = __sbReceiver(handlers, ctx);
6099
6316
  __sres = globalThis.__sbCompat >= "2026-09-28"
6100
- ? handlers.scheduled(event, globalThis.env, ctx)
6101
- : handlers.scheduled(event, ctx);
6317
+ ? receiver.scheduled(event, globalThis.env, ctx)
6318
+ : receiver.scheduled(event, ctx);
6102
6319
  } catch (error) {
6103
6320
  // #179 — same rule as fetch: don't let a throw here crash the process.
6104
6321
  __sbLogUncaught("scheduled", error);
@@ -6109,7 +6326,7 @@ globalThis.__sbEntry = function (handlers, request) {
6109
6326
  }
6110
6327
 
6111
6328
  if (trigger === "queue") {
6112
- if (!__sbIsFn(handlers.queue)) return new Response("no queue handler", { status: 404 });
6329
+ if (!__sbHandles(handlers, "queue")) return new Response("no queue handler", { status: 404 });
6113
6330
  // #171 — same ctx.waitUntil shape. `__sbRunQueueBatch` was also
6114
6331
  // fire-and-forget for an async `queue()`: it computed the default
6115
6332
  // ack/retry and answered the broker before the handler's own awaits ran,
@@ -6124,7 +6341,7 @@ globalThis.__sbEntry = function (handlers, request) {
6124
6341
  // Always a promise now (`__sbRunQueueBatch` is async, to await the
6125
6342
  // handler above), so always tail-call the drain wrapper — same
6126
6343
  // promise-identity reasoning as `__sbFetchWithDrain`.
6127
- return __sbQueueWithDrain(__sbRunQueueBatch(handlers, __sbReadJson(request), ctx), __sbTasks);
6344
+ return __sbQueueWithDrain(__sbRunQueueBatch(__sbReceiver(handlers, ctx), __sbReadJson(request), ctx), __sbTasks);
6128
6345
  }
6129
6346
 
6130
6347
  if (trigger === "alarm") {
package/src/source.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { neutraliseExports } from "./wrap";
1
+ import { inlineWorkersImports, neutraliseExports } from "./wrap";
2
2
 
3
3
  export type SourceValidation = { ok: true } | { ok: false; errors: string[] };
4
4
 
@@ -28,7 +28,10 @@ const alwaysForbidden: Array<[RegExp, string]> = [
28
28
 
29
29
  ];
30
30
 
31
- export function validateHttpSyncSource(source: string): SourceValidation {
31
+ export function validateHttpSyncSource(module: string): SourceValidation {
32
+ // `cloudflare:workers` is the one import allowed past bundling: the compiler turns it
33
+ // into globals the same way (see inlineWorkersImports).
34
+ const source = inlineWorkersImports(module);
32
35
  const errors: string[] = [];
33
36
  // The default export must be an object literal with a `fetch` method. A module
34
37
  // may also declare Durable Object classes / helpers before it, so this is not
@@ -36,8 +39,9 @@ export function validateHttpSyncSource(source: string): SourceValidation {
36
39
  // A hand-written file exports inline; a bundled one re-exports at the end.
37
40
  // `neutraliseExports` is the same reader the compiler uses, so `check` cannot
38
41
  // accept a module the build would then reject.
39
- if (neutraliseExports(source) === null || !/\bfetch\s*\(/.test(source)) {
40
- errors.push("handler must default-export an object with fetch(request)");
42
+ // A WorkerEntrypoint may serve only RPC, with no fetch() at all.
43
+ if (neutraliseExports(source) === null || (!/\bfetch\s*\(/.test(source) && !/\bextends\s+WorkerEntrypoint\b/.test(source))) {
44
+ errors.push("handler must default-export an object with fetch(request), or a WorkerEntrypoint class");
41
45
  }
42
46
  for (const [pattern, message] of alwaysForbidden) if (pattern.test(source)) errors.push(message);
43
47
  return errors.length ? { ok: false, errors } : { ok: true };
@@ -895,6 +895,11 @@ function __sbCancelAsync(asyncId) {
895
895
  `;
896
896
  }
897
897
 
898
+ // Before compatibility date 2026-10-22, fetch() through the broker answered at once.
899
+ function __sbAsyncAtAnyDate() {
900
+ return false;
901
+ }
902
+
898
903
  // Whether the host runs exchanges off its loop (it installed a waker).
899
904
  function __sbHttpAsyncEnabled() {
900
905
  let res = false;
@@ -2086,6 +2086,11 @@ function __sbHttpAsyncResult(asyncId) {
2086
2086
  // placeholder timer cleared, the reply is dropped when it arrives.
2087
2087
  function __sbCancelAsync() {}
2088
2088
 
2089
+ // sproutd ran fetch() off its loop, as a promise, at every compatibility date (B18).
2090
+ function __sbAsyncAtAnyDate() {
2091
+ return true;
2092
+ }
2093
+
2089
2094
  // Whether the host runs async requests (sb_async_enable was called).
2090
2095
  function __sbHttpAsyncEnabled() {
2091
2096
  let res = false;
package/src/wrap.ts CHANGED
@@ -113,7 +113,33 @@ function hasBindings(b: Bindings): boolean {
113
113
  * and re-exports at the end (`export { src_default as default, Counter }`) —
114
114
  * #89 made the second shape the normal case. Returns null when neither matches.
115
115
  */
116
+ /**
117
+ * `cloudflare:workers` is not a module here: WorkerEntrypoint, DurableObject, RpcTarget
118
+ * and env are globals the prelude defines. Each import of it becomes a destructuring
119
+ * of globalThis, `as` renames included, so the handler's names still resolve. The CLI
120
+ * leaves the specifier external for this.
121
+ */
122
+ export function inlineWorkersImports(source: string): string {
123
+ return source.replace(
124
+ /^import\s*\{([^}]*)\}\s*from\s*["']cloudflare:workers["'];?[ \t]*$/gm,
125
+ (_match, names: string) => {
126
+ const pairs = names
127
+ .split(",")
128
+ .map((part) => part.trim())
129
+ .filter(Boolean)
130
+ .map((part) => part.replace(/^(\S+)\s+as\s+(\S+)$/, "$1: $2"));
131
+ return `const { ${pairs.join(", ")} } = globalThis;`;
132
+ },
133
+ );
134
+ }
135
+
116
136
  export function neutraliseExports(source: string): string | null {
137
+ // `export default class Name extends WorkerEntrypoint {`: the class is the handler.
138
+ if (/^\s*export\s+default\s+class\b/m.test(source)) {
139
+ return source
140
+ .replace(/^(\s*)export\s+default\s+class\b/m, "$1const __sbHandlers = class")
141
+ .replace(/^export\s+(async\s+function|function|class|const|let|var)\b/gm, "$1");
142
+ }
117
143
  if (/\bexport\s+default\s*\{/.test(source)) {
118
144
  return source
119
145
  .replace(/^(\s*)export\s+default\s*/m, "$1const __sbHandlers = ")
@@ -182,9 +208,10 @@ export function wrapNativeFetchHandler(
182
208
  */
183
209
  versionMetadata?: { binding: string; id: string; tag: string; timestamp: string },
184
210
  ): string {
185
- const neutralised = neutraliseExports(source);
186
- if (neutralised === null || !/\bfetch\s*\(/.test(source)) {
187
- throw new Error("handler must default-export an object with a fetch(request) method");
211
+ const neutralised = neutraliseExports(inlineWorkersImports(source));
212
+ // A WorkerEntrypoint may serve only RPC, with no fetch() at all.
213
+ if (neutralised === null || (!/\bfetch\s*\(/.test(source) && !/\bextends\s+WorkerEntrypoint\b/.test(source))) {
214
+ throw new Error("handler must default-export an object with a fetch(request) method, or a WorkerEntrypoint class");
188
215
  }
189
216
 
190
217
  const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
@@ -18,7 +18,13 @@ const handler = `export default {fetch() {
18
18
  const decoded=Array.from(Uint8Array.fromBase64(url,{alphabet:"base64url"}));
19
19
  rows.push({padded,url,decoded});
20
20
  }
21
- return Response.json(rows);
21
+ // atob/btoa on strings that are not literals (from JSON.parse, from TextDecoder): they
22
+ // used to read the string's UTF-16 storage and return the wrong bytes.
23
+ const parsed=JSON.parse('{"latin":"h\\u00e9llo w\\u00f6rld","b64":"AQL/"}');
24
+ const decodedText=new TextDecoder().decode(new TextEncoder().encode("AQL/"));
25
+ const codes=(s)=>Array.from(s,(c)=>c.charCodeAt(0));
26
+ const strings={btoa:btoa(parsed.latin),atob:codes(atob(parsed.b64)),fromDecoder:codes(atob(decodedText))};
27
+ return Response.json({rows,strings});
22
28
  }};`;
23
29
 
24
30
  function freePort(): number {
@@ -78,7 +84,10 @@ try {
78
84
  }
79
85
  assert(response, "compiled streams handler did not start");
80
86
  assert.equal(response.status, 200);
81
- assert.deepEqual(await response.json(), expected);
87
+ assert.deepEqual(await response.json(), {
88
+ rows: expected,
89
+ strings: { btoa: btoa("h\u00e9llo w\u00f6rld"), atob: [1, 2, 255], fromDecoder: [1, 2, 255] },
90
+ });
82
91
  console.log("Native base64 and base64url round-trip zero to nine bytes with correct padding");
83
92
  } finally {
84
93
  if (server) {
@@ -15,7 +15,9 @@ import { EMPTY_BINDINGS, preludePath, TRANSPORT_MARKER, transportPath, wrapNativ
15
15
  // From compatibility date 2026-10-22, redirects are followed as Fetch does. A stand-in
16
16
  // edge plays the platform's.
17
17
  const handler = `
18
- export default {
18
+ import { WorkerEntrypoint } from "cloudflare:workers";
19
+
20
+ export default class Service extends WorkerEntrypoint {
19
21
  async fetch(request, env) {
20
22
  const url = new URL(request.url);
21
23
  if (url.pathname === "/ping") return new Response("pong");
@@ -86,6 +88,22 @@ export default {
86
88
  for (let i = 0; bigOk && i < big.length; i += 4093) bigOk = big[i] === (i & 255);
87
89
  return Response.json({ firstEarly: firstMs >= 0 && firstMs < 250, text, whole, bigOk });
88
90
  }
91
+ if (url.pathname === "/rpc") {
92
+ // RPC to this deployment's own WorkerEntrypoint, through the vouched SELF binding.
93
+ const sum = await env.SELF.add(2, 3);
94
+ const trip = await env.SELF.roundTrip({ d: new Date(5), b: new Uint8Array([7, 255]), m: new Map([["k", -0]]), u: undefined, n: NaN });
95
+ const kept = {
96
+ d: trip.d instanceof Date && trip.d.getTime() === 5,
97
+ b: trip.b instanceof Uint8Array && trip.b.length === 2 && trip.b[1] === 255,
98
+ m: trip.m instanceof Map && Object.is(trip.m.get("k"), -0),
99
+ u: "u" in trip && trip.u === undefined,
100
+ n: Number.isNaN(trip.n),
101
+ };
102
+ const thrown = await env.SELF.fail("nope").then(() => null, (e) => [e.name, e.message, e instanceof RangeError]);
103
+ const unknown = await env.SELF.noSuchMethod().then(() => null, (e) => [e.name, e.message]);
104
+ const context = await env.SELF.whoAmI();
105
+ return Response.json({ sum, kept, thrown, unknown, context });
106
+ }
89
107
  if (url.pathname === "/whoami") {
90
108
  return Response.json({ url: request.url, internal: [...request.headers.keys()].filter((k) => k.startsWith("x-sb-")) });
91
109
  }
@@ -101,8 +119,24 @@ export default {
101
119
  return Response.json({ who, thrown });
102
120
  }
103
121
  return new Response("no route", { status: 404 });
104
- },
105
- };
122
+ }
123
+
124
+ add(a, b) {
125
+ return a + b;
126
+ }
127
+
128
+ roundTrip(value) {
129
+ return value;
130
+ }
131
+
132
+ fail(message) {
133
+ throw new RangeError(message);
134
+ }
135
+
136
+ whoAmI() {
137
+ return { env: this.env !== undefined && this.env.SELF !== undefined, waitUntil: this.ctx.waitUntil instanceof Function };
138
+ }
139
+ }
106
140
  `;
107
141
 
108
142
  function freePort(): number {
@@ -136,7 +170,8 @@ const edge = Bun.serve({
136
170
  const callerUrl = request.headers.get("x-sb-url");
137
171
  if (callerUrl) headers.set("x-sb-url", callerUrl);
138
172
  }
139
- return fetch(`http://127.0.0.1:${port}${url.pathname}${url.search}`, { method: request.method, headers });
173
+ const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer();
174
+ return fetch(`http://127.0.0.1:${port}${url.pathname}${url.search}`, { method: request.method, headers, body });
140
175
  }
141
176
  if (url.pathname === "/echo") {
142
177
  return Response.json({
@@ -204,6 +239,7 @@ const broker = createBroker({
204
239
  });
205
240
  const brokerServer = listen(broker, "127.0.0.1", 0);
206
241
  let server: ReturnType<typeof Bun.spawn> | undefined;
242
+ let legacy: ReturnType<typeof Bun.spawn> | undefined;
207
243
  const get = async (path: string) => {
208
244
  const t0 = performance.now();
209
245
  const res = await fetch(`http://127.0.0.1:${port}${path}`, { signal: AbortSignal.timeout(20_000) });
@@ -321,24 +357,75 @@ try {
321
357
  assert.equal(publicBoom.status, 500);
322
358
  assert.equal(publicBoom.headers.get("x-sb-uncaught"), null);
323
359
 
324
- // Streamed responses (compatibility date 2026-10-21 on): the call resolves on the
360
+ // Streamed responses (compatibility date 2026-10-22 on): the call resolves on the
325
361
  // head, the first chunk arrives before the rest, and a 12 MiB body arrives intact.
326
362
  const streamed = await get("/streamed");
327
363
  assert.equal(streamed.status, 200, streamed.text);
328
364
  assert.deepEqual(JSON.parse(streamed.text), { firstEarly: true, text: "0 1 2 3 ", whole: "0 1 2 3 ", bigOk: true });
329
365
 
366
+ // RPC: methods of the WorkerEntrypoint, called on the binding, with values copied as
367
+ // structured clone does and errors thrown as in workerd.
368
+ const rpc = await get("/rpc");
369
+ assert.equal(rpc.status, 200, rpc.text);
370
+ assert.deepEqual(JSON.parse(rpc.text), {
371
+ sum: 5,
372
+ kept: { d: true, b: true, m: true, u: true, n: true },
373
+ thrown: ["RangeError", "nope", true],
374
+ unknown: ["TypeError", 'The RPC receiver does not implement the method "noSuchMethod".'],
375
+ context: { env: true, waitUntil: true },
376
+ });
377
+ // A client cannot call a method: without the edge's vouching, x-sb-rpc is just a header.
378
+ const forgedRpc = await fetch(`http://127.0.0.1:${port}/ping`, { method: "POST", headers: { "x-sb-rpc": "fail" }, body: "[\"x\"]" });
379
+ assert.equal(await forgedRpc.text(), "pong");
380
+
330
381
  // A deployment calling itself, 30 levels deep: every level waits on the next at once.
331
382
  const self = await get("/self");
332
383
  assert.equal(self.text, "bottom 30");
384
+ // Before compatibility date 2026-10-22 a binding call answers at once, as it always
385
+ // did: a handler that uses the Response without awaiting it keeps working.
386
+ const legacyHandler = `
387
+ export default {
388
+ fetch(request, env) {
389
+ const res = env.PEER.fetch("https://peer/echo?legacy=1");
390
+ return new Response(String(res instanceof Response) + ":" + res.status + ":" + res.json().path);
391
+ },
392
+ };
393
+ `;
394
+ const legacyPort = freePort();
395
+ const legacySource = join(workdir, "legacy.generated.js");
396
+ const legacyBinary = join(workdir, "legacy-native");
397
+ await writeFile(legacySource, wrapNativeFetchHandler(legacyHandler, core.replace(TRANSPORT_MARKER, transport), {}, bindings, undefined, "2026-10-21"));
398
+ const legacyCompile = Bun.spawnSync([node, resolve(porffor, "runtime/index.js"), "native", legacySource, "-o", legacyBinary, "-s", "-O0"], { cwd: workdir });
399
+ assert.equal(legacyCompile.exitCode, 0, `legacy compile failed:\n${legacyCompile.stderr}`);
400
+ legacy = Bun.spawn([legacyBinary], {
401
+ cwd: workdir,
402
+ env: { ...process.env, PORT: String(legacyPort), SB_BROKER_PORT: String(brokerServer.port), SB_BROKER_TOKEN: "native-services" },
403
+ stdout: "ignore",
404
+ stderr: "inherit",
405
+ });
406
+ let legacyText = "";
407
+ for (let attempt = 0; attempt < 50 && !legacyText; attempt++) {
408
+ try {
409
+ legacyText = await (await fetch(`http://127.0.0.1:${legacyPort}/`, { signal: AbortSignal.timeout(20_000) })).text();
410
+ } catch {
411
+ await Bun.sleep(100);
412
+ }
413
+ }
414
+ assert.equal(legacyText, "true:200:/echo?legacy=1", "before 2026-10-22 a binding call answers synchronously");
415
+
333
416
  console.log(
334
417
  `Service bindings return promises and run off the loop: /ping ${Math.round(ping.ms)} ms during a call, ` +
335
- `8x500 ms in ${Math.round(total)} ms, self-recursion ok; URL, Request+init, stream bodies, Set-Cookie and missing content types preserved; redirects follow Fetch; aborted calls stop upstream; vouched calls see the caller's URL and rethrow the callee's errors; responses stream`,
418
+ `8x500 ms in ${Math.round(total)} ms, self-recursion ok; URL, Request+init, stream bodies, Set-Cookie and missing content types preserved; redirects follow Fetch; aborted calls stop upstream; before 2026-10-22 calls answer at once; vouched calls see the caller's URL and rethrow the callee's errors; responses stream; RPC calls WorkerEntrypoint methods`,
336
419
  );
337
420
  } finally {
338
421
  if (server) {
339
422
  server.kill(9);
340
423
  await server.exited;
341
424
  }
425
+ if (legacy) {
426
+ legacy.kill(9);
427
+ await legacy.exited;
428
+ }
342
429
  brokerServer.stop();
343
430
  broker.close();
344
431
  edge.stop(true);