@sproutboat/wire 0.6.0 → 0.7.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,33 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 493e4e3: Add short-lived R2 direct-transfer tickets for broker-backed sprouts. A
8
+ loopback HTTP listener streams PUT bodies to file-backed R2 storage, and serves
9
+ GET and HEAD requests with single-range support without placing object bytes
10
+ in a binding frame.
11
+
12
+ Tickets are random bearer capabilities with a byte limit, expiry, one-use
13
+ claim, and optional SHA-256 verification. Native standalone direct transfers
14
+ remain unavailable until the native HTTP ingress can stream request bodies.
15
+
16
+ For broker deployments, ticket issuance now reserves account-level capacity
17
+ across every R2 resource. Reservations survive redeploys, expired and orphaned
18
+ temporary uploads are reclaimed, and the broker retains a configurable free
19
+ disk floor with capacity and rejected-transfer counters for the dashboard.
20
+ - 5acb93c: Add Cloudflare-shaped resumable R2 multipart uploads. Parts are persisted
21
+ independently, and standalone completion assembles the final object through a
22
+ fixed 64 KiB native buffer to keep memory bounded by part size rather than total
23
+ object size.
24
+
25
+ Unfinished multipart uploads expire after seven days and their persisted part
26
+ files are removed automatically.
27
+
28
+ Store completed objects as immutable blob generations so a failed overwrite
29
+ cannot expose new bytes under the previous metadata.
30
+
3
31
  ## 0.6.0
4
32
 
5
33
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -9,6 +9,7 @@ import {
9
9
  encodeFrame,
10
10
  encodeV1,
11
11
  listen,
12
+ listenTransfers,
12
13
  type Broker,
13
14
  type Frame,
14
15
  type FetchLike,
@@ -159,6 +160,180 @@ test("R2: put / get / head / list / delete on a bound bucket", async () => {
159
160
  await expect(b.dispatch({ op: "r2.put", bucket: "NOPE", key: "x", body: "y" })).rejects.toThrow("not bound");
160
161
  });
161
162
 
163
+ test("R2 direct transfer: streamed upload, metadata, one-use ticket and ranged download", async () => {
164
+ const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-"));
165
+ try {
166
+ const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
167
+ const upload = await b.dispatch({
168
+ op: "r2.transfer.create", bucket: "UPLOADS", key: "large.bin", method: "upload",
169
+ maxBytes: 100, sha256: createHash("sha256").update("abcdef").digest("hex"),
170
+ httpMetadata: { contentType: "application/octet-stream" },
171
+ });
172
+ const url = `http://127.0.0.1${upload.url}`;
173
+ const bytes = new TextEncoder().encode("abcdef");
174
+ const body = new ReadableStream<Uint8Array>({
175
+ pull(controller) { controller.enqueue(bytes.subarray(0, 3)); controller.enqueue(bytes.subarray(3)); controller.close(); },
176
+ });
177
+ // SAFETY: Bun accepts duplex for streamed bodies; the bundled DOM type omits this runtime field.
178
+ const put = await b.transfer(new Request(url, { method: "PUT", body, duplex: "half" } as RequestInit));
179
+ expect(put.status).toBe(201);
180
+ expect(await b.transfer(new Request(url, { method: "PUT", body: "again" }))).toMatchObject({ status: 410 });
181
+ const head = await b.dispatch({ op: "r2.head", bucket: "UPLOADS", key: "large.bin" });
182
+ expect(obj(head.object).size).toBe(6);
183
+
184
+ const download = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large.bin", method: "download" });
185
+ const get = await b.transfer(new Request(`http://127.0.0.1${download.url}`, { headers: { range: "bytes=2-4" } }));
186
+ expect(get.status).toBe(206);
187
+ expect(get.headers.get("content-range")).toBe("bytes 2-4/6");
188
+ expect(get.headers.get("content-type")).toBe("application/octet-stream");
189
+ expect(await get.text()).toBe("cde");
190
+ expect((await b.transfer(new Request(`http://127.0.0.1${download.url}`))).status).toBe(410);
191
+ } finally {
192
+ rmSync(root, { recursive: true, force: true });
193
+ }
194
+ });
195
+
196
+ test("R2 direct transfer: invalid ranges and oversized bodies never replace an object", async () => {
197
+ const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-errors-"));
198
+ try {
199
+ const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
200
+ await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "k", body: "old" });
201
+ const upload = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "k", method: "upload", maxBytes: 2 });
202
+ const url = `http://127.0.0.1${upload.url}`;
203
+ expect((await b.transfer(new Request(url, { method: "PUT", body: "large" }))).status).toBe(413);
204
+ expect((await b.transfer(new Request(url, { method: "PUT", body: "ok" }))).status).toBe(410);
205
+ const retry = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "k", method: "upload", maxBytes: 2 });
206
+ expect((await b.transfer(new Request(`http://127.0.0.1${retry.url}`, { method: "PUT", body: "ok" }))).status).toBe(201);
207
+ const after = await b.dispatch({ op: "r2.get", bucket: "UPLOADS", key: "k" });
208
+ expect(after.body).toBe("ok");
209
+ const dl = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "k", method: "download" });
210
+ expect((await b.transfer(new Request(`http://127.0.0.1${dl.url}`, { headers: { range: "bytes=1-0" } }))).status).toBe(416);
211
+ expect((await b.transfer(new Request(`http://127.0.0.1${dl.url}`))).status).toBe(200);
212
+ } finally {
213
+ rmSync(root, { recursive: true, force: true });
214
+ }
215
+ });
216
+
217
+ test("R2 direct transfer: account quota includes objects and shared in-flight reservations", async () => {
218
+ const root = mkdtempSync(join(tmpdir(), "sb-r2-quota-"));
219
+ try {
220
+ const resourceDir = join(root, "resources");
221
+ const bindings = {
222
+ r2: ["UPLOADS"],
223
+ resources: { UPLOADS: { kind: "r2" as const, id: "uploads" } },
224
+ };
225
+ const config = {
226
+ resourceDir,
227
+ ownerId: "owner-1",
228
+ r2QuotaBytes: 12,
229
+ r2ResourceIds: ["uploads"],
230
+ bindings,
231
+ };
232
+ const first = make({ ...config, db: join(root, "first.sqlite") });
233
+ const second = make({ ...config, db: join(root, "second.sqlite") });
234
+
235
+ await first.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "existing", body: "four" });
236
+ const resource = new Database(join(resourceDir, "uploads.sqlite"));
237
+ try {
238
+ resource.query("INSERT INTO r2_part (bucket, key, upload_id, part_number, size, etag) VALUES (?1, ?2, ?3, 1, 2, ?4)")
239
+ .run("uploads", "partial", "upload-1", "part");
240
+ } finally {
241
+ resource.close();
242
+ }
243
+ await expect(first.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large", method: "upload", maxBytes: 7 }))
244
+ .rejects.toThrow("quota");
245
+ const ticket = await first.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large", method: "upload", maxBytes: 6 });
246
+ await expect(second.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "other", method: "upload", maxBytes: 1 }))
247
+ .rejects.toThrow("quota");
248
+
249
+ expect((await first.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "five!" }))).status).toBe(201);
250
+ const replacement = await second.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "other", method: "upload", maxBytes: 1 });
251
+ expect(replacement.ok).toBe(true);
252
+ const ledger = new Database(join(resourceDir, "r2-quota.sqlite"), { readonly: true });
253
+ try {
254
+ expect(ledger.query<{ tickets_issued: number; rejected_quota: number }, [string]>(
255
+ "SELECT tickets_issued, rejected_quota FROM r2_transfer_metric WHERE owner = ?",
256
+ ).get("owner-1")).toEqual({ tickets_issued: 2, rejected_quota: 2 });
257
+ } finally {
258
+ ledger.close();
259
+ }
260
+ } finally {
261
+ rmSync(root, { recursive: true, force: true });
262
+ }
263
+ });
264
+
265
+ test("R2 direct transfer: failed uploads release their reservation and retain the disk floor", async () => {
266
+ const root = mkdtempSync(join(tmpdir(), "sb-r2-quota-release-"));
267
+ try {
268
+ const config = {
269
+ db: join(root, "state.sqlite"),
270
+ resourceDir: join(root, "resources"),
271
+ ownerId: "owner-1",
272
+ r2QuotaBytes: 5,
273
+ r2ResourceIds: ["uploads"],
274
+ bindings: { r2: ["UPLOADS"], resources: { UPLOADS: { kind: "r2" as const, id: "uploads" } } },
275
+ };
276
+ const b = make(config);
277
+ const ticket = await b.dispatch({
278
+ op: "r2.transfer.create", bucket: "UPLOADS", key: "bad", method: "upload", maxBytes: 5,
279
+ sha256: createHash("sha256").update("expected").digest("hex"),
280
+ });
281
+ expect((await b.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "wrong" }))).status).toBe(422);
282
+ expect((await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "retry", method: "upload", maxBytes: 5 })).ok).toBe(true);
283
+
284
+ const floor = make({ ...config, db: join(root, "floor.sqlite"), r2QuotaBytes: 100, r2MinFreeBytes: 10, freeBytes: () => 11 });
285
+ await expect(floor.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "no-space", method: "upload", maxBytes: 2 }))
286
+ .rejects.toThrow("temporarily unavailable");
287
+
288
+ let freeBytes = 20;
289
+ const duringUpload = make({
290
+ ...config,
291
+ db: join(root, "during-upload.sqlite"),
292
+ r2QuotaBytes: 100,
293
+ r2MinFreeBytes: 10,
294
+ freeBytes: () => freeBytes,
295
+ });
296
+ const unknownLength = await duringUpload.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "during", method: "upload", maxBytes: 5 });
297
+ freeBytes = 10;
298
+ expect((await duringUpload.transfer(new Request(`http://127.0.0.1${unknownLength.url}`, { method: "PUT", body: "x" }))).status).toBe(507);
299
+
300
+ const abandoned = join(config.resourceDir, "r2-blobs", "a".repeat(24) + ".upload.tmp");
301
+ mkdirSync(join(config.resourceDir, "r2-blobs"), { recursive: true });
302
+ writeFileSync(abandoned, "partial");
303
+ await expect(b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "quota-full", method: "upload", maxBytes: 1 }))
304
+ .rejects.toThrow("quota");
305
+ expect(existsSync(abandoned)).toBe(false);
306
+ } finally {
307
+ rmSync(root, { recursive: true, force: true });
308
+ }
309
+ });
310
+
311
+ test("R2 direct transfer: live HTTP listener accepts a chunked binary PUT", async () => {
312
+ const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-http-"));
313
+ const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
314
+ const server = listenTransfers(b, "127.0.0.1", 0);
315
+ try {
316
+ const ticket = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "stream.bin", method: "upload", maxBytes: 2 * 1024 * 1024 });
317
+ let chunks = 0;
318
+ const body = new ReadableStream<Uint8Array>({
319
+ pull(controller) {
320
+ if (chunks++ < 16) controller.enqueue(new Uint8Array(64 * 1024).fill(chunks));
321
+ else controller.close();
322
+ },
323
+ });
324
+ // SAFETY: Bun accepts duplex for streamed bodies; the bundled DOM type omits this runtime field.
325
+ const uploaded = await fetch(`http://127.0.0.1:${server.port}${ticket.url}`, { method: "PUT", body, duplex: "half" } as RequestInit);
326
+ expect(uploaded.status).toBe(201);
327
+ const download = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "stream.bin", method: "download" });
328
+ const response = await fetch(`http://127.0.0.1:${server.port}${download.url}`, { headers: { range: "bytes=65535-65537" } });
329
+ expect(response.status).toBe(206);
330
+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([1, 2, 2]));
331
+ } finally {
332
+ server.stop();
333
+ rmSync(root, { recursive: true, force: true });
334
+ }
335
+ });
336
+
162
337
  test("R2: list paginates with a cursor", async () => {
163
338
  const b = make({ bindings: { r2: ["ASSETS"] } });
164
339
  for (let i = 0; i < 5; i++) await b.dispatch({ op: "r2.put", bucket: "ASSETS", key: `k${i}`, body: "x" });
@@ -169,6 +344,119 @@ test("R2: list paginates with a cursor", async () => {
169
344
  expect(arr(p2.objects).map((o) => obj(o).key)).toEqual(["k2", "k3"]);
170
345
  });
171
346
 
347
+ test("R2 multipart: binary parts complete without joining the full object in the broker heap", async () => {
348
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-multipart-"));
349
+ try {
350
+ const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] }, token: "tok" });
351
+ const created = await b.handleFrame(
352
+ encodeV1({
353
+ v: 1,
354
+ id: 1,
355
+ token: "tok",
356
+ op: "r2.multipart.create",
357
+ bucket: "UPLOADS",
358
+ key: "archive.bin",
359
+ customMetadata: { source: "test" },
360
+ }),
361
+ );
362
+ const uploadId = String(decodeV1Frame(created).json.uploadId);
363
+ const first = new Uint8Array([0, 1, 2, 255]);
364
+ const second = new Uint8Array([4, 5]);
365
+ const part1 = decodeV1Frame(
366
+ await b.handleFrame(
367
+ encodeV1(
368
+ {
369
+ v: 1,
370
+ id: 2,
371
+ token: "tok",
372
+ op: "r2.multipart.put",
373
+ bucket: "UPLOADS",
374
+ key: "archive.bin",
375
+ uploadId,
376
+ partNumber: 1,
377
+ },
378
+ first,
379
+ ),
380
+ ),
381
+ ).json;
382
+ const part2 = decodeV1Frame(
383
+ await b.handleFrame(
384
+ encodeV1(
385
+ {
386
+ v: 1,
387
+ id: 3,
388
+ token: "tok",
389
+ op: "r2.multipart.put",
390
+ bucket: "UPLOADS",
391
+ key: "archive.bin",
392
+ uploadId,
393
+ partNumber: 2,
394
+ },
395
+ second,
396
+ ),
397
+ ),
398
+ ).json;
399
+ const completed = await b.dispatch({
400
+ op: "r2.multipart.complete",
401
+ bucket: "UPLOADS",
402
+ key: "archive.bin",
403
+ uploadId,
404
+ parts: [obj(part1.part), obj(part2.part)],
405
+ });
406
+ expect(obj(completed.object)).toMatchObject({ key: "archive.bin", size: 6, customMetadata: { source: "test" } });
407
+
408
+ const got = decodeV1Frame(
409
+ await b.handleFrame(encodeV1({ v: 1, token: "tok", op: "r2.get", bucket: "UPLOADS", key: "archive.bin" })),
410
+ );
411
+ expect(Buffer.from(got.bytes).equals(Buffer.from([...first, ...second]))).toBe(true);
412
+ expect(existsSync(join(root, "r2-blobs"))).toBe(true);
413
+ } finally {
414
+ rmSync(root, { recursive: true, force: true });
415
+ }
416
+ });
417
+
418
+ test("R2 overwrite keeps the committed generation visible when metadata update fails", async () => {
419
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-atomic-"));
420
+ try {
421
+ const databasePath = join(root, "state.sqlite");
422
+ const b = make({ db: databasePath, bindings: { r2: ["UPLOADS"] } });
423
+ await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "old-bytes" });
424
+ const control = new Database(databasePath);
425
+ control.exec(
426
+ "CREATE TRIGGER fail_r2_metadata BEFORE UPDATE ON r2 BEGIN SELECT RAISE(ABORT, 'forced metadata failure'); END",
427
+ );
428
+ await expect(
429
+ b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "new-and-longer-bytes" }),
430
+ ).rejects.toThrow("forced metadata failure");
431
+ expect(await b.dispatch({ op: "r2.get", bucket: "UPLOADS", key: "object" })).toMatchObject({
432
+ found: true,
433
+ body: "old-bytes",
434
+ object: { size: 9 },
435
+ });
436
+ control.close();
437
+ } finally {
438
+ rmSync(root, { recursive: true, force: true });
439
+ }
440
+ });
441
+
442
+ test("R2 direct overwrite also preserves the old generation on metadata failure", async () => {
443
+ const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-atomic-"));
444
+ try {
445
+ const databasePath = join(root, "state.sqlite");
446
+ const b = make({ db: databasePath, bindings: { r2: ["UPLOADS"] } });
447
+ await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "old" });
448
+ const control = new Database(databasePath);
449
+ control.exec("CREATE TRIGGER fail_r2_metadata BEFORE UPDATE ON r2 BEGIN SELECT RAISE(ABORT, 'forced metadata failure'); END");
450
+ const ticket = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "object", method: "upload" });
451
+ await expect(b.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "new" })))
452
+ .rejects.toThrow("forced metadata failure");
453
+ expect(await b.dispatch({ op: "r2.get", bucket: "UPLOADS", key: "object" })).toMatchObject({ found: true, body: "old" });
454
+ control.close();
455
+ } finally {
456
+ rmSync(root, { recursive: true, force: true });
457
+ }
458
+ });
459
+
172
460
  test("Durable Object storage: get / put / delete / list / deleteAll scoped to (class, id)", async () => {
173
461
  const b = make({ bindings: { do: [{ binding: "COUNTER", className: "Counter" }] } });
174
462
  expect(await b.dispatch({ op: "do.storage.get", cls: "Counter", id: "a", key: "n" })).toMatchObject({ found: false });
@@ -714,6 +1002,19 @@ async function v1(
714
1002
  };
715
1003
  }
716
1004
 
1005
+ type V1Reply = { json: JsonObject; bytes: Uint8Array };
1006
+
1007
+ function decodeV1Frame(frame: Buffer): V1Reply {
1008
+ const payloadLength = frame.readUInt32LE(0);
1009
+ const payload = frame.subarray(4, 4 + payloadLength);
1010
+ expect(payload[0]).toBe(1);
1011
+ const jsonLength = payload.readUInt32LE(1);
1012
+ return {
1013
+ json: obj(parseJsonValue(payload.subarray(5, 5 + jsonLength).toString("utf8"))),
1014
+ bytes: new Uint8Array(payload.subarray(5 + jsonLength)),
1015
+ };
1016
+ }
1017
+
717
1018
  test("v1: an object body round-trips as bytes, not as escaped JSON", async () => {
718
1019
  const b = make({ bindings: { r2: ["UP"] }, token: "tok" });
719
1020
  const server = listen(b, "127.0.0.1", 0);