@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.
package/src/source.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { neutraliseExports } from "./wrap";
2
+
3
+ export type SourceValidation = { ok: true } | { ok: false; errors: string[] };
4
+
5
+ // Checked against the *bundled* module (#89), not the entry file: after
6
+ // bundling there are no imports left to reject, and a dependency reaching for a
7
+ // Node API has to fail exactly as hand-written code would. A bare specifier
8
+ // that resolves to nothing never gets this far — the bundler fails first.
9
+ const alwaysForbidden: Array<[RegExp, string]> = [
10
+ [/^\s*import\s/m, "an import survived bundling — only static imports can be resolved at build time"],
11
+ [/\bimport\s*\(/, "dynamic import() is not supported: nothing can resolve it at build time"],
12
+ [/\brequire\s*\(/, "CommonJS require is not supported"],
13
+ [/\b(WebSocket|XMLHttpRequest)\s*\(/, "WebSocket / XMLHttpRequest are not supported"],
14
+ [/\b(process|Bun|Deno|Buffer|node:)\b/, "Node, Bun, and Deno APIs are not supported"],
15
+ // Porffor alpha-4 compiles `new Proxy(...)` and then ignores the handler: a
16
+ // trapped property reads back as `undefined`, with no throw. Rejecting it
17
+ // here is the difference between a build error and a 502 nobody can explain.
18
+ // It is why itty-router and other Proxy-based routers do not work yet.
19
+ [
20
+ /\bnew\s+Proxy\s*\(|\bProxy\s*\.\s*revocable\s*\(/,
21
+ "Proxy is not supported by the compiler: its traps are silently ignored and the property reads back as undefined",
22
+ ],
23
+ ];
24
+
25
+ const fetchWithoutAllowlist: [RegExp, string] = [
26
+ /(?:\breturn\s+|\bawait\s+|=\s*)fetch\s*\(/,
27
+ "outbound networking needs an `outbound` host allowlist in sproutboat.jsonc",
28
+ ];
29
+
30
+ export function validateHttpSyncSource(source: string, outboundAllowed = false): SourceValidation {
31
+ const errors: string[] = [];
32
+ // The default export must be an object literal with a `fetch` method. A module
33
+ // may also declare Durable Object classes / helpers before it, so this is not
34
+ // anchored to the start of the file.
35
+ // A hand-written file exports inline; a bundled one re-exports at the end.
36
+ // `neutraliseExports` is the same reader the compiler uses, so `check` cannot
37
+ // accept a module the build would then reject.
38
+ if (neutraliseExports(source) === null || !/\bfetch\s*\(/.test(source)) {
39
+ errors.push("handler must default-export an object with fetch(request)");
40
+ }
41
+ for (const [pattern, message] of alwaysForbidden) if (pattern.test(source)) errors.push(message);
42
+ if (!outboundAllowed && fetchWithoutAllowlist[0].test(source)) errors.push(fetchWithoutAllowlist[1]);
43
+ return errors.length ? { ok: false, errors } : { ok: true };
44
+ }
@@ -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
+ };