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/src/surface.ts CHANGED
@@ -57,9 +57,9 @@ const storageCommands: readonly Command[] = STORAGE_PRODUCTS.map((product) => ({
57
57
  name: product.name,
58
58
  group: "Storage" as const,
59
59
  emoji: product.emoji,
60
- args: STORAGE_ARGS,
60
+ args: product.name === "kv" ? `${STORAGE_ARGS} | <key | bulk | export> ...` : STORAGE_ARGS,
61
61
  brief: `<${STORAGE_VERBS.join(" | ")}>`,
62
- summary: `${product.plural[0].toUpperCase()}${product.plural.slice(1)}. \`create\` prints the id to bind from sproutboat.jsonc${product.name === "queues" ? "; consumers deliver in batches with retries, and stop after 5 attempts" : ""}.`,
62
+ summary: `${product.plural[0].toUpperCase()}${product.plural.slice(1)}. \`create\` prints the id to bind from sproutboat.jsonc${product.name === "kv" ? "; key and bulk operations manage contents, and export writes a restorable JSON dump" : ""}${product.name === "queues" ? "; consumers deliver in batches with retries, and stop after 5 attempts" : ""}.`,
63
63
  }));
64
64
 
65
65
  export const COMMANDS: readonly Command[] = [
@@ -77,6 +77,14 @@ export const COMMANDS: readonly Command[] = [
77
77
  args: "[project-dir]",
78
78
  summary: "Validate the config and entry point without building.",
79
79
  },
80
+ {
81
+ name: "types",
82
+ group: "Develop",
83
+ emoji: "๐Ÿงพ",
84
+ args: "[project-dir]",
85
+ summary:
86
+ "Write sproutboat-env.d.ts from the config, so an editor knows what `env` holds. `dev` and `deploy` refresh it when the config is newer.",
87
+ },
80
88
  {
81
89
  name: "dev",
82
90
  group: "Develop",
@@ -89,10 +97,10 @@ export const COMMANDS: readonly Command[] = [
89
97
  name: "build",
90
98
  group: "Develop",
91
99
  emoji: "๐Ÿ”จ",
92
- args: "[project-dir] [--target host]",
100
+ args: "[project-dir] [--target host] [--standalone]",
93
101
  brief: "[project-dir]",
94
102
  summary:
95
- "Cross-compile the native-fetch sprout (Porffor + Zig). `--target host` builds for this machine instead, to run locally โ€” not deployable.",
103
+ "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
104
  },
97
105
 
98
106
  {
@@ -189,8 +197,14 @@ export const ENV_VARS: readonly EnvVar[] = [
189
197
  {
190
198
  name: "SPROUTBOAT_UWS_TARBALL",
191
199
  purpose:
192
- "Path to a prebuilt uWebSockets (x86_64-linux-musl) tarball to seed the Porffor cache with, instead of downloading it (removes the first-build git + make need).",
200
+ "Path to a uWebSockets source tarball to seed the Porffor cache with, instead of the one vendored in the package. Both targets are seeded from it.",
201
+ },
202
+ {
203
+ name: "CC",
204
+ purpose:
205
+ "C compiler used to build uSockets for a host build (default `cc`). A host build needs one regardless: it is what Porffor compiles its own generated C with.",
193
206
  },
207
+ { name: "AR", purpose: "Archiver used to assemble uSockets.a for a host build (default `ar`)." },
194
208
  { name: "SPROUTBOAT_COMPILE_TIMEOUT_MS", purpose: "Porffor compile timeout in ms (default 600000)." },
195
209
  {
196
210
  name: "SPROUTBOAT_VARS_JSON",
@@ -228,6 +242,41 @@ export const ENV_VARS: readonly EnvVar[] = [
228
242
  purpose:
229
243
  "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
244
  },
245
+ {
246
+ name: "SB_DATA_DIR",
247
+ purpose:
248
+ "Where a standalone binary keeps store.sqlite and d1/ (#15). Read by the sprout itself in an embedded build; defaults to ./<name>.data.",
249
+ },
250
+ {
251
+ name: "SPROUTBOAT_DATA",
252
+ purpose:
253
+ "Data directory for a standalone binary, after --data and SB_DATA_DIR, before the ./<name>.data default (#15).",
254
+ },
255
+ {
256
+ name: "SB_CA_BUNDLE",
257
+ purpose:
258
+ "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.",
259
+ },
260
+ {
261
+ name: "SB_FETCH_MAX_BYTES",
262
+ purpose:
263
+ "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.",
264
+ },
265
+ {
266
+ name: "SB_REQUEST_BODY_MAX",
267
+ purpose:
268
+ "Largest inbound request body the runtime accepts, in bytes (default 1048576). Anything larger is refused with 413 before the handler runs.",
269
+ },
270
+ {
271
+ name: "SB_EXTRA_CFLAGS",
272
+ purpose:
273
+ "Flags added to Porffor's native-fetch compile step, set by the build so inline C can include BearSSL's header (#15).",
274
+ },
275
+ {
276
+ name: "SB_EXTRA_LINK",
277
+ purpose:
278
+ "Objects to add to Porffor's native-fetch link line, set by the build so a standalone binary links SQLite and BearSSL (#15).",
279
+ },
231
280
  ];
232
281
 
233
282
  const GROUP_ORDER: readonly Group[] = ["Develop", "Ship", "Storage", "Configure", "Account"];
package/src/toolchain.ts CHANGED
@@ -156,6 +156,11 @@ export async function ensureUWebSockets(): Promise<void> {
156
156
  }
157
157
  }
158
158
 
159
+ await extractUws(archive, dir);
160
+ }
161
+
162
+ /** Unpack the vendored source tree into `dir`. */
163
+ async function extractUws(archive: string, dir: string): Promise<void> {
159
164
  await mkdir(dir, { recursive: true });
160
165
  const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], {
161
166
  stdout: "pipe",
@@ -168,6 +173,72 @@ export async function ensureUWebSockets(): Promise<void> {
168
173
  }
169
174
  }
170
175
 
176
+ /**
177
+ * The same seeding for a **host** build (`dev`, `build --target host`,
178
+ * `--standalone` on this machine).
179
+ *
180
+ * The vendored archive is a full checkout, so the sources are the same for
181
+ * every target; only `uSockets.a` is target-specific. Porffor would build that
182
+ * with `make -C uSockets`, which is where the `git` and `make` requirements on
183
+ * a first build came from. Compiling the thirteen C files directly removes
184
+ * both, and adds no new dependency: a host build already needs a C compiler,
185
+ * because that is what Porffor compiles its own generated C with.
186
+ *
187
+ * Flags mirror the uSockets makefile's default target with the WITH_* switches
188
+ * Porffor passes (all off): `-std=c11 -Isrc -DLIBUS_NO_SSL -flto -O3`, then
189
+ * `ar rvs`. Nothing here is a judgement call; it is that recipe.
190
+ */
191
+ export async function ensureUWebSocketsHost(): Promise<void> {
192
+ const commit = uwsCommitFull();
193
+ const dir = resolve(homedir(), ".cache/porffor/deps", `uWebSockets-${commit}`);
194
+ const uSockets = resolve(dir, "uSockets");
195
+ const archivePath = resolve(uSockets, "uSockets.a");
196
+ if (existsSync(resolve(dir, "src/App.h")) && existsSync(archivePath)) return;
197
+
198
+ const vendored = process.env.SPROUTBOAT_UWS_TARBALL || uwsVendorArchive(commit.slice(0, 8));
199
+ if (!existsSync(vendored)) {
200
+ throw new UwsUnavailableError(`no vendored uWebSockets archive at ${vendored}`);
201
+ }
202
+ if (!process.env.SPROUTBOAT_UWS_TARBALL) {
203
+ const actual = await sha256File(vendored);
204
+ if (actual !== UWS_TARBALL_SHA256) {
205
+ throw new UwsUnavailableError(
206
+ `vendored uWebSockets sha256 mismatch\n expected ${UWS_TARBALL_SHA256}\n got ${actual}`,
207
+ );
208
+ }
209
+ }
210
+ if (!existsSync(resolve(dir, "src/App.h"))) await extractUws(vendored, dir);
211
+
212
+ // The archive carries the musl-built uSockets.a. Linking that into a host
213
+ // binary fails in a way nobody would connect to this, so it goes first.
214
+ await rm(archivePath, { force: true });
215
+
216
+ const sources = ["src/*.c", "src/eventing/*.c", "src/crypto/*.c", "src/io_uring/*.c"];
217
+ const cc = process.env.CC || "cc";
218
+ const ar = process.env.AR || "ar";
219
+ const compile = Bun.spawn(["sh", "-c", `${cc} -std=c11 -Isrc -DLIBUS_NO_SSL -flto -O3 -c ${sources.join(" ")}`], {
220
+ cwd: uSockets,
221
+ stdout: "pipe",
222
+ stderr: "pipe",
223
+ });
224
+ const [ccCode, ccErr] = await Promise.all([compile.exited, new Response(compile.stderr).text()]);
225
+ if (ccCode !== 0) {
226
+ throw new UwsUnavailableError(
227
+ `could not compile uSockets with ${cc}: ${ccErr.trim().split("\n").slice(-3).join(" ")}`,
228
+ );
229
+ }
230
+
231
+ const archiveStep = Bun.spawn(["sh", "-c", `${ar} rvs uSockets.a *.o`], {
232
+ cwd: uSockets,
233
+ stdout: "pipe",
234
+ stderr: "pipe",
235
+ });
236
+ const [arCode, arErr] = await Promise.all([archiveStep.exited, new Response(archiveStep.stderr).text()]);
237
+ if (arCode !== 0 || !existsSync(archivePath)) {
238
+ throw new UwsUnavailableError(`could not archive uSockets with ${ar}: ${arErr.trim()}`);
239
+ }
240
+ }
241
+
171
242
  /** Directory holding node_modules/porffor (walks up from this file). */
172
243
  export function porfforRoot(start = import.meta.dir): string {
173
244
  let dir = start;
@@ -0,0 +1,299 @@
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
+ };
291
+
292
+ /** Static asset metadata stays JSON; the response body uses the v1 byte tail. */
293
+ globalThis.__sbAssetsGet = function (path) {
294
+ const token = __sbEnv("SB_BROKER_TOKEN");
295
+ const reply = JSON.parse(__sbCallBin(JSON.stringify({ v: 1, token, op: "assets.get", path }), ""));
296
+ if (reply.ok === false) throw new Error("sproutboat assets.get: " + (reply.error || "failed"));
297
+ reply.body = __sbTakeBin();
298
+ return reply;
299
+ };