sproutboat 0.3.0 → 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.3.0 · 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,14 +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). |
35
- | `SPROUTBOAT_VARS_JSON` | JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the worker module. |
37
+ | `SPROUTBOAT_VARS_JSON` | JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the sprout module. |
36
38
  | `SPROUTBOAT_BINDINGS_JSON` | The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line. |
37
39
  | `SPROUTBOAT_CONFIG_DIR` | Directory for credentials.json (default ~/.config/sproutboat). |
38
40
  | `XDG_CONFIG_HOME` | Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset. |
39
41
  | `PORFFOR_VERSION` | Override the Porffor identity string recorded in the manifest. |
40
- | `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). |
41
- | `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). |
42
- | `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. |
43
45
 
44
46
  ## Build toolchain (pinned)
45
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.3.0",
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",
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";
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>"
@@ -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`. */
@@ -423,15 +423,15 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
423
423
  }
424
424
  }
425
425
 
426
- // --- cron + queue delivery (only when this broker knows its worker) --------
426
+ // --- cron + queue delivery (only when this broker knows its sprout) --------
427
427
  const timers: ReturnType<typeof setInterval>[] = [];
428
428
  const QUEUE_BATCH = 10;
429
429
  const QUEUE_MAX_ATTEMPTS = 5;
430
430
 
431
431
  async function deliverTrigger(kind: "scheduled" | "queue", body: unknown): Promise<Response | null> {
432
- if (!opts.workerUrl) return null;
432
+ if (!opts.sproutUrl) return null;
433
433
  try {
434
- return await doFetch(opts.workerUrl, {
434
+ return await doFetch(opts.sproutUrl, {
435
435
  method: "POST",
436
436
  headers: { "x-sb-trigger": kind, "x-sb-token": token, "content-type": "application/json" },
437
437
  body: JSON.stringify(body),
@@ -442,7 +442,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
442
442
  }
443
443
 
444
444
  function drainQueuesOnce(): void {
445
- if (!opts.workerUrl || bindings.queues.length === 0) return;
445
+ if (!opts.sproutUrl || bindings.queues.length === 0) return;
446
446
  const now = Date.now();
447
447
  for (const q of bindings.queues) {
448
448
  const rows = db.query<{ id: string; body: string; attempts: number }, [string, number, number]>(
@@ -458,7 +458,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
458
458
  queue: q,
459
459
  messages: rows.map((r) => ({ id: r.id, body: r.body, timestamp: now, attempts: r.attempts + 1 })),
460
460
  }).then(async (res) => {
461
- 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
462
462
  let retry: string[] = [];
463
463
  if (res && res.ok) {
464
464
  try {
@@ -479,7 +479,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
479
479
  }
480
480
  }
481
481
 
482
- if (opts.workerUrl) {
482
+ if (opts.sproutUrl) {
483
483
  if (bindings.queues.length > 0) timers.push(setInterval(drainQueuesOnce, 500));
484
484
  if (bindings.crons.length > 0) {
485
485
  let lastTick = "";
@@ -574,7 +574,7 @@ if (import.meta.main) {
574
574
  "data-dir": { type: "string" },
575
575
  bindings: { type: "string" },
576
576
  secrets: { type: "string" },
577
- "worker-url": { type: "string" },
577
+ "sprout-url": { type: "string" },
578
578
  "assets-dir": { type: "string" },
579
579
  },
580
580
  });
@@ -590,9 +590,9 @@ if (import.meta.main) {
590
590
  token: values.token ?? process.env.SB_BROKER_TOKEN,
591
591
  bindings,
592
592
  secrets,
593
- workerUrl: values["worker-url"] ?? process.env.SB_WORKER_URL,
593
+ sproutUrl: values["sprout-url"] ?? process.env.SB_SPROUT_URL,
594
594
  assetsDir: values["assets-dir"],
595
595
  });
596
596
  const { port } = listen(broker, "127.0.0.1", Number(values.port ?? process.env.SB_BROKER_PORT ?? 0));
597
- 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)"}`);
598
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
@@ -28,7 +28,7 @@ export type CompileInput = {
28
28
  };
29
29
 
30
30
  /** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
31
- export async function compileWorker(input: CompileInput): Promise<void> {
31
+ export async function compileSprout(input: CompileInput): Promise<void> {
32
32
  await ensurePorfforPatched();
33
33
 
34
34
  // Seed the Porffor uWebSockets cache from the prebuilt archive in `vendor/` so
@@ -54,7 +54,7 @@ export async function compileWorker(input: CompileInput): Promise<void> {
54
54
 
55
55
  const outDir = dirname(input.outPath);
56
56
  await mkdir(outDir, { recursive: true });
57
- const generatedPath = resolve(outDir, "worker.generated.js");
57
+ const generatedPath = resolve(outDir, "sprout.generated.js");
58
58
  const [source, prelude] = await Promise.all([readFile(input.sourcePath, "utf8"), readFile(preludePath, "utf8")]);
59
59
  await writeFile(generatedPath, wrapNativeFetchHandler(source, prelude, input.vars, input.bindings ?? EMPTY_BINDINGS));
60
60
 
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();
@@ -31,6 +31,44 @@ function __sbStartupMark() {
31
31
  }
32
32
  __sbStartupMark();
33
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
+
34
72
  class __SproutboatURLSearchParams {
35
73
  constructor(init) {
36
74
  this._keys = [];
@@ -181,13 +219,13 @@ if (globalThis.crypto.randomUUID == null) {
181
219
  // Bindings: env.<KV>, env.<SECRET>, env.<D1>, env.<R2>, and globalThis.fetch,
182
220
  // backed by a Bun broker on a loopback TCP port. The transport is inline C —
183
221
  // 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).
222
+ // sprout event-loop turn per request, so a blocking roundtrip is acceptable).
185
223
  // Wire frame:
186
224
  // [u32 LE len][ <token> "\n" <json> ] reply: [u32 LE len][ <json> ]
187
225
  // SB_BROKER_PORT / SB_BROKER_TOKEN are set by the supervisor next to $PORT.
188
226
  // If SB_BROKER_PORT is unset the shims below are never installed (compile.ts
189
227
  // only emits the __sbInstallBindings call when the project declares bindings),
190
- // so a plain worker is byte-for-byte unchanged.
228
+ // so a plain sprout is byte-for-byte unchanged.
191
229
  // ponytail: text values only; still AF_INET loopback, not AF_UNIX. A failed
192
230
  // exchange reconnects and resends once — a broker crash between "request applied"
193
231
  // and "reply read" can double-apply a non-idempotent op (queue.send, INSERT);
@@ -252,7 +290,7 @@ static int sb_broker_fd = -1;
252
290
  static int sb_broker_connect(void) {
253
291
  const char* port_s = getenv("SB_BROKER_PORT");
254
292
  if (!port_s) return -10;
255
- signal(SIGPIPE, SIG_IGN); // a dead broker must yield EPIPE, not kill the worker
293
+ signal(SIGPIPE, SIG_IGN); // a dead broker must yield EPIPE, not kill the sprout
256
294
  int fd = socket(AF_INET, SOCK_STREAM, 0);
257
295
  if (fd < 0) return -1;
258
296
  int one = 1;
@@ -422,7 +460,7 @@ function __sbMakeD1(dbName) {
422
460
  }
423
461
 
424
462
  // R2: a Cloudflare-shaped object. When `body` is present the sync accessors
425
- // mirror R2ObjectBody's async ones (a worker may `await` them harmlessly).
463
+ // mirror R2ObjectBody's async ones (a sprout may `await` them harmlessly).
426
464
  function __sbR2Object(meta, body) {
427
465
  const obj = {
428
466
  key: meta.key,
@@ -572,7 +610,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
572
610
  }
573
611
 
574
612
  // Static assets: env.<ASSETS>.fetch(request) -> broker `assets.get`. The edge
575
- // 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
576
614
  // it wants to own (SPA fallback, auth-gated files). Text assets only — binary
577
615
  // files go through the edge (the broker frame is UTF-8 JSON).
578
616
  if (bindings.assets) {
@@ -612,13 +650,13 @@ globalThis.__sbInstallBindings = function (target, bindings) {
612
650
  };
613
651
 
614
652
  // ---------------------------------------------------------------------------
615
- // Durable Objects. The class runs here in the sandboxed worker. There is exactly
616
- // 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
617
655
  // runtime processes one turn at a time, so calls to a given object id are
618
656
  // already serialized — `env.<NS>.get(id).fetch()` invokes the instance directly,
619
657
  // no round-trip. Only `state.storage.*` goes to the broker (so object state
620
- // outlives a worker restart), scoped to (class, id).
621
- // 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
622
660
  // deployment needs the broker to hold a per-id lock (cloud). Storage ops are one
623
661
  // key at a time; blockConcurrencyWhile just runs the fn.
624
662
 
@@ -747,7 +785,19 @@ function __sbTriggerAuthed(request) {
747
785
 
748
786
  globalThis.__sbEntry = function (handlers, request) {
749
787
  const trigger = request.headers.get('x-sb-trigger');
750
- 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
+ }
751
801
  if (!__sbTriggerAuthed(request)) return new Response('forbidden', { status: 403 });
752
802
 
753
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,14 +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)." },
30
- { name: "SPROUTBOAT_VARS_JSON", purpose: "JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the worker module." },
32
+ { name: "SPROUTBOAT_VARS_JSON", purpose: "JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the sprout module." },
31
33
  { name: "SPROUTBOAT_BINDINGS_JSON", purpose: "The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line." },
32
34
  { name: "SPROUTBOAT_CONFIG_DIR", purpose: "Directory for credentials.json (default ~/.config/sproutboat)." },
33
35
  { name: "XDG_CONFIG_HOME", purpose: "Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset." },
34
36
  { name: "PORFFOR_VERSION", purpose: "Override the Porffor identity string recorded in the manifest." },
35
- { 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)." },
36
- { 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)." },
37
- { 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." },
38
40
  ];
39
41
 
40
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 CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The build-independent half of worker compilation: the binding/trigger wrapper
2
+ * The build-independent half of sprout compilation: the binding/trigger wrapper
3
3
  * that turns a user's `export default { fetch }` into a native-fetch module, plus
4
4
  * the `Bindings` shape and the `SPROUTBOAT_*_JSON` env readers.
5
5
  *
@@ -54,12 +54,12 @@ function hasBindings(b: Bindings): boolean {
54
54
  * `__sbEntry` (HTTP → `handlers.fetch`; `x-sb-trigger` → scheduled / queue / DO).
55
55
  *
56
56
  * With no bindings and no `scheduled`/`queue`/DO the output behaves exactly like
57
- * a plain `export default { fetch }` worker.
57
+ * a plain `export default { fetch }` sprout.
58
58
  *
59
59
  * `port` is only the baked fallback in `export default { port }`; the runtime
60
60
  * reads `$PORT` first. The monorepo's bench path overrides it.
61
61
  *
62
- * ponytail: the worker process is long-lived, so a handler that mutates `env`
62
+ * ponytail: the sprout process is long-lived, so a handler that mutates `env`
63
63
  * leaks that change to later requests. Freeze upstream once Porffor supports
64
64
  * Object.freeze in native mode.
65
65
  */