@sproutboat/runtime 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1574 @@
1
+ /**
2
+ * The embedded transport: SQLite compiled into the sprout, no broker at all.
3
+ *
4
+ * Same contract as transport-broker.js — `__sbCall(reqJson) -> replyJson` — so
5
+ * every binding shim above it is unchanged and the conformance suite is the
6
+ * proof that the swap changed no behaviour.
7
+ *
8
+ * The split is deliberate: C does only what JS cannot (open a database, bind
9
+ * parameters, step a statement, encode a row), and every op — which SQL, which
10
+ * partition key, what shape comes back — stays in JS, mirroring broker.ts. That
11
+ * keeps the second implementation of the binding ops as small as it can be,
12
+ * which is the whole worry with having one at all.
13
+ *
14
+ * Paths come from __sbEnv: the launcher-free binary is told its data directory
15
+ * through SB_DATA_DIR, the same way it learns its port.
16
+ */
17
+
18
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
19
+ Porffor.c`
20
+ #include <stdint.h>
21
+ #include <sys/stat.h>
22
+ #include <sys/socket.h>
23
+ #include <netdb.h>
24
+ #include <strings.h>
25
+ #include <sys/time.h>
26
+ #include <bearssl.h>
27
+
28
+ // sqlite3 is linked in via SB_EXTRA_LINK (see patch-porffor.ts). Declared here
29
+ // rather than including sqlite3.h so the build needs no include path.
30
+ typedef struct sqlite3 sqlite3;
31
+ typedef struct sqlite3_stmt sqlite3_stmt;
32
+ extern int sqlite3_open(const char*, sqlite3**);
33
+ extern int sqlite3_exec(sqlite3*, const char*, void*, void*, char**);
34
+ extern int sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const char**);
35
+ extern int sqlite3_step(sqlite3_stmt*);
36
+ extern int sqlite3_finalize(sqlite3_stmt*);
37
+ extern int sqlite3_reset(sqlite3_stmt*);
38
+ extern int sqlite3_column_count(sqlite3_stmt*);
39
+ extern int sqlite3_column_type(sqlite3_stmt*, int);
40
+ extern const unsigned char* sqlite3_column_text(sqlite3_stmt*, int);
41
+ extern int sqlite3_column_bytes(sqlite3_stmt*, int);
42
+ extern double sqlite3_column_double(sqlite3_stmt*, int);
43
+ extern const char* sqlite3_column_name(sqlite3_stmt*, int);
44
+ extern int sqlite3_bind_null(sqlite3_stmt*, int);
45
+ extern int sqlite3_bind_double(sqlite3_stmt*, int, double);
46
+ extern int sqlite3_bind_text(sqlite3_stmt*, int, const char*, int, void*);
47
+ extern int sqlite3_bind_blob(sqlite3_stmt*, int, const void*, int, void*);
48
+ extern int sqlite3_bind_int64(sqlite3_stmt*, int, int64_t);
49
+ extern const void* sqlite3_column_blob(sqlite3_stmt*, int);
50
+ extern int sqlite3_changes(sqlite3*);
51
+ extern int64_t sqlite3_last_insert_rowid(sqlite3*);
52
+ extern const char* sqlite3_errmsg(sqlite3*);
53
+
54
+ #define SB_SQLITE_ROW 100
55
+ #define SB_SQLITE_DONE 101
56
+ #define SB_SQLITE_TRANSIENT ((void*)-1)
57
+ // SQLITE_STATIC: "this buffer is yours to read and outlives the statement".
58
+ // Worth using for an object body — the alternative is sqlite copying it.
59
+ #define SB_SQLITE_STATIC ((void*)0)
60
+ #define SB_MAX_DB 16
61
+
62
+ static sqlite3* sb_dbs[SB_MAX_DB];
63
+ static char sb_db_names[SB_MAX_DB][256];
64
+ static int sb_db_count = 0;
65
+
66
+ // One handle per path, opened once and kept for the process lifetime — the same
67
+ // lifetime the broker gives a Database. Returns an index, or -1.
68
+ // Create every parent directory of path, like mkdir -p. A standalone binary
69
+ // has no launcher to prepare its data directory, and sqlite3_open creates files
70
+ // but never the directories above them.
71
+ static void sb_mkdirs(const char* path) {
72
+ char tmp[1024];
73
+ size_t n = strlen(path);
74
+ if (n == 0 || n >= sizeof(tmp)) return;
75
+ memcpy(tmp, path, n + 1);
76
+ for (char* p = tmp + 1; *p; p++) {
77
+ if (*p != '/') continue;
78
+ *p = 0;
79
+ mkdir(tmp, 0700);
80
+ *p = '/';
81
+ }
82
+ }
83
+
84
+ static int sb_db_for(const char* path) {
85
+ for (int i = 0; i < sb_db_count; i++) {
86
+ if (strcmp(sb_db_names[i], path) == 0) return i;
87
+ }
88
+ if (sb_db_count >= SB_MAX_DB) return -1;
89
+ sqlite3* db = 0;
90
+ sb_mkdirs(path);
91
+ if (sqlite3_open(path, &db) != 0) return -1;
92
+ // No mmap_size on purpose: mapping the database makes every page a read
93
+ // touches count toward RSS, and measured on Linux that cost more than the
94
+ // copy it saves once the file holds a few large objects. The page cache is
95
+ // capped instead, and journal_size_limit stops the WAL staying huge after one
96
+ // big write.
97
+ sqlite3_exec(db,
98
+ "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;"
99
+ "PRAGMA cache_size=-2000; PRAGMA journal_size_limit=16777216;",
100
+ 0, 0, 0);
101
+ sb_dbs[sb_db_count] = db;
102
+ snprintf(sb_db_names[sb_db_count], 256, "%s", path);
103
+ return sb_db_count++;
104
+ }
105
+
106
+ // --- a growable output buffer, for building the reply JSON ------------------
107
+ typedef struct { char* p; size_t len; size_t cap; } sb_buf;
108
+ static void sb_buf_need(sb_buf* b, size_t extra) {
109
+ if (b->len + extra + 1 <= b->cap) return;
110
+ size_t cap = b->cap ? b->cap * 2 : 1024;
111
+ while (cap < b->len + extra + 1) cap *= 2;
112
+ b->p = (char*)realloc(b->p, cap);
113
+ b->cap = cap;
114
+ }
115
+ static void sb_put(sb_buf* b, const char* s, size_t n) {
116
+ sb_buf_need(b, n);
117
+ memcpy(b->p + b->len, s, n);
118
+ b->len += n;
119
+ b->p[b->len] = 0;
120
+ }
121
+ static void sb_puts(sb_buf* b, const char* s) { sb_put(b, s, strlen(s)); }
122
+ static void sb_putjson(sb_buf* b, const char* s, size_t n) {
123
+ sb_put(b, "\"", 1);
124
+ for (size_t i = 0; i < n; i++) {
125
+ unsigned char c = (unsigned char)s[i];
126
+ if (c == '"' || c == '\\') { char e[2] = { '\\', (char)c }; sb_put(b, e, 2); }
127
+ else if (c == '\n') sb_put(b, "\\n", 2);
128
+ else if (c == '\r') sb_put(b, "\\r", 2);
129
+ else if (c == '\t') sb_put(b, "\\t", 2);
130
+ else if (c < 0x20) { char e[8]; int k = snprintf(e, 8, "\\u%04x", c); sb_put(b, e, (size_t)k); }
131
+ else sb_put(b, (const char*)&c, 1);
132
+ }
133
+ sb_put(b, "\"", 1);
134
+ }
135
+
136
+ // --- the narrow JSON reader: a flat array of null | number | string ----------
137
+ // Only ever fed __sbSql's own params, which JS builds; anything unexpected
138
+ // binds as NULL rather than guessing.
139
+ static const char* sb_skip_ws(const char* p) { while (*p == ' ' || *p == '\n' || *p == '\t' || *p == '\r') p++; return p; }
140
+
141
+ static int sb_bind_params(sqlite3_stmt* st, const char* json) {
142
+ const char* p = sb_skip_ws(json);
143
+ if (*p != '[') return 0;
144
+ p++;
145
+ int index = 1;
146
+ while (1) {
147
+ p = sb_skip_ws(p);
148
+ if (*p == ']' || *p == 0) break;
149
+ if (*p == ',') { p++; continue; }
150
+ if (*p == 'n') { sqlite3_bind_null(st, index++); p += 4; continue; }
151
+ if (*p == '"') {
152
+ p++;
153
+ char* out = (char*)malloc(strlen(p) + 1);
154
+ size_t n = 0;
155
+ while (*p && *p != '"') {
156
+ if (*p == '\\' && p[1]) {
157
+ p++;
158
+ char c = *p++;
159
+ if (c == 'n') out[n++] = '\n';
160
+ else if (c == 't') out[n++] = '\t';
161
+ else if (c == 'r') out[n++] = '\r';
162
+ // \b and \f are the two escapes JSON.stringify emits that are easy to
163
+ // forget. Without them the fallthrough below writes the letter, so a
164
+ // stored byte 0x08 came back as 'b' and 0x0c as 'f' — the only two
165
+ // values in 0..255 that R2 could not round-trip.
166
+ else if (c == 'b') out[n++] = 8;
167
+ else if (c == 'f') out[n++] = 12;
168
+ else if (c == 'u') {
169
+ unsigned int cp = 0;
170
+ for (int k = 0; k < 4 && *p; k++) {
171
+ char h = *p++;
172
+ cp = cp * 16 + (unsigned int)(h >= 'a' ? h - 'a' + 10 : (h >= 'A' ? h - 'A' + 10 : h - '0'));
173
+ }
174
+ // Encode as UTF-8; surrogate halves are passed through as-is.
175
+ if (cp < 0x80) out[n++] = (char)cp;
176
+ else if (cp < 0x800) { out[n++] = (char)(0xC0 | (cp >> 6)); out[n++] = (char)(0x80 | (cp & 0x3F)); }
177
+ else { out[n++] = (char)(0xE0 | (cp >> 12)); out[n++] = (char)(0x80 | ((cp >> 6) & 0x3F)); out[n++] = (char)(0x80 | (cp & 0x3F)); }
178
+ } else out[n++] = c;
179
+ } else out[n++] = *p++;
180
+ }
181
+ if (*p == '"') p++;
182
+ sqlite3_bind_text(st, index++, out, (int)n, SB_SQLITE_TRANSIENT);
183
+ free(out);
184
+ continue;
185
+ }
186
+ // number (or true/false, bound as 1/0)
187
+ if (*p == 't') { sqlite3_bind_double(st, index++, 1); p += 4; continue; }
188
+ if (*p == 'f') { sqlite3_bind_double(st, index++, 0); p += 5; continue; }
189
+ {
190
+ char* endp = 0;
191
+ double v = strtod(p, &endp);
192
+ if (endp == p) break; // not something we understand; stop rather than spin
193
+ sqlite3_bind_double(st, index++, v);
194
+ p = endp;
195
+ }
196
+ }
197
+ return 0;
198
+ }
199
+
200
+ // --- R2 bodies, without the JSON detour -------------------------------------
201
+ // An object's bytes never enter a JSON frame: they arrive as their own string
202
+ // parameter and go straight into a BLOB column. That matters for size as much
203
+ // as correctness — escaping a binary body inflates it several times over, and
204
+ // the arena grows to fit the biggest thing it ever had to hold.
205
+
206
+ static void sb_hex32(const unsigned char* in, char* out) {
207
+ static const char* d = "0123456789abcdef";
208
+ for (int i = 0; i < 32; i++) { out[i * 2] = d[in[i] >> 4]; out[i * 2 + 1] = d[in[i] & 15]; }
209
+ out[64] = 0;
210
+ }
211
+
212
+ // sha256 of the body, matching what the broker records as the etag.
213
+ static void sb_sha256_hex(const char* data, size_t len, char* out65) {
214
+ br_sha256_context ctx;
215
+ br_sha256_init(&ctx);
216
+ br_sha256_update(&ctx, data, len);
217
+ unsigned char digest[32];
218
+ br_sha256_out(&ctx, digest);
219
+ sb_hex32(digest, out65);
220
+ }
221
+
222
+ static void sb_iso_now(char* out, size_t cap) {
223
+ time_t now = time(0);
224
+ struct tm g;
225
+ gmtime_r(&now, &g);
226
+ strftime(out, cap, "%Y-%m-%dT%H:%M:%S.000Z", &g);
227
+ }
228
+
229
+ // Returns malloc'd JSON metadata; the body itself is never serialised.
230
+ static char* sb_r2_put_c(const char* path, const char* bucket, const char* key, const char* body, size_t bodylen,
231
+ const char* http_json, const char* custom_json) {
232
+ sb_buf out = { 0, 0, 0 };
233
+ int idx = sb_db_for(path);
234
+ if (idx < 0) { sb_puts(&out, "{\"ok\":false,\"error\":\"cannot open database\"}"); return out.p; }
235
+ sqlite3* db = sb_dbs[idx];
236
+
237
+ char etag[65];
238
+ sb_sha256_hex(body, bodylen, etag);
239
+ char uploaded[40];
240
+ sb_iso_now(uploaded, sizeof(uploaded));
241
+
242
+ sqlite3_stmt* st = 0;
243
+ const char* sql =
244
+ "INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) "
245
+ "ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8";
246
+ if (sqlite3_prepare_v2(db, sql, -1, &st, 0) != 0 || !st) {
247
+ sb_puts(&out, "{\"ok\":false,\"error\":");
248
+ const char* m = sqlite3_errmsg(db);
249
+ sb_putjson(&out, m, strlen(m));
250
+ sb_puts(&out, "}");
251
+ return out.p;
252
+ }
253
+ sqlite3_bind_text(st, 1, bucket, -1, SB_SQLITE_TRANSIENT);
254
+ sqlite3_bind_text(st, 2, key, -1, SB_SQLITE_TRANSIENT);
255
+ sqlite3_bind_blob(st, 3, body, (int)bodylen, SB_SQLITE_STATIC);
256
+ sqlite3_bind_int64(st, 4, (int64_t)bodylen);
257
+ sqlite3_bind_text(st, 5, etag, -1, SB_SQLITE_TRANSIENT);
258
+ sqlite3_bind_text(st, 6, uploaded, -1, SB_SQLITE_TRANSIENT);
259
+ sqlite3_bind_text(st, 7, http_json && *http_json ? http_json : "{}", -1, SB_SQLITE_TRANSIENT);
260
+ sqlite3_bind_text(st, 8, custom_json && *custom_json ? custom_json : "{}", -1, SB_SQLITE_TRANSIENT);
261
+ int rc = sqlite3_step(st);
262
+ sqlite3_finalize(st);
263
+ if (rc != SB_SQLITE_DONE) { sb_puts(&out, "{\"ok\":false,\"error\":\"r2 put failed\"}"); return out.p; }
264
+
265
+ char head[256];
266
+ int k = snprintf(head, sizeof(head), "{\"ok\":true,\"etag\":\"%s\",\"size\":%zu,\"uploaded\":\"%s\"}", etag, bodylen, uploaded);
267
+ sb_put(&out, head, (size_t)k);
268
+ return out.p;
269
+ }
270
+
271
+ // Body bytes straight into a Porffor bytestring.
272
+ //
273
+ // The allocation happens while the statement is still open, so sqlite's own
274
+ // buffer is the source and there is no intermediate copy: one 8 MB object means
275
+ // one 8 MB allocation, not two. Returns the bytestring pointer, or 0.
276
+ static u32 sb_r2_get_c(const char* path, const char* bucket, const char* key, int* found) {
277
+ *found = 0;
278
+ int idx = sb_db_for(path);
279
+ if (idx < 0) return 0;
280
+ sqlite3_stmt* st = 0;
281
+ if (sqlite3_prepare_v2(sb_dbs[idx], "SELECT body FROM r2 WHERE bucket = ? AND key = ?", -1, &st, 0) != 0 || !st) return 0;
282
+ sqlite3_bind_text(st, 1, bucket, -1, SB_SQLITE_TRANSIENT);
283
+ sqlite3_bind_text(st, 2, key, -1, SB_SQLITE_TRANSIENT);
284
+ u32 out = 0;
285
+ if (sqlite3_step(st) == SB_SQLITE_ROW) {
286
+ int n = sqlite3_column_bytes(st, 0);
287
+ const void* blob = sqlite3_column_blob(st, 0);
288
+ out = porf_native_fetch_alloc_bytestring((const char*)(blob ? blob : ""), (size_t)(n > 0 ? n : 0));
289
+ *found = 1;
290
+ }
291
+ sqlite3_finalize(st);
292
+ return out;
293
+ }
294
+
295
+ // Run a script: one or more statements separated by semicolons. sqlite3_exec
296
+ // handles the whole string, which prepare/step does not — it compiles the first
297
+ // statement and silently ignores the rest, so a schema built from one exec call
298
+ // would come out with only its first table.
299
+ static char* sb_sql_script(const char* path, const char* sql) {
300
+ sb_buf b = { 0, 0, 0 };
301
+ int idx = sb_db_for(path);
302
+ if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
303
+ char* err = 0;
304
+ if (sqlite3_exec(sb_dbs[idx], sql, 0, 0, &err) != 0) {
305
+ sb_puts(&b, "{\"ok\":false,\"error\":");
306
+ sb_putjson(&b, err ? err : "exec failed", err ? strlen(err) : 11);
307
+ sb_puts(&b, "}");
308
+ return b.p;
309
+ }
310
+ sb_puts(&b, "{\"ok\":true}");
311
+ return b.p;
312
+ }
313
+
314
+ // Run one statement. Returns malloc'd JSON:
315
+ // {"ok":true,"cols":[...],"rows":[[...]],"changes":n,"rowid":n}
316
+ // or {"ok":false,"error":"..."}.
317
+ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
318
+ sb_buf b = { 0, 0, 0 };
319
+ int idx = sb_db_for(path);
320
+ if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
321
+ sqlite3* db = sb_dbs[idx];
322
+ sqlite3_stmt* st = 0;
323
+ if (sqlite3_prepare_v2(db, sql, -1, &st, 0) != 0 || !st) {
324
+ sb_puts(&b, "{\"ok\":false,\"error\":");
325
+ const char* m = sqlite3_errmsg(db);
326
+ sb_putjson(&b, m, strlen(m));
327
+ sb_puts(&b, "}");
328
+ return b.p;
329
+ }
330
+ if (params && *params) sb_bind_params(st, params);
331
+
332
+ sb_puts(&b, "{\"ok\":true,\"cols\":[");
333
+ int ncol = sqlite3_column_count(st);
334
+ for (int i = 0; i < ncol; i++) {
335
+ if (i) sb_puts(&b, ",");
336
+ const char* name = sqlite3_column_name(st, i);
337
+ sb_putjson(&b, name ? name : "", name ? strlen(name) : 0);
338
+ }
339
+ sb_puts(&b, "],\"rows\":[");
340
+ int rc, first = 1;
341
+ while ((rc = sqlite3_step(st)) == SB_SQLITE_ROW) {
342
+ if (!first) sb_puts(&b, ",");
343
+ first = 0;
344
+ sb_puts(&b, "[");
345
+ for (int i = 0; i < ncol; i++) {
346
+ if (i) sb_puts(&b, ",");
347
+ int t = sqlite3_column_type(st, i);
348
+ if (t == 5) { sb_puts(&b, "null"); continue; } // SQLITE_NULL
349
+ if (t == 1 || t == 2) { // INTEGER / FLOAT
350
+ char num[40];
351
+ int k = snprintf(num, 40, "%.17g", sqlite3_column_double(st, i));
352
+ sb_put(&b, num, (size_t)k);
353
+ continue;
354
+ }
355
+ const unsigned char* txt = sqlite3_column_text(st, i);
356
+ int n = sqlite3_column_bytes(st, i);
357
+ sb_putjson(&b, txt ? (const char*)txt : "", txt ? (size_t)n : 0);
358
+ }
359
+ sb_puts(&b, "]");
360
+ }
361
+ char tail[96];
362
+ int k = snprintf(tail, 96, "],\"changes\":%d,\"rowid\":%lld}", sqlite3_changes(db), (long long)sqlite3_last_insert_rowid(db));
363
+ sb_put(&b, tail, (size_t)k);
364
+ sqlite3_finalize(st);
365
+ if (rc != SB_SQLITE_DONE && rc != SB_SQLITE_ROW) {
366
+ free(b.p);
367
+ sb_buf e = { 0, 0, 0 };
368
+ sb_puts(&e, "{\"ok\":false,\"error\":");
369
+ const char* m = sqlite3_errmsg(db);
370
+ sb_putjson(&e, m, strlen(m));
371
+ sb_puts(&e, "}");
372
+ return e.p;
373
+ }
374
+ return b.p;
375
+ }
376
+
377
+ // --- outbound HTTP + HTTPS ---------------------------------------------------
378
+ // Plain sockets for http, BearSSL for https, with the Mozilla root set compiled
379
+ // in (see src/bearssl.ts). Both directions share the request builder and the
380
+ // response parser: the only thing that differs is how bytes move.
381
+
382
+ // #56 — an outbound response is held whole in memory, and its size is chosen by
383
+ // the remote host, not by us. Without a cap one allowlisted upstream can drive a
384
+ // sprout out of memory: a 100 MB body measured at 321 MB resident. 32 MiB by
385
+ // default, raisable for a deployment that knowingly fetches something bigger.
386
+ static size_t sb_fetch_max(void) {
387
+ static size_t cached = 0;
388
+ if (cached == 0) {
389
+ const char* raw = getenv("SB_FETCH_MAX_BYTES");
390
+ long parsed = raw && *raw ? atol(raw) : 0;
391
+ cached = parsed > 0 ? (size_t)parsed : 32u * 1024u * 1024u;
392
+ }
393
+ return cached;
394
+ }
395
+
396
+ static int sb_tcp_connect(const char* host, int port) {
397
+ struct addrinfo hints, *res = 0, *it;
398
+ memset(&hints, 0, sizeof(hints));
399
+ hints.ai_family = AF_UNSPEC;
400
+ hints.ai_socktype = SOCK_STREAM;
401
+ char portstr[16];
402
+ snprintf(portstr, sizeof(portstr), "%d", port);
403
+ if (getaddrinfo(host, portstr, &hints, &res) != 0 || !res) return -1;
404
+ int fd = -1;
405
+ for (it = res; it; it = it->ai_next) {
406
+ fd = socket(it->ai_family, it->ai_socktype, it->ai_protocol);
407
+ if (fd < 0) continue;
408
+ struct timeval tv; tv.tv_sec = 30; tv.tv_usec = 0;
409
+ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
410
+ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
411
+ if (connect(fd, it->ai_addr, it->ai_addrlen) == 0) break;
412
+ close(fd);
413
+ fd = -1;
414
+ }
415
+ freeaddrinfo(res);
416
+ return fd;
417
+ }
418
+
419
+ static void sb_build_request(sb_buf* req, const char* host, const char* path, const char* method,
420
+ const char* headers, const char* body) {
421
+ sb_puts(req, method); sb_puts(req, " "); sb_puts(req, path); sb_puts(req, " HTTP/1.1\r\n");
422
+ sb_puts(req, "Host: "); sb_puts(req, host); sb_puts(req, "\r\n");
423
+ sb_puts(req, "Connection: close\r\n");
424
+ sb_puts(req, "Accept-Encoding: identity\r\n");
425
+ if (headers && *headers) sb_puts(req, headers);
426
+ size_t blen = body ? strlen(body) : 0;
427
+ if (blen) {
428
+ char cl[64];
429
+ int k = snprintf(cl, sizeof(cl), "Content-Length: %zu\r\n", blen);
430
+ sb_put(req, cl, (size_t)k);
431
+ }
432
+ sb_puts(req, "\r\n");
433
+ if (blen) sb_put(req, body, blen);
434
+ }
435
+
436
+ // Turn a whole raw response into the reply frame. Returns malloc'd JSON.
437
+ static char* sb_parse_response(sb_buf* raw) {
438
+ sb_buf out = { 0, 0, 0 };
439
+ const char* head_end = raw->p ? strstr(raw->p, "\r\n\r\n") : 0;
440
+ if (!head_end) {
441
+ sb_puts(&out, "{\"ok\":false,\"error\":\"malformed response\"}");
442
+ return out.p;
443
+ }
444
+ int status = 0;
445
+ {
446
+ const char* sp = strchr(raw->p, ' ');
447
+ if (sp) status = atoi(sp + 1);
448
+ }
449
+
450
+ int chunked = 0;
451
+ long content_length = -1;
452
+ sb_buf hdrs = { 0, 0, 0 };
453
+ sb_puts(&hdrs, "[");
454
+ {
455
+ const char* line = strstr(raw->p, "\r\n");
456
+ int first = 1;
457
+ while (line && line + 2 < head_end) {
458
+ line += 2;
459
+ const char* eol = strstr(line, "\r\n");
460
+ if (!eol || eol > head_end) break;
461
+ const char* colon = memchr(line, ':', (size_t)(eol - line));
462
+ if (colon) {
463
+ const char* vs = colon + 1;
464
+ while (vs < eol && (*vs == ' ' || *vs == 9)) vs++;
465
+ size_t klen = (size_t)(colon - line);
466
+ if (!first) sb_puts(&hdrs, ",");
467
+ first = 0;
468
+ sb_puts(&hdrs, "[");
469
+ sb_putjson(&hdrs, line, klen);
470
+ sb_puts(&hdrs, ",");
471
+ sb_putjson(&hdrs, vs, (size_t)(eol - vs));
472
+ sb_puts(&hdrs, "]");
473
+ if (klen == 17 && strncasecmp(line, "transfer-encoding", 17) == 0 && strncasecmp(vs, "chunked", 7) == 0)
474
+ chunked = 1;
475
+ if (klen == 14 && strncasecmp(line, "content-length", 14) == 0) content_length = atol(vs);
476
+ }
477
+ line = eol;
478
+ }
479
+ }
480
+ sb_puts(&hdrs, "]");
481
+
482
+ const char* bodyp = head_end + 4;
483
+ size_t bodylen = raw->len - (size_t)(bodyp - raw->p);
484
+
485
+ sb_buf decoded = { 0, 0, 0 };
486
+ if (chunked) {
487
+ const char* p = bodyp;
488
+ const char* end = bodyp + bodylen;
489
+ while (p < end) {
490
+ char* stop = 0;
491
+ long size = strtol(p, &stop, 16);
492
+ if (!stop || size <= 0) break;
493
+ p = strstr(stop, "\r\n");
494
+ if (!p) break;
495
+ p += 2;
496
+ if (p + size > end) break;
497
+ sb_put(&decoded, p, (size_t)size);
498
+ p += size + 2;
499
+ }
500
+ bodyp = decoded.p ? decoded.p : "";
501
+ bodylen = decoded.len;
502
+ }
503
+
504
+ char head[96];
505
+ int k = snprintf(head, sizeof(head), "{\"ok\":true,\"status\":%d,\"complete\":%d,\"headers\":",
506
+ status, (content_length < 0 || (long)bodylen >= content_length) ? 1 : 0);
507
+ sb_put(&out, head, (size_t)k);
508
+ sb_put(&out, hdrs.p, hdrs.len);
509
+ sb_puts(&out, ",\"body\":");
510
+ sb_putjson(&out, bodyp, bodylen);
511
+ sb_puts(&out, "}");
512
+ free(hdrs.p);
513
+ free(decoded.p);
514
+ return out.p;
515
+ }
516
+
517
+ static char* sb_error_json(const char* prefix, const char* detail) {
518
+ sb_buf out = { 0, 0, 0 };
519
+ sb_puts(&out, "{\"ok\":false,\"error\":");
520
+ sb_buf msg = { 0, 0, 0 };
521
+ sb_puts(&msg, prefix);
522
+ if (detail) { sb_puts(&msg, detail); }
523
+ sb_putjson(&out, msg.p ? msg.p : "", msg.len);
524
+ sb_puts(&out, "}");
525
+ free(msg.p);
526
+ return out.p;
527
+ }
528
+
529
+ static char* sb_http_plain(const char* host, int port, const char* path, const char* method,
530
+ const char* headers, const char* body) {
531
+ int fd = sb_tcp_connect(host, port);
532
+ if (fd < 0) return sb_error_json("could not connect to ", host);
533
+
534
+ sb_buf req = { 0, 0, 0 };
535
+ sb_build_request(&req, host, path, method, headers, body);
536
+ size_t sent = 0;
537
+ while (sent < req.len) {
538
+ long n = write(fd, req.p + sent, req.len - sent);
539
+ if (n <= 0) { if (n < 0 && errno == EINTR) continue; break; }
540
+ sent += (size_t)n;
541
+ }
542
+ free(req.p);
543
+
544
+ sb_buf raw = { 0, 0, 0 };
545
+ char chunk[8192];
546
+ int too_big = 0;
547
+ while (1) {
548
+ long n = read(fd, chunk, sizeof(chunk));
549
+ if (n > 0) {
550
+ if (raw.len + (size_t)n > sb_fetch_max()) { too_big = 1; break; }
551
+ sb_put(&raw, chunk, (size_t)n);
552
+ continue;
553
+ }
554
+ if (n < 0 && errno == EINTR) continue;
555
+ break;
556
+ }
557
+ close(fd);
558
+ if (too_big) { free(raw.p); return sb_error_json("response exceeds SB_FETCH_MAX_BYTES from ", host); }
559
+ char* out = sb_parse_response(&raw);
560
+ free(raw.p);
561
+ return out;
562
+ }
563
+
564
+ // --- TLS ---------------------------------------------------------------------
565
+ // Trust anchors come from src/bearssl.ts: the Mozilla root set, compiled in.
566
+ extern const br_x509_trust_anchor sb_trust_anchors[];
567
+ extern const size_t sb_trust_anchor_count;
568
+
569
+ // SB_CA_BUNDLE: extra roots, read at run time.
570
+ //
571
+ // Adds trust, never removes it — the compiled-in Mozilla set stays in force, so
572
+ // pointing at a file with one corporate CA cannot silently stop public
573
+ // certificates from validating. Nothing here can disable verification.
574
+ //
575
+ // Adapted from BearSSL's own tools/certs.c (same MIT licence), which is the
576
+ // reference for turning a DER certificate into a trust anchor.
577
+ static br_x509_trust_anchor* sb_ca_extra = 0;
578
+ static size_t sb_ca_extra_count = 0;
579
+ static const br_x509_trust_anchor* sb_all_anchors = 0;
580
+ static size_t sb_all_anchor_count = 0;
581
+
582
+ static void sb_buf_append(void* ctx, const void* buf, size_t len) {
583
+ sb_put((sb_buf*)ctx, (const char*)buf, len);
584
+ }
585
+
586
+ static unsigned char* sb_blobdup(const void* src, size_t len) {
587
+ unsigned char* out = (unsigned char*)malloc(len ? len : 1);
588
+ if (out && len) memcpy(out, src, len);
589
+ return out;
590
+ }
591
+
592
+ // One DER certificate -> one appended trust anchor. Returns 0 on success.
593
+ static int sb_add_anchor(const unsigned char* der, size_t len) {
594
+ br_x509_decoder_context dc;
595
+ sb_buf dn = { 0, 0, 0 };
596
+ br_x509_decoder_init(&dc, sb_buf_append, &dn);
597
+ br_x509_decoder_push(&dc, der, len);
598
+ br_x509_pkey* pk = br_x509_decoder_get_pkey(&dc);
599
+ if (!pk) { free(dn.p); return -1; }
600
+
601
+ br_x509_trust_anchor ta;
602
+ memset(&ta, 0, sizeof(ta));
603
+ ta.dn.data = (unsigned char*)dn.p;
604
+ ta.dn.len = dn.len;
605
+ ta.flags = br_x509_decoder_isCA(&dc) ? BR_X509_TA_CA : 0;
606
+ if (pk->key_type == BR_KEYTYPE_RSA) {
607
+ ta.pkey.key_type = BR_KEYTYPE_RSA;
608
+ ta.pkey.key.rsa.n = sb_blobdup(pk->key.rsa.n, pk->key.rsa.nlen);
609
+ ta.pkey.key.rsa.nlen = pk->key.rsa.nlen;
610
+ ta.pkey.key.rsa.e = sb_blobdup(pk->key.rsa.e, pk->key.rsa.elen);
611
+ ta.pkey.key.rsa.elen = pk->key.rsa.elen;
612
+ } else if (pk->key_type == BR_KEYTYPE_EC) {
613
+ ta.pkey.key_type = BR_KEYTYPE_EC;
614
+ ta.pkey.key.ec.curve = pk->key.ec.curve;
615
+ ta.pkey.key.ec.q = sb_blobdup(pk->key.ec.q, pk->key.ec.qlen);
616
+ ta.pkey.key.ec.qlen = pk->key.ec.qlen;
617
+ } else {
618
+ free(dn.p);
619
+ return -1; // a key type BearSSL cannot verify with
620
+ }
621
+
622
+ br_x509_trust_anchor* grown =
623
+ (br_x509_trust_anchor*)realloc(sb_ca_extra, (sb_ca_extra_count + 1) * sizeof(br_x509_trust_anchor));
624
+ if (!grown) { free(dn.p); return -1; }
625
+ sb_ca_extra = grown;
626
+ sb_ca_extra[sb_ca_extra_count++] = ta;
627
+ return 0;
628
+ }
629
+
630
+ // Read every CERTIFICATE block out of a PEM file and add it.
631
+ static void sb_load_ca_bundle(const char* path) {
632
+ FILE* f = fopen(path, "rb");
633
+ if (!f) {
634
+ fprintf(stderr, "sproutboat: SB_CA_BUNDLE %s could not be opened; using the built-in roots only\n", path);
635
+ return;
636
+ }
637
+ sb_buf pem = { 0, 0, 0 };
638
+ char chunk[8192];
639
+ size_t n;
640
+ while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) sb_put(&pem, chunk, n);
641
+ fclose(f);
642
+
643
+ br_pem_decoder_context pc;
644
+ br_pem_decoder_init(&pc);
645
+ sb_buf der = { 0, 0, 0 };
646
+ int in_cert = 0, added = 0;
647
+ size_t off = 0;
648
+ while (off < pem.len) {
649
+ size_t used = br_pem_decoder_push(&pc, pem.p + off, pem.len - off);
650
+ off += used;
651
+ switch (br_pem_decoder_event(&pc)) {
652
+ case BR_PEM_BEGIN_OBJ: {
653
+ const char* name = br_pem_decoder_name(&pc);
654
+ in_cert = name && (strcmp(name, "CERTIFICATE") == 0 || strcmp(name, "X509 CERTIFICATE") == 0);
655
+ der.len = 0;
656
+ if (in_cert) br_pem_decoder_setdest(&pc, sb_buf_append, &der);
657
+ else br_pem_decoder_setdest(&pc, 0, 0);
658
+ break;
659
+ }
660
+ case BR_PEM_END_OBJ:
661
+ if (in_cert && der.len && sb_add_anchor((const unsigned char*)der.p, der.len) == 0) added++;
662
+ der.len = 0;
663
+ in_cert = 0;
664
+ break;
665
+ case BR_PEM_ERROR:
666
+ fprintf(stderr, "sproutboat: SB_CA_BUNDLE %s is not valid PEM\n", path);
667
+ off = pem.len;
668
+ break;
669
+ default:
670
+ break;
671
+ }
672
+ if (used == 0 && br_pem_decoder_event(&pc) == 0) break; // no progress, no event
673
+ }
674
+ free(pem.p);
675
+ free(der.p);
676
+ if (added == 0) fprintf(stderr, "sproutboat: SB_CA_BUNDLE %s held no usable certificates\n", path);
677
+ }
678
+
679
+ // The anchor set every handshake verifies against: built in, plus SB_CA_BUNDLE.
680
+ static void sb_init_anchors(void) {
681
+ if (sb_all_anchors) return;
682
+ const char* path = getenv("SB_CA_BUNDLE");
683
+ if (path && *path) sb_load_ca_bundle(path);
684
+ if (sb_ca_extra_count == 0) {
685
+ sb_all_anchors = sb_trust_anchors;
686
+ sb_all_anchor_count = sb_trust_anchor_count;
687
+ return;
688
+ }
689
+ size_t total = sb_trust_anchor_count + sb_ca_extra_count;
690
+ br_x509_trust_anchor* all = (br_x509_trust_anchor*)malloc(total * sizeof(br_x509_trust_anchor));
691
+ if (!all) {
692
+ sb_all_anchors = sb_trust_anchors;
693
+ sb_all_anchor_count = sb_trust_anchor_count;
694
+ return;
695
+ }
696
+ memcpy(all, sb_trust_anchors, sb_trust_anchor_count * sizeof(br_x509_trust_anchor));
697
+ memcpy(all + sb_trust_anchor_count, sb_ca_extra, sb_ca_extra_count * sizeof(br_x509_trust_anchor));
698
+ sb_all_anchors = all;
699
+ sb_all_anchor_count = total;
700
+ }
701
+
702
+ static int sb_sock_read(void* ctx, unsigned char* buf, size_t len) {
703
+ for (;;) {
704
+ ssize_t n = read(*(int*)ctx, buf, len);
705
+ if (n < 0 && errno == EINTR) continue;
706
+ if (n <= 0) return -1;
707
+ return (int)n;
708
+ }
709
+ }
710
+ static int sb_sock_write(void* ctx, const unsigned char* buf, size_t len) {
711
+ for (;;) {
712
+ ssize_t n = write(*(int*)ctx, buf, len);
713
+ if (n < 0 && errno == EINTR) continue;
714
+ if (n <= 0) return -1;
715
+ return (int)n;
716
+ }
717
+ }
718
+
719
+ static char* sb_https(const char* host, int port, const char* path, const char* method,
720
+ const char* headers, const char* body) {
721
+ int fd = sb_tcp_connect(host, port);
722
+ if (fd < 0) return sb_error_json("could not connect to ", host);
723
+
724
+ br_ssl_client_context* sc = (br_ssl_client_context*)malloc(sizeof(br_ssl_client_context));
725
+ br_x509_minimal_context* xc = (br_x509_minimal_context*)malloc(sizeof(br_x509_minimal_context));
726
+ unsigned char* iobuf = (unsigned char*)malloc(BR_SSL_BUFSIZE_BIDI);
727
+ if (!sc || !xc || !iobuf) {
728
+ free(sc); free(xc); free(iobuf); close(fd);
729
+ return sb_error_json("out of memory setting up TLS for ", host);
730
+ }
731
+ br_sslio_context ioc;
732
+ sb_init_anchors();
733
+ br_ssl_client_init_full(sc, xc, sb_all_anchors, sb_all_anchor_count);
734
+ br_ssl_engine_set_buffer(&sc->eng, iobuf, BR_SSL_BUFSIZE_BIDI, 1);
735
+ br_ssl_client_reset(sc, host, 0);
736
+ br_sslio_init(&ioc, &sc->eng, sb_sock_read, &fd, sb_sock_write, &fd);
737
+
738
+ sb_buf req = { 0, 0, 0 };
739
+ sb_build_request(&req, host, path, method, headers, body);
740
+ int wrote = br_sslio_write_all(&ioc, req.p, req.len);
741
+ free(req.p);
742
+ if (wrote != 0) {
743
+ char detail[96];
744
+ snprintf(detail, sizeof(detail), "%s (tls error %d)", host, br_ssl_engine_last_error(&sc->eng));
745
+ free(sc); free(xc); free(iobuf); close(fd);
746
+ return sb_error_json("TLS handshake failed for ", detail);
747
+ }
748
+ br_sslio_flush(&ioc);
749
+
750
+ sb_buf raw = { 0, 0, 0 };
751
+ char chunk[8192];
752
+ int rc, too_big = 0;
753
+ while ((rc = br_sslio_read(&ioc, chunk, sizeof(chunk))) > 0) {
754
+ if (raw.len + (size_t)rc > sb_fetch_max()) { too_big = 1; break; }
755
+ sb_put(&raw, chunk, (size_t)rc);
756
+ }
757
+ int err = br_ssl_engine_last_error(&sc->eng);
758
+ free(sc); free(xc); free(iobuf);
759
+ close(fd);
760
+ if (too_big) { free(raw.p); return sb_error_json("response exceeds SB_FETCH_MAX_BYTES from ", host); }
761
+
762
+ // BR_ERR_IO here means the peer closed without close_notify, which is what
763
+ // most servers do on Connection: close. It is indistinguishable from a
764
+ // truncation attack on its own, so the reply carries "complete" (whether the
765
+ // body satisfied Content-Length) and the JS side decides.
766
+ char* out = sb_parse_response(&raw);
767
+ free(raw.p);
768
+ if (err != 0 && err != BR_ERR_IO) {
769
+ free(out);
770
+ char detail[64];
771
+ snprintf(detail, sizeof(detail), "tls error %d", err);
772
+ return sb_error_json("", detail);
773
+ }
774
+ if (err == BR_ERR_IO) {
775
+ // Mark it so JS can refuse a body that was cut short.
776
+ char* marked = (char*)malloc(strlen(out) + 32);
777
+ if (marked) {
778
+ size_t n = strlen(out);
779
+ memcpy(marked, out, n - 1);
780
+ memcpy(marked + n - 1, ",\"unclean\":true}", 17);
781
+ free(out);
782
+ return marked;
783
+ }
784
+ }
785
+ return out;
786
+ }
787
+
788
+ static char* sb_http_request(const char* host, int port, const char* path, const char* method,
789
+ const char* headers, const char* body, int tls) {
790
+ return tls ? sb_https(host, port, path, method, headers, body)
791
+ : sb_http_plain(host, port, path, method, headers, body);
792
+ }
793
+ `;
794
+
795
+ // R2 put: the body is its own parameter, so it never gets escaped.
796
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
797
+ function __sbR2PutRaw(path, bucket, key, body, httpJson, customJson) {
798
+ let res = "";
799
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
800
+ Porffor.c`
801
+ const char* __p; size_t __pl; char* __po = 0;
802
+ porf_native_fetch_read_value(path, &__p, &__pl, &__po);
803
+ char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
804
+ if (__po) free(__po);
805
+
806
+ const char* __bk; size_t __bkl; char* __bko = 0;
807
+ porf_native_fetch_read_value(bucket, &__bk, &__bkl, &__bko);
808
+ char* __bucket = (char*)malloc(__bkl + 1); memcpy(__bucket, __bk, __bkl); __bucket[__bkl] = 0;
809
+ if (__bko) free(__bko);
810
+
811
+ const char* __k; size_t __kl; char* __ko = 0;
812
+ porf_native_fetch_read_value(key, &__k, &__kl, &__ko);
813
+ char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
814
+ if (__ko) free(__ko);
815
+
816
+ const char* __h; size_t __hl; char* __ho = 0;
817
+ porf_native_fetch_read_value(httpJson, &__h, &__hl, &__ho);
818
+ char* __http = (char*)malloc(__hl + 1); memcpy(__http, __h, __hl); __http[__hl] = 0;
819
+ if (__ho) free(__ho);
820
+
821
+ const char* __c; size_t __cl; char* __co = 0;
822
+ porf_native_fetch_read_value(customJson, &__c, &__cl, &__co);
823
+ char* __custom = (char*)malloc(__cl + 1); memcpy(__custom, __c, __cl); __custom[__cl] = 0;
824
+ if (__co) free(__co);
825
+
826
+ // The body is read in place and handed straight to sqlite3_bind_blob: no
827
+ // copy beyond what the binding needs, and no escaping at all.
828
+ const char* __b; size_t __bl; char* __bo = 0;
829
+ porf_native_fetch_read_value(body, &__b, &__bl, &__bo);
830
+ char* __out = sb_r2_put_c(__path, __bucket, __key, __b, __bl, __http, __custom);
831
+ if (__bo) free(__bo);
832
+
833
+ free(__path); free(__bucket); free(__key); free(__http); free(__custom);
834
+ if (__out) {
835
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
836
+ free(__out);
837
+ }
838
+ `;
839
+ return res;
840
+ }
841
+
842
+ // R2 get: the bytes come back as a bytestring, not inside a reply frame.
843
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
844
+ function __sbR2GetRaw(path, bucket, key) {
845
+ let res = "";
846
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
847
+ Porffor.c`
848
+ const char* __p; size_t __pl; char* __po = 0;
849
+ porf_native_fetch_read_value(path, &__p, &__pl, &__po);
850
+ char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
851
+ if (__po) free(__po);
852
+
853
+ const char* __bk; size_t __bkl; char* __bko = 0;
854
+ porf_native_fetch_read_value(bucket, &__bk, &__bkl, &__bko);
855
+ char* __bucket = (char*)malloc(__bkl + 1); memcpy(__bucket, __bk, __bkl); __bucket[__bkl] = 0;
856
+ if (__bko) free(__bko);
857
+
858
+ const char* __k; size_t __kl; char* __ko = 0;
859
+ porf_native_fetch_read_value(key, &__k, &__kl, &__ko);
860
+ char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
861
+ if (__ko) free(__ko);
862
+
863
+ int __found = 0;
864
+ u32 __bs = sb_r2_get_c(__path, __bucket, __key, &__found);
865
+ free(__path); free(__bucket); free(__key);
866
+ if (__found) res = porf_box((f64)__bs, 195);
867
+ `;
868
+ return res;
869
+ }
870
+
871
+ // Multi-statement exec. Same string-param pattern as __sbSqlRaw.
872
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
873
+ function __sbSqlScriptRaw(path, sql) {
874
+ let res = "";
875
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
876
+ Porffor.c`
877
+ const char* __p; size_t __pl; char* __po = 0;
878
+ porf_native_fetch_read_value(path, &__p, &__pl, &__po);
879
+ char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
880
+ if (__po) free(__po);
881
+
882
+ const char* __s; size_t __sl; char* __so = 0;
883
+ porf_native_fetch_read_value(sql, &__s, &__sl, &__so);
884
+ char* __sql = (char*)malloc(__sl + 1); memcpy(__sql, __s, __sl); __sql[__sl] = 0;
885
+ if (__so) free(__so);
886
+
887
+ char* __out = sb_sql_script(__path, __sql);
888
+ free(__path); free(__sql);
889
+ if (__out) {
890
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
891
+ free(__out);
892
+ }
893
+ `;
894
+ return res;
895
+ }
896
+
897
+ // Outbound HTTP. String params so C can read each directly; the JS side has
898
+ // already split the URL and enforced the allowlist. `tlsFlag` is "1" or "0" —
899
+ // a string like the rest, so the marshalling stays uniform.
900
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
901
+ function __sbHttpRaw(host, portStr, path, method, headersText, body, tlsFlag) {
902
+ let res = "";
903
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
904
+ Porffor.c`
905
+ const char* __h; size_t __hl; char* __ho = 0;
906
+ porf_native_fetch_read_value(host, &__h, &__hl, &__ho);
907
+ char* __host = (char*)malloc(__hl + 1); memcpy(__host, __h, __hl); __host[__hl] = 0;
908
+ if (__ho) free(__ho);
909
+
910
+ const char* __pt; size_t __ptl; char* __pto = 0;
911
+ porf_native_fetch_read_value(portStr, &__pt, &__ptl, &__pto);
912
+ char __portbuf[16]; size_t __ptk = __ptl < 15 ? __ptl : 15;
913
+ memcpy(__portbuf, __pt, __ptk); __portbuf[__ptk] = 0;
914
+ if (__pto) free(__pto);
915
+
916
+ const char* __pa; size_t __pal; char* __pao = 0;
917
+ porf_native_fetch_read_value(path, &__pa, &__pal, &__pao);
918
+ char* __path = (char*)malloc(__pal + 1); memcpy(__path, __pa, __pal); __path[__pal] = 0;
919
+ if (__pao) free(__pao);
920
+
921
+ const char* __m; size_t __ml; char* __mo = 0;
922
+ porf_native_fetch_read_value(method, &__m, &__ml, &__mo);
923
+ char* __method = (char*)malloc(__ml + 1); memcpy(__method, __m, __ml); __method[__ml] = 0;
924
+ if (__mo) free(__mo);
925
+
926
+ const char* __hd; size_t __hdl; char* __hdo = 0;
927
+ porf_native_fetch_read_value(headersText, &__hd, &__hdl, &__hdo);
928
+ char* __hdrs = (char*)malloc(__hdl + 1); memcpy(__hdrs, __hd, __hdl); __hdrs[__hdl] = 0;
929
+ if (__hdo) free(__hdo);
930
+
931
+ const char* __b; size_t __bl; char* __bo = 0;
932
+ porf_native_fetch_read_value(body, &__b, &__bl, &__bo);
933
+ char* __body = (char*)malloc(__bl + 1); memcpy(__body, __b, __bl); __body[__bl] = 0;
934
+ if (__bo) free(__bo);
935
+
936
+ const char* __t; size_t __tl; char* __to = 0;
937
+ porf_native_fetch_read_value(tlsFlag, &__t, &__tl, &__to);
938
+ int __tls = (__tl > 0 && __t[0] == '1') ? 1 : 0;
939
+ if (__to) free(__to);
940
+
941
+ char* __out = sb_http_request(__host, atoi(__portbuf), __path, __method, __hdrs, __body, __tls);
942
+ free(__host); free(__path); free(__method); free(__hdrs); free(__body);
943
+ if (__out) {
944
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
945
+ free(__out);
946
+ }
947
+ `;
948
+ return res;
949
+ }
950
+
951
+ // One statement in, one JSON reply out. `path`, `sql` and `paramsJson` are
952
+ // parameters so the generated C names them directly.
953
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
954
+ function __sbSqlRaw(path, sql, paramsJson) {
955
+ let res = "";
956
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
957
+ Porffor.c`
958
+ const char* __p; size_t __pl; char* __po = 0;
959
+ porf_native_fetch_read_value(path, &__p, &__pl, &__po);
960
+ char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
961
+ if (__po) free(__po);
962
+
963
+ const char* __s; size_t __sl; char* __so = 0;
964
+ porf_native_fetch_read_value(sql, &__s, &__sl, &__so);
965
+ char* __sql = (char*)malloc(__sl + 1); memcpy(__sql, __s, __sl); __sql[__sl] = 0;
966
+ if (__so) free(__so);
967
+
968
+ const char* __a; size_t __al; char* __ao = 0;
969
+ porf_native_fetch_read_value(paramsJson, &__a, &__al, &__ao);
970
+ char* __args = (char*)malloc(__al + 1); memcpy(__args, __a, __al); __args[__al] = 0;
971
+ if (__ao) free(__ao);
972
+
973
+ char* __out = sb_sql_run(__path, __sql, __args);
974
+ free(__path); free(__sql); free(__args);
975
+ if (__out) {
976
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
977
+ free(__out);
978
+ } else {
979
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring("{\"ok\":false,\"error\":\"no result\"}", 38), 195);
980
+ }
981
+ `;
982
+ return res;
983
+ }
984
+
985
+ // --- the op dispatch, in JS -------------------------------------------------
986
+ // Deliberately the same SQL and the same partition keys as broker.ts. When one
987
+ // changes the other has to, and the conformance suite is what says so.
988
+
989
+ // The allowlist is baked into the module by wrap.ts; read it lazily so the
990
+ // dispatch has no import-order dependency on __sbInstallBindings.
991
+ function bindingsOutbound() {
992
+ return globalThis.__sbOutbound || [];
993
+ }
994
+
995
+ var __sbDataDir = "";
996
+ function __sbDir() {
997
+ // SB_DATA_DIR, then SPROUTBOAT_DATA, then <name>.data relative to the working
998
+ // directory. Environment only: a native-fetch binary never sees argv, because
999
+ // Porffor's runtime init calls porf_init(0, NULL). Resolved once — the answer
1000
+ // cannot change mid-run.
1001
+ if (!__sbDataDir) {
1002
+ __sbDataDir = __sbEnv("SB_DATA_DIR") || __sbEnv("SPROUTBOAT_DATA") || (globalThis.__sbAppName || "app") + ".data";
1003
+ }
1004
+ return __sbDataDir;
1005
+ }
1006
+ function __sbStore() {
1007
+ return __sbDir() + "/store.sqlite";
1008
+ }
1009
+ function __sbD1Path(name) {
1010
+ return __sbDir() + "/d1/" + name + ".sqlite";
1011
+ }
1012
+
1013
+ function __sbSql(path, sql, params) {
1014
+ const reply = JSON.parse(__sbSqlRaw(path, sql, params == null ? "[]" : JSON.stringify(params)));
1015
+ if (reply.ok === false) throw new Error("sqlite: " + reply.error);
1016
+ return reply;
1017
+ }
1018
+
1019
+ var __sbSchemaReady = false;
1020
+ function __sbEnsureSchema() {
1021
+ if (__sbSchemaReady) return;
1022
+ __sbSchemaReady = true;
1023
+ const s = __sbStore();
1024
+ __sbSql(
1025
+ s,
1026
+ "CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))",
1027
+ );
1028
+ __sbSql(
1029
+ s,
1030
+ "CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, body TEXT NOT NULL, size INTEGER NOT NULL, " +
1031
+ "etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
1032
+ "PRIMARY KEY (bucket, key))",
1033
+ );
1034
+ __sbSql(
1035
+ s,
1036
+ "CREATE TABLE IF NOT EXISTS mq (queue TEXT NOT NULL, id TEXT PRIMARY KEY, body TEXT NOT NULL, " +
1037
+ "visible_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, dead INTEGER NOT NULL DEFAULT 0)",
1038
+ );
1039
+ // Additive: old standalone store.sqlite files gain the poll index on open.
1040
+ __sbSql(s, "CREATE INDEX IF NOT EXISTS mq_due ON mq (queue, dead, visible_at)", []);
1041
+ __sbSql(
1042
+ s,
1043
+ "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))",
1044
+ );
1045
+ __sbSql(
1046
+ s,
1047
+ "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))",
1048
+ );
1049
+ __sbSql(s, "CREATE INDEX IF NOT EXISTS do_alarm_due ON do_alarm (at)", []);
1050
+ __sbSql(
1051
+ s,
1052
+ "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)",
1053
+ );
1054
+ }
1055
+
1056
+ function __sbHex(n) {
1057
+ let out = "";
1058
+ const bytes = __sbRandomBytes(String(n));
1059
+ for (let i = 0; i < bytes.length; i++) {
1060
+ const h = bytes.charCodeAt(i).toString(16);
1061
+ out += h.length === 1 ? "0" + h : h;
1062
+ }
1063
+ return out;
1064
+ }
1065
+
1066
+ function __sbEmbeddedDispatch(msg) {
1067
+ __sbEnsureSchema();
1068
+ const store = __sbStore();
1069
+ const op = msg.op;
1070
+
1071
+ if (op === "ping") return { ok: true, op: "pong", echo: msg.msg };
1072
+
1073
+ if (op === "kv.get") {
1074
+ const r = __sbSql(store, "SELECT value FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
1075
+ return r.rows.length ? { ok: true, found: true, value: r.rows[0][0] } : { ok: true, found: false, value: null };
1076
+ }
1077
+ if (op === "kv.put") {
1078
+ __sbSql(store, "INSERT INTO kv (ns, key, value) VALUES (?1,?2,?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3", [
1079
+ msg.ns,
1080
+ msg.key,
1081
+ msg.value,
1082
+ ]);
1083
+ return { ok: true };
1084
+ }
1085
+ if (op === "kv.delete") {
1086
+ __sbSql(store, "DELETE FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
1087
+ return { ok: true };
1088
+ }
1089
+ if (op === "kv.list") {
1090
+ const r = __sbSql(store, "SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' ORDER BY key", [
1091
+ msg.ns,
1092
+ msg.prefix || "",
1093
+ ]);
1094
+ const keys = [];
1095
+ for (let i = 0; i < r.rows.length; i++) keys.push(r.rows[i][0]);
1096
+ return { ok: true, keys };
1097
+ }
1098
+
1099
+ if (op === "assets.get") {
1100
+ // Assets are baked into the module by the build (wrap.ts), so a standalone
1101
+ // binary serves them with nothing beside it on disk. Same resolution rules
1102
+ // as src/assets.ts and the same reply shape as the broker.
1103
+ const bundle = globalThis.__sbAssets;
1104
+ if (!bundle) throw new Error("assets not bound");
1105
+ const files = bundle.files || {};
1106
+ const meta = (bundle.manifest && bundle.manifest.files) || {};
1107
+ let path = String(msg.path || "/");
1108
+ if (path.charAt(0) !== "/") path = "/" + path;
1109
+
1110
+ let key = null;
1111
+ if (path.charAt(path.length - 1) === "/") {
1112
+ const index = path + "index.html";
1113
+ if (files[index] != null) key = index;
1114
+ } else if (files[path] != null) {
1115
+ key = path;
1116
+ } else {
1117
+ const base = path.slice(path.lastIndexOf("/") + 1);
1118
+ if (base.indexOf(".") === -1) {
1119
+ if (files[path + ".html"] != null) key = path + ".html";
1120
+ else if (files[path + "/index.html"] != null) key = path + "/index.html";
1121
+ }
1122
+ }
1123
+ if (key != null) {
1124
+ const info = meta[key] || {};
1125
+ return { ok: true, found: true, status: 200, type: info.type, hash: info.hash, body: files[key] };
1126
+ }
1127
+ const nfh = (bundle.manifest && bundle.manifest.notFound) || "none";
1128
+ if (nfh === "single-page-application" && files["/index.html"] != null) {
1129
+ const info = meta["/index.html"] || {};
1130
+ return { ok: true, found: true, status: 200, type: info.type, hash: info.hash, body: files["/index.html"] };
1131
+ }
1132
+ if (nfh === "404-page" && files["/404.html"] != null) {
1133
+ const info = meta["/404.html"] || {};
1134
+ return { ok: true, found: false, status: 404, type: info.type, body: files["/404.html"] };
1135
+ }
1136
+ return { ok: true, found: false, status: 404, body: "Not Found" };
1137
+ }
1138
+
1139
+ if (op === "fetch") {
1140
+ const url = new URL(String(msg.url));
1141
+ const tls = url.protocol === "https:";
1142
+ if (!tls && url.protocol !== "http:") throw new Error("unsupported protocol: " + url.protocol);
1143
+ const allow = bindingsOutbound();
1144
+ if (allow.indexOf(url.host) === -1) throw new Error("host not in outbound allowlist: " + url.host);
1145
+ let headerText = "";
1146
+ const pairs = msg.headers || [];
1147
+ for (let i = 0; i < pairs.length; i++) {
1148
+ const key = String(pairs[i][0]).toLowerCase();
1149
+ // Host, Connection and Content-Length are ours to set.
1150
+ if (key === "host" || key === "connection" || key === "content-length") continue;
1151
+ headerText += pairs[i][0] + ": " + pairs[i][1] + "\r\n";
1152
+ }
1153
+ const port = url.port ? url.port : tls ? "443" : "80";
1154
+ const reply = JSON.parse(
1155
+ __sbHttpRaw(
1156
+ url.hostname,
1157
+ port,
1158
+ url.pathname + url.search,
1159
+ String(msg.method || "GET").toUpperCase(),
1160
+ headerText,
1161
+ msg.body == null ? "" : String(msg.body),
1162
+ tls ? "1" : "0",
1163
+ ),
1164
+ );
1165
+ if (reply.ok === false) throw new Error(reply.error);
1166
+ // A TLS peer that closed without close_notify is normal on Connection:
1167
+ // close, and also what a truncation attack looks like. Accept it only when
1168
+ // Content-Length says the body arrived whole.
1169
+ if (reply.unclean && !reply.complete) {
1170
+ throw new Error("connection closed before the response was complete");
1171
+ }
1172
+ return { ok: true, status: reply.status, headers: reply.headers, body: reply.body };
1173
+ }
1174
+
1175
+ if (op === "secret.get") {
1176
+ // Secrets reach a standalone binary through the environment, the same
1177
+ // channel the launcher and the supervisor use; there is nothing to decrypt.
1178
+ const value = __sbEnv(String(msg.name));
1179
+ if (!value) throw new Error("secret not set: " + msg.name);
1180
+ return { ok: true, value };
1181
+ }
1182
+
1183
+ if (op === "d1.query" || op === "d1.exec" || op === "d1.batch") {
1184
+ const path = __sbD1Path(String(msg.db));
1185
+ if (op === "d1.exec") {
1186
+ // exec() takes a script; a single prepare would run only its first
1187
+ // statement and quietly drop the rest.
1188
+ const reply = JSON.parse(__sbSqlScriptRaw(path, String(msg.sql)));
1189
+ if (reply.ok === false) throw new Error("sqlite: " + reply.error);
1190
+ return { ok: true };
1191
+ }
1192
+ if (op === "d1.batch") {
1193
+ const results = [];
1194
+ const list = msg.statements || [];
1195
+ for (let i = 0; i < list.length; i++) results.push(__sbD1Run(path, list[i].sql, list[i].params));
1196
+ return { ok: true, results };
1197
+ }
1198
+ const one = __sbD1Run(path, msg.sql, msg.params);
1199
+ return { ok: true, results: one.results, meta: one.meta, success: true };
1200
+ }
1201
+
1202
+ if (op === "r2.put") {
1203
+ const body = String(msg.body == null ? "" : msg.body);
1204
+ const etag = __sbHex(16);
1205
+ __sbSql(
1206
+ store,
1207
+ "INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
1208
+ "ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
1209
+ [
1210
+ msg.bucket,
1211
+ msg.key,
1212
+ body,
1213
+ body.length,
1214
+ etag,
1215
+ new Date().toISOString(),
1216
+ JSON.stringify(msg.httpMetadata || {}),
1217
+ JSON.stringify(msg.customMetadata || {}),
1218
+ ],
1219
+ );
1220
+ return {
1221
+ ok: true,
1222
+ object: { key: msg.key, size: body.length, etag, uploaded: new Date().toISOString() },
1223
+ };
1224
+ }
1225
+ if (op === "r2.get" || op === "r2.head") {
1226
+ // head must not select `body`: reading an 8 MB blob only to drop it costs
1227
+ // the read, the text conversion, and a full JSON escape of the row.
1228
+ const wantsBody = op === "r2.get";
1229
+ const r = __sbSql(
1230
+ store,
1231
+ wantsBody
1232
+ ? "SELECT body, size, etag, uploaded, http_json, custom_json FROM r2 WHERE bucket = ? AND key = ?"
1233
+ : "SELECT '', size, etag, uploaded, http_json, custom_json FROM r2 WHERE bucket = ? AND key = ?",
1234
+ [msg.bucket, msg.key],
1235
+ );
1236
+ if (!r.rows.length) return { ok: true, found: false };
1237
+ const row = r.rows[0];
1238
+ // Shape must match the broker's exactly: the shim reads `r.object`.
1239
+ return {
1240
+ ok: true,
1241
+ found: true,
1242
+ object: {
1243
+ key: msg.key,
1244
+ size: Number(row[1]),
1245
+ etag: row[2],
1246
+ uploaded: row[3],
1247
+ httpMetadata: JSON.parse(row[4] || "{}"),
1248
+ customMetadata: JSON.parse(row[5] || "{}"),
1249
+ },
1250
+ body: wantsBody ? row[0] : undefined,
1251
+ };
1252
+ }
1253
+ if (op === "r2.delete") {
1254
+ __sbSql(store, "DELETE FROM r2 WHERE bucket = ? AND key = ?", [msg.bucket, msg.key]);
1255
+ return { ok: true };
1256
+ }
1257
+ if (op === "r2.list") {
1258
+ const r = __sbSql(
1259
+ store,
1260
+ "SELECT key, size, etag, uploaded FROM r2 WHERE bucket = ? AND key LIKE ? || '%' ORDER BY key",
1261
+ [msg.bucket, msg.prefix || ""],
1262
+ );
1263
+ const objects = [];
1264
+ for (let i = 0; i < r.rows.length; i++) {
1265
+ objects.push({
1266
+ key: r.rows[i][0],
1267
+ size: Number(r.rows[i][1]),
1268
+ etag: r.rows[i][2],
1269
+ uploaded: r.rows[i][3],
1270
+ httpMetadata: {},
1271
+ customMetadata: {},
1272
+ });
1273
+ }
1274
+ return { ok: true, objects };
1275
+ }
1276
+
1277
+ if (op === "queue.send" || op === "queue.send_batch") {
1278
+ const items = op === "queue.send" ? [msg.body] : msg.messages || [];
1279
+ for (let i = 0; i < items.length; i++) {
1280
+ __sbSql(store, "INSERT INTO mq (queue, id, body, visible_at, attempts, dead) VALUES (?1,?2,?3,?4,0,0)", [
1281
+ msg.queue,
1282
+ __sbHex(12),
1283
+ String(items[i]),
1284
+ Date.now(),
1285
+ ]);
1286
+ }
1287
+ return { ok: true };
1288
+ }
1289
+
1290
+ if (op === "do.storage.get") {
1291
+ const r = __sbSql(store, "SELECT value FROM do_storage WHERE cls = ? AND id = ? AND key = ?", [
1292
+ msg.cls,
1293
+ msg.id,
1294
+ msg.key,
1295
+ ]);
1296
+ return r.rows.length ? { ok: true, found: true, value: r.rows[0][0] } : { ok: true, found: false };
1297
+ }
1298
+ if (op === "do.storage.put") {
1299
+ __sbSql(
1300
+ store,
1301
+ "INSERT INTO do_storage (cls, id, key, value) VALUES (?1,?2,?3,?4) ON CONFLICT (cls, id, key) DO UPDATE SET value = ?4",
1302
+ [msg.cls, msg.id, msg.key, msg.value],
1303
+ );
1304
+ return { ok: true };
1305
+ }
1306
+ if (op === "do.storage.delete") {
1307
+ const r = __sbSql(store, "DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?", [msg.cls, msg.id, msg.key]);
1308
+ return { ok: true, deleted: r.changes > 0 };
1309
+ }
1310
+ if (op === "do.storage.delete_all") {
1311
+ __sbSql(store, "DELETE FROM do_storage WHERE cls = ? AND id = ?", [msg.cls, msg.id]);
1312
+ return { ok: true };
1313
+ }
1314
+ if (op === "do.storage.list") {
1315
+ const r = __sbSql(
1316
+ store,
1317
+ // Same clamp as the broker: an unbounded list builds the whole result in
1318
+ // memory before the caller sees any of it.
1319
+ "SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT ?",
1320
+ [msg.cls, msg.id, msg.prefix || "", Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000)],
1321
+ );
1322
+ const entries = [];
1323
+ for (let i = 0; i < r.rows.length; i++) entries.push([r.rows[i][0], r.rows[i][1]]);
1324
+ return { ok: true, entries };
1325
+ }
1326
+ if (op === "do.alarm.set") {
1327
+ __sbSql(
1328
+ store,
1329
+ "INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,0) ON CONFLICT (cls, id) DO UPDATE SET at = ?3, attempts = 0",
1330
+ [msg.cls, msg.id, Math.trunc(Number(msg.at) || 0)],
1331
+ );
1332
+ return { ok: true };
1333
+ }
1334
+ if (op === "do.alarm.get") {
1335
+ const r = __sbSql(store, "SELECT at FROM do_alarm WHERE cls = ? AND id = ?", [msg.cls, msg.id]);
1336
+ return { ok: true, at: r.rows.length ? Number(r.rows[0][0]) : null };
1337
+ }
1338
+ if (op === "do.alarm.delete") {
1339
+ const r = __sbSql(store, "DELETE FROM do_alarm WHERE cls = ? AND id = ?", [msg.cls, msg.id]);
1340
+ return { ok: true, deleted: r.changes > 0 };
1341
+ }
1342
+
1343
+ if (op === "ae.write") {
1344
+ __sbSql(store, "INSERT INTO ae (dataset, ts, indexes_json, blobs_json, doubles_json) VALUES (?1,?2,?3,?4,?5)", [
1345
+ msg.dataset,
1346
+ Date.now(),
1347
+ JSON.stringify(msg.indexes || []),
1348
+ JSON.stringify(msg.blobs || []),
1349
+ JSON.stringify(msg.doubles || []),
1350
+ ]);
1351
+ return { ok: true };
1352
+ }
1353
+ if (op === "ae.query") {
1354
+ // Reply must match the broker's: { count, rows: [{timestamp, indexes, blobs, doubles}] }.
1355
+ const limit = Math.min(Math.max(Number(msg.limit) || 20, 1), 200);
1356
+ const r = __sbSql(
1357
+ store,
1358
+ "SELECT ts, indexes_json, blobs_json, doubles_json FROM ae WHERE dataset = ? ORDER BY ts DESC, rowid DESC LIMIT ?",
1359
+ [msg.dataset, limit],
1360
+ );
1361
+ const total = __sbSql(store, "SELECT count(*) FROM ae WHERE dataset = ?", [msg.dataset]);
1362
+ const rows = [];
1363
+ for (let i = 0; i < r.rows.length; i++) {
1364
+ rows.push({
1365
+ timestamp: Number(r.rows[i][0]),
1366
+ indexes: JSON.parse(r.rows[i][1]),
1367
+ blobs: JSON.parse(r.rows[i][2]),
1368
+ doubles: JSON.parse(r.rows[i][3]),
1369
+ });
1370
+ }
1371
+ return { ok: true, count: total.rows.length ? Number(total.rows[0][0]) : 0, rows };
1372
+ }
1373
+
1374
+ throw new Error("unknown op: " + op);
1375
+ }
1376
+
1377
+ /** One D1 statement, shaped like the broker's d1Run. */
1378
+ function __sbD1Run(path, sql, params) {
1379
+ const r = __sbSql(path, String(sql), params || []);
1380
+ const results = [];
1381
+ for (let i = 0; i < r.rows.length; i++) {
1382
+ const row = {};
1383
+ for (let c = 0; c < r.cols.length; c++) row[r.cols[c]] = r.rows[i][c];
1384
+ results.push(row);
1385
+ }
1386
+ return { results, meta: { changes: r.changes, last_row_id: r.rowid, rows_read: r.rows.length } };
1387
+ }
1388
+
1389
+ /**
1390
+ * R2 object bodies, out of band (#56).
1391
+ *
1392
+ * The core shim calls these instead of putting an object body in a frame. The
1393
+ * broker transport defines the same two names in terms of __sbRpc, so the shim
1394
+ * itself does not know which backend it is on.
1395
+ */
1396
+ globalThis.__sbR2Put = function (bucket, key, body, httpMetadata, customMetadata) {
1397
+ __sbEnsureSchema();
1398
+ const reply = JSON.parse(
1399
+ __sbR2PutRaw(
1400
+ __sbStore(),
1401
+ String(bucket),
1402
+ String(key),
1403
+ body == null ? "" : String(body),
1404
+ JSON.stringify(httpMetadata || {}),
1405
+ JSON.stringify(customMetadata || {}),
1406
+ ),
1407
+ );
1408
+ if (reply.ok === false) throw new Error("sproutboat r2.put: " + reply.error);
1409
+ return { object: { key: String(key), size: reply.size, etag: reply.etag, uploaded: reply.uploaded } };
1410
+ };
1411
+
1412
+ globalThis.__sbR2Get = function (bucket, key) {
1413
+ // Metadata through the normal path (small), bytes through their own.
1414
+ const meta = __sbEmbeddedDispatch({ op: "r2.head", bucket, key });
1415
+ if (!meta.found) return { found: false };
1416
+ return { found: true, object: meta.object, body: __sbR2GetRaw(__sbStore(), String(bucket), String(key)) };
1417
+ };
1418
+
1419
+ globalThis.__sbAssetsGet = function (path) {
1420
+ return __sbEmbeddedDispatch({ op: "assets.get", path });
1421
+ };
1422
+
1423
+ /** The transport contract: one request string in, one reply string out. */
1424
+ function __sbCall(reqJson) {
1425
+ try {
1426
+ return JSON.stringify(__sbEmbeddedDispatch(JSON.parse(reqJson)));
1427
+ } catch (err) {
1428
+ return JSON.stringify({ ok: false, error: String((err && err.message) || err) });
1429
+ }
1430
+ }
1431
+
1432
+ // --- local triggers ---------------------------------------------------------
1433
+ // A deployed sprout is driven by the broker: it POSTs x-sb-trigger for cron
1434
+ // ticks, queue batches and DO alarms. An embedded binary has no broker, so the
1435
+ // same work runs on timers in this process and calls the handler directly.
1436
+ // Porffor's native-fetch runtime provides setInterval, so this needs no C.
1437
+
1438
+ /** 5-field cron match (min hour dom month dow, UTC) — the same rules broker.ts applies. */
1439
+ function __sbCronMatches(expr, when) {
1440
+ const parts = String(expr).trim().split(/\s+/);
1441
+ if (parts.length !== 5) return false;
1442
+ const fields = [
1443
+ when.getUTCMinutes(),
1444
+ when.getUTCHours(),
1445
+ when.getUTCDate(),
1446
+ when.getUTCMonth() + 1,
1447
+ when.getUTCDay(),
1448
+ ];
1449
+ for (let i = 0; i < 5; i++) {
1450
+ const spec = parts[i];
1451
+ const value = fields[i];
1452
+ const tokens = spec.split(",");
1453
+ let hit = false;
1454
+ for (let t = 0; t < tokens.length; t++) {
1455
+ const token = tokens[t];
1456
+ if (token === "*") {
1457
+ hit = true;
1458
+ break;
1459
+ }
1460
+ if (token.indexOf("*/") === 0) {
1461
+ const step = Number(token.slice(2));
1462
+ if (step && value % step === 0) {
1463
+ hit = true;
1464
+ break;
1465
+ }
1466
+ continue;
1467
+ }
1468
+ const range = token.split("-");
1469
+ if (range.length === 2) {
1470
+ if (value >= Number(range[0]) && value <= Number(range[1])) {
1471
+ hit = true;
1472
+ break;
1473
+ }
1474
+ continue;
1475
+ }
1476
+ if (Number(token) === value) {
1477
+ hit = true;
1478
+ break;
1479
+ }
1480
+ }
1481
+ if (!hit) return false;
1482
+ }
1483
+ return true;
1484
+ }
1485
+
1486
+ var __sbLastCronTick = "";
1487
+
1488
+ /**
1489
+ * Start the timers an embedded binary needs. Called from the generated module
1490
+ * once the handler object exists; the broker transport defines a no-op of the
1491
+ * same name, so the generated code is identical either way.
1492
+ */
1493
+ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
1494
+ const crons = bindings.crons || [];
1495
+ const queues = bindings.queues || [];
1496
+ const dos = bindings.do || [];
1497
+ const store = __sbStore();
1498
+
1499
+ if (crons.length > 0 && __sbIsFn(handlers.scheduled)) {
1500
+ setInterval(function () {
1501
+ const now = new Date();
1502
+ const stamp =
1503
+ now.getUTCFullYear() +
1504
+ "-" +
1505
+ now.getUTCMonth() +
1506
+ "-" +
1507
+ now.getUTCDate() +
1508
+ "-" +
1509
+ now.getUTCHours() +
1510
+ "-" +
1511
+ now.getUTCMinutes();
1512
+ if (stamp === __sbLastCronTick) return; // once a minute, like the broker
1513
+ __sbLastCronTick = stamp;
1514
+ for (let i = 0; i < crons.length; i++) {
1515
+ if (__sbCronMatches(crons[i], now)) {
1516
+ handlers.scheduled({ cron: crons[i], scheduledTime: now.getTime(), noRetry() {} });
1517
+ }
1518
+ }
1519
+ }, 15000);
1520
+ }
1521
+
1522
+ if (queues.length > 0 && __sbIsFn(handlers.queue)) {
1523
+ setInterval(function () {
1524
+ __sbEnsureSchema();
1525
+ const now = Date.now();
1526
+ for (let q = 0; q < queues.length; q++) {
1527
+ const name = queues[q];
1528
+ const due = __sbSql(
1529
+ store,
1530
+ "SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT 10",
1531
+ [name, now],
1532
+ );
1533
+ if (due.rows.length === 0) continue;
1534
+ // Hide the batch first, so a slow handler cannot have it delivered twice.
1535
+ const messages = [];
1536
+ for (let i = 0; i < due.rows.length; i++) {
1537
+ const row = due.rows[i];
1538
+ __sbSql(store, "UPDATE mq SET visible_at = ? WHERE id = ?", [now + 30000, row[0]]);
1539
+ messages.push({ id: row[0], body: row[1], timestamp: now, attempts: Number(row[2]) + 1 });
1540
+ }
1541
+ const result = __sbRunQueueBatch(handlers, { queue: name, messages });
1542
+ for (let i = 0; i < result.ack.length; i++) {
1543
+ __sbSql(store, "DELETE FROM mq WHERE id = ?", [result.ack[i]]);
1544
+ }
1545
+ for (let i = 0; i < result.retry.length; i++) {
1546
+ __sbSql(
1547
+ store,
1548
+ "UPDATE mq SET attempts = attempts + 1, visible_at = ?, dead = CASE WHEN attempts + 1 >= 5 THEN 1 ELSE 0 END WHERE id = ?",
1549
+ [Date.now() + 5000, result.retry[i]],
1550
+ );
1551
+ }
1552
+ }
1553
+ }, 500);
1554
+ }
1555
+
1556
+ if (dos.length > 0) {
1557
+ setInterval(function () {
1558
+ __sbEnsureSchema();
1559
+ const now = Date.now();
1560
+ const due = __sbSql(store, "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10", [
1561
+ now,
1562
+ ]);
1563
+ for (let i = 0; i < due.rows.length; i++) {
1564
+ const cls = due.rows[i][0];
1565
+ const id = due.rows[i][1];
1566
+ // Claim before running: alarm() may schedule the next one, and deleting
1567
+ // afterwards would erase it. Same rule as the broker (#125).
1568
+ __sbSql(store, "DELETE FROM do_alarm WHERE cls = ? AND id = ?", [cls, id]);
1569
+ const instance = __sbGetDOInstance(cls, id);
1570
+ if (__sbIsFn(instance.alarm)) instance.alarm();
1571
+ }
1572
+ }, 500);
1573
+ }
1574
+ };