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 +44 -14
- 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
|
@@ -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
|
-
| `
|
|
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
|
|
48
|
+
| `versions <list \| view <id>>` | Deployed versions, or one version's artifact and bindings |
|
|
42
49
|
| `rollback <id>` | Activate a previous version |
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
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
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
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
|
+
}
|
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
|
-
|
|
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([
|
|
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
|
|
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,
|
|
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
|
|
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;
|
package/src/credentials.ts
CHANGED
|
@@ -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()}`;
|