@sproutboat/runtime 0.26.0 → 0.27.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,24 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.27.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9be9c74: R2 matches workerd from compatibility date `2026-10-26`:
8
+
9
+ - `get`, `put`, `head`, `delete`, `list` and `createMultipartUpload` return Promises, and errors reject them.
10
+ - An object's `etag` is the MD5 of its bytes, with `checksums.md5`. Objects written before keep their old etag.
11
+ - Objects have `uploaded` as a `Date`, `version`, `storageClass`, `range` and `writeHttpMetadata(headers)`. A body has `body` (a `ReadableStream`), `bodyUsed`, `text()`, `json()`, `arrayBuffer()` and `blob()`.
12
+ - `get(key, { range, onlyIf })` takes `{ offset, length }`, `{ suffix }` or a `Range` header, and `etagMatches`, `etagDoesNotMatch`, `uploadedBefore`, `uploadedAfter` or `If-*` headers. When the condition fails it returns the object without a body.
13
+ - `put(key, value, { onlyIf, httpMetadata })` takes a `ReadableStream` or a `Blob`, and `httpMetadata` as `Headers`. When the condition fails it returns `null`.
14
+ - `delete([keys])` deletes several keys.
15
+ - `list({ prefix, limit, cursor, delimiter, include })` pages at up to 1,000 entries, groups keys into `delimitedPrefixes`, and returns `httpMetadata` and `customMetadata` only when `include` asks for them.
16
+ - A value of the wrong type and a key longer than 1,024 bytes fail with R2's errors.
17
+
18
+ Earlier dates keep the synchronous shim. One change applies at every date: **a `list()` prefix now matches exactly.** It used SQL `LIKE`, so it ignored case and read `_` and `%` as wildcards. Both transports store an object's MD5 in a new column, added to existing stores when they open.
19
+
20
+ Against miniflare 4.20260730, `conformance/bindings/r2.js` in sproutd passes 48/48 at `2026-10-26` (16/48 before).
21
+
3
22
  ## 0.26.0
4
23
 
5
24
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -5238,6 +5238,261 @@ function __sbR2Multipart(bucket, key, uploadId) {
5238
5238
  };
5239
5239
  }
5240
5240
 
5241
+ // R2 from compatibility date 2026-10-26: workerd's surface. Every method returns a
5242
+ // Promise; an object's etag is the MD5 of its bytes (sha256 before; objects written
5243
+ // earlier keep theirs), with `checksums.md5`, `uploaded` as a Date and `range`; get()
5244
+ // takes `range` and `onlyIf`, put() takes `onlyIf`, a ReadableStream or a Blob and
5245
+ // httpMetadata as Headers; delete() takes a list of keys; list() pages with limit and
5246
+ // cursor, groups by delimiter and returns metadata only when `include` asks for it.
5247
+ // Earlier dates keep the synchronous shim below.
5248
+ function __sbR2Strict() {
5249
+ return globalThis.__sbCompat >= "2026-10-26";
5250
+ }
5251
+ function __sbR2Key(key, verb) {
5252
+ const name = String(key);
5253
+ if (new TextEncoder().encode(name).length > 1024) throw new Error(verb + ": The specified object name is not valid. (10020)");
5254
+ return name;
5255
+ }
5256
+ function __sbR2HexOf(bytes) {
5257
+ let out = "";
5258
+ for (let i = 0; i < bytes.length; i++) {
5259
+ const b = bytes.charCodeAt(i) & 0xff;
5260
+ out += (b < 16 ? "0" : "") + b.toString(16);
5261
+ }
5262
+ return out;
5263
+ }
5264
+ // R2's httpMetadata, in workerd's key order, from an object or a Headers.
5265
+ function __sbR2Http(h) {
5266
+ if (h == null) return {};
5267
+ const src = h instanceof Headers
5268
+ ? {
5269
+ contentType: h.get("content-type"),
5270
+ contentLanguage: h.get("content-language"),
5271
+ contentDisposition: h.get("content-disposition"),
5272
+ contentEncoding: h.get("content-encoding"),
5273
+ cacheControl: h.get("cache-control"),
5274
+ cacheExpiry: h.get("expires"),
5275
+ }
5276
+ : h;
5277
+ const out = {};
5278
+ const names = ["contentType", "contentLanguage", "contentDisposition", "contentEncoding", "cacheControl", "cacheExpiry"];
5279
+ for (let i = 0; i < names.length; i++) if (src[names[i]] != null) out[names[i]] = src[names[i]];
5280
+ return out;
5281
+ }
5282
+ function __sbR2EtagIn(list, etag) {
5283
+ const items = Array.isArray(list) ? list : String(list).split(",");
5284
+ for (let i = 0; i < items.length; i++) {
5285
+ const e = String(items[i]).trim().replace(/^W\//, "").replace(/^"|"$/g, "");
5286
+ if (e === "*" || e === etag) return true;
5287
+ }
5288
+ return false;
5289
+ }
5290
+ // Whether an object passes onlyIf (an object or a Headers with If-* headers).
5291
+ function __sbR2OnlyIf(cond, etag, uploaded) {
5292
+ if (cond == null) return true;
5293
+ const c = cond instanceof Headers
5294
+ ? {
5295
+ etagMatches: cond.get("if-match"),
5296
+ etagDoesNotMatch: cond.get("if-none-match"),
5297
+ uploadedBefore: cond.get("if-unmodified-since"),
5298
+ uploadedAfter: cond.get("if-modified-since"),
5299
+ }
5300
+ : cond;
5301
+ const at = new Date(uploaded).getTime();
5302
+ if (c.etagMatches != null && !__sbR2EtagIn(c.etagMatches, etag)) return false;
5303
+ if (c.etagDoesNotMatch != null && __sbR2EtagIn(c.etagDoesNotMatch, etag)) return false;
5304
+ if (c.uploadedBefore != null && !(at < new Date(c.uploadedBefore).getTime())) return false;
5305
+ if (c.uploadedAfter != null && !(at > new Date(c.uploadedAfter).getTime())) return false;
5306
+ return true;
5307
+ }
5308
+ // {offset, length} of a range (an object or a Headers with Range) on an object of SIZE.
5309
+ function __sbR2Range(range, size) {
5310
+ let offset = 0, length = size;
5311
+ if (range instanceof Headers) {
5312
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(range.get("range") || "").trim());
5313
+ if (!m) return { offset, length };
5314
+ if (m[1] === "") range = { suffix: Number(m[2]) };
5315
+ else range = m[2] === "" ? { offset: Number(m[1]) } : { offset: Number(m[1]), length: Number(m[2]) - Number(m[1]) + 1 };
5316
+ }
5317
+ if (range.suffix != null) {
5318
+ length = Math.min(Number(range.suffix), size);
5319
+ offset = size - length;
5320
+ } else {
5321
+ offset = Math.min(Number(range.offset || 0), size);
5322
+ length = range.length == null ? size - offset : Math.min(Number(range.length), size - offset);
5323
+ }
5324
+ return { offset, length };
5325
+ }
5326
+ // An R2Object (or an R2ObjectBody, given BYTES) as workerd returns it.
5327
+ function __sbR2StrictObject(meta, bytes, range, include) {
5328
+ const etag = meta.md5 || meta.etag;
5329
+ const md5 = meta.md5;
5330
+ const obj = {
5331
+ key: meta.key,
5332
+ version: meta.etag,
5333
+ size: meta.size,
5334
+ etag,
5335
+ httpEtag: '"' + etag + '"',
5336
+ checksums: {
5337
+ md5: md5 ? __sbBufFrom(__sbR2Bytes(md5)) : undefined,
5338
+ toJSON() {
5339
+ return { md5: md5 || undefined, sha1: undefined, sha256: undefined, sha384: undefined, sha512: undefined };
5340
+ },
5341
+ },
5342
+ uploaded: new Date(meta.uploaded),
5343
+ httpMetadata: include === false ? {} : __sbR2Http(meta.httpMetadata),
5344
+ customMetadata: include === false ? {} : meta.customMetadata || {},
5345
+ storageClass: "",
5346
+ range,
5347
+ writeHttpMetadata(headers) {
5348
+ const h = obj.httpMetadata;
5349
+ if (h.contentType != null) headers.set("content-type", h.contentType);
5350
+ if (h.contentLanguage != null) headers.set("content-language", h.contentLanguage);
5351
+ if (h.contentDisposition != null) headers.set("content-disposition", h.contentDisposition);
5352
+ if (h.contentEncoding != null) headers.set("content-encoding", h.contentEncoding);
5353
+ if (h.cacheControl != null) headers.set("cache-control", h.cacheControl);
5354
+ if (h.cacheExpiry != null) headers.set("expires", new Date(h.cacheExpiry).toUTCString());
5355
+ },
5356
+ };
5357
+ if (bytes == null) return obj;
5358
+ let used = false;
5359
+ const take = () => {
5360
+ 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.");
5361
+ used = true;
5362
+ return bytes;
5363
+ };
5364
+ obj.body = new Response(__sbBufFrom(bytes)).body;
5365
+ Object.defineProperty(obj, "bodyUsed", { get: () => used, enumerable: true });
5366
+ obj.text = async () => __sbFromUtf8(take());
5367
+ obj.json = async () => JSON.parse(__sbFromUtf8(take()));
5368
+ obj.arrayBuffer = async () => __sbBufFrom(take());
5369
+ obj.blob = async () => new Blob([__sbBufFrom(take())], { type: obj.httpMetadata.contentType || "" });
5370
+ return obj;
5371
+ }
5372
+ // A hex string's bytes, one char per byte.
5373
+ function __sbR2Bytes(hex) {
5374
+ let out = "";
5375
+ for (let i = 0; i + 1 < hex.length; i += 2) out += String.fromCharCode(parseInt(hex.slice(i, i + 2), 16));
5376
+ return out;
5377
+ }
5378
+ function __sbMakeR2(name) {
5379
+ const attempt = (run) => {
5380
+ try {
5381
+ return Promise.resolve(run());
5382
+ } catch (err) {
5383
+ return Promise.reject(err);
5384
+ }
5385
+ };
5386
+ const head = (key) => {
5387
+ const r = __sbRpc("r2.head", { bucket: name, key });
5388
+ return r.found ? r.object : null;
5389
+ };
5390
+ const upload = (key, uploadId) => {
5391
+ const inner = __sbR2Multipart(name, key, uploadId);
5392
+ return {
5393
+ key,
5394
+ uploadId,
5395
+ uploadPart: (partNumber, value) => attempt(() => inner.uploadPart(partNumber, value)),
5396
+ // ponytail: a multipart object's etag stays the transport's sha256; workerd's is
5397
+ // MD5-of-part-MD5s-N. Add when an app compares multipart etags.
5398
+ complete: (parts) => attempt(() => __sbR2StrictObject(__sbRpc("r2.multipart.complete", { bucket: name, key, uploadId, parts: parts || [] }).object, null)),
5399
+ abort: () => attempt(() => inner.abort()),
5400
+ };
5401
+ };
5402
+ return {
5403
+ head(key) {
5404
+ return attempt(() => {
5405
+ const meta = head(__sbR2Key(key, "head"));
5406
+ return meta ? __sbR2StrictObject(meta, null, { offset: 0, length: meta.size }) : null;
5407
+ });
5408
+ },
5409
+ get(key, options) {
5410
+ return attempt(() => {
5411
+ const o = options || {};
5412
+ const r = __sbR2Get(name, __sbR2Key(key, "get"));
5413
+ if (!r.found) return null;
5414
+ const meta = r.object;
5415
+ if (!__sbR2OnlyIf(o.onlyIf, meta.md5 || meta.etag, meta.uploaded)) return __sbR2StrictObject(meta, null, { offset: 0, length: meta.size });
5416
+ const bytes = r.body == null ? "" : r.body;
5417
+ const range = o.range == null ? { offset: 0, length: meta.size } : __sbR2Range(o.range, meta.size);
5418
+ return __sbR2StrictObject(meta, bytes.slice(range.offset, range.offset + range.length), range);
5419
+ });
5420
+ },
5421
+ async put(key, value, options) {
5422
+ const objectKey = __sbR2Key(key, "put");
5423
+ const o = options || {};
5424
+ value = __sbUnwrapBody(value);
5425
+ if (value instanceof Blob) value = await value.arrayBuffer();
5426
+ else if (value != null && __sbIsFn(value.getReader)) value = await new Response(value).arrayBuffer();
5427
+ let bytes;
5428
+ if (value == null) bytes = "";
5429
+ else if (__sbIsStr(value)) bytes = __sbToBytes(value);
5430
+ else if (value.byteLength !== undefined)
5431
+ bytes = __sbToBytes(value.buffer !== undefined ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value));
5432
+ else
5433
+ throw new TypeError("Failed to execute 'put' on 'R2Bucket': parameter 2 is not of type 'ReadableStream or ArrayBuffer or ArrayBufferView or string or Blob'.");
5434
+ if (o.onlyIf != null) {
5435
+ // ponytail: check, then write; one worker per app, so nothing writes in between.
5436
+ const current = head(objectKey);
5437
+ if (current && !__sbR2OnlyIf(o.onlyIf, current.md5 || current.etag, current.uploaded)) return null;
5438
+ if (!current && o.onlyIf.etagMatches != null) return null;
5439
+ }
5440
+ const http = __sbR2Http(o.httpMetadata);
5441
+ const custom = o.customMetadata || {};
5442
+ const meta = __sbR2Put(name, objectKey, bytes, http, custom).object;
5443
+ meta.md5 = __sbR2HexOf(__sbDigestRaw("5", bytes));
5444
+ __sbRpc("r2.md5", { bucket: name, key: objectKey, etag: meta.etag, md5: meta.md5 });
5445
+ meta.httpMetadata = http;
5446
+ meta.customMetadata = custom;
5447
+ return __sbR2StrictObject(meta, null, undefined);
5448
+ },
5449
+ delete(keys) {
5450
+ return attempt(() => {
5451
+ const list = Array.isArray(keys) ? keys : [keys];
5452
+ for (let i = 0; i < list.length; i++) __sbRpc("r2.delete", { bucket: name, key: __sbR2Key(list[i], "delete") });
5453
+ });
5454
+ },
5455
+ list(options) {
5456
+ return attempt(() => {
5457
+ const o = options || {};
5458
+ const r = __sbRpc("r2.list", {
5459
+ bucket: name,
5460
+ page: true,
5461
+ prefix: o.prefix == null ? "" : String(o.prefix),
5462
+ cursor: o.cursor == null ? "" : String(o.cursor),
5463
+ limit: o.limit == null ? 1000 : Number(o.limit),
5464
+ delimiter: o.delimiter == null ? "" : String(o.delimiter),
5465
+ });
5466
+ const include = o.include || [];
5467
+ const objects = [];
5468
+ for (let j = 0; j < (r.objects || []).length; j++) {
5469
+ const obj = __sbR2StrictObject(r.objects[j], null, undefined);
5470
+ if (include.indexOf("httpMetadata") < 0) obj.httpMetadata = {};
5471
+ if (include.indexOf("customMetadata") < 0) obj.customMetadata = {};
5472
+ objects.push(obj);
5473
+ }
5474
+ return { objects, truncated: !!r.truncated, cursor: r.truncated ? r.cursor : undefined, delimitedPrefixes: r.delimitedPrefixes || [] };
5475
+ });
5476
+ },
5477
+ createMultipartUpload(key, options) {
5478
+ return attempt(() => {
5479
+ const objectKey = __sbR2Key(key, "createMultipartUpload");
5480
+ const o = options || {};
5481
+ const r = __sbRpc("r2.multipart.create", {
5482
+ bucket: name,
5483
+ key: objectKey,
5484
+ httpMetadata: __sbR2Http(o.httpMetadata),
5485
+ customMetadata: o.customMetadata || {},
5486
+ });
5487
+ return upload(objectKey, r.uploadId);
5488
+ });
5489
+ },
5490
+ resumeMultipartUpload(key, uploadId) {
5491
+ return upload(String(key), String(uploadId));
5492
+ },
5493
+ };
5494
+ }
5495
+
5241
5496
  // Asset rules use the same parsed manifest in both transports. Matching mirrors
5242
5497
  // @sproutboat/assets; parity tests cover the portable native implementation.
5243
5498
  function __sbAssetRedirect(rule, path) {
@@ -5364,7 +5619,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5364
5619
 
5365
5620
  for (let i = 0; i < (bindings.r2 || []).length; i++) {
5366
5621
  const name = bindings.r2[i];
5367
- target[name] = {
5622
+ target[name] = __sbR2Strict() ? __sbMakeR2(name) : {
5368
5623
  put(key, value, options) {
5369
5624
  value = __sbUnwrapBody(value);
5370
5625
  const o = options || {};
@@ -3590,6 +3590,10 @@ function __sbEnsureSchema() {
3590
3590
  let hasBlobId = false;
3591
3591
  for (let i = 0; i < r2Columns.length; i++) if (r2Columns[i][1] === "blob_id") hasBlobId = true;
3592
3592
  if (!hasBlobId) __sbSql(s, "ALTER TABLE r2 ADD COLUMN blob_id TEXT", []);
3593
+ // The MD5 of an object's bytes, its etag from compatibility date 2026-10-26 (r2.md5).
3594
+ let hasR2Md5 = false;
3595
+ for (let i = 0; i < r2Columns.length; i++) if (r2Columns[i][1] === "md5") hasR2Md5 = true;
3596
+ if (!hasR2Md5) __sbSql(s, "ALTER TABLE r2 ADD COLUMN md5 TEXT", []);
3593
3597
  __sbSql(
3594
3598
  s,
3595
3599
  "CREATE TABLE IF NOT EXISTS r2_upload (bucket TEXT NOT NULL, key TEXT NOT NULL, upload_id TEXT NOT NULL, " +
@@ -3661,6 +3665,51 @@ function __sbEnsureSchema() {
3661
3665
  );
3662
3666
  }
3663
3667
 
3668
+ // One page of r2.list for the shim from 2026-10-26: at most LIMIT entries after CURSOR,
3669
+ // keys under PREFIX, those holding DELIMITER after it grouped into delimitedPrefixes (a
3670
+ // group counts as one entry). Same rules as the broker's r2ListPage.
3671
+ // ponytail: with a delimiter every key after the cursor is read; an index on the
3672
+ // delimited prefix if buckets grow past ~100k keys.
3673
+ function __sbR2ListPage(store, msg) {
3674
+ const prefix = String(msg.prefix || "");
3675
+ const delimiter = String(msg.delimiter || "");
3676
+ const limit = Math.min(Math.max(Math.floor(Number(msg.limit)) || 1000, 1), 1000);
3677
+ const r = __sbSql(
3678
+ store,
3679
+ "SELECT key, size, etag, uploaded, http_json, custom_json, md5 FROM r2 WHERE bucket = ?1 AND substr(key, 1, length(?2)) = ?2 " +
3680
+ "AND key > ?3 ORDER BY key" + (delimiter ? "" : " LIMIT " + (limit + 1)),
3681
+ [msg.bucket, prefix, String(msg.cursor || "")],
3682
+ );
3683
+ const objects = [];
3684
+ const delimitedPrefixes = [];
3685
+ let last = "";
3686
+ let truncated = false;
3687
+ for (let i = 0; i < r.rows.length; i++) {
3688
+ const row = r.rows[i];
3689
+ const at = delimiter ? row[0].indexOf(delimiter, prefix.length) : -1;
3690
+ const group = at < 0 ? "" : row[0].slice(0, at + delimiter.length);
3691
+ if (!group || delimitedPrefixes[delimitedPrefixes.length - 1] !== group) {
3692
+ if (objects.length + delimitedPrefixes.length >= limit) {
3693
+ truncated = true;
3694
+ break;
3695
+ }
3696
+ if (group) delimitedPrefixes.push(group);
3697
+ else
3698
+ objects.push({
3699
+ key: row[0],
3700
+ size: Number(row[1]),
3701
+ etag: row[2],
3702
+ uploaded: row[3],
3703
+ httpMetadata: JSON.parse(row[4] || "{}"),
3704
+ customMetadata: JSON.parse(row[5] || "{}"),
3705
+ md5: row[6],
3706
+ });
3707
+ }
3708
+ last = row[0];
3709
+ }
3710
+ return { ok: true, objects, delimitedPrefixes, truncated, cursor: truncated ? last : "" };
3711
+ }
3712
+
3664
3713
  function __sbHex(n) {
3665
3714
  let out = "";
3666
3715
  const bytes = __sbRandomBytes(String(n));
@@ -4072,7 +4121,7 @@ function __sbEmbeddedDispatch(msg) {
4072
4121
  const wantsBody = op === "r2.get";
4073
4122
  const r = __sbSql(
4074
4123
  store,
4075
- "SELECT size, etag, uploaded, http_json, custom_json, blob_id FROM r2 WHERE bucket = ? AND key = ?",
4124
+ "SELECT size, etag, uploaded, http_json, custom_json, blob_id, md5 FROM r2 WHERE bucket = ? AND key = ?",
4076
4125
  [msg.bucket, msg.key],
4077
4126
  );
4078
4127
  if (!r.rows.length) return { ok: true, found: false };
@@ -4088,6 +4137,7 @@ function __sbEmbeddedDispatch(msg) {
4088
4137
  uploaded: row[2],
4089
4138
  httpMetadata: JSON.parse(row[3] || "{}"),
4090
4139
  customMetadata: JSON.parse(row[4] || "{}"),
4140
+ md5: row[6],
4091
4141
  },
4092
4142
  blobId: row[5] || "",
4093
4143
  body: wantsBody
@@ -4110,10 +4160,23 @@ function __sbEmbeddedDispatch(msg) {
4110
4160
  );
4111
4161
  return { ok: true };
4112
4162
  }
4163
+ if (op === "r2.md5") {
4164
+ // Recorded after the put that wrote these bytes (etag): the shim from 2026-10-26 hashes them.
4165
+ __sbSql(store, "UPDATE r2 SET md5 = ?1 WHERE bucket = ?2 AND key = ?3 AND etag = ?4", [
4166
+ String(msg.md5),
4167
+ msg.bucket,
4168
+ msg.key,
4169
+ String(msg.etag),
4170
+ ]);
4171
+ return { ok: true };
4172
+ }
4113
4173
  if (op === "r2.list") {
4174
+ // The prefix matches exactly at every date: LIKE ignored case and read "_" and "%" as
4175
+ // wildcards. With `page` (the shim from 2026-10-26), a page after the cursor.
4176
+ if (msg.page) return __sbR2ListPage(store, msg);
4114
4177
  const r = __sbSql(
4115
4178
  store,
4116
- "SELECT key, size, etag, uploaded FROM r2 WHERE bucket = ? AND key LIKE ? || '%' ORDER BY key",
4179
+ "SELECT key, size, etag, uploaded FROM r2 WHERE bucket = ?1 AND substr(key, 1, length(?2)) = ?2 ORDER BY key",
4117
4180
  [msg.bucket, msg.prefix || ""],
4118
4181
  );
4119
4182
  const objects = [];