@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.
@@ -2082,6 +2082,10 @@ function __sbHttpAsyncResult(asyncId) {
2082
2082
  return res;
2083
2083
  }
2084
2084
 
2085
+ // An async request's caller gave up. The host's request runs to the end; with its
2086
+ // placeholder timer cleared, the reply is dropped when it arrives.
2087
+ function __sbCancelAsync() {}
2088
+
2085
2089
  // Whether the host runs async requests (sb_async_enable was called).
2086
2090
  function __sbHttpAsyncEnabled() {
2087
2091
  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) {
@@ -0,0 +1,396 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { ensurePorffor } from "../../toolchain/src/acquire";
6
+ import { createBroker, listen } from "../../wire/src/broker";
7
+ import { EMPTY_BINDINGS, preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
8
+
9
+ // Service bindings on a standalone sprout. env.PEER.fetch() returns a promise and runs
10
+ // off the event loop (the broker transport's async threads), so the sprout keeps
11
+ // serving while a call is in flight: other requests answer at once, concurrent calls
12
+ // overlap, and a deployment that calls itself through a binding does not deadlock.
13
+ // Requests read like Fetch's: a URL object, a Request plus init, a stream body; every
14
+ // Set-Cookie of the reply survives, and a reply without a content type keeps none.
15
+ // From compatibility date 2026-10-22, redirects are followed as Fetch does. A stand-in
16
+ // edge plays the platform's.
17
+ const handler = `
18
+ import { WorkerEntrypoint } from "cloudflare:workers";
19
+
20
+ export default class Service extends WorkerEntrypoint {
21
+ async fetch(request, env) {
22
+ const url = new URL(request.url);
23
+ if (url.pathname === "/ping") return new Response("pong");
24
+ if (url.pathname === "/requests") {
25
+ const pending = env.PEER.fetch(new URL("https://peer/echo?u=1"));
26
+ const isPromise = pending instanceof Promise;
27
+ const viaUrl = await (await pending).json();
28
+ const viaRequest = await (await env.PEER.fetch(new Request("https://peer/echo", { method: "PUT", headers: { "x-from": "request" }, body: "req body" }), { method: "PATCH" })).json();
29
+ const stream = new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode("streamed ")); c.enqueue(new TextEncoder().encode("body")); c.close(); } });
30
+ const viaStream = await (await env.PEER.fetch("https://peer/echo", { method: "POST", body: stream, duplex: "half" })).json();
31
+ const cookies = (await env.PEER.fetch("https://peer/cookies")).headers.getSetCookie();
32
+ const bare = (await env.PEER.fetch("https://peer/bare")).headers.get("content-type");
33
+ return Response.json({ isPromise, viaUrl, viaRequest, viaStream, cookies, bare });
34
+ }
35
+ if (url.pathname === "/redirects") {
36
+ const followed = await (await env.PEER.fetch("https://peer/redirect?status=302&to=/echo?hop=1")).json();
37
+ const manual = await env.PEER.fetch("https://peer/redirect?status=302&to=/echo", { redirect: "manual" });
38
+ const post = (status, body) => env.PEER.fetch("https://peer/redirect?status=" + status + "&to=/typed", { method: "POST", body, headers: { "content-type": "text/plain" } });
39
+ const seeOther = await (await post(303, "dropped")).json();
40
+ const temporary = await (await post(307, "kept")).json();
41
+ let refused = "";
42
+ try {
43
+ await env.PEER.fetch("https://peer/redirect?status=302&to=/echo", { redirect: "error" });
44
+ } catch (e) {
45
+ refused = e.name;
46
+ }
47
+ return Response.json({ followed, manual: [manual.status, manual.headers.get("location")], seeOther, temporary, refused });
48
+ }
49
+ if (url.pathname === "/slow") return new Response(await (await env.PEER.fetch("https://peer/slow" + url.search)).text());
50
+ if (url.pathname === "/abort") {
51
+ const outcome = async (signal) => {
52
+ const t0 = Date.now();
53
+ try {
54
+ await env.PEER.fetch("https://peer/slow?ms=3000", { signal });
55
+ return "answered";
56
+ } catch (e) {
57
+ return e.name + " " + (Date.now() - t0 < 1000 ? "fast" : "slow");
58
+ }
59
+ };
60
+ const controller = new AbortController();
61
+ setTimeout(() => controller.abort(), 100);
62
+ const aborted = await outcome(controller.signal);
63
+ const timedOut = await outcome(AbortSignal.timeout(100));
64
+ const already = await outcome(AbortSignal.abort());
65
+ return Response.json({ aborted, timedOut, already });
66
+ }
67
+ if (url.pathname === "/self") {
68
+ const depth = Number(url.searchParams.get("depth") || 0);
69
+ if (depth >= 30) return new Response("bottom " + depth);
70
+ return new Response(await (await env.SELF.fetch("https://self/self?depth=" + (depth + 1))).text());
71
+ }
72
+ if (url.pathname === "/streamed") {
73
+ // Four chunks 300 ms apart upstream: a streamed body gets the first one early.
74
+ const t0 = Date.now();
75
+ const res = await env.PEER.fetch("https://peer/drip");
76
+ const reader = res.body.getReader();
77
+ let firstMs = -1;
78
+ let text = "";
79
+ for (;;) {
80
+ const { value, done } = await reader.read();
81
+ if (done) break;
82
+ if (firstMs < 0) firstMs = Date.now() - t0;
83
+ text += new TextDecoder().decode(value);
84
+ }
85
+ const whole = await (await env.PEER.fetch("https://peer/drip")).text();
86
+ const big = new Uint8Array(await (await env.PEER.fetch("https://peer/big")).arrayBuffer());
87
+ let bigOk = big.length === 12 * 1048576;
88
+ for (let i = 0; bigOk && i < big.length; i += 4093) bigOk = big[i] === (i & 255);
89
+ return Response.json({ firstEarly: firstMs >= 0 && firstMs < 250, text, whole, bigOk });
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
+ }
107
+ if (url.pathname === "/whoami") {
108
+ return Response.json({ url: request.url, internal: [...request.headers.keys()].filter((k) => k.startsWith("x-sb-")) });
109
+ }
110
+ if (url.pathname === "/boom") throw new RangeError("kaboom");
111
+ if (url.pathname === "/callself") {
112
+ const who = await (await env.SELF.fetch("https://self.example/whoami?q=1")).json();
113
+ let thrown = null;
114
+ try {
115
+ await env.SELF.fetch("https://self.example/boom");
116
+ } catch (e) {
117
+ thrown = [e.name, e.message, e instanceof RangeError];
118
+ }
119
+ return Response.json({ who, thrown });
120
+ }
121
+ return new Response("no route", { status: 404 });
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
+ }
140
+ `;
141
+
142
+ function freePort(): number {
143
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
144
+ const port = listener.port;
145
+ listener.stop(true);
146
+ return port;
147
+ }
148
+
149
+ const port = freePort();
150
+ const EDGE_SECRET = "native-services-edge-secret";
151
+ const EDGE_TOKEN = "native-services-edge-token";
152
+ // Upstream requests whose client went away before the answer: a cancelled call must
153
+ // stop here, not only be ignored by the caller.
154
+ let slowAbandoned = 0;
155
+ // The platform's edge, reduced to what the broker forwards to it: routing by Host.
156
+ const edge = Bun.serve({
157
+ hostname: "127.0.0.1",
158
+ port: 0,
159
+ async fetch(request) {
160
+ const url = new URL(request.url);
161
+ const host = request.headers.get("host");
162
+ if (host === "self.test") {
163
+ // Vouch for the call as the platform's edge does: the broker's secret in, the
164
+ // sprout's own token out, and the caller's URL passed along.
165
+ const headers = new Headers(request.headers);
166
+ for (const name of ["x-sb-edge-secret", "x-sb-edge-token", "x-sb-binding-call", "x-sb-url"]) headers.delete(name);
167
+ if (request.headers.get("x-sb-edge-secret") === EDGE_SECRET) {
168
+ headers.set("x-sb-binding-call", "1");
169
+ headers.set("x-sb-edge-token", EDGE_TOKEN);
170
+ const callerUrl = request.headers.get("x-sb-url");
171
+ if (callerUrl) headers.set("x-sb-url", callerUrl);
172
+ }
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 });
175
+ }
176
+ if (url.pathname === "/echo") {
177
+ return Response.json({
178
+ method: request.method,
179
+ path: url.pathname + url.search,
180
+ from: request.headers.get("x-from"),
181
+ body: await request.text(),
182
+ });
183
+ }
184
+ if (url.pathname === "/typed") {
185
+ return Response.json({ method: request.method, type: request.headers.get("content-type"), body: await request.text() });
186
+ }
187
+ if (url.pathname === "/redirect") {
188
+ const status = Number(url.searchParams.get("status"));
189
+ return new Response(null, { status, headers: { location: url.searchParams.get("to") ?? "/" } });
190
+ }
191
+ if (url.pathname === "/drip") {
192
+ const encoder = new TextEncoder();
193
+ return new Response(
194
+ new ReadableStream({
195
+ async start(controller) {
196
+ for (let i = 0; i < 4; i++) {
197
+ if (i) await Bun.sleep(300);
198
+ controller.enqueue(encoder.encode(i + " "));
199
+ }
200
+ controller.close();
201
+ },
202
+ }),
203
+ );
204
+ }
205
+ if (url.pathname === "/big") {
206
+ const bytes = new Uint8Array(12 * 1048576);
207
+ for (let i = 0; i < bytes.length; i++) bytes[i] = i & 255;
208
+ return new Response(bytes);
209
+ }
210
+ if (url.pathname === "/bare") return new Response(null, { headers: { "x-bare": "1" } });
211
+ if (url.pathname === "/slow") {
212
+ request.signal.addEventListener("abort", () => slowAbandoned++);
213
+ await Bun.sleep(Number(url.searchParams.get("ms") || 500));
214
+ return new Response("slow done");
215
+ }
216
+ if (url.pathname === "/cookies") {
217
+ const headers = new Headers();
218
+ headers.append("set-cookie", "a=1; Path=/");
219
+ headers.append("set-cookie", "b=2; Path=/");
220
+ return new Response("cookies", { headers });
221
+ }
222
+ return new Response("edge: no route", { status: 404 });
223
+ },
224
+ });
225
+
226
+ const workdir = await mkdtemp(join(tmpdir(), "sb-native-services-"));
227
+ const services = [
228
+ { binding: "PEER", service: "peer" },
229
+ { binding: "SELF", service: "self" },
230
+ ];
231
+ const bindings = { ...EMPTY_BINDINGS, services };
232
+ const broker = createBroker({
233
+ db: ":memory:",
234
+ token: "native-services",
235
+ bindings: { ...bindings, resources: {} },
236
+ edgeUrl: `http://127.0.0.1:${edge.port}/`,
237
+ edgeSecret: EDGE_SECRET,
238
+ services: { PEER: "peer.test", SELF: "self.test" },
239
+ });
240
+ const brokerServer = listen(broker, "127.0.0.1", 0);
241
+ let server: ReturnType<typeof Bun.spawn> | undefined;
242
+ const get = async (path: string) => {
243
+ const t0 = performance.now();
244
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, { signal: AbortSignal.timeout(20_000) });
245
+ return { status: res.status, text: await res.text(), ms: performance.now() - t0 };
246
+ };
247
+ try {
248
+ const porffor = await ensurePorffor({ cacheRoot: workdir });
249
+ const node = Bun.which("node");
250
+ if (!node) throw new Error("native service-binding test needs node on PATH");
251
+ const [core, transport] = await Promise.all([
252
+ readFile(preludePath, "utf8"),
253
+ readFile(transportPath("broker"), "utf8"),
254
+ ]);
255
+ assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
256
+ const generated = join(workdir, "services.generated.js");
257
+ const binary = join(workdir, "services-native");
258
+ await writeFile(generated, wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, transport), {}, bindings, undefined, "2026-10-22"));
259
+
260
+ const compile = Bun.spawn([node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"], {
261
+ cwd: workdir,
262
+ env: process.env,
263
+ stdout: "pipe",
264
+ stderr: "pipe",
265
+ });
266
+ const [exitCode, stdout, stderr] = await Promise.all([
267
+ compile.exited,
268
+ new Response(compile.stdout).text(),
269
+ new Response(compile.stderr).text(),
270
+ ]);
271
+ assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
272
+
273
+ server = Bun.spawn([binary], {
274
+ cwd: workdir,
275
+ env: {
276
+ ...process.env,
277
+ PORT: String(port),
278
+ SB_BROKER_PORT: String(brokerServer.port),
279
+ SB_BROKER_TOKEN: "native-services",
280
+ SB_EDGE_TOKEN: EDGE_TOKEN,
281
+ },
282
+ stdout: "ignore",
283
+ stderr: "inherit",
284
+ });
285
+ let started = false;
286
+ for (let attempt = 0; attempt < 50 && !started; attempt++) {
287
+ try {
288
+ started = (await get("/ping")).text === "pong";
289
+ } catch {
290
+ if (server.exitCode !== null) break;
291
+ await Bun.sleep(100);
292
+ }
293
+ }
294
+ assert(started, "compiled service-binding handler did not start");
295
+
296
+ const requests = await get("/requests");
297
+ assert.equal(requests.status, 200, requests.text);
298
+ assert.deepEqual(JSON.parse(requests.text), {
299
+ isPromise: true,
300
+ viaUrl: { method: "GET", path: "/echo?u=1", from: null, body: "" },
301
+ viaRequest: { method: "PATCH", path: "/echo", from: "request", body: "req body" },
302
+ viaStream: { method: "POST", path: "/echo", from: null, body: "streamed body" },
303
+ cookies: ["a=1; Path=/", "b=2; Path=/"],
304
+ bare: null,
305
+ });
306
+
307
+ // Redirects (compatibility date 2026-10-22): followed through the binding by default,
308
+ // returned under "manual", refused under "error"; a 303 turns a POST into a bodiless GET.
309
+ const redirects = await get("/redirects");
310
+ assert.equal(redirects.status, 200, redirects.text);
311
+ assert.deepEqual(JSON.parse(redirects.text), {
312
+ followed: { method: "GET", path: "/echo?hop=1", from: null, body: "" },
313
+ manual: [302, "/echo"],
314
+ seeOther: { method: "GET", type: null, body: "" },
315
+ temporary: { method: "POST", type: "text/plain", body: "kept" },
316
+ refused: "TypeError",
317
+ });
318
+
319
+ // A call in flight does not hold up other requests.
320
+ const slow = get("/slow?ms=1500");
321
+ await Bun.sleep(200);
322
+ const ping = await get("/ping");
323
+ assert.equal(ping.text, "pong");
324
+ assert(ping.ms < 500, `/ping took ${Math.round(ping.ms)} ms behind a 1.5 s call`);
325
+ assert.equal((await slow).text, "slow done");
326
+
327
+ // Calls overlap.
328
+ const t0 = performance.now();
329
+ const all = await Promise.all(Array.from({ length: 8 }, () => get("/slow?ms=500")));
330
+ const total = performance.now() - t0;
331
+ assert(all.every((r) => r.text === "slow done"), JSON.stringify(all));
332
+ assert(total < 1500, `8 concurrent 500 ms calls took ${Math.round(total)} ms`);
333
+
334
+ // Cancellation: an aborted signal rejects at once, and the abort reaches the upstream
335
+ // request (the broker drops it when the sprout closes the exchange's connection).
336
+ const abandonedBefore = slowAbandoned;
337
+ const aborts = await get("/abort");
338
+ assert.equal(aborts.status, 200, aborts.text);
339
+ assert.deepEqual(JSON.parse(aborts.text), { aborted: "AbortError fast", timedOut: "TimeoutError fast", already: "AbortError fast" });
340
+ await Bun.sleep(300);
341
+ assert.equal(slowAbandoned - abandonedBefore, 2, "both in-flight calls were stopped upstream");
342
+
343
+ // A call the edge vouched for sees the caller's URL and none of the edge's headers,
344
+ // and the callee's error is thrown in the caller, class and message intact.
345
+ const callself = await get("/callself");
346
+ assert.equal(callself.status, 200, callself.text);
347
+ assert.deepEqual(JSON.parse(callself.text), {
348
+ who: { url: "https://self.example/whoami?q=1", internal: [] },
349
+ thrown: ["RangeError", "kaboom", true],
350
+ });
351
+ // A client cannot claim to be a binding call: no caller URL, no error marker.
352
+ const forged = { "x-sb-binding-call": "1", "x-sb-edge-token": "guess", "x-sb-url": "https://forged.example/" };
353
+ const spoofed = await fetch(`http://127.0.0.1:${port}/whoami`, { headers: forged });
354
+ assert.equal((await spoofed.json()).url, `http://127.0.0.1:${port}/whoami`);
355
+ const publicBoom = await fetch(`http://127.0.0.1:${port}/boom`, { headers: forged });
356
+ assert.equal(publicBoom.status, 500);
357
+ assert.equal(publicBoom.headers.get("x-sb-uncaught"), null);
358
+
359
+ // Streamed responses (compatibility date 2026-10-21 on): the call resolves on the
360
+ // head, the first chunk arrives before the rest, and a 12 MiB body arrives intact.
361
+ const streamed = await get("/streamed");
362
+ assert.equal(streamed.status, 200, streamed.text);
363
+ assert.deepEqual(JSON.parse(streamed.text), { firstEarly: true, text: "0 1 2 3 ", whole: "0 1 2 3 ", bigOk: true });
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
+
380
+ // A deployment calling itself, 30 levels deep: every level waits on the next at once.
381
+ const self = await get("/self");
382
+ assert.equal(self.text, "bottom 30");
383
+ console.log(
384
+ `Service bindings return promises and run off the loop: /ping ${Math.round(ping.ms)} ms during a call, ` +
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`,
386
+ );
387
+ } finally {
388
+ if (server) {
389
+ server.kill(9);
390
+ await server.exited;
391
+ }
392
+ brokerServer.stop();
393
+ broker.close();
394
+ edge.stop(true);
395
+ await rm(workdir, { recursive: true, force: true });
396
+ }