sproutboat 0.8.0 → 0.10.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 CHANGED
@@ -11,7 +11,8 @@
11
11
  * project, one port.
12
12
  */
13
13
  import { existsSync, watch, type FSWatcher } from "node:fs";
14
- import { mkdir, readFile } from "node:fs/promises";
14
+ import { mkdir, readFile, rm } from "node:fs/promises";
15
+ import { connect } from "node:net";
15
16
  import { dirname, resolve } from "node:path";
16
17
  import { buildArtifact } from "./build";
17
18
  import { createBroker, listen, type Bindings, type Broker } from "./broker";
@@ -29,10 +30,27 @@ export type DevInput = {
29
30
  source: string;
30
31
  port: number;
31
32
  watch: boolean;
32
- /** Re-bundle and re-validate after a file changes; throws with a readable message. */
33
- rebuild: () => Promise<string>;
33
+ /** Re-read the complete project after a change; throws with a readable message. */
34
+ rebuild: () => Promise<Pick<DevInput, "config" | "sourcePath" | "source">>;
35
+ /** Set internally for an asset-only refresh, where recompiling is unnecessary. */
36
+ reuseSproutPath?: string;
37
+ /** Test seam: production always uses the native artifact starter below. */
38
+ candidateFactory?: (input: DevInput, port: number) => Promise<Running>;
39
+ /** Test seam: avoid terminating Bun's test process on a simulated signal. */
40
+ exitOnShutdown?: boolean;
41
+ /** Clock seam for reload observability tests. */
42
+ now?: () => number;
43
+ /** Called once a replacement is ready and the stable route has switched. */
44
+ onReload?: (timing: { editToReadyMs: number; downtimeMs: number }) => void;
34
45
  };
35
46
 
47
+ export function isAssetOnlyRefresh(
48
+ current: Pick<DevInput, "config" | "source">,
49
+ next: Pick<DevInput, "config" | "source">,
50
+ ): boolean {
51
+ return next.source === current.source && JSON.stringify(next.config) === JSON.stringify(current.config);
52
+ }
53
+
36
54
  /**
37
55
  * Secrets for local dev, `KEY=value` per line, from `.dev.vars` beside the
38
56
  * config — the same file Wrangler uses. Deployed secrets live in the control
@@ -70,68 +88,118 @@ async function readBindings(artifactDir: string): Promise<Partial<Bindings> | un
70
88
  return record as Partial<Bindings> | undefined;
71
89
  }
72
90
 
73
- type Running = {
91
+ export type Running = {
74
92
  sprout: Bun.Subprocess;
93
+ sproutPath: string;
94
+ artifactDir: string;
75
95
  broker: Broker;
76
96
  stopBroker: () => void;
77
97
  /** Set before a kill we initiated, so its exit code is not reported as a crash. */
78
98
  expected: boolean;
99
+ port: number;
100
+ enableDispatch: () => void;
101
+ disableDispatch: () => void;
79
102
  };
80
103
 
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
- });
104
+ async function start(input: DevInput, port: number): Promise<Running> {
105
+ const candidateDir = resolve(
106
+ input.projectDir,
107
+ ".sproutboat/dev-build",
108
+ `${Date.now()}-${Math.random().toString(16).slice(2)}`,
109
+ );
110
+ let artifact;
111
+ try {
112
+ artifact = await buildArtifact({
113
+ projectDir: input.projectDir,
114
+ config: input.config,
115
+ sourcePath: input.sourcePath,
116
+ source: input.source,
117
+ target: "host",
118
+ // Never deployable, rebuilt on every edit: buy the iteration loop.
119
+ optimize: "dev",
120
+ reuseSproutPath: input.reuseSproutPath,
121
+ outputDirectory: candidateDir,
122
+ });
123
+ } catch (error) {
124
+ await rm(candidateDir, { recursive: true, force: true });
125
+ throw error;
126
+ }
89
127
  const artifactDir = artifact.artifactDir;
90
128
  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: () => {
129
+ let dispatchEnabled = false;
130
+ try {
131
+ const stateDir = resolve(input.projectDir, ".sproutboat/dev");
132
+ await mkdir(stateDir, { recursive: true });
133
+ const assetsDir = resolve(artifactDir, "assets");
134
+ const broker = createBroker({
135
+ db: resolve(stateDir, "state.sqlite"),
136
+ dataDir: resolve(stateDir, "d1"),
137
+ resourceDir: resolve(stateDir, "resources"),
138
+ token: "sproutboat-dev",
139
+ bindings: await readBindings(artifactDir),
140
+ secrets: await readDevVars(input.projectDir),
141
+ sproutUrl: `http://127.0.0.1:${port}/`,
142
+ assetsDir: existsSync(assetsDir) ? assetsDir : undefined,
143
+ dispatchEnabled: () => dispatchEnabled,
144
+ });
145
+ let server: ReturnType<typeof listen>;
146
+ try {
147
+ server = listen(broker, "127.0.0.1", 0);
148
+ } catch (error) {
149
+ broker.close();
150
+ throw error;
151
+ }
152
+ let sprout: Bun.Subprocess;
153
+ try {
154
+ sprout = Bun.spawn([sproutPath], {
155
+ cwd: dirname(sproutPath),
156
+ env: {
157
+ ...process.env,
158
+ PORT: String(port),
159
+ SB_BROKER_PORT: String(server.port),
160
+ SB_BROKER_TOKEN: "sproutboat-dev",
161
+ },
162
+ stdout: "inherit",
163
+ stderr: "inherit",
164
+ });
165
+ } catch (error) {
124
166
  server.stop();
125
167
  broker.close();
126
- },
127
- expected: false,
128
- };
168
+ throw error;
169
+ }
170
+ return {
171
+ sprout,
172
+ sproutPath,
173
+ artifactDir,
174
+ broker,
175
+ stopBroker: () => {
176
+ server.stop();
177
+ broker.close();
178
+ },
179
+ expected: false,
180
+ port,
181
+ enableDispatch: () => {
182
+ dispatchEnabled = true;
183
+ },
184
+ disableDispatch: () => {
185
+ dispatchEnabled = false;
186
+ },
187
+ };
188
+ } catch (error) {
189
+ await rm(candidateDir, { recursive: true, force: true });
190
+ throw error;
191
+ }
129
192
  }
130
193
 
131
194
  function stop(running: Running): void {
195
+ if (running.expected) return;
132
196
  running.expected = true;
197
+ running.disableDispatch();
133
198
  running.sprout.kill(9);
134
199
  running.stopBroker();
200
+ // Every candidate owns an isolated snapshot. Once it is no longer serving,
201
+ // remove it so repeated edits do not grow .sproutboat/dev-build forever.
202
+ void rm(running.artifactDir, { recursive: true, force: true });
135
203
  }
136
204
 
137
205
  /** Report a sprout that died on its own; a kill we asked for is not news. */
@@ -142,42 +210,173 @@ function watchExit(running: Running): void {
142
210
  });
143
211
  }
144
212
 
213
+ /** Pick an unused loopback port without keeping it reserved. The candidate is
214
+ * started immediately afterwards; the tiny race is preferable to interrupting
215
+ * the current server just to discover a bad replacement. */
216
+ function candidatePort(): number {
217
+ const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
218
+ const port = probe.port;
219
+ probe.stop();
220
+ if (port === undefined) throw new Error("could not allocate a candidate port");
221
+ return port;
222
+ }
223
+
224
+ async function waitUntilReady(running: Running): Promise<void> {
225
+ const deadline = Date.now() + 3_000;
226
+ while (Date.now() < deadline) {
227
+ if (running.expected) throw new Error("candidate stopped before becoming ready");
228
+ const exited = await Promise.race([running.sprout.exited.then(() => true), Bun.sleep(30).then(() => false)]);
229
+ if (exited) throw new Error("candidate sprout exited before becoming ready");
230
+ if (await tcpReady(running.port, Math.min(250, deadline - Date.now()))) return;
231
+ }
232
+ throw new Error("candidate sprout did not become ready within 3s");
233
+ }
234
+
235
+ /** Readiness is a bounded TCP connect, never an application request: booting a
236
+ * candidate must not execute user code or wait forever on a handler. */
237
+ export function tcpReady(port: number, timeout: number): Promise<boolean> {
238
+ return new Promise((resolveReady) => {
239
+ const socket = connect({ host: "127.0.0.1", port });
240
+ const finish = (ready: boolean) => {
241
+ socket.removeAllListeners();
242
+ socket.destroy();
243
+ resolveReady(ready);
244
+ };
245
+ socket.once("connect", () => finish(true));
246
+ socket.once("error", () => finish(false));
247
+ socket.setTimeout(timeout, () => finish(false));
248
+ });
249
+ }
250
+
145
251
  /** Build, run, and (optionally) rebuild on change. Resolves only on shutdown. */
146
252
  export async function runDev(input: DevInput): Promise<void> {
147
- let running = await start(input, input.source);
253
+ let current = input;
254
+ const now = input.now ?? Date.now;
255
+ const startCandidate = input.candidateFactory ?? start;
256
+ let running = await startCandidate(current, candidatePort());
257
+ try {
258
+ await waitUntilReady(running);
259
+ } catch (error) {
260
+ stop(running);
261
+ throw error;
262
+ }
263
+ running.enableDispatch();
264
+ let activePort = running.port;
265
+ // Keep the public port stable while candidates boot on private ports. This is
266
+ // what lets a failed startup leave the last known-good process reachable.
267
+ let proxy: ReturnType<typeof Bun.serve>;
268
+ try {
269
+ proxy = Bun.serve({
270
+ hostname: "127.0.0.1",
271
+ port: input.port,
272
+ fetch(request) {
273
+ const target = new URL(request.url);
274
+ target.host = `127.0.0.1:${activePort}`;
275
+ return fetch(target, {
276
+ method: request.method,
277
+ headers: request.headers,
278
+ body: request.body,
279
+ redirect: "manual",
280
+ }).catch(() => new Response("sprout unavailable", { status: 502 }));
281
+ },
282
+ });
283
+ } catch (error) {
284
+ stop(running);
285
+ throw error;
286
+ }
148
287
  watchExit(running);
149
288
  console.log(ok(`${input.config.name} running on ${leaf(`http://127.0.0.1:${input.port}`)}`));
150
289
  if (input.watch) console.log(dim(" watching for changes — ctrl-c to stop"));
151
290
 
152
- const watchers: FSWatcher[] = [];
291
+ let watchers: FSWatcher[] = [];
153
292
  let shuttingDown = false;
154
293
  let resolveShutdown: (() => void) | null = null;
294
+ let pendingCandidate: Running | null = null;
155
295
  const shutdown = () => {
156
296
  if (shuttingDown) return;
157
297
  shuttingDown = true;
158
298
  for (const watcher of watchers) watcher.close();
299
+ proxy.stop();
300
+ if (pendingCandidate) stop(pendingCandidate);
159
301
  stop(running);
160
302
  resolveShutdown?.();
161
- process.exit(0);
303
+ if (input.exitOnShutdown !== false) process.exit(0);
162
304
  };
163
- for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, shutdown);
305
+ const signals = ["SIGINT", "SIGTERM"] as const;
306
+ for (const signal of signals) process.on(signal, shutdown);
307
+ let rebuildTask: Promise<void> | null = null;
164
308
 
165
309
  if (input.watch) {
166
310
  let pending: ReturnType<typeof setTimeout> | null = null;
167
311
  let rebuilding = false;
312
+ let dirty = false;
313
+ const resetWatchers = () => {
314
+ for (const watcher of watchers) watcher.close();
315
+ const next: FSWatcher[] = [
316
+ watch(dirname(current.sourcePath), { recursive: true }, onChange),
317
+ // Watch the containing directory as well: atomic-save editors replace
318
+ // the config inode, which otherwise silently detaches a file watcher.
319
+ watch(current.projectDir, { recursive: false }, onChange),
320
+ ];
321
+ if (current.config.assets) {
322
+ const assets = resolve(current.projectDir, current.config.assets.directory);
323
+ // The parent sees a generated directory being atomically replaced;
324
+ // the directory watcher sees updates inside an existing snapshot.
325
+ next.push(watch(dirname(assets), { recursive: false }, onChange));
326
+ if (existsSync(assets)) next.push(watch(assets, { recursive: true }, onChange));
327
+ }
328
+ watchers = next;
329
+ };
168
330
  const onChange = () => {
331
+ dirty = true;
169
332
  if (pending !== null) clearTimeout(pending);
170
333
  // Editors write a file in several syscalls; one save should be one build.
171
334
  pending = setTimeout(() => {
172
- void (async () => {
335
+ rebuildTask = (async () => {
173
336
  if (rebuilding || shuttingDown) return;
174
337
  rebuilding = true;
175
338
  try {
176
- const source = await input.rebuild();
339
+ dirty = false;
340
+ const editStartedAt = now();
341
+ const next = await current.rebuild();
177
342
  console.log(dim(" change detected, rebuilding…"));
178
- stop(running);
179
- running = await start(input, source);
343
+ const assetOnly = isAssetOnlyRefresh(current, next);
344
+ const candidate = await startCandidate(
345
+ { ...current, ...next, reuseSproutPath: assetOnly ? running.sproutPath : undefined },
346
+ candidatePort(),
347
+ );
348
+ pendingCandidate = candidate;
349
+ if (shuttingDown) {
350
+ stop(candidate);
351
+ pendingCandidate = null;
352
+ return;
353
+ }
354
+ try {
355
+ await waitUntilReady(candidate);
356
+ } catch (error) {
357
+ stop(candidate);
358
+ pendingCandidate = null;
359
+ throw error;
360
+ }
361
+ if (shuttingDown) {
362
+ stop(candidate);
363
+ pendingCandidate = null;
364
+ return;
365
+ }
366
+ // Only now is the public route switched. The old process stays up
367
+ // through compilation and candidate startup.
368
+ const previous = running;
369
+ previous.disableDispatch();
370
+ candidate.enableDispatch();
371
+ running = candidate;
372
+ pendingCandidate = null;
373
+ current = { ...current, ...next };
374
+ const switchedAt = now();
375
+ activePort = candidate.port;
376
+ resetWatchers();
377
+ stop(previous);
180
378
  watchExit(running);
379
+ input.onReload?.({ editToReadyMs: switchedAt - editStartedAt, downtimeMs: now() - switchedAt });
181
380
  console.log(ok(` reloaded on http://127.0.0.1:${input.port}`));
182
381
  } catch (cause) {
183
382
  // Keep the last good build serving; a typo should not take the
@@ -189,14 +388,13 @@ export async function runDev(input: DevInput): Promise<void> {
189
388
  );
190
389
  } finally {
191
390
  rebuilding = false;
391
+ if (dirty && !shuttingDown) onChange();
192
392
  }
193
393
  })();
394
+ void rebuildTask;
194
395
  }, RESTART_DEBOUNCE_MS);
195
396
  };
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));
397
+ resetWatchers();
200
398
  }
201
399
 
202
400
  // Watching, we stay up until a signal: a crashed sprout is something to fix
@@ -206,8 +404,13 @@ export async function runDev(input: DevInput): Promise<void> {
206
404
  await new Promise<void>((resolve) => {
207
405
  resolveShutdown = resolve;
208
406
  });
407
+ // A signal can arrive while a candidate factory is building. Let that task
408
+ // return its newly-created resources to the coordinator, which observes
409
+ // `shuttingDown` and stops them, before declaring shutdown complete.
410
+ await rebuildTask;
209
411
  } else {
210
412
  await running.sprout.exited;
211
413
  stop(running);
212
414
  }
415
+ for (const signal of signals) process.off(signal, shutdown);
213
416
  }
package/src/json.ts CHANGED
@@ -1,38 +1,3 @@
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
- }
1
+ // Re-exported from @sproutboat/wire (moved verbatim with broker.ts).
2
+ // This shim keeps every `./json` importer working.
3
+ export * from "@sproutboat/wire";