@sproutboat/runtime 0.43.0 → 0.44.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,15 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.44.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8e08bb2: runtime: service bindings between apps on a sproutd host. `env.PEER.fetch()` and RPC on a callee's `WorkerEntrypoint` (`env.PEER.method(...)`) now work from compatibility date 2026-11-18, where the embedded transport had no `service.fetch` op and a call failed with `unknown op`. The caller sends the call as an HTTP request to the pseudo-host `sbsvc.<binding name in hex>` over its egress socket (`SB_EGRESS_FD`). The host looks the binding up in the services map it holds for the caller, answers `ok TICKET` with a connection to its own listener, and the transport sends the ticket as an `x-sb-svc-ticket` header. The host, which alone mints tickets, then delivers the request to the callee marked as a vouched service-binding call, so the callee's existing ingress (`x-sb-binding-call`, `x-sb-edge-token`, `x-sb-url`, `x-sb-rpc`, `x-sb-uncaught`) works unchanged. A call is bounded at 30 seconds per hop, with no depth cap: a loop unwinds on that timeout. Before 2026-11-18 a call fails as before, with a clearer message. A standalone binary, which has no peer apps, reports that. An older host answers the pseudo-host as an unresolvable name.
8
+
9
+ ### Patch Changes
10
+
11
+ - 8e08bb2: runtime: the promise-returning R2 binding (compatibility date 2026-10-26 and later) has `createUploadUrl()` and `createDownloadUrl()` again, with the semantics of the legacy binding (the same `r2.transfer.create` call, now returning promises). They were missing there, so an app that moved past 2026-10-26 lost direct transfer with a "not a function" error. No date gate: the methods did not exist at those dates, so nothing that worked changes.
12
+
3
13
  ## 0.43.0
4
14
 
5
15
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -242,6 +242,82 @@ int main(void) {
242
242
  }
243
243
  });
244
244
 
245
+ test('SB_EGRESS_FD: a service-binding call\'s "ok TICKET" answer leaves the ticket for the request', () => {
246
+ const source = readFileSync(transportFile, "utf8");
247
+ const block = source.slice(source.indexOf("// EGRESS-FD:BEGIN"), source.indexOf("// EGRESS-FD:END"));
248
+ const dir = mkdtempSync(join(tmpdir(), "sb-egress-ticket-"));
249
+ try {
250
+ writeFileSync(
251
+ join(dir, "check.c"),
252
+ `#include <errno.h>
253
+ #include <pthread.h>
254
+ #include <stdio.h>
255
+ #include <stdlib.h>
256
+ #include <string.h>
257
+ #include <unistd.h>
258
+ #include <sys/socket.h>
259
+ #include <netinet/in.h>
260
+ #include <netinet/tcp.h>
261
+ #include <sys/time.h>
262
+ #include <sys/wait.h>
263
+ ${block}
264
+ static void answer(int via, const char* text, int fd) {
265
+ union { struct cmsghdr align; char space[CMSG_SPACE(sizeof(int))]; } control;
266
+ struct iovec io = { (void*)text, strlen(text) };
267
+ struct msghdr msg;
268
+ memset(&msg, 0, sizeof(msg));
269
+ msg.msg_iov = &io; msg.msg_iovlen = 1;
270
+ if (fd >= 0) {
271
+ msg.msg_control = control.space; msg.msg_controllen = sizeof(control.space);
272
+ struct cmsghdr* c = CMSG_FIRSTHDR(&msg);
273
+ c->cmsg_level = SOL_SOCKET; c->cmsg_type = SCM_RIGHTS; c->cmsg_len = CMSG_LEN(sizeof(int));
274
+ memcpy(CMSG_DATA(c), &fd, sizeof(fd));
275
+ }
276
+ sendmsg(via, &msg, 0);
277
+ }
278
+ int main(void) {
279
+ int pair[2];
280
+ socketpair(AF_UNIX, SOCK_STREAM, 0, pair);
281
+ if (fork() == 0) {
282
+ close(pair[0]);
283
+ char line[512];
284
+ int remote[2];
285
+ read(pair[1], line, sizeof(line) - 1);
286
+ socketpair(AF_UNIX, SOCK_STREAM, 0, remote);
287
+ answer(pair[1], "ok 0123abcd\\n", remote[0]);
288
+ read(pair[1], line, sizeof(line) - 1);
289
+ socketpair(AF_UNIX, SOCK_STREAM, 0, remote);
290
+ answer(pair[1], "ok\\n", remote[0]);
291
+ _exit(0);
292
+ }
293
+ close(pair[1]);
294
+ char refused[512] = "";
295
+ int fd = sb_egress_fd_connect(pair[0], "sbsvc.70", 80, refused, sizeof(refused));
296
+ printf("service %s ticket=%s\\n", fd >= 0 ? "yes" : "no", sb_svc_ticket);
297
+ fd = sb_egress_fd_connect(pair[0], "up.test", 80, refused, sizeof(refused));
298
+ printf("plain %s ticket=%s\\n", fd >= 0 ? "yes" : "no", sb_svc_ticket);
299
+ wait(0);
300
+ return 0;
301
+ }
302
+ `,
303
+ );
304
+ const cc = Bun.spawnSync([
305
+ "cc",
306
+ "-Wall",
307
+ "-Werror",
308
+ "-Wno-unused-result",
309
+ "-o",
310
+ join(dir, "check"),
311
+ join(dir, "check.c"),
312
+ ]);
313
+ expect(cc.stderr.toString()).toBe("");
314
+ const run = Bun.spawnSync([join(dir, "check")]);
315
+ expect(run.stdout.toString().trimEnd().split("\n")).toEqual(["service yes ticket=0123abcd", "plain yes ticket="]);
316
+ } finally {
317
+ rmSync(dir, { recursive: true, force: true });
318
+ }
319
+ });
320
+
245
321
  test("sb_tcp_connect sets TCP_NODELAY on the socket it returns", () => {
246
322
  // Nagle plus the peer's delayed ACK stalled every fresh HTTPS fetch about 44 ms on Linux (a TLS
247
323
  // handshake writes small flights). Timing is flaky on CI and loopback on macOS does not show
@@ -11174,6 +11174,31 @@ function __sbMakeR2(name) {
11174
11174
  resumeMultipartUpload(key, uploadId) {
11175
11175
  return upload(String(key), String(uploadId));
11176
11176
  },
11177
+ // Direct transfer (a sproutboat extension, as on the legacy binding): a URL the client sends the bytes to or
11178
+ // fetches them from without passing through the app. Promises, like the rest of this binding.
11179
+ createUploadUrl(key, options) {
11180
+ return attempt(() => {
11181
+ const o = options || {};
11182
+ const r = __sbRpc("r2.transfer.create", {
11183
+ bucket: name,
11184
+ key: String(key),
11185
+ method: "upload",
11186
+ maxBytes: o.maxBytes,
11187
+ expiresIn: o.expiresIn,
11188
+ sha256: o.sha256,
11189
+ httpMetadata: o.httpMetadata || {},
11190
+ customMetadata: o.customMetadata || {},
11191
+ });
11192
+ return { url: r.url, expiresAt: r.expiresAt };
11193
+ });
11194
+ },
11195
+ createDownloadUrl(key, options) {
11196
+ return attempt(() => {
11197
+ const o = options || {};
11198
+ const r = __sbRpc("r2.transfer.create", { bucket: name, key: String(key), method: "download", expiresIn: o.expiresIn });
11199
+ return { url: r.url, expiresAt: r.expiresAt };
11200
+ });
11201
+ },
11177
11202
  };
11178
11203
  }
11179
11204
 
@@ -11970,6 +11995,8 @@ async function __sbRpcCall(binding, method, args) {
11970
11995
  const msg = {
11971
11996
  binding,
11972
11997
  url: "https://rpc.sproutboat.internal/" + method,
11998
+ // Marks an RPC call for a transport that has to tell it from a fetch that carries an x-sb-rpc header of its own.
11999
+ rpc: true,
11973
12000
  method: "POST",
11974
12001
  headers: [
11975
12002
  ["x-sb-rpc", method],
@@ -2679,6 +2679,8 @@ static void sb_tcp_nodelay(int fd) {
2679
2679
  // broken exchange closes N for good, so a late answer can never be taken for the
2680
2680
  // next one.
2681
2681
  static int sb_egress_fd_ask(int via, const char* host, int port, char* refused, size_t refused_len);
2682
+ // The ticket of the last service-binding connect on this thread ("" for any other connect).
2683
+ static __thread char sb_svc_ticket[72];
2682
2684
  static int sb_egress_fd_connect(int via, const char* host, int port, char* refused, size_t refused_len) {
2683
2685
  static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
2684
2686
  pthread_mutex_lock(&lock);
@@ -2714,7 +2716,16 @@ static int sb_egress_fd_ask(int via, const char* host, int port, char* refused,
2714
2716
  return -1;
2715
2717
  }
2716
2718
  answer[got - 1] = 0;
2719
+ // "ok TICKET" (a service-binding call, host "sbsvc.HEX"): the host connected us to itself and the
2720
+ // ticket, which only it could mint, tells it who is calling. The request carries it as a header.
2721
+ sb_svc_ticket[0] = 0;
2722
+ if (!strncmp(answer, "ok ", 3) && fd >= 0 && strlen(answer + 3) > 0 && strlen(answer + 3) < sizeof(sb_svc_ticket)) {
2723
+ snprintf(sb_svc_ticket, sizeof(sb_svc_ticket), "%s", answer + 3);
2724
+ answer[2] = 0;
2725
+ }
2717
2726
  if (!strcmp(answer, "ok") && fd >= 0) {
2727
+ // A service call is bounded per hop at 30 s, as the callee's own wall limit is.
2728
+ if (sb_svc_ticket[0]) tv.tv_sec = 30;
2718
2729
  setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
2719
2730
  setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
2720
2731
  sb_tcp_nodelay(fd);
@@ -2885,11 +2896,22 @@ static char* sb_error_json(const char* prefix, const char* detail) {
2885
2896
  static char* sb_http_plain(const char* host, int port, const char* path, const char* method,
2886
2897
  const char* headers, const char* body, size_t body_len) {
2887
2898
  char refused[512] = "";
2899
+ sb_svc_ticket[0] = 0;
2888
2900
  int fd = sb_tcp_connect(host, port, refused, sizeof(refused));
2889
2901
  if (fd < 0) return refused[0] ? sb_error_json(refused, 0) : sb_error_json("could not connect to ", host);
2890
2902
 
2903
+ // A service-binding call: the ticket the host minted for it goes first among the headers.
2904
+ sb_buf tagged = { 0, 0, 0 };
2905
+ if (sb_svc_ticket[0]) {
2906
+ sb_puts(&tagged, "x-sb-svc-ticket: ");
2907
+ sb_puts(&tagged, sb_svc_ticket);
2908
+ sb_puts(&tagged, "\r\n");
2909
+ if (headers && *headers) sb_puts(&tagged, headers);
2910
+ headers = tagged.p;
2911
+ }
2891
2912
  sb_buf req = { 0, 0, 0 };
2892
2913
  sb_build_request(&req, host, port, 80, path, method, headers, body, body_len);
2914
+ free(tagged.p);
2893
2915
  size_t sent = 0;
2894
2916
  while (sent < req.len) {
2895
2917
  long n = write(fd, req.p + sent, req.len - sent);
@@ -5154,24 +5176,73 @@ function __sbEmbeddedDispatch(msg) {
5154
5176
  return { ok: true, found: false, status: 404, body: "Not Found" };
5155
5177
  }
5156
5178
 
5157
- if (op === "fetch") {
5158
- const url = new URL(String(msg.url));
5159
- const tls = url.protocol === "https:";
5160
- if (!tls && url.protocol !== "http:") throw new Error("unsupported protocol: " + url.protocol);
5179
+ // A service-binding call (compatibility date 2026-11-18): the host the app runs on routes it to the
5180
+ // callee. The call is an HTTP request to the pseudo-host "sbsvc.HEX" (the binding's name in hex): the
5181
+ // host sees the name on the egress socket the question came in on, finds the callee in its services
5182
+ // map and answers "ok TICKET" with a connection to its own listener. __sbHttpRaw sends the ticket as
5183
+ // a header (sb_http_plain) and the host's frontend, which alone mints tickets, delivers the request
5184
+ // to the callee marked as a service-binding call. See sproutd's docs/bindings.md, "Service bindings".
5185
+ if (op === "fetch" || op === "service.fetch") {
5186
+ const service = op === "service.fetch";
5187
+ let hostname;
5188
+ let port;
5189
+ let pathname;
5190
+ let tls = false;
5161
5191
  let headerText = "";
5192
+ if (service) {
5193
+ if (__sbCompatDate() < "2026-11-18") {
5194
+ throw new Error(
5195
+ "service binding " + msg.binding + " needs compatibility_date 2026-11-18 or later to reach another app on this host",
5196
+ );
5197
+ }
5198
+ if (!__sbEnv("SB_EGRESS_FD")) {
5199
+ throw new Error(
5200
+ "service binding " + msg.binding + " has no app to reach here: a standalone binary has no peer apps, only a platform host (sproutd) has",
5201
+ );
5202
+ }
5203
+ const binding = String(msg.binding);
5204
+ let hex = "";
5205
+ for (let i = 0; i < binding.length; i++) {
5206
+ const c = binding.charCodeAt(i);
5207
+ if (c > 127) throw new Error("service binding names are ASCII: " + binding);
5208
+ hex += (c < 16 ? "0" : "") + c.toString(16);
5209
+ }
5210
+ if (hex.length > 200) throw new Error("service binding name too long: " + binding);
5211
+ hostname = "sbsvc." + hex;
5212
+ port = "80";
5213
+ // The callee sees the URL its caller passed (x-sb-url, vouched for by the host); the request line
5214
+ // carries only its path.
5215
+ try {
5216
+ const parsed = new URL(String(msg.url));
5217
+ pathname = parsed.pathname + parsed.search;
5218
+ headerText += "x-sb-url: " + parsed.href + "\r\n";
5219
+ } catch (e) {
5220
+ const p = String(msg.url || "/");
5221
+ pathname = p.charAt(0) === "/" ? p : "/" + p;
5222
+ }
5223
+ } else {
5224
+ const url = new URL(String(msg.url));
5225
+ tls = url.protocol === "https:";
5226
+ if (!tls && url.protocol !== "http:") throw new Error("unsupported protocol: " + url.protocol);
5227
+ hostname = url.hostname;
5228
+ port = url.port ? url.port : tls ? "443" : "80";
5229
+ pathname = url.pathname + url.search;
5230
+ }
5162
5231
  const pairs = msg.headers || [];
5163
5232
  for (let i = 0; i < pairs.length; i++) {
5164
5233
  const key = String(pairs[i][0]).toLowerCase();
5165
5234
  // Host, Connection and Content-Length are ours to set.
5166
5235
  if (key === "host" || key === "connection" || key === "content-length") continue;
5236
+ // The host's markers are its own: the caller's x-sb-* headers do not travel. Only an RPC call (the prelude's
5237
+ // __sbRpcCall, msg.rpc) sends x-sb-rpc, which names the method.
5238
+ if (service && key.slice(0, 5) === "x-sb-" && !(msg.rpc === true && key === "x-sb-rpc")) continue;
5167
5239
  headerText += pairs[i][0] + ": " + pairs[i][1] + "\r\n";
5168
5240
  }
5169
- const port = url.port ? url.port : tls ? "443" : "80";
5170
5241
  const asyncId = msg.asyncId > 0 ? msg.asyncId : 0;
5171
5242
  const raw = __sbHttpRaw(
5172
- url.hostname,
5243
+ hostname,
5173
5244
  port,
5174
- url.pathname + url.search,
5245
+ pathname,
5175
5246
  String(msg.method || "GET").toUpperCase(),
5176
5247
  headerText,
5177
5248
  msg.body == null ? "" : String(msg.body),
@@ -0,0 +1,101 @@
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 } from "node:path";
5
+ import { compileNative } from "./native-compile";
6
+ import { createBroker, listen } from "../../wire/src/broker";
7
+ import { EMPTY_BINDINGS, preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
8
+
9
+ // Direct R2 transfer on the promise-returning binding (compatibility date 2026-10-26 and later): createUploadUrl()
10
+ // and createDownloadUrl() exist there as on the legacy binding, return promises, and answer {url, expiresAt}.
11
+ const handler = `
12
+ export default {
13
+ async fetch(request, env) {
14
+ const url = new URL(request.url);
15
+ if (url.pathname === "/ping") return new Response("pong");
16
+ const up = env.UPLOADS.createUploadUrl("k", { maxBytes: 10 });
17
+ const upIsPromise = up instanceof Promise;
18
+ const upload = await up;
19
+ await env.UPLOADS.put("k", "hello");
20
+ const down = env.UPLOADS.createDownloadUrl("k");
21
+ const downIsPromise = down instanceof Promise;
22
+ const download = await down;
23
+ return Response.json({
24
+ upIsPromise,
25
+ downIsPromise,
26
+ upload: { url: typeof upload.url, expiresAt: typeof upload.expiresAt },
27
+ download: { url: typeof download.url, expiresAt: typeof download.expiresAt },
28
+ });
29
+ },
30
+ };
31
+ `;
32
+
33
+ function freePort(): number {
34
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
35
+ const port = listener.port;
36
+ listener.stop(true);
37
+ return port;
38
+ }
39
+
40
+ const port = freePort();
41
+ const workdir = await mkdtemp(join(tmpdir(), "sb-native-r2-transfer-"));
42
+ const bindings = { ...EMPTY_BINDINGS, r2: ["UPLOADS"] };
43
+ const broker = createBroker({
44
+ db: join(workdir, "state.sqlite"),
45
+ token: "native-r2",
46
+ bindings: { ...bindings, resources: {} },
47
+ });
48
+ const brokerServer = listen(broker, "127.0.0.1", 0);
49
+ let server: ReturnType<typeof Bun.spawn> | undefined;
50
+ const get = async (path: string) => {
51
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, { signal: AbortSignal.timeout(20_000) });
52
+ return { status: res.status, text: await res.text() };
53
+ };
54
+ try {
55
+ const [core, transport] = await Promise.all([
56
+ readFile(preludePath, "utf8"),
57
+ readFile(transportPath("broker"), "utf8"),
58
+ ]);
59
+ assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
60
+ const generated = join(workdir, "r2.generated.js");
61
+ const binary = join(workdir, "r2-native");
62
+ await writeFile(
63
+ generated,
64
+ wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, transport), {}, bindings, undefined, "2026-10-26"),
65
+ );
66
+ await compileNative({ generated, binary, dir: workdir, cacheRoot: workdir });
67
+ server = Bun.spawn([binary], {
68
+ cwd: workdir,
69
+ env: {
70
+ ...process.env,
71
+ PORT: String(port),
72
+ SB_BROKER_PORT: String(brokerServer.port),
73
+ SB_BROKER_TOKEN: "native-r2",
74
+ },
75
+ stdout: "ignore",
76
+ stderr: "inherit",
77
+ });
78
+ let started = false;
79
+ for (let attempt = 0; attempt < 50 && !started; attempt++) {
80
+ try {
81
+ started = (await get("/ping")).text === "pong";
82
+ } catch {
83
+ if (server.exitCode !== null) break;
84
+ await Bun.sleep(100);
85
+ }
86
+ }
87
+ assert(started, "compiled R2 handler did not start");
88
+ const answer = await get("/");
89
+ assert.equal(answer.status, 200, answer.text);
90
+ assert.deepEqual(JSON.parse(answer.text), {
91
+ upIsPromise: true,
92
+ downIsPromise: true,
93
+ upload: { url: "string", expiresAt: "string" },
94
+ download: { url: "string", expiresAt: "string" },
95
+ });
96
+ console.log("native R2 direct transfer: ok");
97
+ } finally {
98
+ server?.kill();
99
+ brokerServer.stop();
100
+ await rm(workdir, { recursive: true, force: true });
101
+ }