@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 +16 -0
- package/package.json +1 -1
- package/src/egress.test.ts +76 -0
- package/src/native-fetch-prelude.js +282 -13
- package/src/r2-lazy.test.js +101 -0
- package/src/transport-broker.js +13 -0
- package/src/transport-embedded.js +302 -7
- package/test/native-r2-transfer.ts +101 -0
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
package/src/egress.test.ts
CHANGED
|
@@ -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
|
-
|
|
11044
|
-
obj.
|
|
11045
|
-
obj.
|
|
11046
|
-
obj.
|
|
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
|
|
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, "
|
|
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 == '
|
|
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
|
|
14125
|
-
//
|
|
14126
|
-
|
|
14127
|
-
|
|
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
|
+
});
|
package/src/transport-broker.js
CHANGED
|
@@ -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
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
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
|
-
|
|
5454
|
+
hostname,
|
|
5173
5455
|
port,
|
|
5174
|
-
|
|
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
|
+
}
|