@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 +16 -0
- package/package.json +1 -1
- package/src/transport-embedded.js +340 -42
- package/src/wrap.ts +34 -6
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
|
@@ -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 =
|
|
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(
|
|
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
|
|
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(
|
|
1631
|
-
|
|
1632
|
-
|
|
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 {
|
|
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 {
|
|
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)
|
|
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++)
|
|
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 {
|
|
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 (
|
|
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 = ?", [
|
|
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
|
-
[
|
|
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(
|
|
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
|
|
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 = ?", [
|
|
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(
|
|
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(
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
String(items[i]),
|
|
1957
|
-
|
|
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
|
|
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 = ?", [
|
|
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(
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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 {
|
|
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)
|
|
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(
|
|
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(
|
|
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(
|
|
2302
|
-
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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 (
|
|
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
|
}
|