@sproutboat/runtime 0.16.0 → 0.17.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.
@@ -25,6 +25,7 @@ Porffor.c`
25
25
  #include <string.h>
26
26
  #include <strings.h>
27
27
  #include <sys/time.h>
28
+ #include <pthread.h>
28
29
  #include <bearssl.h>
29
30
 
30
31
  // sqlite3 is linked in via SB_EXTRA_LINK (see patch-porffor.ts). Declared here
@@ -1112,8 +1113,65 @@ static int sb_egress_refused(const char* host, const struct sockaddr* sa, char*
1112
1113
  }
1113
1114
  // EGRESS:END
1114
1115
 
1116
+ // EGRESS-FD:BEGIN
1117
+ // SB_EGRESS_FD=N: a sandboxed host (sproutd) opens outbound connections for the
1118
+ // sprout, which may not open sockets itself. Ask on unix socket N with
1119
+ // "HOST PORT\n"; the host resolves, applies its policy and answers "ok\n" with the
1120
+ // connected socket attached (SCM_RIGHTS), or "no MESSAGE\n", which fetch() reports.
1121
+ // One question at a time (a lock, for async requests on several threads); any
1122
+ // broken exchange closes N for good, so a late answer can never be taken for the
1123
+ // next one.
1124
+ static int sb_egress_fd_ask(int via, const char* host, int port, char* refused, size_t refused_len);
1125
+ static int sb_egress_fd_connect(int via, const char* host, int port, char* refused, size_t refused_len) {
1126
+ static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
1127
+ pthread_mutex_lock(&lock);
1128
+ int fd = sb_egress_fd_ask(via, host, port, refused, refused_len);
1129
+ pthread_mutex_unlock(&lock);
1130
+ return fd;
1131
+ }
1132
+ static int sb_egress_fd_ask(int via, const char* host, int port, char* refused, size_t refused_len) {
1133
+ static int broken = 0;
1134
+ if (broken) return -1;
1135
+ char line[512];
1136
+ int n = snprintf(line, sizeof(line), "%s %d\n", host, port);
1137
+ if (n <= 0 || (size_t)n >= sizeof(line)) return -1;
1138
+ struct timeval tv; tv.tv_sec = 60; tv.tv_usec = 0;
1139
+ setsockopt(via, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
1140
+ if (send(via, line, (size_t)n, 0) != n) { broken = 1; close(via); return -1; }
1141
+ char answer[512];
1142
+ union { struct cmsghdr align; char space[CMSG_SPACE(sizeof(int))]; } control;
1143
+ struct iovec io = { answer, sizeof(answer) - 1 };
1144
+ struct msghdr msg;
1145
+ memset(&msg, 0, sizeof(msg));
1146
+ msg.msg_iov = &io; msg.msg_iovlen = 1;
1147
+ msg.msg_control = control.space; msg.msg_controllen = sizeof(control.space);
1148
+ ssize_t got;
1149
+ do { got = recvmsg(via, &msg, 0); } while (got < 0 && errno == EINTR);
1150
+ int fd = -1;
1151
+ struct cmsghdr* c = got > 0 ? CMSG_FIRSTHDR(&msg) : 0;
1152
+ if (c && c->cmsg_level == SOL_SOCKET && c->cmsg_type == SCM_RIGHTS && c->cmsg_len == CMSG_LEN(sizeof(int)))
1153
+ memcpy(&fd, CMSG_DATA(c), sizeof(fd));
1154
+ if (got <= 0 || answer[got - 1] != '\n' || (msg.msg_flags & (MSG_TRUNC | MSG_CTRUNC))) {
1155
+ if (fd >= 0) close(fd);
1156
+ broken = 1; close(via);
1157
+ return -1;
1158
+ }
1159
+ answer[got - 1] = 0;
1160
+ if (!strcmp(answer, "ok") && fd >= 0) {
1161
+ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
1162
+ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
1163
+ return fd;
1164
+ }
1165
+ if (fd >= 0) close(fd);
1166
+ if (!strncmp(answer, "no ", 3) && !refused[0]) snprintf(refused, refused_len, "%s", answer + 3);
1167
+ return -1;
1168
+ }
1169
+ // EGRESS-FD:END
1170
+
1115
1171
  // refused gets the first refusal; a caller reports it when nothing connected.
1116
1172
  static int sb_tcp_connect(const char* host, int port, char* refused, size_t refused_len) {
1173
+ const char* via = getenv("SB_EGRESS_FD");
1174
+ if (via && *via) return sb_egress_fd_connect(atoi(via), host, port, refused, refused_len);
1117
1175
  struct addrinfo hints, *res = 0, *it;
1118
1176
  memset(&hints, 0, sizeof(hints));
1119
1177
  hints.ai_family = AF_UNSPEC;
@@ -1151,10 +1209,13 @@ static int sb_tcp_connect(const char* host, int port, char* refused, size_t refu
1151
1209
  }
1152
1210
 
1153
1211
  // body_len, not strlen(body): a binary body can contain NUL bytes (#232).
1154
- static void sb_build_request(sb_buf* req, const char* host, const char* path, const char* method,
1212
+ // Host carries the port unless it is the scheme's default (RFC 9110 7.2).
1213
+ static void sb_build_request(sb_buf* req, const char* host, int port, int default_port, const char* path, const char* method,
1155
1214
  const char* headers, const char* body, size_t body_len) {
1156
1215
  sb_puts(req, method); sb_puts(req, " "); sb_puts(req, path); sb_puts(req, " HTTP/1.1\r\n");
1157
- sb_puts(req, "Host: "); sb_puts(req, host); sb_puts(req, "\r\n");
1216
+ sb_puts(req, "Host: "); sb_puts(req, host);
1217
+ if (port != default_port) { char p[16]; snprintf(p, sizeof(p), ":%d", port); sb_puts(req, p); }
1218
+ sb_puts(req, "\r\n");
1158
1219
  sb_puts(req, "Connection: close\r\n");
1159
1220
  sb_puts(req, "Accept-Encoding: identity\r\n");
1160
1221
  if (headers && *headers) sb_puts(req, headers);
@@ -1268,7 +1329,7 @@ static char* sb_http_plain(const char* host, int port, const char* path, const c
1268
1329
  if (fd < 0) return refused[0] ? sb_error_json(refused, 0) : sb_error_json("could not connect to ", host);
1269
1330
 
1270
1331
  sb_buf req = { 0, 0, 0 };
1271
- sb_build_request(&req, host, path, method, headers, body, body_len);
1332
+ sb_build_request(&req, host, port, 80, path, method, headers, body, body_len);
1272
1333
  size_t sent = 0;
1273
1334
  while (sent < req.len) {
1274
1335
  long n = write(fd, req.p + sent, req.len - sent);
@@ -1473,7 +1534,7 @@ static char* sb_https(const char* host, int port, const char* path, const char*
1473
1534
  br_sslio_init(&ioc, &sc->eng, sb_sock_read, &fd, sb_sock_write, &fd);
1474
1535
 
1475
1536
  sb_buf req = { 0, 0, 0 };
1476
- sb_build_request(&req, host, path, method, headers, body, body_len);
1537
+ sb_build_request(&req, host, port, 443, path, method, headers, body, body_len);
1477
1538
  int wrote = br_sslio_write_all(&ioc, req.p, req.len);
1478
1539
  free(req.p);
1479
1540
  if (wrote != 0) {
@@ -1527,6 +1588,129 @@ static char* sb_http_request(const char* host, int port, const char* path, const
1527
1588
  return tls ? sb_https(host, port, path, method, headers, body, body_len)
1528
1589
  : sb_http_plain(host, port, path, method, headers, body, body_len);
1529
1590
  }
1591
+
1592
+ // ASYNC:BEGIN
1593
+ // Async outbound HTTP for a host with an event loop (sproutd, B18). The host calls
1594
+ // sb_async_enable() and watches the fd it returns. fetch() then queues each request
1595
+ // for a small pool of threads that run the blocking client above, and returns at
1596
+ // once. A finished request is queued and signalled on the fd; the host calls
1597
+ // sb_async_drain(), which fires the JS callback registered under the request's
1598
+ // timer id. Threads touch only their own malloc'd job, never the JS heap. Without
1599
+ // sb_async_enable() (standalone sprouts) nothing here runs.
1600
+ void porf_native_fetch_fire_timer(u32 id);
1601
+ void porf_native_fetch_timer_clear(u32 id);
1602
+ typedef struct sb_async_job {
1603
+ struct sb_async_job* next;
1604
+ u32 id;
1605
+ int port, tls;
1606
+ char *host, *path, *method, *headers, *body, *out;
1607
+ size_t body_len;
1608
+ } sb_async_job;
1609
+ #define SB_ASYNC_THREADS 8
1610
+ static pthread_mutex_t sb_async_lock = PTHREAD_MUTEX_INITIALIZER;
1611
+ static sb_async_job *sb_async_queue = 0, *sb_async_queue_tail = 0, *sb_async_done = 0, *sb_async_current = 0;
1612
+ static int sb_async_pipe[2] = { -1, -1 };
1613
+ static int sb_async_threads = 0;
1614
+
1615
+ int sb_async_enable(void) {
1616
+ if (sb_async_pipe[0] >= 0) return sb_async_pipe[0];
1617
+ if (pipe(sb_async_pipe) != 0) return -1;
1618
+ for (int i = 0; i < 2; i++) {
1619
+ fcntl(sb_async_pipe[i], F_SETFD, FD_CLOEXEC);
1620
+ fcntl(sb_async_pipe[i], F_SETFL, fcntl(sb_async_pipe[i], F_GETFL) | O_NONBLOCK);
1621
+ }
1622
+ return sb_async_pipe[0];
1623
+ }
1624
+ int sb_async_enabled(void) { return sb_async_pipe[0] >= 0; }
1625
+ static void sb_async_free(sb_async_job* j) {
1626
+ free(j->host); free(j->path); free(j->method); free(j->headers); free(j->body); free(j->out); free(j);
1627
+ }
1628
+ // Called with sb_async_lock held.
1629
+ static void sb_async_finish_locked(sb_async_job* j) {
1630
+ j->next = sb_async_done;
1631
+ sb_async_done = j;
1632
+ char one = 1;
1633
+ while (write(sb_async_pipe[1], &one, 1) < 0 && errno == EINTR) {}
1634
+ }
1635
+ static void* sb_async_thread(void* unused) {
1636
+ (void)unused;
1637
+ for (;;) {
1638
+ pthread_mutex_lock(&sb_async_lock);
1639
+ sb_async_job* j = sb_async_queue;
1640
+ if (!j) { sb_async_threads--; pthread_mutex_unlock(&sb_async_lock); return 0; }
1641
+ sb_async_queue = j->next;
1642
+ if (!sb_async_queue) sb_async_queue_tail = 0;
1643
+ pthread_mutex_unlock(&sb_async_lock);
1644
+ j->out = sb_http_request(j->host, j->port, j->path, j->method, j->headers, j->body, j->body_len, j->tls);
1645
+ pthread_mutex_lock(&sb_async_lock);
1646
+ sb_async_finish_locked(j);
1647
+ pthread_mutex_unlock(&sb_async_lock);
1648
+ }
1649
+ }
1650
+ static void sb_async_start(sb_async_job* j) {
1651
+ sb_init_anchors(); // built once, before any thread reads them
1652
+ pthread_mutex_lock(&sb_async_lock);
1653
+ j->next = 0;
1654
+ if (sb_async_queue_tail) sb_async_queue_tail->next = j; else sb_async_queue = j;
1655
+ sb_async_queue_tail = j;
1656
+ int spawn = sb_async_threads < SB_ASYNC_THREADS;
1657
+ if (spawn) sb_async_threads++;
1658
+ pthread_mutex_unlock(&sb_async_lock);
1659
+ if (!spawn) return;
1660
+ pthread_attr_t attr;
1661
+ pthread_attr_init(&attr);
1662
+ pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
1663
+ pthread_attr_setstacksize(&attr, 256 * 1024);
1664
+ pthread_t thread;
1665
+ int failed = pthread_create(&thread, &attr, sb_async_thread, 0);
1666
+ pthread_attr_destroy(&attr);
1667
+ if (!failed) return;
1668
+ // No thread (a pids limit): a running thread takes the queue; with none, fail it.
1669
+ pthread_mutex_lock(&sb_async_lock);
1670
+ if (--sb_async_threads == 0) {
1671
+ while (sb_async_queue) {
1672
+ sb_async_job* q = sb_async_queue;
1673
+ sb_async_queue = q->next;
1674
+ q->out = sb_error_json("could not start a request: ", strerror(failed));
1675
+ sb_async_finish_locked(q);
1676
+ }
1677
+ sb_async_queue_tail = 0;
1678
+ }
1679
+ pthread_mutex_unlock(&sb_async_lock);
1680
+ }
1681
+ // The inline C of __sbHttpRaw and __sbHttpAsyncResult calls these by prototype: Porffor
1682
+ // may emit a function's body before this block.
1683
+ void sb_async_submit(u32 id, int port, int tls, char* host, char* path, char* method, char* headers,
1684
+ char* body, size_t body_len) {
1685
+ sb_async_job* j = (sb_async_job*)calloc(1, sizeof(sb_async_job));
1686
+ if (!j) abort();
1687
+ j->id = id; j->port = port; j->tls = tls;
1688
+ j->host = host; j->path = path; j->method = method; j->headers = headers;
1689
+ j->body = body; j->body_len = body_len;
1690
+ sb_async_start(j);
1691
+ }
1692
+ const char* sb_async_reply(u32 id) {
1693
+ return sb_async_current && sb_async_current->id == id ? sb_async_current->out : 0;
1694
+ }
1695
+ // Host: the fd became readable. Fires each finished request's callback.
1696
+ void sb_async_drain(void) {
1697
+ char buf[64];
1698
+ while (read(sb_async_pipe[0], buf, sizeof(buf)) > 0) {}
1699
+ pthread_mutex_lock(&sb_async_lock);
1700
+ sb_async_job* done = sb_async_done;
1701
+ sb_async_done = 0;
1702
+ pthread_mutex_unlock(&sb_async_lock);
1703
+ while (done) {
1704
+ sb_async_job* j = done;
1705
+ done = j->next;
1706
+ porf_native_fetch_timer_clear(j->id); // the host's placeholder timer
1707
+ sb_async_current = j; // read by __sbHttpAsyncResult in the callback
1708
+ porf_native_fetch_fire_timer(j->id);
1709
+ sb_async_current = 0;
1710
+ sb_async_free(j);
1711
+ }
1712
+ }
1713
+ // ASYNC:END
1530
1714
  `;
1531
1715
 
1532
1716
  // R2 put: the body is its own parameter, so it never gets escaped.
@@ -1810,8 +1994,10 @@ function __sbD1BackupRaw(src, dest) {
1810
1994
  // Outbound HTTP. String params so C can read each directly; the JS side has
1811
1995
  // already split the URL; the connect refuses private addresses. `tlsFlag` is "1" or "0" —
1812
1996
  // a string like the rest, so the marshalling stays uniform.
1997
+ // asyncId > 0 (B18): queue the request under that timer id and return "" at once;
1998
+ // its callback reads the reply with __sbHttpAsyncResult.
1813
1999
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
1814
- function __sbHttpRaw(host, portStr, path, method, headersText, body, tlsFlag) {
2000
+ function __sbHttpRaw(host, portStr, path, method, headersText, body, tlsFlag, asyncId) {
1815
2001
  let res = "";
1816
2002
  // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
1817
2003
  Porffor.c`
@@ -1867,16 +2053,59 @@ function __sbHttpRaw(host, portStr, path, method, headersText, body, tlsFlag) {
1867
2053
  char* __body = (char*)malloc(__bl + 1); memcpy(__body, __b, __bl); __body[__bl] = 0;
1868
2054
  if (__bo) free(__bo);
1869
2055
 
1870
- char* __out = sb_http_request(__host, atoi(__portbuf), __path, __method, __hdrs, __body, __bl, __tls);
1871
- free(__host); free(__path); free(__method); free(__hdrs); free(__body);
1872
- if (__out) {
1873
- res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
1874
- free(__out);
2056
+ const u32 __async = asyncId.type == 1 && asyncId.val > 0 ? (u32)asyncId.val : 0;
2057
+ if (__async) {
2058
+ void sb_async_submit(u32, int, int, char*, char*, char*, char*, char*, size_t);
2059
+ sb_async_submit(__async, atoi(__portbuf), __tls, __host, __path, __method, __hdrs, __body, __bl);
2060
+ } else {
2061
+ char* __out = sb_http_request(__host, atoi(__portbuf), __path, __method, __hdrs, __body, __bl, __tls);
2062
+ free(__host); free(__path); free(__method); free(__hdrs); free(__body);
2063
+ if (__out) {
2064
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
2065
+ free(__out);
2066
+ }
1875
2067
  }
1876
2068
  `;
1877
2069
  return res;
1878
2070
  }
1879
2071
 
2072
+ // The reply of the async request now firing its callback (B18), or "".
2073
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
2074
+ function __sbHttpAsyncResult(asyncId) {
2075
+ let res = "";
2076
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
2077
+ Porffor.c`
2078
+ const char* sb_async_reply(u32);
2079
+ const char* __r = asyncId.type == 1 ? sb_async_reply((u32)asyncId.val) : 0;
2080
+ if (__r) res = porf_box((f64)porf_native_fetch_alloc_bytestring(__r, strlen(__r)), 195);
2081
+ `;
2082
+ return res;
2083
+ }
2084
+
2085
+ // Whether the host runs async requests (sb_async_enable was called).
2086
+ function __sbHttpAsyncEnabled() {
2087
+ let res = false;
2088
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
2089
+ Porffor.c`
2090
+ int sb_async_enabled(void);
2091
+ if (sb_async_enabled()) res = porf_box(1, 2);
2092
+ `;
2093
+ return res;
2094
+ }
2095
+
2096
+ // The outbound client's JSON reply -> the fetch op's result.
2097
+ function __sbFetchReply(raw) {
2098
+ const reply = JSON.parse(raw);
2099
+ if (reply.ok === false) throw new Error(reply.error);
2100
+ // A TLS peer that closed without close_notify is normal on Connection:
2101
+ // close, and also what a truncation attack looks like. Accept it only when
2102
+ // Content-Length says the body arrived whole.
2103
+ if (reply.unclean && !reply.complete) {
2104
+ throw new Error("connection closed before the response was complete");
2105
+ }
2106
+ return { ok: true, status: reply.status, headers: reply.headers, body: reply.body };
2107
+ }
2108
+
1880
2109
  // One statement in, one JSON reply out. `path`, `sql` and `paramsJson` are
1881
2110
  // parameters so the generated C names them directly.
1882
2111
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
@@ -1954,7 +2183,8 @@ function __sbEnsureSchema() {
1954
2183
  );
1955
2184
  const kvColumns = __sbSql(s, "PRAGMA table_info(kv)", []).rows;
1956
2185
  let hasKvExpiresAt = false;
1957
- for (let i = 0; i < kvColumns.length; i++) if (kvColumns[i][1] === "expires_at") hasKvExpiresAt = true;
2186
+ for (let i = 0; i < kvColumns.length; i++)
2187
+ if (kvColumns[i][1] === "expires_at") hasKvExpiresAt = true;
1958
2188
  if (!hasKvExpiresAt) __sbSql(s, "ALTER TABLE kv ADD COLUMN expires_at INTEGER", []);
1959
2189
  // #233 — 1 when the value was put as bytes, same column as the broker's.
1960
2190
  let hasKvBinary = false;
@@ -2011,6 +2241,10 @@ function __sbEnsureSchema() {
2011
2241
  );
2012
2242
  // Additive: old standalone store.sqlite files gain the poll index on open.
2013
2243
  __sbSql(s, "CREATE INDEX IF NOT EXISTS mq_due ON mq (queue, dead, visible_at)", []);
2244
+ const mqColumns = __sbSql(s, "PRAGMA table_info(mq)", []).rows;
2245
+ let mqClaim = false;
2246
+ for (let i = 0; i < mqColumns.length; i++) if (mqColumns[i][1] === "claim_token") mqClaim = true;
2247
+ if (!mqClaim) __sbSql(s, "ALTER TABLE mq ADD COLUMN claim_token TEXT NOT NULL DEFAULT ''", []);
2014
2248
  __sbSql(
2015
2249
  s,
2016
2250
  "CREATE TABLE IF NOT EXISTS do_storage (cls TEXT NOT NULL, id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (cls, id, key))",
@@ -2020,6 +2254,11 @@ function __sbEnsureSchema() {
2020
2254
  "CREATE TABLE IF NOT EXISTS do_alarm (cls TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (cls, id))",
2021
2255
  );
2022
2256
  __sbSql(s, "CREATE INDEX IF NOT EXISTS do_alarm_due ON do_alarm (at)", []);
2257
+ __sbSql(
2258
+ s,
2259
+ "CREATE TABLE IF NOT EXISTS do_alarm_delivery (cls TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL, attempts INTEGER NOT NULL, token TEXT NOT NULL, expires INTEGER NOT NULL, PRIMARY KEY(cls,id))",
2260
+ [],
2261
+ );
2023
2262
  __sbSql(
2024
2263
  s,
2025
2264
  "CREATE TABLE IF NOT EXISTS ae (dataset TEXT NOT NULL, ts INTEGER NOT NULL, indexes_json TEXT NOT NULL, blobs_json TEXT NOT NULL, doubles_json TEXT NOT NULL)",
@@ -2053,7 +2292,10 @@ function __sbEmbeddedDispatch(msg) {
2053
2292
  if (op === "ping") return { ok: true, op: "pong", echo: msg.msg };
2054
2293
 
2055
2294
  if (op === "kv.get") {
2056
- const r = __sbSql(store, "SELECT value, expires_at, binary FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
2295
+ const r = __sbSql(store, "SELECT value, expires_at, binary FROM kv WHERE ns = ? AND key = ?", [
2296
+ msg.ns,
2297
+ msg.key,
2298
+ ]);
2057
2299
  if (!r.rows.length) return { ok: true, found: false, value: null };
2058
2300
  const expiresAt = r.rows[0][1];
2059
2301
  if (expiresAt != null && expiresAt <= Date.now()) {
@@ -2104,6 +2346,11 @@ function __sbEmbeddedDispatch(msg) {
2104
2346
  const meta = (bundle.manifest && bundle.manifest.files) || {};
2105
2347
  let path = String(msg.path || "/");
2106
2348
  if (path.charAt(0) !== "/") path = "/" + path;
2349
+ const redirects = (bundle.manifest && bundle.manifest.redirects) || [];
2350
+ for (let i = 0; i < redirects.length; i++) {
2351
+ const location = __sbAssetRedirect(redirects[i], path);
2352
+ if (location !== null) return { ok: true, found: false, status: redirects[i].status, location };
2353
+ }
2107
2354
 
2108
2355
  let key = null;
2109
2356
  if (path.charAt(path.length - 1) === "/") {
@@ -2161,25 +2408,20 @@ function __sbEmbeddedDispatch(msg) {
2161
2408
  headerText += pairs[i][0] + ": " + pairs[i][1] + "\r\n";
2162
2409
  }
2163
2410
  const port = url.port ? url.port : tls ? "443" : "80";
2164
- const reply = JSON.parse(
2165
- __sbHttpRaw(
2166
- url.hostname,
2167
- port,
2168
- url.pathname + url.search,
2169
- String(msg.method || "GET").toUpperCase(),
2170
- headerText,
2171
- msg.body == null ? "" : String(msg.body),
2172
- (tls ? "1" : "0") + (msg.bodyBinary === true ? "b" : ""),
2173
- ),
2411
+ const asyncId = msg.asyncId > 0 ? msg.asyncId : 0;
2412
+ const raw = __sbHttpRaw(
2413
+ url.hostname,
2414
+ port,
2415
+ url.pathname + url.search,
2416
+ String(msg.method || "GET").toUpperCase(),
2417
+ headerText,
2418
+ msg.body == null ? "" : String(msg.body),
2419
+ (tls ? "1" : "0") + (msg.bodyBinary === true ? "b" : ""),
2420
+ asyncId,
2174
2421
  );
2175
- if (reply.ok === false) throw new Error(reply.error);
2176
- // A TLS peer that closed without close_notify is normal on Connection:
2177
- // close, and also what a truncation attack looks like. Accept it only when
2178
- // Content-Length says the body arrived whole.
2179
- if (reply.unclean && !reply.complete) {
2180
- throw new Error("connection closed before the response was complete");
2181
- }
2182
- return { ok: true, status: reply.status, headers: reply.headers, body: reply.body };
2422
+ // B18: queued; the reply reaches the callback registered under asyncId.
2423
+ if (asyncId) return { ok: true, pending: true };
2424
+ return __sbFetchReply(raw);
2183
2425
  }
2184
2426
 
2185
2427
  if (op === "secret.get") {
@@ -2531,20 +2773,26 @@ function __sbEmbeddedDispatch(msg) {
2531
2773
  return { ok: true, entries };
2532
2774
  }
2533
2775
  if (op === "do.alarm.set") {
2534
- __sbSql(
2535
- store,
2536
- "INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,0) ON CONFLICT (cls, id) DO UPDATE SET at = ?3, attempts = 0",
2537
- [msg.cls, msg.id, Math.trunc(Number(msg.at) || 0)],
2538
- );
2539
- return { ok: true };
2776
+ __sbSql(store,"BEGIN IMMEDIATE",[]);
2777
+ try {
2778
+ __sbSql(store,"DELETE FROM do_alarm_delivery WHERE cls=? AND id=? AND expires<=?",[msg.cls,msg.id,Date.now()]);
2779
+ __sbSql(store,"INSERT INTO do_alarm (cls,id,at,attempts) VALUES (?1,?2,?3,0) ON CONFLICT (cls,id) DO UPDATE SET at=?3,attempts=0",[msg.cls,msg.id,Math.trunc(Number(msg.at)||0)]);
2780
+ __sbSql(store,"COMMIT",[]);
2781
+ } catch (error) {__sbSql(store,"ROLLBACK",[]);throw error;}
2782
+ return {ok:true};
2540
2783
  }
2541
2784
  if (op === "do.alarm.get") {
2542
- const r = __sbSql(store, "SELECT at FROM do_alarm WHERE cls = ? AND id = ?", [msg.cls, msg.id]);
2785
+ const r = __sbSql(store, "SELECT at FROM do_alarm WHERE cls=?1 AND id=?2 UNION ALL SELECT at FROM do_alarm_delivery WHERE cls=?1 AND id=?2 AND expires<=?3 AND attempts<5 LIMIT 1", [msg.cls,msg.id,Date.now()+5000]);
2543
2786
  return { ok: true, at: r.rows.length ? Number(r.rows[0][0]) : null };
2544
2787
  }
2545
2788
  if (op === "do.alarm.delete") {
2546
- const r = __sbSql(store, "DELETE FROM do_alarm WHERE cls = ? AND id = ?", [msg.cls, msg.id]);
2547
- return { ok: true, deleted: r.changes > 0 };
2789
+ __sbSql(store,"BEGIN IMMEDIATE",[]);
2790
+ try {
2791
+ const retry=__sbSql(store,"DELETE FROM do_alarm_delivery WHERE cls=? AND id=?",[msg.cls,msg.id]);
2792
+ const pending=__sbSql(store,"DELETE FROM do_alarm WHERE cls=? AND id=?",[msg.cls,msg.id]);
2793
+ __sbSql(store,"COMMIT",[]);
2794
+ return {ok:true,deleted:Number(retry.changes)+Number(pending.changes)>0};
2795
+ } catch (error) {__sbSql(store,"ROLLBACK",[]);throw error;}
2548
2796
  }
2549
2797
 
2550
2798
  if (op === "ae.write") {
@@ -2604,10 +2852,11 @@ function __sbEmbeddedDispatch(msg) {
2604
2852
 
2605
2853
  if (op === "cache.match") {
2606
2854
  const name = msg.name || "default";
2607
- const r = __sbSql(store, "SELECT status, headers_json, body, expires_at FROM cache WHERE name = ? AND cache_key = ?", [
2608
- name,
2609
- msg.key,
2610
- ]);
2855
+ const r = __sbSql(
2856
+ store,
2857
+ "SELECT status, headers_json, body, expires_at FROM cache WHERE name = ? AND cache_key = ?",
2858
+ [name, msg.key],
2859
+ );
2611
2860
  if (!r.rows.length) return { ok: true, found: false };
2612
2861
  const [status, headersJson, body, expiresAt] = r.rows[0];
2613
2862
  if (expiresAt != null && expiresAt <= Date.now()) {
@@ -2620,10 +2869,16 @@ function __sbEmbeddedDispatch(msg) {
2620
2869
  const name = msg.name || "default";
2621
2870
  const status = Number(msg.status) || 200;
2622
2871
  const headers = msg.headers || {};
2623
- const cacheControl = String(headers["cache-control"] || headers["Cache-Control"] || "").toLowerCase();
2872
+ const cacheControl = String(
2873
+ headers["cache-control"] || headers["Cache-Control"] || "",
2874
+ ).toLowerCase();
2624
2875
  // Mirrors Workers' own caches.default.put(): a 206 or an explicit
2625
2876
  // no-store/private is a silent no-op, not an error.
2626
- if (status === 206 || cacheControl.indexOf("no-store") !== -1 || cacheControl.indexOf("private") !== -1) {
2877
+ if (
2878
+ status === 206 ||
2879
+ cacheControl.indexOf("no-store") !== -1 ||
2880
+ cacheControl.indexOf("private") !== -1
2881
+ ) {
2627
2882
  return { ok: true, stored: false };
2628
2883
  }
2629
2884
  const sMaxAge = /s-maxage=(\d+)/.exec(cacheControl);
@@ -2731,7 +2986,10 @@ globalThis.__sbFetchUpstream = function (op, msg) {
2731
2986
  };
2732
2987
 
2733
2988
  globalThis.__sbAssetsGet = function (path) {
2734
- return __sbEmbeddedDispatch({ op: "assets.get", path });
2989
+ const reply = __sbEmbeddedDispatch({ op: "assets.get", path });
2990
+ const manifest = globalThis.__sbAssets.manifest || {};
2991
+ reply.rules = { headers: manifest.headers || [], redirects: manifest.redirects || [] };
2992
+ return reply;
2735
2993
  };
2736
2994
 
2737
2995
  /** The transport contract: one request string in, one reply string out. */
@@ -2852,17 +3110,36 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
2852
3110
  const now = Date.now();
2853
3111
  for (let q = 0; q < queues.length; q++) {
2854
3112
  const name = queues[q];
2855
- const due = __sbSql(
2856
- store,
2857
- "SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT 10",
2858
- [name, now],
2859
- );
3113
+ const claim = String(now) + ":" + String(Math.random());
3114
+ __sbSql(store, "BEGIN IMMEDIATE", []);
3115
+ let due;
3116
+ try {
3117
+ __sbSql(store, "UPDATE mq SET dead=1 WHERE queue=? AND visible_at<=? AND attempts>=5", [
3118
+ name,
3119
+ now,
3120
+ ]);
3121
+ due = __sbSql(
3122
+ store,
3123
+ "SELECT id,body,attempts FROM mq WHERE queue=? AND dead=0 AND visible_at<=? ORDER BY visible_at LIMIT 10",
3124
+ [name, now],
3125
+ );
3126
+ for (let i = 0; i < due.rows.length; i++)
3127
+ __sbSql(
3128
+ store,
3129
+ "UPDATE mq SET visible_at=?,claim_token=?,attempts=attempts+1 WHERE queue=? AND id=?",
3130
+ [now + 30000, claim, name, due.rows[i][0]],
3131
+ );
3132
+ __sbSql(store, "COMMIT", []);
3133
+ } catch (error) {
3134
+ __sbSql(store, "ROLLBACK", []);
3135
+ throw error;
3136
+ }
2860
3137
  if (due.rows.length === 0) continue;
2861
3138
  // Hide the batch first, so a slow handler cannot have it delivered twice.
2862
3139
  const messages = [];
2863
3140
  for (let i = 0; i < due.rows.length; i++) {
2864
3141
  const row = due.rows[i];
2865
- __sbSql(store, "UPDATE mq SET visible_at = ? WHERE id = ?", [now + 30000, row[0]]);
3142
+
2866
3143
  messages.push({ id: row[0], body: row[1], timestamp: now, attempts: Number(row[2]) + 1 });
2867
3144
  }
2868
3145
  // #171 — same ctx.waitUntil as the broker-delivered path, and the same
@@ -2879,13 +3156,17 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
2879
3156
  if (result && __sbIsFn(result.then)) result = await result;
2880
3157
  if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
2881
3158
  for (let i = 0; i < result.ack.length; i++) {
2882
- __sbSql(store, "DELETE FROM mq WHERE id = ?", [result.ack[i]]);
3159
+ __sbSql(
3160
+ store,
3161
+ "DELETE FROM mq WHERE queue=? AND id=? AND claim_token=? AND visible_at>?",
3162
+ [name, result.ack[i], claim, Date.now()],
3163
+ );
2883
3164
  }
2884
3165
  for (let i = 0; i < result.retry.length; i++) {
2885
3166
  __sbSql(
2886
3167
  store,
2887
- "UPDATE mq SET attempts = attempts + 1, visible_at = ?, dead = CASE WHEN attempts + 1 >= 5 THEN 1 ELSE 0 END WHERE id = ?",
2888
- [Date.now() + 5000, result.retry[i]],
3168
+ "UPDATE mq SET visible_at=?,claim_token='',dead=CASE WHEN attempts>=5 THEN 1 ELSE 0 END WHERE queue=? AND id=? AND claim_token=? AND visible_at>?",
3169
+ [Date.now() + 5000, name, result.retry[i], claim, Date.now()],
2889
3170
  );
2890
3171
  }
2891
3172
  }
@@ -2896,25 +3177,76 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
2896
3177
  setInterval(async function () {
2897
3178
  __sbEnsureSchema();
2898
3179
  const now = Date.now();
2899
- const due = __sbSql(
2900
- store,
2901
- "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10",
2902
- [now],
2903
- );
3180
+ __sbSql(store, "BEGIN IMMEDIATE", []);
3181
+ let due;
3182
+ const claim = String(now) + ":" + String(Math.random());
3183
+ try {
3184
+ __sbSql(store,"DELETE FROM do_alarm_delivery WHERE expires<=? AND attempts>=5 AND EXISTS (SELECT 1 FROM do_alarm a WHERE a.cls=do_alarm_delivery.cls AND a.id=do_alarm_delivery.id)",[now]);
3185
+ const pending = __sbSql(
3186
+ store,
3187
+ "SELECT cls,id,at,attempts FROM do_alarm WHERE at<=? AND NOT EXISTS (SELECT 1 FROM do_alarm_delivery d WHERE d.cls=do_alarm.cls AND d.id=do_alarm.id) ORDER BY at LIMIT 10",
3188
+ [now],
3189
+ );
3190
+ for (let i = 0; i < pending.rows.length; i++) {
3191
+ const r = pending.rows[i];
3192
+ __sbSql(store, "INSERT INTO do_alarm_delivery VALUES (?,?,?,?,?,?)", [
3193
+ r[0],
3194
+ r[1],
3195
+ r[2],
3196
+ r[3],
3197
+ "",
3198
+ 0,
3199
+ ]);
3200
+ __sbSql(store, "DELETE FROM do_alarm WHERE cls=? AND id=? AND at=?", [r[0], r[1], r[2]]);
3201
+ }
3202
+ due = __sbSql(
3203
+ store,
3204
+ "SELECT cls,id,at,attempts FROM do_alarm_delivery WHERE expires<=? AND attempts<5 LIMIT 10",
3205
+ [now],
3206
+ );
3207
+ for (let i = 0; i < due.rows.length; i++)
3208
+ __sbSql(
3209
+ store,
3210
+ "UPDATE do_alarm_delivery SET token=?,expires=?,attempts=attempts+1 WHERE cls=? AND id=?",
3211
+ [claim, now + 30000, due.rows[i][0], due.rows[i][1]],
3212
+ );
3213
+ __sbSql(store, "COMMIT", []);
3214
+ } catch (error) {
3215
+ __sbSql(store, "ROLLBACK", []);
3216
+ throw error;
3217
+ }
2904
3218
  for (let i = 0; i < due.rows.length; i++) {
2905
3219
  const cls = due.rows[i][0];
2906
3220
  const id = due.rows[i][1];
2907
- // Claim before running: alarm() may schedule the next one, and deleting
2908
- // afterwards would erase it. Same rule as the broker (#125).
2909
- __sbSql(store, "DELETE FROM do_alarm WHERE cls = ? AND id = ?", [cls, id]);
2910
- const instance = __sbGetDOInstance(cls, id);
2911
- if (__sbIsFn(instance.alarm)) {
2912
- // #57 gap: this local path bypasses `__sbEntry`'s alarm dispatch
2913
- // entirely, so it kept the old fire-and-forget behaviour after that
2914
- // fix landed. Same treatment: await alarm() and drain its waitUntil.
2915
- const res = instance.alarm();
2916
- if (res && __sbIsFn(res.then)) await res;
2917
- if (instance.__sbTasks.length) await __sbDrainWaitUntil(instance.__sbTasks);
3221
+ try {
3222
+ const instance = __sbGetDOInstance(cls, id);
3223
+ if (__sbIsFn(instance.alarm)) {
3224
+ const res = instance.alarm();
3225
+ if (res && __sbIsFn(res.then)) await res;
3226
+ if (instance.__sbTasks.length) await __sbDrainWaitUntil(instance.__sbTasks);
3227
+ }
3228
+ __sbSql(
3229
+ store,
3230
+ "DELETE FROM do_alarm_delivery WHERE cls=? AND id=? AND token=? AND expires>?",
3231
+ [cls, id, claim, Date.now()],
3232
+ );
3233
+ } catch {
3234
+ const scheduled = __sbSql(store, "SELECT 1 FROM do_alarm WHERE cls=? AND id=?", [
3235
+ cls,
3236
+ id,
3237
+ ]);
3238
+ if (scheduled.rows.length)
3239
+ __sbSql(
3240
+ store,
3241
+ "DELETE FROM do_alarm_delivery WHERE cls=? AND id=? AND token=? AND expires>?",
3242
+ [cls, id, claim, Date.now()],
3243
+ );
3244
+ else
3245
+ __sbSql(
3246
+ store,
3247
+ "UPDATE do_alarm_delivery SET expires=? WHERE cls=? AND id=? AND token=? AND expires>?",
3248
+ [Date.now() + 5000, cls, id, claim, Date.now()],
3249
+ );
2918
3250
  }
2919
3251
  }
2920
3252
  }, 500);