@sproutboat/runtime 0.21.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,27 @@
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
+
3
25
  ## 0.21.0
4
26
 
5
27
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -5218,24 +5218,19 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5218
5218
 
5219
5219
  // #48 — worker-to-worker. Same wire shape as outbound fetch, but the broker
5220
5220
  // resolves the target itself and forwards it internally, so this is not
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.
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.
5224
5226
  for (let i = 0; i < (bindings.services || []).length; i++) {
5225
5227
  const binding = bindings.services[i].binding;
5226
5228
  const fetcher = {
5227
- async fetch(input, init) {
5229
+ fetch(input, init) {
5230
+ if (globalThis.__sbCompat >= "2026-10-22") return __sbServiceFetch(binding, input, init);
5228
5231
  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;
5232
+ const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(parts.body) };
5233
+ return __sbUpstreamResponse(globalThis.__sbFetchUpstream("service.fetch", msg));
5239
5234
  },
5240
5235
  };
5241
5236
  // Any other name is an RPC method of the callee's WorkerEntrypoint (see __sbRpcCall).
@@ -5248,9 +5243,24 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5248
5243
  },
5249
5244
  });
5250
5245
  }
5251
-
5252
5246
  };
5253
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
+
5254
5264
  // The error a service-binding callee threw, from its x-sb-uncaught marker: the same
5255
5265
  // standard class where there is one, else an Error with the original name.
5256
5266
  function __sbRemoteError(marker) {
@@ -5381,7 +5391,7 @@ async function __sbRpcCall(binding, method, args) {
5381
5391
  ],
5382
5392
  body,
5383
5393
  };
5384
- const response = await __sbUpstream("service.fetch", msg, label, null);
5394
+ const response = await __sbUpstream("service.fetch", msg, label, null, true);
5385
5395
  const uncaught = response.headers.get("x-sb-uncaught");
5386
5396
  if (uncaught != null) throw __sbRemoteError(uncaught);
5387
5397
  if (response.headers.get("x-sb-rpc-result") !== "1") {
@@ -5462,8 +5472,8 @@ function __sbUpstreamResponse(r) {
5462
5472
  return response;
5463
5473
  }
5464
5474
 
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
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
5467
5477
  // ReadableStream fed chunk by chunk as the broker passes them on. Each frame fires the
5468
5478
  // same callback, so it is an interval that the last frame clears. A reply that comes
5469
5479
  // whole (a broker from before streaming, or sproutd's embedded transport) is handled
@@ -5587,10 +5597,15 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
5587
5597
  // callback alive until then. An aborted `signal` rejects with its reason and cancels
5588
5598
  // the exchange, which closes its broker connection: the broker stops the upstream
5589
5599
  // request, and the edge in turn stops the callee's.
5590
- 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) {
5591
5605
  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);
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);
5594
5609
  return new Promise((resolve, reject) => {
5595
5610
  let onAbort = null;
5596
5611
  const settled = () => {
@@ -6164,7 +6179,8 @@ function __sbBindingCallIngress(request) {
6164
6179
  const vouched = !!want && headers.get("x-sb-binding-call") === "1" && __sbEqCt(headers.get("x-sb-edge-token") || "", want);
6165
6180
  if (vouched) {
6166
6181
  const url = headers.get("x-sb-url");
6167
- 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;
6168
6184
  headers.delete("x-sb-remote-addr");
6169
6185
  }
6170
6186
  for (const name of ["x-sb-binding-call", "x-sb-url", "x-sb-edge-token"]) {
@@ -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;
@@ -239,6 +239,7 @@ const broker = createBroker({
239
239
  });
240
240
  const brokerServer = listen(broker, "127.0.0.1", 0);
241
241
  let server: ReturnType<typeof Bun.spawn> | undefined;
242
+ let legacy: ReturnType<typeof Bun.spawn> | undefined;
242
243
  const get = async (path: string) => {
243
244
  const t0 = performance.now();
244
245
  const res = await fetch(`http://127.0.0.1:${port}${path}`, { signal: AbortSignal.timeout(20_000) });
@@ -356,7 +357,7 @@ try {
356
357
  assert.equal(publicBoom.status, 500);
357
358
  assert.equal(publicBoom.headers.get("x-sb-uncaught"), null);
358
359
 
359
- // 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
360
361
  // head, the first chunk arrives before the rest, and a 12 MiB body arrives intact.
361
362
  const streamed = await get("/streamed");
362
363
  assert.equal(streamed.status, 200, streamed.text);
@@ -380,15 +381,51 @@ try {
380
381
  // A deployment calling itself, 30 levels deep: every level waits on the next at once.
381
382
  const self = await get("/self");
382
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
+
383
416
  console.log(
384
417
  `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`,
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`,
386
419
  );
387
420
  } finally {
388
421
  if (server) {
389
422
  server.kill(9);
390
423
  await server.exited;
391
424
  }
425
+ if (legacy) {
426
+ legacy.kill(9);
427
+ await legacy.exited;
428
+ }
392
429
  brokerServer.stop();
393
430
  broker.close();
394
431
  edge.stop(true);