@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.
- package/.parachute/config/schema +62 -0
- package/.parachute/info +14 -0
- package/.parachute/module.json +14 -0
- package/CHANGELOG.md +537 -0
- package/LICENSE +661 -0
- package/bin/parachute-app.ts +525 -0
- package/dist/admin/assets/index-BXlRNPxk.js +60 -0
- package/dist/admin/assets/index-DaGP1hmw.css +1 -0
- package/dist/admin/index.html +14 -0
- package/package.json +51 -0
- package/src/admin-routes.ts +884 -0
- package/src/auth.ts +212 -0
- package/src/bootstrap.ts +153 -0
- package/src/cache-headers.ts +106 -0
- package/src/config.ts +289 -0
- package/src/dcr.ts +334 -0
- package/src/dev-injection.ts +166 -0
- package/src/dev-mode.ts +205 -0
- package/src/dev-routes.ts +380 -0
- package/src/dev-watcher.ts +479 -0
- package/src/http-server.ts +682 -0
- package/src/index.ts +394 -0
- package/src/meta-schema.ts +715 -0
- package/src/npm-fetch.ts +320 -0
- package/src/operator-token.ts +95 -0
- package/src/provision-schema.ts +180 -0
- package/src/self-register.ts +184 -0
- package/src/services-manifest.ts +104 -0
- package/src/tenancy-injection.ts +149 -0
- package/src/ui-registry.ts +202 -0
|
@@ -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
|
+
}
|