sproutboat 0.3.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SURFACE.md +13 -10
- package/package.json +1 -1
- package/src/assets.ts +1 -1
- package/src/broker.ts +14 -14
- package/src/build.ts +7 -7
- package/src/compile.ts +2 -2
- package/src/config.ts +2 -2
- package/src/main.ts +193 -20
- package/src/native-fetch-prelude.js +60 -10
- package/src/report.ts +50 -27
- package/src/style.ts +18 -0
- package/src/surface.ts +11 -8
- package/src/toolchain.ts +4 -0
- package/src/wrap.ts +3 -3
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.4.2 · runs on Bun (use `bunx`, not `npx`)
|
|
7
7
|
|
|
8
8
|
## Commands
|
|
9
9
|
|
|
@@ -11,16 +11,18 @@
|
|
|
11
11
|
| --- | --- | --- |
|
|
12
12
|
| `init` | `[name]` | Scaffold sproutboat.jsonc + src/index.js in ./<name>. |
|
|
13
13
|
| `check` | `[project-dir]` | Validate the config and entry point without building. |
|
|
14
|
-
| `build` | `[project-dir]` | Cross-compile the native-fetch
|
|
15
|
-
| `deploy` | `[project-dir] [--dry-run] [--artifact <dir>]` | Build (unless --artifact), print the report, upload. --dry-run stops before upload. |
|
|
14
|
+
| `build` | `[project-dir]` | Cross-compile the native-fetch sprout (Porffor + Zig). |
|
|
15
|
+
| `deploy` | `[project-dir] [--dry-run] [--artifact <dir>] [--no-wait]` | Build (unless --artifact), print the report, upload, wait until the URL serves. --dry-run stops before upload; --no-wait skips the health check. |
|
|
16
16
|
| `login` | `[--api-url <url>] [--token <token>]` | Device-code browser flow, or store <token> for <url> directly. |
|
|
17
|
-
| `tail` | `[project-dir]` | Print the
|
|
17
|
+
| `tail` | `[project-dir] [--sprout]` | Print recent request logs; --sprout prints the running sprout + broker stdout/stderr instead. |
|
|
18
18
|
| `versions` | `list [project-dir]` | List the project's deployed versions. |
|
|
19
19
|
| `rollback` | `<version-id> [project-dir]` | Re-activate a previous version. |
|
|
20
|
-
| `
|
|
20
|
+
| `domains` | `[list | add <host> | verify <host> | rm <host>] [project-dir]` | Attach a custom domain to the project (TXT-verified). No sub-command lists. |
|
|
21
|
+
| `secrets` | `[list | set <NAME> [value] | rm <NAME>] [project-dir]` | Manage encrypted project secrets (read as env.NAME). `set` takes the value from the arg or stdin; applies on next deploy. |
|
|
22
|
+
| `delete` | `[project-dir] [--name <project>] --yes` | Delete the project, every version, and its route. |
|
|
21
23
|
|
|
22
24
|
```
|
|
23
|
-
usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | deploy [project-dir] [--dry-run] [--artifact <dir>] | login [--api-url <url>] [--token <token>] | tail [project-dir] | versions list [project-dir] | rollback <version-id> [project-dir] |
|
|
25
|
+
usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] | login [--api-url <url>] [--token <token>] | tail [project-dir] [--sprout] | versions list [project-dir] | rollback <version-id> [project-dir] | domains [list | add <host> | verify <host> | rm <host>] [project-dir] | secrets [list | set <NAME> [value] | rm <NAME>] [project-dir] | delete [project-dir] [--name <project>] --yes>
|
|
24
26
|
```
|
|
25
27
|
|
|
26
28
|
## Environment variables
|
|
@@ -32,14 +34,15 @@ usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | dep
|
|
|
32
34
|
| `SPROUTBOAT_ZIG` | Path to a Zig binary to use instead of downloading the pinned one. |
|
|
33
35
|
| `SPROUTBOAT_UWS_TARBALL` | Path to a prebuilt uWebSockets (x86_64-linux-musl) tarball to seed the Porffor cache with, instead of downloading it (removes the first-build git + make need). |
|
|
34
36
|
| `SPROUTBOAT_COMPILE_TIMEOUT_MS` | Porffor compile timeout in ms (default 600000). |
|
|
35
|
-
| `SPROUTBOAT_VARS_JSON` | JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the
|
|
37
|
+
| `SPROUTBOAT_VARS_JSON` | JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the sprout module. |
|
|
36
38
|
| `SPROUTBOAT_BINDINGS_JSON` | The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line. |
|
|
37
39
|
| `SPROUTBOAT_CONFIG_DIR` | Directory for credentials.json (default ~/.config/sproutboat). |
|
|
40
|
+
| `NO_COLOR` | When set, disables coloured terminal output (https://no-color.org). Output is also plain whenever stdout is not a TTY. |
|
|
38
41
|
| `XDG_CONFIG_HOME` | Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset. |
|
|
39
42
|
| `PORFFOR_VERSION` | Override the Porffor identity string recorded in the manifest. |
|
|
40
|
-
| `SB_BROKER_PORT` | Loopback port of the binding broker, read by the compiled
|
|
41
|
-
| `SB_BROKER_TOKEN` | Per-deployment auth token the
|
|
42
|
-
| `
|
|
43
|
+
| `SB_BROKER_PORT` | Loopback port of the binding broker, read by the compiled sprout at runtime (set by the control plane, or by `src/broker.ts` for local runs). |
|
|
44
|
+
| `SB_BROKER_TOKEN` | Per-deployment auth token the sprout sends on every broker frame, and the broker sends back on scheduled/queue triggers (paired with SB_BROKER_PORT). |
|
|
45
|
+
| `SB_SPROUT_URL` | http://127.0.0.1:<PORT> of the sprout; when set, `src/broker.ts` runs the cron scheduler and queue consumer and delivers triggers to it. |
|
|
43
46
|
|
|
44
47
|
## Build toolchain (pinned)
|
|
45
48
|
|
package/package.json
CHANGED
package/src/assets.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* directory next to the artifact and writes `assets.json` (this manifest); the
|
|
4
4
|
* edge serves matching files directly (assets-first, like Cloudflare), and the
|
|
5
5
|
* broker's `assets.get` op backs `env.<ASSETS>.fetch(request)` for the paths
|
|
6
|
-
* the
|
|
6
|
+
* the sprout chooses to serve itself.
|
|
7
7
|
*/
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
import { readdirSync, readFileSync } from "node:fs";
|
package/src/broker.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Per-deployment binding broker.
|
|
4
4
|
*
|
|
5
|
-
* A native-fetch
|
|
5
|
+
* A native-fetch sprout has no syscalls of its own beyond an inbound HTTP
|
|
6
6
|
* server; the prelude's inline-C transport opens a loopback TCP connection to
|
|
7
7
|
* this process for every `env.<KV>` / `env.<SECRET>` / `fetch()` call. The
|
|
8
8
|
* supervisor starts one broker per deployment, on its own loopback port, and
|
|
9
|
-
* passes `SB_BROKER_PORT` + `SB_BROKER_TOKEN` to the
|
|
9
|
+
* passes `SB_BROKER_PORT` + `SB_BROKER_TOKEN` to the sprout next to `$PORT`.
|
|
10
10
|
*
|
|
11
11
|
* Wire frame (both directions): [u32 LE length][payload].
|
|
12
12
|
* request payload : "<token>\n<json>"
|
|
@@ -47,11 +47,11 @@ export type BrokerOptions = {
|
|
|
47
47
|
bindings?: Partial<Bindings>;
|
|
48
48
|
secrets?: Record<string, string>;
|
|
49
49
|
/**
|
|
50
|
-
* `http://127.0.0.1:<PORT>` of this deployment's
|
|
51
|
-
* runs the cron scheduler and the queue consumer, delivering to the
|
|
50
|
+
* `http://127.0.0.1:<PORT>` of this deployment's sprout. When set, the broker
|
|
51
|
+
* runs the cron scheduler and the queue consumer, delivering to the sprout
|
|
52
52
|
* with an `x-sb-trigger` header authenticated by `token`.
|
|
53
53
|
*/
|
|
54
|
-
|
|
54
|
+
sproutUrl?: string;
|
|
55
55
|
/** Directory of published static assets (its sibling `assets.json` is the manifest). Backs `assets.get`. */
|
|
56
56
|
assetsDir?: string;
|
|
57
57
|
/** Injected in tests; defaults to the global `fetch`. */
|
|
@@ -423,15 +423,15 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
423
423
|
}
|
|
424
424
|
}
|
|
425
425
|
|
|
426
|
-
// --- cron + queue delivery (only when this broker knows its
|
|
426
|
+
// --- cron + queue delivery (only when this broker knows its sprout) --------
|
|
427
427
|
const timers: ReturnType<typeof setInterval>[] = [];
|
|
428
428
|
const QUEUE_BATCH = 10;
|
|
429
429
|
const QUEUE_MAX_ATTEMPTS = 5;
|
|
430
430
|
|
|
431
431
|
async function deliverTrigger(kind: "scheduled" | "queue", body: unknown): Promise<Response | null> {
|
|
432
|
-
if (!opts.
|
|
432
|
+
if (!opts.sproutUrl) return null;
|
|
433
433
|
try {
|
|
434
|
-
return await doFetch(opts.
|
|
434
|
+
return await doFetch(opts.sproutUrl, {
|
|
435
435
|
method: "POST",
|
|
436
436
|
headers: { "x-sb-trigger": kind, "x-sb-token": token, "content-type": "application/json" },
|
|
437
437
|
body: JSON.stringify(body),
|
|
@@ -442,7 +442,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
442
442
|
}
|
|
443
443
|
|
|
444
444
|
function drainQueuesOnce(): void {
|
|
445
|
-
if (!opts.
|
|
445
|
+
if (!opts.sproutUrl || bindings.queues.length === 0) return;
|
|
446
446
|
const now = Date.now();
|
|
447
447
|
for (const q of bindings.queues) {
|
|
448
448
|
const rows = db.query<{ id: string; body: string; attempts: number }, [string, number, number]>(
|
|
@@ -458,7 +458,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
458
458
|
queue: q,
|
|
459
459
|
messages: rows.map((r) => ({ id: r.id, body: r.body, timestamp: now, attempts: r.attempts + 1 })),
|
|
460
460
|
}).then(async (res) => {
|
|
461
|
-
let ack: string[] = rows.map((r) => r.id); // default: ack all if the
|
|
461
|
+
let ack: string[] = rows.map((r) => r.id); // default: ack all if the sprout did not say
|
|
462
462
|
let retry: string[] = [];
|
|
463
463
|
if (res && res.ok) {
|
|
464
464
|
try {
|
|
@@ -479,7 +479,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
479
479
|
}
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
-
if (opts.
|
|
482
|
+
if (opts.sproutUrl) {
|
|
483
483
|
if (bindings.queues.length > 0) timers.push(setInterval(drainQueuesOnce, 500));
|
|
484
484
|
if (bindings.crons.length > 0) {
|
|
485
485
|
let lastTick = "";
|
|
@@ -574,7 +574,7 @@ if (import.meta.main) {
|
|
|
574
574
|
"data-dir": { type: "string" },
|
|
575
575
|
bindings: { type: "string" },
|
|
576
576
|
secrets: { type: "string" },
|
|
577
|
-
"
|
|
577
|
+
"sprout-url": { type: "string" },
|
|
578
578
|
"assets-dir": { type: "string" },
|
|
579
579
|
},
|
|
580
580
|
});
|
|
@@ -590,9 +590,9 @@ if (import.meta.main) {
|
|
|
590
590
|
token: values.token ?? process.env.SB_BROKER_TOKEN,
|
|
591
591
|
bindings,
|
|
592
592
|
secrets,
|
|
593
|
-
|
|
593
|
+
sproutUrl: values["sprout-url"] ?? process.env.SB_SPROUT_URL,
|
|
594
594
|
assetsDir: values["assets-dir"],
|
|
595
595
|
});
|
|
596
596
|
const { port } = listen(broker, "127.0.0.1", Number(values.port ?? process.env.SB_BROKER_PORT ?? 0));
|
|
597
|
-
console.log(`sproutboat broker: 127.0.0.1:${port} db=${values.db ?? ":memory:"}
|
|
597
|
+
console.log(`sproutboat broker: 127.0.0.1:${port} db=${values.db ?? ":memory:"} sprout=${values["sprout-url"] ?? process.env.SB_SPROUT_URL ?? "(none)"}`);
|
|
598
598
|
}
|
package/src/build.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { walkAssets, type AssetManifest } from "./assets";
|
|
5
5
|
import type { SproutboatConfig } from "./config";
|
|
6
|
-
import {
|
|
6
|
+
import { compileSprout } from "./compile";
|
|
7
7
|
import { ARTIFACT_SCHEMA_VERSION, CAPABILITY_PROFILE, RUNTIME, type ArtifactManifest } from "./manifest";
|
|
8
8
|
import { ensureZig, esbuildVersion, porfforVersion, toolchainStamp } from "./toolchain";
|
|
9
9
|
|
|
@@ -33,7 +33,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
33
33
|
const sourceHash = digest(source);
|
|
34
34
|
const artifactId = sourceHash.slice("sha256:".length, 24);
|
|
35
35
|
const artifactDir = resolve(input.projectDir, ".sproutboat/dist", artifactId);
|
|
36
|
-
const
|
|
36
|
+
const sproutPath = resolve(artifactDir, "sprout");
|
|
37
37
|
await mkdir(artifactDir, { recursive: true });
|
|
38
38
|
|
|
39
39
|
const bindings = {
|
|
@@ -50,15 +50,15 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
const zigBin = await ensureZig();
|
|
53
|
-
await
|
|
53
|
+
await compileSprout({
|
|
54
54
|
sourcePath: input.sourcePath,
|
|
55
|
-
outPath:
|
|
55
|
+
outPath: sproutPath,
|
|
56
56
|
vars: input.config.vars ?? {},
|
|
57
57
|
bindings,
|
|
58
58
|
zigBin,
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
const
|
|
61
|
+
const sprout = await readFile(sproutPath);
|
|
62
62
|
const manifest: ArtifactManifest = {
|
|
63
63
|
schemaVersion: ARTIFACT_SCHEMA_VERSION,
|
|
64
64
|
project: input.config.name,
|
|
@@ -69,8 +69,8 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
69
69
|
esbuildVersion: esbuildVersion(),
|
|
70
70
|
buildImage: toolchainStamp(),
|
|
71
71
|
sourceHash,
|
|
72
|
-
binaryHash: digest(
|
|
73
|
-
binarySize: (await stat(
|
|
72
|
+
binaryHash: digest(sprout),
|
|
73
|
+
binarySize: (await stat(sproutPath)).size,
|
|
74
74
|
builtAt: new Date().toISOString(),
|
|
75
75
|
};
|
|
76
76
|
await writeFile(resolve(artifactDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
package/src/compile.ts
CHANGED
|
@@ -28,7 +28,7 @@ export type CompileInput = {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
/** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
|
|
31
|
-
export async function
|
|
31
|
+
export async function compileSprout(input: CompileInput): Promise<void> {
|
|
32
32
|
await ensurePorfforPatched();
|
|
33
33
|
|
|
34
34
|
// Seed the Porffor uWebSockets cache from the prebuilt archive in `vendor/` so
|
|
@@ -54,7 +54,7 @@ export async function compileWorker(input: CompileInput): Promise<void> {
|
|
|
54
54
|
|
|
55
55
|
const outDir = dirname(input.outPath);
|
|
56
56
|
await mkdir(outDir, { recursive: true });
|
|
57
|
-
const generatedPath = resolve(outDir, "
|
|
57
|
+
const generatedPath = resolve(outDir, "sprout.generated.js");
|
|
58
58
|
const [source, prelude] = await Promise.all([readFile(input.sourcePath, "utf8"), readFile(preludePath, "utf8")]);
|
|
59
59
|
await writeFile(generatedPath, wrapNativeFetchHandler(source, prelude, input.vars, input.bindings ?? EMPTY_BINDINGS));
|
|
60
60
|
|
package/src/config.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type SproutboatConfig = {
|
|
|
10
10
|
kv_namespaces?: string[];
|
|
11
11
|
/** Secret binding names, exposed as `env.<NAME>` (value fetched at use). */
|
|
12
12
|
secrets?: string[];
|
|
13
|
-
/** Hostnames the
|
|
13
|
+
/** Hostnames the sprout's `fetch()` may reach (exact host match). */
|
|
14
14
|
outbound?: string[];
|
|
15
15
|
/** D1 (SQLite) database binding names, exposed as `env.<NAME>`. */
|
|
16
16
|
d1_databases?: string[];
|
|
@@ -35,7 +35,7 @@ export type AssetsConfig = {
|
|
|
35
35
|
binding?: string;
|
|
36
36
|
/** What to serve when a request matches no file (applied by the broker on `env.<BINDING>.fetch`). */
|
|
37
37
|
not_found_handling?: "none" | "single-page-application" | "404-page";
|
|
38
|
-
/** `true` = run the
|
|
38
|
+
/** `true` = run the sprout before serving any asset; string[] = selective route patterns (`!` negates). */
|
|
39
39
|
run_sprout_first?: boolean | string[];
|
|
40
40
|
};
|
|
41
41
|
|
package/src/main.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { validateManifest, type ArtifactManifest } from "./manifest";
|
|
|
8
8
|
import { printDeployReport } from "./report";
|
|
9
9
|
import { activeApiUrl, savedToken, saveToken } from "./credentials";
|
|
10
10
|
import { usageLine } from "./surface";
|
|
11
|
+
import { amber, bold, dim, leaf, ok, rose } from "./style";
|
|
11
12
|
|
|
12
13
|
const defaultApiUrl = "https://dashboard.sproutboat.com";
|
|
13
14
|
|
|
@@ -70,6 +71,16 @@ function parseUrlResponse(source: string): { url: string } | undefined {
|
|
|
70
71
|
return record && isString(record.url) ? { url: record.url } : undefined;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/** #55: `{ from, to }` when this deploy moves the live version onto a different
|
|
75
|
+
* Porffor pin. Deployed artifacts are frozen at their build-time compiler, so
|
|
76
|
+
* the pin only changes by redeploying — and the alpha compiler's output can
|
|
77
|
+
* differ between pins. */
|
|
78
|
+
function parsePorfforDrift(source: string): { from: string; to: string } | undefined {
|
|
79
|
+
const drift = (() => { try { return jsonObject(parseJsonValue(source))?.porfforDrift; } catch { return undefined; } })();
|
|
80
|
+
const record = drift && jsonObject(drift as JsonValue);
|
|
81
|
+
return record && isString(record.from) && isString(record.to) ? { from: record.from, to: record.to } : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
73
84
|
function parseAuthorization(source: string): CliAuthorization | undefined {
|
|
74
85
|
const record = jsonObject(parseJsonValue(source));
|
|
75
86
|
if (!record || !isString(record.deviceCode) || !isString(record.userCode) || !isString(record.verificationUri) || !isSafeInteger(record.interval) || !isString(record.expiresAt)) return undefined;
|
|
@@ -96,7 +107,7 @@ const starterHandler = `export default {
|
|
|
96
107
|
`;
|
|
97
108
|
|
|
98
109
|
function fail(message: string): never {
|
|
99
|
-
console.error(
|
|
110
|
+
console.error(`${rose("✗")} ${message}`);
|
|
100
111
|
process.exit(1);
|
|
101
112
|
}
|
|
102
113
|
|
|
@@ -137,14 +148,14 @@ async function init(name = "hello") {
|
|
|
137
148
|
|
|
138
149
|
async function check(directory?: string) {
|
|
139
150
|
const project = await readProject(directory);
|
|
140
|
-
console.log(`check passed
|
|
151
|
+
console.log(ok(`check passed — ${project.config.name} (${project.config.main}, native-fetch)`));
|
|
141
152
|
}
|
|
142
153
|
|
|
143
154
|
async function build(directory?: string) {
|
|
144
155
|
const project = await readProject(directory);
|
|
145
|
-
console.log("Compiling the native-fetch server with Porffor + Zig (linux-x86_64, static)
|
|
156
|
+
console.log(dim("Compiling the native-fetch server with Porffor + Zig (linux-x86_64, static)…"));
|
|
146
157
|
const artifact = await buildArtifact({ projectDir: project.directory, config: project.config, sourcePath: project.sourcePath });
|
|
147
|
-
console.log(`
|
|
158
|
+
console.log(ok(`built ${project.config.name}`));
|
|
148
159
|
console.log(artifact.artifactDir);
|
|
149
160
|
return { project, artifact };
|
|
150
161
|
}
|
|
@@ -166,8 +177,8 @@ async function deploy(args: string[]) {
|
|
|
166
177
|
config = built.project.config;
|
|
167
178
|
}
|
|
168
179
|
const manifest = Bun.file(resolve(artifactDir, "manifest.json"));
|
|
169
|
-
const
|
|
170
|
-
if (!(await manifest.exists()) || !(await
|
|
180
|
+
const sprout = Bun.file(resolve(artifactDir, "sprout"));
|
|
181
|
+
if (!(await manifest.exists()) || !(await sprout.exists())) fail("artifact must contain manifest.json and sprout");
|
|
171
182
|
const manifestValidation = validateManifest(await manifest.json());
|
|
172
183
|
if (!manifestValidation.ok) fail(`invalid artifact manifest: ${manifestValidation.errors.join(", ")}`);
|
|
173
184
|
const artifactManifest: ArtifactManifest = manifestValidation.value;
|
|
@@ -176,7 +187,7 @@ async function deploy(args: string[]) {
|
|
|
176
187
|
printDeployReport(
|
|
177
188
|
config ?? { name: projectName, main: "", compatibility_date: "(prebuilt artifact)" },
|
|
178
189
|
artifactManifest,
|
|
179
|
-
new Uint8Array(await
|
|
190
|
+
new Uint8Array(await sprout.arrayBuffer()),
|
|
180
191
|
manifest.size,
|
|
181
192
|
);
|
|
182
193
|
if (dryRun) {
|
|
@@ -191,14 +202,33 @@ async function deploy(args: string[]) {
|
|
|
191
202
|
if (!deployments) fail("could not parse deployment list response");
|
|
192
203
|
const active = deployments.find((deployment) => deployment.active && deployment.artifact === digest);
|
|
193
204
|
if (active) {
|
|
194
|
-
console.log(`
|
|
205
|
+
console.log(ok(`nothing to deploy — artifact ${digest.slice(0, 12)} is already active`));
|
|
195
206
|
console.log(`https://${active.hostname}`);
|
|
196
207
|
return;
|
|
197
208
|
}
|
|
198
209
|
}
|
|
199
210
|
const form = new FormData();
|
|
200
211
|
form.set("manifest", new File([await manifest.arrayBuffer()], "manifest.json", { type: "application/json" }));
|
|
201
|
-
form.set("
|
|
212
|
+
form.set("sprout", new File([await sprout.arrayBuffer()], "sprout", { type: "application/octet-stream" }));
|
|
213
|
+
|
|
214
|
+
// #1 — ship the sidecars `sproutboat build` produced so the server can start
|
|
215
|
+
// the binding broker and serve static assets. Without bindings.json the broker
|
|
216
|
+
// never spawns and every KV/D1/R2/secret/queue/cron/DO call is dead on the box.
|
|
217
|
+
const bindingsFile = Bun.file(resolve(artifactDir, "bindings.json"));
|
|
218
|
+
if (await bindingsFile.exists()) {
|
|
219
|
+
form.set("bindings", new File([await bindingsFile.arrayBuffer()], "bindings.json", { type: "application/json" }));
|
|
220
|
+
}
|
|
221
|
+
const assetsManifestFile = Bun.file(resolve(artifactDir, "assets.json"));
|
|
222
|
+
if (await assetsManifestFile.exists()) {
|
|
223
|
+
const assetsManifest: { files?: Record<string, unknown> } = await assetsManifestFile.json();
|
|
224
|
+
form.set("assets_manifest", new File([await assetsManifestFile.arrayBuffer()], "assets.json", { type: "application/json" }));
|
|
225
|
+
for (const key of Object.keys(assetsManifest.files ?? {})) {
|
|
226
|
+
const file = Bun.file(resolve(artifactDir, "assets", `.${key}`));
|
|
227
|
+
if (!(await file.exists())) fail(`assets.json lists ${key} but assets${key} is missing — rebuild`);
|
|
228
|
+
form.append("asset", new File([await file.arrayBuffer()], key));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
202
232
|
const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api/projects/${projectName}/deployments`, {
|
|
203
233
|
method: "POST",
|
|
204
234
|
headers: { "x-api-key": token },
|
|
@@ -207,8 +237,40 @@ async function deploy(args: string[]) {
|
|
|
207
237
|
const body = await responseText(response, "deployment rejected");
|
|
208
238
|
const deployed = parseUrlResponse(body);
|
|
209
239
|
if (!deployed) fail("deployment response did not include a URL");
|
|
210
|
-
console.log(`\
|
|
211
|
-
console.log(` ${deployed.url}`);
|
|
240
|
+
console.log(`\n${leaf("🌱")} ${bold(leaf(`Deployed ${projectName}`))}`);
|
|
241
|
+
console.log(` ${bold(deployed.url)}`);
|
|
242
|
+
const drift = parsePorfforDrift(body);
|
|
243
|
+
if (drift) {
|
|
244
|
+
console.warn(amber(`\n! Porffor pin changed: ${drift.from} -> ${drift.to}`));
|
|
245
|
+
console.warn(dim(` The previous live version stays frozen at ${drift.from}; this one is built with ${drift.to}.`));
|
|
246
|
+
console.warn(dim(` The alpha compiler's output can differ between pins (see COMPAT.md) — roll back if this version misbehaves.`));
|
|
247
|
+
}
|
|
248
|
+
if (!args.includes("--no-wait")) {
|
|
249
|
+
const healthy = await waitForHealthy(deployed.url, 90_000);
|
|
250
|
+
console.log(healthy
|
|
251
|
+
? ` ${ok("serving")}`
|
|
252
|
+
: amber(" ! not serving after 90s — Caddy may still be issuing the cert, or the sprout is crashing (`sproutboat tail`)"));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Poll the deployment URL until the edge returns any non-5xx response. Connection
|
|
258
|
+
* and TLS errors (the cert is issued on the first HTTPS request) count as "not
|
|
259
|
+
* ready yet". Returns false on timeout without failing the deploy — the artifact
|
|
260
|
+
* is already active server-side.
|
|
261
|
+
*/
|
|
262
|
+
async function waitForHealthy(url: string, timeoutMs: number): Promise<boolean> {
|
|
263
|
+
const deadline = Date.now() + timeoutMs;
|
|
264
|
+
let wait = 1000;
|
|
265
|
+
while (Date.now() < deadline) {
|
|
266
|
+
try {
|
|
267
|
+
const response = await fetch(url, { method: "HEAD", redirect: "manual" });
|
|
268
|
+
if (response.status < 500) return true;
|
|
269
|
+
} catch { /* DNS / TLS-not-yet-issued / connection refused — keep waiting */ }
|
|
270
|
+
await Bun.sleep(Math.min(wait, Math.max(0, deadline - Date.now())));
|
|
271
|
+
if (wait < 5000) wait += 1000;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
212
274
|
}
|
|
213
275
|
|
|
214
276
|
function parseLoginArgs(args: string[]) {
|
|
@@ -255,7 +317,7 @@ async function login(args: string[]) {
|
|
|
255
317
|
const token = parseToken(result);
|
|
256
318
|
if (!token) fail("login response did not include a CLI token");
|
|
257
319
|
await saveToken(apiUrl, token);
|
|
258
|
-
console.log("
|
|
320
|
+
console.log(ok("login approved — credentials saved for this endpoint"));
|
|
259
321
|
return;
|
|
260
322
|
}
|
|
261
323
|
fail("login expired before approval");
|
|
@@ -284,22 +346,131 @@ async function rollback(args: string[]) {
|
|
|
284
346
|
const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${id}/activate`, { method: "POST", headers: { "x-api-key": token } });
|
|
285
347
|
const deployment = parseUrlResponse(await responseText(response, "rollback rejected"));
|
|
286
348
|
if (!deployment) fail("rollback response did not include a URL");
|
|
287
|
-
console.log(`
|
|
349
|
+
console.log(ok(`rolled back ${project.config.name}`));
|
|
288
350
|
console.log(deployment.url);
|
|
289
351
|
}
|
|
290
352
|
|
|
291
353
|
async function tail(args: string[]) {
|
|
292
|
-
const
|
|
293
|
-
const
|
|
354
|
+
const sproutLog = args.includes("--sprout");
|
|
355
|
+
const dir = args.find((arg) => !arg.startsWith("-"));
|
|
356
|
+
const [project, { apiUrl, token }] = await Promise.all([readProject(dir), apiCredentials()]);
|
|
357
|
+
const path = sproutLog ? "logs/sprout" : "logs/recent";
|
|
358
|
+
const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/${path}`, { headers: { "x-api-key": token } });
|
|
294
359
|
process.stdout.write(await responseText(response, "could not read logs"));
|
|
295
360
|
}
|
|
296
361
|
|
|
362
|
+
type DomainView = { hostname: string; verified: boolean; verification: { type: string; name: string; value: string } | null };
|
|
363
|
+
function parseDomain(source: string): DomainView | undefined {
|
|
364
|
+
const record = jsonObject(parseJsonValue(source));
|
|
365
|
+
if (!record || !isString(record.hostname) || typeof record.verified !== "boolean") return undefined;
|
|
366
|
+
const v = jsonObject(record.verification ?? null);
|
|
367
|
+
const verification = v && isString(v.type) && isString(v.name) && isString(v.value) ? { type: v.type, name: v.name, value: v.value } : null;
|
|
368
|
+
return { hostname: record.hostname, verified: record.verified, verification };
|
|
369
|
+
}
|
|
370
|
+
function printDomain(domain: DomainView) {
|
|
371
|
+
const status = domain.verified ? "verified" : "unverified";
|
|
372
|
+
console.log(`${status.padEnd(10)} ${domain.hostname}`);
|
|
373
|
+
if (domain.verification) {
|
|
374
|
+
console.log(` add this DNS record, then run: sproutboat domains verify ${domain.hostname}`);
|
|
375
|
+
console.log(` ${domain.verification.type} ${domain.verification.name} "${domain.verification.value}"`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function domains(args: string[]) {
|
|
380
|
+
const sub = args[0] && !args[0].startsWith("-") && ["list", "add", "verify", "rm"].includes(args[0]) ? args.shift()! : "list";
|
|
381
|
+
const host = sub === "list" ? undefined : args.shift();
|
|
382
|
+
if (sub !== "list" && !host) fail(`usage: sproutboat domains ${sub} <hostname> [project-dir]`);
|
|
383
|
+
const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
|
|
384
|
+
const base = `${apiUrl}/api/projects/${project.config.name}/domains`;
|
|
385
|
+
const auth = { "x-api-key": token };
|
|
386
|
+
|
|
387
|
+
if (sub === "list") {
|
|
388
|
+
const response = await fetch(base, { headers: auth });
|
|
389
|
+
const body = await responseText(response, "could not list domains");
|
|
390
|
+
const list = parseJsonValue(body);
|
|
391
|
+
if (!Array.isArray(list)) fail("could not parse domains response");
|
|
392
|
+
if (list.length === 0) { console.log("no custom domains"); return; }
|
|
393
|
+
for (const entry of list) { const d = parseDomain(JSON.stringify(entry)); if (d) printDomain(d); }
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (sub === "rm") {
|
|
397
|
+
const response = await fetch(`${base}/${host}`, { method: "DELETE", headers: auth });
|
|
398
|
+
await responseText(response, "delete rejected");
|
|
399
|
+
console.log(ok(`removed ${host}`));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const url = sub === "add" ? base : `${base}/${host}/verify`;
|
|
403
|
+
const init = sub === "add"
|
|
404
|
+
? { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ hostname: host }) }
|
|
405
|
+
: { method: "POST", headers: auth };
|
|
406
|
+
const response = await fetch(url, init);
|
|
407
|
+
const domain = parseDomain(await responseText(response, `${sub} rejected`));
|
|
408
|
+
if (!domain) fail(`${sub} response was not a domain record`);
|
|
409
|
+
printDomain(domain);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function secrets(args: string[]) {
|
|
413
|
+
const sub = args[0] && ["list", "set", "rm"].includes(args[0]) ? args.shift()! : "list";
|
|
414
|
+
const name = sub === "list" ? undefined : args.shift();
|
|
415
|
+
if (sub !== "list" && !name) fail(`usage: sproutboat secrets ${sub} <NAME> [project-dir]`);
|
|
416
|
+
if (name && !/^[A-Z][A-Z0-9_]*$/.test(name)) fail("secret name must be UPPER_SNAKE_CASE");
|
|
417
|
+
const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
|
|
418
|
+
const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
|
|
419
|
+
const auth = { "x-api-key": token };
|
|
420
|
+
|
|
421
|
+
if (sub === "list") {
|
|
422
|
+
const body = await responseText(await fetch(base, { headers: auth }), "could not list secrets");
|
|
423
|
+
const parsed = jsonObject(parseJsonValue(body));
|
|
424
|
+
const names = parsed && Array.isArray(parsed.names) ? parsed.names.filter(isString) : [];
|
|
425
|
+
console.log(names.length ? names.join("\n") : "no secrets");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (sub === "rm") {
|
|
429
|
+
await responseText(await fetch(`${base}/${name}`, { method: "DELETE", headers: auth }), "delete rejected");
|
|
430
|
+
console.log(ok(`removed ${name}`));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
// set: value from the next arg, else stdin (keeps it out of shell history).
|
|
434
|
+
const value = args[1] && !args[1].startsWith("-") && args[1] !== project.directory
|
|
435
|
+
? args[1]
|
|
436
|
+
: (await Bun.stdin.text()).replace(/\r?\n$/, "");
|
|
437
|
+
if (!value) fail("no value — pass it as an argument or pipe it on stdin");
|
|
438
|
+
await responseText(
|
|
439
|
+
await fetch(`${base}/${name}`, { method: "PUT", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ value }) }),
|
|
440
|
+
"set rejected",
|
|
441
|
+
);
|
|
442
|
+
console.log(`Set ${name} — applies on the next deploy or sprout restart`);
|
|
443
|
+
}
|
|
444
|
+
|
|
297
445
|
async function deleteProject(args: string[]) {
|
|
298
|
-
|
|
299
|
-
const [
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
446
|
+
// `sproutboat delete [project-dir] [--name <project>] --yes` — flags in any order.
|
|
447
|
+
const positional: string[] = [];
|
|
448
|
+
let confirmed = false;
|
|
449
|
+
let explicitName: string | undefined;
|
|
450
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
451
|
+
const arg = args[index];
|
|
452
|
+
if (arg === "--yes" || arg === "-y") confirmed = true;
|
|
453
|
+
else if (arg === "--name") explicitName = args[(index += 1)];
|
|
454
|
+
else if (!arg.startsWith("-")) positional.push(arg);
|
|
455
|
+
else fail(`unknown flag ${arg}\nusage: sproutboat delete [project-dir] [--name <project>] --yes`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const { apiUrl, token } = await apiCredentials();
|
|
459
|
+
const name = explicitName ?? (await readProject(positional[0])).config.name;
|
|
460
|
+
if (!confirmed) fail(`this permanently removes "${name}", every version, and its route — re-run with --yes`);
|
|
461
|
+
|
|
462
|
+
const url = `${apiUrl}/api/projects/${encodeURIComponent(name)}?confirm=${encodeURIComponent(name)}`;
|
|
463
|
+
const body = await responseText(await fetch(url, { method: "DELETE", headers: { "x-api-key": token } }), "delete rejected");
|
|
464
|
+
|
|
465
|
+
let result: JsonObject = {};
|
|
466
|
+
try { result = jsonObject(parseJsonValue(body)) ?? {}; } catch { /* a 2xx already confirmed the delete */ }
|
|
467
|
+
const versions = isSafeInteger(result.versionsRemoved) ? result.versionsRemoved : 0;
|
|
468
|
+
const routes = Array.isArray(result.routeRemoved) ? result.routeRemoved.filter(isString) : [];
|
|
469
|
+
const failed = Array.isArray(result.artifactCleanupFailed) ? result.artifactCleanupFailed.filter(isString) : [];
|
|
470
|
+
|
|
471
|
+
console.log(ok(`deleted ${name} — ${versions} version${versions === 1 ? "" : "s"} removed`));
|
|
472
|
+
for (const route of routes) console.log(` released ${route}`);
|
|
473
|
+
if (failed.length) console.log(` ! ${failed.length} artifact file(s) left on disk — remove them manually`);
|
|
303
474
|
}
|
|
304
475
|
|
|
305
476
|
function usage(): never {
|
|
@@ -316,6 +487,8 @@ switch (command) {
|
|
|
316
487
|
case "deploy": await deploy(args); break;
|
|
317
488
|
case "versions": await versions(args); break;
|
|
318
489
|
case "rollback": await rollback(args); break;
|
|
490
|
+
case "domains": await domains(args); break;
|
|
491
|
+
case "secrets": await secrets(args); break;
|
|
319
492
|
case "tail": await tail(args); break;
|
|
320
493
|
case "delete": await deleteProject(args); break;
|
|
321
494
|
default: usage();
|
|
@@ -31,6 +31,44 @@ function __sbStartupMark() {
|
|
|
31
31
|
}
|
|
32
32
|
__sbStartupMark();
|
|
33
33
|
|
|
34
|
+
// #28 — process CPU time in ms (CLOCK_PROCESS_CPUTIME_ID). Marshalled back as a
|
|
35
|
+
// string via the same primitives as __sbEnv / __sbRandomBytes (proven working),
|
|
36
|
+
// then parsed — Porffor's number boxing for a bare inline-C assignment is not
|
|
37
|
+
// relied on. `__sbEntry` samples it around the handler for per-invocation CPU.
|
|
38
|
+
function __sbCpuMs() {
|
|
39
|
+
let res = '';
|
|
40
|
+
Porffor.c`
|
|
41
|
+
struct timespec __ts;
|
|
42
|
+
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &__ts);
|
|
43
|
+
double __ms = (double)__ts.tv_sec * 1000.0 + (double)__ts.tv_nsec / 1000000.0;
|
|
44
|
+
char __b[32];
|
|
45
|
+
int __n = snprintf(__b, sizeof(__b), "%.3f", __ms);
|
|
46
|
+
if (__n > 0) res = porf_box((f64)porf_native_fetch_alloc_bytestring(__b, (size_t)__n), 195);
|
|
47
|
+
`;
|
|
48
|
+
return res === '' ? 0 : parseFloat(res);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// #28 — stamp `x-sb-cpu-ms` onto a handler Response. Porffor alpha-4's
|
|
52
|
+
// native-fetch serializer only reads headers from the plain object passed to
|
|
53
|
+
// `new Response(body, { headers })` — a later `.set()` or a `Headers` instance
|
|
54
|
+
// is ignored on the wire — so the metric is carried by rebuilding the Response
|
|
55
|
+
// with one extra header. Safe only for a string body (the norm under
|
|
56
|
+
// http-sync-v0) with no Set-Cookie to comma-fold; anything else is returned
|
|
57
|
+
// untouched and the edge simply omits cpuMs for that request.
|
|
58
|
+
function __sbTagCpu(res, t0) {
|
|
59
|
+
const cpu = __sbCpuMs() - t0;
|
|
60
|
+
try {
|
|
61
|
+
const body = res && res.body;
|
|
62
|
+
if (typeof body === 'string' && !res.headers.has('set-cookie')) {
|
|
63
|
+
const headers = {};
|
|
64
|
+
res.headers.forEach(function (value, name) { headers[name] = value; });
|
|
65
|
+
headers['x-sb-cpu-ms'] = (cpu >= 0 ? cpu : 0).toFixed(3);
|
|
66
|
+
return new Response(body, { status: res.status, headers: headers });
|
|
67
|
+
}
|
|
68
|
+
} catch (e) { /* fall through to the original response */ }
|
|
69
|
+
return res;
|
|
70
|
+
}
|
|
71
|
+
|
|
34
72
|
class __SproutboatURLSearchParams {
|
|
35
73
|
constructor(init) {
|
|
36
74
|
this._keys = [];
|
|
@@ -181,13 +219,13 @@ if (globalThis.crypto.randomUUID == null) {
|
|
|
181
219
|
// Bindings: env.<KV>, env.<SECRET>, env.<D1>, env.<R2>, and globalThis.fetch,
|
|
182
220
|
// backed by a Bun broker on a loopback TCP port. The transport is inline C —
|
|
183
221
|
// blocking write/read per call over ONE long-lived connection (http-sync-v0: one
|
|
184
|
-
//
|
|
222
|
+
// sprout event-loop turn per request, so a blocking roundtrip is acceptable).
|
|
185
223
|
// Wire frame:
|
|
186
224
|
// [u32 LE len][ <token> "\n" <json> ] reply: [u32 LE len][ <json> ]
|
|
187
225
|
// SB_BROKER_PORT / SB_BROKER_TOKEN are set by the supervisor next to $PORT.
|
|
188
226
|
// If SB_BROKER_PORT is unset the shims below are never installed (compile.ts
|
|
189
227
|
// only emits the __sbInstallBindings call when the project declares bindings),
|
|
190
|
-
// so a plain
|
|
228
|
+
// so a plain sprout is byte-for-byte unchanged.
|
|
191
229
|
// ponytail: text values only; still AF_INET loopback, not AF_UNIX. A failed
|
|
192
230
|
// exchange reconnects and resends once — a broker crash between "request applied"
|
|
193
231
|
// and "reply read" can double-apply a non-idempotent op (queue.send, INSERT);
|
|
@@ -252,7 +290,7 @@ static int sb_broker_fd = -1;
|
|
|
252
290
|
static int sb_broker_connect(void) {
|
|
253
291
|
const char* port_s = getenv("SB_BROKER_PORT");
|
|
254
292
|
if (!port_s) return -10;
|
|
255
|
-
signal(SIGPIPE, SIG_IGN); // a dead broker must yield EPIPE, not kill the
|
|
293
|
+
signal(SIGPIPE, SIG_IGN); // a dead broker must yield EPIPE, not kill the sprout
|
|
256
294
|
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
|
257
295
|
if (fd < 0) return -1;
|
|
258
296
|
int one = 1;
|
|
@@ -422,7 +460,7 @@ function __sbMakeD1(dbName) {
|
|
|
422
460
|
}
|
|
423
461
|
|
|
424
462
|
// R2: a Cloudflare-shaped object. When `body` is present the sync accessors
|
|
425
|
-
// mirror R2ObjectBody's async ones (a
|
|
463
|
+
// mirror R2ObjectBody's async ones (a sprout may `await` them harmlessly).
|
|
426
464
|
function __sbR2Object(meta, body) {
|
|
427
465
|
const obj = {
|
|
428
466
|
key: meta.key,
|
|
@@ -572,7 +610,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
572
610
|
}
|
|
573
611
|
|
|
574
612
|
// Static assets: env.<ASSETS>.fetch(request) -> broker `assets.get`. The edge
|
|
575
|
-
// already serves matching files directly; the
|
|
613
|
+
// already serves matching files directly; the sprout only calls this for paths
|
|
576
614
|
// it wants to own (SPA fallback, auth-gated files). Text assets only — binary
|
|
577
615
|
// files go through the edge (the broker frame is UTF-8 JSON).
|
|
578
616
|
if (bindings.assets) {
|
|
@@ -612,13 +650,13 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
612
650
|
};
|
|
613
651
|
|
|
614
652
|
// ---------------------------------------------------------------------------
|
|
615
|
-
// Durable Objects. The class runs here in the sandboxed
|
|
616
|
-
// one
|
|
653
|
+
// Durable Objects. The class runs here in the sandboxed sprout. There is exactly
|
|
654
|
+
// one sprout process per deployment (the supervisor model) and the native-fetch
|
|
617
655
|
// runtime processes one turn at a time, so calls to a given object id are
|
|
618
656
|
// already serialized — `env.<NS>.get(id).fetch()` invokes the instance directly,
|
|
619
657
|
// no round-trip. Only `state.storage.*` goes to the broker (so object state
|
|
620
|
-
// outlives a
|
|
621
|
-
// ponytail: serialization relies on the single
|
|
658
|
+
// outlives a sprout restart), scoped to (class, id).
|
|
659
|
+
// ponytail: serialization relies on the single sprout process; a multi-sprout
|
|
622
660
|
// deployment needs the broker to hold a per-id lock (cloud). Storage ops are one
|
|
623
661
|
// key at a time; blockConcurrencyWhile just runs the fn.
|
|
624
662
|
|
|
@@ -747,7 +785,19 @@ function __sbTriggerAuthed(request) {
|
|
|
747
785
|
|
|
748
786
|
globalThis.__sbEntry = function (handlers, request) {
|
|
749
787
|
const trigger = request.headers.get('x-sb-trigger');
|
|
750
|
-
if (!trigger)
|
|
788
|
+
if (!trigger) {
|
|
789
|
+
// #28 — per-invocation CPU time. One fetch turn per process (serial), so the
|
|
790
|
+
// process CPU delta across the handler is this invocation's CPU. Covers both
|
|
791
|
+
// sync handlers and async ones (via `.then`); the delta spans the whole turn.
|
|
792
|
+
// ponytail: serial-turn assumption; revisit if the profile ever allows
|
|
793
|
+
// concurrent in-process requests.
|
|
794
|
+
const __t0 = __sbCpuMs();
|
|
795
|
+
const __res = handlers.fetch(request);
|
|
796
|
+
if (__res && typeof __res.then === 'function') {
|
|
797
|
+
return __res.then(function (resolved) { return __sbTagCpu(resolved, __t0); });
|
|
798
|
+
}
|
|
799
|
+
return __sbTagCpu(__res, __t0);
|
|
800
|
+
}
|
|
751
801
|
if (!__sbTriggerAuthed(request)) return new Response('forbidden', { status: 403 });
|
|
752
802
|
|
|
753
803
|
if (trigger === 'scheduled') {
|
package/src/report.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { gzipSync } from "bun";
|
|
2
3
|
import type { SproutboatConfig } from "./config";
|
|
3
4
|
import type { ArtifactManifest } from "./manifest";
|
|
5
|
+
import { bold, dim, leaf, sprout } from "./style";
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
// Read from package.json so the banner never drifts from the published version.
|
|
8
|
+
const CLI_VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
6
9
|
|
|
7
10
|
function bytes(n: number): string {
|
|
8
11
|
if (n < 1024) return `${n} B`;
|
|
@@ -11,55 +14,75 @@ function bytes(n: number): string {
|
|
|
11
14
|
return `${(kib / 1024).toFixed(2)} MiB`;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
/** Minimal box table. `align` marks columns to right-pad-left (numbers). */
|
|
17
|
+
/** Minimal box table with a green frame. `align` marks columns to right-pad-left (numbers). */
|
|
15
18
|
function table(headers: string[], rows: string[][], align: boolean[] = []): string {
|
|
16
19
|
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
17
|
-
const
|
|
18
|
-
const row = (cells: string[]) =>
|
|
19
|
-
"│ " + cells.map((c, i) =>
|
|
20
|
-
|
|
20
|
+
const rule = (l: string, m: string, r: string) => leaf(l + widths.map((w) => "─".repeat(w + 2)).join(m) + r);
|
|
21
|
+
const row = (cells: string[], head = false) =>
|
|
22
|
+
leaf("│ ") + cells.map((c, i) => {
|
|
23
|
+
const cell = align[i] ? (c ?? "").padStart(widths[i]) : (c ?? "").padEnd(widths[i]);
|
|
24
|
+
return head ? bold(cell) : cell;
|
|
25
|
+
}).join(leaf(" │ ")) + leaf(" │");
|
|
26
|
+
return [rule("┌", "┬", "┐"), row(headers, true), rule("├", "┼", "┤"), ...rows.map((r) => row(r)), rule("└", "┴", "┘")].join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Every binding the compiled sprout will see, flattened to (name, type, detail) rows. */
|
|
30
|
+
function bindingRows(config: SproutboatConfig): string[][] {
|
|
31
|
+
const rows: string[][] = [];
|
|
32
|
+
for (const [name, value] of Object.entries(config.vars ?? {})) rows.push([`env.${name}`, "var", JSON.stringify(value)]);
|
|
33
|
+
const list = (names: string[] | undefined, type: string, detail = "") => {
|
|
34
|
+
for (const name of names ?? []) rows.push([`env.${name}`, type, detail]);
|
|
35
|
+
};
|
|
36
|
+
list(config.kv_namespaces, "kv");
|
|
37
|
+
list(config.secrets, "secret", "value withheld — set with `sproutboat secrets`");
|
|
38
|
+
list(config.d1_databases, "d1");
|
|
39
|
+
list(config.r2_buckets, "r2");
|
|
40
|
+
list(config.queues, "queue");
|
|
41
|
+
list(config.analytics_engine_datasets, "analytics");
|
|
42
|
+
for (const [name, className] of Object.entries(config.durable_objects ?? {})) rows.push([`env.${name}`, "durable object", className]);
|
|
43
|
+
for (const host of config.outbound ?? []) rows.push([`fetch()`, "outbound", host]);
|
|
44
|
+
for (const cron of config.triggers?.crons ?? []) rows.push([`scheduled()`, "cron", cron]);
|
|
45
|
+
if (config.assets?.binding) rows.push([`env.${config.assets.binding}`, "assets", config.assets.directory ?? ""]);
|
|
46
|
+
return rows;
|
|
21
47
|
}
|
|
22
48
|
|
|
23
49
|
/**
|
|
24
|
-
* Wrangler-shaped build/deploy summary
|
|
25
|
-
*
|
|
50
|
+
* Wrangler-shaped build/deploy summary: what the artifact contains, its upload
|
|
51
|
+
* size, and every binding the handler will see. Returns nothing.
|
|
26
52
|
*/
|
|
27
53
|
export function printDeployReport(
|
|
28
54
|
config: SproutboatConfig,
|
|
29
55
|
manifest: ArtifactManifest,
|
|
30
|
-
|
|
56
|
+
sproutBin: Uint8Array,
|
|
31
57
|
manifestBytes: number,
|
|
32
58
|
): void {
|
|
33
|
-
const gz = gzipSync(Uint8Array.from(
|
|
34
|
-
const total =
|
|
59
|
+
const gz = gzipSync(Uint8Array.from(sproutBin)).length;
|
|
60
|
+
const total = sproutBin.length + manifestBytes;
|
|
35
61
|
|
|
36
|
-
console.log(`\n🌱 sproutboat ${CLI_VERSION}`);
|
|
37
|
-
console.log("─".repeat(19));
|
|
38
|
-
console.log(`Compiled ${manifest.project} with Porffor ${manifest.porfforVersion}`);
|
|
39
|
-
console.log(` toolchain ${manifest.buildImage}`);
|
|
40
|
-
console.log(` compat ${config.compatibility_date}`);
|
|
62
|
+
console.log(`\n${sprout("🌱")} ${bold(leaf(`sproutboat ${CLI_VERSION}`))}`);
|
|
63
|
+
console.log(leaf("─".repeat(19)));
|
|
64
|
+
console.log(`Compiled ${bold(manifest.project)} with Porffor ${manifest.porfforVersion}`);
|
|
65
|
+
console.log(dim(` toolchain ${manifest.buildImage}`));
|
|
66
|
+
console.log(dim(` compat ${config.compatibility_date}`));
|
|
41
67
|
console.log();
|
|
42
68
|
|
|
43
|
-
console.log("Artifact
|
|
69
|
+
console.log(bold("Artifact"));
|
|
44
70
|
console.log(table(
|
|
45
71
|
["File", "Type", "Size"],
|
|
46
72
|
[
|
|
47
|
-
["
|
|
73
|
+
["sprout", manifest.runtime, bytes(sproutBin.length)],
|
|
48
74
|
["manifest.json", "json", bytes(manifestBytes)],
|
|
49
75
|
],
|
|
50
76
|
[false, false, true],
|
|
51
77
|
));
|
|
52
|
-
console.log(`Total upload: ${bytes(total)} (
|
|
78
|
+
console.log(`Total upload: ${bold(bytes(total))} ${dim(`(sprout gzip: ${bytes(gz)})`)}`);
|
|
53
79
|
console.log();
|
|
54
80
|
|
|
55
|
-
const
|
|
56
|
-
console.log("Bindings the handler will see
|
|
57
|
-
if (
|
|
58
|
-
console.log(" (none — add
|
|
81
|
+
const rows = bindingRows(config);
|
|
82
|
+
console.log(bold("Bindings the handler will see"));
|
|
83
|
+
if (rows.length === 0) {
|
|
84
|
+
console.log(dim(" (none — add vars / kv_namespaces / secrets / … to sproutboat.jsonc)"));
|
|
59
85
|
} else {
|
|
60
|
-
console.log(table(
|
|
61
|
-
["Binding", "Type", "Value"],
|
|
62
|
-
vars.map(([k, v]) => [`env.${k}`, "var", JSON.stringify(v)]),
|
|
63
|
-
));
|
|
86
|
+
console.log(table(["Binding", "Type", "Detail"], rows));
|
|
64
87
|
}
|
|
65
88
|
}
|
package/src/style.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Green/leaf terminal styling. Every helper is a no-op when stdout is not a TTY
|
|
3
|
+
* or NO_COLOR is set (https://no-color.org), so piped/CI output stays plain.
|
|
4
|
+
*/
|
|
5
|
+
const enabled = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
6
|
+
const paint = (code: string) => (text: string): string => (enabled ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
7
|
+
|
|
8
|
+
export const leaf = paint("32"); // green — headings, structure, the accent
|
|
9
|
+
export const sprout = paint("92"); // bright green — success
|
|
10
|
+
export const dim = paint("2"); // secondary detail
|
|
11
|
+
export const bold = paint("1");
|
|
12
|
+
export const amber = paint("33"); // warnings
|
|
13
|
+
export const rose = paint("31"); // errors
|
|
14
|
+
|
|
15
|
+
/** "✓ message" with a green tick. */
|
|
16
|
+
export const ok = (message: string): string => `${sprout("✓")} ${message}`;
|
|
17
|
+
/** "▸ message" with a green marker — section headers. */
|
|
18
|
+
export const step = (message: string): string => `${leaf("▸")} ${bold(message)}`;
|
package/src/surface.ts
CHANGED
|
@@ -10,13 +10,15 @@ export type Command = { name: string; args: string; summary: string };
|
|
|
10
10
|
export const COMMANDS: readonly Command[] = [
|
|
11
11
|
{ name: "init", args: "[name]", summary: "Scaffold sproutboat.jsonc + src/index.js in ./<name>." },
|
|
12
12
|
{ name: "check", args: "[project-dir]", summary: "Validate the config and entry point without building." },
|
|
13
|
-
{ name: "build", args: "[project-dir]", summary: "Cross-compile the native-fetch
|
|
14
|
-
{ name: "deploy", args: "[project-dir] [--dry-run] [--artifact <dir>]", summary: "Build (unless --artifact), print the report, upload. --dry-run stops before upload." },
|
|
13
|
+
{ name: "build", args: "[project-dir]", summary: "Cross-compile the native-fetch sprout (Porffor + Zig)." },
|
|
14
|
+
{ name: "deploy", args: "[project-dir] [--dry-run] [--artifact <dir>] [--no-wait]", summary: "Build (unless --artifact), print the report, upload, wait until the URL serves. --dry-run stops before upload; --no-wait skips the health check." },
|
|
15
15
|
{ name: "login", args: "[--api-url <url>] [--token <token>]", summary: "Device-code browser flow, or store <token> for <url> directly." },
|
|
16
|
-
{ name: "tail", args: "[project-dir]", summary: "Print the
|
|
16
|
+
{ name: "tail", args: "[project-dir] [--sprout]", summary: "Print recent request logs; --sprout prints the running sprout + broker stdout/stderr instead." },
|
|
17
17
|
{ name: "versions", args: "list [project-dir]", summary: "List the project's deployed versions." },
|
|
18
18
|
{ name: "rollback", args: "<version-id> [project-dir]", summary: "Re-activate a previous version." },
|
|
19
|
-
{ name: "
|
|
19
|
+
{ name: "domains", args: "[list | add <host> | verify <host> | rm <host>] [project-dir]", summary: "Attach a custom domain to the project (TXT-verified). No sub-command lists." },
|
|
20
|
+
{ name: "secrets", args: "[list | set <NAME> [value] | rm <NAME>] [project-dir]", summary: "Manage encrypted project secrets (read as env.NAME). `set` takes the value from the arg or stdin; applies on next deploy." },
|
|
21
|
+
{ name: "delete", args: "[project-dir] [--name <project>] --yes", summary: "Delete the project, every version, and its route." },
|
|
20
22
|
];
|
|
21
23
|
|
|
22
24
|
export type EnvVar = { name: string; purpose: string };
|
|
@@ -27,14 +29,15 @@ export const ENV_VARS: readonly EnvVar[] = [
|
|
|
27
29
|
{ name: "SPROUTBOAT_ZIG", purpose: "Path to a Zig binary to use instead of downloading the pinned one." },
|
|
28
30
|
{ name: "SPROUTBOAT_UWS_TARBALL", purpose: "Path to a prebuilt uWebSockets (x86_64-linux-musl) tarball to seed the Porffor cache with, instead of downloading it (removes the first-build git + make need)." },
|
|
29
31
|
{ name: "SPROUTBOAT_COMPILE_TIMEOUT_MS", purpose: "Porffor compile timeout in ms (default 600000)." },
|
|
30
|
-
{ name: "SPROUTBOAT_VARS_JSON", purpose: "JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the
|
|
32
|
+
{ name: "SPROUTBOAT_VARS_JSON", purpose: "JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the sprout module." },
|
|
31
33
|
{ name: "SPROUTBOAT_BINDINGS_JSON", purpose: "The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line." },
|
|
32
34
|
{ name: "SPROUTBOAT_CONFIG_DIR", purpose: "Directory for credentials.json (default ~/.config/sproutboat)." },
|
|
35
|
+
{ name: "NO_COLOR", purpose: "When set, disables coloured terminal output (https://no-color.org). Output is also plain whenever stdout is not a TTY." },
|
|
33
36
|
{ name: "XDG_CONFIG_HOME", purpose: "Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset." },
|
|
34
37
|
{ name: "PORFFOR_VERSION", purpose: "Override the Porffor identity string recorded in the manifest." },
|
|
35
|
-
{ name: "SB_BROKER_PORT", purpose: "Loopback port of the binding broker, read by the compiled
|
|
36
|
-
{ name: "SB_BROKER_TOKEN", purpose: "Per-deployment auth token the
|
|
37
|
-
{ name: "
|
|
38
|
+
{ name: "SB_BROKER_PORT", purpose: "Loopback port of the binding broker, read by the compiled sprout at runtime (set by the control plane, or by `src/broker.ts` for local runs)." },
|
|
39
|
+
{ name: "SB_BROKER_TOKEN", purpose: "Per-deployment auth token the sprout sends on every broker frame, and the broker sends back on scheduled/queue triggers (paired with SB_BROKER_PORT)." },
|
|
40
|
+
{ name: "SB_SPROUT_URL", purpose: "http://127.0.0.1:<PORT> of the sprout; when set, `src/broker.ts` runs the cron scheduler and queue consumer and delivers triggers to it." },
|
|
38
41
|
];
|
|
39
42
|
|
|
40
43
|
/** One-line usage string, e.g. for `usage()` and `--help`. */
|
package/src/toolchain.ts
CHANGED
|
@@ -24,6 +24,10 @@ const ZIG_SHA256: Record<string, string> = {
|
|
|
24
24
|
|
|
25
25
|
// Pinned Porffor identity — must match the `porffor` entry in package.json
|
|
26
26
|
// (`github:CanadaHonk/porffor#alpha-4`, commit a415d19). PORFFOR_VERSION overrides.
|
|
27
|
+
// When bumping this pin (#55): run the monorepo's `bun run diff` against the
|
|
28
|
+
// frozen reference handlers and update its COMPAT.md for any new mismatch before
|
|
29
|
+
// releasing — the alpha compiler's output can shift between pins. Checked by
|
|
30
|
+
// hand at bump time, not in CI.
|
|
27
31
|
const PORFFOR_CHANNEL = "alpha-4";
|
|
28
32
|
const PORFFOR_COMMIT = "a415d19";
|
|
29
33
|
|
package/src/wrap.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The build-independent half of
|
|
2
|
+
* The build-independent half of sprout compilation: the binding/trigger wrapper
|
|
3
3
|
* that turns a user's `export default { fetch }` into a native-fetch module, plus
|
|
4
4
|
* the `Bindings` shape and the `SPROUTBOAT_*_JSON` env readers.
|
|
5
5
|
*
|
|
@@ -54,12 +54,12 @@ function hasBindings(b: Bindings): boolean {
|
|
|
54
54
|
* `__sbEntry` (HTTP → `handlers.fetch`; `x-sb-trigger` → scheduled / queue / DO).
|
|
55
55
|
*
|
|
56
56
|
* With no bindings and no `scheduled`/`queue`/DO the output behaves exactly like
|
|
57
|
-
* a plain `export default { fetch }`
|
|
57
|
+
* a plain `export default { fetch }` sprout.
|
|
58
58
|
*
|
|
59
59
|
* `port` is only the baked fallback in `export default { port }`; the runtime
|
|
60
60
|
* reads `$PORT` first. The monorepo's bench path overrides it.
|
|
61
61
|
*
|
|
62
|
-
* ponytail: the
|
|
62
|
+
* ponytail: the sprout process is long-lived, so a handler that mutates `env`
|
|
63
63
|
* leaks that change to later requests. Freeze upstream once Porffor supports
|
|
64
64
|
* Object.freeze in native mode.
|
|
65
65
|
*/
|