@sproutboat/runtime 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,950 @@
1
+ // Prepended to every handler by tools/compile.ts, before Porffor's native-fetch
2
+ // esbuild bundle. Porffor's runtime/fetch-globals.js (checked through alpha-4)
3
+ // gives URL (href/origin/pathname/search only) and Response without a static
4
+ // json(). This adds, additively, the rest of the WHATWG surface Worker code
5
+ // expects: URLSearchParams (read + write), URL.prototype.searchParams and the
6
+ // protocol/host/hostname/port/hash accessors, static Response.json,
7
+ // crypto.randomUUID / crypto.getRandomValues, and structuredClone. Each is
8
+ // feature-detected; delete a block once Porffor ships that global.
9
+ // Tracked upstream in patches/UPSTREAM.md.
10
+ //
11
+ // Declared before it is referenced: a getter body that names a later top-level
12
+ // class throws ReferenceError in Porffor (see patches/UPSTREAM.md draft B).
13
+
14
+ // Duck-typing helpers, `typeof`-free (the repo's anti-slop lint bans `typeof`;
15
+ // these express the same spec-mandated checks and are verified under Porffor
16
+ // alpha-4 by examples/kitchen-sink/harness.ts).
17
+ function __sbIsStr(v) {
18
+ return Object(v) !== v && v === String(v);
19
+ }
20
+ function __sbIsFn(v) {
21
+ return v instanceof Function;
22
+ }
23
+ function __sbIsObj(v) {
24
+ return v !== null && Object(v) === v;
25
+ }
26
+
27
+ // #41 — cold-start phase marker. Runs as the first thing in the bundle: writes
28
+ // the current wall-clock ms to $SB_STARTUP_FILE so the supervisor can split
29
+ // cold-start into "spawn -> JS starts" (process + runtime bootstrap) and
30
+ // "JS starts -> listening" (module eval + server bind). No-op when unset.
31
+ function __sbStartupMark() {
32
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
33
+ Porffor.c`
34
+ const char* __f = getenv("SB_STARTUP_FILE");
35
+ if (__f) {
36
+ struct timespec __ts;
37
+ clock_gettime(CLOCK_REALTIME, &__ts);
38
+ double __ms = (double)__ts.tv_sec * 1000.0 + (double)__ts.tv_nsec / 1000000.0;
39
+ char __buf[32];
40
+ int __n = snprintf(__buf, sizeof(__buf), "%.0f", __ms);
41
+ int __fd = open(__f, O_WRONLY | O_CREAT | O_TRUNC, 0600);
42
+ if (__fd >= 0) { write(__fd, __buf, (size_t)__n); close(__fd); }
43
+ }
44
+ `;
45
+ }
46
+ __sbStartupMark();
47
+
48
+ // #28 — process CPU time in ms (CLOCK_PROCESS_CPUTIME_ID). Marshalled back as a
49
+ // string via the same primitives as __sbEnv / __sbRandomBytes (proven working),
50
+ // then parsed — Porffor's number boxing for a bare inline-C assignment is not
51
+ // relied on. `__sbEntry` samples it around the handler for per-invocation CPU.
52
+ function __sbCpuMs() {
53
+ let res = "";
54
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
55
+ Porffor.c`
56
+ struct timespec __ts;
57
+ clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &__ts);
58
+ double __ms = (double)__ts.tv_sec * 1000.0 + (double)__ts.tv_nsec / 1000000.0;
59
+ char __b[32];
60
+ int __n = snprintf(__b, sizeof(__b), "%.3f", __ms);
61
+ if (__n > 0) res = porf_box((f64)porf_native_fetch_alloc_bytestring(__b, (size_t)__n), 195);
62
+ `;
63
+ return res === "" ? 0 : parseFloat(res);
64
+ }
65
+
66
+ // #28 — stamp `x-sb-cpu-ms` onto a handler Response. Porffor alpha-4's
67
+ // native-fetch serializer only reads headers from the plain object passed to
68
+ // `new Response(body, { headers })` — a later `.set()` or a `Headers` instance
69
+ // is ignored on the wire — so the metric is carried by rebuilding the Response
70
+ // with one extra header. Safe only for a string body (the norm under
71
+ // http-sync-v0) with no Set-Cookie to comma-fold; anything else is returned
72
+ // untouched and the edge simply omits cpuMs for that request.
73
+ function __sbTagCpu(res, t0) {
74
+ const cpu = __sbCpuMs() - t0;
75
+ try {
76
+ const body = res && res.body;
77
+ if (__sbIsStr(body) && !res.headers.has("set-cookie")) {
78
+ const headers = {};
79
+ res.headers.forEach(function (value, name) {
80
+ headers[name] = value;
81
+ });
82
+ headers["x-sb-cpu-ms"] = (cpu >= 0 ? cpu : 0).toFixed(3);
83
+ return new Response(body, { status: res.status, headers: headers });
84
+ }
85
+ } catch {
86
+ /* fall through to the original response */
87
+ }
88
+ return res;
89
+ }
90
+
91
+ class __SproutboatURLSearchParams {
92
+ constructor(init) {
93
+ this._keys = [];
94
+ this._vals = [];
95
+ let raw = init == null ? "" : String(init);
96
+ if (raw.charCodeAt(0) === 63) raw = raw.slice(1); // strip a leading '?'
97
+ if (raw.length === 0) return;
98
+ const pairs = raw.split("&");
99
+ for (let i = 0; i < pairs.length; i++) {
100
+ const pair = pairs[i];
101
+ if (pair.length === 0) continue;
102
+ const eq = pair.indexOf("=");
103
+ const k = eq === -1 ? pair : pair.slice(0, eq);
104
+ const v = eq === -1 ? "" : pair.slice(eq + 1);
105
+ this._keys.push(decodeURIComponent(k.split("+").join(" ")));
106
+ this._vals.push(decodeURIComponent(v.split("+").join(" ")));
107
+ }
108
+ }
109
+ get(name) {
110
+ for (let i = 0; i < this._keys.length; i++) if (this._keys[i] === name) return this._vals[i];
111
+ return null;
112
+ }
113
+ getAll(name) {
114
+ const out = [];
115
+ for (let i = 0; i < this._keys.length; i++) if (this._keys[i] === name) out.push(this._vals[i]);
116
+ return out;
117
+ }
118
+ has(name) {
119
+ for (let i = 0; i < this._keys.length; i++) if (this._keys[i] === name) return true;
120
+ return false;
121
+ }
122
+ forEach(cb) {
123
+ for (let i = 0; i < this._keys.length; i++) cb(this._vals[i], this._keys[i], this);
124
+ }
125
+ // Mutators: standalone `new URLSearchParams()` building works. They do NOT
126
+ // write back into a URL's `search` (Porffor's URL has no setter) — build the
127
+ // string with toString() and assign it yourself.
128
+ append(name, value) {
129
+ this._keys.push(String(name));
130
+ this._vals.push(String(value));
131
+ }
132
+ set(name, value) {
133
+ let found = false;
134
+ for (let i = 0; i < this._keys.length; i++) {
135
+ if (this._keys[i] !== name) continue;
136
+ if (found) {
137
+ this._keys.splice(i, 1);
138
+ this._vals.splice(i, 1);
139
+ i--;
140
+ } else {
141
+ this._vals[i] = String(value);
142
+ found = true;
143
+ }
144
+ }
145
+ if (!found) this.append(name, value);
146
+ }
147
+ delete(name) {
148
+ for (let i = 0; i < this._keys.length; i++)
149
+ if (this._keys[i] === name) {
150
+ this._keys.splice(i, 1);
151
+ this._vals.splice(i, 1);
152
+ i--;
153
+ }
154
+ }
155
+ sort() {
156
+ const idx = this._keys
157
+ .map((_, i) => i)
158
+ .sort((a, b) => (this._keys[a] < this._keys[b] ? -1 : this._keys[a] > this._keys[b] ? 1 : 0));
159
+ this._keys = idx.map((i) => this._keys[i]);
160
+ this._vals = idx.map((i) => this._vals[i]);
161
+ }
162
+ keys() {
163
+ return this._keys.slice();
164
+ }
165
+ values() {
166
+ return this._vals.slice();
167
+ }
168
+ get size() {
169
+ return this._keys.length;
170
+ }
171
+ toString() {
172
+ let out = "";
173
+ for (let i = 0; i < this._keys.length; i++) {
174
+ if (i > 0) out += "&";
175
+ out += encodeURIComponent(this._keys[i]) + "=" + encodeURIComponent(this._vals[i]);
176
+ }
177
+ return out;
178
+ }
179
+ }
180
+
181
+ // URL and Response are always defined by Porffor's fetch-globals.js banner.
182
+ if (globalThis.URLSearchParams == null) globalThis.URLSearchParams = __SproutboatURLSearchParams;
183
+
184
+ if (!("searchParams" in URL.prototype)) {
185
+ Object.defineProperty(URL.prototype, "searchParams", {
186
+ configurable: true,
187
+ get() {
188
+ if (this.__sbSearchParams == null) this.__sbSearchParams = new __SproutboatURLSearchParams(this.search);
189
+ return this.__sbSearchParams;
190
+ },
191
+ });
192
+ }
193
+
194
+ if (Response.json == null) {
195
+ Response.json = function (data, init) {
196
+ const response = new Response(JSON.stringify(data), init);
197
+ if (!response.headers.has("content-type")) response.headers.set("content-type", "application/json;charset=utf-8");
198
+ return response;
199
+ };
200
+ }
201
+
202
+ // Porffor's URL exposes href / origin / pathname / search only. Add the rest of
203
+ // the WHATWG read surface, derived from `origin` (scheme://host[:port]).
204
+ // `hash` is always '' server-side — browsers strip the fragment before the
205
+ // request, so there is nothing to recover. Tracked upstream (patches/UPSTREAM.md).
206
+ function __sbDefineURLAccessor(name, get) {
207
+ if (!(name in URL.prototype)) Object.defineProperty(URL.prototype, name, { configurable: true, get });
208
+ }
209
+ __sbDefineURLAccessor("protocol", function () {
210
+ const i = this.origin.indexOf("://");
211
+ return i === -1 ? "" : this.origin.slice(0, i + 1);
212
+ });
213
+ __sbDefineURLAccessor("host", function () {
214
+ const i = this.origin.indexOf("://");
215
+ return i === -1 ? "" : this.origin.slice(i + 3);
216
+ });
217
+ __sbDefineURLAccessor("hostname", function () {
218
+ const h = this.host;
219
+ const c = h.indexOf(":");
220
+ return c === -1 ? h : h.slice(0, c);
221
+ });
222
+ __sbDefineURLAccessor("port", function () {
223
+ const h = this.host;
224
+ const c = h.indexOf(":");
225
+ return c === -1 ? "" : h.slice(c + 1);
226
+ });
227
+ __sbDefineURLAccessor("hash", function () {
228
+ return "";
229
+ });
230
+ __sbDefineURLAccessor("username", function () {
231
+ return "";
232
+ });
233
+ __sbDefineURLAccessor("password", function () {
234
+ return "";
235
+ });
236
+
237
+ // crypto.randomUUID / getRandomValues are absent in native-fetch. Provide them
238
+ // backed by the OS CSPRNG (`__sbRandomBytes` -> inline C -> /dev/urandom), so
239
+ // tokens, idempotency keys and UUIDs are unpredictable. Deliberately no insecure
240
+ // fallback — a silent downgrade to a weak source is worse than throwing.
241
+ if (globalThis.crypto == null) globalThis.crypto = {};
242
+ if (globalThis.crypto.getRandomValues == null) {
243
+ globalThis.crypto.getRandomValues = function (view) {
244
+ const n = view.length >>> 0;
245
+ // WebCrypto caps a single call at 65536 bytes.
246
+ if (n > 65536) throw new RangeError("crypto.getRandomValues: byte length exceeds 65536");
247
+ if (n === 0) return view;
248
+ // One CSPRNG byte per element. Correct for Uint8Array (and randomUUID); a
249
+ // wider view gets its low byte filled, matching the previous polyfill's shape.
250
+ const bytes = __sbRandomBytes(String(n));
251
+ if (bytes.length !== n) throw new Error("crypto.getRandomValues: OS entropy source unavailable");
252
+ for (let i = 0; i < n; i++) view[i] = bytes.charCodeAt(i) & 0xff;
253
+ return view;
254
+ };
255
+ }
256
+ // structuredClone: JSON round-trip. Lossy (no Map/Set/Date/typed arrays), but
257
+ // covers the common "deep-copy a plain object" case Worker code relies on.
258
+ if (globalThis.structuredClone == null) {
259
+ // The suggested fix (use structuredClone) is circular — this IS the polyfill,
260
+ // and Porffor exposes no other deep-clone primitive.
261
+ // react-doctor-disable-next-line react-doctor/no-json-parse-stringify-clone
262
+ globalThis.structuredClone = function (value) {
263
+ return JSON.parse(JSON.stringify(value));
264
+ };
265
+ }
266
+
267
+ if (globalThis.crypto.randomUUID == null) {
268
+ globalThis.crypto.randomUUID = function () {
269
+ const b = new Uint8Array(16);
270
+ globalThis.crypto.getRandomValues(b);
271
+ b[6] = (b[6] & 0x0f) | 0x40;
272
+ b[8] = (b[8] & 0x3f) | 0x80;
273
+ const h = [];
274
+ for (let i = 0; i < 16; i++) h.push((b[i] + 0x100).toString(16).slice(1));
275
+ return `${h[0]}${h[1]}${h[2]}${h[3]}-${h[4]}${h[5]}-${h[6]}${h[7]}-${h[8]}${h[9]}-${h[10]}${h[11]}${h[12]}${h[13]}${h[14]}${h[15]}`;
276
+ };
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Bindings: env.<KV>, env.<SECRET>, env.<D1>, env.<R2>, and globalThis.fetch,
281
+ // backed by a Bun broker on a loopback TCP port. The transport is inline C —
282
+ // blocking write/read per call over ONE long-lived connection (http-sync-v0: one
283
+ // sprout event-loop turn per request, so a blocking roundtrip is acceptable).
284
+ // Wire frame:
285
+ // [u32 LE len][ <token> "\n" <json> ] reply: [u32 LE len][ <json> ]
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.
290
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
291
+ Porffor.c`
292
+ #include <sys/socket.h>
293
+ #include <netinet/in.h>
294
+ #include <netinet/tcp.h>
295
+ #include <signal.h>
296
+ #include <unistd.h>
297
+ #include <string.h>
298
+ #include <stdlib.h>
299
+ #include <stdio.h>
300
+ #include <fcntl.h>
301
+ #include <errno.h>
302
+ #include <time.h>
303
+
304
+ u32 porf_native_fetch_alloc_bytestring(const char* input, size_t len);
305
+ int porf_native_fetch_read_value(jsval value, const char** out_buf, size_t* out_len, char** out_owned);
306
+
307
+ // Fill buf with n bytes from the OS CSPRNG. /dev/urandom is present on Linux and
308
+ // macOS and inside the bubblewrap sandbox; blocking is not a concern after the
309
+ // pool is seeded. Returns 0, or -1 if the source could not be read in full.
310
+ static int sb_os_random(unsigned char* buf, size_t n) {
311
+ int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
312
+ if (fd < 0) return -1;
313
+ size_t off = 0;
314
+ while (off < n) {
315
+ long r = read(fd, buf + off, n - off);
316
+ if (r <= 0) {
317
+ if (r < 0 && errno == EINTR) continue;
318
+ close(fd);
319
+ return -1;
320
+ }
321
+ off += (size_t)r;
322
+ }
323
+ close(fd);
324
+ return 0;
325
+ }
326
+
327
+ `;
328
+
329
+ // TRANSPORT: wrap.ts splices one of transport-broker.js / transport-embedded.js here.
330
+
331
+ // `nStr` is the decimal byte count as a string (same string-param pattern as
332
+ // __sbEnv). Returns a bytestring of that many CSPRNG bytes, or '' on failure.
333
+ // oxlint-disable-next-line no-unused-vars -- `nStr` is read inside the RawC block below, not by JS.
334
+ function __sbRandomBytes(nStr) {
335
+ let out = "";
336
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
337
+ Porffor.c`
338
+ const char* __ns; size_t __nsl; char* __nso = 0;
339
+ porf_native_fetch_read_value(nStr, &__ns, &__nsl, &__nso);
340
+ char __nb[16];
341
+ size_t __k = __nsl < 15 ? __nsl : 15;
342
+ memcpy(__nb, __ns, __k); __nb[__k] = 0;
343
+ if (__nso) free(__nso);
344
+ long __n = atol(__nb);
345
+ if (__n > 0 && __n <= 65536) {
346
+ unsigned char* __b = (unsigned char*)malloc((size_t)__n);
347
+ if (__b) {
348
+ if (sb_os_random(__b, (size_t)__n) == 0)
349
+ out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__b, (size_t)__n), 195);
350
+ free(__b);
351
+ }
352
+ }
353
+ `;
354
+ return out;
355
+ }
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
+
363
+ function __sbRpc(op, extra) {
364
+ const req = { v: 1, id: ++__sbReqId, op };
365
+ if (extra) for (const k in extra) req[k] = extra[k];
366
+ const reply = JSON.parse(__sbCall(JSON.stringify(req)));
367
+ if (reply && reply.ok === false) throw new Error(`sproutboat ${op}: ${reply.error || "failed"}`);
368
+ return reply;
369
+ }
370
+
371
+ // D1: a Cloudflare-shaped `env.<DB>` (prepare / bind / all / run / raw / first,
372
+ // plus batch and exec). Every call is one broker roundtrip.
373
+ function __sbMakeD1(dbName) {
374
+ function stmt(sql, params) {
375
+ const s = {
376
+ __sql: sql,
377
+ __params: params,
378
+ bind() {
379
+ return stmt(sql, Array.prototype.slice.call(arguments));
380
+ },
381
+ all() {
382
+ const r = __sbRpc("d1.query", { db: dbName, sql, params });
383
+ return { results: r.results || [], success: true, meta: r.meta || {} };
384
+ },
385
+ run() {
386
+ return s.all();
387
+ },
388
+ raw() {
389
+ const rows = s.all().results;
390
+ const out = [];
391
+ for (let i = 0; i < rows.length; i++) {
392
+ const cols = [];
393
+ for (const k in rows[i]) cols.push(rows[i][k]);
394
+ out.push(cols);
395
+ }
396
+ return out;
397
+ },
398
+ first(column) {
399
+ const rows = s.all().results;
400
+ if (rows.length === 0) return null;
401
+ return column == null ? rows[0] : rows[0][column];
402
+ },
403
+ };
404
+ return s;
405
+ }
406
+ return {
407
+ prepare(sql) {
408
+ return stmt(String(sql), []);
409
+ },
410
+ batch(statements) {
411
+ const list = [];
412
+ for (let i = 0; i < (statements || []).length; i++)
413
+ list.push({ sql: statements[i].__sql, params: statements[i].__params });
414
+ const r = __sbRpc("d1.batch", { db: dbName, statements: list });
415
+ const out = [];
416
+ for (let i = 0; i < (r.results || []).length; i++)
417
+ out.push({ results: r.results[i].results || [], success: true, meta: r.results[i].meta || {} });
418
+ return out;
419
+ },
420
+ exec(sql) {
421
+ __sbRpc("d1.exec", { db: dbName, sql: String(sql) });
422
+ return { count: (String(sql).match(/;/g) || []).length, duration: 0 };
423
+ },
424
+ };
425
+ }
426
+
427
+ // R2: a Cloudflare-shaped object. When `body` is present the sync accessors
428
+ // mirror R2ObjectBody's async ones (a sprout may `await` them harmlessly).
429
+ function __sbR2Object(meta, body) {
430
+ const obj = {
431
+ key: meta.key,
432
+ size: meta.size,
433
+ etag: meta.etag,
434
+ httpEtag: '"' + meta.etag + '"',
435
+ uploaded: meta.uploaded,
436
+ httpMetadata: meta.httpMetadata || {},
437
+ customMetadata: meta.customMetadata || {},
438
+ };
439
+ if (body != null) {
440
+ obj.body = body;
441
+ obj.text = function () {
442
+ return body;
443
+ };
444
+ obj.json = function () {
445
+ return JSON.parse(body);
446
+ };
447
+ }
448
+ return obj;
449
+ }
450
+
451
+ // Installed only when the project declares bindings. `env` is the module-scoped
452
+ // object from compile.ts (a `const`, but mutable); we add the binding accessors
453
+ // to it in place. compile.ts emits `__sbInstallBindings(env, {...})` right after
454
+ // the `const env = {...}` line.
455
+ globalThis.__sbInstallBindings = function (target, bindings) {
456
+ if (!target) return;
457
+
458
+ for (let i = 0; i < (bindings.kv || []).length; i++) {
459
+ const ns = bindings.kv[i];
460
+ target[ns] = {
461
+ get(key) {
462
+ const r = __sbRpc("kv.get", { ns, key: String(key) });
463
+ return r.found ? r.value : null;
464
+ },
465
+ put(key, value) {
466
+ __sbRpc("kv.put", { ns, key: String(key), value: String(value) });
467
+ },
468
+ delete(key) {
469
+ __sbRpc("kv.delete", { ns, key: String(key) });
470
+ },
471
+ list(prefix) {
472
+ return __sbRpc("kv.list", { ns, prefix: prefix == null ? "" : String(prefix) }).keys || [];
473
+ },
474
+ };
475
+ }
476
+
477
+ for (let i = 0; i < (bindings.secrets || []).length; i++) {
478
+ const name = bindings.secrets[i];
479
+ // Fetch lazily, then freeze as a data property: a secret is process-lifetime
480
+ // immutable (a new value means a redeploy = a new process), so one broker
481
+ // round-trip on first read, zero after. A getter that RPCs on every access
482
+ // turns `'Bearer ' + env.KEY` in a loop into a syscall storm.
483
+ Object.defineProperty(target, name, {
484
+ configurable: true,
485
+ get() {
486
+ const value = __sbRpc("secret.get", { name }).value;
487
+ Object.defineProperty(target, name, { value, configurable: true, enumerable: true });
488
+ return value;
489
+ },
490
+ });
491
+ }
492
+
493
+ for (let i = 0; i < (bindings.d1 || []).length; i++) {
494
+ const name = bindings.d1[i];
495
+ target[name] = __sbMakeD1(name);
496
+ }
497
+
498
+ for (let i = 0; i < (bindings.r2 || []).length; i++) {
499
+ const name = bindings.r2[i];
500
+ target[name] = {
501
+ put(key, value, options) {
502
+ const o = options || {};
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;
511
+ },
512
+ get(key) {
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));
516
+ return r.found ? __sbR2Object(r.object, r.body == null ? "" : r.body) : null;
517
+ },
518
+ head(key) {
519
+ const r = __sbRpc("r2.head", { bucket: name, key: String(key) });
520
+ return r.found ? __sbR2Object(r.object, null) : null;
521
+ },
522
+ delete(key) {
523
+ __sbRpc("r2.delete", { bucket: name, key: String(key) });
524
+ },
525
+ list(options) {
526
+ const o = options || {};
527
+ const r = __sbRpc("r2.list", {
528
+ bucket: name,
529
+ prefix: o.prefix == null ? "" : String(o.prefix),
530
+ cursor: o.cursor == null ? "" : String(o.cursor),
531
+ limit: o.limit == null ? 1000 : o.limit,
532
+ });
533
+ const objects = [];
534
+ for (let j = 0; j < (r.objects || []).length; j++) objects.push(__sbR2Object(r.objects[j], null));
535
+ return { objects, truncated: !!r.truncated, cursor: r.cursor || undefined };
536
+ },
537
+ };
538
+ }
539
+
540
+ for (let i = 0; i < (bindings.queues || []).length; i++) {
541
+ const name = bindings.queues[i];
542
+ target[name] = {
543
+ send(body, options) {
544
+ const o = options || {};
545
+ __sbRpc("queue.send", {
546
+ queue: name,
547
+ body: __sbIsStr(body) ? body : JSON.stringify(body),
548
+ delaySeconds: o.delaySeconds || 0,
549
+ });
550
+ },
551
+ sendBatch(messages) {
552
+ const list = [];
553
+ for (let j = 0; j < (messages || []).length; j++) {
554
+ const m = messages[j];
555
+ list.push({ body: __sbIsStr(m.body) ? m.body : JSON.stringify(m.body), delaySeconds: m.delaySeconds || 0 });
556
+ }
557
+ __sbRpc("queue.send_batch", { queue: name, messages: list });
558
+ },
559
+ };
560
+ }
561
+
562
+ for (let i = 0; i < (bindings.analytics || []).length; i++) {
563
+ const name = bindings.analytics[i];
564
+ target[name] = {
565
+ writeDataPoint(event) {
566
+ const e = event || {};
567
+ __sbRpc("ae.write", {
568
+ dataset: name,
569
+ indexes: e.indexes || [],
570
+ blobs: e.blobs || [],
571
+ doubles: e.doubles || [],
572
+ });
573
+ },
574
+ // Sproutboat extension (Cloudflare AE is write-only from a Worker — you
575
+ // query it via the SQL API). Returns { count, rows }.
576
+ query(options) {
577
+ const o = options || {};
578
+ return __sbRpc("ae.query", { dataset: name, limit: o.limit || 20 });
579
+ },
580
+ };
581
+ }
582
+
583
+ for (let i = 0; i < (bindings.do || []).length; i++) {
584
+ const b = bindings.do[i];
585
+ target[b.binding] = __sbMakeDONamespace(b.binding, b.className);
586
+ }
587
+
588
+ // Static assets: env.<ASSETS>.fetch(request) -> broker `assets.get`. The edge
589
+ // already serves matching files directly; the sprout only calls this for paths
590
+ // it wants to own (SPA fallback, auth-gated files). The transport keeps the
591
+ // body byte-preserving for binary assets.
592
+ if (bindings.assets) {
593
+ target[bindings.assets] = {
594
+ fetch(input) {
595
+ let path = __sbIsStr(input) ? input : String((input && input.url) || "/");
596
+ try {
597
+ path = new URL(path, "http://a").pathname;
598
+ } catch {
599
+ /* use as-is */
600
+ }
601
+ const r = globalThis.__sbAssetsGet(path);
602
+ const headers = {};
603
+ if (r.type) headers["content-type"] = r.type;
604
+ if (r.found) headers["etag"] = '"' + r.hash + '"';
605
+ return new Response(r.body == null ? "" : r.body, { status: r.status || (r.found ? 200 : 404), headers });
606
+ },
607
+ };
608
+ }
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
+
638
+ if ((bindings.outbound || []).length > 0) {
639
+ globalThis.fetch = function (input, init) {
640
+ const url = __sbIsStr(input) ? input : String(input.url);
641
+ const opts = init || {};
642
+ const headers = [];
643
+ if (opts.headers) {
644
+ if (__sbIsFn(opts.headers.forEach)) opts.headers.forEach((v, k) => headers.push([k, v]));
645
+ else for (const k in opts.headers) headers.push([k, opts.headers[k]]);
646
+ }
647
+ const r = __sbRpc("fetch", {
648
+ url,
649
+ method: opts.method || "GET",
650
+ headers,
651
+ body: opts.body == null ? null : String(opts.body),
652
+ });
653
+ const respHeaders = new Headers();
654
+ for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
655
+ return new Response(r.body == null ? "" : r.body, { status: r.status || 502, headers: respHeaders });
656
+ };
657
+ }
658
+ };
659
+
660
+ // ---------------------------------------------------------------------------
661
+ // Durable Objects. The class runs here in the sandboxed sprout. There is exactly
662
+ // one sprout process per deployment (the supervisor model) and the native-fetch
663
+ // runtime processes one turn at a time, so calls to a given object id are
664
+ // already serialized — `env.<NS>.get(id).fetch()` invokes the instance directly,
665
+ // no round-trip. Only `state.storage.*` goes to the broker (so object state
666
+ // outlives a sprout restart), scoped to (class, id).
667
+ // ponytail: serialization relies on the single sprout process; a multi-sprout
668
+ // deployment needs the broker to hold a per-id lock (cloud). Storage ops are one
669
+ // key at a time; blockConcurrencyWhile just runs the fn.
670
+
671
+ function __sbMakeDONamespace(binding, className) {
672
+ return {
673
+ idFromName(name) {
674
+ return {
675
+ toString() {
676
+ return "name:" + String(name);
677
+ },
678
+ name: String(name),
679
+ };
680
+ },
681
+ idFromString(hex) {
682
+ return {
683
+ toString() {
684
+ return String(hex);
685
+ },
686
+ };
687
+ },
688
+ newUniqueId() {
689
+ return {
690
+ toString() {
691
+ return "uid:" + crypto.randomUUID();
692
+ },
693
+ };
694
+ },
695
+ get(id) {
696
+ const idStr = __sbIsStr(id) ? id : id.toString();
697
+ return {
698
+ fetch(input, init) {
699
+ let req;
700
+ if (__sbIsObj(input) && __sbIsStr(input.url) && !init) {
701
+ req = input;
702
+ } else {
703
+ const url = __sbIsStr(input) ? input : String((input && input.url) || "https://do/");
704
+ const opts = init || {};
705
+ const headers = new Headers();
706
+ if (opts.headers) {
707
+ if (__sbIsFn(opts.headers.forEach)) opts.headers.forEach((v, k) => headers.set(k, v));
708
+ else for (const k in opts.headers) headers.set(k, opts.headers[k]);
709
+ }
710
+ req = new Request(url, { method: opts.method || "GET", headers });
711
+ if (opts.body != null) req.body = String(opts.body);
712
+ }
713
+ return __sbGetDOInstance(className, idStr).fetch(req);
714
+ },
715
+ };
716
+ },
717
+ };
718
+ }
719
+
720
+ const __sbDOClasses = {};
721
+ const __sbDOInstances = {};
722
+ globalThis.__sbRegisterDO = function (map) {
723
+ for (const k in map) __sbDOClasses[k] = map[k];
724
+ };
725
+
726
+ function __sbGetDOInstance(cls, id) {
727
+ const Ctor = __sbDOClasses[cls];
728
+ if (!Ctor) throw new Error("no such Durable Object class: " + cls);
729
+ const cacheKey = cls + " " + id;
730
+ let inst = __sbDOInstances[cacheKey];
731
+ if (!inst) {
732
+ const state = {
733
+ id: {
734
+ toString() {
735
+ return id;
736
+ },
737
+ },
738
+ storage: __sbDOStorage(cls, id),
739
+ blockConcurrencyWhile(fn) {
740
+ return fn();
741
+ },
742
+ waitUntil() {},
743
+ };
744
+ inst = new Ctor(state, globalThis.env);
745
+ __sbDOInstances[cacheKey] = inst;
746
+ }
747
+ return inst;
748
+ }
749
+
750
+ function __sbDOStorage(cls, id) {
751
+ return {
752
+ get(key) {
753
+ if (Array.isArray(key)) {
754
+ const out = new Map();
755
+ for (let i = 0; i < key.length; i++) {
756
+ const r = __sbRpc("do.storage.get", { cls, id, key: String(key[i]) });
757
+ if (r.found) out.set(key[i], JSON.parse(r.value));
758
+ }
759
+ return out;
760
+ }
761
+ const r = __sbRpc("do.storage.get", { cls, id, key: String(key) });
762
+ return r.found ? JSON.parse(r.value) : undefined;
763
+ },
764
+ put(key, value) {
765
+ if (key != null && __sbIsObj(key)) {
766
+ for (const k in key) __sbRpc("do.storage.put", { cls, id, key: String(k), value: JSON.stringify(key[k]) });
767
+ return;
768
+ }
769
+ __sbRpc("do.storage.put", { cls, id, key: String(key), value: JSON.stringify(value) });
770
+ },
771
+ delete(key) {
772
+ if (Array.isArray(key)) {
773
+ let n = 0;
774
+ for (let i = 0; i < key.length; i++)
775
+ n += __sbRpc("do.storage.delete", { cls, id, key: String(key[i]) }).deleted ? 1 : 0;
776
+ return n;
777
+ }
778
+ return !!__sbRpc("do.storage.delete", { cls, id, key: String(key) }).deleted;
779
+ },
780
+ deleteAll() {
781
+ __sbRpc("do.storage.delete_all", { cls, id });
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
+ },
796
+ list(options) {
797
+ const o = options || {};
798
+ const r = __sbRpc("do.storage.list", {
799
+ cls,
800
+ id,
801
+ prefix: o.prefix == null ? "" : String(o.prefix),
802
+ limit: o.limit == null ? 1000 : o.limit,
803
+ });
804
+ const out = new Map();
805
+ for (let i = 0; i < (r.entries || []).length; i++) out.set(r.entries[i][0], JSON.parse(r.entries[i][1]));
806
+ return out;
807
+ },
808
+ };
809
+ }
810
+
811
+ // ---------------------------------------------------------------------------
812
+ // Trigger dispatch. The compiled server only ever calls `fetch(request)`; this
813
+ // routes the internal `x-sb-trigger` requests (sent by the broker, authenticated
814
+ // with SB_BROKER_TOKEN) to the right user handler, and everything else to
815
+ // `handlers.fetch`.
816
+
817
+ // oxlint-disable-next-line no-unused-vars -- `name` is read inside the RawC block below, not by JS.
818
+ function __sbEnv(name) {
819
+ let res = "";
820
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
821
+ Porffor.c`
822
+ const char* __n; size_t __nl; char* __no = 0;
823
+ porf_native_fetch_read_value(name, &__n, &__nl, &__no);
824
+ char __key[128];
825
+ size_t __kn = __nl < 127 ? __nl : 127;
826
+ memcpy(__key, __n, __kn); __key[__kn] = 0;
827
+ if (__no) free(__no);
828
+ const char* __v = getenv(__key);
829
+ if (__v) res = porf_box((f64)porf_native_fetch_alloc_bytestring(__v, strlen(__v)), 195);
830
+ `;
831
+ return res;
832
+ }
833
+
834
+ function __sbTriggerAuthed(request) {
835
+ const want = __sbEnv("SB_BROKER_TOKEN");
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;
844
+ return request.headers.get("x-sb-token") === want;
845
+ }
846
+
847
+ globalThis.__sbEntry = function (handlers, request) {
848
+ const trigger = request.headers.get("x-sb-trigger");
849
+ if (!trigger) {
850
+ // #28 — per-invocation CPU time. One fetch turn per process (serial), so the
851
+ // process CPU delta across the handler is this invocation's CPU.
852
+ // ponytail: serial-turn assumption; revisit if the profile ever allows
853
+ // concurrent in-process requests.
854
+ //
855
+ // Sync handlers only. An async handler's promise is handed straight back:
856
+ // Porffor alpha-4's native-fetch server resolves the promise the handler
857
+ // itself returned, but never one derived from `.then()`, so chaining the
858
+ // tag on hangs the request forever. cpuMs is documented as absent for
859
+ // async handlers (see LogEvent in services/edge) — that is this.
860
+ const __t0 = __sbCpuMs();
861
+ const __res = handlers.fetch(request);
862
+ if (__res && __sbIsFn(__res.then)) return __res;
863
+ return __sbTagCpu(__res, __t0);
864
+ }
865
+ if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });
866
+
867
+ if (trigger === "scheduled") {
868
+ if (!__sbIsFn(handlers.scheduled)) return new Response("no scheduled handler", { status: 404 });
869
+ const body = __sbReadJson(request);
870
+ handlers.scheduled({ cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} });
871
+ return new Response("", { status: 204 });
872
+ }
873
+
874
+ if (trigger === "queue") {
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") {
881
+ const body = __sbReadJson(request);
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 });
886
+ }
887
+
888
+ return new Response("unknown trigger", { status: 400 });
889
+ };
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
+
937
+ function __sbReadJson(request) {
938
+ try {
939
+ return JSON.parse(request.body == null ? "{}" : String(request.body));
940
+ } catch {
941
+ return {};
942
+ }
943
+ }
944
+ function __sbTryParse(s) {
945
+ try {
946
+ return JSON.parse(s);
947
+ } catch {
948
+ return s;
949
+ }
950
+ }