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 +82 -30
- package/SURFACE.md +13 -7
- package/package.json +4 -1
- package/src/broker.ts +47 -24
- package/src/build.ts +21 -4
- package/src/bundle.ts +69 -0
- package/src/compile.ts +28 -5
- package/src/config.ts +1 -1
- package/src/credentials.ts +19 -2
- package/src/dev.ts +196 -0
- package/src/json.ts +30 -0
- package/src/main.ts +229 -79
- package/src/manifest.ts +21 -3
- package/src/native-fetch-prelude.js +32 -22
- package/src/report.ts +3 -1
- package/src/source.ts +17 -2
- package/src/surface.ts +57 -12
- package/src/toolchain.ts +6 -3
- package/src/update-check.ts +13 -4
- package/src/wrap.ts +43 -13
package/README.md
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
# sproutboat
|
|
2
2
|
|
|
3
|
-
The CLI for [Sproutboat](https://github.com/baronunread/sproutboat)
|
|
4
|
-
Wrangler-shaped tool
|
|
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
|
-
|
|
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
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
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
|
-
| `
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 (
|
|
76
|
-
ships a prebuilt uWebSockets
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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
|
-
| `
|
|
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` |
|
|
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
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
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] |
|
|
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.
|
|
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
|
-
|
|
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?:
|
|
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
|
|
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 ||
|
|
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:
|
|
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))
|
|
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:
|
|
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:
|
|
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[]):
|
|
255
|
+
const d1Run = (conn: Database, sql: string, params: SqlParam[]): D1Result => {
|
|
243
256
|
const started = performance.now();
|
|
244
|
-
|
|
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:
|
|
260
|
-
customMetadata:
|
|
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
|
|
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) ?
|
|
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) ?
|
|
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:
|
|
430
|
-
blobs:
|
|
431
|
-
doubles:
|
|
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
|
-
|
|
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:
|
|
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.
|
|
545
|
-
|
|
546
|
-
|
|
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):
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|