@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 +22 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +38 -22
- package/src/transport-broker.js +5 -0
- package/src/transport-embedded.js +5 -0
- package/test/native-service-bindings.ts +39 -2
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
|
@@ -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.
|
|
5222
|
-
// in Workers
|
|
5223
|
-
// which is also what lets a deployment call itself without
|
|
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
|
-
|
|
5229
|
+
fetch(input, init) {
|
|
5230
|
+
if (globalThis.__sbCompat >= "2026-10-22") return __sbServiceFetch(binding, input, init);
|
|
5228
5231
|
const parts = __sbFetchParts(input, init);
|
|
5229
|
-
|
|
5230
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
5593
|
-
if (
|
|
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
|
-
|
|
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"]) {
|
package/src/transport-broker.js
CHANGED
|
@@ -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-
|
|
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);
|