@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/src/index.ts ADDED
@@ -0,0 +1,394 @@
1
+ /**
2
+ * @openparachute/app — library entry.
3
+ *
4
+ * Phase 1.1 wires the public surface: `serve` starts the long-running daemon
5
+ * that scans `$PARACHUTE_HOME/app/uis/`, mounts each declared UI at its
6
+ * declared path, serves the bundle with smart cache headers + SPA-routing
7
+ * fallback, and self-registers into `~/.parachute/services.json`. Admin
8
+ * verbs (`addUi`, `removeUi`, `listUis`, `reloadUi`) and dev mode
9
+ * (`setDevMode`) land in Phase 1.2 / 1.3.
10
+ *
11
+ * See the design doc:
12
+ * https://github.com/ParachuteComputer/parachute.computer/blob/main/design/2026-05-21-parachute-apps-design.md
13
+ */
14
+
15
+ import pkg from "../package.json" with { type: "json" };
16
+
17
+ import { addUiInternal, buildUisExtraFieldForBoot } from "./admin-routes.ts";
18
+ import { maybeBootstrapDefaultApps } from "./bootstrap.ts";
19
+ import { type AppConfig, loadConfig, resolveConfigPath, resolveUisDir } from "./config.ts";
20
+ import { disableDevMode, enableDevMode } from "./dev-mode.ts";
21
+ import { stopAllWatchers } from "./dev-watcher.ts";
22
+ import { type AppState, startHttpServer } from "./http-server.ts";
23
+ import { resolveProjectRoot, selfRegister } from "./self-register.ts";
24
+ import { scanUis } from "./ui-registry.ts";
25
+
26
+ // Re-export everything so callers can drop down to a specific layer
27
+ // without an import-path puzzle.
28
+ export * from "./config.ts";
29
+ export * from "./meta-schema.ts";
30
+ export * from "./cache-headers.ts";
31
+ export * from "./ui-registry.ts";
32
+ export * from "./services-manifest.ts";
33
+ export * from "./auth.ts";
34
+ export * from "./operator-token.ts";
35
+ export * from "./dcr.ts";
36
+ export * from "./npm-fetch.ts";
37
+ export * from "./dev-mode.ts";
38
+ export * from "./dev-injection.ts";
39
+ export * from "./dev-watcher.ts";
40
+ export {
41
+ routeAdmin,
42
+ buildUisExtraFieldForBoot,
43
+ addUiInternal,
44
+ type AdminHandlerOpts,
45
+ type AdminMutableState,
46
+ type AddRequestBody,
47
+ type AddUiInternalResult,
48
+ type SerializedUi,
49
+ } from "./admin-routes.ts";
50
+ export {
51
+ maybeBootstrapDefaultApps,
52
+ type BootstrapOpts,
53
+ type BootstrapResult,
54
+ type BootstrapAddFn,
55
+ } from "./bootstrap.ts";
56
+ export {
57
+ provisionSchemaForUi,
58
+ type ProvisionSchemaOpts,
59
+ type ProvisionSchemaResult,
60
+ } from "./provision-schema.ts";
61
+ export { routeDev, type DevRoutesOpts } from "./dev-routes.ts";
62
+ export { resolveProjectRoot, selfRegister } from "./self-register.ts";
63
+ export type { SelfRegisterOpts, SelfRegisterResult } from "./self-register.ts";
64
+ export { startHttpServer } from "./http-server.ts";
65
+ export type { AppState, HttpServerOpts } from "./http-server.ts";
66
+
67
+ /** Package semver. */
68
+ export const VERSION: string = pkg.version;
69
+
70
+ /** Default healthz port (per design doc + canonical-ports pattern, app claims 1946). */
71
+ export const DEFAULT_PORT = 1946;
72
+
73
+ /** Default mount path for app under hub's reverse proxy. */
74
+ export const DEFAULT_MOUNT = "/app";
75
+
76
+ export type ServeOptions = {
77
+ /** Override the healthz port. Defaults to `DEFAULT_PORT` (1946). */
78
+ port?: number;
79
+ /** Override the config path (tests). Defaults to `resolveConfigPath()`. */
80
+ configPath?: string;
81
+ /** Override the uis-dir location (tests). Defaults to `resolveUisDir()`. */
82
+ uisDir?: string;
83
+ /** Override the bind hostname (tests). Defaults to `127.0.0.1`. */
84
+ hostname?: string;
85
+ /** Override the services.json path (tests). */
86
+ manifestPath?: string;
87
+ /** Skip self-registration (tests don't want to touch `~/.parachute/`). */
88
+ skipSelfRegister?: boolean;
89
+ /** Override `.parachute/` location (tests). */
90
+ parachuteDir?: string;
91
+ /** Logger override; default console. */
92
+ logger?: Pick<Console, "log" | "warn" | "error">;
93
+ /**
94
+ * Override `Bun.serve` (tests). Lets us assert on the dispatched config
95
+ * without binding a real port.
96
+ */
97
+ serveFn?: typeof Bun.serve;
98
+ /**
99
+ * Override the absolute path to the built admin SPA bundle (tests). Defaults
100
+ * to `<package-root>/dist/admin/`.
101
+ */
102
+ adminDir?: string;
103
+ /** Inject fetch for DCR calls (tests). */
104
+ fetchFn?: import("./dcr.ts").FetchFn;
105
+ /** Override the operator-token resolver (tests). */
106
+ operatorTokenOverride?: () => string | undefined;
107
+ /** Override the npm-fetch spawner (tests). */
108
+ npmSpawnFn?: import("./npm-fetch.ts").NpmSpawnFn;
109
+ /**
110
+ * Skip the first-boot default-app bootstrap (tests + CI). When omitted,
111
+ * bootstrap runs iff `state.registeredUis.length === 0` AND
112
+ * `config.bootstrap_default_apps.enabled === true`.
113
+ */
114
+ skipBootstrap?: boolean;
115
+ /**
116
+ * Return a promise from `serve()` that callers can await to know when
117
+ * bootstrap is complete (tests). Production `serve()` callers don't
118
+ * need this; bootstrap is fire-and-forget for the daemon.
119
+ */
120
+ awaitBootstrap?: boolean;
121
+ };
122
+
123
+ export type ServeHandle = {
124
+ /** The currently-resolved app config. */
125
+ config: AppConfig;
126
+ /** The running HTTP server — `server.stop()` for graceful shutdown. */
127
+ server: ReturnType<typeof Bun.serve>;
128
+ /** The mutable state object. */
129
+ state: AppState;
130
+ /** Stop the daemon. */
131
+ stop: () => Promise<void>;
132
+ /**
133
+ * Resolves once first-boot bootstrap completes. `undefined` when
134
+ * bootstrap was skipped (state non-empty or `skipBootstrap: true`).
135
+ * Tests `await handle.bootstrap` to assert post-bootstrap state.
136
+ */
137
+ bootstrap?: Promise<import("./bootstrap.ts").BootstrapResult>;
138
+ };
139
+
140
+ /**
141
+ * Long-running daemon: scan `$PARACHUTE_HOME/app/uis/`, mount each UI at its
142
+ * declared path, serve the bundle with smart cache headers + SPA fallback.
143
+ *
144
+ * Phase 1.1: discovery is one-shot at startup. Phase 1.2 adds reload + watch.
145
+ *
146
+ * Returns a handle the CLI uses to wire SIGINT/SIGTERM into graceful
147
+ * shutdown.
148
+ */
149
+ export function serve(opts: ServeOptions = {}): ServeHandle {
150
+ const logger = opts.logger ?? console;
151
+ const port = opts.port ?? DEFAULT_PORT;
152
+ const hostname = opts.hostname ?? "127.0.0.1";
153
+
154
+ const config = loadConfig({ configPath: opts.configPath, logger });
155
+
156
+ // Kill-switch: when `config.disabled` is true, skip the UI scan entirely
157
+ // so no bundles are mounted. The HTTP server still binds (healthz + the
158
+ // `.parachute/*` admin surface keep working) so an operator can flip the
159
+ // flag back via the admin SPA (Phase 1.2) without restarting the daemon.
160
+ // Per design doc + reviewer nit 3 — `disabled` was loaded but not honored.
161
+ const scan = config.disabled
162
+ ? { registered: [], skipped: [] as Array<{ dirName: string; status: string; reason: string }> }
163
+ : scanUis({ uisDir: opts.uisDir, logger });
164
+
165
+ if (config.disabled) {
166
+ logger.log("[app] disabled (config.disabled=true) — no UIs mounted");
167
+ }
168
+
169
+ const state: AppState = {
170
+ config,
171
+ registeredUis: scan.registered,
172
+ skippedUis: scan.skipped.map((s) => ({
173
+ dirName: s.dirName,
174
+ status: s.status,
175
+ reason: s.reason,
176
+ })),
177
+ };
178
+
179
+ const startedAt = new Date();
180
+ const server = startHttpServer({
181
+ state,
182
+ port,
183
+ hostname,
184
+ startedAt,
185
+ logger,
186
+ parachuteDir: opts.parachuteDir,
187
+ serveFn: opts.serveFn,
188
+ adminDir: opts.adminDir,
189
+ adminOpts: {
190
+ uisDir: opts.uisDir,
191
+ manifestPath: opts.manifestPath,
192
+ fetchFn: opts.fetchFn,
193
+ operatorTokenOverride: opts.operatorTokenOverride,
194
+ npmSpawnFn: opts.npmSpawnFn,
195
+ logger,
196
+ skipSelfRegisterRefresh: opts.skipSelfRegister,
197
+ },
198
+ });
199
+
200
+ logger.log(
201
+ `[app] Listening on http://${hostname}:${server.port} — ${state.registeredUis.length} UI${
202
+ state.registeredUis.length === 1 ? "" : "s"
203
+ } hosted${state.skippedUis.length > 0 ? ` (${state.skippedUis.length} skipped)` : ""}`,
204
+ );
205
+ for (const ui of state.registeredUis) {
206
+ logger.log(`[app] ${ui.meta.path} → ${ui.meta.displayName} (${ui.meta.name})`);
207
+ }
208
+
209
+ // Phase 2.1 — first-boot default-app bootstrap. Runs only when no UIs
210
+ // are mounted (fresh install). Best-effort; failures log + continue so
211
+ // a network blip or unpublished package doesn't prevent daemon
212
+ // startup.
213
+ let bootstrapPromise: Promise<import("./bootstrap.ts").BootstrapResult> | undefined;
214
+ if (!opts.skipBootstrap && !config.disabled && state.registeredUis.length === 0) {
215
+ // Fire-and-forget — daemon doesn't block on bootstrap. The add path
216
+ // re-scans + swaps state in-place, so subsequent requests pick up
217
+ // the newly-mounted UIs without a restart. The promise is exposed
218
+ // on the handle so tests can `await handle.bootstrap`.
219
+ bootstrapPromise = runBootstrap({
220
+ config,
221
+ uisDir: opts.uisDir ?? resolveUisDir(),
222
+ adminOpts: {
223
+ state,
224
+ uisDir: opts.uisDir,
225
+ manifestPath: opts.manifestPath,
226
+ fetchFn: opts.fetchFn,
227
+ operatorTokenOverride: opts.operatorTokenOverride,
228
+ npmSpawnFn: opts.npmSpawnFn,
229
+ logger,
230
+ // Bootstrap's own callsite owns the post-bootstrap selfRegister;
231
+ // skip the per-add refresh to avoid stamping a stale partial
232
+ // services.json mid-iteration.
233
+ skipSelfRegisterRefresh: true,
234
+ },
235
+ manifestPath: opts.manifestPath,
236
+ skipSelfRegister: opts.skipSelfRegister,
237
+ logger,
238
+ }).catch((e) => {
239
+ logger.warn(`[app] bootstrap failed unexpectedly: ${(e as Error).message}`);
240
+ return { bootstrapped: [], skipped: [], failed: [], skipReason: "exception" };
241
+ });
242
+ }
243
+
244
+ if (!opts.skipSelfRegister) {
245
+ // `server.port` is `number | undefined` per Bun's types (it's undefined
246
+ // when the server uses unix sockets, which we don't here) — fall back to
247
+ // the operator's requested port. Both paths produce a `number`.
248
+ const portWritten = server.port ?? port;
249
+ selfRegister({
250
+ boundPort: portWritten,
251
+ installDir: resolveProjectRoot(),
252
+ manifestPath: opts.manifestPath,
253
+ extraFields: { uis: buildUisExtraFieldForBoot(state.registeredUis) },
254
+ logger,
255
+ });
256
+ }
257
+
258
+ const stop = async () => {
259
+ logger.log("[app] shutting down");
260
+ // Tear down any dev-mode file watchers so the process exits cleanly.
261
+ // The watcher slots own AbortControllers + FSWatchers; without this
262
+ // the daemon can hang on shutdown until the FSEvents stream closes.
263
+ stopAllWatchers();
264
+ server.stop();
265
+ logger.log("[app] stopped");
266
+ };
267
+
268
+ return {
269
+ config,
270
+ server,
271
+ state,
272
+ stop,
273
+ ...(bootstrapPromise ? { bootstrap: bootstrapPromise } : {}),
274
+ };
275
+ }
276
+
277
+ /**
278
+ * One-shot: scan UIs + report status, exit. Non-daemon counterpart to
279
+ * `serve` — useful for `parachute-app list` (Phase 1.2) and config
280
+ * validation in CI.
281
+ */
282
+ export function runOnce(opts: ServeOptions = {}): {
283
+ config: AppConfig;
284
+ state: AppState;
285
+ } {
286
+ const logger = opts.logger ?? console;
287
+ const config = loadConfig({ configPath: opts.configPath, logger });
288
+ const scan = scanUis({ uisDir: opts.uisDir, logger });
289
+ const state: AppState = {
290
+ config,
291
+ registeredUis: scan.registered,
292
+ skippedUis: scan.skipped.map((s) => ({
293
+ dirName: s.dirName,
294
+ status: s.status,
295
+ reason: s.reason,
296
+ })),
297
+ };
298
+ logger.log(
299
+ `[app] scan: ${state.registeredUis.length} active, ${state.skippedUis.length} skipped`,
300
+ );
301
+ for (const ui of state.registeredUis) {
302
+ logger.log(`[app] active ${ui.meta.path} (${ui.meta.name})`);
303
+ }
304
+ for (const s of state.skippedUis) {
305
+ logger.log(`[app] skip ${s.dirName} — ${s.status}: ${s.reason}`);
306
+ }
307
+ return { config, state };
308
+ }
309
+
310
+ /**
311
+ * Phase 1.3 surface — toggle dev mode for a UI with live reload.
312
+ *
313
+ * The dev-mode API now ships via `./dev-mode.ts`. Callers that want
314
+ * fine-grained control import `enableDevMode` / `disableDevMode` /
315
+ * `broadcastReload` directly. This wrapper stays as the canonical
316
+ * library-level façade for the simple "flip a UI into dev mode" case
317
+ * and keeps the surface stable for downstream consumers.
318
+ */
319
+ export function setDevMode(
320
+ name: string,
321
+ enable: boolean,
322
+ ): { name: string; enabled: boolean; enabledAt: number } {
323
+ const state = enable ? enableDevMode(name) : disableDevMode(name);
324
+ return { name, enabled: state.enabled, enabledAt: state.enabledAt };
325
+ }
326
+
327
+ /**
328
+ * Internal helper — invokes `maybeBootstrapDefaultApps` with a closure
329
+ * that delegates to `addUiInternal` for each declared default app.
330
+ * Exported for tests that want to exercise the wiring without going
331
+ * through the full `serve()` HTTP boot.
332
+ *
333
+ * After the bootstrap iteration completes, if any UIs were added, we
334
+ * call `selfRegister` once so services.json carries the new `uis` map
335
+ * in a single atomic write (vs the per-add stamps the admin path would
336
+ * normally do — `skipSelfRegisterRefresh: true` is set on the addOpts).
337
+ */
338
+ export async function runBootstrap(args: {
339
+ config: AppConfig;
340
+ uisDir: string;
341
+ /** Pre-built admin opts — same shape `routeAdmin` consumes. */
342
+ adminOpts: import("./admin-routes.ts").AdminHandlerOpts;
343
+ /** Override the services.json manifest path (tests). */
344
+ manifestPath?: string;
345
+ logger?: Pick<Console, "log" | "warn" | "error">;
346
+ /** Skip post-bootstrap selfRegister (tests). */
347
+ skipSelfRegister?: boolean;
348
+ }): Promise<import("./bootstrap.ts").BootstrapResult> {
349
+ const logger = args.logger ?? console;
350
+ const result = await maybeBootstrapDefaultApps({
351
+ config: args.config,
352
+ uisDir: args.uisDir,
353
+ logger,
354
+ add: async (spec) => {
355
+ const outcome = await addUiInternal({ source: spec }, args.adminOpts);
356
+ if (!outcome.added) {
357
+ // Surface the underlying error message — best-effort body parse.
358
+ let detail = `HTTP ${outcome.response.status}`;
359
+ try {
360
+ const parsed = (await outcome.response.clone().json()) as {
361
+ error?: string;
362
+ message?: string;
363
+ };
364
+ detail = parsed.message ?? parsed.error ?? detail;
365
+ } catch {
366
+ // ignore
367
+ }
368
+ throw new Error(detail);
369
+ }
370
+ return { name: outcome.added.meta.name, path: outcome.added.meta.path };
371
+ },
372
+ });
373
+
374
+ // One-shot post-bootstrap services.json refresh: stamps the full uis
375
+ // map atomically so hub's per-request discovery sees the bootstrapped
376
+ // UIs on its next read.
377
+ if (!args.skipSelfRegister && result.bootstrapped.length > 0) {
378
+ try {
379
+ selfRegister({
380
+ boundPort: 0,
381
+ installDir: resolveProjectRoot(),
382
+ manifestPath: args.manifestPath,
383
+ extraFields: { uis: buildUisExtraFieldForBoot(args.adminOpts.state.registeredUis) },
384
+ logger,
385
+ });
386
+ } catch (e) {
387
+ logger.warn(`[app] bootstrap: services.json refresh failed: ${(e as Error).message}`);
388
+ }
389
+ }
390
+ return result;
391
+ }
392
+
393
+ /** Expose canonical resolvers for the bin. */
394
+ export { resolveConfigPath, resolveUisDir };