sproutboat 0.2.1 → 0.4.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/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.1 · runs on Bun (use `bunx`, not `npx`)
6
+ **Package:** `sproutboat` 0.4.0 · runs on Bun (use `bunx`, not `npx`)
7
7
 
8
8
  ## Commands
9
9
 
@@ -11,16 +11,18 @@
11
11
  | --- | --- | --- |
12
12
  | `init` | `[name]` | Scaffold sproutboat.jsonc + src/index.js in ./<name>. |
13
13
  | `check` | `[project-dir]` | Validate the config and entry point without building. |
14
- | `build` | `[project-dir]` | Cross-compile the native-fetch worker (Porffor + Zig). |
14
+ | `build` | `[project-dir]` | Cross-compile the native-fetch sprout (Porffor + Zig). |
15
15
  | `deploy` | `[project-dir] [--dry-run] [--artifact <dir>]` | Build (unless --artifact), print the report, upload. --dry-run stops before upload. |
16
16
  | `login` | `[--api-url <url>] [--token <token>]` | Device-code browser flow, or store <token> for <url> directly. |
17
17
  | `tail` | `[project-dir]` | Print the project's recent request logs. |
18
18
  | `versions` | `list [project-dir]` | List the project's deployed versions. |
19
19
  | `rollback` | `<version-id> [project-dir]` | Re-activate a previous version. |
20
+ | `domains` | `[list | add <host> | verify <host> | rm <host>] [project-dir]` | Attach a custom domain to the project (TXT-verified). No sub-command lists. |
21
+ | `secrets` | `[list | set <NAME> [value] | rm <NAME>] [project-dir]` | Manage encrypted project secrets (read as env.NAME). `set` takes the value from the arg or stdin; applies on next deploy. |
20
22
  | `delete` | `--yes [project-dir]` | Delete the project and every version. |
21
23
 
22
24
  ```
23
- usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | deploy [project-dir] [--dry-run] [--artifact <dir>] | login [--api-url <url>] [--token <token>] | tail [project-dir] | versions list [project-dir] | rollback <version-id> [project-dir] | delete --yes [project-dir]>
25
+ usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | deploy [project-dir] [--dry-run] [--artifact <dir>] | login [--api-url <url>] [--token <token>] | tail [project-dir] | versions list [project-dir] | rollback <version-id> [project-dir] | domains [list | add <host> | verify <host> | rm <host>] [project-dir] | secrets [list | set <NAME> [value] | rm <NAME>] [project-dir] | delete --yes [project-dir]>
24
26
  ```
25
27
 
26
28
  ## Environment variables
@@ -32,12 +34,14 @@ usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | dep
32
34
  | `SPROUTBOAT_ZIG` | Path to a Zig binary to use instead of downloading the pinned one. |
33
35
  | `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). |
34
36
  | `SPROUTBOAT_COMPILE_TIMEOUT_MS` | Porffor compile timeout in ms (default 600000). |
37
+ | `SPROUTBOAT_VARS_JSON` | JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the sprout module. |
38
+ | `SPROUTBOAT_BINDINGS_JSON` | The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line. |
35
39
  | `SPROUTBOAT_CONFIG_DIR` | Directory for credentials.json (default ~/.config/sproutboat). |
36
40
  | `XDG_CONFIG_HOME` | Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset. |
37
41
  | `PORFFOR_VERSION` | Override the Porffor identity string recorded in the manifest. |
38
- | `SB_BROKER_PORT` | Loopback port of the binding broker, read by the compiled worker at runtime (set by the control plane, or by `src/broker.ts` for local runs). |
39
- | `SB_BROKER_TOKEN` | Per-deployment auth token the worker sends on every broker frame, and the broker sends back on scheduled/queue triggers (paired with SB_BROKER_PORT). |
40
- | `SB_WORKER_URL` | http://127.0.0.1:<PORT> of the worker; when set, `src/broker.ts` runs the cron scheduler and queue consumer and delivers triggers to it. |
42
+ | `SB_BROKER_PORT` | Loopback port of the binding broker, read by the compiled sprout at runtime (set by the control plane, or by `src/broker.ts` for local runs). |
43
+ | `SB_BROKER_TOKEN` | Per-deployment auth token the sprout sends on every broker frame, and the broker sends back on scheduled/queue triggers (paired with SB_BROKER_PORT). |
44
+ | `SB_SPROUT_URL` | http://127.0.0.1:<PORT> of the sprout; when set, `src/broker.ts` runs the cron scheduler and queue consumer and delivers triggers to it. |
41
45
 
42
46
  ## Build toolchain (pinned)
43
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.2.1",
3
+ "version": "0.4.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,6 +22,16 @@
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",
package/src/assets.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * directory next to the artifact and writes `assets.json` (this manifest); the
4
4
  * edge serves matching files directly (assets-first, like Cloudflare), and the
5
5
  * broker's `assets.get` op backs `env.<ASSETS>.fetch(request)` for the paths
6
- * the worker chooses to serve itself.
6
+ * the sprout chooses to serve itself.
7
7
  */
8
8
  import { createHash } from "node:crypto";
9
9
  import { readdirSync, readFileSync } from "node:fs";
@@ -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
@@ -2,11 +2,11 @@
2
2
  /**
3
3
  * Per-deployment binding broker.
4
4
  *
5
- * A native-fetch worker has no syscalls of its own beyond an inbound HTTP
5
+ * A native-fetch sprout has no syscalls of its own beyond an inbound HTTP
6
6
  * server; the prelude's inline-C transport opens a loopback TCP connection to
7
7
  * this process for every `env.<KV>` / `env.<SECRET>` / `fetch()` call. The
8
8
  * supervisor starts one broker per deployment, on its own loopback port, and
9
- * passes `SB_BROKER_PORT` + `SB_BROKER_TOKEN` to the worker next to `$PORT`.
9
+ * passes `SB_BROKER_PORT` + `SB_BROKER_TOKEN` to the sprout next to `$PORT`.
10
10
  *
11
11
  * Wire frame (both directions): [u32 LE length][payload].
12
12
  * request payload : "<token>\n<json>"
@@ -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[];
@@ -47,11 +47,11 @@ export type BrokerOptions = {
47
47
  bindings?: Partial<Bindings>;
48
48
  secrets?: Record<string, string>;
49
49
  /**
50
- * `http://127.0.0.1:<PORT>` of this deployment's worker. When set, the broker
51
- * runs the cron scheduler and the queue consumer, delivering to the worker
50
+ * `http://127.0.0.1:<PORT>` of this deployment's sprout. When set, the broker
51
+ * runs the cron scheduler and the queue consumer, delivering to the sprout
52
52
  * with an `x-sb-trigger` header authenticated by `token`.
53
53
  */
54
- workerUrl?: string;
54
+ sproutUrl?: string;
55
55
  /** Directory of published static assets (its sibling `assets.json` is the manifest). Backs `assets.get`. */
56
56
  assetsDir?: string;
57
57
  /** Injected in tests; defaults to the global `fetch`. */
@@ -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") {
@@ -418,15 +423,15 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
418
423
  }
419
424
  }
420
425
 
421
- // --- cron + queue delivery (only when this broker knows its worker) --------
426
+ // --- cron + queue delivery (only when this broker knows its sprout) --------
422
427
  const timers: ReturnType<typeof setInterval>[] = [];
423
428
  const QUEUE_BATCH = 10;
424
429
  const QUEUE_MAX_ATTEMPTS = 5;
425
430
 
426
431
  async function deliverTrigger(kind: "scheduled" | "queue", body: unknown): Promise<Response | null> {
427
- if (!opts.workerUrl) return null;
432
+ if (!opts.sproutUrl) return null;
428
433
  try {
429
- return await doFetch(opts.workerUrl, {
434
+ return await doFetch(opts.sproutUrl, {
430
435
  method: "POST",
431
436
  headers: { "x-sb-trigger": kind, "x-sb-token": token, "content-type": "application/json" },
432
437
  body: JSON.stringify(body),
@@ -437,7 +442,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
437
442
  }
438
443
 
439
444
  function drainQueuesOnce(): void {
440
- if (!opts.workerUrl || bindings.queues.length === 0) return;
445
+ if (!opts.sproutUrl || bindings.queues.length === 0) return;
441
446
  const now = Date.now();
442
447
  for (const q of bindings.queues) {
443
448
  const rows = db.query<{ id: string; body: string; attempts: number }, [string, number, number]>(
@@ -453,7 +458,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
453
458
  queue: q,
454
459
  messages: rows.map((r) => ({ id: r.id, body: r.body, timestamp: now, attempts: r.attempts + 1 })),
455
460
  }).then(async (res) => {
456
- let ack: string[] = rows.map((r) => r.id); // default: ack all if the worker didn't say
461
+ let ack: string[] = rows.map((r) => r.id); // default: ack all if the sprout did not say
457
462
  let retry: string[] = [];
458
463
  if (res && res.ok) {
459
464
  try {
@@ -474,7 +479,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
474
479
  }
475
480
  }
476
481
 
477
- if (opts.workerUrl) {
482
+ if (opts.sproutUrl) {
478
483
  if (bindings.queues.length > 0) timers.push(setInterval(drainQueuesOnce, 500));
479
484
  if (bindings.crons.length > 0) {
480
485
  let lastTick = "";
@@ -569,7 +574,7 @@ if (import.meta.main) {
569
574
  "data-dir": { type: "string" },
570
575
  bindings: { type: "string" },
571
576
  secrets: { type: "string" },
572
- "worker-url": { type: "string" },
577
+ "sprout-url": { type: "string" },
573
578
  "assets-dir": { type: "string" },
574
579
  },
575
580
  });
@@ -585,9 +590,9 @@ if (import.meta.main) {
585
590
  token: values.token ?? process.env.SB_BROKER_TOKEN,
586
591
  bindings,
587
592
  secrets,
588
- workerUrl: values["worker-url"] ?? process.env.SB_WORKER_URL,
593
+ sproutUrl: values["sprout-url"] ?? process.env.SB_SPROUT_URL,
589
594
  assetsDir: values["assets-dir"],
590
595
  });
591
596
  const { port } = listen(broker, "127.0.0.1", Number(values.port ?? process.env.SB_BROKER_PORT ?? 0));
592
- console.log(`sproutboat broker: 127.0.0.1:${port} db=${values.db ?? ":memory:"} worker=${values["worker-url"] ?? process.env.SB_WORKER_URL ?? "(none)"}`);
597
+ console.log(`sproutboat broker: 127.0.0.1:${port} db=${values.db ?? ":memory:"} sprout=${values["sprout-url"] ?? process.env.SB_SPROUT_URL ?? "(none)"}`);
593
598
  }
package/src/build.ts CHANGED
@@ -3,7 +3,7 @@ import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { walkAssets, type AssetManifest } from "./assets";
5
5
  import type { SproutboatConfig } from "./config";
6
- import { compileWorker } from "./compile";
6
+ import { compileSprout } from "./compile";
7
7
  import { ARTIFACT_SCHEMA_VERSION, CAPABILITY_PROFILE, RUNTIME, type ArtifactManifest } from "./manifest";
8
8
  import { ensureZig, esbuildVersion, porfforVersion, toolchainStamp } from "./toolchain";
9
9
 
@@ -33,7 +33,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
33
33
  const sourceHash = digest(source);
34
34
  const artifactId = sourceHash.slice("sha256:".length, 24);
35
35
  const artifactDir = resolve(input.projectDir, ".sproutboat/dist", artifactId);
36
- const workerPath = resolve(artifactDir, "worker");
36
+ const sproutPath = resolve(artifactDir, "sprout");
37
37
  await mkdir(artifactDir, { recursive: true });
38
38
 
39
39
  const bindings = {
@@ -50,15 +50,15 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
50
50
  };
51
51
 
52
52
  const zigBin = await ensureZig();
53
- await compileWorker({
53
+ await compileSprout({
54
54
  sourcePath: input.sourcePath,
55
- outPath: workerPath,
55
+ outPath: sproutPath,
56
56
  vars: input.config.vars ?? {},
57
57
  bindings,
58
58
  zigBin,
59
59
  });
60
60
 
61
- const worker = await readFile(workerPath);
61
+ const sprout = await readFile(sproutPath);
62
62
  const manifest: ArtifactManifest = {
63
63
  schemaVersion: ARTIFACT_SCHEMA_VERSION,
64
64
  project: input.config.name,
@@ -69,8 +69,8 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
69
69
  esbuildVersion: esbuildVersion(),
70
70
  buildImage: toolchainStamp(),
71
71
  sourceHash,
72
- binaryHash: digest(worker),
73
- binarySize: (await stat(workerPath)).size,
72
+ binaryHash: digest(sprout),
73
+ binarySize: (await stat(sproutPath)).size,
74
74
  builtAt: new Date().toISOString(),
75
75
  };
76
76
  await writeFile(resolve(artifactDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
package/src/compile.ts CHANGED
@@ -13,81 +13,11 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
13
13
  import { dirname, resolve } from "node:path";
14
14
  import { ensurePorfforPatched } from "./patch-porffor";
15
15
  import { ensureUWebSockets, porfforRoot, UwsUnavailableError } from "./toolchain";
16
+ import { EMPTY_BINDINGS, preludePath, wrapNativeFetchHandler, type Bindings } from "./wrap";
16
17
 
17
- const preludePath = new URL("./native-fetch-prelude.js", import.meta.url);
18
- // The server honours $PORT at runtime (patches/porffor-render.patch); this baked
19
- // value is only a fallback for a directly-run binary.
20
- const DEFAULT_PORT = 8080;
21
- const COMPILE_TIMEOUT_MS = Number(process.env.SPROUTBOAT_COMPILE_TIMEOUT_MS || 600_000);
22
-
23
- /**
24
- * Binding names a project declares. `do` maps a binding name to a Durable Object
25
- * class name; `crons` are schedule expressions with no name.
26
- */
27
- export type Bindings = {
28
- kv: string[];
29
- secrets: string[];
30
- outbound: string[];
31
- d1: string[];
32
- r2: string[];
33
- queues: string[];
34
- analytics: string[];
35
- do: Array<{ binding: string; className: string }>;
36
- crons: string[];
37
- /** Static-asset binding name for `env.<NAME>.fetch(request)`; `""` when assets are edge-only. */
38
- assets: string;
39
- };
40
-
41
- const EMPTY_BINDINGS: Bindings = { kv: [], secrets: [], outbound: [], d1: [], r2: [], queues: [], analytics: [], do: [], crons: [], assets: "" };
42
-
43
- function hasBindings(b: Bindings): boolean {
44
- return (
45
- b.kv.length > 0 || b.secrets.length > 0 || b.outbound.length > 0 || b.d1.length > 0 || b.r2.length > 0 ||
46
- b.queues.length > 0 || b.analytics.length > 0 || b.do.length > 0 || b.assets !== ""
47
- );
48
- }
18
+ export { EMPTY_BINDINGS, preludePath, wrapNativeFetchHandler, type Bindings } from "./wrap";
49
19
 
50
- /**
51
- * Build the final native-fetch module: the prelude (Web API shims + the broker
52
- * binding shim + the trigger dispatcher), then `const env = {…}` with the baked
53
- * `vars`, then — if any binding is declared — one `__sbInstallBindings(env, …)`
54
- * line, then the user's source with its `export` keywords neutralised (so its
55
- * `export default {…}` becomes a plain object we can hand to the dispatcher),
56
- * then our single `export default { fetch }` that routes every request through
57
- * `__sbEntry` (HTTP → `handlers.fetch`; `x-sb-trigger` → scheduled / queue / DO).
58
- *
59
- * With no bindings and no `scheduled`/`queue`/DO the output behaves exactly like
60
- * a plain `export default { fetch }` worker.
61
- */
62
- export function wrapNativeFetchHandler(
63
- source: string,
64
- prelude: string,
65
- vars: Record<string, string> = {},
66
- bindings: Bindings = EMPTY_BINDINGS,
67
- ): string {
68
- if (!/\bexport\s+default\s*\{/.test(source) || !/\bfetch\s*\(/.test(source)) {
69
- throw new Error("handler must default-export an object with a fetch(request) method");
70
- }
71
- // Neutralise the module's exports: its default object becomes `__sbHandlers`,
72
- // and any `export class`/`function`/`const` (Durable Object classes, helpers)
73
- // becomes a plain top-level declaration. Imports are already rejected upstream.
74
- const neutralised = source
75
- .replace(/^(\s*)export\s+default\s*/m, "$1const __sbHandlers = ")
76
- .replace(/^export\s+(async\s+function|function|class|const|let|var)\b/gm, "$1");
77
-
78
- const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
79
- const wire = hasBindings(bindings) ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n` : "";
80
- const registerDO = bindings.do.length
81
- ? `__sbRegisterDO({ ${bindings.do.map((d) => `${d.className}: ${d.className}`).join(", ")} });\n`
82
- : "";
83
-
84
- return (
85
- `${prelude}\n${env}${wire}` +
86
- `${neutralised}\n` +
87
- `${registerDO}` +
88
- `export default {\n port: ${DEFAULT_PORT},\n fetch(request) { return __sbEntry(__sbHandlers, request); }\n};\n`
89
- );
90
- }
20
+ const COMPILE_TIMEOUT_MS = Number(process.env.SPROUTBOAT_COMPILE_TIMEOUT_MS || 600_000);
91
21
 
92
22
  export type CompileInput = {
93
23
  sourcePath: string;
@@ -98,7 +28,7 @@ export type CompileInput = {
98
28
  };
99
29
 
100
30
  /** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
101
- export async function compileWorker(input: CompileInput): Promise<void> {
31
+ export async function compileSprout(input: CompileInput): Promise<void> {
102
32
  await ensurePorfforPatched();
103
33
 
104
34
  // Seed the Porffor uWebSockets cache from the prebuilt archive in `vendor/` so
@@ -124,7 +54,7 @@ export async function compileWorker(input: CompileInput): Promise<void> {
124
54
 
125
55
  const outDir = dirname(input.outPath);
126
56
  await mkdir(outDir, { recursive: true });
127
- const generatedPath = resolve(outDir, "worker.generated.js");
57
+ const generatedPath = resolve(outDir, "sprout.generated.js");
128
58
  const [source, prelude] = await Promise.all([readFile(input.sourcePath, "utf8"), readFile(preludePath, "utf8")]);
129
59
  await writeFile(generatedPath, wrapNativeFetchHandler(source, prelude, input.vars, input.bindings ?? EMPTY_BINDINGS));
130
60
 
@@ -134,8 +64,11 @@ export async function compileWorker(input: CompileInput): Promise<void> {
134
64
  const binDir = resolve(porffor, "../.bin");
135
65
  const path = `${dirname(input.zigBin)}:${binDir}:${process.env.PATH ?? ""}`;
136
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.
137
70
  const child = Bun.spawn(
138
- [process.execPath, launcher, "native", generatedPath, "-o", input.outPath, "--musl"],
71
+ [process.execPath, launcher, "native", generatedPath, "-o", input.outPath, "--musl", "-s"],
139
72
  { cwd: outDir, stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: path } },
140
73
  );
141
74
  let timedOut = false;
package/src/config.ts CHANGED
@@ -10,7 +10,7 @@ export type SproutboatConfig = {
10
10
  kv_namespaces?: string[];
11
11
  /** Secret binding names, exposed as `env.<NAME>` (value fetched at use). */
12
12
  secrets?: string[];
13
- /** Hostnames the worker's `fetch()` may reach (exact host match). */
13
+ /** Hostnames the sprout's `fetch()` may reach (exact host match). */
14
14
  outbound?: string[];
15
15
  /** D1 (SQLite) database binding names, exposed as `env.<NAME>`. */
16
16
  d1_databases?: string[];
@@ -35,7 +35,7 @@ export type AssetsConfig = {
35
35
  binding?: string;
36
36
  /** What to serve when a request matches no file (applied by the broker on `env.<BINDING>.fetch`). */
37
37
  not_found_handling?: "none" | "single-page-application" | "404-page";
38
- /** `true` = run the worker before serving any asset; string[] = selective route patterns (`!` negates). */
38
+ /** `true` = run the sprout before serving any asset; string[] = selective route patterns (`!` negates). */
39
39
  run_sprout_first?: boolean | string[];
40
40
  };
41
41
 
package/src/main.ts CHANGED
@@ -70,6 +70,16 @@ function parseUrlResponse(source: string): { url: string } | undefined {
70
70
  return record && isString(record.url) ? { url: record.url } : undefined;
71
71
  }
72
72
 
73
+ /** #55: `{ from, to }` when this deploy moves the live version onto a different
74
+ * Porffor pin. Deployed artifacts are frozen at their build-time compiler, so
75
+ * the pin only changes by redeploying — and the alpha compiler's output can
76
+ * differ between pins. */
77
+ function parsePorfforDrift(source: string): { from: string; to: string } | undefined {
78
+ const drift = (() => { try { return jsonObject(parseJsonValue(source))?.porfforDrift; } catch { return undefined; } })();
79
+ const record = drift && jsonObject(drift as JsonValue);
80
+ return record && isString(record.from) && isString(record.to) ? { from: record.from, to: record.to } : undefined;
81
+ }
82
+
73
83
  function parseAuthorization(source: string): CliAuthorization | undefined {
74
84
  const record = jsonObject(parseJsonValue(source));
75
85
  if (!record || !isString(record.deviceCode) || !isString(record.userCode) || !isString(record.verificationUri) || !isSafeInteger(record.interval) || !isString(record.expiresAt)) return undefined;
@@ -166,8 +176,8 @@ async function deploy(args: string[]) {
166
176
  config = built.project.config;
167
177
  }
168
178
  const manifest = Bun.file(resolve(artifactDir, "manifest.json"));
169
- const worker = Bun.file(resolve(artifactDir, "worker"));
170
- if (!(await manifest.exists()) || !(await worker.exists())) fail("artifact must contain manifest.json and worker");
179
+ const sprout = Bun.file(resolve(artifactDir, "sprout"));
180
+ if (!(await manifest.exists()) || !(await sprout.exists())) fail("artifact must contain manifest.json and sprout");
171
181
  const manifestValidation = validateManifest(await manifest.json());
172
182
  if (!manifestValidation.ok) fail(`invalid artifact manifest: ${manifestValidation.errors.join(", ")}`);
173
183
  const artifactManifest: ArtifactManifest = manifestValidation.value;
@@ -176,7 +186,7 @@ async function deploy(args: string[]) {
176
186
  printDeployReport(
177
187
  config ?? { name: projectName, main: "", compatibility_date: "(prebuilt artifact)" },
178
188
  artifactManifest,
179
- new Uint8Array(await worker.arrayBuffer()),
189
+ new Uint8Array(await sprout.arrayBuffer()),
180
190
  manifest.size,
181
191
  );
182
192
  if (dryRun) {
@@ -198,7 +208,26 @@ async function deploy(args: string[]) {
198
208
  }
199
209
  const form = new FormData();
200
210
  form.set("manifest", new File([await manifest.arrayBuffer()], "manifest.json", { type: "application/json" }));
201
- form.set("worker", new File([await worker.arrayBuffer()], "worker", { type: "application/octet-stream" }));
211
+ form.set("sprout", new File([await sprout.arrayBuffer()], "sprout", { type: "application/octet-stream" }));
212
+
213
+ // #1 — ship the sidecars `sproutboat build` produced so the server can start
214
+ // the binding broker and serve static assets. Without bindings.json the broker
215
+ // never spawns and every KV/D1/R2/secret/queue/cron/DO call is dead on the box.
216
+ const bindingsFile = Bun.file(resolve(artifactDir, "bindings.json"));
217
+ if (await bindingsFile.exists()) {
218
+ form.set("bindings", new File([await bindingsFile.arrayBuffer()], "bindings.json", { type: "application/json" }));
219
+ }
220
+ const assetsManifestFile = Bun.file(resolve(artifactDir, "assets.json"));
221
+ if (await assetsManifestFile.exists()) {
222
+ const assetsManifest: { files?: Record<string, unknown> } = await assetsManifestFile.json();
223
+ form.set("assets_manifest", new File([await assetsManifestFile.arrayBuffer()], "assets.json", { type: "application/json" }));
224
+ for (const key of Object.keys(assetsManifest.files ?? {})) {
225
+ const file = Bun.file(resolve(artifactDir, "assets", `.${key}`));
226
+ if (!(await file.exists())) fail(`assets.json lists ${key} but assets${key} is missing — rebuild`);
227
+ form.append("asset", new File([await file.arrayBuffer()], key));
228
+ }
229
+ }
230
+
202
231
  const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api/projects/${projectName}/deployments`, {
203
232
  method: "POST",
204
233
  headers: { "x-api-key": token },
@@ -209,6 +238,12 @@ async function deploy(args: string[]) {
209
238
  if (!deployed) fail("deployment response did not include a URL");
210
239
  console.log(`\nDeployed ${projectName}`);
211
240
  console.log(` ${deployed.url}`);
241
+ const drift = parsePorfforDrift(body);
242
+ if (drift) {
243
+ console.warn(`\n! Porffor pin changed: ${drift.from} -> ${drift.to}`);
244
+ console.warn(` The previous live version stays frozen at ${drift.from}; this one is built with ${drift.to}.`);
245
+ console.warn(` The alpha compiler's output can differ between pins (see COMPAT.md) — roll back if this version misbehaves.`);
246
+ }
212
247
  }
213
248
 
214
249
  function parseLoginArgs(args: string[]) {
@@ -294,6 +329,89 @@ async function tail(args: string[]) {
294
329
  process.stdout.write(await responseText(response, "could not read logs"));
295
330
  }
296
331
 
332
+ type DomainView = { hostname: string; verified: boolean; verification: { type: string; name: string; value: string } | null };
333
+ function parseDomain(source: string): DomainView | undefined {
334
+ const record = jsonObject(parseJsonValue(source));
335
+ if (!record || !isString(record.hostname) || typeof record.verified !== "boolean") return undefined;
336
+ const v = jsonObject(record.verification ?? null);
337
+ const verification = v && isString(v.type) && isString(v.name) && isString(v.value) ? { type: v.type, name: v.name, value: v.value } : null;
338
+ return { hostname: record.hostname, verified: record.verified, verification };
339
+ }
340
+ function printDomain(domain: DomainView) {
341
+ const status = domain.verified ? "verified" : "unverified";
342
+ console.log(`${status.padEnd(10)} ${domain.hostname}`);
343
+ if (domain.verification) {
344
+ console.log(` add this DNS record, then run: sproutboat domains verify ${domain.hostname}`);
345
+ console.log(` ${domain.verification.type} ${domain.verification.name} "${domain.verification.value}"`);
346
+ }
347
+ }
348
+
349
+ async function domains(args: string[]) {
350
+ const sub = args[0] && !args[0].startsWith("-") && ["list", "add", "verify", "rm"].includes(args[0]) ? args.shift()! : "list";
351
+ const host = sub === "list" ? undefined : args.shift();
352
+ if (sub !== "list" && !host) fail(`usage: sproutboat domains ${sub} <hostname> [project-dir]`);
353
+ const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
354
+ const base = `${apiUrl}/api/projects/${project.config.name}/domains`;
355
+ const auth = { "x-api-key": token };
356
+
357
+ if (sub === "list") {
358
+ const response = await fetch(base, { headers: auth });
359
+ const body = await responseText(response, "could not list domains");
360
+ const list = parseJsonValue(body);
361
+ if (!Array.isArray(list)) fail("could not parse domains response");
362
+ if (list.length === 0) { console.log("no custom domains"); return; }
363
+ for (const entry of list) { const d = parseDomain(JSON.stringify(entry)); if (d) printDomain(d); }
364
+ return;
365
+ }
366
+ if (sub === "rm") {
367
+ const response = await fetch(`${base}/${host}`, { method: "DELETE", headers: auth });
368
+ await responseText(response, "delete rejected");
369
+ console.log(`Removed ${host}`);
370
+ return;
371
+ }
372
+ const url = sub === "add" ? base : `${base}/${host}/verify`;
373
+ const init = sub === "add"
374
+ ? { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ hostname: host }) }
375
+ : { method: "POST", headers: auth };
376
+ const response = await fetch(url, init);
377
+ const domain = parseDomain(await responseText(response, `${sub} rejected`));
378
+ if (!domain) fail(`${sub} response was not a domain record`);
379
+ printDomain(domain);
380
+ }
381
+
382
+ async function secrets(args: string[]) {
383
+ const sub = args[0] && ["list", "set", "rm"].includes(args[0]) ? args.shift()! : "list";
384
+ const name = sub === "list" ? undefined : args.shift();
385
+ if (sub !== "list" && !name) fail(`usage: sproutboat secrets ${sub} <NAME> [project-dir]`);
386
+ if (name && !/^[A-Z][A-Z0-9_]*$/.test(name)) fail("secret name must be UPPER_SNAKE_CASE");
387
+ const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
388
+ const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
389
+ const auth = { "x-api-key": token };
390
+
391
+ if (sub === "list") {
392
+ const body = await responseText(await fetch(base, { headers: auth }), "could not list secrets");
393
+ const parsed = jsonObject(parseJsonValue(body));
394
+ const names = parsed && Array.isArray(parsed.names) ? parsed.names.filter(isString) : [];
395
+ console.log(names.length ? names.join("\n") : "no secrets");
396
+ return;
397
+ }
398
+ if (sub === "rm") {
399
+ await responseText(await fetch(`${base}/${name}`, { method: "DELETE", headers: auth }), "delete rejected");
400
+ console.log(`Removed ${name}`);
401
+ return;
402
+ }
403
+ // set: value from the next arg, else stdin (keeps it out of shell history).
404
+ const value = args[1] && !args[1].startsWith("-") && args[1] !== project.directory
405
+ ? args[1]
406
+ : (await Bun.stdin.text()).replace(/\r?\n$/, "");
407
+ if (!value) fail("no value — pass it as an argument or pipe it on stdin");
408
+ await responseText(
409
+ await fetch(`${base}/${name}`, { method: "PUT", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ value }) }),
410
+ "set rejected",
411
+ );
412
+ console.log(`Set ${name} — applies on the next deploy or sprout restart`);
413
+ }
414
+
297
415
  async function deleteProject(args: string[]) {
298
416
  if (args[0] !== "--yes") fail("refusing to delete without --yes");
299
417
  const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
@@ -316,6 +434,8 @@ switch (command) {
316
434
  case "deploy": await deploy(args); break;
317
435
  case "versions": await versions(args); break;
318
436
  case "rollback": await rollback(args); break;
437
+ case "domains": await domains(args); break;
438
+ case "secrets": await secrets(args); break;
319
439
  case "tail": await tail(args); break;
320
440
  case "delete": await deleteProject(args); break;
321
441
  default: usage();
@@ -11,6 +11,64 @@
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
+
34
+ // #28 — process CPU time in ms (CLOCK_PROCESS_CPUTIME_ID). Marshalled back as a
35
+ // string via the same primitives as __sbEnv / __sbRandomBytes (proven working),
36
+ // then parsed — Porffor's number boxing for a bare inline-C assignment is not
37
+ // relied on. `__sbEntry` samples it around the handler for per-invocation CPU.
38
+ function __sbCpuMs() {
39
+ let res = '';
40
+ Porffor.c`
41
+ struct timespec __ts;
42
+ clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &__ts);
43
+ double __ms = (double)__ts.tv_sec * 1000.0 + (double)__ts.tv_nsec / 1000000.0;
44
+ char __b[32];
45
+ int __n = snprintf(__b, sizeof(__b), "%.3f", __ms);
46
+ if (__n > 0) res = porf_box((f64)porf_native_fetch_alloc_bytestring(__b, (size_t)__n), 195);
47
+ `;
48
+ return res === '' ? 0 : parseFloat(res);
49
+ }
50
+
51
+ // #28 — stamp `x-sb-cpu-ms` onto a handler Response. Porffor alpha-4's
52
+ // native-fetch serializer only reads headers from the plain object passed to
53
+ // `new Response(body, { headers })` — a later `.set()` or a `Headers` instance
54
+ // is ignored on the wire — so the metric is carried by rebuilding the Response
55
+ // with one extra header. Safe only for a string body (the norm under
56
+ // http-sync-v0) with no Set-Cookie to comma-fold; anything else is returned
57
+ // untouched and the edge simply omits cpuMs for that request.
58
+ function __sbTagCpu(res, t0) {
59
+ const cpu = __sbCpuMs() - t0;
60
+ try {
61
+ const body = res && res.body;
62
+ if (typeof body === 'string' && !res.headers.has('set-cookie')) {
63
+ const headers = {};
64
+ res.headers.forEach(function (value, name) { headers[name] = value; });
65
+ headers['x-sb-cpu-ms'] = (cpu >= 0 ? cpu : 0).toFixed(3);
66
+ return new Response(body, { status: res.status, headers: headers });
67
+ }
68
+ } catch (e) { /* fall through to the original response */ }
69
+ return res;
70
+ }
71
+
14
72
  class __SproutboatURLSearchParams {
15
73
  constructor(init) {
16
74
  this._keys = [];
@@ -118,13 +176,21 @@ __sbDefineURLAccessor('username', function () { return ''; });
118
176
  __sbDefineURLAccessor('password', function () { return ''; });
119
177
 
120
178
  // 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.
179
+ // backed by the OS CSPRNG (`__sbRandomBytes` -> inline C -> /dev/urandom), so
180
+ // tokens, idempotency keys and UUIDs are unpredictable. Deliberately no insecure
181
+ // fallback a silent downgrade to a weak source is worse than throwing.
124
182
  if (globalThis.crypto == null) globalThis.crypto = {};
125
183
  if (globalThis.crypto.getRandomValues == null) {
126
184
  globalThis.crypto.getRandomValues = function (view) {
127
- for (let i = 0; i < view.length; i++) view[i] = Math.floor(Math.random() * 256);
185
+ const n = view.length >>> 0;
186
+ // WebCrypto caps a single call at 65536 bytes.
187
+ if (n > 65536) throw new RangeError("crypto.getRandomValues: byte length exceeds 65536");
188
+ if (n === 0) return view;
189
+ // One CSPRNG byte per element. Correct for Uint8Array (and randomUUID); a
190
+ // wider view gets its low byte filled, matching the previous polyfill's shape.
191
+ const bytes = __sbRandomBytes(String(n));
192
+ if (bytes.length !== n) throw new Error("crypto.getRandomValues: OS entropy source unavailable");
193
+ for (let i = 0; i < n; i++) view[i] = bytes.charCodeAt(i) & 0xff;
128
194
  return view;
129
195
  };
130
196
  }
@@ -151,62 +217,103 @@ if (globalThis.crypto.randomUUID == null) {
151
217
 
152
218
  // ---------------------------------------------------------------------------
153
219
  // 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:
220
+ // backed by a Bun broker on a loopback TCP port. The transport is inline C —
221
+ // blocking write/read per call over ONE long-lived connection (http-sync-v0: one
222
+ // sprout event-loop turn per request, so a blocking roundtrip is acceptable).
223
+ // Wire frame:
157
224
  // [u32 LE len][ <token> "\n" <json> ] reply: [u32 LE len][ <json> ]
158
225
  // SB_BROKER_PORT / SB_BROKER_TOKEN are set by the supervisor next to $PORT.
159
226
  // If SB_BROKER_PORT is unset the shims below are never installed (compile.ts
160
227
  // only emits the __sbInstallBindings call when the project declares bindings),
161
- // 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.
228
+ // so a plain sprout is byte-for-byte unchanged.
229
+ // ponytail: text values only; still AF_INET loopback, not AF_UNIX. A failed
230
+ // exchange reconnects and resends once a broker crash between "request applied"
231
+ // and "reply read" can double-apply a non-idempotent op (queue.send, INSERT);
232
+ // the old fresh-connection-per-call path just failed the call there instead.
233
+ // Binary values + AF_UNIX = v2.
164
234
 
165
235
  Porffor.c`
166
236
  #include <sys/socket.h>
167
237
  #include <netinet/in.h>
238
+ #include <netinet/tcp.h>
239
+ #include <signal.h>
168
240
  #include <unistd.h>
169
241
  #include <string.h>
170
242
  #include <stdlib.h>
171
243
  #include <stdio.h>
244
+ #include <fcntl.h>
245
+ #include <errno.h>
246
+ #include <time.h>
172
247
 
173
248
  u32 porf_native_fetch_alloc_bytestring(const char* input, size_t len);
174
249
  int porf_native_fetch_read_value(jsval value, const char** out_buf, size_t* out_len, char** out_owned);
175
250
 
251
+ // Fill buf with n bytes from the OS CSPRNG. /dev/urandom is present on Linux and
252
+ // macOS and inside the bubblewrap sandbox; blocking is not a concern after the
253
+ // pool is seeded. Returns 0, or -1 if the source could not be read in full.
254
+ static int sb_os_random(unsigned char* buf, size_t n) {
255
+ int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
256
+ if (fd < 0) return -1;
257
+ size_t off = 0;
258
+ while (off < n) {
259
+ long r = read(fd, buf + off, n - off);
260
+ if (r <= 0) {
261
+ if (r < 0 && errno == EINTR) continue;
262
+ close(fd);
263
+ return -1;
264
+ }
265
+ off += (size_t)r;
266
+ }
267
+ close(fd);
268
+ return 0;
269
+ }
270
+
176
271
  static int sb_io_all(int fd, unsigned char* buf, size_t len, int writing) {
177
272
  size_t done = 0;
178
273
  while (done < len) {
179
274
  long n = writing ? write(fd, buf + done, len - done) : read(fd, buf + done, len - done);
180
- if (n <= 0) return -1;
275
+ if (n <= 0) {
276
+ if (n < 0 && errno == EINTR) continue;
277
+ return -1;
278
+ }
181
279
  done += (size_t)n;
182
280
  }
183
281
  return 0;
184
282
  }
185
283
 
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;
284
+ // One long-lived loopback connection to the broker, reused across every binding
285
+ // call. The broker frames each request/reply independently and keeps the socket
286
+ // open, so the steady-state per-call cost is just write + read — no socket(),
287
+ // connect() handshake or close() each time. -1 = not connected.
288
+ static int sb_broker_fd = -1;
189
289
 
290
+ static int sb_broker_connect(void) {
190
291
  const char* port_s = getenv("SB_BROKER_PORT");
191
292
  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
-
293
+ signal(SIGPIPE, SIG_IGN); // a dead broker must yield EPIPE, not kill the sprout
196
294
  int fd = socket(AF_INET, SOCK_STREAM, 0);
197
295
  if (fd < 0) return -1;
198
-
296
+ int one = 1;
297
+ setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
199
298
  struct sockaddr_in addr;
200
299
  memset(&addr, 0, sizeof(addr));
201
300
  addr.sin_family = AF_INET;
202
- addr.sin_port = htons((unsigned short)port);
301
+ addr.sin_port = htons((unsigned short)atoi(port_s));
203
302
  addr.sin_addr.s_addr = htonl(0x7f000001u); // 127.0.0.1
204
303
  if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) { close(fd); return -2; }
304
+ sb_broker_fd = fd;
305
+ return 0;
306
+ }
307
+
308
+ // Send one framed request, read one framed reply, on the persistent fd.
309
+ static int sb_broker_exchange(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
310
+ const char* tok = getenv("SB_BROKER_TOKEN");
311
+ size_t tok_len = tok ? strlen(tok) : 0;
205
312
 
206
313
  // frame body: token "\n" json
207
314
  size_t body_len = tok_len + 1 + req_len;
208
315
  unsigned char* frame = (unsigned char*)malloc(4 + body_len);
209
- if (!frame) { close(fd); return -5; }
316
+ if (!frame) return -5;
210
317
  frame[0] = (unsigned char)(body_len & 0xff);
211
318
  frame[1] = (unsigned char)((body_len >> 8) & 0xff);
212
319
  frame[2] = (unsigned char)((body_len >> 16) & 0xff);
@@ -214,23 +321,40 @@ static int sb_broker_roundtrip(const char* req, size_t req_len, char** resp_out,
214
321
  if (tok_len) memcpy(frame + 4, tok, tok_len);
215
322
  frame[4 + tok_len] = '\n';
216
323
  if (req_len) memcpy(frame + 4 + tok_len + 1, req, req_len);
217
- int wr = sb_io_all(fd, frame, 4 + body_len, 1);
324
+ int wr = sb_io_all(sb_broker_fd, frame, 4 + body_len, 1);
218
325
  free(frame);
219
- if (wr != 0) { close(fd); return -3; }
326
+ if (wr != 0) return -3;
220
327
 
221
328
  unsigned char rhdr[4];
222
- if (sb_io_all(fd, rhdr, 4, 0) != 0) { close(fd); return -4; }
329
+ if (sb_io_all(sb_broker_fd, rhdr, 4, 0) != 0) return -4;
223
330
  size_t rlen = (size_t)rhdr[0] | ((size_t)rhdr[1] << 8) | ((size_t)rhdr[2] << 16) | ((size_t)rhdr[3] << 24);
224
331
 
225
332
  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);
333
+ if (!buf) return -5;
334
+ if (rlen && sb_io_all(sb_broker_fd, (unsigned char*)buf, rlen, 0) != 0) { free(buf); return -6; }
229
335
 
230
336
  *resp_out = buf;
231
337
  *resp_len_out = rlen;
232
338
  return 0;
233
339
  }
340
+
341
+ static int sb_broker_roundtrip(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
342
+ *resp_out = NULL;
343
+ *resp_len_out = 0;
344
+ // Two tries: a broker restart (or an idle-closed socket) invalidates the fd,
345
+ // so a failed exchange drops the connection and reconnects once before failing.
346
+ for (int attempt = 0; attempt < 2; attempt++) {
347
+ if (sb_broker_fd < 0) {
348
+ int rc = sb_broker_connect();
349
+ if (rc != 0) return rc;
350
+ }
351
+ int rc = sb_broker_exchange(req, req_len, resp_out, resp_len_out);
352
+ if (rc == 0) return 0;
353
+ close(sb_broker_fd);
354
+ sb_broker_fd = -1;
355
+ }
356
+ return -3;
357
+ }
234
358
  `;
235
359
 
236
360
  // One request string in, one reply string out. `reqJson` is a parameter, so the
@@ -255,6 +379,30 @@ function __sbCall(reqJson) {
255
379
  return res;
256
380
  }
257
381
 
382
+ // `nStr` is the decimal byte count as a string (same string-param pattern as
383
+ // __sbEnv). Returns a bytestring of that many CSPRNG bytes, or '' on failure.
384
+ function __sbRandomBytes(nStr) {
385
+ let out = '';
386
+ Porffor.c`
387
+ const char* __ns; size_t __nsl; char* __nso = 0;
388
+ porf_native_fetch_read_value(nStr, &__ns, &__nsl, &__nso);
389
+ char __nb[16];
390
+ size_t __k = __nsl < 15 ? __nsl : 15;
391
+ memcpy(__nb, __ns, __k); __nb[__k] = 0;
392
+ if (__nso) free(__nso);
393
+ long __n = atol(__nb);
394
+ if (__n > 0 && __n <= 65536) {
395
+ unsigned char* __b = (unsigned char*)malloc((size_t)__n);
396
+ if (__b) {
397
+ if (sb_os_random(__b, (size_t)__n) == 0)
398
+ out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__b, (size_t)__n), 195);
399
+ free(__b);
400
+ }
401
+ }
402
+ `;
403
+ return out;
404
+ }
405
+
258
406
  function __sbRpc(op, extra) {
259
407
  const req = { op };
260
408
  if (extra) for (const k in extra) req[k] = extra[k];
@@ -312,7 +460,7 @@ function __sbMakeD1(dbName) {
312
460
  }
313
461
 
314
462
  // R2: a Cloudflare-shaped object. When `body` is present the sync accessors
315
- // mirror R2ObjectBody's async ones (a worker may `await` them harmlessly).
463
+ // mirror R2ObjectBody's async ones (a sprout may `await` them harmlessly).
316
464
  function __sbR2Object(meta, body) {
317
465
  const obj = {
318
466
  key: meta.key,
@@ -359,9 +507,17 @@ globalThis.__sbInstallBindings = function (target, bindings) {
359
507
 
360
508
  for (let i = 0; i < (bindings.secrets || []).length; i++) {
361
509
  const name = bindings.secrets[i];
510
+ // Fetch lazily, then freeze as a data property: a secret is process-lifetime
511
+ // immutable (a new value means a redeploy = a new process), so one broker
512
+ // round-trip on first read, zero after. A getter that RPCs on every access
513
+ // turns `'Bearer ' + env.KEY` in a loop into a syscall storm.
362
514
  Object.defineProperty(target, name, {
363
515
  configurable: true,
364
- get() { return __sbRpc('secret.get', { name }).value; },
516
+ get() {
517
+ const value = __sbRpc('secret.get', { name }).value;
518
+ Object.defineProperty(target, name, { value, configurable: true, enumerable: true });
519
+ return value;
520
+ },
365
521
  });
366
522
  }
367
523
 
@@ -454,7 +610,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
454
610
  }
455
611
 
456
612
  // Static assets: env.<ASSETS>.fetch(request) -> broker `assets.get`. The edge
457
- // already serves matching files directly; the worker only calls this for paths
613
+ // already serves matching files directly; the sprout only calls this for paths
458
614
  // it wants to own (SPA fallback, auth-gated files). Text assets only — binary
459
615
  // files go through the edge (the broker frame is UTF-8 JSON).
460
616
  if (bindings.assets) {
@@ -494,13 +650,13 @@ globalThis.__sbInstallBindings = function (target, bindings) {
494
650
  };
495
651
 
496
652
  // ---------------------------------------------------------------------------
497
- // Durable Objects. The class runs here in the sandboxed worker. There is exactly
498
- // one worker process per deployment (the supervisor model) and the native-fetch
653
+ // Durable Objects. The class runs here in the sandboxed sprout. There is exactly
654
+ // one sprout process per deployment (the supervisor model) and the native-fetch
499
655
  // runtime processes one turn at a time, so calls to a given object id are
500
656
  // already serialized — `env.<NS>.get(id).fetch()` invokes the instance directly,
501
657
  // no round-trip. Only `state.storage.*` goes to the broker (so object state
502
- // outlives a worker restart), scoped to (class, id).
503
- // ponytail: serialization relies on the single worker process; a multi-worker
658
+ // outlives a sprout restart), scoped to (class, id).
659
+ // ponytail: serialization relies on the single sprout process; a multi-sprout
504
660
  // deployment needs the broker to hold a per-id lock (cloud). Storage ops are one
505
661
  // key at a time; blockConcurrencyWhile just runs the fn.
506
662
 
@@ -509,8 +665,7 @@ function __sbMakeDONamespace(binding, className) {
509
665
  idFromName(name) { return { toString() { return 'name:' + String(name); }, name: String(name) }; },
510
666
  idFromString(hex) { return { toString() { return String(hex); } }; },
511
667
  newUniqueId() {
512
- const id = (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
513
- return { toString() { return 'uid:' + id; } };
668
+ return { toString() { return 'uid:' + crypto.randomUUID(); } };
514
669
  },
515
670
  get(id) {
516
671
  const idStr = typeof id === 'string' ? id : id.toString();
@@ -630,7 +785,19 @@ function __sbTriggerAuthed(request) {
630
785
 
631
786
  globalThis.__sbEntry = function (handlers, request) {
632
787
  const trigger = request.headers.get('x-sb-trigger');
633
- if (!trigger) return handlers.fetch(request);
788
+ if (!trigger) {
789
+ // #28 — per-invocation CPU time. One fetch turn per process (serial), so the
790
+ // process CPU delta across the handler is this invocation's CPU. Covers both
791
+ // sync handlers and async ones (via `.then`); the delta spans the whole turn.
792
+ // ponytail: serial-turn assumption; revisit if the profile ever allows
793
+ // concurrent in-process requests.
794
+ const __t0 = __sbCpuMs();
795
+ const __res = handlers.fetch(request);
796
+ if (__res && typeof __res.then === 'function') {
797
+ return __res.then(function (resolved) { return __sbTagCpu(resolved, __t0); });
798
+ }
799
+ return __sbTagCpu(__res, __t0);
800
+ }
634
801
  if (!__sbTriggerAuthed(request)) return new Response('forbidden', { status: 403 });
635
802
 
636
803
  if (trigger === 'scheduled') {
package/src/report.ts CHANGED
@@ -27,11 +27,11 @@ function table(headers: string[], rows: string[][], align: boolean[] = []): stri
27
27
  export function printDeployReport(
28
28
  config: SproutboatConfig,
29
29
  manifest: ArtifactManifest,
30
- worker: Uint8Array,
30
+ sprout: Uint8Array,
31
31
  manifestBytes: number,
32
32
  ): void {
33
- const gz = gzipSync(Uint8Array.from(worker)).length;
34
- const total = worker.length + manifestBytes;
33
+ const gz = gzipSync(Uint8Array.from(sprout)).length;
34
+ const total = sprout.length + manifestBytes;
35
35
 
36
36
  console.log(`\n🌱 sproutboat ${CLI_VERSION}`);
37
37
  console.log("─".repeat(19));
@@ -44,12 +44,12 @@ export function printDeployReport(
44
44
  console.log(table(
45
45
  ["File", "Type", "Size"],
46
46
  [
47
- ["worker", manifest.runtime, bytes(worker.length)],
47
+ ["sprout", manifest.runtime, bytes(sprout.length)],
48
48
  ["manifest.json", "json", bytes(manifestBytes)],
49
49
  ],
50
50
  [false, false, true],
51
51
  ));
52
- console.log(`Total upload: ${bytes(total)} (worker gzip: ${bytes(gz)})`);
52
+ console.log(`Total upload: ${bytes(total)} (sprout gzip: ${bytes(gz)})`);
53
53
  console.log();
54
54
 
55
55
  const vars = Object.entries(config.vars ?? {});
package/src/surface.ts CHANGED
@@ -10,12 +10,14 @@ export type Command = { name: string; args: string; summary: string };
10
10
  export const COMMANDS: readonly Command[] = [
11
11
  { name: "init", args: "[name]", summary: "Scaffold sproutboat.jsonc + src/index.js in ./<name>." },
12
12
  { name: "check", args: "[project-dir]", summary: "Validate the config and entry point without building." },
13
- { name: "build", args: "[project-dir]", summary: "Cross-compile the native-fetch worker (Porffor + Zig)." },
13
+ { name: "build", args: "[project-dir]", summary: "Cross-compile the native-fetch sprout (Porffor + Zig)." },
14
14
  { name: "deploy", args: "[project-dir] [--dry-run] [--artifact <dir>]", summary: "Build (unless --artifact), print the report, upload. --dry-run stops before upload." },
15
15
  { name: "login", args: "[--api-url <url>] [--token <token>]", summary: "Device-code browser flow, or store <token> for <url> directly." },
16
16
  { name: "tail", args: "[project-dir]", summary: "Print the project's recent request logs." },
17
17
  { name: "versions", args: "list [project-dir]", summary: "List the project's deployed versions." },
18
18
  { name: "rollback", args: "<version-id> [project-dir]", summary: "Re-activate a previous version." },
19
+ { name: "domains", args: "[list | add <host> | verify <host> | rm <host>] [project-dir]", summary: "Attach a custom domain to the project (TXT-verified). No sub-command lists." },
20
+ { name: "secrets", args: "[list | set <NAME> [value] | rm <NAME>] [project-dir]", summary: "Manage encrypted project secrets (read as env.NAME). `set` takes the value from the arg or stdin; applies on next deploy." },
19
21
  { name: "delete", args: "--yes [project-dir]", summary: "Delete the project and every version." },
20
22
  ];
21
23
 
@@ -27,12 +29,14 @@ export const ENV_VARS: readonly EnvVar[] = [
27
29
  { name: "SPROUTBOAT_ZIG", purpose: "Path to a Zig binary to use instead of downloading the pinned one." },
28
30
  { 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)." },
29
31
  { name: "SPROUTBOAT_COMPILE_TIMEOUT_MS", purpose: "Porffor compile timeout in ms (default 600000)." },
32
+ { name: "SPROUTBOAT_VARS_JSON", purpose: "JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the sprout module." },
33
+ { name: "SPROUTBOAT_BINDINGS_JSON", purpose: "The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line." },
30
34
  { name: "SPROUTBOAT_CONFIG_DIR", purpose: "Directory for credentials.json (default ~/.config/sproutboat)." },
31
35
  { name: "XDG_CONFIG_HOME", purpose: "Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset." },
32
36
  { name: "PORFFOR_VERSION", purpose: "Override the Porffor identity string recorded in the manifest." },
33
- { name: "SB_BROKER_PORT", purpose: "Loopback port of the binding broker, read by the compiled worker at runtime (set by the control plane, or by `src/broker.ts` for local runs)." },
34
- { name: "SB_BROKER_TOKEN", purpose: "Per-deployment auth token the worker sends on every broker frame, and the broker sends back on scheduled/queue triggers (paired with SB_BROKER_PORT)." },
35
- { name: "SB_WORKER_URL", purpose: "http://127.0.0.1:<PORT> of the worker; when set, `src/broker.ts` runs the cron scheduler and queue consumer and delivers triggers to it." },
37
+ { name: "SB_BROKER_PORT", purpose: "Loopback port of the binding broker, read by the compiled sprout at runtime (set by the control plane, or by `src/broker.ts` for local runs)." },
38
+ { name: "SB_BROKER_TOKEN", purpose: "Per-deployment auth token the sprout sends on every broker frame, and the broker sends back on scheduled/queue triggers (paired with SB_BROKER_PORT)." },
39
+ { name: "SB_SPROUT_URL", purpose: "http://127.0.0.1:<PORT> of the sprout; when set, `src/broker.ts` runs the cron scheduler and queue consumer and delivers triggers to it." },
36
40
  ];
37
41
 
38
42
  /** One-line usage string, e.g. for `usage()` and `--help`. */
package/src/toolchain.ts CHANGED
@@ -24,6 +24,10 @@ const ZIG_SHA256: Record<string, string> = {
24
24
 
25
25
  // Pinned Porffor identity — must match the `porffor` entry in package.json
26
26
  // (`github:CanadaHonk/porffor#alpha-4`, commit a415d19). PORFFOR_VERSION overrides.
27
+ // When bumping this pin (#55): run the monorepo's `bun run diff` against the
28
+ // frozen reference handlers and update its COMPAT.md for any new mismatch before
29
+ // releasing — the alpha compiler's output can shift between pins. Checked by
30
+ // hand at bump time, not in CI.
27
31
  const PORFFOR_CHANNEL = "alpha-4";
28
32
  const PORFFOR_COMMIT = "a415d19";
29
33
 
package/src/wrap.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The build-independent half of sprout 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 }` sprout.
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 sprout 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
+ }