sproutboat 0.7.0 → 0.8.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.
@@ -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
- // Splice INJECT in immediately after the opening brace, before the return.
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
- const INJECT =
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 MARKER = 'getenv("PORT")';
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(MARKER)) {
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
- const patched = src.slice(0, anchorAt + ANCHOR.length) + INJECT + src.slice(anchorAt + ANCHOR.length);
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
+ }
package/src/surface.ts CHANGED
@@ -89,10 +89,10 @@ export const COMMANDS: readonly Command[] = [
89
89
  name: "build",
90
90
  group: "Develop",
91
91
  emoji: "🔨",
92
- args: "[project-dir] [--target host]",
92
+ args: "[project-dir] [--target host] [--standalone]",
93
93
  brief: "[project-dir]",
94
94
  summary:
95
- "Cross-compile the native-fetch sprout (Porffor + Zig). `--target host` builds for this machine instead, to run locally — not deployable.",
95
+ "Cross-compile the native-fetch sprout (Porffor + Zig). `--target host` builds for this machine instead, to run locally — not deployable. `--standalone` emits one executable carrying its own bindings, with SQLite compiled in (~2 MB) and no broker process.",
96
96
  },
97
97
 
98
98
  {
@@ -228,6 +228,41 @@ export const ENV_VARS: readonly EnvVar[] = [
228
228
  purpose:
229
229
  "http://127.0.0.1:<PORT> of the sprout; when set, `src/broker.ts` runs the cron scheduler and queue consumer and delivers triggers to it.",
230
230
  },
231
+ {
232
+ name: "SB_DATA_DIR",
233
+ purpose:
234
+ "Where a standalone binary keeps store.sqlite and d1/ (#15). Read by the sprout itself in an embedded build; defaults to ./<name>.data.",
235
+ },
236
+ {
237
+ name: "SPROUTBOAT_DATA",
238
+ purpose:
239
+ "Data directory for a standalone binary, after --data and SB_DATA_DIR, before the ./<name>.data default (#15).",
240
+ },
241
+ {
242
+ name: "SB_CA_BUNDLE",
243
+ purpose:
244
+ "PEM bundle of extra certificate authorities a standalone binary should trust, on top of the compiled-in Mozilla root set (#15). Adds trust; nothing disables verification.",
245
+ },
246
+ {
247
+ name: "SB_FETCH_MAX_BYTES",
248
+ purpose:
249
+ "Largest outbound `fetch()` response body a sprout will read, in bytes (default 33554432). The size is the remote host's choice and the body is held whole, so this is the ceiling that stops one upstream exhausting memory.",
250
+ },
251
+ {
252
+ name: "SB_REQUEST_BODY_MAX",
253
+ purpose:
254
+ "Largest inbound request body the runtime accepts, in bytes (default 1048576). Anything larger is refused with 413 before the handler runs.",
255
+ },
256
+ {
257
+ name: "SB_EXTRA_CFLAGS",
258
+ purpose:
259
+ "Flags added to Porffor's native-fetch compile step, set by the build so inline C can include BearSSL's header (#15).",
260
+ },
261
+ {
262
+ name: "SB_EXTRA_LINK",
263
+ purpose:
264
+ "Objects to add to Porffor's native-fetch link line, set by the build so a standalone binary links SQLite and BearSSL (#15).",
265
+ },
231
266
  ];
232
267
 
233
268
  const GROUP_ORDER: readonly Group[] = ["Develop", "Ship", "Storage", "Configure", "Account"];
@@ -0,0 +1,290 @@
1
+ /**
2
+ * The broker transport: one long-lived loopback connection to the per-deployment
3
+ * binding broker, `[u32 LE length][payload]` frames both ways.
4
+ *
5
+ * This is what a deployed sprout uses, and what `sproutboat dev` and a phase-0
6
+ * standalone binary use. The embedded transport (transport-embedded.js) is the
7
+ * same `__sbCall(reqJson) -> replyJson` contract with SQLite compiled in
8
+ * instead of a broker on the other end — everything above __sbCall is shared.
9
+ */
10
+ // SB_BROKER_PORT / SB_BROKER_TOKEN are set by the supervisor next to $PORT.
11
+ // If SB_BROKER_PORT is unset the shims below are never installed (compile.ts
12
+ // only emits the __sbInstallBindings call when the project declares bindings),
13
+ // so a plain sprout is byte-for-byte unchanged.
14
+ // ponytail: text values only; still AF_INET loopback, not AF_UNIX. A failed
15
+ // exchange reconnects and resends once — a broker crash between "request applied"
16
+ // and "reply read" can double-apply a non-idempotent op (queue.send, INSERT);
17
+ // the old fresh-connection-per-call path just failed the call there instead.
18
+ // Binary values + AF_UNIX = v2.
19
+
20
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
21
+ Porffor.c`
22
+ static int sb_io_all(int fd, unsigned char* buf, size_t len, int writing) {
23
+ size_t done = 0;
24
+ while (done < len) {
25
+ long n = writing ? write(fd, buf + done, len - done) : read(fd, buf + done, len - done);
26
+ if (n <= 0) {
27
+ if (n < 0 && errno == EINTR) continue;
28
+ return -1;
29
+ }
30
+ done += (size_t)n;
31
+ }
32
+ return 0;
33
+ }
34
+
35
+ // One long-lived loopback connection to the broker, reused across every binding
36
+ // call. The broker frames each request/reply independently and keeps the socket
37
+ // open, so the steady-state per-call cost is just write + read — no socket(),
38
+ // connect() handshake or close() each time. -1 = not connected.
39
+ static int sb_broker_fd = -1;
40
+
41
+ static int sb_broker_connect(void) {
42
+ const char* port_s = getenv("SB_BROKER_PORT");
43
+ if (!port_s) return -10;
44
+ signal(SIGPIPE, SIG_IGN); // a dead broker must yield EPIPE, not kill the sprout
45
+ int fd = socket(AF_INET, SOCK_STREAM, 0);
46
+ if (fd < 0) return -1;
47
+ int one = 1;
48
+ setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
49
+ struct sockaddr_in addr;
50
+ memset(&addr, 0, sizeof(addr));
51
+ addr.sin_family = AF_INET;
52
+ addr.sin_port = htons((unsigned short)atoi(port_s));
53
+ addr.sin_addr.s_addr = htonl(0x7f000001u); // 127.0.0.1
54
+ if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) { close(fd); return -2; }
55
+ sb_broker_fd = fd;
56
+ return 0;
57
+ }
58
+
59
+ // Send one framed request, read one framed reply, on the persistent fd.
60
+ // Send one payload verbatim and read one reply. The caller owns the payload's
61
+ // shape: a v0 exchange prepends the token line, a v1 one carries the token in
62
+ // its JSON and must reach the broker byte for byte — prefixing it would leave
63
+ // the marker in the wrong place and the frame would read as v0.
64
+ static int sb_broker_exchange_raw(const char* body, size_t body_len, char** resp_out, size_t* resp_len_out) {
65
+ unsigned char* frame = (unsigned char*)malloc(4 + body_len);
66
+ if (!frame) return -5;
67
+ frame[0] = (unsigned char)(body_len & 0xff);
68
+ frame[1] = (unsigned char)((body_len >> 8) & 0xff);
69
+ frame[2] = (unsigned char)((body_len >> 16) & 0xff);
70
+ frame[3] = (unsigned char)((body_len >> 24) & 0xff);
71
+ if (body_len) memcpy(frame + 4, body, body_len);
72
+ int wr = sb_io_all(sb_broker_fd, frame, 4 + body_len, 1);
73
+ free(frame);
74
+ if (wr != 0) return -3;
75
+
76
+ unsigned char rhdr[4];
77
+ if (sb_io_all(sb_broker_fd, rhdr, 4, 0) != 0) return -4;
78
+ size_t rlen = (size_t)rhdr[0] | ((size_t)rhdr[1] << 8) | ((size_t)rhdr[2] << 16) | ((size_t)rhdr[3] << 24);
79
+
80
+ char* buf = (char*)malloc(rlen ? rlen : 1);
81
+ if (!buf) return -5;
82
+ if (rlen && sb_io_all(sb_broker_fd, (unsigned char*)buf, rlen, 0) != 0) { free(buf); return -6; }
83
+
84
+ *resp_out = buf;
85
+ *resp_len_out = rlen;
86
+ return 0;
87
+ }
88
+
89
+ // #63 — the binary reply from the last v1 exchange, handed to JS on request.
90
+ // Stashed rather than returned inline so an 8 MB object body is one allocation
91
+ // in the Porffor heap, not a substring of a bigger one.
92
+ static char* sb_bin_reply = 0;
93
+ static size_t sb_bin_reply_len = 0;
94
+
95
+ // v0: token line, then the JSON.
96
+ static int sb_broker_exchange(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
97
+ const char* tok = getenv("SB_BROKER_TOKEN");
98
+ size_t tok_len = tok ? strlen(tok) : 0;
99
+ size_t body_len = tok_len + 1 + req_len;
100
+ char* body = (char*)malloc(body_len);
101
+ if (!body) return -5;
102
+ if (tok_len) memcpy(body, tok, tok_len);
103
+ body[tok_len] = '\n';
104
+ if (req_len) memcpy(body + tok_len + 1, req, req_len);
105
+ int rc = sb_broker_exchange_raw(body, body_len, resp_out, resp_len_out);
106
+ free(body);
107
+ return rc;
108
+ }
109
+
110
+ // Backoff between attempts: 0, 5, 25, then 100 ms.
111
+ static void sb_backoff(int attempt) {
112
+ static const long ms[4] = { 0, 5, 25, 100 };
113
+ long wait = ms[attempt < 4 ? attempt : 3];
114
+ if (wait <= 0) return;
115
+ struct timespec ts;
116
+ ts.tv_sec = wait / 1000;
117
+ ts.tv_nsec = (wait % 1000) * 1000000L;
118
+ nanosleep(&ts, 0);
119
+ }
120
+
121
+ static int sb_broker_roundtrip(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
122
+ *resp_out = NULL;
123
+ *resp_len_out = 0;
124
+ // Four tries with backoff. One retry was tuned for "systemd restarted it in
125
+ // 20 ms"; a broker being upgraded, or a shared one restarting, is every
126
+ // deployment's binding calls failing inside that window. Retrying the same
127
+ // bytes is safe because each request carries an id the broker deduplicates.
128
+ int rc = -3;
129
+ for (int attempt = 0; attempt < 4; attempt++) {
130
+ sb_backoff(attempt);
131
+ if (sb_broker_fd < 0) {
132
+ int c = sb_broker_connect();
133
+ if (c != 0) { rc = c; continue; }
134
+ }
135
+ rc = sb_broker_exchange(req, req_len, resp_out, resp_len_out);
136
+ if (rc == 0) return 0;
137
+ close(sb_broker_fd);
138
+ sb_broker_fd = -1;
139
+ }
140
+ return rc;
141
+ }
142
+
143
+ // A v1 exchange: marker, json length, json, then the body bytes. The reply is
144
+ // split the same way, its JSON returned and its bytes stashed for __sbTakeBin.
145
+ static int sb_broker_roundtrip_v1(const char* json, size_t json_len, const char* body, size_t body_len,
146
+ char** resp_out, size_t* resp_len_out) {
147
+ size_t req_len = 1 + 4 + json_len + body_len;
148
+ char* req = (char*)malloc(req_len);
149
+ if (!req) return -4;
150
+ req[0] = 1;
151
+ unsigned int jl = (unsigned int)json_len;
152
+ memcpy(req + 1, &jl, 4);
153
+ memcpy(req + 5, json, json_len);
154
+ if (body_len) memcpy(req + 5 + json_len, body, body_len);
155
+
156
+ char* resp = 0; size_t resp_len = 0;
157
+ int rc = -3;
158
+ for (int attempt = 0; attempt < 4; attempt++) {
159
+ sb_backoff(attempt);
160
+ if (sb_broker_fd < 0) {
161
+ int c = sb_broker_connect();
162
+ if (c != 0) { rc = c; continue; }
163
+ }
164
+ rc = sb_broker_exchange_raw(req, req_len, &resp, &resp_len);
165
+ if (rc == 0) break;
166
+ close(sb_broker_fd);
167
+ sb_broker_fd = -1;
168
+ }
169
+ free(req);
170
+ if (rc != 0) return rc;
171
+
172
+ if (sb_bin_reply) { free(sb_bin_reply); sb_bin_reply = 0; sb_bin_reply_len = 0; }
173
+ if (resp_len >= 5 && (unsigned char)resp[0] == 1) {
174
+ unsigned int rjl = 0;
175
+ memcpy(&rjl, resp + 1, 4);
176
+ if (5 + (size_t)rjl <= resp_len) {
177
+ size_t bin_len = resp_len - 5 - rjl;
178
+ if (bin_len) {
179
+ sb_bin_reply = (char*)malloc(bin_len);
180
+ if (sb_bin_reply) { memcpy(sb_bin_reply, resp + 5 + rjl, bin_len); sb_bin_reply_len = bin_len; }
181
+ }
182
+ char* json_only = (char*)malloc(rjl + 1);
183
+ if (!json_only) { free(resp); return -4; }
184
+ memcpy(json_only, resp + 5, rjl);
185
+ json_only[rjl] = 0;
186
+ free(resp);
187
+ *resp_out = json_only;
188
+ *resp_len_out = rjl;
189
+ return 0;
190
+ }
191
+ }
192
+ // A broker that answered v0 to a v1 request predates this: pass its reply
193
+ // through so the error it wrote is what the handler sees.
194
+ *resp_out = resp;
195
+ *resp_len_out = resp_len;
196
+ return 0;
197
+ }
198
+ `;
199
+
200
+ // One request string in, one reply string out. `reqJson` is a parameter, so the
201
+ // generated C names it directly in the RawC block below.
202
+ // oxlint-disable-next-line no-unused-vars -- `reqJson` is read inside the RawC block below, not by JS.
203
+ function __sbCall(reqJson) {
204
+ let res = "";
205
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
206
+ Porffor.c`
207
+ const char* __req; size_t __reqlen; char* __reqowned = 0;
208
+ porf_native_fetch_read_value(reqJson, &__req, &__reqlen, &__reqowned);
209
+ char* __resp = 0; size_t __resplen = 0;
210
+ int __rc = sb_broker_roundtrip(__req, __reqlen, &__resp, &__resplen);
211
+ if (__reqowned) free(__reqowned);
212
+ if (__rc == 0) {
213
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__resp, __resplen), 195);
214
+ free(__resp);
215
+ } else {
216
+ char __e[40];
217
+ int __n = snprintf(__e, sizeof(__e), "{\"ok\":false,\"error\":\"broker rc %d\"}", __rc);
218
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__e, (size_t)__n), 195);
219
+ }
220
+ `;
221
+ return res;
222
+ }
223
+
224
+ /**
225
+ * No-op: a deployed sprout's cron ticks, queue batches and DO alarms are
226
+ * delivered by the broker over x-sb-trigger. The embedded transport defines the
227
+ * real one, so the generated module can call this unconditionally.
228
+ */
229
+ globalThis.__sbStartLocalTriggers = function () {};
230
+
231
+ // #63 — a v1 exchange carrying a body. Returns the reply JSON; any bytes in the
232
+ // reply wait in __sbTakeBin.
233
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
234
+ function __sbCallBin(reqJson, body) {
235
+ let res = "";
236
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
237
+ Porffor.c`
238
+ const char* __j; size_t __jl; char* __jo = 0;
239
+ porf_native_fetch_read_value(reqJson, &__j, &__jl, &__jo);
240
+ const char* __b; size_t __bl; char* __bo = 0;
241
+ porf_native_fetch_read_value(body, &__b, &__bl, &__bo);
242
+ char* __resp = 0; size_t __resplen = 0;
243
+ int __rc = sb_broker_roundtrip_v1(__j, __jl, __b, __bl, &__resp, &__resplen);
244
+ if (__jo) free(__jo);
245
+ if (__bo) free(__bo);
246
+ if (__rc == 0) {
247
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__resp, __resplen), 195);
248
+ free(__resp);
249
+ } else {
250
+ char __e[40];
251
+ int __n = snprintf(__e, sizeof(__e), "{\"ok\":false,\"error\":\"broker rc %d\"}", __rc);
252
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__e, (size_t)__n), 195);
253
+ }
254
+ `;
255
+ return res;
256
+ }
257
+
258
+ /** The bytes from the last v1 reply, if it carried any. Clears the stash. */
259
+ function __sbTakeBin() {
260
+ let out = "";
261
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
262
+ Porffor.c`
263
+ if (sb_bin_reply && sb_bin_reply_len) {
264
+ out = porf_box((f64)porf_native_fetch_alloc_bytestring(sb_bin_reply, sb_bin_reply_len), 195);
265
+ }
266
+ if (sb_bin_reply) { free(sb_bin_reply); sb_bin_reply = 0; sb_bin_reply_len = 0; }
267
+ `;
268
+ return out;
269
+ }
270
+
271
+ /** #63 — R2 bodies as bytes rather than escaped into the frame. */
272
+ globalThis.__sbR2Put = function (bucket, key, body, httpMetadata, customMetadata) {
273
+ const token = __sbEnv("SB_BROKER_TOKEN");
274
+ const reply = JSON.parse(
275
+ __sbCallBin(
276
+ JSON.stringify({ v: 1, token, op: "r2.put", bucket, key, httpMetadata, customMetadata }),
277
+ body == null ? "" : String(body),
278
+ ),
279
+ );
280
+ if (reply.ok === false) throw new Error("sproutboat r2.put: " + (reply.error || "failed"));
281
+ return reply;
282
+ };
283
+
284
+ globalThis.__sbR2Get = function (bucket, key) {
285
+ const token = __sbEnv("SB_BROKER_TOKEN");
286
+ const reply = JSON.parse(__sbCallBin(JSON.stringify({ v: 1, token, op: "r2.get", bucket, key }), ""));
287
+ if (reply.ok === false) throw new Error("sproutboat r2.get: " + (reply.error || "failed"));
288
+ if (!reply.found) return { found: false };
289
+ return { found: true, object: reply.object, body: __sbTakeBin() };
290
+ };