sproutboat 0.4.11 → 0.6.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/bundle.ts ADDED
@@ -0,0 +1,70 @@
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)
34
+ return errors.map((error) => ` ${error instanceof Error ? error.message : String(error)}`).join("\n");
35
+ return ` ${cause instanceof Error ? cause.message : String(cause)}`;
36
+ }
37
+
38
+ /** Bundle `entryPath` into a single module, resolving imports from `projectDir`. */
39
+ export async function bundleHandler(entryPath: string, projectDir: string): Promise<BundleResult> {
40
+ let built: Awaited<ReturnType<typeof Bun.build>>;
41
+ try {
42
+ built = await Bun.build({
43
+ entrypoints: [entryPath],
44
+ root: projectDir,
45
+ // `browser` keeps the output free of Node shims — a dependency that wants
46
+ // `process` must fail the capability check, not get a polyfill smuggled in.
47
+ target: "browser",
48
+ format: "esm",
49
+ // Readability over size: a compile error from Porffor should point at
50
+ // something a human can find, and Porffor strips the binary anyway.
51
+ minify: false,
52
+ splitting: false,
53
+ sourcemap: "none",
54
+ });
55
+ } catch (cause) {
56
+ // An unresolvable specifier arrives as an AggregateError whose `errors`
57
+ // carry the useful part ("Could not resolve: ./missing.js"); the top-level
58
+ // message is only "Bundle failed", which tells nobody which import broke.
59
+ throw new BundleError(`could not bundle ${entryLabel(entryPath, projectDir)}:\n${bundleDetail(cause)}`);
60
+ }
61
+
62
+ if (!built.success) {
63
+ const detail = built.logs.map((log) => ` ${log.message}`).join("\n");
64
+ throw new BundleError(`could not bundle ${entryLabel(entryPath, projectDir)}:\n${detail}`);
65
+ }
66
+ const [output] = built.outputs;
67
+ if (output === undefined) throw new BundleError("the bundler produced no output");
68
+
69
+ return { code: await output.text() };
70
+ }
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). */
@@ -41,38 +51,56 @@ export async function compileSprout(input: CompileInput): Promise<void> {
41
51
  const haveGit = Bun.which("git");
42
52
  const haveMake = Bun.which("make");
43
53
  if (haveGit && haveMake) {
44
- console.warn(`prebuilt uWebSockets unusable (${error.message.split("\n")[0]}); falling back to git + make (slower, one-time)`);
54
+ console.warn(
55
+ `prebuilt uWebSockets unusable (${error.message.split("\n")[0]}); falling back to git + make (slower, one-time)`,
56
+ );
45
57
  } else {
46
58
  const missing = [!haveGit && "git", !haveMake && "make"].filter(Boolean).join(" and ");
47
59
  throw new Error(
48
60
  `${error.message}\n\nThe prebuilt uWebSockets is unusable, and ${missing} ` +
49
- `${missing.includes("and") ? "are" : "is"} not on PATH for the fallback build. ` +
50
- `Install ${missing}, or set SPROUTBOAT_UWS_TARBALL to a valid archive.`,
61
+ `${missing.includes("and") ? "are" : "is"} not on PATH for the fallback build. ` +
62
+ `Install ${missing}, or set SPROUTBOAT_UWS_TARBALL to a valid archive.`,
51
63
  );
52
64
  }
53
65
  }
54
66
 
55
67
  const outDir = dirname(input.outPath);
56
68
  await mkdir(outDir, { recursive: true });
69
+ // The artifact dir is content-addressed, so rebuilding unchanged source lands
70
+ // on the previous binary — which `chmod 0555` left read-only, and which the
71
+ // OS may still be executing. The linker cannot overwrite either, so clear it
72
+ // first rather than failing with "can't write output file".
73
+ await rm(input.outPath, { force: true });
57
74
  const generatedPath = resolve(outDir, "sprout.generated.js");
58
- const [source, prelude] = await Promise.all([readFile(input.sourcePath, "utf8"), readFile(preludePath, "utf8")]);
75
+ const [source, prelude] = await Promise.all([
76
+ input.source === undefined ? readFile(input.sourcePath, "utf8") : Promise.resolve(input.source),
77
+ readFile(preludePath, "utf8"),
78
+ ]);
59
79
  await writeFile(generatedPath, wrapNativeFetchHandler(source, prelude, input.vars, input.bindings ?? EMPTY_BINDINGS));
60
80
 
61
81
  const porffor = porfforRoot();
62
82
  const launcher = resolve(porffor, "runtime/index.js");
63
83
  // Porffor shells bare `zig` and `esbuild`; put both on PATH for the child.
84
+ // A host build never shells `zig`, so it has no zigBin to contribute.
64
85
  const binDir = resolve(porffor, "../.bin");
65
- const path = `${dirname(input.zigBin)}:${binDir}:${process.env.PATH ?? ""}`;
86
+ const zigDir = input.zigBin ? `${dirname(input.zigBin)}:` : "";
87
+ const path = `${zigDir}${binDir}:${process.env.PATH ?? ""}`;
66
88
 
67
89
  // `-s`: strip at link. The unstripped static-musl binary is ~90% DWARF that
68
90
  // nothing needs at runtime (12 MB -> ~1.3 MB for the kitchen-sink). Porffor
69
91
  // forwards `-s` straight to the `zig cc` link step.
92
+ // `--musl` is what makes it a cross-compile; a host build simply omits it and
93
+ // Porffor targets the machine it is running on.
94
+ const crossFlags = input.target === "host" ? [] : ["--musl"];
70
95
  const child = Bun.spawn(
71
- [process.execPath, launcher, "native", generatedPath, "-o", input.outPath, "--musl", "-s"],
96
+ [process.execPath, launcher, "native", generatedPath, "-o", input.outPath, ...crossFlags, "-s"],
72
97
  { cwd: outDir, stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: path } },
73
98
  );
74
99
  let timedOut = false;
75
- const timer = setTimeout(() => { timedOut = true; child.kill(); }, COMPILE_TIMEOUT_MS);
100
+ const timer = setTimeout(() => {
101
+ timedOut = true;
102
+ child.kill();
103
+ }, COMPILE_TIMEOUT_MS);
76
104
  const [code, stdout, stderr] = await Promise.all([
77
105
  child.exited,
78
106
  new Response(child.stdout).text(),
package/src/config.ts CHANGED
@@ -4,14 +4,16 @@ 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;
11
11
 
12
12
  /** Normalizes a storage-binding array to `{ binding, id? }` rows. */
13
13
  export function resourceRefs(field: readonly ResourceRef[] | undefined): Array<{ binding: string; id?: string }> {
14
- return (field ?? []).map((entry) => (isString(entry) ? { binding: entry } : { binding: entry.binding, id: entry.id }));
14
+ return (field ?? []).map((entry) =>
15
+ isString(entry) ? { binding: entry } : { binding: entry.binding, id: entry.id },
16
+ );
15
17
  }
16
18
 
17
19
  /**
@@ -73,9 +75,7 @@ export type AssetsConfig = {
73
75
  run_sprout_first?: boolean | string[];
74
76
  };
75
77
 
76
- export type ConfigValidation =
77
- | { ok: true; value: SproutboatConfig }
78
- | { ok: false; errors: string[] };
78
+ export type ConfigValidation = { ok: true; value: SproutboatConfig } | { ok: false; errors: string[] };
79
79
 
80
80
  type JsonValue = string | number | boolean | null | ConfigJsonObject | JsonValue[];
81
81
 
@@ -86,8 +86,7 @@ interface ConfigJsonObject {
86
86
  type ConfigInput = JsonValue | undefined;
87
87
 
88
88
  function isRecord(value: ConfigInput): value is ConfigJsonObject {
89
- return value !== null && Object(value) === value && !Array.isArray(value)
90
- && !(value instanceof Function);
89
+ return value !== null && Object(value) === value && !Array.isArray(value) && !(value instanceof Function);
91
90
  }
92
91
 
93
92
  function isString(value: ConfigInput): value is string {
@@ -102,9 +101,21 @@ function validateConfig(value: ConfigInput): ConfigValidation {
102
101
  const errors: string[] = [];
103
102
  if (!isRecord(value)) return { ok: false, errors: ["config must be an object"] };
104
103
  const allowed = new Set([
105
- "$schema", "name", "main", "compatibility_date", "vars",
106
- "kv_namespaces", "secrets", "outbound", "d1_databases", "r2_buckets",
107
- "queues", "analytics_engine_datasets", "durable_objects", "triggers", "assets",
104
+ "$schema",
105
+ "name",
106
+ "main",
107
+ "compatibility_date",
108
+ "vars",
109
+ "kv_namespaces",
110
+ "secrets",
111
+ "outbound",
112
+ "d1_databases",
113
+ "r2_buckets",
114
+ "queues",
115
+ "analytics_engine_datasets",
116
+ "durable_objects",
117
+ "triggers",
118
+ "assets",
108
119
  ]);
109
120
  for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`unsupported config field: ${key}`);
110
121
  const name = isString(value.name) && isProjectSlug(value.name) ? value.name : null;
@@ -115,7 +126,10 @@ function validateConfig(value: ConfigInput): ConfigValidation {
115
126
  if (main === null) {
116
127
  errors.push("main must be a relative entry point under src/");
117
128
  }
118
- const compatibility_date = isString(value.compatibility_date) && /^\d{4}-\d{2}-\d{2}$/.test(value.compatibility_date) ? value.compatibility_date : null;
129
+ const compatibility_date =
130
+ isString(value.compatibility_date) && /^\d{4}-\d{2}-\d{2}$/.test(value.compatibility_date)
131
+ ? value.compatibility_date
132
+ : null;
119
133
  if (compatibility_date === null) {
120
134
  errors.push("compatibility_date must use YYYY-MM-DD");
121
135
  }
@@ -127,7 +141,8 @@ function validateConfig(value: ConfigInput): ConfigValidation {
127
141
  else {
128
142
  vars = {};
129
143
  for (const [key, item] of Object.entries(value.vars)) {
130
- if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !isString(item)) errors.push(`vars.${key} must be a string environment name`);
144
+ if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !isString(item))
145
+ errors.push(`vars.${key} must be a string environment name`);
131
146
  else vars[key] = item;
132
147
  }
133
148
  }
@@ -174,9 +189,14 @@ function validateConfig(value: ConfigInput): ConfigValidation {
174
189
  if (isString(entry)) {
175
190
  if (bindingName.test(entry)) out.push(entry);
176
191
  else errors.push(`${field}: "${entry}" must be an UPPER_SNAKE binding name`);
177
- } else if (isRecord(entry) && isString(entry.binding) && isString(entry.id)
178
- && bindingName.test(entry.binding) && idPattern.test(entry.id)
179
- && Object.keys(entry).every((key) => key === "binding" || key === "id")) {
192
+ } else if (
193
+ isRecord(entry) &&
194
+ isString(entry.binding) &&
195
+ isString(entry.id) &&
196
+ bindingName.test(entry.binding) &&
197
+ idPattern.test(entry.id) &&
198
+ Object.keys(entry).every((key) => key === "binding" || key === "id")
199
+ ) {
180
200
  out.push({ binding: entry.binding, id: entry.id });
181
201
  } else {
182
202
  errors.push(`${field} entries must be an UPPER_SNAKE name or { binding: "NAME", id: "${kind}_…" }`);
@@ -189,7 +209,11 @@ function validateConfig(value: ConfigInput): ConfigValidation {
189
209
  const outbound = stringArray("outbound", hostPattern, "hostnames");
190
210
  // Analytics Engine datasets aren't provisioned — the dataset name springs into
191
211
  // existence on first writeDataPoint(), so there's no resource id to bind (#74).
192
- const analytics_engine_datasets = stringArray("analytics_engine_datasets", bindingName, "binding names (UPPER_SNAKE_CASE)");
212
+ const analytics_engine_datasets = stringArray(
213
+ "analytics_engine_datasets",
214
+ bindingName,
215
+ "binding names (UPPER_SNAKE_CASE)",
216
+ );
193
217
  const kv_namespaces = resourceArray("kv_namespaces", "kv");
194
218
  const d1_databases = resourceArray("d1_databases", "d1");
195
219
  const r2_buckets = resourceArray("r2_buckets", "r2");
@@ -198,7 +222,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
198
222
  let durable_objects: Record<string, string> | undefined;
199
223
  if (value.durable_objects !== undefined) {
200
224
  if (!isRecord(value.durable_objects)) {
201
- errors.push("durable_objects must be an object of { BINDING_NAME: \"ClassName\" }");
225
+ errors.push('durable_objects must be an object of { BINDING_NAME: "ClassName" }');
202
226
  } else {
203
227
  durable_objects = {};
204
228
  for (const [binding, className] of Object.entries(value.durable_objects)) {
@@ -230,15 +254,24 @@ function validateConfig(value: ConfigInput): ConfigValidation {
230
254
  errors.push("assets must be an object with a `directory`");
231
255
  } else {
232
256
  const raw = value.assets;
233
- const dir = isString(raw.directory) && raw.directory.length > 0 && !raw.directory.includes("..")
234
- ? raw.directory.replace(/^\.\//, "").replace(/\/$/, "")
235
- : null;
257
+ const dir =
258
+ isString(raw.directory) && raw.directory.length > 0 && !raw.directory.includes("..")
259
+ ? raw.directory.replace(/^\.\//, "").replace(/\/$/, "")
260
+ : null;
236
261
  if (dir === null) errors.push("assets.directory must be a project-relative path");
237
- const binding = raw.binding === undefined ? undefined
238
- : isString(raw.binding) && bindingName.test(raw.binding) ? raw.binding : null;
262
+ const binding =
263
+ raw.binding === undefined
264
+ ? undefined
265
+ : isString(raw.binding) && bindingName.test(raw.binding)
266
+ ? raw.binding
267
+ : null;
239
268
  if (binding === null) errors.push("assets.binding must be a binding name (UPPER_SNAKE_CASE)");
240
- const nfh = raw.not_found_handling === "none" || raw.not_found_handling === "single-page-application"
241
- || raw.not_found_handling === "404-page" ? raw.not_found_handling : undefined;
269
+ const nfh =
270
+ raw.not_found_handling === "none" ||
271
+ raw.not_found_handling === "single-page-application" ||
272
+ raw.not_found_handling === "404-page"
273
+ ? raw.not_found_handling
274
+ : undefined;
242
275
  if (raw.not_found_handling !== undefined && nfh === undefined) {
243
276
  errors.push('assets.not_found_handling must be "none", "single-page-application", or "404-page"');
244
277
  }
@@ -259,16 +292,22 @@ function validateConfig(value: ConfigInput): ConfigValidation {
259
292
  }
260
293
  }
261
294
 
262
- const resourceNames = (refs: ResourceRef[] | undefined): string[] =>
263
- resourceRefs(refs).map((ref) => ref.binding);
295
+ const resourceNames = (refs: ResourceRef[] | undefined): string[] => resourceRefs(refs).map((ref) => ref.binding);
264
296
  const bindingSlots = [
265
- ...resourceNames(kv_namespaces), ...(secrets ?? []), ...resourceNames(d1_databases), ...resourceNames(r2_buckets),
266
- ...resourceNames(queues), ...(analytics_engine_datasets ?? []), ...Object.keys(durable_objects ?? {}), ...Object.keys(vars ?? {}),
297
+ ...resourceNames(kv_namespaces),
298
+ ...(secrets ?? []),
299
+ ...resourceNames(d1_databases),
300
+ ...resourceNames(r2_buckets),
301
+ ...resourceNames(queues),
302
+ ...(analytics_engine_datasets ?? []),
303
+ ...Object.keys(durable_objects ?? {}),
304
+ ...Object.keys(vars ?? {}),
267
305
  ...(assets?.binding ? [assets.binding] : []),
268
306
  ];
269
307
  if (new Set(bindingSlots).size !== bindingSlots.length) errors.push("vars and binding names must not collide");
270
308
 
271
- if (errors.length || name === null || main === null || compatibility_date === null || schema === null) return { ok: false, errors };
309
+ if (errors.length || name === null || main === null || compatibility_date === null || schema === null)
310
+ return { ok: false, errors };
272
311
  const config: SproutboatConfig = { name, main, compatibility_date };
273
312
  if ("$schema" in value) config.$schema = schema;
274
313
  if ("vars" in value) config.vars = vars;
@@ -307,6 +346,9 @@ export function parseConfig(source: string): ConfigValidation {
307
346
  json = json.replace(/,\s*([}\]])/g, "$1");
308
347
  return validateConfig(JSON.parse(json));
309
348
  } catch (error) {
310
- return { ok: false, errors: [`invalid sproutboat.jsonc: ${error instanceof Error ? error.message : String(error)}`] };
349
+ return {
350
+ ok: false,
351
+ errors: [`invalid sproutboat.jsonc: ${error instanceof Error ? error.message : String(error)}`],
352
+ };
311
353
  }
312
354
  }
@@ -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,213 @@
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 { 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(
47
+ text.split("\n").flatMap((line): Array<[string, string]> => {
48
+ const trimmed = line.trim();
49
+ if (trimmed === "" || trimmed.startsWith("#")) return [];
50
+ const eq = trimmed.indexOf("=");
51
+ if (eq <= 0) return [];
52
+ const value = trimmed.slice(eq + 1).trim();
53
+ // Accept quoted values, since a secret can legitimately contain spaces.
54
+ const unquoted =
55
+ (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
56
+ ? value.slice(1, -1)
57
+ : value;
58
+ return [[trimmed.slice(0, eq).trim(), unquoted]];
59
+ }),
60
+ );
61
+ }
62
+
63
+ /** `bindings.json` is written by the build whenever the project declares any. */
64
+ async function readBindings(artifactDir: string): Promise<Partial<Bindings> | undefined> {
65
+ const path = resolve(artifactDir, "bindings.json");
66
+ if (!existsSync(path)) return undefined;
67
+ const record = jsonObject(parseJsonValue(await readFile(path, "utf8")));
68
+ // SAFETY: written by `buildArtifact` in this process moments ago, from the
69
+ // Bindings shape; the broker re-validates every field it reads anyway.
70
+ return record as Partial<Bindings> | undefined;
71
+ }
72
+
73
+ type Running = {
74
+ sprout: Bun.Subprocess;
75
+ broker: Broker;
76
+ stopBroker: () => void;
77
+ /** Set before a kill we initiated, so its exit code is not reported as a crash. */
78
+ expected: boolean;
79
+ };
80
+
81
+ async function start(input: DevInput, source: string): Promise<Running> {
82
+ const artifact = await buildArtifact({
83
+ projectDir: input.projectDir,
84
+ config: input.config,
85
+ sourcePath: input.sourcePath,
86
+ source,
87
+ target: "host",
88
+ });
89
+ const artifactDir = artifact.artifactDir;
90
+ const sproutPath = resolve(artifactDir, "sprout");
91
+
92
+ // `new Database(path, { create: true })` creates the file, never the
93
+ // directory above it, so a first run would fail with SQLITE_CANTOPEN.
94
+ const stateDir = resolve(input.projectDir, ".sproutboat/dev");
95
+ await mkdir(stateDir, { recursive: true });
96
+ const assetsDir = resolve(artifactDir, "assets");
97
+ const broker = createBroker({
98
+ db: resolve(stateDir, "state.sqlite"),
99
+ dataDir: resolve(stateDir, "d1"),
100
+ resourceDir: resolve(stateDir, "resources"),
101
+ token: "sproutboat-dev",
102
+ bindings: await readBindings(artifactDir),
103
+ secrets: await readDevVars(input.projectDir),
104
+ sproutUrl: `http://127.0.0.1:${input.port}/`,
105
+ assetsDir: existsSync(assetsDir) ? assetsDir : undefined,
106
+ });
107
+ const server = listen(broker, "127.0.0.1", 0);
108
+
109
+ const sprout = Bun.spawn([sproutPath], {
110
+ cwd: dirname(sproutPath),
111
+ env: {
112
+ ...process.env,
113
+ PORT: String(input.port),
114
+ SB_BROKER_PORT: String(server.port),
115
+ SB_BROKER_TOKEN: "sproutboat-dev",
116
+ },
117
+ stdout: "inherit",
118
+ stderr: "inherit",
119
+ });
120
+ return {
121
+ sprout,
122
+ broker,
123
+ stopBroker: () => {
124
+ server.stop();
125
+ broker.close();
126
+ },
127
+ expected: false,
128
+ };
129
+ }
130
+
131
+ function stop(running: Running): void {
132
+ running.expected = true;
133
+ running.sprout.kill(9);
134
+ running.stopBroker();
135
+ }
136
+
137
+ /** Report a sprout that died on its own; a kill we asked for is not news. */
138
+ function watchExit(running: Running): void {
139
+ void running.sprout.exited.then((code) => {
140
+ if (running.expected || code === 0) return;
141
+ console.error(amber(`sprout exited with status ${code} — fix it and save to rebuild`));
142
+ });
143
+ }
144
+
145
+ /** Build, run, and (optionally) rebuild on change. Resolves only on shutdown. */
146
+ export async function runDev(input: DevInput): Promise<void> {
147
+ let running = await start(input, input.source);
148
+ watchExit(running);
149
+ console.log(ok(`${input.config.name} running on ${leaf(`http://127.0.0.1:${input.port}`)}`));
150
+ if (input.watch) console.log(dim(" watching for changes — ctrl-c to stop"));
151
+
152
+ const watchers: FSWatcher[] = [];
153
+ let shuttingDown = false;
154
+ let resolveShutdown: (() => void) | null = null;
155
+ const shutdown = () => {
156
+ if (shuttingDown) return;
157
+ shuttingDown = true;
158
+ for (const watcher of watchers) watcher.close();
159
+ stop(running);
160
+ resolveShutdown?.();
161
+ process.exit(0);
162
+ };
163
+ for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, shutdown);
164
+
165
+ if (input.watch) {
166
+ let pending: ReturnType<typeof setTimeout> | null = null;
167
+ let rebuilding = false;
168
+ const onChange = () => {
169
+ if (pending !== null) clearTimeout(pending);
170
+ // Editors write a file in several syscalls; one save should be one build.
171
+ pending = setTimeout(() => {
172
+ void (async () => {
173
+ if (rebuilding || shuttingDown) return;
174
+ rebuilding = true;
175
+ try {
176
+ const source = await input.rebuild();
177
+ console.log(dim(" change detected, rebuilding…"));
178
+ stop(running);
179
+ running = await start(input, source);
180
+ watchExit(running);
181
+ console.log(ok(` reloaded on http://127.0.0.1:${input.port}`));
182
+ } catch (cause) {
183
+ // Keep the last good build serving; a typo should not take the
184
+ // server down mid-edit.
185
+ console.error(
186
+ amber(
187
+ ` rebuild failed, still serving the previous build:\n ${cause instanceof Error ? cause.message : String(cause)}`,
188
+ ),
189
+ );
190
+ } finally {
191
+ rebuilding = false;
192
+ }
193
+ })();
194
+ }, RESTART_DEBOUNCE_MS);
195
+ };
196
+ // The entry's directory covers the usual `src/` layout; the config itself
197
+ // changes bindings, so it needs a rebuild too.
198
+ watchers.push(watch(dirname(input.sourcePath), { recursive: true }, onChange));
199
+ watchers.push(watch(resolve(input.projectDir, "sproutboat.jsonc"), onChange));
200
+ }
201
+
202
+ // Watching, we stay up until a signal: a crashed sprout is something to fix
203
+ // and save, not a reason to tear the whole session down. Without a watcher
204
+ // there is nothing to wait for but this one process.
205
+ if (input.watch) {
206
+ await new Promise<void>((resolve) => {
207
+ resolveShutdown = resolve;
208
+ });
209
+ } else {
210
+ await running.sprout.exited;
211
+ stop(running);
212
+ }
213
+ }
package/src/json.ts ADDED
@@ -0,0 +1,38 @@
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 (
25
+ value === null ||
26
+ value === true ||
27
+ value === false ||
28
+ value === String(value) ||
29
+ Number.isFinite(value) ||
30
+ value instanceof Object
31
+ )
32
+ return value;
33
+ throw new Error("response was not valid JSON");
34
+ }
35
+
36
+ export function jsonObject(value: JsonValue): JsonObject | undefined {
37
+ return value instanceof Object && !Array.isArray(value) ? value : undefined;
38
+ }