@sproutboat/runtime 0.43.0 → 0.45.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,21 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.45.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2b6d912: R2 large objects on the embedded transport (sproutd #99 and #100). `get()` reads an object as asked: a `range` reads only its window from the object's file, and `body` is a stream of 256 KiB reads, so an object of any size can be returned with `new Response(object.body)`; `text()`, `json()`, `arrayBuffer()` and `blob()` still read the whole window. A streamed response now has flow control on a host that offers it (sproutd): the pump reads no more of its source while a window of its bytes is unacknowledged, and a response with a declared `content-length` is sent with it. A worker whose host forwards `/__sb/r2/transfer/BUCKET/TOKEN` (sproutd) serves the ticket itself, with the standalone server's statuses and headers: uploads and downloads move a window at a time between the connection and the object's file. A standalone executable answers a HEAD request with the length the app declared.
8
+
9
+ ## 0.44.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.
14
+
15
+ ### Patch Changes
16
+
17
+ - 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.
18
+
3
19
  ## 0.43.0
4
20
 
5
21
  ### 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.45.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
@@ -11024,28 +11024,63 @@ function __sbR2StrictObject(meta, bytes, range, include) {
11024
11024
  };
11025
11025
  if (bytes == null) return obj;
11026
11026
  let used = false;
11027
+ // A lazy body (bytes.lazy: the object's file, read as asked) is never read whole unless text(), json(),
11028
+ // arrayBuffer() or blob() ask for all of it; its stream reads one chunk at a time.
11029
+ const lazy = bytes.lazy === true;
11027
11030
  const take = () => {
11028
11031
  if (used) throw new TypeError("Body has already been used. It can only be used once. Use tee() first if you need to read it twice.");
11029
11032
  used = true;
11030
- return bytes;
11033
+ return lazy ? __sbR2ReadAll(bytes, range) : bytes;
11031
11034
  };
11032
11035
  // The stream is made when first asked for: text() and friends read the bytes directly,
11033
11036
  // so an object read as text never holds a second copy (sproutd #22).
11034
11037
  let stream = null;
11035
11038
  Object.defineProperty(obj, "body", {
11036
11039
  get: () => {
11037
- if (stream === null) stream = new Response(__sbBufFrom(bytes)).body;
11040
+ if (stream === null) stream = lazy ? __sbR2LazyStream(bytes, range) : new Response(__sbBufFrom(bytes)).body;
11038
11041
  return stream;
11039
11042
  },
11040
11043
  enumerable: true,
11041
11044
  });
11042
11045
  Object.defineProperty(obj, "bodyUsed", { get: () => used, enumerable: true });
11043
- obj.text = async () => __sbFromUtf8(take());
11044
- obj.json = async () => JSON.parse(__sbFromUtf8(take()));
11045
- obj.arrayBuffer = async () => __sbBufFrom(take());
11046
- obj.blob = async () => new Blob([__sbBufFrom(take())], { type: obj.httpMetadata.contentType || "" });
11046
+ // A lazy body reads into a Uint8Array (never a bytestring), so each of these takes bytes, not text.
11047
+ obj.text = async () => (lazy ? new TextDecoder().decode(take()) : __sbFromUtf8(take()));
11048
+ obj.json = async () => JSON.parse(lazy ? new TextDecoder().decode(take()) : __sbFromUtf8(take()));
11049
+ obj.arrayBuffer = async () => (lazy ? take().buffer : __sbBufFrom(take()));
11050
+ obj.blob = async () => new Blob([lazy ? take().buffer : __sbBufFrom(take())], { type: obj.httpMetadata.contentType || "" });
11047
11051
  return obj;
11048
11052
  }
11053
+ // The chunk a streamed R2 body reads at a time (a Uint8Array of this size, then collected): a chunk is
11054
+ // what the worker's heap holds per read, and what the response's flow control hands to the host.
11055
+ const __SB_R2_CHUNK = 256 * 1024;
11056
+ // The bytes of a lazy body's window, all of them: text(), json(), arrayBuffer() and blob() need the whole
11057
+ // object in memory whatever they are called on.
11058
+ function __sbR2ReadAll(src, range) {
11059
+ const out = new Uint8Array(range.length);
11060
+ if (range.length > 0 && __sbR2ReadRange(src.bucket, src.key, src.blobId, range.offset, out) !== range.length)
11061
+ throw new Error("sproutboat r2.get: the object could not be read");
11062
+ return out;
11063
+ }
11064
+ // A lazy body's window as a ReadableStream of __SB_R2_CHUNK-byte reads.
11065
+ function __sbR2LazyStream(src, range) {
11066
+ let at = 0;
11067
+ return new ReadableStream({
11068
+ pull(controller) {
11069
+ if (at >= range.length) {
11070
+ controller.close();
11071
+ return;
11072
+ }
11073
+ const n = Math.min(__SB_R2_CHUNK, range.length - at);
11074
+ const chunk = new Uint8Array(n);
11075
+ if (__sbR2ReadRange(src.bucket, src.key, src.blobId, range.offset + at, chunk) !== n) {
11076
+ controller.error(new Error("sproutboat r2.get: the object could not be read"));
11077
+ return;
11078
+ }
11079
+ at += n;
11080
+ controller.enqueue(chunk);
11081
+ },
11082
+ });
11083
+ }
11049
11084
  // A hex string's bytes, one char per byte.
11050
11085
  function __sbR2Bytes(hex) {
11051
11086
  let out = "";
@@ -11092,7 +11127,19 @@ function __sbMakeR2(name) {
11092
11127
  get(key, options) {
11093
11128
  return attempt(() => {
11094
11129
  const o = options || {};
11095
- const r = __sbR2Get(name, __sbR2Key(key, "get"));
11130
+ const objectKey = __sbR2Key(key, "get");
11131
+ // Where the transport can read an object's file as asked (embedded: sproutd and standalone), get() takes its
11132
+ // metadata and no bytes: a range reads only its own window, a body streams. The broker answers null here
11133
+ // and returns the object whole, as before.
11134
+ const opened = __sbR2Open(name, objectKey);
11135
+ if (opened !== null) {
11136
+ if (!opened.found) return null;
11137
+ const meta = opened.object;
11138
+ if (!__sbR2OnlyIf(o.onlyIf, meta.md5 || meta.etag, meta.uploaded)) return __sbR2StrictObject(meta, null, { offset: 0, length: meta.size });
11139
+ const window = o.range == null ? { offset: 0, length: meta.size } : __sbR2Range(o.range, meta.size);
11140
+ return __sbR2StrictObject(meta, { lazy: true, bucket: name, key: objectKey, blobId: opened.blobId }, window);
11141
+ }
11142
+ const r = __sbR2Get(name, objectKey);
11096
11143
  if (!r.found) return null;
11097
11144
  const meta = r.object;
11098
11145
  if (!__sbR2OnlyIf(o.onlyIf, meta.md5 || meta.etag, meta.uploaded)) return __sbR2StrictObject(meta, null, { offset: 0, length: meta.size });
@@ -11174,6 +11221,31 @@ function __sbMakeR2(name) {
11174
11221
  resumeMultipartUpload(key, uploadId) {
11175
11222
  return upload(String(key), String(uploadId));
11176
11223
  },
11224
+ // Direct transfer (a sproutboat extension, as on the legacy binding): a URL the client sends the bytes to or
11225
+ // fetches them from without passing through the app. Promises, like the rest of this binding.
11226
+ createUploadUrl(key, options) {
11227
+ return attempt(() => {
11228
+ const o = options || {};
11229
+ const r = __sbRpc("r2.transfer.create", {
11230
+ bucket: name,
11231
+ key: String(key),
11232
+ method: "upload",
11233
+ maxBytes: o.maxBytes,
11234
+ expiresIn: o.expiresIn,
11235
+ sha256: o.sha256,
11236
+ httpMetadata: o.httpMetadata || {},
11237
+ customMetadata: o.customMetadata || {},
11238
+ });
11239
+ return { url: r.url, expiresAt: r.expiresAt };
11240
+ });
11241
+ },
11242
+ createDownloadUrl(key, options) {
11243
+ return attempt(() => {
11244
+ const o = options || {};
11245
+ const r = __sbRpc("r2.transfer.create", { bucket: name, key: String(key), method: "download", expiresIn: o.expiresIn });
11246
+ return { url: r.url, expiresAt: r.expiresAt };
11247
+ });
11248
+ },
11177
11249
  };
11178
11250
  }
11179
11251
 
@@ -11970,6 +12042,8 @@ async function __sbRpcCall(binding, method, args) {
11970
12042
  const msg = {
11971
12043
  binding,
11972
12044
  url: "https://rpc.sproutboat.internal/" + method,
12045
+ // 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.
12046
+ rpc: true,
11973
12047
  method: "POST",
11974
12048
  headers: [
11975
12049
  ["x-sb-rpc", method],
@@ -14063,7 +14137,8 @@ function __sbHasStreamBody(res) {
14063
14137
  // x-sb-stream header, the native writer sends the status and headers and holds the
14064
14138
  // connection open, and each chunk is written as it arrives. Hosts that do not stream
14065
14139
  // (sproutd's workers, for now) link the weak fallbacks, report unsupported and keep buffering.
14066
- // op: "s" supported, "o" open, "w" write data, "e" end. Returns a short string.
14140
+ // op: "s" supported, "o" open, "w" write data (a bytestring), "W" write the Uint8Array in data, "p" the bytes written
14141
+ // the client's connection has not taken yet, "k" park the timer in data until that falls, "e" end. Returns a short string.
14067
14142
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block, not by JS.
14068
14143
  function __sbStreamOp(op, id, data) {
14069
14144
  let out = "";
@@ -14073,6 +14148,11 @@ function __sbStreamOp(op, id, data) {
14073
14148
  extern unsigned int sb_stream_open(void);
14074
14149
  extern int sb_stream_write(unsigned int, const char*, size_t);
14075
14150
  extern void sb_stream_end(unsigned int);
14151
+ // Flow control (sproutd): the bytes written and not yet taken by the client's connection, and a timer the
14152
+ // host fires when that falls. A host without them (a standalone executable, whose server buffers by itself)
14153
+ // reports none, so a stream is never held back there.
14154
+ extern size_t sb_stream_pending(unsigned int) __attribute__((weak));
14155
+ extern void sb_stream_wait(unsigned int, unsigned int) __attribute__((weak));
14076
14156
  const char* __o; size_t __ol; char* __oo = 0;
14077
14157
  porf_native_fetch_read_value(op, &__o, &__ol, &__oo);
14078
14158
  char __op = __ol ? __o[0] : 0;
@@ -14092,7 +14172,17 @@ function __sbStreamOp(op, id, data) {
14092
14172
  int __w = -1;
14093
14173
  if (porf_native_fetch_read_raw_bytes(data, &__d, &__dl) == 0) __w = sb_stream_write(__id, __d, __dl);
14094
14174
  snprintf(__r, sizeof(__r), "%d", __w);
14095
- } else if (__op == 'e') { sb_stream_end(__id); snprintf(__r, sizeof(__r), "0"); }
14175
+ } else if (__op == 'W' && data.type == 81) {
14176
+ // A Uint8Array written as it is, with no bytestring copy of it.
14177
+ const u32 __ta = (u32)data.val;
14178
+ const int32_t __len = *(int32_t*)(MEM + __ta);
14179
+ const u32 __at = *(u32*)(MEM + __ta + 4) + 4;
14180
+ int __w = -1;
14181
+ if (__len >= 0) __w = sb_stream_write(__id, (const char*)(MEM + __at), (size_t)__len);
14182
+ snprintf(__r, sizeof(__r), "%d", __w);
14183
+ } else if (__op == 'p') snprintf(__r, sizeof(__r), "%zu", sb_stream_pending ? sb_stream_pending(__id) : (size_t)0);
14184
+ else if (__op == 'k') { if (sb_stream_wait && data.type == 1 && data.val > 0) sb_stream_wait(__id, (unsigned int)data.val); }
14185
+ else if (__op == 'e') { sb_stream_end(__id); snprintf(__r, sizeof(__r), "0"); }
14096
14186
  }
14097
14187
  out = porf_box((f64)porf_native_fetch_alloc_bytestring(__r, strlen(__r)), 195);
14098
14188
  `;
@@ -14103,6 +14193,16 @@ function __sbStreamingSupported() {
14103
14193
  if (__sbStreaming < 0) __sbStreaming = __sbStreamOp("s", "0", "") === "1" ? 1 : 0;
14104
14194
  return __sbStreaming === 1;
14105
14195
  }
14196
+ // What the client's connection may hold of a stream before the pump waits for it (the host credits bytes as
14197
+ // they leave): a client slower than the app is waited for, not buffered for or cut off.
14198
+ const __SB_STREAM_WINDOW = 1024 * 1024;
14199
+ // Resolves when the host says the stream's unsent bytes fell (or the client went away).
14200
+ function __sbStreamDrained(id) {
14201
+ return new Promise((resolve) => {
14202
+ const timer = setTimeout(resolve, 2147483647);
14203
+ __sbStreamOp("k", id, timer);
14204
+ });
14205
+ }
14106
14206
  function __sbStreamResponse(response) {
14107
14207
  const stream = __sbRawBody(response);
14108
14208
  const id = __sbStreamOp("o", "0", "");
@@ -14121,10 +14221,22 @@ function __sbStreamResponse(response) {
14121
14221
  else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value);
14122
14222
  else if (ArrayBuffer.isView(value)) bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
14123
14223
  else throw new TypeError("This ReadableStream did not return bytes.");
14124
- if (bytes.length > 0 && __sbStreamOp("w", id, __sbToBytes(bytes)) !== "0") {
14125
- // The client went away: stop the source.
14126
- reader.cancel();
14127
- break;
14224
+ if (bytes.length > 0) {
14225
+ // A whole Uint8Array goes to the host without a copy; a view of part of a buffer is copied.
14226
+ const wrote =
14227
+ bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
14228
+ ? __sbStreamOp("W", id, bytes)
14229
+ : __sbStreamOp("w", id, __sbToBytes(bytes));
14230
+ if (wrote !== "0") {
14231
+ // The client went away: stop the source.
14232
+ reader.cancel();
14233
+ break;
14234
+ }
14235
+ // Flow control: while the connection holds more than the window, read no more of the source.
14236
+ while (Number(__sbStreamOp("p", id, "")) > __SB_STREAM_WINDOW) {
14237
+ await __sbStreamDrained(id);
14238
+ // Woken by the client going away: the next write says so (-1).
14239
+ }
14128
14240
  }
14129
14241
  }
14130
14242
  } catch (error) {
@@ -15864,7 +15976,164 @@ function __sbTrustedProxiesOnce() {
15864
15976
  return __sbTrustedCache;
15865
15977
  }
15866
15978
 
15979
+ // R2 transfer tickets (createUploadUrl, createDownloadUrl): /__sb/r2/transfer/BUCKET/TOKEN, scoped to one object and
15980
+ // good once. A standalone executable's native server answers them before JavaScript runs (patchR2Transfer, #195);
15981
+ // on a host that forwards every path to the worker (sproutd) the worker answers them here with the same statuses
15982
+ // and headers, before the app's fetch() sees the request. The body goes straight between the connection and the
15983
+ // object's file, one step at a time (__sbR2XferOp): at most a window of it is ever in the worker.
15984
+ const __SB_R2_TRANSFER = "/__sb/r2/transfer/";
15985
+ // Set by the embedded transport (it has the ticket contexts and their steps); the broker's own listener owns the path.
15986
+ // oxlint-disable-next-line no-unassigned-vars -- set by the embedded transport, above
15987
+ var __sbR2TransferOn; // no initializer: the embedded transport (above) sets it, and an initializer here would run after it
15988
+ // [bucket, token] when the request is for a ticket (the same shape the native server accepts), else null.
15989
+ function __sbR2TransferTarget(request) {
15990
+ const method = request.method;
15991
+ if (method !== "GET" && method !== "PUT" && method !== "HEAD" && method !== "POST" && method !== "DELETE") return null;
15992
+ const url = request.url;
15993
+ const at = url.indexOf(__SB_R2_TRANSFER);
15994
+ if (at < 0) return null;
15995
+ const hostEnd = url.indexOf("/", url.indexOf("//") + 2);
15996
+ if (at !== hostEnd) return null;
15997
+ let rest = url.slice(at + __SB_R2_TRANSFER.length);
15998
+ const cut = rest.search(/[?#]/);
15999
+ if (cut >= 0) rest = rest.slice(0, cut);
16000
+ const slash = rest.indexOf("/");
16001
+ if (slash <= 0 || slash > 255 || rest.indexOf("/", slash + 1) >= 0) return null;
16002
+ const bucket = rest.slice(0, slash);
16003
+ const token = rest.slice(slash + 1);
16004
+ if (token.length !== 24 || !/^[0-9a-f]+$/.test(token) || !/^[A-Za-z0-9_]+$/.test(bucket)) return null;
16005
+ return [bucket, token];
16006
+ }
16007
+ function __sbR2TransferError(status, text) {
16008
+ return new Response(text, { status, headers: { "content-type": "text/plain" } });
16009
+ }
16010
+ // A transfer status as a response (the native server's table: anything else is a 500).
16011
+ function __sbR2TransferFailed(status, text) {
16012
+ const known = status === 404 || status === 410 || status === 413 || status === 422 || status === 503 || status === 507;
16013
+ return __sbR2TransferError(known ? status : 500, text);
16014
+ }
16015
+ // Resolves when the streamed request body `body` has more (or ended or failed): the host fires the parked timer.
16016
+ function __sbR2BodyWait(body) {
16017
+ return new Promise((resolve) => {
16018
+ const timer = setTimeout(resolve, 2147483647);
16019
+ __sbReqBodyOp("w", body, timer);
16020
+ });
16021
+ }
16022
+ async function __sbR2TransferUpload(request, bucket, token, body) {
16023
+ let declared = 0;
16024
+ const length = request.headers.get("content-length");
16025
+ if (length !== null && length !== "") {
16026
+ if (!/^[0-9]+$/.test(length) || length.length > 15) return __sbR2TransferError(400, "invalid content-length");
16027
+ declared = Number(length);
16028
+ }
16029
+ const opened = __sbR2XferOp("u", bucket, token, declared).split(" ");
16030
+ if (opened[0] !== "0") return __sbR2TransferFailed(Number(opened[0]), "R2 transfer unavailable");
16031
+ const handle = opened[1];
16032
+ let status = 0;
16033
+ if (body === 0) {
16034
+ // The host delivered the body whole (or there is none): one write.
16035
+ const raw = __sbRawBody(request);
16036
+ if (raw != null && raw !== "") status = Number(__sbR2XferOp("b", handle, raw, 0));
16037
+ } else {
16038
+ for (;;) {
16039
+ const step = __sbR2XferOp("s", handle, "", body).split(" ");
16040
+ const kind = step[0];
16041
+ if (kind === "d") continue;
16042
+ if (kind === "w") {
16043
+ await __sbR2BodyWait(body);
16044
+ continue;
16045
+ }
16046
+ if (kind === "e") break;
16047
+ // 'f': the client went away; 's': a status refused the bytes (the ticket is released with it).
16048
+ status = kind === "s" ? Number(step[1]) : 500;
16049
+ if (kind === "f") __sbR2XferOp("x", "0", "", Number(handle));
16050
+ __sbReqBodyOp("c", body, "");
16051
+ return kind === "f" ? __sbR2TransferError(500, "R2 transfer failed") : __sbR2TransferFailed(status, "R2 transfer failed");
16052
+ }
16053
+ __sbReqBodyOp("c", body, "");
16054
+ }
16055
+ if (status !== 0) return __sbR2TransferFailed(status, "R2 transfer failed");
16056
+ const finished = Number(__sbR2XferOp("f", handle, "", 0));
16057
+ if (finished === 201) return new Response(null, { status: 201 });
16058
+ return __sbR2TransferFailed(finished, "R2 transfer failed");
16059
+ }
16060
+ function __sbR2TransferDownload(request, bucket, token) {
16061
+ const opened = __sbR2XferOp("d", bucket, token, 0).split(" ");
16062
+ if (opened[0] !== "0") return __sbR2TransferFailed(Number(opened[0]), "R2 transfer unavailable");
16063
+ const handle = Number(opened[1]);
16064
+ const size = Number(opened[2]);
16065
+ const etag = opened[3];
16066
+ const close = () => __sbR2XferOp("c", "0", "", handle);
16067
+ const match = request.headers.get("if-none-match");
16068
+ if (match === "*" || match === etag || match === '"' + etag + '"') {
16069
+ close();
16070
+ return new Response(null, { status: 304, headers: { etag } });
16071
+ }
16072
+ let first = 0;
16073
+ let last = size ? size - 1 : 0;
16074
+ let partial = false;
16075
+ const range = request.headers.get("range");
16076
+ if (range) {
16077
+ const refuse = (why) => {
16078
+ close();
16079
+ return __sbR2TransferError(416, why);
16080
+ };
16081
+ if (range.slice(0, 6) !== "bytes=") return refuse("invalid range");
16082
+ const spec = range.slice(6);
16083
+ const dash = spec.indexOf("-");
16084
+ if (dash <= 0) return refuse("invalid range");
16085
+ const from = spec.slice(0, dash);
16086
+ const to = spec.slice(dash + 1);
16087
+ if (!/^[0-9]+$/.test(from) || (to !== "" && !/^[0-9]+$/.test(to))) return refuse("invalid range");
16088
+ first = Number(from);
16089
+ if (to !== "") last = Number(to);
16090
+ if (first >= size || last < first) return refuse("range outside object");
16091
+ if (last >= size) last = size - 1;
16092
+ partial = true;
16093
+ }
16094
+ const total = size === 0 ? 0 : last - first + 1;
16095
+ const headers = { "content-type": "application/octet-stream", etag, "accept-ranges": "bytes", "content-length": String(total) };
16096
+ if (partial) headers["content-range"] = "bytes " + first + "-" + last + "/" + size;
16097
+ let sent = 0;
16098
+ const stream = new ReadableStream({
16099
+ pull(controller) {
16100
+ if (sent >= total) {
16101
+ close();
16102
+ controller.close();
16103
+ return;
16104
+ }
16105
+ const chunk = new Uint8Array(Math.min(__SB_R2_CHUNK, total - sent));
16106
+ const got = Number(__sbR2XferOp("r", String(handle), "", first + sent, chunk));
16107
+ if (got !== chunk.length) {
16108
+ close();
16109
+ controller.error(new Error("R2 transfer failed"));
16110
+ return;
16111
+ }
16112
+ sent += got;
16113
+ controller.enqueue(chunk);
16114
+ },
16115
+ cancel() {
16116
+ close();
16117
+ },
16118
+ });
16119
+ return new Response(stream, { status: partial ? 206 : 200, headers });
16120
+ }
16121
+ // The response for a ticket request, or a Promise of it.
16122
+ function __sbR2Transfer(request, target) {
16123
+ // The streamed body's id, taken now: a ticket request never reaches __sbDecodeIngress.
16124
+ const body = __sbReqBodyTake();
16125
+ const method = request.method;
16126
+ if (method === "GET") return __sbR2TransferDownload(request, target[0], target[1]);
16127
+ if (method !== "PUT") return __sbR2TransferError(405, "R2 transfer requires GET or PUT");
16128
+ return __sbR2TransferUpload(request, target[0], target[1], body);
16129
+ }
16130
+
15867
16131
  globalThis.__sbEntry = function (handlers, request) {
16132
+ if (__sbR2TransferOn) {
16133
+ const ticket = __sbR2TransferTarget(request);
16134
+ // Through the same finish as the app's answer (promise, then a stream body is sent as it is read).
16135
+ if (ticket !== null) return __sbFetchWithDrain(__sbR2Transfer(request, ticket), [], false, false, true, null, null);
16136
+ }
15868
16137
  __sbDecodeIngress(request);
15869
16138
  const trigger = __sbHeader(request.headers, "x-sb-trigger");
15870
16139
  if (!trigger) {
@@ -0,0 +1,101 @@
1
+ // R2 get() reads an object as asked (sproutd #99): a range reads only its window, a body streams one chunk at
2
+ // a time, and nothing reads the whole object unless text(), json(), arrayBuffer() or blob() ask for all of it.
3
+ // The prelude is text, so this lifts the strict R2 object block out and runs it against a fake file whose reads
4
+ // are recorded, as r2-object.test.js does for the legacy object.
5
+ import { expect, test } from "bun:test";
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
10
+ const start = src.indexOf("function __sbR2Key(");
11
+ const end = src.indexOf("function __sbMakeR2(");
12
+ const isStrStart = src.indexOf("function __sbIsStr(");
13
+ const isStrEnd = src.indexOf("function __sbIsFn(");
14
+ const bytesStart = src.indexOf("const __SB_BYTES_WINDOW");
15
+ const utf8Start = src.indexOf("function __sbBufFrom(");
16
+ const utf8End = src.indexOf("function __sbRawBodyResponse(");
17
+ const lazyStart = src.indexOf("const __SB_R2_CHUNK");
18
+ if (
19
+ [start, end, isStrStart, isStrEnd, bytesStart, utf8Start, utf8End, lazyStart].some((i) => i === -1) ||
20
+ end < start ||
21
+ lazyStart < start ||
22
+ lazyStart > end
23
+ )
24
+ throw new Error("the strict R2 object block moved in the prelude");
25
+
26
+ // oxlint-disable-next-line no-function-constructor -- lifting pure blocks out of the text-only prelude for test
27
+ const load = new Function(
28
+ "__sbR2ReadRange",
29
+ `function __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\nfunction __sbFillBytes() { return false; }\n${src.slice(isStrStart, isStrEnd)}\n${src.slice(bytesStart, utf8Start)}\n${src.slice(utf8Start, utf8End)}\n${src.slice(start, end)}\nreturn { __sbR2StrictObject, __SB_R2_CHUNK };`,
30
+ );
31
+
32
+ // An "object file" of SIZE bytes, byte i being i % 251, and the reads asked of it.
33
+ const reads = [];
34
+ const file = (size) => (_bucket, _key, _blob, offset, view) => {
35
+ reads.push({ offset, length: view.length });
36
+ const n = Math.max(0, Math.min(view.length, size - offset));
37
+ for (let i = 0; i < n; i++) view[i] = (offset + i) % 251;
38
+ return n;
39
+ };
40
+ const meta = (size) => ({ key: "big.bin", size, etag: "abc123", uploaded: "2026-09-14T00:00:00.000Z" });
41
+ const lazy = { lazy: true, bucket: "B", key: "big.bin", blobId: "abc123" };
42
+ const drain = async (stream) => {
43
+ const reader = stream.getReader();
44
+ const chunks = [];
45
+ for (;;) {
46
+ const { value, done } = await reader.read();
47
+ if (done) return chunks;
48
+ chunks.push(value);
49
+ }
50
+ };
51
+
52
+ test("a body streams in chunks and never reads the object whole", async () => {
53
+ const { __sbR2StrictObject, __SB_R2_CHUNK } = load(file(3 * 1024 * 1024 + 100));
54
+ reads.length = 0;
55
+ const obj = __sbR2StrictObject(meta(3 * 1024 * 1024 + 100), lazy, { offset: 0, length: 3 * 1024 * 1024 + 100 });
56
+ expect(reads.length).toBe(0);
57
+ const chunks = await drain(obj.body);
58
+ expect(chunks.length).toBe(Math.ceil((3 * 1024 * 1024 + 100) / __SB_R2_CHUNK));
59
+ expect(Math.max(...reads.map((r) => r.length))).toBe(__SB_R2_CHUNK);
60
+ expect(reads.map((r) => r.offset)).toEqual(chunks.map((_, i) => i * __SB_R2_CHUNK));
61
+ expect(chunks[0][250]).toBe(250);
62
+ expect(chunks[1][0]).toBe(__SB_R2_CHUNK % 251);
63
+ });
64
+
65
+ test("a range reads only its window", async () => {
66
+ const { __sbR2StrictObject } = load(file(10 * 1024 * 1024));
67
+ reads.length = 0;
68
+ const range = { offset: 5 * 1024 * 1024 + 7, length: 1000 };
69
+ const obj = __sbR2StrictObject(meta(10 * 1024 * 1024), lazy, range);
70
+ const chunks = await drain(obj.body);
71
+ expect(chunks.length).toBe(1);
72
+ expect(reads).toEqual([{ offset: range.offset, length: 1000 }]);
73
+ expect(chunks[0].length).toBe(1000);
74
+ expect(chunks[0][0]).toBe(range.offset % 251);
75
+ });
76
+
77
+ test("arrayBuffer() and text() read the window in one read", async () => {
78
+ const { __sbR2StrictObject } = load(file(600));
79
+ reads.length = 0;
80
+ const range = { offset: 100, length: 400 };
81
+ const obj = __sbR2StrictObject(meta(600), lazy, range);
82
+ const buffer = await obj.arrayBuffer();
83
+ expect(buffer.byteLength).toBe(400);
84
+ expect(new Uint8Array(buffer)[0]).toBe(100);
85
+ expect(reads).toEqual([{ offset: 100, length: 400 }]);
86
+ await expect(obj.text()).rejects.toThrow(/already been used/);
87
+ });
88
+
89
+ test("an empty window closes at once without reading", async () => {
90
+ const { __sbR2StrictObject } = load(file(0));
91
+ reads.length = 0;
92
+ const obj = __sbR2StrictObject(meta(0), lazy, { offset: 0, length: 0 });
93
+ expect((await drain(obj.body)).length).toBe(0);
94
+ expect(reads.length).toBe(0);
95
+ });
96
+
97
+ test("a read that cannot be completed errors the stream", async () => {
98
+ const { __sbR2StrictObject } = load(() => -1);
99
+ const obj = __sbR2StrictObject(meta(100), lazy, { offset: 0, length: 100 });
100
+ await expect(drain(obj.body)).rejects.toThrow(/could not be read/);
101
+ });
@@ -1400,6 +1400,19 @@ globalThis.__sbR2Get = function (bucket, key) {
1400
1400
  return { found: true, object: reply.object, body: __sbTakeBin() };
1401
1401
  };
1402
1402
 
1403
+ // The broker returns an object whole (r2.get); only the embedded transport reads a file as asked.
1404
+ globalThis.__sbR2Open = function () {
1405
+ return null;
1406
+ };
1407
+ globalThis.__sbR2ReadRange = function () {
1408
+ return -1;
1409
+ };
1410
+
1411
+ // The broker's own transfer listener owns /__sb/r2/transfer/: this runtime serves none of it.
1412
+ function __sbR2XferOp() {
1413
+ return "404";
1414
+ }
1415
+
1403
1416
  globalThis.__sbR2MultipartPut = function (bucket, key, uploadId, partNumber, body) {
1404
1417
  const token = __sbEnv("SB_BROKER_TOKEN");
1405
1418
  const reply = JSON.parse(
@@ -1879,6 +1879,22 @@ static u32 sb_r2_get_c(const char* path, const char* bucket, const char* key, co
1879
1879
  return out;
1880
1880
  }
1881
1881
 
1882
+ // Reads at most CAP bytes at OFFSET of an object's file into OUT, and nothing else of it: a range read
1883
+ // (R2 get with a range) and each chunk of a streamed body come through here, so an object of any size is
1884
+ // never in memory whole. Returns the byte count (short at the end of the object), or -1 when the file
1885
+ // cannot be read. The file is named by the object's generation, which a put never rewrites.
1886
+ static long sb_r2_read_into_c(const char* path, const char* bucket, const char* key, const char* generation, size_t offset, char* out, size_t cap) {
1887
+ char blobpath[1024];
1888
+ sb_r2_blob_path(blobpath, sizeof(blobpath), path, bucket, key, generation);
1889
+ FILE* f = fopen(blobpath, "rb");
1890
+ if (!f) return -1;
1891
+ if (offset && fseek(f, (long)offset, SEEK_SET) != 0) { fclose(f); return -1; }
1892
+ size_t rd = cap ? fread(out, 1, cap, f) : 0;
1893
+ int bad = ferror(f);
1894
+ fclose(f);
1895
+ return bad ? -1 : (long)rd;
1896
+ }
1897
+
1882
1898
  // Best-effort: a metadata row deleted with no blob file (or vice versa) is
1883
1899
  // still a correct end state, so a missing file here is not an error.
1884
1900
  static void sb_r2_delete_blob(const char* path, const char* bucket, const char* key, const char* generation) {
@@ -2119,6 +2135,95 @@ size_t sb_r2_transfer_download_read(sb_r2_transfer_ctx* ctx, size_t offset, char
2119
2135
  }
2120
2136
  void sb_r2_transfer_download_close(sb_r2_transfer_ctx* ctx) { if (ctx) { if (ctx->file) fclose(ctx->file); free(ctx); } }
2121
2137
 
2138
+ // A host whose workers serve transfer tickets themselves (sproutd: the frontend forwards /__sb/r2/transfer/...
2139
+ // like any request, with its body streamed) drives the same context from JavaScript, one step at a time.
2140
+ // The contexts live here, in a small table (a handle is its index plus one), so JavaScript never holds a
2141
+ // pointer; a handle is released by finishing, aborting or closing it. An upload step moves what the request
2142
+ // body has buffered (at most 1 MiB, in 64 KiB pieces from the stack) into the object's temporary file, and a
2143
+ // download read fills a Uint8Array from the object's file: object bytes are never strings or JSON.
2144
+ #define SB_XFER_MAX 16
2145
+ static sb_r2_transfer_ctx* sb_xfer[SB_XFER_MAX];
2146
+ static int sb_xfer_hold(sb_r2_transfer_ctx* ctx) {
2147
+ for (int i = 0; i < SB_XFER_MAX; i++) if (!sb_xfer[i]) { sb_xfer[i] = ctx; return i + 1; }
2148
+ return 0;
2149
+ }
2150
+ static sb_r2_transfer_ctx* sb_xfer_get(int handle) {
2151
+ return handle >= 1 && handle <= SB_XFER_MAX ? sb_xfer[handle - 1] : 0;
2152
+ }
2153
+ extern long long sb_reqbody_poll(unsigned int);
2154
+ extern size_t sb_reqbody_take(unsigned int, char*, size_t);
2155
+ // Claims an upload ticket: the HTTP status (0: claimed, HANDLE set).
2156
+ static int sb_xfer_upload_open(const char* bucket, const char* token, size_t declared, int* handle) {
2157
+ sb_r2_transfer_ctx* ctx = 0;
2158
+ *handle = 0;
2159
+ int status = sb_r2_transfer_open(bucket, token, declared, &ctx);
2160
+ if (status != 0 || !ctx) return status ? status : 500;
2161
+ *handle = sb_xfer_hold(ctx);
2162
+ if (!*handle) { sb_r2_transfer_abort(ctx); return 503; }
2163
+ return 0;
2164
+ }
2165
+ // One step of an upload from request body BODY: 'd' some bytes moved (*arg), 'w' none yet (wait for the
2166
+ // body's timer), 'e' the body ended (finish), 'f' the body failed (the client went away, abort), 's' a
2167
+ // status refused them (*arg; the ticket is released). A step stops after 1 MiB so other requests run.
2168
+ static char sb_xfer_upload_step(int handle, unsigned int body, long* arg) {
2169
+ sb_r2_transfer_ctx* ctx = sb_xfer_get(handle);
2170
+ *arg = 0;
2171
+ if (!ctx) { *arg = 500; return 's'; }
2172
+ char piece[65536];
2173
+ long moved = 0;
2174
+ while (moved < (1 << 20)) {
2175
+ long long ready = sb_reqbody_poll(body);
2176
+ if (ready == -1) { *arg = moved; return moved ? 'd' : 'e'; }
2177
+ if (ready == -2) { *arg = moved; return moved ? 'd' : 'f'; }
2178
+ if (ready == 0) { *arg = moved; return moved ? 'd' : 'w'; }
2179
+ size_t n = sb_reqbody_take(body, piece, sizeof(piece));
2180
+ if (!n) { *arg = moved; return moved ? 'd' : 'w'; }
2181
+ int status = sb_r2_transfer_write(ctx, piece, n);
2182
+ if (status != 0) { sb_r2_transfer_abort(ctx); sb_xfer[handle - 1] = 0; *arg = status; return 's'; }
2183
+ moved += (long)n;
2184
+ }
2185
+ *arg = moved;
2186
+ return 'd';
2187
+ }
2188
+ // A body that arrived whole (a request of a host that buffers it): written in one go.
2189
+ static int sb_xfer_upload_write(int handle, const char* bytes, size_t length) {
2190
+ sb_r2_transfer_ctx* ctx = sb_xfer_get(handle);
2191
+ if (!ctx) return 500;
2192
+ int status = sb_r2_transfer_write(ctx, bytes, length);
2193
+ if (status != 0) { sb_r2_transfer_abort(ctx); sb_xfer[handle - 1] = 0; }
2194
+ return status;
2195
+ }
2196
+ static int sb_xfer_finish(int handle) {
2197
+ sb_r2_transfer_ctx* ctx = sb_xfer_get(handle);
2198
+ if (!ctx) return 500;
2199
+ sb_xfer[handle - 1] = 0;
2200
+ return sb_r2_transfer_finish(ctx);
2201
+ }
2202
+ static void sb_xfer_close(int handle) {
2203
+ sb_r2_transfer_ctx* ctx = sb_xfer_get(handle);
2204
+ if (!ctx) return;
2205
+ sb_xfer[handle - 1] = 0;
2206
+ sb_r2_transfer_abort(ctx);
2207
+ }
2208
+ // Claims a download ticket: the HTTP status (0: claimed, HANDLE, *size and *etag set).
2209
+ static int sb_xfer_download_open(const char* bucket, const char* token, int* handle, size_t* size, char* etag, size_t etag_cap) {
2210
+ sb_r2_transfer_ctx* ctx = 0;
2211
+ *handle = 0;
2212
+ int status = sb_r2_transfer_download_open(bucket, token, &ctx);
2213
+ if (status != 0 || !ctx) return status ? status : 500;
2214
+ *handle = sb_xfer_hold(ctx);
2215
+ if (!*handle) { sb_r2_transfer_download_close(ctx); return 503; }
2216
+ *size = sb_r2_transfer_download_size(ctx);
2217
+ snprintf(etag, etag_cap, "%s", sb_r2_transfer_download_etag(ctx));
2218
+ return 0;
2219
+ }
2220
+ static void sb_xfer_download_close(int handle) {
2221
+ sb_r2_transfer_ctx* ctx = sb_xfer_get(handle);
2222
+ if (!ctx) return;
2223
+ sb_xfer[handle - 1] = 0;
2224
+ sb_r2_transfer_download_close(ctx);
2225
+ }
2226
+
2122
2227
  // LTX capture (#141) failed after SQLite committed: the write is in the database but not in
2123
2228
  // the replication log, and the database refuses writes until the process restarts. Said as
2124
2229
  // such (SQLITE_IOERR), never as SQLite's own message, which still reads "not an error".
@@ -2679,6 +2784,8 @@ static void sb_tcp_nodelay(int fd) {
2679
2784
  // broken exchange closes N for good, so a late answer can never be taken for the
2680
2785
  // next one.
2681
2786
  static int sb_egress_fd_ask(int via, const char* host, int port, char* refused, size_t refused_len);
2787
+ // The ticket of the last service-binding connect on this thread ("" for any other connect).
2788
+ static __thread char sb_svc_ticket[72];
2682
2789
  static int sb_egress_fd_connect(int via, const char* host, int port, char* refused, size_t refused_len) {
2683
2790
  static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
2684
2791
  pthread_mutex_lock(&lock);
@@ -2714,7 +2821,16 @@ static int sb_egress_fd_ask(int via, const char* host, int port, char* refused,
2714
2821
  return -1;
2715
2822
  }
2716
2823
  answer[got - 1] = 0;
2824
+ // "ok TICKET" (a service-binding call, host "sbsvc.HEX"): the host connected us to itself and the
2825
+ // ticket, which only it could mint, tells it who is calling. The request carries it as a header.
2826
+ sb_svc_ticket[0] = 0;
2827
+ if (!strncmp(answer, "ok ", 3) && fd >= 0 && strlen(answer + 3) > 0 && strlen(answer + 3) < sizeof(sb_svc_ticket)) {
2828
+ snprintf(sb_svc_ticket, sizeof(sb_svc_ticket), "%s", answer + 3);
2829
+ answer[2] = 0;
2830
+ }
2717
2831
  if (!strcmp(answer, "ok") && fd >= 0) {
2832
+ // A service call is bounded per hop at 30 s, as the callee's own wall limit is.
2833
+ if (sb_svc_ticket[0]) tv.tv_sec = 30;
2718
2834
  setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
2719
2835
  setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
2720
2836
  sb_tcp_nodelay(fd);
@@ -2885,11 +3001,22 @@ static char* sb_error_json(const char* prefix, const char* detail) {
2885
3001
  static char* sb_http_plain(const char* host, int port, const char* path, const char* method,
2886
3002
  const char* headers, const char* body, size_t body_len) {
2887
3003
  char refused[512] = "";
3004
+ sb_svc_ticket[0] = 0;
2888
3005
  int fd = sb_tcp_connect(host, port, refused, sizeof(refused));
2889
3006
  if (fd < 0) return refused[0] ? sb_error_json(refused, 0) : sb_error_json("could not connect to ", host);
2890
3007
 
3008
+ // A service-binding call: the ticket the host minted for it goes first among the headers.
3009
+ sb_buf tagged = { 0, 0, 0 };
3010
+ if (sb_svc_ticket[0]) {
3011
+ sb_puts(&tagged, "x-sb-svc-ticket: ");
3012
+ sb_puts(&tagged, sb_svc_ticket);
3013
+ sb_puts(&tagged, "\r\n");
3014
+ if (headers && *headers) sb_puts(&tagged, headers);
3015
+ headers = tagged.p;
3016
+ }
2891
3017
  sb_buf req = { 0, 0, 0 };
2892
3018
  sb_build_request(&req, host, port, 80, path, method, headers, body, body_len);
3019
+ free(tagged.p);
2893
3020
  size_t sent = 0;
2894
3021
  while (sent < req.len) {
2895
3022
  long n = write(fd, req.p + sent, req.len - sent);
@@ -4320,6 +4447,112 @@ function __sbR2GetRaw(path, bucket, key, blobId) {
4320
4447
  return res;
4321
4448
  }
4322
4449
 
4450
+ // R2 get without the whole object: fills VIEW (a fresh Uint8Array) with the bytes at OFFSET of the object's
4451
+ // file. Returns how many it read (fewer at the end of the object), or -1 when the file cannot be read.
4452
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
4453
+ function __sbR2ReadInto(path, bucket, key, blobId, offset, view) {
4454
+ let got = -1;
4455
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4456
+ Porffor.c`
4457
+ const char* __p; size_t __pl; char* __po = 0;
4458
+ porf_native_fetch_read_value(path, &__p, &__pl, &__po);
4459
+ char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
4460
+ if (__po) free(__po);
4461
+
4462
+ const char* __bk; size_t __bkl; char* __bko = 0;
4463
+ porf_native_fetch_read_value(bucket, &__bk, &__bkl, &__bko);
4464
+ char* __bucket = (char*)malloc(__bkl + 1); memcpy(__bucket, __bk, __bkl); __bucket[__bkl] = 0;
4465
+ if (__bko) free(__bko);
4466
+
4467
+ const char* __k; size_t __kl; char* __ko = 0;
4468
+ porf_native_fetch_read_value(key, &__k, &__kl, &__ko);
4469
+ char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
4470
+ if (__ko) free(__ko);
4471
+
4472
+ const char* __g; size_t __gl; char* __go = 0;
4473
+ porf_native_fetch_read_value(blobId, &__g, &__gl, &__go);
4474
+ char* __generation = (char*)malloc(__gl + 1); memcpy(__generation, __g, __gl); __generation[__gl] = 0;
4475
+ if (__go) free(__go);
4476
+
4477
+ long __n = -1;
4478
+ if (view.type == 81 && offset.type == 1 && offset.val >= 0) {
4479
+ const u32 __ta = (u32)view.val;
4480
+ const int32_t __len = *(int32_t*)(MEM + __ta);
4481
+ const u32 __at = *(u32*)(MEM + __ta + 4) + 4;
4482
+ if (__len >= 0) __n = sb_r2_read_into_c(__path, __bucket, __key, __generation, (size_t)offset.val, (char*)(MEM + __at), (size_t)__len);
4483
+ }
4484
+ free(__path); free(__bucket); free(__key); free(__generation);
4485
+ got = (f64)__n;
4486
+ `;
4487
+ return got;
4488
+ }
4489
+
4490
+ // Transfer tickets served by the worker (see sb_xfer_* above). op: "u" claim an upload ticket (a = bucket, b = token,
4491
+ // n = the declared length; "0 HANDLE" or "STATUS"), "s" a step of the upload from body id n ("d BYTES", "w", "e", "f"
4492
+ // or "s STATUS"), "b" write the bytestring in a (a buffered body; the status), "f" finish ("STATUS"), "x" release the
4493
+ // handle in n, "d" claim a download ticket ("0 HANDLE SIZE ETAG" or "STATUS"), "r" read the object's file at offset n
4494
+ // into the Uint8Array in view (handle in a; the count), "c" release a download handle. Returns a short string.
4495
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
4496
+ function __sbR2XferOp(op, a, b, n, view) {
4497
+ let out = "";
4498
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4499
+ Porffor.c`
4500
+ const char* __o; size_t __ol; char* __oo = 0;
4501
+ porf_native_fetch_read_value(op, &__o, &__ol, &__oo);
4502
+ char __op = __ol ? __o[0] : 0;
4503
+ if (__oo) free(__oo);
4504
+
4505
+ const char* __a; size_t __al; char* __ao = 0;
4506
+ porf_native_fetch_read_value(a, &__a, &__al, &__ao);
4507
+ char* __as = (char*)malloc(__al + 1); memcpy(__as, __a, __al); __as[__al] = 0;
4508
+ if (__ao) free(__ao);
4509
+ const char* __b; size_t __bl; char* __bo = 0;
4510
+ porf_native_fetch_read_value(b, &__b, &__bl, &__bo);
4511
+ char* __bs = (char*)malloc(__bl + 1); memcpy(__bs, __b, __bl); __bs[__bl] = 0;
4512
+ if (__bo) free(__bo);
4513
+
4514
+ const double __n = n.type == 1 ? n.val : 0;
4515
+ const int __handle = (int)strtol(__as, 0, 10);
4516
+ char __r[160]; __r[0] = 0;
4517
+ if (__op == 'u') {
4518
+ int __h = 0;
4519
+ int __s = sb_xfer_upload_open(__as, __bs, (size_t)__n, &__h);
4520
+ if (__s == 0) snprintf(__r, sizeof(__r), "0 %d", __h); else snprintf(__r, sizeof(__r), "%d", __s);
4521
+ } else if (__op == 's') {
4522
+ long __moved = 0;
4523
+ char __k = sb_xfer_upload_step(__handle, (unsigned int)__n, &__moved);
4524
+ snprintf(__r, sizeof(__r), "%c %ld", __k, __moved);
4525
+ } else if (__op == 'b') {
4526
+ const char* __d = 0; size_t __dl = 0;
4527
+ int __s = 500;
4528
+ if (porf_native_fetch_read_raw_bytes(b, &__d, &__dl) == 0) __s = sb_xfer_upload_write(__handle, __d, __dl);
4529
+ snprintf(__r, sizeof(__r), "%d", __s);
4530
+ } else if (__op == 'f') snprintf(__r, sizeof(__r), "%d", sb_xfer_finish(__handle));
4531
+ else if (__op == 'x') sb_xfer_close((int)__n);
4532
+ else if (__op == 'd') {
4533
+ int __h = 0; size_t __size = 0; char __etag[80]; __etag[0] = 0;
4534
+ int __s = sb_xfer_download_open(__as, __bs, &__h, &__size, __etag, sizeof(__etag));
4535
+ if (__s == 0) snprintf(__r, sizeof(__r), "0 %d %zu %s", __h, __size, __etag); else snprintf(__r, sizeof(__r), "%d", __s);
4536
+ } else if (__op == 'r') {
4537
+ long __got = -1;
4538
+ sb_r2_transfer_ctx* __ctx = sb_xfer_get(__handle);
4539
+ if (__ctx && view.type == 81) {
4540
+ const u32 __ta = (u32)view.val;
4541
+ const int32_t __len = *(int32_t*)(MEM + __ta);
4542
+ const u32 __at = *(u32*)(MEM + __ta + 4) + 4;
4543
+ if (__len >= 0) __got = (long)sb_r2_transfer_download_read(__ctx, (size_t)__n, (char*)(MEM + __at), (size_t)__len);
4544
+ }
4545
+ snprintf(__r, sizeof(__r), "%ld", __got);
4546
+ } else if (__op == 'c') sb_xfer_download_close((int)__n);
4547
+ free(__as); free(__bs);
4548
+ out = porf_box((f64)porf_native_fetch_alloc_bytestring(__r, strlen(__r)), 195);
4549
+ `;
4550
+ return out;
4551
+ }
4552
+ // This transport's workers serve transfer tickets themselves, on a host that forwards the path (sproutd); a
4553
+ // standalone executable's native server answers them before JavaScript runs, and never gets here.
4554
+ __sbR2TransferOn = true;
4555
+
4323
4556
  // R2 delete: also removes the object's own file, not just the metadata row
4324
4557
  // (#56 — sb_r2_delete_blob is best-effort, a missing file is not an error).
4325
4558
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
@@ -5154,24 +5387,73 @@ function __sbEmbeddedDispatch(msg) {
5154
5387
  return { ok: true, found: false, status: 404, body: "Not Found" };
5155
5388
  }
5156
5389
 
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);
5390
+ // A service-binding call (compatibility date 2026-11-18): the host the app runs on routes it to the
5391
+ // callee. The call is an HTTP request to the pseudo-host "sbsvc.HEX" (the binding's name in hex): the
5392
+ // host sees the name on the egress socket the question came in on, finds the callee in its services
5393
+ // map and answers "ok TICKET" with a connection to its own listener. __sbHttpRaw sends the ticket as
5394
+ // a header (sb_http_plain) and the host's frontend, which alone mints tickets, delivers the request
5395
+ // to the callee marked as a service-binding call. See sproutd's docs/bindings.md, "Service bindings".
5396
+ if (op === "fetch" || op === "service.fetch") {
5397
+ const service = op === "service.fetch";
5398
+ let hostname;
5399
+ let port;
5400
+ let pathname;
5401
+ let tls = false;
5161
5402
  let headerText = "";
5403
+ if (service) {
5404
+ if (__sbCompatDate() < "2026-11-18") {
5405
+ throw new Error(
5406
+ "service binding " + msg.binding + " needs compatibility_date 2026-11-18 or later to reach another app on this host",
5407
+ );
5408
+ }
5409
+ if (!__sbEnv("SB_EGRESS_FD")) {
5410
+ throw new Error(
5411
+ "service binding " + msg.binding + " has no app to reach here: a standalone binary has no peer apps, only a platform host (sproutd) has",
5412
+ );
5413
+ }
5414
+ const binding = String(msg.binding);
5415
+ let hex = "";
5416
+ for (let i = 0; i < binding.length; i++) {
5417
+ const c = binding.charCodeAt(i);
5418
+ if (c > 127) throw new Error("service binding names are ASCII: " + binding);
5419
+ hex += (c < 16 ? "0" : "") + c.toString(16);
5420
+ }
5421
+ if (hex.length > 200) throw new Error("service binding name too long: " + binding);
5422
+ hostname = "sbsvc." + hex;
5423
+ port = "80";
5424
+ // The callee sees the URL its caller passed (x-sb-url, vouched for by the host); the request line
5425
+ // carries only its path.
5426
+ try {
5427
+ const parsed = new URL(String(msg.url));
5428
+ pathname = parsed.pathname + parsed.search;
5429
+ headerText += "x-sb-url: " + parsed.href + "\r\n";
5430
+ } catch (e) {
5431
+ const p = String(msg.url || "/");
5432
+ pathname = p.charAt(0) === "/" ? p : "/" + p;
5433
+ }
5434
+ } else {
5435
+ const url = new URL(String(msg.url));
5436
+ tls = url.protocol === "https:";
5437
+ if (!tls && url.protocol !== "http:") throw new Error("unsupported protocol: " + url.protocol);
5438
+ hostname = url.hostname;
5439
+ port = url.port ? url.port : tls ? "443" : "80";
5440
+ pathname = url.pathname + url.search;
5441
+ }
5162
5442
  const pairs = msg.headers || [];
5163
5443
  for (let i = 0; i < pairs.length; i++) {
5164
5444
  const key = String(pairs[i][0]).toLowerCase();
5165
5445
  // Host, Connection and Content-Length are ours to set.
5166
5446
  if (key === "host" || key === "connection" || key === "content-length") continue;
5447
+ // The host's markers are its own: the caller's x-sb-* headers do not travel. Only an RPC call (the prelude's
5448
+ // __sbRpcCall, msg.rpc) sends x-sb-rpc, which names the method.
5449
+ if (service && key.slice(0, 5) === "x-sb-" && !(msg.rpc === true && key === "x-sb-rpc")) continue;
5167
5450
  headerText += pairs[i][0] + ": " + pairs[i][1] + "\r\n";
5168
5451
  }
5169
- const port = url.port ? url.port : tls ? "443" : "80";
5170
5452
  const asyncId = msg.asyncId > 0 ? msg.asyncId : 0;
5171
5453
  const raw = __sbHttpRaw(
5172
- url.hostname,
5454
+ hostname,
5173
5455
  port,
5174
- url.pathname + url.search,
5456
+ pathname,
5175
5457
  String(msg.method || "GET").toUpperCase(),
5176
5458
  headerText,
5177
5459
  msg.body == null ? "" : String(msg.body),
@@ -5884,6 +6166,19 @@ globalThis.__sbR2Get = function (bucket, key) {
5884
6166
  };
5885
6167
  };
5886
6168
 
6169
+ // The object's metadata and where its bytes are, without the bytes: get() reads them as the caller asks for them
6170
+ // (range windows and a body stream), through __sbR2ReadRange.
6171
+ globalThis.__sbR2Open = function (bucket, key) {
6172
+ const meta = __sbEmbeddedDispatch({ op: "r2.head", bucket, key });
6173
+ if (!meta.found) return { found: false };
6174
+ return { found: true, object: meta.object, blobId: String(meta.blobId || "") };
6175
+ };
6176
+
6177
+ // Fills the Uint8Array VIEW with the object's bytes from OFFSET; the count read, or -1.
6178
+ globalThis.__sbR2ReadRange = function (bucket, key, blobId, offset, view) {
6179
+ return __sbR2ReadInto(__sbStore(), String(bucket), String(key), String(blobId), offset, view);
6180
+ };
6181
+
5887
6182
  globalThis.__sbR2MultipartPut = function (bucket, key, uploadId, partNumber, body) {
5888
6183
  __sbQuotaCheck("R2 uploadPart()");
5889
6184
  __sbEnsureSchema();
@@ -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
+ }