sproutboat 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +304 -0
- package/README.md +33 -1
- package/SURFACE.md +15 -5
- package/package.json +12 -6
- package/src/bearssl.ts +180 -0
- package/src/broker.ts +420 -19
- package/src/build.ts +61 -1
- package/src/compile.ts +94 -14
- package/src/config.ts +25 -0
- package/src/dev.ts +2 -0
- package/src/main.ts +260 -6
- package/src/native-fetch-prelude.js +130 -171
- package/src/patch-porffor.ts +104 -6
- package/src/sqlite.ts +99 -0
- package/src/standalone-build.ts +64 -0
- package/src/surface.ts +54 -5
- package/src/toolchain.ts +71 -0
- package/src/transport-broker.js +299 -0
- package/src/transport-embedded.js +1571 -0
- package/src/types-gen.ts +90 -0
- package/src/wrap.ts +46 -2
- package/types/sproutboat.d.ts +217 -0
- package/vendor/sqlite-amalgamation-3500400/shell.c +34019 -0
- package/vendor/sqlite-amalgamation-3500400/sqlite3.c +262899 -0
- package/vendor/sqlite-amalgamation-3500400/sqlite3.h +13775 -0
- package/vendor/sqlite-amalgamation-3500400/sqlite3ext.h +723 -0
- package/vendor/sqlite-amalgamation.zip +0 -0
|
@@ -283,16 +283,10 @@ if (globalThis.crypto.randomUUID == null) {
|
|
|
283
283
|
// sprout event-loop turn per request, so a blocking roundtrip is acceptable).
|
|
284
284
|
// Wire frame:
|
|
285
285
|
// [u32 LE len][ <token> "\n" <json> ] reply: [u32 LE len][ <json> ]
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// ponytail: text values only; still AF_INET loopback, not AF_UNIX. A failed
|
|
291
|
-
// exchange reconnects and resends once — a broker crash between "request applied"
|
|
292
|
-
// and "reply read" can double-apply a non-idempotent op (queue.send, INSERT);
|
|
293
|
-
// the old fresh-connection-per-call path just failed the call there instead.
|
|
294
|
-
// Binary values + AF_UNIX = v2.
|
|
295
|
-
|
|
286
|
+
// Shared C preamble: the headers, the two Porffor marshalling helpers every
|
|
287
|
+
// inline-C block uses, and the CSPRNG. Lives here rather than in a transport so
|
|
288
|
+
// both transports — and __sbRandomBytes / __sbEnv below — compile against the
|
|
289
|
+
// same declarations.
|
|
296
290
|
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
297
291
|
Porffor.c`
|
|
298
292
|
#include <sys/socket.h>
|
|
@@ -330,118 +324,9 @@ static int sb_os_random(unsigned char* buf, size_t n) {
|
|
|
330
324
|
return 0;
|
|
331
325
|
}
|
|
332
326
|
|
|
333
|
-
static int sb_io_all(int fd, unsigned char* buf, size_t len, int writing) {
|
|
334
|
-
size_t done = 0;
|
|
335
|
-
while (done < len) {
|
|
336
|
-
long n = writing ? write(fd, buf + done, len - done) : read(fd, buf + done, len - done);
|
|
337
|
-
if (n <= 0) {
|
|
338
|
-
if (n < 0 && errno == EINTR) continue;
|
|
339
|
-
return -1;
|
|
340
|
-
}
|
|
341
|
-
done += (size_t)n;
|
|
342
|
-
}
|
|
343
|
-
return 0;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// One long-lived loopback connection to the broker, reused across every binding
|
|
347
|
-
// call. The broker frames each request/reply independently and keeps the socket
|
|
348
|
-
// open, so the steady-state per-call cost is just write + read — no socket(),
|
|
349
|
-
// connect() handshake or close() each time. -1 = not connected.
|
|
350
|
-
static int sb_broker_fd = -1;
|
|
351
|
-
|
|
352
|
-
static int sb_broker_connect(void) {
|
|
353
|
-
const char* port_s = getenv("SB_BROKER_PORT");
|
|
354
|
-
if (!port_s) return -10;
|
|
355
|
-
signal(SIGPIPE, SIG_IGN); // a dead broker must yield EPIPE, not kill the sprout
|
|
356
|
-
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
|
357
|
-
if (fd < 0) return -1;
|
|
358
|
-
int one = 1;
|
|
359
|
-
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
|
360
|
-
struct sockaddr_in addr;
|
|
361
|
-
memset(&addr, 0, sizeof(addr));
|
|
362
|
-
addr.sin_family = AF_INET;
|
|
363
|
-
addr.sin_port = htons((unsigned short)atoi(port_s));
|
|
364
|
-
addr.sin_addr.s_addr = htonl(0x7f000001u); // 127.0.0.1
|
|
365
|
-
if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) { close(fd); return -2; }
|
|
366
|
-
sb_broker_fd = fd;
|
|
367
|
-
return 0;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Send one framed request, read one framed reply, on the persistent fd.
|
|
371
|
-
static int sb_broker_exchange(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
|
|
372
|
-
const char* tok = getenv("SB_BROKER_TOKEN");
|
|
373
|
-
size_t tok_len = tok ? strlen(tok) : 0;
|
|
374
|
-
|
|
375
|
-
// frame body: token "\n" json
|
|
376
|
-
size_t body_len = tok_len + 1 + req_len;
|
|
377
|
-
unsigned char* frame = (unsigned char*)malloc(4 + body_len);
|
|
378
|
-
if (!frame) return -5;
|
|
379
|
-
frame[0] = (unsigned char)(body_len & 0xff);
|
|
380
|
-
frame[1] = (unsigned char)((body_len >> 8) & 0xff);
|
|
381
|
-
frame[2] = (unsigned char)((body_len >> 16) & 0xff);
|
|
382
|
-
frame[3] = (unsigned char)((body_len >> 24) & 0xff);
|
|
383
|
-
if (tok_len) memcpy(frame + 4, tok, tok_len);
|
|
384
|
-
frame[4 + tok_len] = '\n';
|
|
385
|
-
if (req_len) memcpy(frame + 4 + tok_len + 1, req, req_len);
|
|
386
|
-
int wr = sb_io_all(sb_broker_fd, frame, 4 + body_len, 1);
|
|
387
|
-
free(frame);
|
|
388
|
-
if (wr != 0) return -3;
|
|
389
|
-
|
|
390
|
-
unsigned char rhdr[4];
|
|
391
|
-
if (sb_io_all(sb_broker_fd, rhdr, 4, 0) != 0) return -4;
|
|
392
|
-
size_t rlen = (size_t)rhdr[0] | ((size_t)rhdr[1] << 8) | ((size_t)rhdr[2] << 16) | ((size_t)rhdr[3] << 24);
|
|
393
|
-
|
|
394
|
-
char* buf = (char*)malloc(rlen ? rlen : 1);
|
|
395
|
-
if (!buf) return -5;
|
|
396
|
-
if (rlen && sb_io_all(sb_broker_fd, (unsigned char*)buf, rlen, 0) != 0) { free(buf); return -6; }
|
|
397
|
-
|
|
398
|
-
*resp_out = buf;
|
|
399
|
-
*resp_len_out = rlen;
|
|
400
|
-
return 0;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
static int sb_broker_roundtrip(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
|
|
404
|
-
*resp_out = NULL;
|
|
405
|
-
*resp_len_out = 0;
|
|
406
|
-
// Two tries: a broker restart (or an idle-closed socket) invalidates the fd,
|
|
407
|
-
// so a failed exchange drops the connection and reconnects once before failing.
|
|
408
|
-
for (int attempt = 0; attempt < 2; attempt++) {
|
|
409
|
-
if (sb_broker_fd < 0) {
|
|
410
|
-
int rc = sb_broker_connect();
|
|
411
|
-
if (rc != 0) return rc;
|
|
412
|
-
}
|
|
413
|
-
int rc = sb_broker_exchange(req, req_len, resp_out, resp_len_out);
|
|
414
|
-
if (rc == 0) return 0;
|
|
415
|
-
close(sb_broker_fd);
|
|
416
|
-
sb_broker_fd = -1;
|
|
417
|
-
}
|
|
418
|
-
return -3;
|
|
419
|
-
}
|
|
420
327
|
`;
|
|
421
328
|
|
|
422
|
-
//
|
|
423
|
-
// generated C names it directly in the RawC block below.
|
|
424
|
-
// oxlint-disable-next-line no-unused-vars -- `reqJson` is read inside the RawC block below, not by JS.
|
|
425
|
-
function __sbCall(reqJson) {
|
|
426
|
-
let res = "";
|
|
427
|
-
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
428
|
-
Porffor.c`
|
|
429
|
-
const char* __req; size_t __reqlen; char* __reqowned = 0;
|
|
430
|
-
porf_native_fetch_read_value(reqJson, &__req, &__reqlen, &__reqowned);
|
|
431
|
-
char* __resp = 0; size_t __resplen = 0;
|
|
432
|
-
int __rc = sb_broker_roundtrip(__req, __reqlen, &__resp, &__resplen);
|
|
433
|
-
if (__reqowned) free(__reqowned);
|
|
434
|
-
if (__rc == 0) {
|
|
435
|
-
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__resp, __resplen), 195);
|
|
436
|
-
free(__resp);
|
|
437
|
-
} else {
|
|
438
|
-
char __e[40];
|
|
439
|
-
int __n = snprintf(__e, sizeof(__e), "{\"ok\":false,\"error\":\"broker rc %d\"}", __rc);
|
|
440
|
-
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__e, (size_t)__n), 195);
|
|
441
|
-
}
|
|
442
|
-
`;
|
|
443
|
-
return res;
|
|
444
|
-
}
|
|
329
|
+
// TRANSPORT: wrap.ts splices one of transport-broker.js / transport-embedded.js here.
|
|
445
330
|
|
|
446
331
|
// `nStr` is the decimal byte count as a string (same string-param pattern as
|
|
447
332
|
// __sbEnv). Returns a bytestring of that many CSPRNG bytes, or '' on failure.
|
|
@@ -469,8 +354,14 @@ function __sbRandomBytes(nStr) {
|
|
|
469
354
|
return out;
|
|
470
355
|
}
|
|
471
356
|
|
|
357
|
+
// #63 — every request carries the protocol version it was built against and an
|
|
358
|
+
// id unique to this process. The id is what makes a resend safe: the transport
|
|
359
|
+
// retries the *same bytes*, so a broker that already applied the request can
|
|
360
|
+
// recognise it and replay its answer instead of applying it twice.
|
|
361
|
+
var __sbReqId = 0;
|
|
362
|
+
|
|
472
363
|
function __sbRpc(op, extra) {
|
|
473
|
-
const req = { op };
|
|
364
|
+
const req = { v: 1, id: ++__sbReqId, op };
|
|
474
365
|
if (extra) for (const k in extra) req[k] = extra[k];
|
|
475
366
|
const reply = JSON.parse(__sbCall(JSON.stringify(req)));
|
|
476
367
|
if (reply && reply.ok === false) throw new Error(`sproutboat ${op}: ${reply.error || "failed"}`);
|
|
@@ -609,16 +500,19 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
609
500
|
target[name] = {
|
|
610
501
|
put(key, value, options) {
|
|
611
502
|
const o = options || {};
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
503
|
+
// #56 — the body goes out of band where the transport allows it.
|
|
504
|
+
return __sbR2Put(
|
|
505
|
+
name,
|
|
506
|
+
String(key),
|
|
507
|
+
value == null ? "" : String(value),
|
|
508
|
+
o.httpMetadata || {},
|
|
509
|
+
o.customMetadata || {},
|
|
510
|
+
).object;
|
|
619
511
|
},
|
|
620
512
|
get(key) {
|
|
621
|
-
|
|
513
|
+
// #56 — bytes come back out of band on a transport that supports it, so
|
|
514
|
+
// an object body is never JSON-escaped into a frame.
|
|
515
|
+
const r = __sbR2Get(name, String(key));
|
|
622
516
|
return r.found ? __sbR2Object(r.object, r.body == null ? "" : r.body) : null;
|
|
623
517
|
},
|
|
624
518
|
head(key) {
|
|
@@ -693,8 +587,8 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
693
587
|
|
|
694
588
|
// Static assets: env.<ASSETS>.fetch(request) -> broker `assets.get`. The edge
|
|
695
589
|
// already serves matching files directly; the sprout only calls this for paths
|
|
696
|
-
// it wants to own (SPA fallback, auth-gated files).
|
|
697
|
-
//
|
|
590
|
+
// it wants to own (SPA fallback, auth-gated files). The transport keeps the
|
|
591
|
+
// body byte-preserving for binary assets.
|
|
698
592
|
if (bindings.assets) {
|
|
699
593
|
target[bindings.assets] = {
|
|
700
594
|
fetch(input) {
|
|
@@ -704,7 +598,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
704
598
|
} catch {
|
|
705
599
|
/* use as-is */
|
|
706
600
|
}
|
|
707
|
-
const r =
|
|
601
|
+
const r = globalThis.__sbAssetsGet(path);
|
|
708
602
|
const headers = {};
|
|
709
603
|
if (r.type) headers["content-type"] = r.type;
|
|
710
604
|
if (r.found) headers["etag"] = '"' + r.hash + '"';
|
|
@@ -713,6 +607,34 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
713
607
|
};
|
|
714
608
|
}
|
|
715
609
|
|
|
610
|
+
// #48 — worker-to-worker. Same wire shape as outbound fetch, but the broker
|
|
611
|
+
// resolves the target itself and forwards it internally, so this is not
|
|
612
|
+
// egress and is not subject to the outbound allowlist.
|
|
613
|
+
for (let i = 0; i < (bindings.services || []).length; i++) {
|
|
614
|
+
const binding = bindings.services[i].binding;
|
|
615
|
+
target[binding] = {
|
|
616
|
+
fetch(input, init) {
|
|
617
|
+
const url = __sbIsStr(input) ? input : String((input && input.url) || "https://service/");
|
|
618
|
+
const opts = init || (!__sbIsStr(input) && input) || {};
|
|
619
|
+
const headers = [];
|
|
620
|
+
if (opts.headers) {
|
|
621
|
+
if (__sbIsFn(opts.headers.forEach)) opts.headers.forEach((v, k) => headers.push([k, v]));
|
|
622
|
+
else for (const k in opts.headers) headers.push([k, opts.headers[k]]);
|
|
623
|
+
}
|
|
624
|
+
const r = __sbRpc("service.fetch", {
|
|
625
|
+
binding,
|
|
626
|
+
url,
|
|
627
|
+
method: opts.method || "GET",
|
|
628
|
+
headers,
|
|
629
|
+
body: opts.body == null ? null : String(opts.body),
|
|
630
|
+
});
|
|
631
|
+
const respHeaders = new Headers();
|
|
632
|
+
for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
|
|
633
|
+
return new Response(r.body == null ? "" : r.body, { status: r.status || 502, headers: respHeaders });
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
716
638
|
if ((bindings.outbound || []).length > 0) {
|
|
717
639
|
globalThis.fetch = function (input, init) {
|
|
718
640
|
const url = __sbIsStr(input) ? input : String(input.url);
|
|
@@ -858,6 +780,19 @@ function __sbDOStorage(cls, id) {
|
|
|
858
780
|
deleteAll() {
|
|
859
781
|
__sbRpc("do.storage.delete_all", { cls, id });
|
|
860
782
|
},
|
|
783
|
+
// #125 — alarms. Cloudflare takes a Date or epoch ms; at most one is
|
|
784
|
+
// pending per object, so setting one replaces any earlier alarm.
|
|
785
|
+
setAlarm(when) {
|
|
786
|
+
const at = when instanceof Date ? when.getTime() : Number(when);
|
|
787
|
+
__sbRpc("do.alarm.set", { cls, id, at: at });
|
|
788
|
+
},
|
|
789
|
+
getAlarm() {
|
|
790
|
+
const r = __sbRpc("do.alarm.get", { cls, id });
|
|
791
|
+
return r.at == null ? null : r.at;
|
|
792
|
+
},
|
|
793
|
+
deleteAlarm() {
|
|
794
|
+
__sbRpc("do.alarm.delete", { cls, id });
|
|
795
|
+
},
|
|
861
796
|
list(options) {
|
|
862
797
|
const o = options || {};
|
|
863
798
|
const r = __sbRpc("do.storage.list", {
|
|
@@ -898,7 +833,14 @@ function __sbEnv(name) {
|
|
|
898
833
|
|
|
899
834
|
function __sbTriggerAuthed(request) {
|
|
900
835
|
const want = __sbEnv("SB_BROKER_TOKEN");
|
|
901
|
-
|
|
836
|
+
// No token configured means no caller can be trusted to send one, so refuse
|
|
837
|
+
// rather than wave the request through. Every path that legitimately delivers
|
|
838
|
+
// a trigger over HTTP sets SB_BROKER_TOKEN — the supervisor per deployment,
|
|
839
|
+
// `sproutboat dev`, the standalone launcher. The one build that has no token
|
|
840
|
+
// is the embedded binary (#15), which fires its own triggers in-process and
|
|
841
|
+
// is also the one most likely to be listening on a public interface: exactly
|
|
842
|
+
// where "anyone may invoke scheduled()" would be a hole.
|
|
843
|
+
if (!want) return false;
|
|
902
844
|
return request.headers.get("x-sb-token") === want;
|
|
903
845
|
}
|
|
904
846
|
|
|
@@ -931,50 +873,67 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
931
873
|
|
|
932
874
|
if (trigger === "queue") {
|
|
933
875
|
if (!__sbIsFn(handlers.queue)) return new Response("no queue handler", { status: 404 });
|
|
876
|
+
const result = __sbRunQueueBatch(handlers, __sbReadJson(request));
|
|
877
|
+
return new Response(JSON.stringify(result), { headers: { "content-type": "application/json" } });
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (trigger === "alarm") {
|
|
934
881
|
const body = __sbReadJson(request);
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
for (let i = 0; i < raw.length; i++) {
|
|
940
|
-
const m = raw[i];
|
|
941
|
-
const msg = {
|
|
942
|
-
id: m.id,
|
|
943
|
-
timestamp: m.timestamp,
|
|
944
|
-
attempts: m.attempts || 1,
|
|
945
|
-
body: __sbTryParse(m.body),
|
|
946
|
-
ack() {
|
|
947
|
-
if (acked.indexOf(m.id) === -1) acked.push(m.id);
|
|
948
|
-
},
|
|
949
|
-
retry() {
|
|
950
|
-
if (retried.indexOf(m.id) === -1) retried.push(m.id);
|
|
951
|
-
},
|
|
952
|
-
};
|
|
953
|
-
messages.push(msg);
|
|
954
|
-
}
|
|
955
|
-
const batch = {
|
|
956
|
-
queue: body.queue || "",
|
|
957
|
-
messages,
|
|
958
|
-
ackAll() {
|
|
959
|
-
for (let i = 0; i < messages.length; i++) messages[i].ack();
|
|
960
|
-
},
|
|
961
|
-
retryAll() {
|
|
962
|
-
for (let i = 0; i < messages.length; i++) messages[i].retry();
|
|
963
|
-
},
|
|
964
|
-
};
|
|
965
|
-
handlers.queue(batch);
|
|
966
|
-
// default: any message neither acked nor retried is treated as acked
|
|
967
|
-
for (let i = 0; i < messages.length; i++) {
|
|
968
|
-
if (acked.indexOf(messages[i].id) === -1 && retried.indexOf(messages[i].id) === -1) acked.push(messages[i].id);
|
|
969
|
-
}
|
|
970
|
-
return new Response(JSON.stringify({ ack: acked, retry: retried }), {
|
|
971
|
-
headers: { "content-type": "application/json" },
|
|
972
|
-
});
|
|
882
|
+
const inst = __sbGetDOInstance(String(body.cls || ""), String(body.id || ""));
|
|
883
|
+
if (!__sbIsFn(inst.alarm)) return new Response("no alarm handler", { status: 404 });
|
|
884
|
+
inst.alarm();
|
|
885
|
+
return new Response("", { status: 204 });
|
|
973
886
|
}
|
|
974
887
|
|
|
975
888
|
return new Response("unknown trigger", { status: 400 });
|
|
976
889
|
};
|
|
977
890
|
|
|
891
|
+
/**
|
|
892
|
+
* Run one queue batch through the handler and report what it acked.
|
|
893
|
+
*
|
|
894
|
+
* Shared so the two ways a batch can arrive agree: over HTTP from the broker
|
|
895
|
+
* (deployed, and the phase-0 standalone launcher), or straight from the local
|
|
896
|
+
* timer in an embedded binary that has no broker to be delivered from.
|
|
897
|
+
*/
|
|
898
|
+
function __sbRunQueueBatch(handlers, body) {
|
|
899
|
+
const acked = [];
|
|
900
|
+
const retried = [];
|
|
901
|
+
const raw = body.messages || [];
|
|
902
|
+
const messages = [];
|
|
903
|
+
for (let i = 0; i < raw.length; i++) {
|
|
904
|
+
const m = raw[i];
|
|
905
|
+
const msg = {
|
|
906
|
+
id: m.id,
|
|
907
|
+
timestamp: m.timestamp,
|
|
908
|
+
attempts: m.attempts || 1,
|
|
909
|
+
body: __sbTryParse(m.body),
|
|
910
|
+
ack() {
|
|
911
|
+
if (acked.indexOf(m.id) === -1) acked.push(m.id);
|
|
912
|
+
},
|
|
913
|
+
retry() {
|
|
914
|
+
if (retried.indexOf(m.id) === -1) retried.push(m.id);
|
|
915
|
+
},
|
|
916
|
+
};
|
|
917
|
+
messages.push(msg);
|
|
918
|
+
}
|
|
919
|
+
const batch = {
|
|
920
|
+
queue: body.queue || "",
|
|
921
|
+
messages,
|
|
922
|
+
ackAll() {
|
|
923
|
+
for (let i = 0; i < messages.length; i++) messages[i].ack();
|
|
924
|
+
},
|
|
925
|
+
retryAll() {
|
|
926
|
+
for (let i = 0; i < messages.length; i++) messages[i].retry();
|
|
927
|
+
},
|
|
928
|
+
};
|
|
929
|
+
handlers.queue(batch);
|
|
930
|
+
// default: any message neither acked nor retried is treated as acked
|
|
931
|
+
for (let i = 0; i < messages.length; i++) {
|
|
932
|
+
if (acked.indexOf(messages[i].id) === -1 && retried.indexOf(messages[i].id) === -1) acked.push(messages[i].id);
|
|
933
|
+
}
|
|
934
|
+
return { ack: acked, retry: retried };
|
|
935
|
+
}
|
|
936
|
+
|
|
978
937
|
function __sbReadJson(request) {
|
|
979
938
|
try {
|
|
980
939
|
return JSON.parse(request.body == null ? "{}" : String(request.body));
|
package/src/patch-porffor.ts
CHANGED
|
@@ -16,20 +16,119 @@ import { readFile, writeFile } from "node:fs/promises";
|
|
|
16
16
|
import { resolve } from "node:path";
|
|
17
17
|
import { porfforRoot } from "./toolchain";
|
|
18
18
|
|
|
19
|
-
//
|
|
19
|
+
// Each edit below is independent and idempotent, with its own marker: a file
|
|
20
|
+
// patched by an older version of this file must still receive the newer edits.
|
|
20
21
|
const ANCHOR = "f64 porf_native_fetch_get_port(void) {\n";
|
|
21
|
-
|
|
22
|
+
|
|
23
|
+
/** Port from $PORT (the supervisor and `sproutboat dev` set it). */
|
|
24
|
+
const ENV_INJECT =
|
|
22
25
|
' const char* __sb_port = getenv("PORT");\n' +
|
|
23
26
|
" if (__sb_port && *__sb_port) { long __sb_v = strtol(__sb_port, NULL, 10); if (__sb_v > 0 && __sb_v < 65536) return (f64)__sb_v; }\n";
|
|
24
|
-
const
|
|
27
|
+
const ENV_MARKER = 'getenv("PORT")';
|
|
28
|
+
|
|
29
|
+
// #15 — no `--port` flag: Porffor's native-fetch entry point calls
|
|
30
|
+
// `porf_init(0, NULL)` (see porf_native_fetch_runtime_init in render.js), so a
|
|
31
|
+
// native-fetch binary never sees argv at all. A standalone binary takes its
|
|
32
|
+
// port and data directory from the environment instead, which is what systemd
|
|
33
|
+
// and docker set anyway. Worth an upstream note alongside the $PORT ask.
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* #15 — let the build add objects to the native-fetch link line.
|
|
37
|
+
*
|
|
38
|
+
* Porffor builds `linkArgs` as a fixed array, so an embedded backend that needs
|
|
39
|
+
* SQLite compiled into the sprout has nowhere to put it. `CXX` is not a way in:
|
|
40
|
+
* a musl (deploy) build overrides it outright. This splices one spread of
|
|
41
|
+
* `SB_EXTRA_LINK` before `-lm`, inert unless the variable is set.
|
|
42
|
+
*/
|
|
43
|
+
const LINK_ANCHOR = " uSocketsArchive,\n '-lm'\n";
|
|
44
|
+
const LINK_INJECT =
|
|
45
|
+
" ...(process.env.SB_EXTRA_LINK ? process.env.SB_EXTRA_LINK.split(' ').filter(Boolean) : []),\n";
|
|
46
|
+
const LINK_MARKER = "SB_EXTRA_LINK";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* #15 — and the same for the compile step, so the prelude's inline C can
|
|
50
|
+
* `#include <bearssl.h>`. The link patch alone is not enough: Porffor compiles
|
|
51
|
+
* the generated C from stdin with a fixed argument list, so there is otherwise
|
|
52
|
+
* no way to add an include path.
|
|
53
|
+
*/
|
|
54
|
+
const CFLAGS_ANCHOR = " '-xc', '-', '-c',\n";
|
|
55
|
+
const CFLAGS_INJECT =
|
|
56
|
+
" ...(process.env.SB_EXTRA_CFLAGS ? process.env.SB_EXTRA_CFLAGS.split(' ').filter(Boolean) : []),\n";
|
|
57
|
+
const CFLAGS_MARKER = "SB_EXTRA_CFLAGS";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* #56 — make the inbound request-body limit configurable.
|
|
61
|
+
*
|
|
62
|
+
* Porffor's uWebSockets shim hardcodes 1 MiB and answers anything larger with a
|
|
63
|
+
* bare `413 request body too large` before the handler runs, so a project can
|
|
64
|
+
* neither accept a bigger upload nor say anything useful about the refusal.
|
|
65
|
+
*
|
|
66
|
+
* The default stays 1 MiB: a larger body is held in memory whole, so raising it
|
|
67
|
+
* is a decision about this deployment's memory, not something to inherit.
|
|
68
|
+
*/
|
|
69
|
+
const BODY_ANCHOR = "static const size_t REQUEST_BODY_MAX_BYTES = 1024u * 1024u;";
|
|
70
|
+
const BODY_INJECT = `static size_t sb_request_body_max(void) {
|
|
71
|
+
static size_t cached = 0;
|
|
72
|
+
if (cached == 0) {
|
|
73
|
+
const char* raw = getenv("SB_REQUEST_BODY_MAX");
|
|
74
|
+
long parsed = raw && *raw ? atol(raw) : 0;
|
|
75
|
+
cached = parsed > 0 ? (size_t)parsed : 1024u * 1024u;
|
|
76
|
+
}
|
|
77
|
+
return cached;
|
|
78
|
+
}
|
|
79
|
+
#define REQUEST_BODY_MAX_BYTES sb_request_body_max()`;
|
|
80
|
+
const BODY_MARKER = "sb_request_body_max";
|
|
25
81
|
|
|
26
82
|
let done = false;
|
|
27
83
|
|
|
84
|
+
/** The uWebSockets shim source, which is where the body limit lives. */
|
|
85
|
+
async function patchBodyLimit(): Promise<void> {
|
|
86
|
+
const file = resolve(porfforRoot(), "compiler/uwebsockets.js");
|
|
87
|
+
const src = await readFile(file, "utf8");
|
|
88
|
+
if (src.includes(BODY_MARKER)) return;
|
|
89
|
+
if (!src.includes(BODY_ANCHOR)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`could not patch Porffor's request body limit: anchor not found in ${file}. ` +
|
|
92
|
+
"Porffor's uWebSockets shim changed — check patches/UPSTREAM.md.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
await writeFile(file, src.replace(BODY_ANCHOR, BODY_INJECT));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function patchCompilerArgs(): Promise<void> {
|
|
99
|
+
const file = resolve(porfforRoot(), "compiler/index.js");
|
|
100
|
+
let src = await readFile(file, "utf8");
|
|
101
|
+
let changed = false;
|
|
102
|
+
for (const [marker, anchor, inject, what] of [
|
|
103
|
+
[LINK_MARKER, LINK_ANCHOR, LINK_INJECT, "extra link args"],
|
|
104
|
+
[CFLAGS_MARKER, CFLAGS_ANCHOR, CFLAGS_INJECT, "extra compiler flags"],
|
|
105
|
+
] as const) {
|
|
106
|
+
if (src.includes(marker)) continue;
|
|
107
|
+
const at = src.indexOf(anchor);
|
|
108
|
+
if (at === -1) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`could not patch Porffor for ${what}: anchor not found in ${file}. ` +
|
|
111
|
+
"Porffor's native-fetch build changed — check patches/UPSTREAM.md.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
// After the anchor for cflags (the args follow it), before it for the link
|
|
115
|
+
// line (the object list ends with it).
|
|
116
|
+
src =
|
|
117
|
+
marker === CFLAGS_MARKER
|
|
118
|
+
? src.slice(0, at + anchor.length) + inject + src.slice(at + anchor.length)
|
|
119
|
+
: src.slice(0, at) + inject + src.slice(at);
|
|
120
|
+
changed = true;
|
|
121
|
+
}
|
|
122
|
+
if (changed) await writeFile(file, src);
|
|
123
|
+
}
|
|
124
|
+
|
|
28
125
|
export async function ensurePorfforPatched(): Promise<void> {
|
|
29
126
|
if (done) return;
|
|
127
|
+
await patchCompilerArgs();
|
|
128
|
+
await patchBodyLimit();
|
|
30
129
|
const file = resolve(porfforRoot(), "compiler/render.js");
|
|
31
130
|
const src = await readFile(file, "utf8");
|
|
32
|
-
if (src.includes(
|
|
131
|
+
if (src.includes(ENV_MARKER)) {
|
|
33
132
|
done = true;
|
|
34
133
|
return;
|
|
35
134
|
}
|
|
@@ -40,7 +139,6 @@ export async function ensurePorfforPatched(): Promise<void> {
|
|
|
40
139
|
"Porffor's native-fetch renderer changed — check patches/UPSTREAM.md.",
|
|
41
140
|
);
|
|
42
141
|
}
|
|
43
|
-
|
|
44
|
-
await writeFile(file, patched);
|
|
142
|
+
await writeFile(file, src.slice(0, anchorAt + ANCHOR.length) + ENV_INJECT + src.slice(anchorAt + ANCHOR.length));
|
|
45
143
|
done = true;
|
|
46
144
|
}
|
package/src/sqlite.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #15 — the SQLite object an embedded-backend sprout links against.
|
|
3
|
+
*
|
|
4
|
+
* Built once and cached: the amalgamation is a single 9 MB C file that takes
|
|
5
|
+
* ~10s to compile, which is fine once per toolchain and intolerable per build.
|
|
6
|
+
* Cached beside the Zig toolchain, keyed by version + target, so switching
|
|
7
|
+
* targets or bumping SQLite cannot silently reuse the wrong object.
|
|
8
|
+
*
|
|
9
|
+
* The amalgamation is downloaded rather than vendored: 9 MB of third-party C in
|
|
10
|
+
* the repo would dwarf the CLI, and the checksum below is what makes the
|
|
11
|
+
* download safe to trust.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
|
|
20
|
+
export const SQLITE_VERSION = "3.50.4";
|
|
21
|
+
|
|
22
|
+
/** Same cache root Zig uses, so one `rm -rf ~/.cache/sproutboat` clears everything. */
|
|
23
|
+
const cacheDir = (): string => resolve(homedir(), ".cache/sproutboat", `sqlite-${SQLITE_VERSION}`);
|
|
24
|
+
const SQLITE_ZIP = `https://sqlite.org/2025/sqlite-amalgamation-3500400.zip`;
|
|
25
|
+
const SQLITE_SHA256 = "1d3049dd0f830a025a53105fc79fd2ab9431aea99e137809d064d8ee8356b032";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Compile flags. THREADSAFE=0 because a sprout serves one turn at a time;
|
|
29
|
+
* DQS=0 rejects double-quoted string literals, which is the setting that turns
|
|
30
|
+
* a typo'd column name in user SQL into an error instead of a string.
|
|
31
|
+
*/
|
|
32
|
+
const SQLITE_DEFINES = [
|
|
33
|
+
"-DSQLITE_THREADSAFE=0",
|
|
34
|
+
"-DSQLITE_OMIT_LOAD_EXTENSION",
|
|
35
|
+
"-DSQLITE_DQS=0",
|
|
36
|
+
"-DSQLITE_DEFAULT_MEMSTATUS=0",
|
|
37
|
+
"-DSQLITE_OMIT_DEPRECATED",
|
|
38
|
+
"-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const run = (cmd: string, args: string[]): Promise<{ code: number; stderr: string }> =>
|
|
42
|
+
new Promise((done) => {
|
|
43
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
44
|
+
let stderr = "";
|
|
45
|
+
child.stderr.on("data", (chunk) => (stderr += String(chunk)));
|
|
46
|
+
child.on("close", (code) => done({ code: code ?? 1, stderr }));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function amalgamation(): Promise<string> {
|
|
50
|
+
const dir = cacheDir();
|
|
51
|
+
const source = resolve(dir, "sqlite3.c");
|
|
52
|
+
if (existsSync(source)) return source;
|
|
53
|
+
await mkdir(dir, { recursive: true });
|
|
54
|
+
|
|
55
|
+
const response = await fetch(SQLITE_ZIP);
|
|
56
|
+
if (!response.ok) throw new Error(`could not download SQLite ${SQLITE_VERSION}: HTTP ${response.status}`);
|
|
57
|
+
const zip = Buffer.from(await response.arrayBuffer());
|
|
58
|
+
const digest = createHash("sha256").update(zip).digest("hex");
|
|
59
|
+
if (digest !== SQLITE_SHA256) {
|
|
60
|
+
throw new Error(`SQLite amalgamation checksum mismatch: expected ${SQLITE_SHA256}, got ${digest}`);
|
|
61
|
+
}
|
|
62
|
+
const zipPath = resolve(dir, "amalgamation.zip");
|
|
63
|
+
await writeFile(zipPath, zip);
|
|
64
|
+
const unzip = await run("unzip", ["-oqj", zipPath, "-d", dir]);
|
|
65
|
+
if (unzip.code !== 0) throw new Error(`could not unpack the SQLite amalgamation: ${unzip.stderr}`);
|
|
66
|
+
if (!existsSync(source)) throw new Error("the SQLite amalgamation did not contain sqlite3.c");
|
|
67
|
+
return source;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type SqliteObjectInput = {
|
|
71
|
+
/** `host` compiles with cc; `linux-x86_64` cross-compiles with the pinned Zig. */
|
|
72
|
+
target: "linux-x86_64" | "host";
|
|
73
|
+
/** Zig binary, required for the linux target. */
|
|
74
|
+
zigBin?: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Path to `sqlite3.o` for this target, building it on first use. */
|
|
78
|
+
export async function ensureSqliteObject(input: SqliteObjectInput): Promise<string> {
|
|
79
|
+
const dir = cacheDir();
|
|
80
|
+
const objectPath = resolve(dir, `sqlite3-${input.target}.o`);
|
|
81
|
+
if (existsSync(objectPath)) return objectPath;
|
|
82
|
+
|
|
83
|
+
const source = await amalgamation();
|
|
84
|
+
const [cmd, prefix] =
|
|
85
|
+
input.target === "host"
|
|
86
|
+
? (["cc", []] as const)
|
|
87
|
+
: ([input.zigBin ?? "zig", ["cc", "-target", "x86_64-linux-musl"]] as const);
|
|
88
|
+
const result = await run(cmd, [...prefix, "-c", source, "-o", objectPath, "-O2", ...SQLITE_DEFINES]);
|
|
89
|
+
if (result.code !== 0) throw new Error(`could not compile SQLite for ${input.target}:\n${result.stderr}`);
|
|
90
|
+
return objectPath;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The SQLite build stamp, for the artifact manifest's provenance string. */
|
|
94
|
+
export const sqliteStamp = (): string => `sqlite/${SQLITE_VERSION}`;
|
|
95
|
+
|
|
96
|
+
/** Read back the compiled object's size, for reporting. */
|
|
97
|
+
export async function sqliteObjectSize(path: string): Promise<number> {
|
|
98
|
+
return (await readFile(path)).byteLength;
|
|
99
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #15 — `sproutboat build --standalone`.
|
|
3
|
+
*
|
|
4
|
+
* Emits ONE executable carrying the compiled sprout, its static assets and its
|
|
5
|
+
* binding declarations, with SQLite linked in so there is no broker and no
|
|
6
|
+
* second process. The build is thin on purpose: `buildArtifact` with the
|
|
7
|
+
* embedded transport already produces exactly the binary we want, so this
|
|
8
|
+
* copies it out and enforces the one thing a standalone binary cannot do.
|
|
9
|
+
*/
|
|
10
|
+
import { chmod, cp, mkdir, readFile } from "node:fs/promises";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { buildArtifact, type BuildInput } from "./build";
|
|
13
|
+
import type { Bindings } from "./wrap";
|
|
14
|
+
|
|
15
|
+
export type StandaloneBuildInput = BuildInput & {
|
|
16
|
+
/** Where to write the executable. Defaults to `<projectDir>/dist/<name>`. */
|
|
17
|
+
outPath?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type StandaloneBuildResult = { outPath: string; bytes: number };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Bindings a standalone binary cannot serve.
|
|
24
|
+
*
|
|
25
|
+
* A build error, not a runtime no-op: a binary whose binding silently does
|
|
26
|
+
* nothing is discovered in production, on a device someone has to reach
|
|
27
|
+
* physically.
|
|
28
|
+
*
|
|
29
|
+
* Outbound `fetch` is absent from this list: http and https both work, with
|
|
30
|
+
* BearSSL and the Mozilla root set compiled in.
|
|
31
|
+
*/
|
|
32
|
+
export function unsupportedBindings(bindings: Partial<Bindings>): string[] {
|
|
33
|
+
const reasons: string[] = [];
|
|
34
|
+
if ((bindings.services ?? []).length > 0) {
|
|
35
|
+
reasons.push("service bindings call another deployment through an edge, which a standalone binary has none of");
|
|
36
|
+
}
|
|
37
|
+
return reasons;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function buildStandalone(input: StandaloneBuildInput): Promise<StandaloneBuildResult> {
|
|
41
|
+
const artifact = await buildArtifact({ ...input, transport: "embedded" });
|
|
42
|
+
|
|
43
|
+
let bindings: Partial<Bindings> = {};
|
|
44
|
+
try {
|
|
45
|
+
// SAFETY: bindings.json is written by buildArtifact from the validated
|
|
46
|
+
// config in this same call — our own output, not user input.
|
|
47
|
+
bindings = JSON.parse(await readFile(resolve(artifact.artifactDir, "bindings.json"), "utf8")) as Partial<Bindings>;
|
|
48
|
+
} catch {
|
|
49
|
+
bindings = {}; // a project with no bindings at all
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const blocked = unsupportedBindings(bindings);
|
|
53
|
+
if (blocked.length > 0) {
|
|
54
|
+
throw new Error(`cannot build a standalone binary for this project:\n - ${blocked.join("\n - ")}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The sprout *is* the binary: assets and bindings are compiled into it, and
|
|
58
|
+
// everything else it needs (port, data dir, secrets) arrives at run time.
|
|
59
|
+
const outPath = input.outPath ?? resolve(input.projectDir, "dist", input.config.name);
|
|
60
|
+
await mkdir(resolve(outPath, ".."), { recursive: true });
|
|
61
|
+
await cp(resolve(artifact.artifactDir, "sprout"), outPath);
|
|
62
|
+
await chmod(outPath, 0o755);
|
|
63
|
+
return { outPath, bytes: Bun.file(outPath).size };
|
|
64
|
+
}
|