@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.
- package/.parachute/config/schema +62 -0
- package/.parachute/info +14 -0
- package/.parachute/module.json +14 -0
- package/CHANGELOG.md +405 -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 +533 -0
- package/src/index.ts +394 -0
- package/src/meta-schema.ts +662 -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 +155 -0
- package/src/services-manifest.ts +104 -0
- package/src/ui-registry.ts +202 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP surface for `parachute-app serve` — Phase 1.1.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1.1 ships core UI hosting: discovery + mount + SPA fallback + smart
|
|
5
|
+
* cache headers + PWA opt-in. Admin endpoints (`POST /app/add`,
|
|
6
|
+
* `DELETE /app/<name>`, OAuth DCR, the admin SPA) land in Phase 1.2; the
|
|
7
|
+
* routing shape below leaves a clean seam to drop them in.
|
|
8
|
+
*
|
|
9
|
+
* Endpoints (Phase 1.1):
|
|
10
|
+
* - GET /healthz, GET /app/healthz — liveness, open
|
|
11
|
+
* - GET /.parachute/info — module identity (open)
|
|
12
|
+
* - GET /.parachute/config/schema — Draft-07 schema (open)
|
|
13
|
+
* - GET /.parachute/config — current config (open;
|
|
14
|
+
* no secrets in app config)
|
|
15
|
+
* - GET /<meta.path>/[anything] — per-UI bundle serving
|
|
16
|
+
*
|
|
17
|
+
* Per-UI routing:
|
|
18
|
+
* - GET /<meta.path>/ — serves `dist/index.html`
|
|
19
|
+
* - GET /<meta.path> — same (no-trailing-slash variant)
|
|
20
|
+
* - GET /<meta.path>/<asset> — serves `dist/<asset>` if it exists
|
|
21
|
+
* - GET /<meta.path>/<anything-else> — SPA fallback: serves `dist/index.html`
|
|
22
|
+
*
|
|
23
|
+
* SPA fallback discipline: any URL under `<meta.path>` that doesn't resolve
|
|
24
|
+
* to a file in `dist/` serves `index.html` with the no-cache headers.
|
|
25
|
+
* Client-side routers (React Router, hash routing, BrowserRouter) all
|
|
26
|
+
* benefit. Asset extensions that would normally 404 (a missing `.png`)
|
|
27
|
+
* still serve index.html — the bundle decides whether to render a 404 page
|
|
28
|
+
* or fall through to the router. That tradeoff matches design doc section 9.
|
|
29
|
+
*
|
|
30
|
+
* Hostname defaults to `127.0.0.1` (loopback-only) — same security posture
|
|
31
|
+
* as runner. Hub forwards `/app/*` traffic from the public origin.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
|
|
37
|
+
import { type AdminHandlerOpts, routeAdmin } from "./admin-routes.ts";
|
|
38
|
+
import { cacheHeadersFor } from "./cache-headers.ts";
|
|
39
|
+
import { type AppConfig, loadConfig } from "./config.ts";
|
|
40
|
+
import { injectDevReloadScript } from "./dev-injection.ts";
|
|
41
|
+
import { isDevMode } from "./dev-mode.ts";
|
|
42
|
+
import { type DevRoutesOpts, routeDev } from "./dev-routes.ts";
|
|
43
|
+
import type { UiMeta } from "./meta-schema.ts";
|
|
44
|
+
import { type RegisteredUi, scanUis } from "./ui-registry.ts";
|
|
45
|
+
|
|
46
|
+
export type AppState = {
|
|
47
|
+
/** Currently-resolved config. Phase 1.2 PUTs will mutate this in place. */
|
|
48
|
+
config: AppConfig;
|
|
49
|
+
/** Mounted UIs. Phase 1.2's `reload` will swap this list. */
|
|
50
|
+
registeredUis: RegisteredUi[];
|
|
51
|
+
/**
|
|
52
|
+
* Skipped UIs from the last scan — surfaced in `/app/healthz`'s diagnostic
|
|
53
|
+
* payload so operators can spot broken UIs without leaving the daemon.
|
|
54
|
+
*/
|
|
55
|
+
skippedUis: Array<{ dirName: string; status: string; reason: string }>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type HttpServerOpts = {
|
|
59
|
+
/** Mutable state. */
|
|
60
|
+
state: AppState;
|
|
61
|
+
/** Bind port. Use 0 in tests to let the OS pick. */
|
|
62
|
+
port: number;
|
|
63
|
+
/** Process start time, for `/healthz` uptime. */
|
|
64
|
+
startedAt: Date;
|
|
65
|
+
/**
|
|
66
|
+
* Bind address. Defaults to `127.0.0.1` — loopback-only because the admin
|
|
67
|
+
* endpoints (Phase 1.2) leak state. Hub forwards `/app/*` over loopback.
|
|
68
|
+
*/
|
|
69
|
+
hostname?: string;
|
|
70
|
+
/** Override for tests — defaults to `Bun.serve`. */
|
|
71
|
+
serveFn?: typeof Bun.serve;
|
|
72
|
+
/** Logger override; default console. */
|
|
73
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
74
|
+
/**
|
|
75
|
+
* Override the `.parachute/` manifest dir. Defaults to the repo's
|
|
76
|
+
* `.parachute/` next to `package.json`. Tests inject a tmpdir.
|
|
77
|
+
*/
|
|
78
|
+
parachuteDir?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Override the absolute path to the `dist/admin/` directory that holds the
|
|
81
|
+
* built admin SPA. Tests inject a tmpdir with a fake index.html. Production
|
|
82
|
+
* resolves to `<package-root>/dist/admin/` via `defaultAdminDir()`.
|
|
83
|
+
*/
|
|
84
|
+
adminDir?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Phase 1.2 admin-route handlers need a couple of side-channel hooks
|
|
87
|
+
* (tests inject the uis-dir, services.json path, npm-spawn, fetch). The
|
|
88
|
+
* server exposes them so callers don't have to thread them in by hand.
|
|
89
|
+
*/
|
|
90
|
+
adminOpts?: Omit<AdminHandlerOpts, "state">;
|
|
91
|
+
/**
|
|
92
|
+
* Phase 1.3 dev-route opts (test seam for enforceScope override).
|
|
93
|
+
*/
|
|
94
|
+
devOpts?: Omit<DevRoutesOpts, "state">;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Spin up the app HTTP server. Returns the running Bun.Server so the CLI
|
|
99
|
+
* can `server.stop()` during graceful shutdown.
|
|
100
|
+
*/
|
|
101
|
+
export function startHttpServer(opts: HttpServerOpts): ReturnType<typeof Bun.serve> {
|
|
102
|
+
const { port, startedAt } = opts;
|
|
103
|
+
const hostname = opts.hostname ?? "127.0.0.1";
|
|
104
|
+
const serve = opts.serveFn ?? Bun.serve;
|
|
105
|
+
const parachuteDir = opts.parachuteDir ?? defaultParachuteDir();
|
|
106
|
+
const logger = opts.logger ?? console;
|
|
107
|
+
|
|
108
|
+
const adminDir = opts.adminDir ?? defaultAdminDir();
|
|
109
|
+
const adminOpts = opts.adminOpts ?? {};
|
|
110
|
+
const devOpts = opts.devOpts ?? {};
|
|
111
|
+
return serve({
|
|
112
|
+
port,
|
|
113
|
+
hostname,
|
|
114
|
+
fetch: (req) =>
|
|
115
|
+
handle(req, opts.state, { startedAt, parachuteDir, logger, adminDir, adminOpts, devOpts }),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type HandleCtx = {
|
|
120
|
+
startedAt: Date;
|
|
121
|
+
parachuteDir: string;
|
|
122
|
+
logger: Pick<Console, "log" | "warn" | "error">;
|
|
123
|
+
adminDir: string;
|
|
124
|
+
adminOpts: Omit<AdminHandlerOpts, "state">;
|
|
125
|
+
devOpts: Omit<DevRoutesOpts, "state">;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
function handle(req: Request, state: AppState, ctx: HandleCtx): Response | Promise<Response> {
|
|
129
|
+
const url = new URL(req.url);
|
|
130
|
+
const pathname = url.pathname;
|
|
131
|
+
const method = req.method;
|
|
132
|
+
|
|
133
|
+
// /healthz: open, both with and without /app prefix so hub-as-supervisor
|
|
134
|
+
// (forwards via /app) and direct localhost probes both work.
|
|
135
|
+
//
|
|
136
|
+
// When `config.disabled` is true, surface `status: "disabled"` so probes
|
|
137
|
+
// can distinguish "daemon's up but intentionally not hosting" from "ok".
|
|
138
|
+
// The JSON key is `skippedUis` (matching the `AppState.skippedUis` field
|
|
139
|
+
// name) per reviewer Open Q 2 — keeps the shape consistent across the
|
|
140
|
+
// wire format and the internal state.
|
|
141
|
+
if (
|
|
142
|
+
(method === "GET" || method === "HEAD") &&
|
|
143
|
+
(pathname === "/healthz" || pathname === "/app/healthz")
|
|
144
|
+
) {
|
|
145
|
+
return Response.json({
|
|
146
|
+
status: state.config.disabled ? "disabled" : "ok",
|
|
147
|
+
uis: state.registeredUis.length,
|
|
148
|
+
skippedUis: state.skippedUis.length,
|
|
149
|
+
uptime_seconds: Math.floor((Date.now() - ctx.startedAt.getTime()) / 1000),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// .parachute/* — module-protocol surface, open (no secrets in app config).
|
|
154
|
+
if (method === "GET" && pathname === "/.parachute/info") {
|
|
155
|
+
return serveStaticFile(path.join(ctx.parachuteDir, "info"), "application/json");
|
|
156
|
+
}
|
|
157
|
+
if (method === "GET" && pathname === "/.parachute/config/schema") {
|
|
158
|
+
return serveStaticFile(path.join(ctx.parachuteDir, "config", "schema"), "application/json");
|
|
159
|
+
}
|
|
160
|
+
if (method === "GET" && pathname === "/.parachute/config") {
|
|
161
|
+
return Response.json(state.config);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Phase 1.2 admin SPA — mounted at /app/admin/. Reserved namespace: hosted
|
|
165
|
+
// UIs are rejected from claiming `/app/admin` by `meta-schema` + admin
|
|
166
|
+
// /app/add. The bundle is `dist/admin/` shipped inside the npm package.
|
|
167
|
+
// Bundle path serving deliberately runs BEFORE the per-UI matcher so
|
|
168
|
+
// /app/admin/* always resolves to admin assets, even if a malformed UI
|
|
169
|
+
// somehow registers `/app/admin` (path-pattern + reserved-path checks
|
|
170
|
+
// prevent that, but defense-in-depth).
|
|
171
|
+
if (
|
|
172
|
+
(method === "GET" || method === "HEAD") &&
|
|
173
|
+
(pathname === "/app/admin" || pathname === "/app/admin/" || pathname.startsWith("/app/admin/"))
|
|
174
|
+
) {
|
|
175
|
+
return serveAdminAsset(req, ctx.adminDir, pathname);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Phase 1.3 dev-mode routes: SSE reload stream + trigger endpoint.
|
|
179
|
+
// Matched ahead of admin so the per-UI `/_dev/reload` SSE path doesn't
|
|
180
|
+
// race with the admin regex (different prefix shapes — defense in depth).
|
|
181
|
+
const dev = routeDev(req, { state, ...ctx.devOpts });
|
|
182
|
+
if (dev.handled) {
|
|
183
|
+
return dev.response;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Phase 1.2 admin endpoints (POST /app/add, DELETE /app/<name>, etc.).
|
|
187
|
+
const admin = routeAdmin(req, { state, ...ctx.adminOpts });
|
|
188
|
+
if (admin.handled) {
|
|
189
|
+
return admin.response;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Per-UI mount paths. Find the matching UI (longest mount-path wins,
|
|
193
|
+
// though Phase 1.1's PATH_PATTERN constrains mounts to single-segment
|
|
194
|
+
// so there's no overlap in practice — the longest-prefix loop is
|
|
195
|
+
// forward-defensive for Phase 2's multi-segment relaxation).
|
|
196
|
+
if (method === "GET" || method === "HEAD") {
|
|
197
|
+
const ui = matchUi(pathname, state.registeredUis);
|
|
198
|
+
if (ui) {
|
|
199
|
+
return serveUiAsset(req, ui, pathname);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Fall-through: unknown route for an unsupported method → 405, otherwise 404.
|
|
204
|
+
if (method !== "GET" && method !== "HEAD" && method !== "POST" && method !== "DELETE") {
|
|
205
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
206
|
+
}
|
|
207
|
+
return new Response("Not Found", { status: 404 });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Find the UI whose mount path is a prefix of `pathname`. Longest-prefix
|
|
212
|
+
* wins so a UI at `/app/foo-bar` doesn't shadow `/app/foo` — though the
|
|
213
|
+
* current PATH_PATTERN forbids that exact case, the search shape is
|
|
214
|
+
* forward-defensive.
|
|
215
|
+
*/
|
|
216
|
+
function matchUi(pathname: string, uis: ReadonlyArray<RegisteredUi>): RegisteredUi | undefined {
|
|
217
|
+
let best: RegisteredUi | undefined;
|
|
218
|
+
for (const ui of uis) {
|
|
219
|
+
const mount = ui.meta.path;
|
|
220
|
+
if (pathname === mount || pathname.startsWith(`${mount}/`)) {
|
|
221
|
+
if (!best || ui.meta.path.length > best.meta.path.length) best = ui;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return best;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Serve an asset for a registered UI. The flow:
|
|
229
|
+
*
|
|
230
|
+
* 1. If `pathname === meta.path` or `pathname === meta.path + "/"`, serve
|
|
231
|
+
* `dist/index.html` (the root document).
|
|
232
|
+
* 2. Otherwise compute the relative path after `meta.path/`. If the file
|
|
233
|
+
* exists under `dist/`, serve it with content-type + cache headers.
|
|
234
|
+
* 3. If it doesn't exist (SPA-routing case), serve `dist/index.html` with
|
|
235
|
+
* the no-cache headers — the client-side router decides what to render.
|
|
236
|
+
*
|
|
237
|
+
* Path traversal: `path.resolve(distDir, rel)` is checked against `distDir`
|
|
238
|
+
* via a containment test (`resolved.startsWith(distDir + path.sep)`). Any
|
|
239
|
+
* attempt to escape (`../etc/passwd`) gets a 404. Bun's URL parser
|
|
240
|
+
* already collapses `..` segments but the explicit check is the load-bearing
|
|
241
|
+
* line — defense in depth.
|
|
242
|
+
*/
|
|
243
|
+
function serveUiAsset(req: Request, ui: RegisteredUi, pathname: string): Response {
|
|
244
|
+
const mount = ui.meta.path;
|
|
245
|
+
const distDir = ui.distDir;
|
|
246
|
+
const indexHtmlPath = path.join(distDir, "index.html");
|
|
247
|
+
// Per-request dev-mode check — flipping `parachute-app dev <name>` takes
|
|
248
|
+
// effect on the very next request without restarting the server.
|
|
249
|
+
const devMode = isDevMode(ui.meta.name);
|
|
250
|
+
|
|
251
|
+
// Root document: /app/foo or /app/foo/
|
|
252
|
+
if (pathname === mount || pathname === `${mount}/`) {
|
|
253
|
+
return serveFileWithHeaders(req, indexHtmlPath, "index.html", ui.meta, devMode);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Strip the mount prefix; rel is the path within dist/.
|
|
257
|
+
const rel = pathname.slice(mount.length + 1); // +1 to drop the leading '/'
|
|
258
|
+
// Defense in depth: reject any explicit traversal segments before resolve.
|
|
259
|
+
if (rel.includes("\0") || rel.split("/").some((seg) => seg === "..")) {
|
|
260
|
+
// Fall through to SPA fallback — the bundle's router handles unknown routes.
|
|
261
|
+
return serveFileWithHeaders(req, indexHtmlPath, "index.html", ui.meta, devMode);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const resolved = path.resolve(distDir, rel);
|
|
265
|
+
// Containment check — the resolved path must live under distDir.
|
|
266
|
+
if (resolved !== distDir && !resolved.startsWith(`${distDir}${path.sep}`)) {
|
|
267
|
+
return serveFileWithHeaders(req, indexHtmlPath, "index.html", ui.meta, devMode);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (existsSync(resolved)) {
|
|
271
|
+
try {
|
|
272
|
+
const st = statSync(resolved);
|
|
273
|
+
if (st.isFile()) {
|
|
274
|
+
const basename = path.basename(resolved);
|
|
275
|
+
return serveFileWithHeaders(req, resolved, basename, ui.meta, devMode);
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
// Race with file deletion — fall through to SPA fallback.
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// SPA fallback: serve index.html with no-cache headers so the router runs
|
|
283
|
+
// on a fresh document.
|
|
284
|
+
return serveFileWithHeaders(req, indexHtmlPath, "index.html", ui.meta, devMode);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Read a file from disk and wrap it with content-type + cache headers.
|
|
289
|
+
* Returns 404 if the file is unreadable — caller passes a path it already
|
|
290
|
+
* stat'd, so the only realistic 404 is a race with deletion mid-request.
|
|
291
|
+
*
|
|
292
|
+
* Body is the literal string `"Not Found"` — we deliberately don't include
|
|
293
|
+
* the underlying error message because ENOENT's `Error.message` leaks the
|
|
294
|
+
* absolute filesystem path (e.g. `ENOENT: no such file or directory, open
|
|
295
|
+
* '/Users/.../app/uis/notes/dist/missing.js'`). That information is fine in
|
|
296
|
+
* logs but not in a client-visible response. We log the path-loss event
|
|
297
|
+
* server-side and return the generic body to the client.
|
|
298
|
+
*/
|
|
299
|
+
function serveFileWithHeaders(
|
|
300
|
+
req: Request,
|
|
301
|
+
filePath: string,
|
|
302
|
+
filenameForHeaders: string,
|
|
303
|
+
meta: UiMeta,
|
|
304
|
+
devMode = false,
|
|
305
|
+
): Response {
|
|
306
|
+
let body: Buffer;
|
|
307
|
+
try {
|
|
308
|
+
body = readFileSync(filePath);
|
|
309
|
+
} catch (e) {
|
|
310
|
+
// Log the actual path server-side for debugging — never returns to the client.
|
|
311
|
+
console.warn(`[app] serve: file vanished mid-request: ${filePath} (${(e as Error).message})`);
|
|
312
|
+
return new Response("Not Found", { status: 404 });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Dev-mode reload-script injection: when dev mode is on AND we're serving
|
|
316
|
+
// the index.html document, parse + inject the EventSource shim. Idempotent
|
|
317
|
+
// — re-serving without a rebuild won't duplicate the tag.
|
|
318
|
+
let payload: Buffer | string = body;
|
|
319
|
+
if (devMode && filenameForHeaders === "index.html") {
|
|
320
|
+
const endpoint = `${meta.path}/_dev/reload`;
|
|
321
|
+
try {
|
|
322
|
+
const html = body.toString("utf8");
|
|
323
|
+
const { html: injected, fallback } = injectDevReloadScript(html, endpoint);
|
|
324
|
+
if (fallback) {
|
|
325
|
+
console.warn(
|
|
326
|
+
`[app] dev: injected reload script via ${fallback} fallback for ${meta.name} (no </head> in index.html)`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
payload = injected;
|
|
330
|
+
} catch (e) {
|
|
331
|
+
// Should never throw — string ops only. Fall back to the raw bytes.
|
|
332
|
+
console.warn(`[app] dev: inject failed for ${meta.name}: ${(e as Error).message}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const bodyLen = typeof payload === "string" ? Buffer.byteLength(payload) : payload.length;
|
|
337
|
+
const headers: Record<string, string> = {
|
|
338
|
+
"content-type": contentTypeFor(filenameForHeaders),
|
|
339
|
+
...cacheHeadersFor(filenameForHeaders, meta, devMode),
|
|
340
|
+
};
|
|
341
|
+
if (req.method === "HEAD") {
|
|
342
|
+
// HEAD: include Content-Length but no body.
|
|
343
|
+
headers["content-length"] = String(bodyLen);
|
|
344
|
+
return new Response(null, { status: 200, headers });
|
|
345
|
+
}
|
|
346
|
+
// Bun's Response accepts string / Buffer / Uint8Array / ArrayBuffer
|
|
347
|
+
// interchangeably; pass whichever shape we have.
|
|
348
|
+
return new Response(payload, { status: 200, headers });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Minimal content-type table — the common SPA bundle assets. Falls through
|
|
353
|
+
* to application/octet-stream for anything not listed. Operators can override
|
|
354
|
+
* via meta.json `cache_headers` extension in Phase 2 (designed-but-not-shipped);
|
|
355
|
+
* for MVP this table covers Vite's default output.
|
|
356
|
+
*/
|
|
357
|
+
function contentTypeFor(filename: string): string {
|
|
358
|
+
const ext = path.extname(filename).toLowerCase();
|
|
359
|
+
switch (ext) {
|
|
360
|
+
case ".html":
|
|
361
|
+
return "text/html; charset=utf-8";
|
|
362
|
+
case ".js":
|
|
363
|
+
case ".mjs":
|
|
364
|
+
return "application/javascript; charset=utf-8";
|
|
365
|
+
case ".css":
|
|
366
|
+
return "text/css; charset=utf-8";
|
|
367
|
+
case ".json":
|
|
368
|
+
return "application/json; charset=utf-8";
|
|
369
|
+
case ".svg":
|
|
370
|
+
return "image/svg+xml";
|
|
371
|
+
case ".png":
|
|
372
|
+
return "image/png";
|
|
373
|
+
case ".jpg":
|
|
374
|
+
case ".jpeg":
|
|
375
|
+
return "image/jpeg";
|
|
376
|
+
case ".gif":
|
|
377
|
+
return "image/gif";
|
|
378
|
+
case ".webp":
|
|
379
|
+
return "image/webp";
|
|
380
|
+
case ".ico":
|
|
381
|
+
return "image/x-icon";
|
|
382
|
+
case ".woff":
|
|
383
|
+
return "font/woff";
|
|
384
|
+
case ".woff2":
|
|
385
|
+
return "font/woff2";
|
|
386
|
+
case ".ttf":
|
|
387
|
+
return "font/ttf";
|
|
388
|
+
case ".otf":
|
|
389
|
+
return "font/otf";
|
|
390
|
+
case ".map":
|
|
391
|
+
return "application/json; charset=utf-8";
|
|
392
|
+
case ".txt":
|
|
393
|
+
return "text/plain; charset=utf-8";
|
|
394
|
+
case ".wasm":
|
|
395
|
+
return "application/wasm";
|
|
396
|
+
case ".webmanifest":
|
|
397
|
+
return "application/manifest+json";
|
|
398
|
+
default:
|
|
399
|
+
return "application/octet-stream";
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function serveStaticFile(filePath: string, contentType: string): Response {
|
|
404
|
+
try {
|
|
405
|
+
const body = readFileSync(filePath, "utf8");
|
|
406
|
+
return new Response(body, { status: 200, headers: { "content-type": contentType } });
|
|
407
|
+
} catch (e) {
|
|
408
|
+
// Same posture as serveFileWithHeaders: log the path server-side but
|
|
409
|
+
// return a generic body so ENOENT's absolute filesystem path doesn't
|
|
410
|
+
// leak to the client.
|
|
411
|
+
console.warn(`[app] serve-static: ${filePath} unreadable (${(e as Error).message})`);
|
|
412
|
+
return new Response(JSON.stringify({ error: "not_found" }), {
|
|
413
|
+
status: 404,
|
|
414
|
+
headers: { "content-type": "application/json" },
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Default location of `.parachute/` relative to the installed package.
|
|
421
|
+
* The files we serve (info, config/schema) are checked into the npm
|
|
422
|
+
* package via `package.json#files`.
|
|
423
|
+
*/
|
|
424
|
+
function defaultParachuteDir(): string {
|
|
425
|
+
return path.resolve(import.meta.dir, "..", ".parachute");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Default location of the built admin SPA. Shipped via `package.json#files`
|
|
430
|
+
* (`dist/admin/**`) so `bunx @openparachute/app` resolves it.
|
|
431
|
+
*/
|
|
432
|
+
function defaultAdminDir(): string {
|
|
433
|
+
return path.resolve(import.meta.dir, "..", "dist", "admin");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Serve a file from the admin SPA's dist directory. Same cache shape Phase 1.1
|
|
438
|
+
* used for hosted UIs: `index.html` no-cache; hashed assets immutable;
|
|
439
|
+
* everything else 1-hour. SPA-fallback: anything that doesn't resolve serves
|
|
440
|
+
* index.html (react-router runs).
|
|
441
|
+
*
|
|
442
|
+
* If the admin SPA bundle isn't present (e.g. tests, fresh dev checkout
|
|
443
|
+
* before `bun run build`), we return a friendly placeholder so operators
|
|
444
|
+
* see "admin SPA not built" instead of a bare 404. Production npm installs
|
|
445
|
+
* ship the bundle so this branch is the dev affordance.
|
|
446
|
+
*/
|
|
447
|
+
function serveAdminAsset(req: Request, adminDir: string, pathname: string): Response {
|
|
448
|
+
const indexHtmlPath = path.join(adminDir, "index.html");
|
|
449
|
+
|
|
450
|
+
if (!existsSync(indexHtmlPath)) {
|
|
451
|
+
// Dev / pre-build branch: return a static placeholder so the operator
|
|
452
|
+
// sees the daemon is healthy but the bundle isn't shipped yet.
|
|
453
|
+
return new Response(adminSpaPlaceholder(), {
|
|
454
|
+
status: 200,
|
|
455
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" },
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Strip the `/app/admin/` prefix, falling back to index.html on root.
|
|
460
|
+
if (pathname === "/app/admin" || pathname === "/app/admin/") {
|
|
461
|
+
return serveAdminFile(req, indexHtmlPath, "index.html");
|
|
462
|
+
}
|
|
463
|
+
const rel = pathname.slice("/app/admin/".length);
|
|
464
|
+
if (rel.includes("\0") || rel.split("/").some((seg) => seg === "..")) {
|
|
465
|
+
return serveAdminFile(req, indexHtmlPath, "index.html");
|
|
466
|
+
}
|
|
467
|
+
const resolved = path.resolve(adminDir, rel);
|
|
468
|
+
if (resolved !== adminDir && !resolved.startsWith(`${adminDir}${path.sep}`)) {
|
|
469
|
+
return serveAdminFile(req, indexHtmlPath, "index.html");
|
|
470
|
+
}
|
|
471
|
+
if (existsSync(resolved)) {
|
|
472
|
+
try {
|
|
473
|
+
const st = statSync(resolved);
|
|
474
|
+
if (st.isFile()) {
|
|
475
|
+
return serveAdminFile(req, resolved, path.basename(resolved));
|
|
476
|
+
}
|
|
477
|
+
} catch {
|
|
478
|
+
// race with deletion — SPA fallback
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return serveAdminFile(req, indexHtmlPath, "index.html");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function serveAdminFile(req: Request, filePath: string, filenameForHeaders: string): Response {
|
|
485
|
+
let body: Buffer;
|
|
486
|
+
try {
|
|
487
|
+
body = readFileSync(filePath);
|
|
488
|
+
} catch (e) {
|
|
489
|
+
console.warn(`[app] admin serve: ${filePath} unreadable (${(e as Error).message})`);
|
|
490
|
+
return new Response("Not Found", { status: 404 });
|
|
491
|
+
}
|
|
492
|
+
const headers: Record<string, string> = {
|
|
493
|
+
"content-type": contentTypeFor(filenameForHeaders),
|
|
494
|
+
// Mirror the hosted-UI policy: index.html → no-cache; everything else
|
|
495
|
+
// → 1 year + immutable when content-hashed, 1h otherwise. The admin
|
|
496
|
+
// SPA doesn't have meta.json so we use a `pwa: false`-equivalent shim.
|
|
497
|
+
...cacheHeadersFor(filenameForHeaders, {
|
|
498
|
+
name: "admin",
|
|
499
|
+
displayName: "Admin",
|
|
500
|
+
path: "/app/admin",
|
|
501
|
+
scopes_required: [],
|
|
502
|
+
pwa: false,
|
|
503
|
+
public: false,
|
|
504
|
+
} as UiMeta),
|
|
505
|
+
};
|
|
506
|
+
if (req.method === "HEAD") {
|
|
507
|
+
headers["content-length"] = String(body.length);
|
|
508
|
+
return new Response(null, { status: 200, headers });
|
|
509
|
+
}
|
|
510
|
+
return new Response(body, { status: 200, headers });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Placeholder served when the admin SPA bundle isn't built. The dev
|
|
515
|
+
* affordance — production installs always ship `dist/admin/`. Keeps the
|
|
516
|
+
* /app/admin/ route from 404'ing in a fresh checkout.
|
|
517
|
+
*/
|
|
518
|
+
function adminSpaPlaceholder(): string {
|
|
519
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>parachute-app admin</title></head>
|
|
520
|
+
<body style="font-family:system-ui;margin:2rem;color:#222;max-width:42rem;">
|
|
521
|
+
<h1>parachute-app admin</h1>
|
|
522
|
+
<p>The admin SPA bundle isn't present. Run <code>bun run build</code> from this checkout to build it.</p>
|
|
523
|
+
<p>API endpoints under <code>/app/list</code>, <code>/app/add</code>, etc. are live; CLI <code>parachute-app list</code> works.</p>
|
|
524
|
+
</body></html>`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Force-load the app config — used by callers that want defaults applied
|
|
529
|
+
* without going through the full daemon boot path (e.g. CI healthchecks).
|
|
530
|
+
*/
|
|
531
|
+
export function loadOrDefaultConfig(opts: Parameters<typeof loadConfig>[0] = {}): AppConfig {
|
|
532
|
+
return loadConfig(opts);
|
|
533
|
+
}
|