sproutboat 0.2.0 → 0.3.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/README.md CHANGED
@@ -70,9 +70,16 @@ binding, with an Astro UI and a runnable end-to-end harness.
70
70
  ## Requirements
71
71
 
72
72
  - [Bun](https://bun.sh) 1.4+
73
- - `git` and `make` on `PATH` (first build only — compiles uWebSockets once)
74
- - No Docker. Builds cross-compile to a static `linux-x86_64` binary with Porffor
75
- and Zig. Windows: build from WSL.
73
+
74
+ `build` / `deploy` cross-compile the handler to a static `linux-x86_64` binary
75
+ with Porffor and Zig (Zig is fetched automatically on first use). The package
76
+ ships a prebuilt uWebSockets, so nothing else is compiled from source. No Docker,
77
+ no root. On Windows, build from WSL.
78
+
79
+ If that prebuilt is unusable (a `porffor` pin bump before the archive is
80
+ refreshed), the first build falls back to compiling uWebSockets locally, which
81
+ needs `git` and `make` on `PATH`. `SPROUTBOAT_UWS_TARBALL=<archive>` overrides
82
+ the shipped one.
76
83
 
77
84
  ## Limits (v1)
78
85
 
package/SURFACE.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Generated by `src/surface.test.ts` from `src/surface.ts` + the pinned
4
4
  > toolchain constants. Do not edit by hand — run `UPDATE_SURFACE=1 bun test`.
5
5
 
6
- **Package:** `sproutboat` 0.2.0 · runs on Bun (use `bunx`, not `npx`)
6
+ **Package:** `sproutboat` 0.3.0 · runs on Bun (use `bunx`, not `npx`)
7
7
 
8
8
  ## Commands
9
9
 
@@ -30,7 +30,10 @@ usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | dep
30
30
  | `SPROUTBOAT_API_URL` | Control-plane URL. Overrides the saved active endpoint. |
31
31
  | `SPROUTBOAT_TOKEN` | API token. Overrides the saved credential for the endpoint. |
32
32
  | `SPROUTBOAT_ZIG` | Path to a Zig binary to use instead of downloading the pinned one. |
33
+ | `SPROUTBOAT_UWS_TARBALL` | 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). |
33
34
  | `SPROUTBOAT_COMPILE_TIMEOUT_MS` | Porffor compile timeout in ms (default 600000). |
35
+ | `SPROUTBOAT_VARS_JSON` | JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the worker module. |
36
+ | `SPROUTBOAT_BINDINGS_JSON` | The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line. |
34
37
  | `SPROUTBOAT_CONFIG_DIR` | Directory for credentials.json (default ~/.config/sproutboat). |
35
38
  | `XDG_CONFIG_HOME` | Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset. |
36
39
  | `PORFFOR_VERSION` | Override the Porffor identity string recorded in the manifest. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Wrangler-shaped CLI for Sproutboat. Deploys workers to any control plane via --api-url / SPROUTBOAT_API_URL.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,9 +22,20 @@
22
22
  "bin": {
23
23
  "sproutboat": "src/main.ts"
24
24
  },
25
+ "exports": {
26
+ "./runtime/config": "./src/config.ts",
27
+ "./runtime/source": "./src/source.ts",
28
+ "./runtime/manifest": "./src/manifest.ts",
29
+ "./runtime/assets": "./src/assets.ts",
30
+ "./runtime/broker": "./src/broker.ts",
31
+ "./runtime/wrap": "./src/wrap.ts",
32
+ "./runtime/prelude": "./src/native-fetch-prelude.js",
33
+ "./package.json": "./package.json"
34
+ },
25
35
  "files": [
26
36
  "src",
27
37
  "!src/*.test.ts",
38
+ "vendor",
28
39
  "SURFACE.md"
29
40
  ],
30
41
  "engines": {
package/src/assets.ts CHANGED
@@ -36,6 +36,30 @@ export function contentType(name: string): string {
36
36
  return (dot >= 0 ? TYPES.get(name.slice(dot + 1).toLowerCase()) : undefined) ?? "application/octet-stream";
37
37
  }
38
38
 
39
+ /**
40
+ * Resolve a request path to a manifest key the way a static host does:
41
+ * - an exact hit wins;
42
+ * - a directory path (`/docs/`) tries `/docs/index.html`;
43
+ * - an extensionless path (`/docs`) tries `/docs.html`, then `/docs/index.html`.
44
+ * Returns the matched key, or `null`. This only picks which file to serve — no
45
+ * canonical redirects, and the caller still owns not-found handling. Mirrors
46
+ * Cloudflare's `html_handling: "auto-trailing-slash"` minus the 3xx responses.
47
+ */
48
+ export function resolveAssetKey(path: string, has: (key: string) => boolean): string | null {
49
+ if (!path.startsWith("/")) path = `/${path}`;
50
+ if (path.endsWith("/")) {
51
+ const index = `${path}index.html`;
52
+ return has(index) ? index : null;
53
+ }
54
+ if (has(path)) return path;
55
+ const base = path.slice(path.lastIndexOf("/") + 1);
56
+ if (!base.includes(".")) {
57
+ if (has(`${path}.html`)) return `${path}.html`;
58
+ if (has(`${path}/index.html`)) return `${path}/index.html`;
59
+ }
60
+ return null;
61
+ }
62
+
39
63
  /** Walk `dir` recursively, returning `{ "/path": {hash,size,type} }`. */
40
64
  export function walkAssets(dir: string) {
41
65
  const out: AssetFiles = {};
package/src/broker.ts CHANGED
@@ -22,7 +22,7 @@ import { createHash } from "node:crypto";
22
22
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
23
23
  import { dirname, join, normalize, resolve } from "node:path";
24
24
  import { parseArgs } from "node:util";
25
- import type { AssetManifest } from "./assets";
25
+ import { resolveAssetKey, type AssetManifest } from "./assets";
26
26
 
27
27
  export type Bindings = {
28
28
  kv: string[];
@@ -109,6 +109,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
109
109
 
110
110
  const db = new Database(dbPath);
111
111
  db.exec("PRAGMA journal_mode = WAL");
112
+ // WAL + NORMAL is the standard pairing: a write no longer fsyncs the WAL, so
113
+ // host power loss can drop the last few committed txns, but a process crash
114
+ // never can and the file never corrupts. Right trade for a single-VPS
115
+ // KV/queue/DO store; on real block storage this is ~10-100x on writes.
116
+ db.exec("PRAGMA synchronous = NORMAL");
112
117
  db.exec("CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))");
113
118
  db.exec(
114
119
  "CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, body TEXT NOT NULL, size INTEGER NOT NULL, " +
@@ -158,6 +163,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
158
163
  if (d1Dir) mkdirSync(d1Dir, { recursive: true });
159
164
  conn = new Database(d1Dir ? join(d1Dir, `${name}.sqlite`) : ":memory:", { create: true });
160
165
  conn.exec("PRAGMA journal_mode = WAL");
166
+ conn.exec("PRAGMA synchronous = NORMAL");
161
167
  d1Conns.set(name, conn);
162
168
  }
163
169
  return conn;
@@ -290,9 +296,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
290
296
  const prefix = str(msg.prefix);
291
297
  const cursor = str(msg.cursor);
292
298
  const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 1000);
293
- const rows = db.query<R2Row, [string, string, string]>(
294
- "SELECT * FROM r2 WHERE bucket = ? AND key LIKE ? || '%' AND key > ? ORDER BY key LIMIT " + (limit + 1),
295
- ).all(bucket, prefix, cursor);
299
+ const rows = db.query<R2Row, [string, string, string, number]>(
300
+ "SELECT * FROM r2 WHERE bucket = ? AND key LIKE ? || '%' AND key > ? ORDER BY key LIMIT ?",
301
+ ).all(bucket, prefix, cursor, limit + 1);
296
302
  const truncated = rows.length > limit;
297
303
  const page = truncated ? rows.slice(0, limit) : rows;
298
304
  return {
@@ -335,9 +341,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
335
341
  // query it via the SQL API). Exposed here so a dashboard can read back.
336
342
  const ds = requireAe(msg.dataset);
337
343
  const limit = Math.min(Math.max(Number(msg.limit) || 20, 1), 200);
338
- const rows = db.query<{ ts: number; indexes_json: string; blobs_json: string; doubles_json: string }, [string]>(
339
- "SELECT ts, indexes_json, blobs_json, doubles_json FROM ae WHERE dataset = ? ORDER BY ts DESC, rowid DESC LIMIT " + limit,
340
- ).all(ds);
344
+ const rows = db.query<{ ts: number; indexes_json: string; blobs_json: string; doubles_json: string }, [string, number]>(
345
+ "SELECT ts, indexes_json, blobs_json, doubles_json FROM ae WHERE dataset = ? ORDER BY ts DESC, rowid DESC LIMIT ?",
346
+ ).all(ds, limit);
341
347
  const total = db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM ae WHERE dataset = ?").get(ds);
342
348
  return {
343
349
  ok: true,
@@ -376,18 +382,17 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
376
382
  case "do.storage.list": {
377
383
  const cls = requireDoClass(msg.cls);
378
384
  const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000);
379
- const rows = db.query<{ key: string; value: string }, [string, string, string]>(
380
- "SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT " + limit,
381
- ).all(cls, str(msg.id), str(msg.prefix));
385
+ const rows = db.query<{ key: string; value: string }, [string, string, string, number]>(
386
+ "SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT ?",
387
+ ).all(cls, str(msg.id), str(msg.prefix), limit);
382
388
  return { ok: true, entries: rows.map((r) => [r.key, r.value]) };
383
389
  }
384
390
 
385
391
  case "assets.get": {
386
392
  if (!bindings.assets) throw new Error("assets not bound");
387
- let path = str(msg.path) || "/";
388
- if (!path.startsWith("/")) path = `/${path}`;
389
- if (path.endsWith("/")) path += "index.html";
390
- const hit = readAsset(path);
393
+ const reqPath = str(msg.path) || "/";
394
+ const key = resolveAssetKey(reqPath, (k) => !!assetManifest?.files[k]);
395
+ const hit = key ? readAsset(key) : null;
391
396
  if (hit) return { ok: true, found: true, status: 200, type: hit.type, hash: hit.hash, body: hit.body };
392
397
  const nfh = assetManifest?.notFound ?? "none";
393
398
  if (nfh === "single-page-application") {
package/src/compile.ts CHANGED
@@ -4,89 +4,20 @@
4
4
  * `zig cc -target x86_64-linux-musl` and statically links, so the same command
5
5
  * works from macOS, Linux, or WSL with no Docker.
6
6
  *
7
- * One-time per machine: Porffor git-clones uWebSockets and builds `uSockets.a`
8
- * into ~/.cache/porffor/deps/ (needs `git` and `make` on PATH). Later builds
9
- * reuse it and take a few seconds.
7
+ * One-time per machine: the uWebSockets tree Porffor links is unpacked into
8
+ * ~/.cache/porffor/deps/. `ensureUWebSockets()` extracts the prebuilt archive
9
+ * shipped in `vendor/` so this needs no `git` or `make`; if that archive is
10
+ * unusable it falls back to Porffor's own git + make path (needs both on PATH).
10
11
  */
11
12
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
12
13
  import { dirname, resolve } from "node:path";
13
14
  import { ensurePorfforPatched } from "./patch-porffor";
14
- import { porfforRoot } from "./toolchain";
15
+ import { ensureUWebSockets, porfforRoot, UwsUnavailableError } from "./toolchain";
16
+ import { EMPTY_BINDINGS, preludePath, wrapNativeFetchHandler, type Bindings } from "./wrap";
15
17
 
16
- const preludePath = new URL("./native-fetch-prelude.js", import.meta.url);
17
- // The server honours $PORT at runtime (patches/porffor-render.patch); this baked
18
- // value is only a fallback for a directly-run binary.
19
- const DEFAULT_PORT = 8080;
20
- const COMPILE_TIMEOUT_MS = Number(process.env.SPROUTBOAT_COMPILE_TIMEOUT_MS || 600_000);
21
-
22
- /**
23
- * Binding names a project declares. `do` maps a binding name to a Durable Object
24
- * class name; `crons` are schedule expressions with no name.
25
- */
26
- export type Bindings = {
27
- kv: string[];
28
- secrets: string[];
29
- outbound: string[];
30
- d1: string[];
31
- r2: string[];
32
- queues: string[];
33
- analytics: string[];
34
- do: Array<{ binding: string; className: string }>;
35
- crons: string[];
36
- /** Static-asset binding name for `env.<NAME>.fetch(request)`; `""` when assets are edge-only. */
37
- assets: string;
38
- };
39
-
40
- const EMPTY_BINDINGS: Bindings = { kv: [], secrets: [], outbound: [], d1: [], r2: [], queues: [], analytics: [], do: [], crons: [], assets: "" };
18
+ export { EMPTY_BINDINGS, preludePath, wrapNativeFetchHandler, type Bindings } from "./wrap";
41
19
 
42
- function hasBindings(b: Bindings): boolean {
43
- return (
44
- b.kv.length > 0 || b.secrets.length > 0 || b.outbound.length > 0 || b.d1.length > 0 || b.r2.length > 0 ||
45
- b.queues.length > 0 || b.analytics.length > 0 || b.do.length > 0 || b.assets !== ""
46
- );
47
- }
48
-
49
- /**
50
- * Build the final native-fetch module: the prelude (Web API shims + the broker
51
- * binding shim + the trigger dispatcher), then `const env = {…}` with the baked
52
- * `vars`, then — if any binding is declared — one `__sbInstallBindings(env, …)`
53
- * line, then the user's source with its `export` keywords neutralised (so its
54
- * `export default {…}` becomes a plain object we can hand to the dispatcher),
55
- * then our single `export default { fetch }` that routes every request through
56
- * `__sbEntry` (HTTP → `handlers.fetch`; `x-sb-trigger` → scheduled / queue / DO).
57
- *
58
- * With no bindings and no `scheduled`/`queue`/DO the output behaves exactly like
59
- * a plain `export default { fetch }` worker.
60
- */
61
- export function wrapNativeFetchHandler(
62
- source: string,
63
- prelude: string,
64
- vars: Record<string, string> = {},
65
- bindings: Bindings = EMPTY_BINDINGS,
66
- ): string {
67
- if (!/\bexport\s+default\s*\{/.test(source) || !/\bfetch\s*\(/.test(source)) {
68
- throw new Error("handler must default-export an object with a fetch(request) method");
69
- }
70
- // Neutralise the module's exports: its default object becomes `__sbHandlers`,
71
- // and any `export class`/`function`/`const` (Durable Object classes, helpers)
72
- // becomes a plain top-level declaration. Imports are already rejected upstream.
73
- const neutralised = source
74
- .replace(/^(\s*)export\s+default\s*/m, "$1const __sbHandlers = ")
75
- .replace(/^export\s+(async\s+function|function|class|const|let|var)\b/gm, "$1");
76
-
77
- const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
78
- const wire = hasBindings(bindings) ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n` : "";
79
- const registerDO = bindings.do.length
80
- ? `__sbRegisterDO({ ${bindings.do.map((d) => `${d.className}: ${d.className}`).join(", ")} });\n`
81
- : "";
82
-
83
- return (
84
- `${prelude}\n${env}${wire}` +
85
- `${neutralised}\n` +
86
- `${registerDO}` +
87
- `export default {\n port: ${DEFAULT_PORT},\n fetch(request) { return __sbEntry(__sbHandlers, request); }\n};\n`
88
- );
89
- }
20
+ const COMPILE_TIMEOUT_MS = Number(process.env.SPROUTBOAT_COMPILE_TIMEOUT_MS || 600_000);
90
21
 
91
22
  export type CompileInput = {
92
23
  sourcePath: string;
@@ -99,6 +30,28 @@ export type CompileInput = {
99
30
  /** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
100
31
  export async function compileWorker(input: CompileInput): Promise<void> {
101
32
  await ensurePorfforPatched();
33
+
34
+ // Seed the Porffor uWebSockets cache from the prebuilt archive in `vendor/` so
35
+ // the first build needs no `git` / `make`. Fall back to Porffor's own git+make
36
+ // path if the archive is missing or fails its checksum.
37
+ try {
38
+ await ensureUWebSockets();
39
+ } catch (error) {
40
+ if (!(error instanceof UwsUnavailableError)) throw error;
41
+ const haveGit = Bun.which("git");
42
+ const haveMake = Bun.which("make");
43
+ if (haveGit && haveMake) {
44
+ console.warn(`prebuilt uWebSockets unusable (${error.message.split("\n")[0]}); falling back to git + make (slower, one-time)`);
45
+ } else {
46
+ const missing = [!haveGit && "git", !haveMake && "make"].filter(Boolean).join(" and ");
47
+ throw new Error(
48
+ `${error.message}\n\nThe prebuilt uWebSockets is unusable, and ${missing} ` +
49
+ `${missing.includes("and") ? "are" : "is"} not on PATH for the fallback build. ` +
50
+ `Install ${missing}, or set SPROUTBOAT_UWS_TARBALL to a valid archive.`,
51
+ );
52
+ }
53
+ }
54
+
102
55
  const outDir = dirname(input.outPath);
103
56
  await mkdir(outDir, { recursive: true });
104
57
  const generatedPath = resolve(outDir, "worker.generated.js");
@@ -111,8 +64,11 @@ export async function compileWorker(input: CompileInput): Promise<void> {
111
64
  const binDir = resolve(porffor, "../.bin");
112
65
  const path = `${dirname(input.zigBin)}:${binDir}:${process.env.PATH ?? ""}`;
113
66
 
67
+ // `-s`: strip at link. The unstripped static-musl binary is ~90% DWARF that
68
+ // nothing needs at runtime (12 MB -> ~1.3 MB for the kitchen-sink). Porffor
69
+ // forwards `-s` straight to the `zig cc` link step.
114
70
  const child = Bun.spawn(
115
- [process.execPath, launcher, "native", generatedPath, "-o", input.outPath, "--musl"],
71
+ [process.execPath, launcher, "native", generatedPath, "-o", input.outPath, "--musl", "-s"],
116
72
  { cwd: outDir, stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: path } },
117
73
  );
118
74
  let timedOut = false;
package/src/main.ts CHANGED
File without changes
@@ -11,6 +11,26 @@
11
11
  // Declared before it is referenced: a getter body that names a later top-level
12
12
  // class throws ReferenceError in Porffor (see patches/UPSTREAM.md draft B).
13
13
 
14
+ // #41 — cold-start phase marker. Runs as the first thing in the bundle: writes
15
+ // the current wall-clock ms to $SB_STARTUP_FILE so the supervisor can split
16
+ // cold-start into "spawn -> JS starts" (process + runtime bootstrap) and
17
+ // "JS starts -> listening" (module eval + server bind). No-op when unset.
18
+ function __sbStartupMark() {
19
+ Porffor.c`
20
+ const char* __f = getenv("SB_STARTUP_FILE");
21
+ if (__f) {
22
+ struct timespec __ts;
23
+ clock_gettime(CLOCK_REALTIME, &__ts);
24
+ double __ms = (double)__ts.tv_sec * 1000.0 + (double)__ts.tv_nsec / 1000000.0;
25
+ char __buf[32];
26
+ int __n = snprintf(__buf, sizeof(__buf), "%.0f", __ms);
27
+ int __fd = open(__f, O_WRONLY | O_CREAT | O_TRUNC, 0600);
28
+ if (__fd >= 0) { write(__fd, __buf, (size_t)__n); close(__fd); }
29
+ }
30
+ `;
31
+ }
32
+ __sbStartupMark();
33
+
14
34
  class __SproutboatURLSearchParams {
15
35
  constructor(init) {
16
36
  this._keys = [];
@@ -118,13 +138,21 @@ __sbDefineURLAccessor('username', function () { return ''; });
118
138
  __sbDefineURLAccessor('password', function () { return ''; });
119
139
 
120
140
  // crypto.randomUUID / getRandomValues are absent in native-fetch. Provide them
121
- // so Worker code (request ids, cache keys, idempotency keys) runs.
122
- // ponytail: Math.random() is NOT cryptographically strong. Swap for a real
123
- // CSPRNG the moment Porffor exposes one do not use these for tokens/secrets.
141
+ // backed by the OS CSPRNG (`__sbRandomBytes` -> inline C -> /dev/urandom), so
142
+ // tokens, idempotency keys and UUIDs are unpredictable. Deliberately no insecure
143
+ // fallback a silent downgrade to a weak source is worse than throwing.
124
144
  if (globalThis.crypto == null) globalThis.crypto = {};
125
145
  if (globalThis.crypto.getRandomValues == null) {
126
146
  globalThis.crypto.getRandomValues = function (view) {
127
- for (let i = 0; i < view.length; i++) view[i] = Math.floor(Math.random() * 256);
147
+ const n = view.length >>> 0;
148
+ // WebCrypto caps a single call at 65536 bytes.
149
+ if (n > 65536) throw new RangeError("crypto.getRandomValues: byte length exceeds 65536");
150
+ if (n === 0) return view;
151
+ // One CSPRNG byte per element. Correct for Uint8Array (and randomUUID); a
152
+ // wider view gets its low byte filled, matching the previous polyfill's shape.
153
+ const bytes = __sbRandomBytes(String(n));
154
+ if (bytes.length !== n) throw new Error("crypto.getRandomValues: OS entropy source unavailable");
155
+ for (let i = 0; i < n; i++) view[i] = bytes.charCodeAt(i) & 0xff;
128
156
  return view;
129
157
  };
130
158
  }
@@ -151,62 +179,103 @@ if (globalThis.crypto.randomUUID == null) {
151
179
 
152
180
  // ---------------------------------------------------------------------------
153
181
  // Bindings: env.<KV>, env.<SECRET>, env.<D1>, env.<R2>, and globalThis.fetch,
154
- // backed by a Bun broker on a loopback TCP port. The transport is inline C — blocking
155
- // socket/connect/write/read per call (http-sync-v0: one worker event-loop turn
156
- // per request, so a blocking roundtrip is acceptable). Wire frame:
182
+ // backed by a Bun broker on a loopback TCP port. The transport is inline C —
183
+ // blocking write/read per call over ONE long-lived connection (http-sync-v0: one
184
+ // worker event-loop turn per request, so a blocking roundtrip is acceptable).
185
+ // Wire frame:
157
186
  // [u32 LE len][ <token> "\n" <json> ] reply: [u32 LE len][ <json> ]
158
187
  // SB_BROKER_PORT / SB_BROKER_TOKEN are set by the supervisor next to $PORT.
159
188
  // If SB_BROKER_PORT is unset the shims below are never installed (compile.ts
160
189
  // only emits the __sbInstallBindings call when the project declares bindings),
161
190
  // so a plain worker is byte-for-byte unchanged.
162
- // ponytail: blocking IO, fresh connection per call, text values only. Connection
163
- // pooling + binary values + non-blocking = v2.
191
+ // ponytail: text values only; still AF_INET loopback, not AF_UNIX. A failed
192
+ // exchange reconnects and resends once a broker crash between "request applied"
193
+ // and "reply read" can double-apply a non-idempotent op (queue.send, INSERT);
194
+ // the old fresh-connection-per-call path just failed the call there instead.
195
+ // Binary values + AF_UNIX = v2.
164
196
 
165
197
  Porffor.c`
166
198
  #include <sys/socket.h>
167
199
  #include <netinet/in.h>
200
+ #include <netinet/tcp.h>
201
+ #include <signal.h>
168
202
  #include <unistd.h>
169
203
  #include <string.h>
170
204
  #include <stdlib.h>
171
205
  #include <stdio.h>
206
+ #include <fcntl.h>
207
+ #include <errno.h>
208
+ #include <time.h>
172
209
 
173
210
  u32 porf_native_fetch_alloc_bytestring(const char* input, size_t len);
174
211
  int porf_native_fetch_read_value(jsval value, const char** out_buf, size_t* out_len, char** out_owned);
175
212
 
213
+ // Fill buf with n bytes from the OS CSPRNG. /dev/urandom is present on Linux and
214
+ // macOS and inside the bubblewrap sandbox; blocking is not a concern after the
215
+ // pool is seeded. Returns 0, or -1 if the source could not be read in full.
216
+ static int sb_os_random(unsigned char* buf, size_t n) {
217
+ int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
218
+ if (fd < 0) return -1;
219
+ size_t off = 0;
220
+ while (off < n) {
221
+ long r = read(fd, buf + off, n - off);
222
+ if (r <= 0) {
223
+ if (r < 0 && errno == EINTR) continue;
224
+ close(fd);
225
+ return -1;
226
+ }
227
+ off += (size_t)r;
228
+ }
229
+ close(fd);
230
+ return 0;
231
+ }
232
+
176
233
  static int sb_io_all(int fd, unsigned char* buf, size_t len, int writing) {
177
234
  size_t done = 0;
178
235
  while (done < len) {
179
236
  long n = writing ? write(fd, buf + done, len - done) : read(fd, buf + done, len - done);
180
- if (n <= 0) return -1;
237
+ if (n <= 0) {
238
+ if (n < 0 && errno == EINTR) continue;
239
+ return -1;
240
+ }
181
241
  done += (size_t)n;
182
242
  }
183
243
  return 0;
184
244
  }
185
245
 
186
- static int sb_broker_roundtrip(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
187
- *resp_out = NULL;
188
- *resp_len_out = 0;
246
+ // One long-lived loopback connection to the broker, reused across every binding
247
+ // call. The broker frames each request/reply independently and keeps the socket
248
+ // open, so the steady-state per-call cost is just write + read — no socket(),
249
+ // connect() handshake or close() each time. -1 = not connected.
250
+ static int sb_broker_fd = -1;
189
251
 
252
+ static int sb_broker_connect(void) {
190
253
  const char* port_s = getenv("SB_BROKER_PORT");
191
254
  if (!port_s) return -10;
192
- int port = atoi(port_s);
193
- const char* tok = getenv("SB_BROKER_TOKEN");
194
- size_t tok_len = tok ? strlen(tok) : 0;
195
-
255
+ signal(SIGPIPE, SIG_IGN); // a dead broker must yield EPIPE, not kill the worker
196
256
  int fd = socket(AF_INET, SOCK_STREAM, 0);
197
257
  if (fd < 0) return -1;
198
-
258
+ int one = 1;
259
+ setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
199
260
  struct sockaddr_in addr;
200
261
  memset(&addr, 0, sizeof(addr));
201
262
  addr.sin_family = AF_INET;
202
- addr.sin_port = htons((unsigned short)port);
263
+ addr.sin_port = htons((unsigned short)atoi(port_s));
203
264
  addr.sin_addr.s_addr = htonl(0x7f000001u); // 127.0.0.1
204
265
  if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) { close(fd); return -2; }
266
+ sb_broker_fd = fd;
267
+ return 0;
268
+ }
269
+
270
+ // Send one framed request, read one framed reply, on the persistent fd.
271
+ static int sb_broker_exchange(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
272
+ const char* tok = getenv("SB_BROKER_TOKEN");
273
+ size_t tok_len = tok ? strlen(tok) : 0;
205
274
 
206
275
  // frame body: token "\n" json
207
276
  size_t body_len = tok_len + 1 + req_len;
208
277
  unsigned char* frame = (unsigned char*)malloc(4 + body_len);
209
- if (!frame) { close(fd); return -5; }
278
+ if (!frame) return -5;
210
279
  frame[0] = (unsigned char)(body_len & 0xff);
211
280
  frame[1] = (unsigned char)((body_len >> 8) & 0xff);
212
281
  frame[2] = (unsigned char)((body_len >> 16) & 0xff);
@@ -214,23 +283,40 @@ static int sb_broker_roundtrip(const char* req, size_t req_len, char** resp_out,
214
283
  if (tok_len) memcpy(frame + 4, tok, tok_len);
215
284
  frame[4 + tok_len] = '\n';
216
285
  if (req_len) memcpy(frame + 4 + tok_len + 1, req, req_len);
217
- int wr = sb_io_all(fd, frame, 4 + body_len, 1);
286
+ int wr = sb_io_all(sb_broker_fd, frame, 4 + body_len, 1);
218
287
  free(frame);
219
- if (wr != 0) { close(fd); return -3; }
288
+ if (wr != 0) return -3;
220
289
 
221
290
  unsigned char rhdr[4];
222
- if (sb_io_all(fd, rhdr, 4, 0) != 0) { close(fd); return -4; }
291
+ if (sb_io_all(sb_broker_fd, rhdr, 4, 0) != 0) return -4;
223
292
  size_t rlen = (size_t)rhdr[0] | ((size_t)rhdr[1] << 8) | ((size_t)rhdr[2] << 16) | ((size_t)rhdr[3] << 24);
224
293
 
225
294
  char* buf = (char*)malloc(rlen ? rlen : 1);
226
- if (!buf) { close(fd); return -5; }
227
- if (rlen && sb_io_all(fd, (unsigned char*)buf, rlen, 0) != 0) { free(buf); close(fd); return -6; }
228
- close(fd);
295
+ if (!buf) return -5;
296
+ if (rlen && sb_io_all(sb_broker_fd, (unsigned char*)buf, rlen, 0) != 0) { free(buf); return -6; }
229
297
 
230
298
  *resp_out = buf;
231
299
  *resp_len_out = rlen;
232
300
  return 0;
233
301
  }
302
+
303
+ static int sb_broker_roundtrip(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
304
+ *resp_out = NULL;
305
+ *resp_len_out = 0;
306
+ // Two tries: a broker restart (or an idle-closed socket) invalidates the fd,
307
+ // so a failed exchange drops the connection and reconnects once before failing.
308
+ for (int attempt = 0; attempt < 2; attempt++) {
309
+ if (sb_broker_fd < 0) {
310
+ int rc = sb_broker_connect();
311
+ if (rc != 0) return rc;
312
+ }
313
+ int rc = sb_broker_exchange(req, req_len, resp_out, resp_len_out);
314
+ if (rc == 0) return 0;
315
+ close(sb_broker_fd);
316
+ sb_broker_fd = -1;
317
+ }
318
+ return -3;
319
+ }
234
320
  `;
235
321
 
236
322
  // One request string in, one reply string out. `reqJson` is a parameter, so the
@@ -255,6 +341,30 @@ function __sbCall(reqJson) {
255
341
  return res;
256
342
  }
257
343
 
344
+ // `nStr` is the decimal byte count as a string (same string-param pattern as
345
+ // __sbEnv). Returns a bytestring of that many CSPRNG bytes, or '' on failure.
346
+ function __sbRandomBytes(nStr) {
347
+ let out = '';
348
+ Porffor.c`
349
+ const char* __ns; size_t __nsl; char* __nso = 0;
350
+ porf_native_fetch_read_value(nStr, &__ns, &__nsl, &__nso);
351
+ char __nb[16];
352
+ size_t __k = __nsl < 15 ? __nsl : 15;
353
+ memcpy(__nb, __ns, __k); __nb[__k] = 0;
354
+ if (__nso) free(__nso);
355
+ long __n = atol(__nb);
356
+ if (__n > 0 && __n <= 65536) {
357
+ unsigned char* __b = (unsigned char*)malloc((size_t)__n);
358
+ if (__b) {
359
+ if (sb_os_random(__b, (size_t)__n) == 0)
360
+ out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__b, (size_t)__n), 195);
361
+ free(__b);
362
+ }
363
+ }
364
+ `;
365
+ return out;
366
+ }
367
+
258
368
  function __sbRpc(op, extra) {
259
369
  const req = { op };
260
370
  if (extra) for (const k in extra) req[k] = extra[k];
@@ -359,9 +469,17 @@ globalThis.__sbInstallBindings = function (target, bindings) {
359
469
 
360
470
  for (let i = 0; i < (bindings.secrets || []).length; i++) {
361
471
  const name = bindings.secrets[i];
472
+ // Fetch lazily, then freeze as a data property: a secret is process-lifetime
473
+ // immutable (a new value means a redeploy = a new process), so one broker
474
+ // round-trip on first read, zero after. A getter that RPCs on every access
475
+ // turns `'Bearer ' + env.KEY` in a loop into a syscall storm.
362
476
  Object.defineProperty(target, name, {
363
477
  configurable: true,
364
- get() { return __sbRpc('secret.get', { name }).value; },
478
+ get() {
479
+ const value = __sbRpc('secret.get', { name }).value;
480
+ Object.defineProperty(target, name, { value, configurable: true, enumerable: true });
481
+ return value;
482
+ },
365
483
  });
366
484
  }
367
485
 
@@ -509,8 +627,7 @@ function __sbMakeDONamespace(binding, className) {
509
627
  idFromName(name) { return { toString() { return 'name:' + String(name); }, name: String(name) }; },
510
628
  idFromString(hex) { return { toString() { return String(hex); } }; },
511
629
  newUniqueId() {
512
- const id = (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
513
- return { toString() { return 'uid:' + id; } };
630
+ return { toString() { return 'uid:' + crypto.randomUUID(); } };
514
631
  },
515
632
  get(id) {
516
633
  const idStr = typeof id === 'string' ? id : id.toString();
package/src/surface.ts CHANGED
@@ -25,7 +25,10 @@ export const ENV_VARS: readonly EnvVar[] = [
25
25
  { name: "SPROUTBOAT_API_URL", purpose: "Control-plane URL. Overrides the saved active endpoint." },
26
26
  { name: "SPROUTBOAT_TOKEN", purpose: "API token. Overrides the saved credential for the endpoint." },
27
27
  { name: "SPROUTBOAT_ZIG", purpose: "Path to a Zig binary to use instead of downloading the pinned one." },
28
+ { name: "SPROUTBOAT_UWS_TARBALL", purpose: "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)." },
28
29
  { name: "SPROUTBOAT_COMPILE_TIMEOUT_MS", purpose: "Porffor compile timeout in ms (default 600000)." },
30
+ { name: "SPROUTBOAT_VARS_JSON", purpose: "JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the worker module." },
31
+ { name: "SPROUTBOAT_BINDINGS_JSON", purpose: "The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line." },
29
32
  { name: "SPROUTBOAT_CONFIG_DIR", purpose: "Directory for credentials.json (default ~/.config/sproutboat)." },
30
33
  { name: "XDG_CONFIG_HOME", purpose: "Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset." },
31
34
  { name: "PORFFOR_VERSION", purpose: "Override the Porffor identity string recorded in the manifest." },
package/src/toolchain.ts CHANGED
@@ -30,6 +30,14 @@ const PORFFOR_COMMIT = "a415d19";
30
30
  // uWebSockets commit Porffor alpha-4 fetches for the native-fetch server. Read
31
31
  // from node_modules/porffor at build time; this is the fallback for the stamp.
32
32
  const UWS_COMMIT = "360c276d";
33
+ const UWS_COMMIT_FULL = "360c276d609d59af56ae6932adb95154ace9f15f";
34
+
35
+ // `vendor/uwebsockets-<UWS_COMMIT>-musl.tar.xz` ships in the package: the
36
+ // checked-out, patched, `zig cc -target x86_64-linux-musl`-built uWebSockets
37
+ // tree (headers + `uSockets/uSockets.a`). Regenerate + re-pin the sha whenever
38
+ // the `porffor` pin (and thus UWS_COMMIT_FULL) changes — `bun tools/prebuild-uws.ts`
39
+ // or the `uws-prebuild` workflow.
40
+ const UWS_TARBALL_SHA256 = "e83736f3f8cf9d56a1ebe6ea61625a7af12386763374d47c14cff472ada7484a";
33
41
 
34
42
  function platformKey(): string {
35
43
  const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : null;
@@ -82,6 +90,66 @@ export async function ensureZig(): Promise<string> {
82
90
  return bin;
83
91
  }
84
92
 
93
+ function uwsCommitFull(): string {
94
+ try {
95
+ const src = readFileSync(resolve(porfforRoot(), "compiler/uwebsockets.js"), "utf8");
96
+ return /UWS_COMMIT\s*=\s*['"]([0-9a-f]{40})/.exec(src)?.[1] ?? UWS_COMMIT_FULL;
97
+ } catch {
98
+ return UWS_COMMIT_FULL;
99
+ }
100
+ }
101
+
102
+ /** Thrown when the prebuilt uWebSockets archive is missing or fails its checksum. */
103
+ export class UwsUnavailableError extends Error {}
104
+
105
+ /** Path to the vendored prebuilt archive for the given short commit. */
106
+ export function uwsVendorArchive(short: string): string {
107
+ return resolve(import.meta.dir, "..", "vendor", `uwebsockets-${short}-musl.tar.xz`);
108
+ }
109
+
110
+ /**
111
+ * Seed `~/.cache/porffor/deps/uWebSockets-<commit>-musl/` with the checked-out,
112
+ * patched, `x86_64-linux-musl`-built uWebSockets tree so Porffor's own
113
+ * `ensureUWebSockets` / `ensureUSocketsBuilt` short-circuit — the first build
114
+ * then needs no `git` and no `make`, only Zig.
115
+ *
116
+ * The archive ships in the package (`vendor/`). No-ops if the cache is already
117
+ * populated. Throws `UwsUnavailableError` if the archive is missing or fails its
118
+ * checksum; the caller decides whether to fall back to Porffor's git + make path.
119
+ *
120
+ * `SPROUTBOAT_UWS_TARBALL=/path/to/archive.tar.xz` overrides the vendored one.
121
+ */
122
+ export async function ensureUWebSockets(): Promise<void> {
123
+ const commit = uwsCommitFull();
124
+ const short = commit.slice(0, 8);
125
+ const depsRoot = resolve(homedir(), ".cache/porffor/deps");
126
+ const dir = resolve(depsRoot, `uWebSockets-${commit}-musl`);
127
+ if (existsSync(resolve(dir, "src/App.h")) && existsSync(resolve(dir, "uSockets/uSockets.a"))) return;
128
+
129
+ const archive = process.env.SPROUTBOAT_UWS_TARBALL || uwsVendorArchive(short);
130
+ if (!existsSync(archive)) {
131
+ throw new UwsUnavailableError(
132
+ process.env.SPROUTBOAT_UWS_TARBALL
133
+ ? `SPROUTBOAT_UWS_TARBALL=${archive} does not exist`
134
+ : `no vendored uWebSockets archive at ${archive} (porffor pin moved? run \`bun tools/prebuild-uws.ts\`)`,
135
+ );
136
+ }
137
+ if (!process.env.SPROUTBOAT_UWS_TARBALL) {
138
+ const actual = await sha256File(archive);
139
+ if (actual !== UWS_TARBALL_SHA256) {
140
+ throw new UwsUnavailableError(`vendored uWebSockets sha256 mismatch\n expected ${UWS_TARBALL_SHA256}\n got ${actual}`);
141
+ }
142
+ }
143
+
144
+ await mkdir(dir, { recursive: true });
145
+ const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], { stdout: "pipe", stderr: "pipe" });
146
+ const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
147
+ if (code !== 0) {
148
+ await rm(dir, { recursive: true, force: true });
149
+ throw new UwsUnavailableError(`could not extract vendored uWebSockets: ${err.trim()}`);
150
+ }
151
+ }
152
+
85
153
  /** Directory holding node_modules/porffor (walks up from this file). */
86
154
  export function porfforRoot(start = import.meta.dir): string {
87
155
  let dir = start;
package/src/wrap.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The build-independent half of worker compilation: the binding/trigger wrapper
3
+ * that turns a user's `export default { fetch }` into a native-fetch module, plus
4
+ * the `Bindings` shape and the `SPROUTBOAT_*_JSON` env readers.
5
+ *
6
+ * This module has no imports on purpose — the monorepo consumes it via the
7
+ * `sproutboat/runtime/wrap` export to drive its own (host-native, non-musl)
8
+ * compile path without pulling in `toolchain.ts` / `patch-porffor.ts`.
9
+ */
10
+
11
+ /** The prelude file (Web API shims + broker binding shim + trigger dispatcher).
12
+ * It is read as text and string-prepended before Porffor sees it, never
13
+ * imported — callers do `readFile(preludePath, "utf8")`. */
14
+ export const preludePath = new URL("./native-fetch-prelude.js", import.meta.url);
15
+
16
+ // The server honours $PORT at runtime (patches/porffor-render.patch); this baked
17
+ // value is only a fallback for a directly-run binary.
18
+ const DEFAULT_PORT = 8080;
19
+
20
+ /**
21
+ * Binding names a project declares. `do` maps a binding name to a Durable Object
22
+ * class name; `crons` are schedule expressions with no name.
23
+ */
24
+ export type Bindings = {
25
+ kv: string[];
26
+ secrets: string[];
27
+ outbound: string[];
28
+ d1: string[];
29
+ r2: string[];
30
+ queues: string[];
31
+ analytics: string[];
32
+ do: Array<{ binding: string; className: string }>;
33
+ crons: string[];
34
+ /** Static-asset binding name for `env.<NAME>.fetch(request)`; `""` when assets are edge-only. */
35
+ assets: string;
36
+ };
37
+
38
+ export const EMPTY_BINDINGS: Bindings = { kv: [], secrets: [], outbound: [], d1: [], r2: [], queues: [], analytics: [], do: [], crons: [], assets: "" };
39
+
40
+ function hasBindings(b: Bindings): boolean {
41
+ return (
42
+ b.kv.length > 0 || b.secrets.length > 0 || b.outbound.length > 0 || b.d1.length > 0 || b.r2.length > 0 ||
43
+ b.queues.length > 0 || b.analytics.length > 0 || b.do.length > 0 || b.assets !== ""
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Build the final native-fetch module: the prelude (Web API shims + the broker
49
+ * binding shim + the trigger dispatcher), then `const env = {…}` with the baked
50
+ * `vars`, then — if any binding is declared — one `__sbInstallBindings(env, …)`
51
+ * line, then the user's source with its `export` keywords neutralised (so its
52
+ * `export default {…}` becomes a plain object we can hand to the dispatcher),
53
+ * then our single `export default { fetch }` that routes every request through
54
+ * `__sbEntry` (HTTP → `handlers.fetch`; `x-sb-trigger` → scheduled / queue / DO).
55
+ *
56
+ * With no bindings and no `scheduled`/`queue`/DO the output behaves exactly like
57
+ * a plain `export default { fetch }` worker.
58
+ *
59
+ * `port` is only the baked fallback in `export default { port }`; the runtime
60
+ * reads `$PORT` first. The monorepo's bench path overrides it.
61
+ *
62
+ * ponytail: the worker process is long-lived, so a handler that mutates `env`
63
+ * leaks that change to later requests. Freeze upstream once Porffor supports
64
+ * Object.freeze in native mode.
65
+ */
66
+ export function wrapNativeFetchHandler(
67
+ source: string,
68
+ prelude: string,
69
+ vars: Record<string, string> = {},
70
+ bindings: Bindings = EMPTY_BINDINGS,
71
+ port: number = DEFAULT_PORT,
72
+ ): string {
73
+ if (!/\bexport\s+default\s*\{/.test(source) || !/\bfetch\s*\(/.test(source)) {
74
+ throw new Error("handler must default-export an object with a fetch(request) method");
75
+ }
76
+ // Neutralise the module's exports: its default object becomes `__sbHandlers`,
77
+ // and any `export class`/`function`/`const` (Durable Object classes, helpers)
78
+ // becomes a plain top-level declaration. Imports are already rejected upstream.
79
+ const neutralised = source
80
+ .replace(/^(\s*)export\s+default\s*/m, "$1const __sbHandlers = ")
81
+ .replace(/^export\s+(async\s+function|function|class|const|let|var)\b/gm, "$1");
82
+
83
+ const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
84
+ const wire = hasBindings(bindings) ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n` : "";
85
+ const registerDO = bindings.do.length
86
+ ? `__sbRegisterDO({ ${bindings.do.map((d) => `${d.className}: ${d.className}`).join(", ")} });\n`
87
+ : "";
88
+
89
+ return (
90
+ `${prelude}\n${env}${wire}` +
91
+ `${neutralised}\n` +
92
+ `${registerDO}` +
93
+ `export default {\n port: ${port},\n fetch(request) { return __sbEntry(__sbHandlers, request); }\n};\n`
94
+ );
95
+ }
96
+
97
+ type VarsJson = string | number | boolean | null | { readonly [key: string]: VarsJson } | VarsJson[];
98
+ function isVarsObject(value: VarsJson): value is { readonly [key: string]: VarsJson } {
99
+ return value !== null && Object(value) === value && !Array.isArray(value);
100
+ }
101
+ function isVarsString(value: VarsJson): value is string {
102
+ return Object(value) !== value && value === String(value);
103
+ }
104
+
105
+ /** `SPROUTBOAT_VARS_JSON` (set by the build) → a validated flat string map. */
106
+ export function readVarsFromEnv(): Record<string, string> {
107
+ const raw = process.env.SPROUTBOAT_VARS_JSON;
108
+ const vars: Record<string, string> = {};
109
+ if (!raw) return vars;
110
+ const parsed: VarsJson = JSON.parse(raw);
111
+ if (!isVarsObject(parsed)) throw new Error("SPROUTBOAT_VARS_JSON must be a JSON object");
112
+ for (const [key, value] of Object.entries(parsed)) {
113
+ if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !isVarsString(value)) throw new Error(`SPROUTBOAT_VARS_JSON.${key} must map an UPPER_SNAKE name to a string`);
114
+ vars[key] = value;
115
+ }
116
+ return vars;
117
+ }
118
+
119
+ /**
120
+ * `SPROUTBOAT_BINDINGS_JSON` (the artifact's `bindings.json`, passed by the
121
+ * build) → a `Bindings` shape. Every field is re-validated here; unknown keys
122
+ * are dropped and a missing / empty payload is `EMPTY_BINDINGS`, so an old build
123
+ * with no bindings still compiles.
124
+ */
125
+ export function readBindingsFromEnv(): Bindings {
126
+ const raw = process.env.SPROUTBOAT_BINDINGS_JSON;
127
+ if (!raw) return EMPTY_BINDINGS;
128
+ const parsed: VarsJson = JSON.parse(raw);
129
+ if (!isVarsObject(parsed)) throw new Error("SPROUTBOAT_BINDINGS_JSON must be a JSON object");
130
+ const strings = (v: VarsJson): string[] => (Array.isArray(v) ? v.filter(isVarsString) : []);
131
+ const dos: Array<{ binding: string; className: string }> = [];
132
+ if (Array.isArray(parsed.do)) {
133
+ for (const entry of parsed.do) {
134
+ if (isVarsObject(entry) && isVarsString(entry.binding) && isVarsString(entry.className)) {
135
+ dos.push({ binding: entry.binding, className: entry.className });
136
+ }
137
+ }
138
+ }
139
+ return {
140
+ kv: strings(parsed.kv),
141
+ secrets: strings(parsed.secrets),
142
+ outbound: strings(parsed.outbound),
143
+ d1: strings(parsed.d1),
144
+ r2: strings(parsed.r2),
145
+ queues: strings(parsed.queues),
146
+ analytics: strings(parsed.analytics),
147
+ do: dos,
148
+ crons: strings(parsed.crons),
149
+ assets: isVarsString(parsed.assets) ? parsed.assets : "",
150
+ };
151
+ }