sproutboat 0.2.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.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Make Porffor's generated native-fetch server read its listen port from $PORT
3
+ * at runtime (falling back to the compiled `port:` value). Porffor renders the
4
+ * server as C text in `compiler/render.js`; we splice two lines into
5
+ * `porf_native_fetch_get_port()`.
6
+ *
7
+ * Done as an idempotent in-place edit rather than a `postinstall` hook: package
8
+ * managers block dependency lifecycle scripts by default, so a published
9
+ * `postinstall` would silently not run. This is called from the build path
10
+ * instead, where it always runs.
11
+ *
12
+ * Tracked upstream in patches/UPSTREAM.md — once Porffor reads $PORT (or exposes
13
+ * env to handlers) this whole file goes away.
14
+ */
15
+ import { readFile, writeFile } from "node:fs/promises";
16
+ import { resolve } from "node:path";
17
+ import { porfforRoot } from "./toolchain";
18
+
19
+ // Splice INJECT in immediately after the opening brace, before the return.
20
+ const ANCHOR = "f64 porf_native_fetch_get_port(void) {\n";
21
+ const INJECT =
22
+ ' const char* __sb_port = getenv("PORT");\n' +
23
+ " if (__sb_port && *__sb_port) { long __sb_v = strtol(__sb_port, NULL, 10); if (__sb_v > 0 && __sb_v < 65536) return (f64)__sb_v; }\n";
24
+ const MARKER = 'getenv("PORT")';
25
+
26
+ let done = false;
27
+
28
+ export async function ensurePorfforPatched(): Promise<void> {
29
+ if (done) return;
30
+ const file = resolve(porfforRoot(), "compiler/render.js");
31
+ const src = await readFile(file, "utf8");
32
+ if (src.includes(MARKER)) { done = true; return; }
33
+ const anchorAt = src.indexOf(ANCHOR);
34
+ if (anchorAt === -1) {
35
+ throw new Error(
36
+ `could not patch Porffor for $PORT: anchor not found in ${file}. ` +
37
+ "Porffor's native-fetch renderer changed — check patches/UPSTREAM.md.",
38
+ );
39
+ }
40
+ const patched = src.slice(0, anchorAt + ANCHOR.length) + INJECT + src.slice(anchorAt + ANCHOR.length);
41
+ await writeFile(file, patched);
42
+ done = true;
43
+ }
package/src/report.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { gzipSync } from "bun";
2
+ import type { SproutboatConfig } from "./config";
3
+ import type { ArtifactManifest } from "./manifest";
4
+
5
+ const CLI_VERSION = "0.1.0";
6
+
7
+ function bytes(n: number): string {
8
+ if (n < 1024) return `${n} B`;
9
+ const kib = n / 1024;
10
+ if (kib < 1024) return `${kib.toFixed(2)} KiB`;
11
+ return `${(kib / 1024).toFixed(2)} MiB`;
12
+ }
13
+
14
+ /** Minimal box table. `align` marks columns to right-pad-left (numbers). */
15
+ function table(headers: string[], rows: string[][], align: boolean[] = []): string {
16
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
17
+ const line = (l: string, m: string, r: string) => l + widths.map((w) => "─".repeat(w + 2)).join(m) + r;
18
+ const row = (cells: string[]) =>
19
+ "│ " + cells.map((c, i) => (align[i] ? (c ?? "").padStart(widths[i]) : (c ?? "").padEnd(widths[i]))).join(" │ ") + " │";
20
+ return [line("┌", "┬", "┐"), row(headers), line("├", "┼", "┤"), ...rows.map(row), line("└", "┴", "┘")].join("\n");
21
+ }
22
+
23
+ /**
24
+ * Wrangler-shaped build/deploy summary. Prints what the artifact contains, its
25
+ * upload size, and the bindings the handler will see. Returns nothing.
26
+ */
27
+ export function printDeployReport(
28
+ config: SproutboatConfig,
29
+ manifest: ArtifactManifest,
30
+ worker: Uint8Array,
31
+ manifestBytes: number,
32
+ ): void {
33
+ const gz = gzipSync(Uint8Array.from(worker)).length;
34
+ const total = worker.length + manifestBytes;
35
+
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}`);
41
+ console.log();
42
+
43
+ console.log("Artifact:");
44
+ console.log(table(
45
+ ["File", "Type", "Size"],
46
+ [
47
+ ["worker", manifest.runtime, bytes(worker.length)],
48
+ ["manifest.json", "json", bytes(manifestBytes)],
49
+ ],
50
+ [false, false, true],
51
+ ));
52
+ console.log(`Total upload: ${bytes(total)} (worker gzip: ${bytes(gz)})`);
53
+ console.log();
54
+
55
+ const vars = Object.entries(config.vars ?? {});
56
+ console.log("Bindings the handler will see:");
57
+ if (vars.length === 0) {
58
+ console.log(" (none — add [vars] to sproutboat.jsonc)");
59
+ } else {
60
+ console.log(table(
61
+ ["Binding", "Type", "Value"],
62
+ vars.map(([k, v]) => [`env.${k}`, "var", JSON.stringify(v)]),
63
+ ));
64
+ }
65
+ }
package/src/source.ts ADDED
@@ -0,0 +1,26 @@
1
+ export type SourceValidation = { ok: true } | { ok: false; errors: string[] };
2
+
3
+ const alwaysForbidden: Array<[RegExp, string]> = [
4
+ [/^\s*import\s/m, "imports are not supported"],
5
+ [/\brequire\s*\(/, "CommonJS require is not supported"],
6
+ [/\b(WebSocket|XMLHttpRequest)\s*\(/, "WebSocket / XMLHttpRequest are not supported"],
7
+ [/\b(process|Bun|Deno|Buffer|node:)\b/, "Node, Bun, and Deno APIs are not supported"],
8
+ ];
9
+
10
+ const fetchWithoutAllowlist: [RegExp, string] = [
11
+ /(?:\breturn\s+|\bawait\s+|=\s*)fetch\s*\(/,
12
+ "outbound networking needs an `outbound` host allowlist in sproutboat.jsonc",
13
+ ];
14
+
15
+ export function validateHttpSyncSource(source: string, outboundAllowed = false): SourceValidation {
16
+ const errors: string[] = [];
17
+ // The default export must be an object literal with a `fetch` method. A module
18
+ // may also declare Durable Object classes / helpers before it, so this is not
19
+ // anchored to the start of the file.
20
+ if (!/\bexport\s+default\s*\{/.test(source) || !/\bfetch\s*\(/.test(source)) {
21
+ errors.push("handler must default-export an object with fetch(request)");
22
+ }
23
+ for (const [pattern, message] of alwaysForbidden) if (pattern.test(source)) errors.push(message);
24
+ if (!outboundAllowed && fetchWithoutAllowlist[0].test(source)) errors.push(fetchWithoutAllowlist[1]);
25
+ return errors.length ? { ok: false, errors } : { ok: true };
26
+ }
package/src/surface.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The CLI's public surface, as data. `main.ts` builds its usage string from
3
+ * this, and `surface.test.ts` renders it to `SURFACE.md` and checks that the
4
+ * command switch, the referenced env vars, and the doc can't drift apart.
5
+ */
6
+ export const CLI_NAME = "sproutboat";
7
+
8
+ export type Command = { name: string; args: string; summary: string };
9
+
10
+ export const COMMANDS: readonly Command[] = [
11
+ { name: "init", args: "[name]", summary: "Scaffold sproutboat.jsonc + src/index.js in ./<name>." },
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 worker (Porffor + Zig)." },
14
+ { name: "deploy", args: "[project-dir] [--dry-run] [--artifact <dir>]", summary: "Build (unless --artifact), print the report, upload. --dry-run stops before upload." },
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 project's recent request logs." },
17
+ { name: "versions", args: "list [project-dir]", summary: "List the project's deployed versions." },
18
+ { name: "rollback", args: "<version-id> [project-dir]", summary: "Re-activate a previous version." },
19
+ { name: "delete", args: "--yes [project-dir]", summary: "Delete the project and every version." },
20
+ ];
21
+
22
+ export type EnvVar = { name: string; purpose: string };
23
+
24
+ export const ENV_VARS: readonly EnvVar[] = [
25
+ { name: "SPROUTBOAT_API_URL", purpose: "Control-plane URL. Overrides the saved active endpoint." },
26
+ { name: "SPROUTBOAT_TOKEN", purpose: "API token. Overrides the saved credential for the endpoint." },
27
+ { name: "SPROUTBOAT_ZIG", purpose: "Path to a Zig binary to use instead of downloading the pinned one." },
28
+ { name: "SPROUTBOAT_COMPILE_TIMEOUT_MS", purpose: "Porffor compile timeout in ms (default 600000)." },
29
+ { name: "SPROUTBOAT_CONFIG_DIR", purpose: "Directory for credentials.json (default ~/.config/sproutboat)." },
30
+ { name: "XDG_CONFIG_HOME", purpose: "Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset." },
31
+ { name: "PORFFOR_VERSION", purpose: "Override the Porffor identity string recorded in the manifest." },
32
+ { name: "SB_BROKER_PORT", purpose: "Loopback port of the binding broker, read by the compiled worker at runtime (set by the control plane, or by `src/broker.ts` for local runs)." },
33
+ { name: "SB_BROKER_TOKEN", purpose: "Per-deployment auth token the worker sends on every broker frame, and the broker sends back on scheduled/queue triggers (paired with SB_BROKER_PORT)." },
34
+ { name: "SB_WORKER_URL", purpose: "http://127.0.0.1:<PORT> of the worker; when set, `src/broker.ts` runs the cron scheduler and queue consumer and delivers triggers to it." },
35
+ ];
36
+
37
+ /** One-line usage string, e.g. for `usage()` and `--help`. */
38
+ export function usageLine(): string {
39
+ const parts = COMMANDS.map((c) => (c.args ? `${c.name} ${c.args}` : c.name));
40
+ return `usage: ${CLI_NAME} <${parts.join(" | ")}>`;
41
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The build toolchain: a pinned Zig (the linux-x86_64 cross-compiler Porffor
3
+ * shells out to for `--musl`) plus version stamps for the artifact manifest.
4
+ *
5
+ * Zig is fetched once to ~/.cache/sproutboat/zig-<version>/ and reused. No
6
+ * Docker, no root. Override with SPROUTBOAT_ZIG=/path/to/zig.
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { chmod, mkdir, readFile, rm } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { dirname, resolve } from "node:path";
13
+
14
+ export const ZIG_VERSION = "0.16.0";
15
+
16
+ // sha256 of the official ziglang.org tarballs for ZIG_VERSION, keyed by
17
+ // `<arch>-<os>` (the download naming). Bump alongside ZIG_VERSION.
18
+ const ZIG_SHA256: Record<string, string> = {
19
+ "x86_64-linux": "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00",
20
+ "aarch64-linux": "ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17",
21
+ "x86_64-macos": "0387557ed1877bc6a2e1802c8391953baddba76081876301c522f52977b52ba7",
22
+ "aarch64-macos": "b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489",
23
+ };
24
+
25
+ // Pinned Porffor identity — must match the `porffor` entry in package.json
26
+ // (`github:CanadaHonk/porffor#alpha-4`, commit a415d19). PORFFOR_VERSION overrides.
27
+ const PORFFOR_CHANNEL = "alpha-4";
28
+ const PORFFOR_COMMIT = "a415d19";
29
+
30
+ // uWebSockets commit Porffor alpha-4 fetches for the native-fetch server. Read
31
+ // from node_modules/porffor at build time; this is the fallback for the stamp.
32
+ const UWS_COMMIT = "360c276d";
33
+
34
+ function platformKey(): string {
35
+ const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : null;
36
+ const os = process.platform === "linux" ? "linux" : process.platform === "darwin" ? "macos" : null;
37
+ if (!arch || !os) throw new Error(`no pinned Zig for ${process.platform}/${process.arch} — set SPROUTBOAT_ZIG to a zig ${ZIG_VERSION} binary`);
38
+ return `${arch}-${os}`;
39
+ }
40
+
41
+ async function sha256File(path: string): Promise<string> {
42
+ const hash = createHash("sha256");
43
+ hash.update(await readFile(path));
44
+ return hash.digest("hex");
45
+ }
46
+
47
+ /** Absolute path to a usable `zig` binary, downloading it on first use. */
48
+ export async function ensureZig(): Promise<string> {
49
+ const override = process.env.SPROUTBOAT_ZIG;
50
+ if (override) {
51
+ if (!existsSync(override)) throw new Error(`SPROUTBOAT_ZIG=${override} does not exist`);
52
+ return override;
53
+ }
54
+ const key = platformKey();
55
+ const home = homedir();
56
+ const dir = resolve(home, ".cache/sproutboat", `zig-${ZIG_VERSION}`);
57
+ const bin = resolve(dir, "zig");
58
+ if (existsSync(bin)) return bin;
59
+
60
+ const url = `https://ziglang.org/download/${ZIG_VERSION}/zig-${key}-${ZIG_VERSION}.tar.xz`;
61
+ const expected = ZIG_SHA256[key];
62
+ console.log(`Fetching Zig ${ZIG_VERSION} (${key}, one-time)...`);
63
+ await mkdir(dir, { recursive: true });
64
+ const archive = resolve(dir, "zig.tar.xz");
65
+ const response = await fetch(url);
66
+ if (!response.ok) throw new Error(`could not download Zig: ${url} (${response.status})`);
67
+ await Bun.write(archive, response);
68
+
69
+ const actual = await sha256File(archive);
70
+ if (expected && actual !== expected) {
71
+ await rm(dir, { recursive: true, force: true });
72
+ throw new Error(`Zig download sha256 mismatch\n expected ${expected}\n got ${actual}`);
73
+ }
74
+
75
+ // `tar -xJ` (xz) works on macOS bsdtar and GNU tar with xz on PATH.
76
+ const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], { stdout: "pipe", stderr: "pipe" });
77
+ const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
78
+ if (code !== 0) throw new Error(`could not extract Zig (needs \`tar\` with xz support): ${err.trim()}`);
79
+ await rm(archive, { force: true });
80
+ if (!existsSync(bin)) throw new Error("Zig archive did not contain a `zig` binary");
81
+ await chmod(bin, 0o755);
82
+ return bin;
83
+ }
84
+
85
+ /** Directory holding node_modules/porffor (walks up from this file). */
86
+ export function porfforRoot(start = import.meta.dir): string {
87
+ let dir = start;
88
+ for (;;) {
89
+ const candidate = resolve(dir, "node_modules/porffor");
90
+ if (existsSync(resolve(candidate, "runtime/index.js"))) return candidate;
91
+ const parent = dirname(dir);
92
+ if (parent === dir) throw new Error("node_modules/porffor not found — run `bun install`");
93
+ dir = parent;
94
+ }
95
+ }
96
+
97
+ export function porfforVersion(): string {
98
+ if (process.env.PORFFOR_VERSION) return process.env.PORFFOR_VERSION;
99
+ return `${PORFFOR_CHANNEL} (${PORFFOR_COMMIT})`;
100
+ }
101
+
102
+ export function esbuildVersion(): string {
103
+ try {
104
+ const pkg = Bun.resolveSync("esbuild/package.json", import.meta.dir);
105
+ // SAFETY: esbuild's package.json always has a string `version`; defaulted below.
106
+ const parsed = JSON.parse(readFileSync(pkg, "utf8")) as { version?: string };
107
+ return parsed.version || "unknown";
108
+ } catch {
109
+ return "unknown";
110
+ }
111
+ }
112
+
113
+ function uwsCommit(): string {
114
+ try {
115
+ const src = readFileSync(resolve(porfforRoot(), "compiler/uwebsockets.js"), "utf8");
116
+ return /UWS_COMMIT\s*=\s*['"]([0-9a-f]{7,40})/.exec(src)?.[1]?.slice(0, 8) || UWS_COMMIT;
117
+ } catch {
118
+ return UWS_COMMIT;
119
+ }
120
+ }
121
+
122
+ /** Compact provenance string for the artifact manifest. */
123
+ export function toolchainStamp(): string {
124
+ return `zig-musl/${ZIG_VERSION}+porffor/${PORFFOR_COMMIT}+uws/${uwsCommit()}`;
125
+ }