sproutboat 0.4.10 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- zigBin: string;
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([readFile(input.sourcePath, "utf8"), readFile(preludePath, "utf8")]);
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 path = `${dirname(input.zigBin)}:${binDir}:${process.env.PATH ?? ""}`;
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, "--musl", "-s"],
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 resource create`. The id carries its own `<kind>_` prefix.
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;
@@ -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()}`;
package/src/dev.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * #62 — `sproutboat dev`: the project running on this machine, rebuilt on save.
3
+ *
4
+ * The platform's own local stack (control + edge + supervisor) exists to serve
5
+ * *deployed* artifacts, which are linux-x86_64 and cannot execute on a laptop.
6
+ * This is the other half: build for the host (#62), stand up the same broker
7
+ * the supervisor would, and run the sprout against it — so `env.KV`, secrets,
8
+ * cron and the rest behave the way they will in production without a deploy.
9
+ *
10
+ * Deliberately not the platform: no control plane, no TLS, no routing. One
11
+ * project, one port.
12
+ */
13
+ import { existsSync, watch, type FSWatcher } from "node:fs";
14
+ import { mkdir, readFile } from "node:fs/promises";
15
+ import { dirname, resolve } from "node:path";
16
+ import { buildArtifact } from "./build";
17
+ import { createBroker, listen, type Bindings, type Broker } from "./broker";
18
+ import { isString, jsonObject, parseJsonValue } from "./json";
19
+ import { amber, dim, leaf, ok } from "./style";
20
+ import type { SproutboatConfig } from "./config";
21
+
22
+ const RESTART_DEBOUNCE_MS = 120;
23
+
24
+ export type DevInput = {
25
+ projectDir: string;
26
+ config: SproutboatConfig;
27
+ sourcePath: string;
28
+ /** The bundled module (#89) — already validated by the caller. */
29
+ source: string;
30
+ port: number;
31
+ watch: boolean;
32
+ /** Re-bundle and re-validate after a file changes; throws with a readable message. */
33
+ rebuild: () => Promise<string>;
34
+ };
35
+
36
+ /**
37
+ * Secrets for local dev, `KEY=value` per line, from `.dev.vars` beside the
38
+ * config — the same file Wrangler uses. Deployed secrets live in the control
39
+ * plane and are never on a developer's disk, so this is the only way a bound
40
+ * secret can resolve here.
41
+ */
42
+ async function readDevVars(projectDir: string): Promise<Record<string, string>> {
43
+ const path = resolve(projectDir, ".dev.vars");
44
+ if (!existsSync(path)) return {};
45
+ const text = await readFile(path, "utf8");
46
+ return Object.fromEntries(text.split("\n").flatMap((line): Array<[string, string]> => {
47
+ const trimmed = line.trim();
48
+ if (trimmed === "" || trimmed.startsWith("#")) return [];
49
+ const eq = trimmed.indexOf("=");
50
+ if (eq <= 0) return [];
51
+ const value = trimmed.slice(eq + 1).trim();
52
+ // Accept quoted values, since a secret can legitimately contain spaces.
53
+ const unquoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
54
+ ? value.slice(1, -1)
55
+ : value;
56
+ return [[trimmed.slice(0, eq).trim(), unquoted]];
57
+ }));
58
+ }
59
+
60
+ /** `bindings.json` is written by the build whenever the project declares any. */
61
+ async function readBindings(artifactDir: string): Promise<Partial<Bindings> | undefined> {
62
+ const path = resolve(artifactDir, "bindings.json");
63
+ if (!existsSync(path)) return undefined;
64
+ const record = jsonObject(parseJsonValue(await readFile(path, "utf8")));
65
+ // SAFETY: written by `buildArtifact` in this process moments ago, from the
66
+ // Bindings shape; the broker re-validates every field it reads anyway.
67
+ return record as Partial<Bindings> | undefined;
68
+ }
69
+
70
+ type Running = {
71
+ sprout: Bun.Subprocess;
72
+ broker: Broker;
73
+ stopBroker: () => void;
74
+ /** Set before a kill we initiated, so its exit code is not reported as a crash. */
75
+ expected: boolean;
76
+ };
77
+
78
+ async function start(input: DevInput, source: string): Promise<Running> {
79
+ const artifact = await buildArtifact({
80
+ projectDir: input.projectDir,
81
+ config: input.config,
82
+ sourcePath: input.sourcePath,
83
+ source,
84
+ target: "host",
85
+ });
86
+ const artifactDir = artifact.artifactDir;
87
+ const sproutPath = resolve(artifactDir, "sprout");
88
+
89
+ // `new Database(path, { create: true })` creates the file, never the
90
+ // directory above it, so a first run would fail with SQLITE_CANTOPEN.
91
+ const stateDir = resolve(input.projectDir, ".sproutboat/dev");
92
+ await mkdir(stateDir, { recursive: true });
93
+ const assetsDir = resolve(artifactDir, "assets");
94
+ const broker = createBroker({
95
+ db: resolve(stateDir, "state.sqlite"),
96
+ dataDir: resolve(stateDir, "d1"),
97
+ resourceDir: resolve(stateDir, "resources"),
98
+ token: "sproutboat-dev",
99
+ bindings: await readBindings(artifactDir),
100
+ secrets: await readDevVars(input.projectDir),
101
+ sproutUrl: `http://127.0.0.1:${input.port}/`,
102
+ assetsDir: existsSync(assetsDir) ? assetsDir : undefined,
103
+ });
104
+ const server = listen(broker, "127.0.0.1", 0);
105
+
106
+ const sprout = Bun.spawn([sproutPath], {
107
+ cwd: dirname(sproutPath),
108
+ env: {
109
+ ...process.env,
110
+ PORT: String(input.port),
111
+ SB_BROKER_PORT: String(server.port),
112
+ SB_BROKER_TOKEN: "sproutboat-dev",
113
+ },
114
+ stdout: "inherit",
115
+ stderr: "inherit",
116
+ });
117
+ return { sprout, broker, stopBroker: () => { server.stop(); broker.close(); }, expected: false };
118
+ }
119
+
120
+ function stop(running: Running): void {
121
+ running.expected = true;
122
+ running.sprout.kill(9);
123
+ running.stopBroker();
124
+ }
125
+
126
+ /** Report a sprout that died on its own; a kill we asked for is not news. */
127
+ function watchExit(running: Running): void {
128
+ void running.sprout.exited.then((code) => {
129
+ if (running.expected || code === 0) return;
130
+ console.error(amber(`sprout exited with status ${code} — fix it and save to rebuild`));
131
+ });
132
+ }
133
+
134
+ /** Build, run, and (optionally) rebuild on change. Resolves only on shutdown. */
135
+ export async function runDev(input: DevInput): Promise<void> {
136
+ let running = await start(input, input.source);
137
+ watchExit(running);
138
+ console.log(ok(`${input.config.name} running on ${leaf(`http://127.0.0.1:${input.port}`)}`));
139
+ if (input.watch) console.log(dim(" watching for changes — ctrl-c to stop"));
140
+
141
+ const watchers: FSWatcher[] = [];
142
+ let shuttingDown = false;
143
+ let resolveShutdown: (() => void) | null = null;
144
+ const shutdown = () => {
145
+ if (shuttingDown) return;
146
+ shuttingDown = true;
147
+ for (const watcher of watchers) watcher.close();
148
+ stop(running);
149
+ resolveShutdown?.();
150
+ process.exit(0);
151
+ };
152
+ for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, shutdown);
153
+
154
+ if (input.watch) {
155
+ let pending: ReturnType<typeof setTimeout> | null = null;
156
+ let rebuilding = false;
157
+ const onChange = () => {
158
+ if (pending !== null) clearTimeout(pending);
159
+ // Editors write a file in several syscalls; one save should be one build.
160
+ pending = setTimeout(() => {
161
+ void (async () => {
162
+ if (rebuilding || shuttingDown) return;
163
+ rebuilding = true;
164
+ try {
165
+ const source = await input.rebuild();
166
+ console.log(dim(" change detected, rebuilding…"));
167
+ stop(running);
168
+ running = await start(input, source);
169
+ watchExit(running);
170
+ console.log(ok(` reloaded on http://127.0.0.1:${input.port}`));
171
+ } catch (cause) {
172
+ // Keep the last good build serving; a typo should not take the
173
+ // server down mid-edit.
174
+ console.error(amber(` rebuild failed, still serving the previous build:\n ${cause instanceof Error ? cause.message : String(cause)}`));
175
+ } finally {
176
+ rebuilding = false;
177
+ }
178
+ })();
179
+ }, RESTART_DEBOUNCE_MS);
180
+ };
181
+ // The entry's directory covers the usual `src/` layout; the config itself
182
+ // changes bindings, so it needs a rebuild too.
183
+ watchers.push(watch(dirname(input.sourcePath), { recursive: true }, onChange));
184
+ watchers.push(watch(resolve(input.projectDir, "sproutboat.jsonc"), onChange));
185
+ }
186
+
187
+ // Watching, we stay up until a signal: a crashed sprout is something to fix
188
+ // and save, not a reason to tear the whole session down. Without a watcher
189
+ // there is nothing to wait for but this one process.
190
+ if (input.watch) {
191
+ await new Promise<void>((resolve) => { resolveShutdown = resolve; });
192
+ } else {
193
+ await running.sprout.exited;
194
+ stop(running);
195
+ }
196
+ }
package/src/json.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The one JSON contract the CLI decodes external payloads through: registry
3
+ * responses, control-plane responses, broker request bodies. Parse at the I/O
4
+ * boundary with `parseJsonValue`, then narrow with these guards — nothing
5
+ * downstream should see an unparsed value.
6
+ */
7
+ export type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
8
+ export type JsonObject = { [key: string]: JsonValue };
9
+
10
+ export function isString(value: JsonValue | undefined): value is string {
11
+ return value !== undefined && value === String(value);
12
+ }
13
+
14
+ export function isSafeInteger(value: JsonValue | undefined): value is number {
15
+ return Number.isSafeInteger(value);
16
+ }
17
+
18
+ export function isBoolean(value: JsonValue | undefined): value is boolean {
19
+ return value === true || value === false;
20
+ }
21
+
22
+ export function parseJsonValue(source: string): JsonValue {
23
+ const value = JSON.parse(source);
24
+ if (value === null || value === true || value === false || value === String(value) || Number.isFinite(value) || value instanceof Object) return value;
25
+ throw new Error("response was not valid JSON");
26
+ }
27
+
28
+ export function jsonObject(value: JsonValue): JsonObject | undefined {
29
+ return value instanceof Object && !Array.isArray(value) ? value : undefined;
30
+ }