@openparachute/app 0.2.0-rc.4

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,380 @@
1
+ /**
2
+ * Dev-mode HTTP routes — Phase 1.3.
3
+ *
4
+ * Two surfaces:
5
+ *
6
+ * GET /app/<name>/_dev/reload — SSE stream (unauthenticated; the
7
+ * UI's injected reload script reads
8
+ * it at page load before any token
9
+ * exists, same affordance as the
10
+ * OAuth-client discovery endpoint).
11
+ * 404 when the UI isn't in dev mode.
12
+ * POST /app/<name>/dev/enable — flip dev mode on (app:admin)
13
+ * POST /app/<name>/dev/disable — flip dev mode off (app:admin)
14
+ * POST /app/<name>/dev/trigger — broadcast a reload event (app:admin)
15
+ * GET /app/dev/list — UIs in dev mode (app:read)
16
+ *
17
+ * The SSE endpoint stays open as long as the client is connected; we hold
18
+ * a per-stream subscriber in `dev-mode.ts`'s registry and broadcast to
19
+ * every subscriber when `/dev/trigger` fires. Disconnects clean up via
20
+ * the stream's `cancel` hook.
21
+ *
22
+ * Why a separate dispatcher (mirrors `routeAdmin`):
23
+ *
24
+ * Keeping dev routes out of admin-routes.ts means Phase 2's auto-reload
25
+ * (file watcher driving `broadcastReload`) doesn't have to thread state
26
+ * through the admin handlers. The dispatcher shape is the same — fall
27
+ * through to `{ handled: false }` so the caller's per-UI matcher fires.
28
+ */
29
+
30
+ import { SCOPE_ADMIN, SCOPE_READ, enforceScope as defaultEnforceScope } from "./auth.ts";
31
+ import {
32
+ type DevReloadSubscriber,
33
+ addSubscriber,
34
+ broadcastReload,
35
+ disableDevMode,
36
+ enableDevMode,
37
+ getDevMode,
38
+ isDevMode,
39
+ listDevMode,
40
+ removeSubscriber,
41
+ subscriberCount,
42
+ } from "./dev-mode.ts";
43
+ import {
44
+ type DevSpawnFn,
45
+ DevWatcherError,
46
+ startWatcher as defaultStartWatcher,
47
+ stopWatcher as defaultStopWatcher,
48
+ watcherStatus as defaultWatcherStatus,
49
+ isWatching,
50
+ } from "./dev-watcher.ts";
51
+
52
+ import type { AppState } from "./http-server.ts";
53
+
54
+ /**
55
+ * Pluggable façade over the dev-watcher module so tests can swap in
56
+ * fakes without forking shells or arming real FSWatchers. Production
57
+ * code uses the defaults from `./dev-watcher.ts` directly.
58
+ */
59
+ export type DevWatcherFns = {
60
+ startWatcher: (opts: Parameters<typeof defaultStartWatcher>[0]) => {
61
+ watchedAbsDir: string;
62
+ debounceMs: number;
63
+ };
64
+ stopWatcher: (name: string) => void;
65
+ isWatching: (name: string) => boolean;
66
+ watcherStatus: (name: string) => ReturnType<typeof defaultWatcherStatus>;
67
+ };
68
+
69
+ const DEFAULT_WATCHER_FNS: DevWatcherFns = {
70
+ startWatcher: defaultStartWatcher,
71
+ stopWatcher: defaultStopWatcher,
72
+ isWatching,
73
+ watcherStatus: defaultWatcherStatus,
74
+ };
75
+
76
+ export type DevRoutesOpts = {
77
+ state: Pick<AppState, "config" | "registeredUis">;
78
+ /** Test-only seam: replace `enforceScope` with a stub. */
79
+ enforceScopeFn?: (
80
+ req: Request,
81
+ requiredScope: typeof SCOPE_ADMIN | typeof SCOPE_READ,
82
+ ) => Promise<Response | { scopes: readonly string[] }>;
83
+ /**
84
+ * Test-only seam: replace the dev-watcher module functions. When
85
+ * omitted, the real `./dev-watcher.ts` exports are used so the
86
+ * production daemon arms a real FSWatcher on enable.
87
+ */
88
+ watcherFns?: DevWatcherFns;
89
+ /**
90
+ * Phase 3.0 — override the build-command spawner (tests). Forwarded
91
+ * to `startWatcher` so a test can assert on the spawn argv without
92
+ * actually shelling out.
93
+ */
94
+ watcherSpawnFn?: DevSpawnFn;
95
+ /** Logger override; default console. */
96
+ logger?: Pick<Console, "log" | "warn" | "error">;
97
+ };
98
+
99
+ type RouteOutcome = { handled: false } | { handled: true; response: Promise<Response> | Response };
100
+
101
+ const RELOAD_STREAM_RE = /^\/app\/([a-z][a-z0-9-]*)\/_dev\/reload$/;
102
+ const DEV_ENABLE_RE = /^\/app\/([a-z][a-z0-9-]*)\/dev\/enable$/;
103
+ const DEV_DISABLE_RE = /^\/app\/([a-z][a-z0-9-]*)\/dev\/disable$/;
104
+ const DEV_TRIGGER_RE = /^\/app\/([a-z][a-z0-9-]*)\/dev\/trigger$/;
105
+ const DEV_STATUS_RE = /^\/app\/([a-z][a-z0-9-]*)\/dev$/;
106
+
107
+ export function routeDev(req: Request, opts: DevRoutesOpts): RouteOutcome {
108
+ const url = new URL(req.url);
109
+ const pathname = url.pathname;
110
+ const method = req.method;
111
+
112
+ // GET /app/dev/list — admin SPA + CLI's `dev list` reads this.
113
+ if (pathname === "/app/dev/list" && method === "GET") {
114
+ return { handled: true, response: handleList(req, opts) };
115
+ }
116
+
117
+ // GET /app/<name>/_dev/reload — UNAUTHENTICATED SSE stream.
118
+ const streamMatch = pathname.match(RELOAD_STREAM_RE);
119
+ if (streamMatch && method === "GET") {
120
+ return { handled: true, response: handleReloadStream(streamMatch[1]!, opts) };
121
+ }
122
+
123
+ // GET /app/<name>/dev — dev-mode status for one UI (app:read).
124
+ const statusMatch = pathname.match(DEV_STATUS_RE);
125
+ if (statusMatch && method === "GET") {
126
+ return { handled: true, response: handleStatus(req, statusMatch[1]!, opts) };
127
+ }
128
+
129
+ // POST /app/<name>/dev/enable
130
+ const enableMatch = pathname.match(DEV_ENABLE_RE);
131
+ if (enableMatch && method === "POST") {
132
+ return { handled: true, response: handleEnable(req, enableMatch[1]!, opts) };
133
+ }
134
+
135
+ // POST /app/<name>/dev/disable
136
+ const disableMatch = pathname.match(DEV_DISABLE_RE);
137
+ if (disableMatch && method === "POST") {
138
+ return { handled: true, response: handleDisable(req, disableMatch[1]!, opts) };
139
+ }
140
+
141
+ // POST /app/<name>/dev/trigger
142
+ const triggerMatch = pathname.match(DEV_TRIGGER_RE);
143
+ if (triggerMatch && method === "POST") {
144
+ return { handled: true, response: handleTrigger(req, triggerMatch[1]!, opts) };
145
+ }
146
+
147
+ return { handled: false };
148
+ }
149
+
150
+ function runEnforce(
151
+ req: Request,
152
+ scope: typeof SCOPE_ADMIN | typeof SCOPE_READ,
153
+ opts: DevRoutesOpts,
154
+ ): Promise<Response | { scopes: readonly string[] }> {
155
+ if (opts.enforceScopeFn) return opts.enforceScopeFn(req, scope);
156
+ return defaultEnforceScope(req, scope, { hubUrl: opts.state.config.hub_url });
157
+ }
158
+
159
+ function findUi(name: string, opts: DevRoutesOpts) {
160
+ return opts.state.registeredUis.find((u) => u.meta.name === name);
161
+ }
162
+
163
+ function notFoundJson(name: string): Response {
164
+ return Response.json({ error: "not_found", message: `no UI named "${name}"` }, { status: 404 });
165
+ }
166
+
167
+ // --- GET /app/dev/list ---------------------------------------------------
168
+
169
+ async function handleList(req: Request, opts: DevRoutesOpts): Promise<Response> {
170
+ const auth = await runEnforce(req, SCOPE_READ, opts);
171
+ if (auth instanceof Response) return auth;
172
+ const watcherFns = opts.watcherFns ?? DEFAULT_WATCHER_FNS;
173
+ const active = listDevMode().map(({ name, state }) => {
174
+ const ws = watcherFns.watcherStatus(name);
175
+ return {
176
+ name,
177
+ enabled: state.enabled,
178
+ enabledAt: state.enabledAt,
179
+ subscribers: subscriberCount(name),
180
+ watcher: ws
181
+ ? {
182
+ watching: true,
183
+ watchDir: ws.watchedAbsDir,
184
+ debounceMs: ws.debounceMs,
185
+ buildCmd: ws.buildCmd ?? null,
186
+ building: ws.building,
187
+ }
188
+ : { watching: false },
189
+ };
190
+ });
191
+ return Response.json({ uis: active });
192
+ }
193
+
194
+ // --- GET /app/<name>/dev -------------------------------------------------
195
+
196
+ async function handleStatus(req: Request, name: string, opts: DevRoutesOpts): Promise<Response> {
197
+ const auth = await runEnforce(req, SCOPE_READ, opts);
198
+ if (auth instanceof Response) return auth;
199
+ const ui = findUi(name, opts);
200
+ if (!ui) return notFoundJson(name);
201
+ const state = getDevMode(name);
202
+ const watcherFns = opts.watcherFns ?? DEFAULT_WATCHER_FNS;
203
+ const ws = watcherFns.watcherStatus(name);
204
+ return Response.json({
205
+ name,
206
+ enabled: state.enabled,
207
+ enabledAt: state.enabledAt,
208
+ subscribers: subscriberCount(name),
209
+ watcher: ws
210
+ ? {
211
+ watching: true,
212
+ watchDir: ws.watchedAbsDir,
213
+ debounceMs: ws.debounceMs,
214
+ buildCmd: ws.buildCmd ?? null,
215
+ building: ws.building,
216
+ }
217
+ : { watching: false },
218
+ });
219
+ }
220
+
221
+ // --- POST /app/<name>/dev/enable ----------------------------------------
222
+
223
+ async function handleEnable(req: Request, name: string, opts: DevRoutesOpts): Promise<Response> {
224
+ const auth = await runEnforce(req, SCOPE_ADMIN, opts);
225
+ if (auth instanceof Response) return auth;
226
+
227
+ // Honor `config.dev_mode_allowed` so an operator who wants dev mode off
228
+ // for a deploy can lock it down via config.
229
+ if (opts.state.config.dev_mode_allowed === false) {
230
+ return Response.json(
231
+ {
232
+ error: "dev_mode_disabled",
233
+ message: "dev mode is disabled in config (`dev_mode_allowed: false`)",
234
+ },
235
+ { status: 409 },
236
+ );
237
+ }
238
+
239
+ const ui = findUi(name, opts);
240
+ if (!ui) return notFoundJson(name);
241
+
242
+ const state = enableDevMode(name);
243
+
244
+ // Phase 3.0 — arm the file watcher. Best-effort: a watcher failure
245
+ // doesn't unwind dev mode (the operator can still use `--trigger`),
246
+ // but we surface the reason in the response so the admin SPA / CLI
247
+ // can show it. `meta.dev_watch_dir` is the operator override; absent,
248
+ // we default to the UI's root dir (the watcher filters dist/ +
249
+ // node_modules/ to avoid the build-output loop).
250
+ const watcherFns = opts.watcherFns ?? DEFAULT_WATCHER_FNS;
251
+ let watcher: { watchedAbsDir: string; debounceMs: number } | undefined;
252
+ let watcherWarning: string | undefined;
253
+ try {
254
+ watcher = watcherFns.startWatcher({
255
+ name,
256
+ uiRootDir: ui.uiDir,
257
+ watchDir: ui.meta.dev_watch_dir,
258
+ buildCmd: ui.meta.dev_build_cmd,
259
+ debounceMs: ui.meta.dev_debounce_ms,
260
+ spawnFn: opts.watcherSpawnFn,
261
+ logger: opts.logger,
262
+ });
263
+ } catch (e) {
264
+ if (e instanceof DevWatcherError) {
265
+ watcherWarning = e.message;
266
+ opts.logger?.warn(`[app] dev-watcher: failed to start for ${name}: ${e.message}`);
267
+ } else {
268
+ watcherWarning = `unexpected error starting watcher: ${(e as Error).message}`;
269
+ opts.logger?.warn(`[app] dev-watcher: ${watcherWarning}`);
270
+ }
271
+ }
272
+
273
+ opts.logger?.log(
274
+ `[app] dev mode ON for ${name}${watcher ? ` (watching ${watcher.watchedAbsDir})` : ""}`,
275
+ );
276
+ return Response.json({
277
+ ok: true,
278
+ name,
279
+ enabled: state.enabled,
280
+ enabledAt: state.enabledAt,
281
+ subscribers: subscriberCount(name),
282
+ watcher: watcher
283
+ ? {
284
+ watching: true,
285
+ watchDir: watcher.watchedAbsDir,
286
+ debounceMs: watcher.debounceMs,
287
+ buildCmd: ui.meta.dev_build_cmd ?? null,
288
+ }
289
+ : { watching: false, warning: watcherWarning ?? "watcher_unavailable" },
290
+ });
291
+ }
292
+
293
+ // --- POST /app/<name>/dev/disable ---------------------------------------
294
+
295
+ async function handleDisable(req: Request, name: string, opts: DevRoutesOpts): Promise<Response> {
296
+ const auth = await runEnforce(req, SCOPE_ADMIN, opts);
297
+ if (auth instanceof Response) return auth;
298
+ // We tolerate disabling a UI that's missing — the operator wants the
299
+ // state cleaned up, and the in-memory map may have a stale entry.
300
+ const before = isDevMode(name);
301
+ disableDevMode(name);
302
+ // Phase 3.0 — tear down the file watcher (idempotent; no-op if absent).
303
+ const watcherFns = opts.watcherFns ?? DEFAULT_WATCHER_FNS;
304
+ watcherFns.stopWatcher(name);
305
+ opts.logger?.log(`[app] dev mode OFF for ${name}${before ? "" : " (was already off)"}`);
306
+ return Response.json({ ok: true, name, enabled: false, was_on: before });
307
+ }
308
+
309
+ // --- POST /app/<name>/dev/trigger ---------------------------------------
310
+
311
+ async function handleTrigger(req: Request, name: string, opts: DevRoutesOpts): Promise<Response> {
312
+ const auth = await runEnforce(req, SCOPE_ADMIN, opts);
313
+ if (auth instanceof Response) return auth;
314
+ if (!isDevMode(name)) {
315
+ return Response.json(
316
+ {
317
+ error: "dev_mode_off",
318
+ message: `UI "${name}" is not in dev mode; run \`parachute-app dev ${name}\` first`,
319
+ },
320
+ { status: 409 },
321
+ );
322
+ }
323
+ const notified = broadcastReload(name);
324
+ opts.logger?.log(`[app] dev reload broadcast for ${name}: notified=${notified}`);
325
+ return Response.json({ ok: true, name, notified });
326
+ }
327
+
328
+ // --- GET /app/<name>/_dev/reload (SSE, unauthenticated) -----------------
329
+
330
+ function handleReloadStream(name: string, opts: DevRoutesOpts): Response {
331
+ // 404 when dev mode is off — the injected script auto-reconnects via
332
+ // EventSource defaults; once the operator flips dev mode on, the next
333
+ // attempt will succeed.
334
+ if (!isDevMode(name)) {
335
+ return Response.json(
336
+ { error: "dev_mode_off", message: `UI "${name}" is not in dev mode` },
337
+ { status: 404 },
338
+ );
339
+ }
340
+
341
+ let subscriber: DevReloadSubscriber | undefined;
342
+ const stream = new ReadableStream<Uint8Array>({
343
+ start(controller) {
344
+ subscriber = { controller, closed: false };
345
+ addSubscriber(name, subscriber);
346
+ // Emit a comment immediately. SSE clients dispatch on `:` lines as
347
+ // no-op keepalives; this both flushes the response start and tells
348
+ // the client the stream is alive.
349
+ try {
350
+ controller.enqueue(new TextEncoder().encode(`: connected ${Date.now()}\n\n`));
351
+ } catch {
352
+ // controller might already be closed in unit-test fakes.
353
+ }
354
+ opts.logger?.log(
355
+ `[app] dev SSE subscriber connected for ${name} (count=${subscriberCount(name)})`,
356
+ );
357
+ },
358
+ cancel() {
359
+ if (subscriber) {
360
+ subscriber.closed = true;
361
+ removeSubscriber(name, subscriber);
362
+ opts.logger?.log(
363
+ `[app] dev SSE subscriber disconnected for ${name} (count=${subscriberCount(name)})`,
364
+ );
365
+ }
366
+ },
367
+ });
368
+
369
+ return new Response(stream, {
370
+ status: 200,
371
+ headers: {
372
+ "content-type": "text/event-stream",
373
+ "cache-control": "no-cache, no-store, must-revalidate",
374
+ connection: "keep-alive",
375
+ // Disable response buffering for proxies that respect this header
376
+ // (nginx / hub's reverse proxy in particular).
377
+ "x-accel-buffering": "no",
378
+ },
379
+ });
380
+ }