@sproutboat/runtime 0.7.1 → 0.9.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 +36 -0
- package/README.md +41 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +58 -0
- package/src/r2-object.test.js +28 -2
- package/src/transport-broker.js +21 -0
- package/src/transport-embedded.js +813 -54
- package/src/wrap.ts +27 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0b0a666: Add a native standalone R2 direct-upload bridge that streams transfer-ticket
|
|
8
|
+
request chunks to a temporary blob file, validates the digest, and atomically
|
|
9
|
+
publishes the object without buffering the upload in the Porffor request body.
|
|
10
|
+
|
|
11
|
+
## 0.8.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- 493e4e3: Add short-lived R2 direct-transfer tickets for broker-backed sprouts. A
|
|
16
|
+
loopback HTTP listener streams PUT bodies to file-backed R2 storage, and serves
|
|
17
|
+
GET and HEAD requests with single-range support without placing object bytes
|
|
18
|
+
in a binding frame.
|
|
19
|
+
|
|
20
|
+
Tickets are random bearer capabilities with a byte limit, expiry, one-use
|
|
21
|
+
claim, and optional SHA-256 verification. Native standalone direct transfers
|
|
22
|
+
remain unavailable until the native HTTP ingress can stream request bodies.
|
|
23
|
+
|
|
24
|
+
For broker deployments, ticket issuance now reserves account-level capacity
|
|
25
|
+
across every R2 resource. Reservations survive redeploys, expired and orphaned
|
|
26
|
+
temporary uploads are reclaimed, and the broker retains a configurable free
|
|
27
|
+
disk floor with capacity and rejected-transfer counters for the dashboard.
|
|
28
|
+
- 5acb93c: Add Cloudflare-shaped resumable R2 multipart uploads. Parts are persisted
|
|
29
|
+
independently, and standalone completion assembles the final object through a
|
|
30
|
+
fixed 64 KiB native buffer to keep memory bounded by part size rather than total
|
|
31
|
+
object size.
|
|
32
|
+
|
|
33
|
+
Unfinished multipart uploads expire after seven days and their persisted part
|
|
34
|
+
files are removed automatically.
|
|
35
|
+
|
|
36
|
+
Store completed objects as immutable blob generations so a failed overwrite
|
|
37
|
+
cannot expose new bytes under the previous metadata.
|
|
38
|
+
|
|
3
39
|
## 0.7.1
|
|
4
40
|
|
|
5
41
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -31,3 +31,44 @@ The prelude shims the Web API surface handlers expect (URL, Response,
|
|
|
31
31
|
crypto random values, binding shims, trigger dispatch) and is identical
|
|
32
32
|
for both transports, which is what lets one conformance suite hold them
|
|
33
33
|
honest.
|
|
34
|
+
|
|
35
|
+
## R2 multipart uploads
|
|
36
|
+
|
|
37
|
+
R2 bindings implement the Cloudflare multipart method shape for objects that
|
|
38
|
+
are larger than one safe request body:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const upload = env.FILES.createMultipartUpload("archive.tar", {
|
|
42
|
+
httpMetadata: { contentType: "application/x-tar" },
|
|
43
|
+
customMetadata: { source: "backup" },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const parts = [];
|
|
47
|
+
parts.push(await upload.uploadPart(1, firstChunk));
|
|
48
|
+
|
|
49
|
+
const resumed = env.FILES.resumeMultipartUpload(upload.key, upload.uploadId);
|
|
50
|
+
parts.push(await resumed.uploadPart(2, finalChunk));
|
|
51
|
+
|
|
52
|
+
const object = await resumed.complete(parts);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Part numbers must be between 1 and 10,000. Parts must be supplied to
|
|
56
|
+
`complete()` in ascending order. Every part except the last must have the same
|
|
57
|
+
size, and the last cannot be larger. Sproutboat permits parts smaller than
|
|
58
|
+
Cloudflare R2's 5 MiB minimum so the default 1 MiB request-body limit remains
|
|
59
|
+
useful. Keep each part below the configured request-body limit.
|
|
60
|
+
|
|
61
|
+
Each part is persisted independently. Standalone completion copies parts into
|
|
62
|
+
the finished object through a fixed 64 KiB native buffer, so final object size
|
|
63
|
+
does not determine peak Porffor heap use. A single `put()` still buffers its
|
|
64
|
+
whole value; use multipart for larger objects. Call `abort()` to remove an
|
|
65
|
+
unfinished upload and its parts.
|
|
66
|
+
|
|
67
|
+
Completed objects use immutable, etag-addressed blob generations. The metadata
|
|
68
|
+
row switches to a fully written generation before the previous file is
|
|
69
|
+
removed. If a metadata update fails, readers continue seeing the previous
|
|
70
|
+
generation with its matching size and etag.
|
|
71
|
+
|
|
72
|
+
Unfinished uploads expire after seven days. The broker removes expired parts
|
|
73
|
+
when another multipart upload starts; a standalone binary removes them when it
|
|
74
|
+
initializes its embedded store.
|
package/package.json
CHANGED
|
@@ -1136,6 +1136,26 @@ function __sbR2Object(meta, body) {
|
|
|
1136
1136
|
return obj;
|
|
1137
1137
|
}
|
|
1138
1138
|
|
|
1139
|
+
function __sbR2Multipart(bucket, key, uploadId) {
|
|
1140
|
+
return {
|
|
1141
|
+
key,
|
|
1142
|
+
uploadId,
|
|
1143
|
+
uploadPart(partNumber, value) {
|
|
1144
|
+
const number = Number(partNumber);
|
|
1145
|
+
if (!Number.isInteger(number) || number < 1 || number > 10000)
|
|
1146
|
+
throw new TypeError("partNumber must be an integer between 1 and 10000");
|
|
1147
|
+
return __sbR2MultipartPut(bucket, key, uploadId, number, value == null ? "" : String(value)).part;
|
|
1148
|
+
},
|
|
1149
|
+
complete(parts) {
|
|
1150
|
+
const r = __sbRpc("r2.multipart.complete", { bucket, key, uploadId, parts: parts || [] });
|
|
1151
|
+
return __sbR2Object(r.object, null);
|
|
1152
|
+
},
|
|
1153
|
+
abort() {
|
|
1154
|
+
__sbRpc("r2.multipart.abort", { bucket, key, uploadId });
|
|
1155
|
+
},
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1139
1159
|
// Installed only when the project declares bindings. `env` is the module-scoped
|
|
1140
1160
|
// object from compile.ts (a `const`, but mutable); we add the binding accessors
|
|
1141
1161
|
// to it in place. compile.ts emits `__sbInstallBindings(env, {...})` right after
|
|
@@ -1197,6 +1217,44 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
1197
1217
|
o.customMetadata || {},
|
|
1198
1218
|
).object;
|
|
1199
1219
|
},
|
|
1220
|
+
createMultipartUpload(key, options) {
|
|
1221
|
+
const objectKey = String(key);
|
|
1222
|
+
const o = options || {};
|
|
1223
|
+
const r = __sbRpc("r2.multipart.create", {
|
|
1224
|
+
bucket: name,
|
|
1225
|
+
key: objectKey,
|
|
1226
|
+
httpMetadata: o.httpMetadata || {},
|
|
1227
|
+
customMetadata: o.customMetadata || {},
|
|
1228
|
+
});
|
|
1229
|
+
return __sbR2Multipart(name, objectKey, r.uploadId);
|
|
1230
|
+
},
|
|
1231
|
+
resumeMultipartUpload(key, uploadId) {
|
|
1232
|
+
return __sbR2Multipart(name, String(key), String(uploadId));
|
|
1233
|
+
},
|
|
1234
|
+
createUploadUrl(key, options) {
|
|
1235
|
+
const o = options || {};
|
|
1236
|
+
const r = __sbRpc("r2.transfer.create", {
|
|
1237
|
+
bucket: name,
|
|
1238
|
+
key: String(key),
|
|
1239
|
+
method: "upload",
|
|
1240
|
+
maxBytes: o.maxBytes,
|
|
1241
|
+
expiresIn: o.expiresIn,
|
|
1242
|
+
sha256: o.sha256,
|
|
1243
|
+
httpMetadata: o.httpMetadata || {},
|
|
1244
|
+
customMetadata: o.customMetadata || {},
|
|
1245
|
+
});
|
|
1246
|
+
return { url: r.url, expiresAt: r.expiresAt };
|
|
1247
|
+
},
|
|
1248
|
+
createDownloadUrl(key, options) {
|
|
1249
|
+
const o = options || {};
|
|
1250
|
+
const r = __sbRpc("r2.transfer.create", {
|
|
1251
|
+
bucket: name,
|
|
1252
|
+
key: String(key),
|
|
1253
|
+
method: "download",
|
|
1254
|
+
expiresIn: o.expiresIn,
|
|
1255
|
+
});
|
|
1256
|
+
return { url: r.url, expiresAt: r.expiresAt };
|
|
1257
|
+
},
|
|
1200
1258
|
get(key) {
|
|
1201
1259
|
// #56 — bytes come back out of band on a transport that supports it, so
|
|
1202
1260
|
// an object body is never JSON-escaped into a frame.
|
package/src/r2-object.test.js
CHANGED
|
@@ -24,9 +24,21 @@ if (r2Start === -1 || r2End === -1 || r2End < r2Start) throw new Error("__sbR2Ob
|
|
|
24
24
|
|
|
25
25
|
// oxlint-disable-next-line no-function-constructor -- lifting pure blocks out of the text-only prelude for test
|
|
26
26
|
const load = new Function(
|
|
27
|
-
|
|
27
|
+
"__sbRpc",
|
|
28
|
+
"__sbR2MultipartPut",
|
|
29
|
+
`${src.slice(utf8Start, utf8End)}\n${src.slice(rawStart, rawEnd)}\n${src.slice(r2Start, r2End)}\nreturn { __sbR2Object, __sbR2Multipart };`,
|
|
28
30
|
);
|
|
29
|
-
const
|
|
31
|
+
const calls = [];
|
|
32
|
+
const rpc = (op, value) => {
|
|
33
|
+
calls.push({ op, value });
|
|
34
|
+
if (op === "r2.multipart.complete") return { object: meta };
|
|
35
|
+
return { ok: true };
|
|
36
|
+
};
|
|
37
|
+
const putPart = (bucket, key, uploadId, partNumber, body) => {
|
|
38
|
+
calls.push({ op: "r2.multipart.put", value: { bucket, key, uploadId, partNumber, body } });
|
|
39
|
+
return { part: { partNumber, etag: `part-${partNumber}` } };
|
|
40
|
+
};
|
|
41
|
+
const { __sbR2Object, __sbR2Multipart } = load(rpc, putPart);
|
|
30
42
|
|
|
31
43
|
const meta = { key: "f.bin", size: 3, etag: "abc123", uploaded: "2026-09-14T00:00:00.000Z" };
|
|
32
44
|
|
|
@@ -63,3 +75,17 @@ test("toResponse() on an empty body still returns bytes, not the string \"\"", a
|
|
|
63
75
|
const buf = new Uint8Array(await resp.arrayBuffer());
|
|
64
76
|
expect(buf.length).toBe(0);
|
|
65
77
|
});
|
|
78
|
+
|
|
79
|
+
test("multipart uploads expose the Cloudflare-shaped uploadPart, complete, and abort methods", () => {
|
|
80
|
+
calls.length = 0;
|
|
81
|
+
const upload = __sbR2Multipart("FILES", "archive.bin", "upload-1");
|
|
82
|
+
expect(upload).toMatchObject({ key: "archive.bin", uploadId: "upload-1" });
|
|
83
|
+
expect(upload.uploadPart(1, "first")).toEqual({ partNumber: 1, etag: "part-1" });
|
|
84
|
+
expect(upload.complete([{ partNumber: 1, etag: "part-1" }])).toMatchObject(meta);
|
|
85
|
+
upload.abort();
|
|
86
|
+
expect(calls.map((call) => call.op)).toEqual([
|
|
87
|
+
"r2.multipart.put",
|
|
88
|
+
"r2.multipart.complete",
|
|
89
|
+
"r2.multipart.abort",
|
|
90
|
+
]);
|
|
91
|
+
});
|
package/src/transport-broker.js
CHANGED
|
@@ -303,6 +303,27 @@ globalThis.__sbR2Get = function (bucket, key) {
|
|
|
303
303
|
return { found: true, object: reply.object, body: __sbTakeBin() };
|
|
304
304
|
};
|
|
305
305
|
|
|
306
|
+
globalThis.__sbR2MultipartPut = function (bucket, key, uploadId, partNumber, body) {
|
|
307
|
+
const token = __sbEnv("SB_BROKER_TOKEN");
|
|
308
|
+
const reply = JSON.parse(
|
|
309
|
+
__sbCallBin(
|
|
310
|
+
JSON.stringify({
|
|
311
|
+
v: 1,
|
|
312
|
+
id: ++__sbReqId,
|
|
313
|
+
token,
|
|
314
|
+
op: "r2.multipart.put",
|
|
315
|
+
bucket,
|
|
316
|
+
key,
|
|
317
|
+
uploadId,
|
|
318
|
+
partNumber,
|
|
319
|
+
}),
|
|
320
|
+
body == null ? "" : String(body),
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
if (reply.ok === false) throw new Error("sproutboat r2.multipart.put: " + (reply.error || "failed"));
|
|
324
|
+
return reply;
|
|
325
|
+
};
|
|
326
|
+
|
|
306
327
|
/** Static asset metadata stays JSON; the response body uses the v1 byte tail. */
|
|
307
328
|
globalThis.__sbAssetsGet = function (path) {
|
|
308
329
|
const token = __sbEnv("SB_BROKER_TOKEN");
|