@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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.
@@ -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
- `${src.slice(utf8Start, utf8End)}\n${src.slice(rawStart, rawEnd)}\n${src.slice(r2Start, r2End)}\nreturn { __sbR2Object };`,
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 { __sbR2Object } = load();
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
+ });
@@ -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");