sproutboat 0.4.11 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,9 +8,15 @@ Full reference: [sproutboat.com/docs](https://sproutboat.com/docs)
8
8
  (plain text for agents: [sproutboat.com/llms.txt](https://sproutboat.com/llms.txt)).
9
9
 
10
10
  ```sh
11
- bunx sproutboat login --api-url https://control.example.com # one browser approval
12
11
  bunx sproutboat init hello
13
12
  cd hello
13
+ bunx sproutboat dev # runs it right here, no control plane needed
14
+ ```
15
+
16
+ Happy with it? Ship it:
17
+
18
+ ```sh
19
+ bunx sproutboat login --api-url https://control.example.com # one browser approval
14
20
  bunx sproutboat deploy
15
21
  ```
16
22
 
@@ -34,17 +40,29 @@ of that file).
34
40
  | --- | --- |
35
41
  | `init [name]` | Scaffold `sproutboat.jsonc` + `src/index.js` |
36
42
  | `check` | Validate the config and entry point |
37
- | `build` | Cross-compile the sprout binary (Porffor + Zig) |
43
+ | `dev [--port <n>] [--no-watch]` | Run the project on this machine against a real broker, rebuilding on save |
44
+ | `build [--target host]` | Cross-compile the sprout binary (Porffor + Zig); `--target host` builds for this machine instead, for `dev` — not deployable |
38
45
  | `deploy [--dry-run] [--no-wait] [--no-provision] [--artifact <dir>]` | Build, auto-provision id-less storage bindings, upload, wait until the URL serves |
39
46
  | `login [--api-url <url>] [--token <token>]` | Browser device flow, or store a token directly |
40
47
  | `tail [name] [--sprout]` | Recent request logs; `--sprout` streams the running sprout + broker output |
41
- | `versions list [name]` | Deployed versions |
48
+ | `versions <list \| view <id>>` | Deployed versions, or one version's artifact and bindings |
42
49
  | `rollback <id>` | Activate a previous version |
43
- | `secrets [list \| set <NAME> [value] \| rm <NAME>]` | Encrypted project secrets, read as `env.NAME` |
44
- | `resource [list \| create <kind> <name> \| rename <id> <name> \| delete <id>]` | Account-level KV / D1 / R2 / queue stores |
45
- | `domains [list \| add <host> \| verify <host> \| rm <host>]` | Attach your own hostname (TXT + A, apex allowed) |
50
+ | `logout [--api-url <url>]` | Forget the stored credential |
51
+ | `whoami` | Active endpoint and the account the token belongs to |
52
+ | `kv <list \| create \| info \| rename \| delete>` | KV namespaces |
53
+ | `d1 <list \| create \| info \| rename \| delete>` | D1 databases |
54
+ | `r2 <list \| create \| info \| rename \| delete>` | R2 buckets |
55
+ | `queues <list \| create \| info \| rename \| delete>` | Queues |
56
+ | `secrets <list \| put <NAME> [--value <v>] \| delete <NAME>>` | Encrypted project secrets, read as `env.NAME` |
57
+ | `domains <list \| add <host> \| verify <host> \| delete <host>>` | Attach your own hostname (TXT + A, apex allowed) |
46
58
  | `delete --yes` | Delete the project, every version, and its route |
47
59
 
60
+ Each storage product is its own command with the same five verbs. Wrangler nests
61
+ two of its four (`kv namespace create`, `r2 bucket create`) and leaves the other
62
+ two flat; the nesting separates a container from its contents, which the verb
63
+ already does, so ours are uniform. Contents get their own noun when they arrive
64
+ (`kv key get`, `r2 object put`).
65
+
48
66
  Run `sproutboat` with no arguments for the grouped list.
49
67
  [`SURFACE.md`](SURFACE.md) is the generated inventory of every command and env var.
50
68
 
@@ -75,15 +93,22 @@ Run `sproutboat` with no arguments for the grouped list.
75
93
  A bare `"CACHE"` binding is auto-provisioned on `deploy`: the CLI creates an
76
94
  account-level resource, writes its id back into `sproutboat.jsonc`, and the
77
95
  store then survives redeploys. Pass `--no-provision` to keep it a throwaway
78
- per-deploy store instead, or `sproutboat resource create` to make one up front
96
+ per-deploy store instead, or `sproutboat kv create <name>` (or `d1`/`r2`/`queues`) to make one up front
79
97
  and share its id across projects.
80
98
 
81
- The handler is one `export default { fetch(request) }`, optionally with
82
- `scheduled(event)` / `queue(batch)` handlers and Durable Object classes above
83
- it. `env` is a global (not a parameter), and every binding call is synchronous.
99
+ The handler is `export default { fetch(request) }`; it may import from other
100
+ files in the project and from its own `node_modules`, and optionally export
101
+ `scheduled(event)` / `queue(batch)` handlers and Durable Object classes.
102
+ `env` is a global (not a parameter), and every binding call is synchronous.
84
103
  See [`examples/kitchen-sink/`](examples/kitchen-sink) for one app that uses
85
104
  every binding.
86
105
 
106
+ `sproutboat dev` runs against a real local broker, so bindings behave like
107
+ production without a deploy — except `secrets`, which live only in the
108
+ control plane. Give it a value with a `.dev.vars` file next to
109
+ `sproutboat.jsonc` (`API_KEY=whatever`, one per line, gitignore it):
110
+ `env.API_KEY` then resolves to that value under `dev` only.
111
+
87
112
  ## Requirements
88
113
 
89
114
  - [Bun](https://bun.sh) 1.4+
@@ -104,10 +129,15 @@ Capability profile `http-sync-v0`: one synchronous `fetch` handler, optional
104
129
  `scheduled` / `queue` handlers, no streaming, no WebSockets. Binding values are
105
130
  text/JSON and travel one at a time over the loopback frame (32 MiB cap);
106
131
  large-object R2 is [#56](https://github.com/baronunread/sproutboat/issues/56).
107
- The sprout upload caps at 16 MiB, assets at 64 MiB / 4096 files. Node
108
- compatibility is Porffor alpha, so `import`/`require`, `process`, `node:*`, and
109
- parsing date strings do not work; `sproutboat check` catches most of it. Full
110
- list at [sproutboat.com/docs](https://sproutboat.com/docs).
132
+ The sprout upload caps at 16 MiB, assets at 64 MiB / 4096 files. The entry
133
+ point is bundled before it compiles, so relative imports and npm dependencies
134
+ from the project's own `node_modules` work; CommonJS `require`, dynamic
135
+ `import()`, and any `process`/`Bun`/`Deno`/`node:*` reached even through a
136
+ dependency do not. `new Proxy(...)` compiles but its traps are silently
137
+ ignored by Porffor alpha, so it's rejected too. A parsed `Date` with a numeric
138
+ timezone offset (`+02:00`) currently comes out wrong; plain ISO-8601 UTC
139
+ strings are fine. `sproutboat check` catches most of this before a build.
140
+ Full list at [sproutboat.com/docs](https://sproutboat.com/docs).
111
141
 
112
142
  ---
113
143
 
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.4.11 · runs on Bun (use `bunx`, not `npx`)
6
+ **Package:** `sproutboat` 0.5.0 · runs on Bun (use `bunx`, not `npx`)
7
7
 
8
8
  ## Commands
9
9
 
@@ -11,19 +11,25 @@
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 sprout (Porffor + Zig). |
14
+ | `dev` | `[project-dir] [--port <n>] [--no-watch]` | Run the project on this machine against a real broker, rebuilding on save. |
15
+ | `build` | `[project-dir] [--target host]` | Cross-compile the native-fetch sprout (Porffor + Zig). `--target host` builds for this machine instead, to run locally — not deployable. |
15
16
  | `deploy` | `[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision]` | Build (unless --artifact), auto-provision id-less storage bindings and pin their ids into sproutboat.jsonc, print the report, upload, wait until the URL serves. The control plane skips an upload that matches the live artifact byte-for-byte. --dry-run stops before upload; --no-wait skips the health check; --no-provision leaves id-less bindings as ephemeral deploy-scoped stores. |
16
- | `versions` | `list [project-dir]` | List the project's deployed versions. |
17
+ | `versions` | `<list | view <version-id>> [project-dir]` | List the project's deployed versions, or show one version's artifact and bindings. |
17
18
  | `rollback` | `<version-id> [project-dir]` | Re-activate a previous version. |
18
19
  | `tail` | `[project-dir] [--sprout]` | Print recent request logs; --sprout prints the running sprout + broker stdout/stderr instead. |
19
- | `domains` | `[list | add <host> | verify <host> | rm <host>] [project-dir]` | Attach a custom domain to the project (TXT-verified). No sub-command lists. |
20
- | `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. |
21
- | `resource` | `[list [kind] | create <kind> <name> | rename <id> <name> | delete <id>]` | Manage account-level storage resources (kv | d1 | r2 | queue). `create` prints the id to reference from sproutboat.jsonc bindings. |
20
+ | `kv` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | KV namespaces. `create` prints the id to bind from sproutboat.jsonc. |
21
+ | `d1` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | D1 databases. `create` prints the id to bind from sproutboat.jsonc. |
22
+ | `r2` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | R2 buckets. `create` prints the id to bind from sproutboat.jsonc. |
23
+ | `queues` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | Queues. `create` prints the id to bind from sproutboat.jsonc; consumers are not implemented yet. |
24
+ | `domains` | `<list | add <host> | verify <host> | delete <host>> [project-dir]` | Attach a custom domain to the project (TXT-verified). No sub-command lists. |
25
+ | `secrets` | `<list | put <NAME> [--value <value>] | delete <NAME>> [project-dir]` | Manage encrypted project secrets (read as env.NAME). `put` reads the value from stdin unless --value is given, so it stays out of shell history; applies on next deploy. |
22
26
  | `delete` | `[project-dir] [--name <project>] --yes` | Delete the project, every version, and its route. |
23
27
  | `login` | `[--api-url <url>] [--token <token>]` | Device-code browser flow, or store <token> for <url> directly. |
28
+ | `logout` | `[--api-url <url>]` | Forget the stored credential for the active endpoint, or for <url>. |
29
+ | `whoami` | — | Show the active endpoint and the account the stored token belongs to. |
24
30
 
25
31
  ```
26
- usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision] | versions list [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--sprout] | domains [list | add <host> | verify <host> | rm <host>] [project-dir] | secrets [list | set <NAME> [value] | rm <NAME>] [project-dir] | resource [list [kind] | create <kind> <name> | rename <id> <name> | delete <id>] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>]>
32
+ usage: sproutboat <init [name] | check [project-dir] | dev [project-dir] [--port <n>] [--no-watch] | build [project-dir] [--target host] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision] | versions <list | view <version-id>> [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--sprout] | kv <list | create <name> | info <name> | rename <name> <new> | delete <name>> | d1 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | r2 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | queues <list | create <name> | info <name> | rename <name> <new> | delete <name>> | domains <list | add <host> | verify <host> | delete <host>> [project-dir] | secrets <list | put <NAME> [--value <value>] | delete <NAME>> [project-dir] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>] | logout [--api-url <url>] | whoami>
27
33
  ```
28
34
 
29
35
  ## Environment variables
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.4.11",
3
+ "version": "0.5.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",
@@ -43,6 +43,7 @@
43
43
  },
44
44
  "scripts": {
45
45
  "typecheck": "tsc --noEmit",
46
+ "lint": "oxlint .",
46
47
  "test": "bun test",
47
48
  "example:kitchen-sink": "bun examples/kitchen-sink/harness.ts",
48
49
  "surface": "UPDATE_SURFACE=1 bun test src/surface.test.ts",
@@ -55,7 +56,9 @@
55
56
  "porffor": "github:CanadaHonk/porffor#alpha-4"
56
57
  },
57
58
  "devDependencies": {
59
+ "@oxlint/plugins": "1.81.0",
58
60
  "@types/bun": "1.4.0",
61
+ "oxlint": "1.81.0",
59
62
  "typescript": "5.9.2"
60
63
  }
61
64
  }
package/src/broker.ts CHANGED
@@ -23,6 +23,7 @@ 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
25
  import { resolveAssetKey, type AssetManifest } from "./assets";
26
+ import { isBoolean, isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
26
27
 
27
28
  export type Bindings = {
28
29
  kv: string[];
@@ -44,7 +45,12 @@ export type Bindings = {
44
45
  */
45
46
  resources: Record<string, { kind: "kv" | "d1" | "r2" | "queue"; id: string }>;
46
47
  };
47
- export type Frame = Record<string, unknown>;
48
+ /** One decoded wire frame. `undefined` fields are dropped by `JSON.stringify`, so
49
+ * an optional reply field can be left off without a second object shape. */
50
+ export type Frame = { [key: string]: JsonValue | undefined };
51
+
52
+ /** The only shape of `fetch` the broker calls — the global `fetch` satisfies it. */
53
+ export type FetchLike = (input: URL | string, init?: RequestInit) => Promise<Response>;
48
54
 
49
55
  export type BrokerOptions = {
50
56
  db?: string;
@@ -69,21 +75,24 @@ export type BrokerOptions = {
69
75
  /** Directory of published static assets (its sibling `assets.json` is the manifest). Backs `assets.get`. */
70
76
  assetsDir?: string;
71
77
  /** Injected in tests; defaults to the global `fetch`. */
72
- fetchImpl?: typeof fetch;
78
+ fetchImpl?: FetchLike;
73
79
  };
74
80
 
75
81
  type SqlParam = string | number | null;
82
+ /** The `{ results, meta }` shape Cloudflare's D1 client expects back per statement. */
83
+ type D1Result = { results: JsonValue[]; meta: { duration: number; changes: number; last_row_id: number; rows_read: number } };
76
84
  type R2Row = { key: string; body: string; size: number; etag: string; uploaded: string; http_json: string; custom_json: string };
77
- const sqlParams = (v: unknown): SqlParam[] => {
85
+ const isNumber = (v: JsonValue | undefined): v is number => Number.isFinite(v);
86
+ const sqlParams = (v: JsonValue | undefined): SqlParam[] => {
78
87
  if (!Array.isArray(v)) return [];
79
- return v.map((p) => (p === null || typeof p === "number" || typeof p === "string" ? p : typeof p === "boolean" ? (p ? 1 : 0) : String(p)));
88
+ return v.map((p) => (p === null || isNumber(p) || isString(p) ? p : isBoolean(p) ? (p ? 1 : 0) : String(p)));
80
89
  };
81
90
 
82
91
  // One binding call = one frame, and an R2/KV value travels inside it as a JSON
83
92
  // string (escaping can inflate binary content several ×). 32 MiB keeps a ~25 MB
84
93
  // upload working; true large-object streaming is v2.
85
94
  const MAX_FRAME = 32 * 1024 * 1024;
86
- const str = (v: unknown): string => (typeof v === "string" ? v : String(v ?? ""));
95
+ const str = (v: JsonValue | undefined): string => (isString(v) ? v : String(v ?? ""));
87
96
 
88
97
  export type Broker = {
89
98
  /** Run one parsed request object through the op dispatch. */
@@ -103,7 +112,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
103
112
  let assetManifest: AssetManifest | null = null;
104
113
  if (assetsDir) {
105
114
  const manifestPath = join(dirname(assetsDir), "assets.json");
106
- if (existsSync(manifestPath)) assetManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AssetManifest;
115
+ if (existsSync(manifestPath)) {
116
+ // SAFETY: assets.json sits beside the published assets dir and is written
117
+ // only by `sproutboat build` from the AssetManifest contract.
118
+ assetManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AssetManifest;
119
+ }
107
120
  }
108
121
  const readAsset = (path: string): { type: string; hash: string; body: string } | null => {
109
122
  if (!assetsDir || !assetManifest) return null;
@@ -200,7 +213,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
200
213
  return stmts;
201
214
  };
202
215
 
203
- const bound = (list: string[], kind: string) => (name: unknown): string => {
216
+ const bound = (list: string[], kind: string) => (name: JsonValue | undefined): string => {
204
217
  const n = str(name);
205
218
  if (!list.includes(n)) throw new Error(`${kind} not bound: ${n}`);
206
219
  return n;
@@ -211,7 +224,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
211
224
  const requireQueue = bound(bindings.queues, "queue");
212
225
  const requireAe = bound(bindings.analytics, "analytics dataset");
213
226
  const doClasses = new Set(bindings.do.map((d) => d.className));
214
- const requireDoClass = (cls: unknown): string => {
227
+ const requireDoClass = (cls: JsonValue | undefined): string => {
215
228
  const n = str(cls);
216
229
  if (!doClasses.has(n)) throw new Error(`Durable Object class not bound: ${n}`);
217
230
  return n;
@@ -239,9 +252,10 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
239
252
  };
240
253
 
241
254
  // Run one statement, return CF-D1-shaped { results, meta }.
242
- const d1Run = (conn: Database, sql: string, params: SqlParam[]): { results: unknown[]; meta: Record<string, unknown> } => {
255
+ const d1Run = (conn: Database, sql: string, params: SqlParam[]): D1Result => {
243
256
  const started = performance.now();
244
- const results = conn.query(sql).all(...params);
257
+ // SQLite hands back column->(string|number|null|blob) rows, i.e. a JSON object.
258
+ const results = conn.query<JsonObject, SqlParam[]>(sql).all(...params);
245
259
  const m = conn.query<{ changes: number; last_row_id: number }, []>(
246
260
  "SELECT changes() AS changes, last_insert_rowid() AS last_row_id",
247
261
  ).get();
@@ -256,8 +270,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
256
270
  size: r.size,
257
271
  etag: r.etag,
258
272
  uploaded: r.uploaded,
259
- httpMetadata: JSON.parse(r.http_json) as unknown,
260
- customMetadata: JSON.parse(r.custom_json) as unknown,
273
+ httpMetadata: parseJsonValue(r.http_json),
274
+ customMetadata: parseJsonValue(r.custom_json),
261
275
  });
262
276
 
263
277
  async function proxyFetch(msg: Frame): Promise<Frame> {
@@ -272,7 +286,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
272
286
 
273
287
  const headers = new Headers();
274
288
  if (Array.isArray(msg.headers)) {
275
- for (const pair of msg.headers as unknown[]) {
289
+ for (const pair of msg.headers) {
276
290
  if (Array.isArray(pair) && pair.length === 2) headers.set(str(pair[0]), str(pair[1]));
277
291
  }
278
292
  }
@@ -326,7 +340,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
326
340
  }
327
341
  case "d1.batch": {
328
342
  const conn = d1(requireD1(msg.db));
329
- const stmts = Array.isArray(msg.statements) ? (msg.statements as Frame[]) : [];
343
+ const stmts = Array.isArray(msg.statements) ? msg.statements.map((s) => jsonObject(s) ?? {}) : [];
330
344
  const runAll = conn.transaction(() => stmts.map((s) => d1Run(conn, str(s.sql), sqlParams(s.params))));
331
345
  return { ok: true, results: runAll() };
332
346
  }
@@ -393,7 +407,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
393
407
  }
394
408
  case "queue.send_batch": {
395
409
  const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
396
- const msgs = Array.isArray(msg.messages) ? (msg.messages as Frame[]) : [];
410
+ const msgs = Array.isArray(msg.messages) ? msg.messages.map((m) => jsonObject(m) ?? {}) : [];
397
411
  const ins = store.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)");
398
412
  store.transaction(() => {
399
413
  for (const m of msgs) ins.run(q, newId(), str(m.body), Date.now() + Math.max(0, Number(m.delaySeconds) || 0) * 1000);
@@ -426,9 +440,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
426
440
  count: total?.n ?? 0,
427
441
  rows: rows.map((r) => ({
428
442
  timestamp: r.ts,
429
- indexes: JSON.parse(r.indexes_json) as unknown,
430
- blobs: JSON.parse(r.blobs_json) as unknown,
431
- doubles: JSON.parse(r.doubles_json) as unknown,
443
+ indexes: parseJsonValue(r.indexes_json),
444
+ blobs: parseJsonValue(r.blobs_json),
445
+ doubles: parseJsonValue(r.doubles_json),
432
446
  })),
433
447
  };
434
448
  }
@@ -493,7 +507,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
493
507
  const json = nl === -1 ? payload : payload.slice(nl + 1);
494
508
  if (token && gotToken !== token) return { ok: false, error: "unauthorized" };
495
509
  try {
496
- return await dispatch(JSON.parse(json) as Frame);
510
+ const msg = jsonObject(parseJsonValue(json));
511
+ if (!msg) throw new Error("request frame was not a JSON object");
512
+ return await dispatch(msg);
497
513
  } catch (e) {
498
514
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
499
515
  }
@@ -504,7 +520,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
504
520
  const QUEUE_BATCH = 10;
505
521
  const QUEUE_MAX_ATTEMPTS = 5;
506
522
 
507
- async function deliverTrigger(kind: "scheduled" | "queue", body: unknown): Promise<Response | null> {
523
+ async function deliverTrigger(kind: "scheduled" | "queue", body: JsonObject): Promise<Response | null> {
508
524
  if (!opts.sproutUrl) return null;
509
525
  try {
510
526
  return await doFetch(opts.sproutUrl, {
@@ -541,9 +557,10 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
541
557
  let retry: string[] = [];
542
558
  if (res && res.ok) {
543
559
  try {
544
- const parsed = (await res.json()) as { ack?: string[]; retry?: string[] };
545
- ack = Array.isArray(parsed.ack) ? parsed.ack : ack;
546
- retry = Array.isArray(parsed.retry) ? parsed.retry : [];
560
+ const parsed = jsonObject(parseJsonValue(await res.text()));
561
+ const ids = (value: JsonValue | undefined) => (Array.isArray(value) ? value.filter(isString) : null);
562
+ ack = (parsed && ids(parsed.ack)) ?? ack;
563
+ retry = (parsed && ids(parsed.retry)) ?? [];
547
564
  } catch { /* keep defaults */ }
548
565
  } else {
549
566
  ack = []; retry = rows.map((r) => r.id); // delivery failed → retry all
@@ -614,8 +631,11 @@ export function encodeFrame(obj: Frame): Buffer {
614
631
  return frame;
615
632
  }
616
633
 
634
+ /** The bound TCP listener: the port actually assigned, and its shutdown handle. */
635
+ export type BrokerServer = { port: number; stop(): void };
636
+
617
637
  /** Start the TCP listener. Returns the bound port. */
618
- export function listen(broker: Broker, hostname: string, port: number): { port: number; stop(): void } {
638
+ export function listen(broker: Broker, hostname: string, port: number): BrokerServer {
619
639
  const server = Bun.listen<{ buf: Buffer }>({
620
640
  hostname,
621
641
  port,
@@ -659,9 +679,12 @@ if (import.meta.main) {
659
679
  "assets-dir": { type: "string" },
660
680
  },
661
681
  });
682
+ // SAFETY: --bindings and --secrets are the artifact's own bindings.json /
683
+ // secrets.json, written by `sproutboat build` and handed to us by the supervisor.
662
684
  const bindings: Partial<Bindings> | undefined = values.bindings
663
685
  ? (JSON.parse(readFileSync(values.bindings, "utf8")) as Partial<Bindings>)
664
686
  : undefined;
687
+ // SAFETY: as above — secrets.json is a flat name->value map written by the build.
665
688
  const secrets: Record<string, string> | undefined = values.secrets
666
689
  ? (JSON.parse(readFileSync(values.secrets, "utf8")) as Record<string, string>)
667
690
  : undefined;
package/src/build.ts CHANGED
@@ -4,13 +4,25 @@ import { resolve } from "node:path";
4
4
  import { walkAssets, type AssetManifest } from "./assets";
5
5
  import { resourceRefs, type SproutboatConfig } from "./config";
6
6
  import { compileSprout } from "./compile";
7
- import { ARTIFACT_SCHEMA_VERSION, CAPABILITY_PROFILE, RUNTIME, type ArtifactManifest } from "./manifest";
7
+ import { ARTIFACT_SCHEMA_VERSION, CAPABILITY_PROFILE, DEPLOY_TARGET, hostTarget, RUNTIME, type ArtifactManifest } from "./manifest";
8
8
  import { ensureZig, esbuildVersion, porfforVersion, toolchainStamp } from "./toolchain";
9
9
 
10
10
  export type BuildInput = {
11
11
  projectDir: string;
12
12
  config: SproutboatConfig;
13
13
  sourcePath: string;
14
+ /**
15
+ * The bundled module (#89). When present this is what gets hashed and
16
+ * compiled, so the artifact tracks every imported file rather than just the
17
+ * entry point — change a dependency, get a different version.
18
+ */
19
+ source?: string;
20
+ /**
21
+ * `host` (#62) compiles for this machine instead of cross-compiling for a
22
+ * box, so `sproutboat dev` can run the sprout locally. The manifest records
23
+ * the real target, which is what stops the result being deployed.
24
+ */
25
+ target?: "linux-x86_64" | "host";
14
26
  };
15
27
 
16
28
  export type BuildOutput = {
@@ -29,7 +41,7 @@ function digest(value: Uint8Array | string): `sha256:${string}` {
29
41
  * does not come up.
30
42
  */
31
43
  export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
32
- const source = await readFile(input.sourcePath);
44
+ const source = input.source ?? await readFile(input.sourcePath);
33
45
  const sourceHash = digest(source);
34
46
  const artifactId = sourceHash.slice("sha256:".length, 24);
35
47
  const artifactDir = resolve(input.projectDir, ".sproutboat/dist", artifactId);
@@ -63,20 +75,25 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
63
75
  resources,
64
76
  };
65
77
 
66
- const zigBin = await ensureZig();
78
+ // A host build never shells out to `zig`, so do not fetch a 50 MB toolchain
79
+ // for it — that download is the slowest part of a first local build.
80
+ const host = input.target === "host";
81
+ const zigBin = host ? undefined : await ensureZig();
67
82
  await compileSprout({
68
83
  sourcePath: input.sourcePath,
84
+ source: input.source,
69
85
  outPath: sproutPath,
70
86
  vars: input.config.vars ?? {},
71
87
  bindings,
72
88
  zigBin,
89
+ target: input.target,
73
90
  });
74
91
 
75
92
  const sprout = await readFile(sproutPath);
76
93
  const manifest: ArtifactManifest = {
77
94
  schemaVersion: ARTIFACT_SCHEMA_VERSION,
78
95
  project: input.config.name,
79
- target: "linux-x86_64",
96
+ target: host ? hostTarget() : DEPLOY_TARGET,
80
97
  runtime: RUNTIME,
81
98
  capabilityProfile: CAPABILITY_PROFILE,
82
99
  porfforVersion: porfforVersion(),
package/src/bundle.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * #89 — bundle the handler before Porffor sees it.
3
+ *
4
+ * Porffor compiles one self-contained module, so until now a project was one
5
+ * file with no imports: no router, no validation library, no SDK, no splitting
6
+ * a codebase in two. Bundling first lifts that without touching the compiler.
7
+ *
8
+ * Bun's bundler resolves relative imports across the project and bare
9
+ * specifiers out of the project's own `node_modules`, then emits a single ESM
10
+ * module. The capability bans (`process`, `Bun`, `node:`, WebSocket, …) are
11
+ * checked against that output rather than the entry file, so a dependency
12
+ * reaching for a Node API fails exactly as user code would.
13
+ */
14
+ import { relative } from "node:path";
15
+
16
+ export type BundleResult = {
17
+ /** One self-contained ESM module: what gets validated, hashed, and compiled. */
18
+ code: string;
19
+ };
20
+
21
+ export class BundleError extends Error {
22
+ constructor(message: string) {
23
+ super(message);
24
+ this.name = "BundleError";
25
+ }
26
+ }
27
+
28
+ const entryLabel = (entryPath: string, projectDir: string): string => relative(projectDir, entryPath) || entryPath;
29
+
30
+ /** Bun reports resolution failures on `AggregateError.errors`; its own message is just "Bundle failed". */
31
+ function bundleDetail(cause: unknown): string {
32
+ const errors = cause instanceof AggregateError ? cause.errors : [];
33
+ if (errors.length > 0) return errors.map((error) => ` ${error instanceof Error ? error.message : String(error)}`).join("\n");
34
+ return ` ${cause instanceof Error ? cause.message : String(cause)}`;
35
+ }
36
+
37
+ /** Bundle `entryPath` into a single module, resolving imports from `projectDir`. */
38
+ export async function bundleHandler(entryPath: string, projectDir: string): Promise<BundleResult> {
39
+ let built: Awaited<ReturnType<typeof Bun.build>>;
40
+ try {
41
+ built = await Bun.build({
42
+ entrypoints: [entryPath],
43
+ root: projectDir,
44
+ // `browser` keeps the output free of Node shims — a dependency that wants
45
+ // `process` must fail the capability check, not get a polyfill smuggled in.
46
+ target: "browser",
47
+ format: "esm",
48
+ // Readability over size: a compile error from Porffor should point at
49
+ // something a human can find, and Porffor strips the binary anyway.
50
+ minify: false,
51
+ splitting: false,
52
+ sourcemap: "none",
53
+ });
54
+ } catch (cause) {
55
+ // An unresolvable specifier arrives as an AggregateError whose `errors`
56
+ // carry the useful part ("Could not resolve: ./missing.js"); the top-level
57
+ // message is only "Bundle failed", which tells nobody which import broke.
58
+ throw new BundleError(`could not bundle ${entryLabel(entryPath, projectDir)}:\n${bundleDetail(cause)}`);
59
+ }
60
+
61
+ if (!built.success) {
62
+ const detail = built.logs.map((log) => ` ${log.message}`).join("\n");
63
+ throw new BundleError(`could not bundle ${entryLabel(entryPath, projectDir)}:\n${detail}`);
64
+ }
65
+ const [output] = built.outputs;
66
+ if (output === undefined) throw new BundleError("the bundler produced no output");
67
+
68
+ return { code: await output.text() };
69
+ }
package/src/compile.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * shipped in `vendor/` so this needs no `git` or `make`; if that archive is
10
10
  * unusable it falls back to Porffor's own git + make path (needs both on PATH).
11
11
  */
12
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { chmod, mkdir, readFile, rm, 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";
@@ -21,10 +21,20 @@ const COMPILE_TIMEOUT_MS = Number(process.env.SPROUTBOAT_COMPILE_TIMEOUT_MS || 6
21
21
 
22
22
  export type CompileInput = {
23
23
  sourcePath: string;
24
+ /** The bundled module (#89). Falls back to reading `sourcePath` verbatim. */
25
+ source?: string;
24
26
  outPath: string;
25
27
  vars: Record<string, string>;
26
28
  bindings?: Bindings;
27
- zigBin: string;
29
+ /** Cross-compiler for `linux-x86_64`. Not needed, and not used, for `host`. */
30
+ zigBin?: string;
31
+ /**
32
+ * `linux-x86_64` (default) cross-compiles the static musl binary every box
33
+ * runs. `host` compiles for the machine doing the build (#62) so `sproutboat
34
+ * dev` can actually serve a sprout on a developer's laptop — an arm64 Mac
35
+ * cannot execute the deploy artifact.
36
+ */
37
+ target?: "linux-x86_64" | "host";
28
38
  };
29
39
 
30
40
  /** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
@@ -54,21 +64,34 @@ export async function compileSprout(input: CompileInput): Promise<void> {
54
64
 
55
65
  const outDir = dirname(input.outPath);
56
66
  await mkdir(outDir, { recursive: true });
67
+ // The artifact dir is content-addressed, so rebuilding unchanged source lands
68
+ // on the previous binary — which `chmod 0555` left read-only, and which the
69
+ // OS may still be executing. The linker cannot overwrite either, so clear it
70
+ // first rather than failing with "can't write output file".
71
+ await rm(input.outPath, { force: true });
57
72
  const generatedPath = resolve(outDir, "sprout.generated.js");
58
- const [source, prelude] = await Promise.all([readFile(input.sourcePath, "utf8"), readFile(preludePath, "utf8")]);
73
+ const [source, prelude] = await Promise.all([
74
+ input.source === undefined ? readFile(input.sourcePath, "utf8") : Promise.resolve(input.source),
75
+ readFile(preludePath, "utf8"),
76
+ ]);
59
77
  await writeFile(generatedPath, wrapNativeFetchHandler(source, prelude, input.vars, input.bindings ?? EMPTY_BINDINGS));
60
78
 
61
79
  const porffor = porfforRoot();
62
80
  const launcher = resolve(porffor, "runtime/index.js");
63
81
  // Porffor shells bare `zig` and `esbuild`; put both on PATH for the child.
82
+ // A host build never shells `zig`, so it has no zigBin to contribute.
64
83
  const binDir = resolve(porffor, "../.bin");
65
- const path = `${dirname(input.zigBin)}:${binDir}:${process.env.PATH ?? ""}`;
84
+ const zigDir = input.zigBin ? `${dirname(input.zigBin)}:` : "";
85
+ const path = `${zigDir}${binDir}:${process.env.PATH ?? ""}`;
66
86
 
67
87
  // `-s`: strip at link. The unstripped static-musl binary is ~90% DWARF that
68
88
  // nothing needs at runtime (12 MB -> ~1.3 MB for the kitchen-sink). Porffor
69
89
  // forwards `-s` straight to the `zig cc` link step.
90
+ // `--musl` is what makes it a cross-compile; a host build simply omits it and
91
+ // Porffor targets the machine it is running on.
92
+ const crossFlags = input.target === "host" ? [] : ["--musl"];
70
93
  const child = Bun.spawn(
71
- [process.execPath, launcher, "native", generatedPath, "-o", input.outPath, "--musl", "-s"],
94
+ [process.execPath, launcher, "native", generatedPath, "-o", input.outPath, ...crossFlags, "-s"],
72
95
  { cwd: outDir, stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: path } },
73
96
  );
74
97
  let timedOut = false;
package/src/config.ts CHANGED
@@ -4,7 +4,7 @@ const slugPattern = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/;
4
4
  * A storage binding entry (#74). Either a bare `"BINDING"` — resolved to an
5
5
  * ephemeral local resource for `sproutboat dev`, rejected by a real deploy — or
6
6
  * `{ binding, id }` pointing at an account-level resource created with
7
- * `sproutboat resource create`. The id carries its own `<kind>_` prefix.
7
+ * `sproutboat <kv|d1|r2|queues> create`. The id carries its own `<kind>_` prefix.
8
8
  */
9
9
  export type ResourceBinding = { binding: string; id: string };
10
10
  export type ResourceRef = string | ResourceBinding;
@@ -59,12 +59,29 @@ export async function activeApiUrl(): Promise<string | undefined> {
59
59
  return (await readCredentials()).activeApiUrl;
60
60
  }
61
61
 
62
+ /** #79 `logout`: drop one endpoint's token. Returns false when none was stored. */
63
+ export async function forgetToken(apiUrl: string): Promise<boolean> {
64
+ const credentials = await readCredentials();
65
+ if (!credentials.profiles[apiUrl]) return false;
66
+ delete credentials.profiles[apiUrl];
67
+ if (credentials.activeApiUrl === apiUrl) {
68
+ credentials.activeApiUrl = Object.keys(credentials.profiles)[0];
69
+ }
70
+ await writeCredentials(credentials);
71
+ return true;
72
+ }
73
+
62
74
  export async function saveToken(apiUrl: string, token: string): Promise<void> {
63
- const directory = configDirectory();
64
- const path = credentialsPath();
65
75
  const credentials = await readCredentials();
66
76
  credentials.profiles[apiUrl] = { token };
67
77
  credentials.activeApiUrl = apiUrl;
78
+ await writeCredentials(credentials);
79
+ }
80
+
81
+ /** Atomic 0600 write of the whole credentials file. */
82
+ async function writeCredentials(credentials: Credentials): Promise<void> {
83
+ const directory = configDirectory();
84
+ const path = credentialsPath();
68
85
  await mkdir(directory, { recursive: true, mode: 0o700 });
69
86
  await chmod(directory, 0o700);
70
87
  const temporary = `${path}.tmp-${crypto.randomUUID()}`;