@openparachute/app 0.2.0-rc.10

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,479 @@
1
+ /**
2
+ * Dev-mode file watcher + optional auto-rebuild — Phase 3.0.
3
+ *
4
+ * Closes the dev-mode loop Phase 1.3 left half-open. Phase 1.3 shipped
5
+ * SSE live-reload but the operator still had to call
6
+ * `parachute-app dev <name> --trigger` (or rebuild a watched dist/) to
7
+ * fire a reload. Phase 3.0 wires a process-local file watcher per UI in
8
+ * dev mode so any edit under the UI's source tree:
9
+ *
10
+ * 1. (optional) re-runs the operator-declared `dev_build_cmd` to
11
+ * produce a fresh `dist/`, and
12
+ * 2. broadcasts a `reload` event to every connected SSE subscriber
13
+ * (the injected EventSource shim in `dev-injection.ts`).
14
+ *
15
+ * Design choices:
16
+ *
17
+ * - `node:fs.watch(..., { recursive: true })` over `Bun.watch` because
18
+ * it's the lower-level primitive Bun also implements on macOS +
19
+ * Linux + Windows. Recursive watches work out-of-the-box on macOS
20
+ * (FSEvents) and Linux (inotify since Node 20). On systems where
21
+ * recursive is unsupported, the watcher logs + falls back to non-
22
+ * recursive — better than silently missing nested edits.
23
+ *
24
+ * - **Filtering.** We ignore changes inside `dist/` and
25
+ * `node_modules/` (relative to the watch root). A naive watcher
26
+ * loops on its own build output — the build writes to dist/, the
27
+ * watcher fires, the build runs again. The filter is a path-prefix
28
+ * check on the reported `filename` (no `stat()` per event — the
29
+ * hot path stays allocation-light).
30
+ *
31
+ * - **Debounce.** Build tools touch many files in quick succession.
32
+ * We coalesce file events into one reload per quiet-window
33
+ * (`dev_debounce_ms` from meta.json, default 250ms; floor 50ms).
34
+ * A pending build/reload cycle is cancelled if a new event fires
35
+ * before the timer expires; only the LATEST event fires the work.
36
+ *
37
+ * - **Build execution.** When `meta.dev_build_cmd` is set, we
38
+ * `Bun.spawn(["sh", "-c", cmd], { cwd: uiRootDir })` after the
39
+ * debounce expires. A 60s timeout aborts long-running builds.
40
+ * Success (exit 0) → broadcast reload. Failure → log stderr +
41
+ * stdout (truncated to keep daemon logs sane), no reload broadcast,
42
+ * watch stays armed (the next edit retries the build). Phase 4+
43
+ * may surface build failure to the browser as a status event; for
44
+ * MVP we just log.
45
+ *
46
+ * - **Build serialization.** Per-UI single-flight: if a build is
47
+ * already running when the next debounced batch lands, we mark the
48
+ * watcher dirty and re-run once the current build finishes. We
49
+ * don't run two builds in parallel for the same UI — that race is
50
+ * a reliable way to corrupt `dist/`.
51
+ *
52
+ * - **Lifecycle.** `start()` is idempotent — calling twice for the
53
+ * same UI replaces the previous watcher (operator might toggle
54
+ * dev_watch_dir at runtime via admin SPA in a future phase).
55
+ * `stop()` cancels pending timers, kills the in-flight build via
56
+ * its AbortController, and closes the FSWatcher. `stopAll()` is the
57
+ * test-mode + daemon-shutdown reaper.
58
+ *
59
+ * - **Test seams.** `spawnFn` lets unit tests inject a fake spawner
60
+ * so we don't fork a shell. `nowFn` + `setTimeoutFn` are NOT
61
+ * mocked — the tests use real timers because the debounce window
62
+ * is small and the wall-clock cost is negligible.
63
+ *
64
+ * State design echoes `dev-mode.ts`: process-local map, single-
65
+ * threaded mutations, no locking needed under Bun's event loop.
66
+ */
67
+
68
+ import { type FSWatcher, existsSync, watch as fsWatch, statSync } from "node:fs";
69
+ import * as path from "node:path";
70
+
71
+ import { broadcastReload } from "./dev-mode.ts";
72
+
73
+ /** Default debounce window when meta.json doesn't override. */
74
+ export const DEFAULT_DEBOUNCE_MS = 250;
75
+ /** Floor enforced even when meta.json declares a smaller value. */
76
+ export const MIN_DEBOUNCE_MS = 50;
77
+ /** Maximum build time before we abort + skip the reload. */
78
+ export const BUILD_TIMEOUT_MS = 60_000;
79
+ /** Output truncation cap so a runaway build doesn't drown the daemon log. */
80
+ const LOG_OUTPUT_LIMIT = 4_000;
81
+
82
+ /**
83
+ * Shape `Bun.spawn`-equivalent test mocks need to fulfill. Mirrors
84
+ * `npm-fetch.ts`'s `NpmSpawnFn` — we accept the env + signal hook
85
+ * because the watcher's spawn path needs an AbortController for the
86
+ * 60s timeout.
87
+ */
88
+ export type DevSpawnFn = (
89
+ argv: string[],
90
+ opts: { cwd: string; signal?: AbortSignal },
91
+ ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
92
+
93
+ const DEFAULT_SPAWN: DevSpawnFn = async (argv, { cwd, signal }) => {
94
+ const proc = Bun.spawn(argv, {
95
+ cwd,
96
+ stdout: "pipe",
97
+ stderr: "pipe",
98
+ });
99
+ // Bridge AbortSignal → process kill. Bun.spawn doesn't yet take a
100
+ // `signal` option natively (as of bun 1.3); we wire it manually.
101
+ const onAbort = () => {
102
+ try {
103
+ proc.kill();
104
+ } catch {
105
+ // already exited
106
+ }
107
+ };
108
+ if (signal) {
109
+ if (signal.aborted) onAbort();
110
+ else signal.addEventListener("abort", onAbort, { once: true });
111
+ }
112
+ const [stdout, stderr] = await Promise.all([
113
+ new Response(proc.stdout).text(),
114
+ new Response(proc.stderr).text(),
115
+ ]);
116
+ const exitCode = await proc.exited;
117
+ if (signal) signal.removeEventListener("abort", onAbort);
118
+ return { exitCode, stdout, stderr };
119
+ };
120
+
121
+ /**
122
+ * Options that drive a single per-UI watcher. The `name` and `uiRootDir`
123
+ * pair identifies which UI this watch belongs to; everything else is
124
+ * configuration parsed from `meta.json` + caller overrides.
125
+ */
126
+ export type WatchOpts = {
127
+ /** UI name; used for log prefix + reload broadcast key. */
128
+ name: string;
129
+ /** Absolute path to the UI's root dir (`<uis>/<dirName>/`). */
130
+ uiRootDir: string;
131
+ /**
132
+ * Path relative to `uiRootDir` the watcher monitors. When undefined,
133
+ * defaults to `uiRootDir` itself — minus `dist/` and `node_modules/`
134
+ * which the event filter discards.
135
+ */
136
+ watchDir?: string;
137
+ /**
138
+ * Shell command (e.g. `"bun run build"`) run on each debounced batch.
139
+ * When undefined, the watcher skips the build step and broadcasts the
140
+ * reload directly. cwd is always `uiRootDir`.
141
+ */
142
+ buildCmd?: string;
143
+ /** Debounce window in ms; clamped to `[MIN_DEBOUNCE_MS, ∞]`. */
144
+ debounceMs?: number;
145
+ /** Spawner override (tests). Defaults to `Bun.spawn`. */
146
+ spawnFn?: DevSpawnFn;
147
+ /**
148
+ * Per-call override for the build timeout. Production code never sets
149
+ * this — it exists so unit tests can drop the 60s ceiling to something
150
+ * a test can wait for (~100ms) without slowing the suite. Falsy /
151
+ * undefined → use `BUILD_TIMEOUT_MS`.
152
+ */
153
+ buildTimeoutMs?: number;
154
+ /** Logger override; default console. */
155
+ logger?: Pick<Console, "log" | "warn" | "error">;
156
+ };
157
+
158
+ type WatcherSlot = {
159
+ name: string;
160
+ watcher: FSWatcher;
161
+ /** Absolute path the watcher monitors. */
162
+ watchedAbsDir: string;
163
+ /** Build command (post-config). */
164
+ buildCmd?: string;
165
+ /** Resolved debounce after clamping. */
166
+ debounceMs: number;
167
+ /** Pending debounce timer (cleared on stop + when new events arrive). */
168
+ pendingTimer?: ReturnType<typeof setTimeout>;
169
+ /** Cwd for the build spawn. */
170
+ cwd: string;
171
+ /** Spawn function captured at start time. */
172
+ spawn: DevSpawnFn;
173
+ /** Per-slot build-timeout (test seam). Defaults to `BUILD_TIMEOUT_MS`. */
174
+ buildTimeoutMs: number;
175
+ /** Logger captured at start time. */
176
+ logger: Pick<Console, "log" | "warn" | "error">;
177
+ /** A build is currently in flight (single-flight per UI). */
178
+ building: boolean;
179
+ /** Set when a fresh batch fires while `building` — re-run on completion. */
180
+ rerunPending: boolean;
181
+ /**
182
+ * AbortController for the in-flight build. `stop()` aborts; the
183
+ * spawn promise resolves with the kill exit-code and we treat it as
184
+ * "no reload" (the next batch — or `stop()`'s reaper — handles it).
185
+ */
186
+ buildAbort?: AbortController;
187
+ };
188
+
189
+ const SLOTS = new Map<string, WatcherSlot>();
190
+
191
+ /**
192
+ * Start (or replace) the watcher for a UI. Idempotent — calling with
193
+ * the same `name` reaps the prior slot first so meta.json edits to
194
+ * `dev_watch_dir` / `dev_build_cmd` propagate cleanly.
195
+ *
196
+ * Returns the resolved absolute watch dir + debounce so callers can
197
+ * log "watching <dir> @ <ms>ms" in their own messages. Throws only if
198
+ * the watch dir doesn't exist (operator config error worth surfacing);
199
+ * everything else falls through to a logged warning and a no-op slot.
200
+ */
201
+ export function startWatcher(opts: WatchOpts): { watchedAbsDir: string; debounceMs: number } {
202
+ // Reap any prior slot first — supports meta.json edits.
203
+ stopWatcher(opts.name);
204
+
205
+ const logger = opts.logger ?? console;
206
+ const spawn = opts.spawnFn ?? DEFAULT_SPAWN;
207
+ const debounceMs = clampDebounce(opts.debounceMs);
208
+
209
+ // Resolve the watch dir: relative paths join under uiRootDir; absolute
210
+ // paths win. Default to uiRootDir itself.
211
+ const watchedAbsDir = resolveWatchDir(opts.uiRootDir, opts.watchDir);
212
+
213
+ if (!existsSync(watchedAbsDir)) {
214
+ // Surface as a thrown error so the caller (typically the dev-routes
215
+ // `enable` handler) can report a 4xx instead of silently arming a
216
+ // non-firing watcher.
217
+ throw new DevWatcherError(
218
+ `watch dir does not exist: ${watchedAbsDir} (resolved from meta.dev_watch_dir="${opts.watchDir ?? "<default>"}")`,
219
+ "watch_dir_missing",
220
+ );
221
+ }
222
+
223
+ let st: ReturnType<typeof statSync>;
224
+ try {
225
+ st = statSync(watchedAbsDir);
226
+ } catch (e) {
227
+ throw new DevWatcherError(
228
+ `failed to stat watch dir ${watchedAbsDir}: ${(e as Error).message}`,
229
+ "watch_dir_stat_failed",
230
+ );
231
+ }
232
+ if (!st.isDirectory()) {
233
+ throw new DevWatcherError(
234
+ `watch dir is not a directory: ${watchedAbsDir}`,
235
+ "watch_dir_not_directory",
236
+ );
237
+ }
238
+
239
+ // Construct the FSWatcher. We pass `{ recursive: true }` — supported
240
+ // out-of-the-box on macOS + recent Node/Bun on Linux. If recursive is
241
+ // somehow unsupported, fall back to non-recursive (worse, but better
242
+ // than throwing).
243
+ let watcher: FSWatcher;
244
+ try {
245
+ watcher = fsWatch(watchedAbsDir, { recursive: true, persistent: false }, (_event, filename) => {
246
+ handleEvent(opts.name, filename ?? "");
247
+ });
248
+ } catch (e) {
249
+ logger.warn(
250
+ `[app] dev-watcher: recursive watch failed (${(e as Error).message}); falling back to non-recursive on ${watchedAbsDir}`,
251
+ );
252
+ watcher = fsWatch(watchedAbsDir, { persistent: false }, (_event, filename) => {
253
+ handleEvent(opts.name, filename ?? "");
254
+ });
255
+ }
256
+
257
+ const slot: WatcherSlot = {
258
+ name: opts.name,
259
+ watcher,
260
+ watchedAbsDir,
261
+ buildCmd: opts.buildCmd,
262
+ debounceMs,
263
+ cwd: opts.uiRootDir,
264
+ spawn,
265
+ buildTimeoutMs:
266
+ opts.buildTimeoutMs && opts.buildTimeoutMs > 0 ? opts.buildTimeoutMs : BUILD_TIMEOUT_MS,
267
+ logger,
268
+ building: false,
269
+ rerunPending: false,
270
+ };
271
+ SLOTS.set(opts.name, slot);
272
+
273
+ logger.log(
274
+ `[app] dev-watcher: watching ${watchedAbsDir} for ${opts.name}${
275
+ opts.buildCmd ? ` (build: \`${opts.buildCmd}\`)` : " (no build cmd)"
276
+ } debounce=${debounceMs}ms`,
277
+ );
278
+ return { watchedAbsDir, debounceMs };
279
+ }
280
+
281
+ /**
282
+ * Stop the watcher for a UI. Idempotent; safe to call when nothing is
283
+ * registered. Clears any pending debounce timer, aborts in-flight
284
+ * builds, and closes the underlying FSWatcher.
285
+ */
286
+ export function stopWatcher(name: string): void {
287
+ const slot = SLOTS.get(name);
288
+ if (!slot) return;
289
+ if (slot.pendingTimer) clearTimeout(slot.pendingTimer);
290
+ slot.pendingTimer = undefined;
291
+ if (slot.buildAbort) {
292
+ try {
293
+ slot.buildAbort.abort();
294
+ } catch {
295
+ // ignore — controller may have already fired
296
+ }
297
+ }
298
+ try {
299
+ slot.watcher.close();
300
+ } catch {
301
+ // already closed
302
+ }
303
+ SLOTS.delete(name);
304
+ slot.logger.log(`[app] dev-watcher: stopped for ${name}`);
305
+ }
306
+
307
+ /** Whether a watcher is currently active for `name`. */
308
+ export function isWatching(name: string): boolean {
309
+ return SLOTS.has(name);
310
+ }
311
+
312
+ /**
313
+ * Diagnostic snapshot — used by the status endpoint + admin SPA to
314
+ * render "watching <dir>" sub-text on the dev badge.
315
+ */
316
+ export type WatcherStatus = {
317
+ name: string;
318
+ watchedAbsDir: string;
319
+ debounceMs: number;
320
+ buildCmd?: string;
321
+ building: boolean;
322
+ };
323
+
324
+ export function watcherStatus(name: string): WatcherStatus | undefined {
325
+ const slot = SLOTS.get(name);
326
+ if (!slot) return undefined;
327
+ return {
328
+ name: slot.name,
329
+ watchedAbsDir: slot.watchedAbsDir,
330
+ debounceMs: slot.debounceMs,
331
+ buildCmd: slot.buildCmd,
332
+ building: slot.building,
333
+ };
334
+ }
335
+
336
+ /** Stop every watcher. Used on shutdown + tests. */
337
+ export function stopAllWatchers(): void {
338
+ for (const name of [...SLOTS.keys()]) stopWatcher(name);
339
+ }
340
+
341
+ /**
342
+ * Custom error so the route handler can distinguish "operator misconfig
343
+ * — surface as a 4xx" from "internal failure — log + 5xx". `code` is
344
+ * stable; `message` is human-facing.
345
+ */
346
+ export class DevWatcherError extends Error {
347
+ override name = "DevWatcherError" as const;
348
+ readonly code: string;
349
+ constructor(message: string, code: string) {
350
+ super(message);
351
+ this.code = code;
352
+ }
353
+ }
354
+
355
+ // --- internal -----------------------------------------------------------
356
+
357
+ function clampDebounce(input: number | undefined): number {
358
+ if (input === undefined || !Number.isFinite(input)) return DEFAULT_DEBOUNCE_MS;
359
+ return Math.max(MIN_DEBOUNCE_MS, Math.floor(input));
360
+ }
361
+
362
+ function resolveWatchDir(uiRootDir: string, watchDir: string | undefined): string {
363
+ if (!watchDir) return uiRootDir;
364
+ if (path.isAbsolute(watchDir)) return watchDir;
365
+ return path.resolve(uiRootDir, watchDir);
366
+ }
367
+
368
+ /**
369
+ * Filter that drops events from inside `dist/` and `node_modules/`.
370
+ * `filename` is the path the FSWatcher reported relative to the watch
371
+ * root; on some platforms it can be `""` (rename-without-name) — those
372
+ * we keep because they may signal a top-level event.
373
+ */
374
+ function shouldIgnore(filename: string): boolean {
375
+ if (!filename) return false;
376
+ // Normalize path separators (Windows) — Bun on Windows isn't supported
377
+ // by parachute-app at the moment but the cost is one regex.
378
+ const normalized = filename.replace(/\\/g, "/");
379
+ const segments = normalized.split("/");
380
+ for (const seg of segments) {
381
+ if (seg === "dist" || seg === "node_modules" || seg === ".git") return true;
382
+ }
383
+ // Common transient editor turds.
384
+ const base = segments[segments.length - 1] ?? "";
385
+ if (base.startsWith(".#") || base.endsWith("~")) return true;
386
+ return false;
387
+ }
388
+
389
+ /**
390
+ * Handle a single FSWatcher event. Applies the filter, then resets the
391
+ * debounce timer. The timer callback is what actually runs the build +
392
+ * fires the reload broadcast.
393
+ */
394
+ function handleEvent(name: string, filename: string): void {
395
+ const slot = SLOTS.get(name);
396
+ if (!slot) return;
397
+ if (shouldIgnore(filename)) return;
398
+
399
+ if (slot.pendingTimer) clearTimeout(slot.pendingTimer);
400
+ slot.pendingTimer = setTimeout(() => {
401
+ slot.pendingTimer = undefined;
402
+ void runDebouncedCycle(name);
403
+ }, slot.debounceMs);
404
+ }
405
+
406
+ /**
407
+ * Run one build → broadcast cycle. Honors the single-flight guard: if a
408
+ * build is already in flight, mark `rerunPending` and return. The
409
+ * in-flight build's completion path consumes `rerunPending` and starts
410
+ * the next cycle.
411
+ */
412
+ async function runDebouncedCycle(name: string): Promise<void> {
413
+ const slot = SLOTS.get(name);
414
+ if (!slot) return;
415
+ if (slot.building) {
416
+ slot.rerunPending = true;
417
+ return;
418
+ }
419
+
420
+ // If no build command, fast-path: broadcast immediately.
421
+ if (!slot.buildCmd) {
422
+ const notified = broadcastReload(name);
423
+ slot.logger.log(`[app] dev-watcher: reload broadcast for ${name} (notified=${notified})`);
424
+ return;
425
+ }
426
+
427
+ slot.building = true;
428
+ slot.buildAbort = new AbortController();
429
+ const cmd = slot.buildCmd;
430
+ const startedAt = Date.now();
431
+ slot.logger.log(`[app] dev-watcher: build for ${name} starting: \`${cmd}\``);
432
+
433
+ // Per-slot timeout (default 60s) — abort if the build hangs.
434
+ const timeoutHandle = setTimeout(() => {
435
+ slot.buildAbort?.abort();
436
+ }, slot.buildTimeoutMs);
437
+
438
+ try {
439
+ const result = await slot.spawn(["sh", "-c", cmd], {
440
+ cwd: slot.cwd,
441
+ signal: slot.buildAbort.signal,
442
+ });
443
+ clearTimeout(timeoutHandle);
444
+ const elapsedMs = Date.now() - startedAt;
445
+ if (result.exitCode === 0) {
446
+ slot.logger.log(`[app] dev-watcher: build for ${name} succeeded in ${elapsedMs}ms`);
447
+ const notified = broadcastReload(name);
448
+ slot.logger.log(`[app] dev-watcher: reload broadcast for ${name} (notified=${notified})`);
449
+ } else {
450
+ slot.logger.warn(
451
+ `[app] dev-watcher: build for ${name} failed (exit=${result.exitCode}, ${elapsedMs}ms) — NOT broadcasting reload`,
452
+ );
453
+ const out = truncate(result.stdout);
454
+ const err = truncate(result.stderr);
455
+ if (out) slot.logger.warn(`[app] dev-watcher: build stdout:\n${out}`);
456
+ if (err) slot.logger.warn(`[app] dev-watcher: build stderr:\n${err}`);
457
+ }
458
+ } catch (e) {
459
+ clearTimeout(timeoutHandle);
460
+ slot.logger.warn(
461
+ `[app] dev-watcher: build for ${name} threw: ${(e as Error).message} — NOT broadcasting reload`,
462
+ );
463
+ } finally {
464
+ slot.building = false;
465
+ slot.buildAbort = undefined;
466
+ // If a debounce-batch landed while we were building, run again.
467
+ if (slot.rerunPending) {
468
+ slot.rerunPending = false;
469
+ // Re-enter via the same path; no recursion concerns since the
470
+ // function is async + awaited internally.
471
+ void runDebouncedCycle(name);
472
+ }
473
+ }
474
+ }
475
+
476
+ function truncate(s: string): string {
477
+ if (s.length <= LOG_OUTPUT_LIMIT) return s;
478
+ return `${s.slice(0, LOG_OUTPUT_LIMIT)}\n… (truncated, ${s.length - LOG_OUTPUT_LIMIT} more chars)`;
479
+ }