@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,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
|
+
}
|