@sproutboat/runtime 0.8.0 → 0.9.1

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,21 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.9.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e0944a4: Provide the native direct-transfer ABI in broker builds so the shared
8
+ uWebSockets patch does not introduce unresolved symbols outside standalone
9
+ mode.
10
+
11
+ ## 0.9.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 0b0a666: Add a native standalone R2 direct-upload bridge that streams transfer-ticket
16
+ request chunks to a temporary blob file, validates the digest, and atomically
17
+ publishes the object without buffering the upload in the Porffor request body.
18
+
3
19
  ## 0.8.0
4
20
 
5
21
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -574,6 +574,178 @@ static void sb_r2_delete_blob(const char* path, const char* bucket, const char*
574
574
  unlink(blobpath);
575
575
  }
576
576
 
577
+ // The native-fetch C++ shim calls this narrow ABI for a reserved R2 transfer
578
+ // path before it allocates a PendingRequest body. The context owns only a file
579
+ // descriptor and small metadata buffers; object bytes stay in the kernel page
580
+ // cache until the final fixed-size digest pass.
581
+ typedef struct sb_r2_transfer_ctx {
582
+ FILE* file;
583
+ sqlite3* db;
584
+ char store[1024], bucket[256], key[512], token[32], expected[65], http[1024], custom[1024], temporary[1200];
585
+ size_t size, max_bytes;
586
+ } sb_r2_transfer_ctx;
587
+
588
+ extern const char* sb_standalone_app_name(void);
589
+
590
+ static void sb_r2_transfer_store_path(char* out, size_t cap) {
591
+ const char* dir = getenv("SB_DATA_DIR");
592
+ if (!dir || !*dir) dir = getenv("SPROUTBOAT_DATA");
593
+ if (!dir || !*dir) {
594
+ const char* app = sb_standalone_app_name();
595
+ snprintf(out, cap, "%s.data/store.sqlite", app && *app ? app : "app");
596
+ return;
597
+ }
598
+ snprintf(out, cap, "%s/store.sqlite", dir);
599
+ }
600
+
601
+ // Return HTTP status, or 0 when a one-use upload ticket was claimed.
602
+ int sb_r2_transfer_open(const char* bucket, const char* token, size_t declared, sb_r2_transfer_ctx** out) {
603
+ *out = 0;
604
+ if (!bucket || !token || strlen(bucket) >= 256 || strlen(token) != 24) return 404;
605
+ sb_r2_transfer_ctx* ctx = (sb_r2_transfer_ctx*)calloc(1, sizeof(*ctx));
606
+ if (!ctx) return 503;
607
+ sb_r2_transfer_store_path(ctx->store, sizeof(ctx->store));
608
+ int index = sb_db_for(ctx->store);
609
+ if (index < 0) { free(ctx); return 503; }
610
+ ctx->db = sb_dbs[index];
611
+ sqlite3_stmt* st = 0;
612
+ const char* sql = "SELECT key, max_bytes, expected_sha256, http_json, custom_json FROM r2_transfer "
613
+ "WHERE token=?1 AND bucket=?2 AND method='upload' AND used=0 AND expires>=?3";
614
+ if (sqlite3_prepare_v2(ctx->db, sql, -1, &st, 0) != 0 || !st) { free(ctx); return 503; }
615
+ sqlite3_bind_text(st, 1, token, -1, SB_SQLITE_TRANSIENT);
616
+ sqlite3_bind_text(st, 2, bucket, -1, SB_SQLITE_TRANSIENT);
617
+ sqlite3_bind_int64(st, 3, (int64_t)time(0) * 1000);
618
+ if (sqlite3_step(st) != SB_SQLITE_ROW) { sqlite3_finalize(st); free(ctx); return 410; }
619
+ const char* key = (const char*)sqlite3_column_text(st, 0);
620
+ ctx->max_bytes = (size_t)sqlite3_column_int64(st, 1);
621
+ const char* expected = (const char*)sqlite3_column_text(st, 2);
622
+ const char* http = (const char*)sqlite3_column_text(st, 3);
623
+ const char* custom = (const char*)sqlite3_column_text(st, 4);
624
+ if ((key && strlen(key) >= sizeof(ctx->key)) ||
625
+ (expected && strlen(expected) >= sizeof(ctx->expected)) ||
626
+ (http && strlen(http) >= sizeof(ctx->http)) ||
627
+ (custom && strlen(custom) >= sizeof(ctx->custom))) {
628
+ sqlite3_finalize(st); free(ctx); return 413;
629
+ }
630
+ snprintf(ctx->bucket, sizeof(ctx->bucket), "%s", bucket);
631
+ snprintf(ctx->key, sizeof(ctx->key), "%s", key ? key : "");
632
+ snprintf(ctx->token, sizeof(ctx->token), "%s", token);
633
+ snprintf(ctx->expected, sizeof(ctx->expected), "%s", expected ? expected : "");
634
+ snprintf(ctx->http, sizeof(ctx->http), "%s", http ? http : "{}");
635
+ snprintf(ctx->custom, sizeof(ctx->custom), "%s", custom ? custom : "{}");
636
+ sqlite3_finalize(st);
637
+ if (declared > ctx->max_bytes) { free(ctx); return 413; }
638
+ st = 0;
639
+ if (sqlite3_prepare_v2(ctx->db, "UPDATE r2_transfer SET used=1 WHERE token=?1 AND used=0", -1, &st, 0) != 0 || !st) { free(ctx); return 503; }
640
+ sqlite3_bind_text(st, 1, token, -1, SB_SQLITE_TRANSIENT);
641
+ int claimed = sqlite3_step(st) == SB_SQLITE_DONE && sqlite3_changes(ctx->db) == 1;
642
+ sqlite3_finalize(st);
643
+ if (!claimed) { free(ctx); return 410; }
644
+ const char* slash = strrchr(ctx->store, '/');
645
+ if (!slash) { free(ctx); return 500; }
646
+ char blobdir[1024];
647
+ sb_r2_blob_path(blobdir, sizeof(blobdir), ctx->store, ctx->bucket, ctx->key, "");
648
+ sb_mkdirs(blobdir);
649
+ if (snprintf(ctx->temporary, sizeof(ctx->temporary), "%.*s/r2-blobs/%s.upload.tmp", (int)(slash - ctx->store), ctx->store, token) >= (int)sizeof(ctx->temporary)) { free(ctx); return 507; }
650
+ ctx->file = fopen(ctx->temporary, "wb");
651
+ if (!ctx->file) { free(ctx); return 507; }
652
+ *out = ctx;
653
+ return 0;
654
+ }
655
+
656
+ int sb_r2_transfer_write(sb_r2_transfer_ctx* ctx, const char* bytes, size_t length) {
657
+ if (!ctx || !ctx->file || length > ctx->max_bytes - ctx->size) return 413;
658
+ if (length && fwrite(bytes, 1, length, ctx->file) != length) return 507;
659
+ ctx->size += length;
660
+ return 0;
661
+ }
662
+
663
+ // Finish the native direct-upload path. The digest is deliberately computed
664
+ // from the temporary file in fixed-size chunks so neither validation nor the
665
+ // immutable-generation commit ever materialises the object in process memory.
666
+ int sb_r2_transfer_finish(sb_r2_transfer_ctx* ctx) {
667
+ if (!ctx || !ctx->file) return 500;
668
+ if (fclose(ctx->file) != 0) { ctx->file = 0; unlink(ctx->temporary); free(ctx); return 507; }
669
+ ctx->file = 0;
670
+
671
+ FILE* source = fopen(ctx->temporary, "rb");
672
+ if (!source) { unlink(ctx->temporary); free(ctx); return 500; }
673
+ br_sha256_context hash;
674
+ br_sha256_init(&hash);
675
+ unsigned char chunk[65536];
676
+ for (;;) {
677
+ size_t count = fread(chunk, 1, sizeof(chunk), source);
678
+ if (count) br_sha256_update(&hash, chunk, count);
679
+ if (count < sizeof(chunk)) {
680
+ if (ferror(source)) { fclose(source); unlink(ctx->temporary); free(ctx); return 500; }
681
+ break;
682
+ }
683
+ }
684
+ fclose(source);
685
+ unsigned char digest[32];
686
+ char etag[65];
687
+ br_sha256_out(&hash, digest);
688
+ sb_hex32(digest, etag);
689
+ if (ctx->expected[0] && strcmp(ctx->expected, etag) != 0) {
690
+ unlink(ctx->temporary); free(ctx); return 422;
691
+ }
692
+
693
+ char previous[65] = { 0 };
694
+ sqlite3_stmt* old = 0;
695
+ if (sqlite3_prepare_v2(ctx->db, "SELECT blob_id FROM r2 WHERE bucket=?1 AND key=?2", -1, &old, 0) == 0 && old) {
696
+ sqlite3_bind_text(old, 1, ctx->bucket, -1, SB_SQLITE_TRANSIENT);
697
+ sqlite3_bind_text(old, 2, ctx->key, -1, SB_SQLITE_TRANSIENT);
698
+ if (sqlite3_step(old) == SB_SQLITE_ROW) {
699
+ const unsigned char* value = sqlite3_column_text(old, 0);
700
+ if (value) snprintf(previous, sizeof(previous), "%s", (const char*)value);
701
+ }
702
+ sqlite3_finalize(old);
703
+ }
704
+ char blobpath[1024];
705
+ sb_r2_blob_path(blobpath, sizeof(blobpath), ctx->store, ctx->bucket, ctx->key, etag);
706
+ sb_mkdirs(blobpath);
707
+ if (rename(ctx->temporary, blobpath) != 0) { unlink(ctx->temporary); free(ctx); return 507; }
708
+
709
+ char uploaded[40];
710
+ sb_iso_now(uploaded, sizeof(uploaded));
711
+ sqlite3_stmt* st = 0;
712
+ const char* sql =
713
+ "INSERT INTO r2 (bucket,key,size,etag,uploaded,http_json,custom_json,blob_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) "
714
+ "ON CONFLICT(bucket,key) DO UPDATE SET size=?3,etag=?4,uploaded=?5,http_json=?6,custom_json=?7,blob_id=?8";
715
+ if (sqlite3_prepare_v2(ctx->db, sql, -1, &st, 0) != 0 || !st) {
716
+ if (strcmp(previous, etag) != 0) unlink(blobpath);
717
+ free(ctx); return 500;
718
+ }
719
+ sqlite3_bind_text(st, 1, ctx->bucket, -1, SB_SQLITE_TRANSIENT);
720
+ sqlite3_bind_text(st, 2, ctx->key, -1, SB_SQLITE_TRANSIENT);
721
+ sqlite3_bind_int64(st, 3, (int64_t)ctx->size);
722
+ sqlite3_bind_text(st, 4, etag, -1, SB_SQLITE_TRANSIENT);
723
+ sqlite3_bind_text(st, 5, uploaded, -1, SB_SQLITE_TRANSIENT);
724
+ sqlite3_bind_text(st, 6, ctx->http[0] ? ctx->http : "{}", -1, SB_SQLITE_TRANSIENT);
725
+ sqlite3_bind_text(st, 7, ctx->custom[0] ? ctx->custom : "{}", -1, SB_SQLITE_TRANSIENT);
726
+ sqlite3_bind_text(st, 8, etag, -1, SB_SQLITE_TRANSIENT);
727
+ int rc = sqlite3_step(st);
728
+ sqlite3_finalize(st);
729
+ if (rc != SB_SQLITE_DONE) {
730
+ if (strcmp(previous, etag) != 0) unlink(blobpath);
731
+ free(ctx); return 500;
732
+ }
733
+ if (previous[0] && strcmp(previous, etag) != 0) {
734
+ char oldpath[1024];
735
+ sb_r2_blob_path(oldpath, sizeof(oldpath), ctx->store, ctx->bucket, ctx->key, previous);
736
+ unlink(oldpath);
737
+ }
738
+ free(ctx);
739
+ return 201;
740
+ }
741
+
742
+ void sb_r2_transfer_abort(sb_r2_transfer_ctx* ctx) {
743
+ if (!ctx) return;
744
+ if (ctx->file) fclose(ctx->file);
745
+ if (ctx->temporary[0]) unlink(ctx->temporary);
746
+ free(ctx);
747
+ }
748
+
577
749
  // Run a script: one or more statements separated by semicolons. sqlite3_exec
578
750
  // handles the whole string, which prepare/step does not — it compiles the first
579
751
  // statement and silently ignores the rest, so a schema built from one exec call
@@ -1520,7 +1692,10 @@ function __sbDir() {
1520
1692
  // Porffor's runtime init calls porf_init(0, NULL). Resolved once — the answer
1521
1693
  // cannot change mid-run.
1522
1694
  if (!__sbDataDir) {
1523
- __sbDataDir = __sbEnv("SB_DATA_DIR") || __sbEnv("SPROUTBOAT_DATA") || (globalThis.__sbAppName || "app") + ".data";
1695
+ __sbDataDir =
1696
+ __sbEnv("SB_DATA_DIR") ||
1697
+ __sbEnv("SPROUTBOAT_DATA") ||
1698
+ (globalThis.__sbAppName || "app") + ".data";
1524
1699
  }
1525
1700
  return __sbDataDir;
1526
1701
  }
@@ -1571,11 +1746,22 @@ function __sbEnsureSchema() {
1571
1746
  "part_number INTEGER NOT NULL, size INTEGER NOT NULL, etag TEXT NOT NULL, " +
1572
1747
  "PRIMARY KEY (bucket, key, upload_id, part_number))",
1573
1748
  );
1749
+ __sbSql(
1750
+ s,
1751
+ "CREATE TABLE IF NOT EXISTS r2_transfer (token TEXT PRIMARY KEY, bucket TEXT NOT NULL, key TEXT NOT NULL, " +
1752
+ "method TEXT NOT NULL, expires INTEGER NOT NULL, max_bytes INTEGER NOT NULL, expected_sha256 TEXT NOT NULL DEFAULT '', " +
1753
+ "http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', used INTEGER NOT NULL DEFAULT 0)",
1754
+ );
1574
1755
  const expired = __sbSql(s, "SELECT bucket, key, upload_id FROM r2_upload WHERE uploaded < ?", [
1575
1756
  new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
1576
1757
  ]).rows;
1577
1758
  for (let i = 0; i < expired.length; i++) {
1578
- __sbR2MultipartDeleteRaw(s, String(expired[i][0]), String(expired[i][1]), String(expired[i][2]));
1759
+ __sbR2MultipartDeleteRaw(
1760
+ s,
1761
+ String(expired[i][0]),
1762
+ String(expired[i][1]),
1763
+ String(expired[i][2]),
1764
+ );
1579
1765
  __sbSql(s, "DELETE FROM r2_part WHERE bucket = ? AND key = ? AND upload_id = ?", expired[i]);
1580
1766
  __sbSql(s, "DELETE FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?", expired[i]);
1581
1767
  }
@@ -1624,14 +1810,16 @@ function __sbEmbeddedDispatch(msg) {
1624
1810
 
1625
1811
  if (op === "kv.get") {
1626
1812
  const r = __sbSql(store, "SELECT value FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
1627
- return r.rows.length ? { ok: true, found: true, value: r.rows[0][0] } : { ok: true, found: false, value: null };
1813
+ return r.rows.length
1814
+ ? { ok: true, found: true, value: r.rows[0][0] }
1815
+ : { ok: true, found: false, value: null };
1628
1816
  }
1629
1817
  if (op === "kv.put") {
1630
- __sbSql(store, "INSERT INTO kv (ns, key, value) VALUES (?1,?2,?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3", [
1631
- msg.ns,
1632
- msg.key,
1633
- msg.value,
1634
- ]);
1818
+ __sbSql(
1819
+ store,
1820
+ "INSERT INTO kv (ns, key, value) VALUES (?1,?2,?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3",
1821
+ [msg.ns, msg.key, msg.value],
1822
+ );
1635
1823
  return { ok: true };
1636
1824
  }
1637
1825
  if (op === "kv.delete") {
@@ -1674,12 +1862,26 @@ function __sbEmbeddedDispatch(msg) {
1674
1862
  }
1675
1863
  if (key != null) {
1676
1864
  const info = meta[key] || {};
1677
- return { ok: true, found: true, status: 200, type: info.type, hash: info.hash, body: files[key] };
1865
+ return {
1866
+ ok: true,
1867
+ found: true,
1868
+ status: 200,
1869
+ type: info.type,
1870
+ hash: info.hash,
1871
+ body: files[key],
1872
+ };
1678
1873
  }
1679
1874
  const nfh = (bundle.manifest && bundle.manifest.notFound) || "none";
1680
1875
  if (nfh === "single-page-application" && files["/index.html"] != null) {
1681
1876
  const info = meta["/index.html"] || {};
1682
- return { ok: true, found: true, status: 200, type: info.type, hash: info.hash, body: files["/index.html"] };
1877
+ return {
1878
+ ok: true,
1879
+ found: true,
1880
+ status: 200,
1881
+ type: info.type,
1882
+ hash: info.hash,
1883
+ body: files["/index.html"],
1884
+ };
1683
1885
  }
1684
1886
  if (nfh === "404-page" && files["/404.html"] != null) {
1685
1887
  const info = meta["/404.html"] || {};
@@ -1693,7 +1895,8 @@ function __sbEmbeddedDispatch(msg) {
1693
1895
  const tls = url.protocol === "https:";
1694
1896
  if (!tls && url.protocol !== "http:") throw new Error("unsupported protocol: " + url.protocol);
1695
1897
  const allow = bindingsOutbound();
1696
- if (allow.indexOf(url.host) === -1) throw new Error("host not in outbound allowlist: " + url.host);
1898
+ if (allow.indexOf(url.host) === -1)
1899
+ throw new Error("host not in outbound allowlist: " + url.host);
1697
1900
  let headerText = "";
1698
1901
  const pairs = msg.headers || [];
1699
1902
  for (let i = 0; i < pairs.length; i++) {
@@ -1744,7 +1947,8 @@ function __sbEmbeddedDispatch(msg) {
1744
1947
  if (op === "d1.batch") {
1745
1948
  const results = [];
1746
1949
  const list = msg.statements || [];
1747
- for (let i = 0; i < list.length; i++) results.push(__sbD1Run(path, list[i].sql, list[i].params));
1950
+ for (let i = 0; i < list.length; i++)
1951
+ results.push(__sbD1Run(path, list[i].sql, list[i].params));
1748
1952
  return { ok: true, results };
1749
1953
  }
1750
1954
  const one = __sbD1Run(path, msg.sql, msg.params);
@@ -1783,7 +1987,46 @@ function __sbEmbeddedDispatch(msg) {
1783
1987
  ),
1784
1988
  );
1785
1989
  if (reply.ok === false) throw new Error("r2 put: " + reply.error);
1786
- return { ok: true, object: { key: msg.key, size: reply.size, etag: reply.etag, uploaded: reply.uploaded } };
1990
+ return {
1991
+ ok: true,
1992
+ object: { key: msg.key, size: reply.size, etag: reply.etag, uploaded: reply.uploaded },
1993
+ };
1994
+ }
1995
+ if (op === "r2.transfer.create") {
1996
+ const method = msg.method === "upload" || msg.method === "download" ? msg.method : "";
1997
+ if (!method) throw new Error("transfer method must be upload or download");
1998
+ const expiresIn = Math.min(Math.max(Number(msg.expiresIn) || 900, 30), 3600);
1999
+ const maxBytes =
2000
+ method === "upload"
2001
+ ? Math.min(Math.max(Number(msg.maxBytes) || 100 * 1024 * 1024, 1), 5 * 1024 * 1024 * 1024)
2002
+ : 0;
2003
+ const expectedSha256 = String(msg.sha256 || "").toLowerCase();
2004
+ if (expectedSha256 && !/^[0-9a-f]{64}$/.test(expectedSha256))
2005
+ throw new Error("sha256 must be 64 hexadecimal characters");
2006
+ const expires = Date.now() + expiresIn * 1000;
2007
+ __sbSql(store, "DELETE FROM r2_transfer WHERE expires < ? OR used = 1", [Date.now()]);
2008
+ const token = __sbHex(12);
2009
+ __sbSql(
2010
+ store,
2011
+ "INSERT INTO r2_transfer (token, bucket, key, method, expires, max_bytes, expected_sha256, http_json, custom_json) " +
2012
+ "VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
2013
+ [
2014
+ token,
2015
+ msg.bucket,
2016
+ msg.key,
2017
+ method,
2018
+ expires,
2019
+ maxBytes,
2020
+ expectedSha256,
2021
+ JSON.stringify(msg.httpMetadata || {}),
2022
+ JSON.stringify(msg.customMetadata || {}),
2023
+ ],
2024
+ );
2025
+ return {
2026
+ ok: true,
2027
+ url: "/__sb/r2/transfer/" + encodeURIComponent(String(msg.bucket)) + "/" + token,
2028
+ expiresAt: new Date(expires).toISOString(),
2029
+ };
1787
2030
  }
1788
2031
  if (op === "r2.multipart.create") {
1789
2032
  const uploadId = String(msg.uploadId || __sbHex(16));
@@ -1826,10 +2069,17 @@ function __sbEmbeddedDispatch(msg) {
1826
2069
  throw new Error("uploaded parts must be ordered by partNumber");
1827
2070
  if (i < stored.length - 1 && Number(stored[i][1]) !== Number(stored[0][1]))
1828
2071
  throw new Error("all multipart parts except the last must have the same size");
1829
- if (i === stored.length - 1 && stored.length > 1 && Number(stored[i][1]) > Number(stored[0][1]))
2072
+ if (
2073
+ i === stored.length - 1 &&
2074
+ stored.length > 1 &&
2075
+ Number(stored[i][1]) > Number(stored[0][1])
2076
+ )
1830
2077
  throw new Error("the last multipart part cannot be larger than the others");
1831
2078
  }
1832
- const previous = __sbSql(store, "SELECT blob_id FROM r2 WHERE bucket = ? AND key = ?", [msg.bucket, msg.key]);
2079
+ const previous = __sbSql(store, "SELECT blob_id FROM r2 WHERE bucket = ? AND key = ?", [
2080
+ msg.bucket,
2081
+ msg.key,
2082
+ ]);
1833
2083
  const completed = JSON.parse(
1834
2084
  __sbR2MultipartCompleteRaw(store, String(msg.bucket), String(msg.key), String(msg.uploadId)),
1835
2085
  );
@@ -1841,7 +2091,16 @@ function __sbEmbeddedDispatch(msg) {
1841
2091
  "INSERT INTO r2 (bucket, key, size, etag, uploaded, http_json, custom_json, blob_id) " +
1842
2092
  "VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
1843
2093
  "ON CONFLICT (bucket, key) DO UPDATE SET size=?3, etag=?4, uploaded=?5, http_json=?6, custom_json=?7, blob_id=?8",
1844
- [msg.bucket, msg.key, completed.size, completed.etag, uploaded, upload.rows[0][0], upload.rows[0][1], completed.etag],
2094
+ [
2095
+ msg.bucket,
2096
+ msg.key,
2097
+ completed.size,
2098
+ completed.etag,
2099
+ uploaded,
2100
+ upload.rows[0][0],
2101
+ upload.rows[0][1],
2102
+ completed.etag,
2103
+ ],
1845
2104
  );
1846
2105
  } catch (error) {
1847
2106
  if (!previous.rows.length || String(previous.rows[0][0] || "") !== String(completed.etag))
@@ -1849,7 +2108,12 @@ function __sbEmbeddedDispatch(msg) {
1849
2108
  throw error;
1850
2109
  }
1851
2110
  if (previous.rows.length && String(previous.rows[0][0] || "") !== String(completed.etag))
1852
- __sbR2DeleteRaw(store, String(msg.bucket), String(msg.key), String(previous.rows[0][0] || ""));
2111
+ __sbR2DeleteRaw(
2112
+ store,
2113
+ String(msg.bucket),
2114
+ String(msg.key),
2115
+ String(previous.rows[0][0] || ""),
2116
+ );
1853
2117
  __sbR2MultipartDeleteRaw(store, String(msg.bucket), String(msg.key), String(msg.uploadId));
1854
2118
  __sbSql(store, "DELETE FROM r2_part WHERE bucket = ? AND key = ? AND upload_id = ?", [
1855
2119
  msg.bucket,
@@ -1917,14 +2181,24 @@ function __sbEmbeddedDispatch(msg) {
1917
2181
  customMetadata: JSON.parse(row[4] || "{}"),
1918
2182
  },
1919
2183
  blobId: row[5] || "",
1920
- body: wantsBody ? __sbR2GetRaw(store, String(msg.bucket), String(msg.key), String(row[5] || "")) : undefined,
2184
+ body: wantsBody
2185
+ ? __sbR2GetRaw(store, String(msg.bucket), String(msg.key), String(row[5] || ""))
2186
+ : undefined,
1921
2187
  };
1922
2188
  }
1923
2189
  if (op === "r2.delete") {
1924
- const existing = __sbSql(store, "SELECT blob_id FROM r2 WHERE bucket = ? AND key = ?", [msg.bucket, msg.key]);
2190
+ const existing = __sbSql(store, "SELECT blob_id FROM r2 WHERE bucket = ? AND key = ?", [
2191
+ msg.bucket,
2192
+ msg.key,
2193
+ ]);
1925
2194
  __sbSql(store, "DELETE FROM r2 WHERE bucket = ? AND key = ?", [msg.bucket, msg.key]);
1926
2195
  if (existing.rows.length)
1927
- __sbR2DeleteRaw(store, String(msg.bucket), String(msg.key), String(existing.rows[0][0] || ""));
2196
+ __sbR2DeleteRaw(
2197
+ store,
2198
+ String(msg.bucket),
2199
+ String(msg.key),
2200
+ String(existing.rows[0][0] || ""),
2201
+ );
1928
2202
  return { ok: true };
1929
2203
  }
1930
2204
  if (op === "r2.list") {
@@ -1950,12 +2224,11 @@ function __sbEmbeddedDispatch(msg) {
1950
2224
  if (op === "queue.send" || op === "queue.send_batch") {
1951
2225
  const items = op === "queue.send" ? [msg.body] : msg.messages || [];
1952
2226
  for (let i = 0; i < items.length; i++) {
1953
- __sbSql(store, "INSERT INTO mq (queue, id, body, visible_at, attempts, dead) VALUES (?1,?2,?3,?4,0,0)", [
1954
- msg.queue,
1955
- __sbHex(12),
1956
- String(items[i]),
1957
- Date.now(),
1958
- ]);
2227
+ __sbSql(
2228
+ store,
2229
+ "INSERT INTO mq (queue, id, body, visible_at, attempts, dead) VALUES (?1,?2,?3,?4,0,0)",
2230
+ [msg.queue, __sbHex(12), String(items[i]), Date.now()],
2231
+ );
1959
2232
  }
1960
2233
  return { ok: true };
1961
2234
  }
@@ -1966,7 +2239,9 @@ function __sbEmbeddedDispatch(msg) {
1966
2239
  msg.id,
1967
2240
  msg.key,
1968
2241
  ]);
1969
- return r.rows.length ? { ok: true, found: true, value: r.rows[0][0] } : { ok: true, found: false };
2242
+ return r.rows.length
2243
+ ? { ok: true, found: true, value: r.rows[0][0] }
2244
+ : { ok: true, found: false };
1970
2245
  }
1971
2246
  if (op === "do.storage.put") {
1972
2247
  __sbSql(
@@ -1977,7 +2252,11 @@ function __sbEmbeddedDispatch(msg) {
1977
2252
  return { ok: true };
1978
2253
  }
1979
2254
  if (op === "do.storage.delete") {
1980
- const r = __sbSql(store, "DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?", [msg.cls, msg.id, msg.key]);
2255
+ const r = __sbSql(store, "DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?", [
2256
+ msg.cls,
2257
+ msg.id,
2258
+ msg.key,
2259
+ ]);
1981
2260
  return { ok: true, deleted: r.changes > 0 };
1982
2261
  }
1983
2262
  if (op === "do.storage.delete_all") {
@@ -2014,13 +2293,17 @@ function __sbEmbeddedDispatch(msg) {
2014
2293
  }
2015
2294
 
2016
2295
  if (op === "ae.write") {
2017
- __sbSql(store, "INSERT INTO ae (dataset, ts, indexes_json, blobs_json, doubles_json) VALUES (?1,?2,?3,?4,?5)", [
2018
- msg.dataset,
2019
- Date.now(),
2020
- JSON.stringify(msg.indexes || []),
2021
- JSON.stringify(msg.blobs || []),
2022
- JSON.stringify(msg.doubles || []),
2023
- ]);
2296
+ __sbSql(
2297
+ store,
2298
+ "INSERT INTO ae (dataset, ts, indexes_json, blobs_json, doubles_json) VALUES (?1,?2,?3,?4,?5)",
2299
+ [
2300
+ msg.dataset,
2301
+ Date.now(),
2302
+ JSON.stringify(msg.indexes || []),
2303
+ JSON.stringify(msg.blobs || []),
2304
+ JSON.stringify(msg.doubles || []),
2305
+ ],
2306
+ );
2024
2307
  return { ok: true };
2025
2308
  }
2026
2309
  if (op === "ae.query") {
@@ -2099,7 +2382,9 @@ globalThis.__sbR2Put = function (bucket, key, body, httpMetadata, customMetadata
2099
2382
  ),
2100
2383
  );
2101
2384
  if (reply.ok === false) throw new Error("sproutboat r2.put: " + reply.error);
2102
- return { object: { key: String(key), size: reply.size, etag: reply.etag, uploaded: reply.uploaded } };
2385
+ return {
2386
+ object: { key: String(key), size: reply.size, etag: reply.etag, uploaded: reply.uploaded },
2387
+ };
2103
2388
  };
2104
2389
 
2105
2390
  globalThis.__sbR2Get = function (bucket, key) {
@@ -2121,9 +2406,17 @@ globalThis.__sbR2MultipartPut = function (bucket, key, uploadId, partNumber, bod
2121
2406
  "SELECT 1 FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?",
2122
2407
  [bucket, key, uploadId],
2123
2408
  );
2124
- if (!upload.rows.length) throw new Error("sproutboat r2.multipart.put: multipart upload not found");
2409
+ if (!upload.rows.length)
2410
+ throw new Error("sproutboat r2.multipart.put: multipart upload not found");
2125
2411
  const reply = JSON.parse(
2126
- __sbR2MultipartPutRaw(store, String(bucket), String(key), String(uploadId), String(partNumber), body),
2412
+ __sbR2MultipartPutRaw(
2413
+ store,
2414
+ String(bucket),
2415
+ String(key),
2416
+ String(uploadId),
2417
+ String(partNumber),
2418
+ body,
2419
+ ),
2127
2420
  );
2128
2421
  if (reply.ok === false) throw new Error("sproutboat r2.multipart.put: " + reply.error);
2129
2422
  __sbSql(
@@ -2240,7 +2533,10 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
2240
2533
  if (p && __sbIsFn(p.then)) __sbTasks.push(p);
2241
2534
  },
2242
2535
  };
2243
- const res = handlers.scheduled({ cron: crons[i], scheduledTime: now.getTime(), noRetry() {} }, ctx);
2536
+ const res = handlers.scheduled(
2537
+ { cron: crons[i], scheduledTime: now.getTime(), noRetry() {} },
2538
+ ctx,
2539
+ );
2244
2540
  if (res && __sbIsFn(res.then)) await res;
2245
2541
  if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
2246
2542
  }
@@ -2298,9 +2594,11 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
2298
2594
  setInterval(async function () {
2299
2595
  __sbEnsureSchema();
2300
2596
  const now = Date.now();
2301
- const due = __sbSql(store, "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10", [
2302
- now,
2303
- ]);
2597
+ const due = __sbSql(
2598
+ store,
2599
+ "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10",
2600
+ [now],
2601
+ );
2304
2602
  for (let i = 0; i < due.rows.length; i++) {
2305
2603
  const cls = due.rows[i][0];
2306
2604
  const id = due.rows[i][1];
package/src/wrap.ts CHANGED
@@ -24,7 +24,10 @@ export const preludePath = new URL("./native-fetch-prelude.js", import.meta.url)
24
24
  */
25
25
  export type Transport = "broker" | "embedded";
26
26
  export const transportPath = (transport: Transport): URL =>
27
- new URL(transport === "embedded" ? "./transport-embedded.js" : "./transport-broker.js", import.meta.url);
27
+ new URL(
28
+ transport === "embedded" ? "./transport-embedded.js" : "./transport-broker.js",
29
+ import.meta.url,
30
+ );
28
31
 
29
32
  /** Where the prelude expects its transport spliced in. */
30
33
  export const TRANSPORT_MARKER =
@@ -172,6 +175,8 @@ export function wrapNativeFetchHandler(
172
175
  appName: string = "app",
173
176
  /** #15 — assets baked into the module for a binary that has no files beside it. */
174
177
  assets?: { manifest: unknown; files: Record<string, string> },
178
+ /** The selected transport determines whether the direct-transfer C ABI is real or stubbed. */
179
+ transport: Transport = "broker",
175
180
  ): string {
176
181
  const neutralised = neutraliseExports(source);
177
182
  if (neutralised === null || !/\bfetch\s*\(/.test(source)) {
@@ -188,24 +193,42 @@ export function wrapNativeFetchHandler(
188
193
  // #15 — and enforces the outbound allowlist itself, with no broker to do it.
189
194
  `globalThis.__sbOutbound = ${JSON.stringify(bindings.outbound)};\n` +
190
195
  (assets ? `globalThis.__sbAssets = ${JSON.stringify(assets)};\n` : "");
191
- const wire = hasBindings(bindings) ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n` : "";
196
+ // The native transfer hook runs before JavaScript receives its Request. It
197
+ // has a real file-backed implementation only in embedded builds. The broker
198
+ // transport supplies ABI-compatible stubs because its separate transfer
199
+ // listener owns these URLs, while the shared uWebSockets patch is global.
200
+ const nativeTransferAbi =
201
+ transport === "embedded"
202
+ ? `Porffor.c\`const char* sb_standalone_app_name(void) { return ${JSON.stringify(appName)}; }\`;\n`
203
+ : "Porffor.c`typedef struct sb_r2_transfer_ctx { int unused; } sb_r2_transfer_ctx; int sb_r2_transfer_open(const char* a, const char* b, size_t c, sb_r2_transfer_ctx** d) { if (d) *d = 0; return 404; } int sb_r2_transfer_write(sb_r2_transfer_ctx* a, const char* b, size_t c) { return 404; } int sb_r2_transfer_finish(sb_r2_transfer_ctx* a) { return 404; } void sb_r2_transfer_abort(sb_r2_transfer_ctx* a) {}`;\n";
204
+ const wire = hasBindings(bindings)
205
+ ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n`
206
+ : "";
192
207
  const registerDO = bindings.do.length
193
208
  ? `__sbRegisterDO({ ${bindings.do.map((d) => `${d.className}: ${d.className}`).join(", ")} });\n`
194
209
  : "";
195
210
  // Cron / queue / alarm timers, for a transport that has no broker to deliver
196
211
  // them. The broker transport defines this as a no-op, so the emitted module
197
212
  // is the same either way.
198
- const triggers = hasBindings(bindings) ? `__sbStartLocalTriggers(__sbHandlers, ${JSON.stringify(bindings)});\n` : "";
213
+ const triggers = hasBindings(bindings)
214
+ ? `__sbStartLocalTriggers(__sbHandlers, ${JSON.stringify(bindings)});\n`
215
+ : "";
199
216
 
200
217
  return (
201
- `${prelude}\n${compat}${env}${wire}` +
218
+ `${prelude}\n${nativeTransferAbi}${compat}${env}${wire}` +
202
219
  `${neutralised}\n` +
203
220
  `${registerDO}${triggers}` +
204
221
  `export default {\n port: ${port},\n fetch(request) { return __sbEntry(__sbHandlers, request); }\n};\n`
205
222
  );
206
223
  }
207
224
 
208
- type VarsJson = string | number | boolean | null | { readonly [key: string]: VarsJson } | VarsJson[];
225
+ type VarsJson =
226
+ | string
227
+ | number
228
+ | boolean
229
+ | null
230
+ | { readonly [key: string]: VarsJson }
231
+ | VarsJson[];
209
232
  function isVarsObject(value: VarsJson): value is { readonly [key: string]: VarsJson } {
210
233
  return value !== null && Object(value) === value && !Array.isArray(value);
211
234
  }
@@ -260,7 +283,12 @@ export function readBindingsFromEnv(): Bindings {
260
283
  const ratelimiters: Array<{ binding: string; limit: number; period: number }> = [];
261
284
  if (Array.isArray(parsed.ratelimiters)) {
262
285
  for (const entry of parsed.ratelimiters) {
263
- if (isVarsObject(entry) && isVarsString(entry.binding) && isCount(entry.limit) && isCount(entry.period)) {
286
+ if (
287
+ isVarsObject(entry) &&
288
+ isVarsString(entry.binding) &&
289
+ isCount(entry.limit) &&
290
+ isCount(entry.period)
291
+ ) {
264
292
  ratelimiters.push({ binding: entry.binding, limit: entry.limit, period: entry.period });
265
293
  }
266
294
  }