sproutboat 0.4.10 → 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
@@ -1,13 +1,22 @@
1
1
  # sproutboat
2
2
 
3
- The CLI for [Sproutboat](https://github.com/baronunread/sproutboat) a
4
- Wrangler-shaped tool for deploying workers to any Sproutboat control plane.
5
- MIT licensed.
3
+ The CLI for [Sproutboat](https://github.com/baronunread/sproutboat). It is a
4
+ Wrangler-shaped tool that compiles a `fetch` handler to a native binary and
5
+ ships it to any Sproutboat control plane. MIT licensed.
6
+
7
+ Full reference: [sproutboat.com/docs](https://sproutboat.com/docs)
8
+ (plain text for agents: [sproutboat.com/llms.txt](https://sproutboat.com/llms.txt)).
6
9
 
7
10
  ```sh
8
- bunx sproutboat login --api-url https://control.example.com
9
11
  bunx sproutboat init hello
10
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
11
20
  bunx sproutboat deploy
12
21
  ```
13
22
 
@@ -19,7 +28,11 @@ bun add -g sproutboat # then: sproutboat deploy, sproutboat tail, ...
19
28
 
20
29
  Want it shorter? Alias it yourself: `alias sprout='sproutboat'`.
21
30
 
22
- For CI, skip `login` and set `SPROUTBOAT_API_URL` + `SPROUTBOAT_TOKEN`.
31
+ The `login` browser step is one-time. It writes a long-lived token to
32
+ `~/.config/sproutboat/credentials.json`, keyed by API URL, so you can hold
33
+ logins for several instances at once. For CI or an agent, skip `login`
34
+ entirely and set `SPROUTBOAT_API_URL` + `SPROUTBOAT_TOKEN` (copy the token out
35
+ of that file).
23
36
 
24
37
  ## Commands
25
38
 
@@ -27,20 +40,35 @@ For CI, skip `login` and set `SPROUTBOAT_API_URL` + `SPROUTBOAT_TOKEN`.
27
40
  | --- | --- |
28
41
  | `init [name]` | Scaffold `sproutboat.jsonc` + `src/index.js` |
29
42
  | `check` | Validate the config and entry point |
30
- | `build` | Cross-compile the worker artifact |
31
- | `deploy [--dry-run] [--artifact <dir>]` | Build and upload (`--dry-run` prints the report only) |
32
- | `login [--api-url <url>] [--token <token>]` | Browser device flow, or store a token |
33
- | `tail [name]` | Recent logs |
34
- | `versions list [name]` | Deployed versions |
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 |
45
+ | `deploy [--dry-run] [--no-wait] [--no-provision] [--artifact <dir>]` | Build, auto-provision id-less storage bindings, upload, wait until the URL serves |
46
+ | `login [--api-url <url>] [--token <token>]` | Browser device flow, or store a token directly |
47
+ | `tail [name] [--sprout]` | Recent request logs; `--sprout` streams the running sprout + broker output |
48
+ | `versions <list \| view <id>>` | Deployed versions, or one version's artifact and bindings |
35
49
  | `rollback <id>` | Activate a previous version |
36
- | `delete --yes` | Delete the project |
37
-
38
- Credentials are keyed by API URL in `~/.config/sproutboat/credentials.json`, so
39
- you can hold logins for several instances at once.
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) |
58
+ | `delete --yes` | Delete the project, every version, and its route |
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
+
66
+ Run `sproutboat` with no arguments for the grouped list.
67
+ [`SURFACE.md`](SURFACE.md) is the generated inventory of every command and env var.
40
68
 
41
69
  ## Config
42
70
 
43
- `sproutboat.jsonc` the entry point plus Cloudflare-shaped `env.*` bindings:
71
+ `sproutboat.jsonc`: the entry point plus Cloudflare-shaped `env.*` bindings.
44
72
 
45
73
  ```jsonc
46
74
  {
@@ -50,42 +78,66 @@ you can hold logins for several instances at once.
50
78
 
51
79
  "vars": { "SITE": "hi" },
52
80
  "secrets": ["API_KEY"],
53
- "kv_namespaces": ["CACHE"],
81
+ "kv_namespaces": ["CACHE"], // bare name, or { "binding": "CACHE", "id": "kv_..." }
54
82
  "d1_databases": ["DB"],
55
83
  "r2_buckets": ["UPLOADS"],
56
84
  "queues": ["JOBS"],
57
- "analytics_engine_datasets": ["METRICS"],
85
+ "analytics_engine_datasets": ["METRICS"], // bare name only, no id
58
86
  "durable_objects": { "COUNTER": "Counter" },
59
87
  "outbound": ["api.example.com"],
60
88
  "triggers": { "crons": ["*/5 * * * *"] },
61
- "assets": { "directory": "public", "binding": "ASSETS" }
89
+ "assets": { "directory": "public", "binding": "ASSETS", "run_sprout_first": ["/api/*"] }
62
90
  }
63
91
  ```
64
92
 
65
- The handler is one `export default { fetch }`, optionally with `scheduled` /
66
- `queue` handlers and exported Durable Object classes. See
67
- [`examples/kitchen-sink/`](examples/kitchen-sink) for one app that uses every
68
- binding, with an Astro UI and a runnable end-to-end harness.
93
+ A bare `"CACHE"` binding is auto-provisioned on `deploy`: the CLI creates an
94
+ account-level resource, writes its id back into `sproutboat.jsonc`, and the
95
+ store then survives redeploys. Pass `--no-provision` to keep it a throwaway
96
+ per-deploy store instead, or `sproutboat kv create <name>` (or `d1`/`r2`/`queues`) to make one up front
97
+ and share its id across projects.
98
+
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.
103
+ See [`examples/kitchen-sink/`](examples/kitchen-sink) for one app that uses
104
+ every binding.
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.
69
111
 
70
112
  ## Requirements
71
113
 
72
114
  - [Bun](https://bun.sh) 1.4+
73
115
 
74
116
  `build` / `deploy` cross-compile the handler to a static `linux-x86_64` binary
75
- with Porffor and Zig (Zig is fetched automatically on first use). The package
76
- ships a prebuilt uWebSockets, so nothing else is compiled from source. No Docker,
77
- no root. On Windows, build from WSL.
117
+ with Porffor and Zig (the CLI fetches Zig automatically on first use). The
118
+ package ships a prebuilt uWebSockets and compiles nothing else from source. No
119
+ Docker, no root. On Windows, build from WSL.
78
120
 
79
121
  If that prebuilt is unusable (a `porffor` pin bump before the archive is
80
122
  refreshed), the first build falls back to compiling uWebSockets locally, which
81
123
  needs `git` and `make` on `PATH`. `SPROUTBOAT_UWS_TARBALL=<archive>` overrides
82
124
  the shipped one.
83
125
 
84
- ## Limits (v1)
85
-
86
- Binding values are text/JSON and travel one at a time over a loopback frame; an
87
- upload is capped at 1 MiB by the worker's HTTP server (large-object R2 is
88
- [#56](https://github.com/baronunread/sproutboat/issues/56)). No WebSockets yet.
126
+ ## Limits
127
+
128
+ Capability profile `http-sync-v0`: one synchronous `fetch` handler, optional
129
+ `scheduled` / `queue` handlers, no streaming, no WebSockets. Binding values are
130
+ text/JSON and travel one at a time over the loopback frame (32 MiB cap);
131
+ large-object R2 is [#56](https://github.com/baronunread/sproutboat/issues/56).
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).
89
141
 
90
142
  ---
91
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.10 · 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.10",
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
+ }