@sproutboat/runtime 0.20.0 → 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 CHANGED
@@ -1,5 +1,36 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.21.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6f253b9: Service bindings support Workers RPC.
8
+
9
+ **Callee.** A deployment whose default export extends `WorkerEntrypoint`, imported from `cloudflare:workers`, exposes its methods:
10
+
11
+ - `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.
12
+ - Arguments and results are copied as structured clone does: `undefined`, `NaN`, `-0`, `Date`, `RegExp`, binary data, `Map`, `Set` and errors survive the trip.
13
+ - An error the method throws rejects the call with the same class and message.
14
+ - An unknown method rejects with workerd's `TypeError`.
15
+ - Passing functions or RPC stubs is not supported yet.
16
+
17
+ **Where it works.** RPC travels over the service-binding calls a platform edge vouches for, so no client can invoke a method.
18
+
19
+ **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.
20
+
21
+ **Default exports.**
22
+ - A class default export is instantiated per request, for `fetch`, `scheduled` and `queue`.
23
+ - A `WorkerEntrypoint` may have no `fetch` at all.
24
+
25
+ ### Patch Changes
26
+
27
+ - 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`:
28
+
29
+ - `atob(JSON.parse('"AQL/"'))` returned two wrong bytes instead of `01 02 ff`;
30
+ - `btoa` of a parsed `"héllo"` encoded the UTF-16 storage.
31
+
32
+ Both are now plain JavaScript, with the same validation and errors as before.
33
+
3
34
  ## 0.20.0
4
35
 
5
36
  ### 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.0",
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
 
@@ -5188,7 +5223,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5188
5223
  // which is also what lets a deployment call itself without deadlocking.
5189
5224
  for (let i = 0; i < (bindings.services || []).length; i++) {
5190
5225
  const binding = bindings.services[i].binding;
5191
- target[binding] = {
5226
+ const fetcher = {
5192
5227
  async fetch(input, init) {
5193
5228
  const parts = __sbFetchParts(input, init);
5194
5229
  if (parts.signal) parts.signal.throwIfAborted();
@@ -5203,6 +5238,15 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5203
5238
  return response;
5204
5239
  },
5205
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
+ });
5206
5250
  }
5207
5251
 
5208
5252
  };
@@ -5229,6 +5273,157 @@ function __sbRemoteError(marker) {
5229
5273
  return error;
5230
5274
  }
5231
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
+
5232
5427
  // fetch(input, init) as Fetch reads it: a Request's method, headers, body, redirect mode
5233
5428
  // and signal apply unless init overrides them; a URL object stands for its href.
5234
5429
  function __sbFetchParts(input, init) {
@@ -6066,9 +6261,14 @@ globalThis.__sbEntry = function (handlers, request) {
6066
6261
  };
6067
6262
  let __res;
6068
6263
  try {
6069
- __res = globalThis.__sbCompat >= "2026-09-28"
6070
- ? handlers.fetch(request, globalThis.env, ctx)
6071
- : 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);
6072
6272
  } catch (error) {
6073
6273
  // #179 — a synchronous throw anywhere in the handler must not take the
6074
6274
  // whole process (and every other in-flight request) down with it.
@@ -6082,7 +6282,7 @@ globalThis.__sbEntry = function (handlers, request) {
6082
6282
  if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });
6083
6283
 
6084
6284
  if (trigger === "scheduled") {
6085
- 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 });
6086
6286
  const body = __sbReadJson(request);
6087
6287
  // #171 — ctx.waitUntil, same shape as fetch's. Was also fire-and-forget
6088
6288
  // before this: an async `scheduled()`'s own promise was dropped the
@@ -6096,9 +6296,10 @@ globalThis.__sbEntry = function (handlers, request) {
6096
6296
  let __sres;
6097
6297
  try {
6098
6298
  const event = { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} };
6299
+ const receiver = __sbReceiver(handlers, ctx);
6099
6300
  __sres = globalThis.__sbCompat >= "2026-09-28"
6100
- ? handlers.scheduled(event, globalThis.env, ctx)
6101
- : handlers.scheduled(event, ctx);
6301
+ ? receiver.scheduled(event, globalThis.env, ctx)
6302
+ : receiver.scheduled(event, ctx);
6102
6303
  } catch (error) {
6103
6304
  // #179 — same rule as fetch: don't let a throw here crash the process.
6104
6305
  __sbLogUncaught("scheduled", error);
@@ -6109,7 +6310,7 @@ globalThis.__sbEntry = function (handlers, request) {
6109
6310
  }
6110
6311
 
6111
6312
  if (trigger === "queue") {
6112
- 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 });
6113
6314
  // #171 — same ctx.waitUntil shape. `__sbRunQueueBatch` was also
6114
6315
  // fire-and-forget for an async `queue()`: it computed the default
6115
6316
  // ack/retry and answered the broker before the handler's own awaits ran,
@@ -6124,7 +6325,7 @@ globalThis.__sbEntry = function (handlers, request) {
6124
6325
  // Always a promise now (`__sbRunQueueBatch` is async, to await the
6125
6326
  // handler above), so always tail-call the drain wrapper — same
6126
6327
  // promise-identity reasoning as `__sbFetchWithDrain`.
6127
- return __sbQueueWithDrain(__sbRunQueueBatch(handlers, __sbReadJson(request), ctx), __sbTasks);
6328
+ return __sbQueueWithDrain(__sbRunQueueBatch(__sbReceiver(handlers, ctx), __sbReadJson(request), ctx), __sbTasks);
6128
6329
  }
6129
6330
 
6130
6331
  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 };
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({
@@ -327,12 +362,27 @@ try {
327
362
  assert.equal(streamed.status, 200, streamed.text);
328
363
  assert.deepEqual(JSON.parse(streamed.text), { firstEarly: true, text: "0 1 2 3 ", whole: "0 1 2 3 ", bigOk: true });
329
364
 
365
+ // RPC: methods of the WorkerEntrypoint, called on the binding, with values copied as
366
+ // structured clone does and errors thrown as in workerd.
367
+ const rpc = await get("/rpc");
368
+ assert.equal(rpc.status, 200, rpc.text);
369
+ assert.deepEqual(JSON.parse(rpc.text), {
370
+ sum: 5,
371
+ kept: { d: true, b: true, m: true, u: true, n: true },
372
+ thrown: ["RangeError", "nope", true],
373
+ unknown: ["TypeError", 'The RPC receiver does not implement the method "noSuchMethod".'],
374
+ context: { env: true, waitUntil: true },
375
+ });
376
+ // A client cannot call a method: without the edge's vouching, x-sb-rpc is just a header.
377
+ const forgedRpc = await fetch(`http://127.0.0.1:${port}/ping`, { method: "POST", headers: { "x-sb-rpc": "fail" }, body: "[\"x\"]" });
378
+ assert.equal(await forgedRpc.text(), "pong");
379
+
330
380
  // A deployment calling itself, 30 levels deep: every level waits on the next at once.
331
381
  const self = await get("/self");
332
382
  assert.equal(self.text, "bottom 30");
333
383
  console.log(
334
384
  `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`,
385
+ `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; RPC calls WorkerEntrypoint methods`,
336
386
  );
337
387
  } finally {
338
388
  if (server) {