sproutboat 0.4.11 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ }