@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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `selfRegister()` — stamp app's entry into `~/.parachute/services.json`
|
|
3
|
+
* on `parachute-app serve` boot.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists, in one sentence: hub-as-supervisor (v0.6) reads
|
|
6
|
+
* `~/.parachute/services.json` to know which modules exist on the host; a
|
|
7
|
+
* module that doesn't self-register is invisible to `parachute status`,
|
|
8
|
+
* `parachute restart`, the admin SPA module catalog, and the live
|
|
9
|
+
* `/.well-known/parachute.json` builder.
|
|
10
|
+
*
|
|
11
|
+
* Two reads from the file before we write:
|
|
12
|
+
* 1. The existing row's `port` is preserved on subsequent boots so an
|
|
13
|
+
* operator (or hub) who set `app.port = 1948` in services.json stays
|
|
14
|
+
* at 1948 across restarts — even if the env var that pointed app at
|
|
15
|
+
* 1948 is later unset. Same first-boot-vs-subsequent-boot rule
|
|
16
|
+
* scribe + agent + runner settled (scribe#40, paraclaw#145).
|
|
17
|
+
* 2. The existing row's hub-stamped fields (`installDir` from
|
|
18
|
+
* parachute-hub#84, future `uiUrl` / `managementUrl`) merge through
|
|
19
|
+
* because `upsertService` spreads `entry` last. We re-stamp our own
|
|
20
|
+
* `installDir = PROJECT_ROOT` regardless — hub#293/#302 made the
|
|
21
|
+
* runtime install path stamp installDir, and we want services.json
|
|
22
|
+
* to keep that resolution after a `git pull` moves the checkout.
|
|
23
|
+
*
|
|
24
|
+
* Failure mode: any error during the write is logged + swallowed by the
|
|
25
|
+
* caller (see `serve()` in `src/index.ts`). The daemon still serves locally
|
|
26
|
+
* if services.json is unwritable, malformed, or fights with a concurrent
|
|
27
|
+
* writer — the operator just won't see app in `parachute status` until the
|
|
28
|
+
* underlying issue clears.
|
|
29
|
+
*
|
|
30
|
+
* Phase 1.2 hook: this function writes only the module-level row today.
|
|
31
|
+
* When per-UI `uis` map lands (design doc section 12), the caller assembles
|
|
32
|
+
* the `uis` field and passes it through via `extraFields`.
|
|
33
|
+
*/
|
|
34
|
+
import { readFileSync } from "node:fs";
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
|
|
37
|
+
import pkg from "../package.json" with { type: "json" };
|
|
38
|
+
import { type ServiceEntry, readServiceEntry, upsertService } from "./services-manifest.ts";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The canonical services.json row key for the app module — sourced from
|
|
42
|
+
* `.parachute/module.json#manifestName`. Hub looks up modules in
|
|
43
|
+
* services.json by `manifestName` (vault + scribe use this convention), so
|
|
44
|
+
* self-registering under the short name "app" would create a duplicate row
|
|
45
|
+
* alongside the hub-installed `parachute-app` row and trip hub's
|
|
46
|
+
* duplicate-port detector on re-read. See parachute-patterns or the
|
|
47
|
+
* `manifestName` field on .parachute/module.json files. */
|
|
48
|
+
const ROW_NAME = resolveManifestName();
|
|
49
|
+
|
|
50
|
+
function resolveManifestName(): string {
|
|
51
|
+
// Read once at module load — `.parachute/module.json` ships in the
|
|
52
|
+
// package and doesn't change at runtime.
|
|
53
|
+
try {
|
|
54
|
+
const manifestPath = path.resolve(import.meta.dir, "..", ".parachute", "module.json");
|
|
55
|
+
const raw = JSON.parse(readFileSync(manifestPath, "utf8")) as { manifestName?: unknown };
|
|
56
|
+
if (typeof raw.manifestName === "string" && raw.manifestName.length > 0) {
|
|
57
|
+
return raw.manifestName;
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Fall through to the conservative fallback. The module.json is part
|
|
61
|
+
// of the published package, so this branch is effectively unreachable
|
|
62
|
+
// in production — it exists so tests that mount a stub package layout
|
|
63
|
+
// don't hard-crash on import.
|
|
64
|
+
}
|
|
65
|
+
return "parachute-app";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type SelfRegisterOpts = {
|
|
69
|
+
/**
|
|
70
|
+
* The port app just bound. Used only as the first-run fallback — if
|
|
71
|
+
* services.json already has an entry, we re-stamp the existing port
|
|
72
|
+
* unchanged to preserve operator/hub overrides.
|
|
73
|
+
*/
|
|
74
|
+
boundPort: number;
|
|
75
|
+
/**
|
|
76
|
+
* Absolute path to the app package root (where `.parachute/` and
|
|
77
|
+
* `package.json` live). Stamped as `installDir` so hub can resolve
|
|
78
|
+
* `parachute restart app` back to this checkout.
|
|
79
|
+
*/
|
|
80
|
+
installDir: string;
|
|
81
|
+
/**
|
|
82
|
+
* Additional fields to merge into the row — used for the per-UI `uis` map
|
|
83
|
+
* (Phase 1.2) and any future schema extensions without touching this
|
|
84
|
+
* function's signature.
|
|
85
|
+
*/
|
|
86
|
+
extraFields?: Record<string, unknown>;
|
|
87
|
+
/**
|
|
88
|
+
* Override the services.json location (tests). Defaults to
|
|
89
|
+
* `$PARACHUTE_HOME/services.json`.
|
|
90
|
+
*/
|
|
91
|
+
manifestPath?: string;
|
|
92
|
+
/** Logger override; default console. */
|
|
93
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type SelfRegisterResult = {
|
|
97
|
+
ok: boolean;
|
|
98
|
+
/** The path we wrote to (or attempted to write to). */
|
|
99
|
+
manifestPath: string;
|
|
100
|
+
/** True when services.json already had a row for `parachute-app` before we wrote. */
|
|
101
|
+
hadExistingEntry: boolean;
|
|
102
|
+
/** The port we ended up stamping (existing-entry port or boundPort). */
|
|
103
|
+
portWritten: number;
|
|
104
|
+
/** Set when ok=false — the error swallowed by the caller. */
|
|
105
|
+
error?: Error;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Self-register app's services.json entry. Best-effort: returns
|
|
110
|
+
* `{ok: false, error}` on any failure rather than throwing, so the caller's
|
|
111
|
+
* "log + continue" branch is one shape regardless of failure mode.
|
|
112
|
+
*
|
|
113
|
+
* Idempotent against repeated calls — the canonical case is `serve()`
|
|
114
|
+
* invoking this once per boot, but if the daemon restarts in-process or a
|
|
115
|
+
* Phase 1.2 UI add/remove re-runs the registration to refresh the `uis`
|
|
116
|
+
* map, repeated calls converge to the same disk state.
|
|
117
|
+
*/
|
|
118
|
+
export function selfRegister(opts: SelfRegisterOpts): SelfRegisterResult {
|
|
119
|
+
const logger = opts.logger ?? console;
|
|
120
|
+
const manifestPath = opts.manifestPath; // undefined → resolveManifestPath() default
|
|
121
|
+
|
|
122
|
+
let existing: ServiceEntry | undefined;
|
|
123
|
+
try {
|
|
124
|
+
existing = readServiceEntry(ROW_NAME, manifestPath);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
const err = e as Error;
|
|
127
|
+
logger.warn(`[app] skipped self-register: ${err.message}`);
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
manifestPath: manifestPath ?? "~/.parachute/services.json",
|
|
131
|
+
hadExistingEntry: false,
|
|
132
|
+
portWritten: opts.boundPort,
|
|
133
|
+
error: err,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const portToWrite = existing?.port ?? opts.boundPort;
|
|
138
|
+
const entry: ServiceEntry = {
|
|
139
|
+
name: ROW_NAME,
|
|
140
|
+
port: portToWrite,
|
|
141
|
+
paths: ["/app", "/.parachute"],
|
|
142
|
+
health: "/app/healthz",
|
|
143
|
+
version: pkg.version,
|
|
144
|
+
displayName: "App",
|
|
145
|
+
tagline:
|
|
146
|
+
"Host module for custom Parachute UIs — drop a built bundle in and serve it under one origin.",
|
|
147
|
+
installDir: opts.installDir,
|
|
148
|
+
...(opts.extraFields ?? {}),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
upsertService(entry, manifestPath);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
const err = e as Error;
|
|
155
|
+
logger.warn(`[app] skipped self-register: ${err.message}`);
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
manifestPath: manifestPath ?? "~/.parachute/services.json",
|
|
159
|
+
hadExistingEntry: existing !== undefined,
|
|
160
|
+
portWritten: portToWrite,
|
|
161
|
+
error: err,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
logger.log(
|
|
166
|
+
`[app] self-registered services.json entry (port=${portToWrite}, installDir=${opts.installDir}${existing ? ", existing entry merged" : ", first boot"})`,
|
|
167
|
+
);
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
manifestPath: manifestPath ?? "~/.parachute/services.json",
|
|
171
|
+
hadExistingEntry: existing !== undefined,
|
|
172
|
+
portWritten: portToWrite,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve the app package root — the directory containing
|
|
178
|
+
* `.parachute/module.json` + `package.json`. `import.meta.dir` points at
|
|
179
|
+
* `src/`; walk up one level. Matches the resolver in `http-server.ts`'s
|
|
180
|
+
* `defaultParachuteDir()`.
|
|
181
|
+
*/
|
|
182
|
+
export function resolveProjectRoot(): string {
|
|
183
|
+
return path.resolve(import.meta.dir, "..");
|
|
184
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-registration into `~/.parachute/services.json` on `parachute-app
|
|
3
|
+
* serve` boot.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors `parachute-runner/src/services-manifest.ts` deliberately — the file
|
|
6
|
+
* shape is the contract between every Parachute module and the hub
|
|
7
|
+
* (`parachute-hub/src/services-manifest.ts` is the canonical reader).
|
|
8
|
+
*
|
|
9
|
+
* Failure mode: any write error is logged + swallowed by the caller. Self-
|
|
10
|
+
* registration is best-effort — the daemon still serves locally even if the
|
|
11
|
+
* manifest write fails (permissions, disk full, race with another writer,
|
|
12
|
+
* malformed pre-existing file).
|
|
13
|
+
*
|
|
14
|
+
* `installDir` is the third-party-module hook (parachute-hub#84): hub looks
|
|
15
|
+
* the field up to resolve `parachute restart app` back to the checkout it
|
|
16
|
+
* should drive. Self-registering it here means app doesn't need a vendored
|
|
17
|
+
* fallback in hub's `FIRST_PARTY_FALLBACKS` registry.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
|
|
23
|
+
export interface ServiceEntry {
|
|
24
|
+
name: string;
|
|
25
|
+
port: number;
|
|
26
|
+
paths: string[];
|
|
27
|
+
health: string;
|
|
28
|
+
version: string;
|
|
29
|
+
displayName?: string;
|
|
30
|
+
tagline?: string;
|
|
31
|
+
installDir?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Hub-stamped fields (e.g. `installDir` from parachute-hub#84, future
|
|
34
|
+
* uiUrl / managementUrl pass-throughs) ride on the row even though the
|
|
35
|
+
* module itself doesn't author them. The upsert merges rather than
|
|
36
|
+
* replaces so those survive a self-registration write.
|
|
37
|
+
*
|
|
38
|
+
* App-specific: the per-UI `uis` map (design doc section 12) also lands
|
|
39
|
+
* here once Phase 1.2 ships per-UI registration. The current shape lets
|
|
40
|
+
* a Phase 1.2 PR add `uis` without re-touching the storage layer.
|
|
41
|
+
*/
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ServicesManifest {
|
|
46
|
+
services: ServiceEntry[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Canonical location of `services.json`. Honors `PARACHUTE_HOME` for sandbox +
|
|
51
|
+
* Render deployments (matches the convention every other committed-core
|
|
52
|
+
* module follows).
|
|
53
|
+
*/
|
|
54
|
+
export function resolveManifestPath(env: Record<string, string | undefined> = process.env): string {
|
|
55
|
+
const base = env.PARACHUTE_HOME ?? join(env.HOME ?? os.homedir(), ".parachute");
|
|
56
|
+
return join(base, "services.json");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readManifest(path: string): ServicesManifest {
|
|
60
|
+
if (!existsSync(path)) return { services: [] };
|
|
61
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
62
|
+
if (!raw || typeof raw !== "object" || !Array.isArray((raw as { services?: unknown }).services)) {
|
|
63
|
+
throw new Error(`services manifest at ${path} is malformed (missing "services" array)`);
|
|
64
|
+
}
|
|
65
|
+
return raw as ServicesManifest;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read an existing service entry from the manifest. Returns `undefined` when
|
|
70
|
+
* the file is missing or no row matches `name`.
|
|
71
|
+
*
|
|
72
|
+
* Used at boot so app can respect an operator- or hub-set port already
|
|
73
|
+
* recorded in services.json (same first-boot-vs-subsequent-boot discipline
|
|
74
|
+
* scribe + agent settled on — see paraclaw#145 / scribe#40).
|
|
75
|
+
*/
|
|
76
|
+
export function readServiceEntry(
|
|
77
|
+
name: string,
|
|
78
|
+
path: string = resolveManifestPath(),
|
|
79
|
+
): ServiceEntry | undefined {
|
|
80
|
+
const manifest = readManifest(path);
|
|
81
|
+
return manifest.services.find((s) => s.name === name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Idempotent upsert of a service entry. Merges into any existing row rather
|
|
86
|
+
* than replacing it — preserves hub-stamped fields the module doesn't own
|
|
87
|
+
* (installDir from hub#84, future uiUrl, etc.). The module still wins for
|
|
88
|
+
* the fields it owns (port, paths, version, health, displayName, installDir
|
|
89
|
+
* — because `entry` spreads last in the merge).
|
|
90
|
+
*
|
|
91
|
+
* Atomic write: stages to `<path>.tmp-<pid>-<now>`, then renames over the
|
|
92
|
+
* target. A crash mid-write leaves the prior file intact rather than
|
|
93
|
+
* corrupting it.
|
|
94
|
+
*/
|
|
95
|
+
export function upsertService(entry: ServiceEntry, path: string = resolveManifestPath()): void {
|
|
96
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
97
|
+
const manifest = readManifest(path);
|
|
98
|
+
const idx = manifest.services.findIndex((s) => s.name === entry.name);
|
|
99
|
+
if (idx >= 0) manifest.services[idx] = { ...manifest.services[idx], ...entry };
|
|
100
|
+
else manifest.services.push(entry);
|
|
101
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
102
|
+
writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
103
|
+
renameSync(tmp, path);
|
|
104
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime tenancy contract injection — implements the host side of
|
|
3
|
+
* `parachute-patterns/patterns/runtime-tenancy-contract.md`.
|
|
4
|
+
*
|
|
5
|
+
* For every `index.html` parachute-app serves on behalf of a hosted UI we
|
|
6
|
+
* inject a small block of structured environment metadata into `<head>`:
|
|
7
|
+
*
|
|
8
|
+
* <head>
|
|
9
|
+
* <base href="/app/<name>/"> browser URL resolution
|
|
10
|
+
* <meta name="parachute-mount" content="/app/<name>"> runtime code reads this
|
|
11
|
+
* <meta name="parachute-hub" content="<hub-origin>"> OAuth discovery
|
|
12
|
+
* ... existing head ...
|
|
13
|
+
* </head>
|
|
14
|
+
*
|
|
15
|
+
* Two layers, deliberately:
|
|
16
|
+
*
|
|
17
|
+
* - `<base href>` is load-bearing for the BROWSER's URL resolution. Without
|
|
18
|
+
* it, Vite-built bundles' `./assets/...` URLs resolve against the
|
|
19
|
+
* document's perceived directory (`/app/` when the operator visits
|
|
20
|
+
* `/app/notes` with no trailing slash), and 404 on every asset.
|
|
21
|
+
* - `<meta name="parachute-mount">` / `<meta name="parachute-hub">` are
|
|
22
|
+
* read at runtime by `@openparachute/app-client` (parachute-app#22).
|
|
23
|
+
* Strings the bundle reads as JavaScript — no help from the browser's
|
|
24
|
+
* URL resolver, hence the separate meta tags.
|
|
25
|
+
*
|
|
26
|
+
* Tags deliberately deferred (out of scope for parachute-app#21):
|
|
27
|
+
* - `parachute-vault` — needs vault-binding-via-session design that's
|
|
28
|
+
* orthogonal to the mount-path concerns this PR addresses.
|
|
29
|
+
* - `parachute-tenant-id` — derivable on the consumer side from
|
|
30
|
+
* `parachute-mount`; not worth its own injection.
|
|
31
|
+
* - `parachute-vault-origin` — forward-looking for cross-origin vault.
|
|
32
|
+
*
|
|
33
|
+
* Why string scanning (no `cheerio`): the rationale in `dev-injection.ts`
|
|
34
|
+
* applies identically here. One conservative substring insertion; we don't
|
|
35
|
+
* want to canonicalize the operator's HTML.
|
|
36
|
+
*
|
|
37
|
+
* Idempotency: if `<meta name="parachute-mount">` is already present in
|
|
38
|
+
* the source HTML (e.g. some future bundle ships its own injection), we
|
|
39
|
+
* leave the document untouched. The marker check is regex-based — a false
|
|
40
|
+
* positive (a comment containing the exact attribute) suppresses injection
|
|
41
|
+
* harmlessly.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** Regex marker for idempotency. Matches both quote styles + case. */
|
|
45
|
+
const MOUNT_META_REGEX = /<meta\b[^>]*\bname\s*=\s*['"]parachute-mount['"][^>]*>/i;
|
|
46
|
+
/** Find the opening `<head ...>` tag, case-insensitive, whitespace-tolerant. */
|
|
47
|
+
const HEAD_OPEN_REGEX = /<head(\s[^>]*)?>/i;
|
|
48
|
+
|
|
49
|
+
/** Minimal HTML attribute-value escape — order matters (escape `&` first). */
|
|
50
|
+
export function escapeHtmlAttr(s: string): string {
|
|
51
|
+
return s
|
|
52
|
+
.replace(/&/g, "&")
|
|
53
|
+
.replace(/"/g, """)
|
|
54
|
+
.replace(/</g, "<")
|
|
55
|
+
.replace(/>/g, ">");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type TenancyInjectionResult = {
|
|
59
|
+
/** The (maybe-modified) document. */
|
|
60
|
+
html: string;
|
|
61
|
+
/** Did we change anything? */
|
|
62
|
+
injected: boolean;
|
|
63
|
+
/** Why we skipped, if we did. `undefined` on the happy path. */
|
|
64
|
+
skipped?: "already-present" | "no-head";
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Inject the runtime tenancy contract tags into `html`.
|
|
69
|
+
*
|
|
70
|
+
* - `mount` is the UI's mount path (`/app/<name>`, no trailing slash).
|
|
71
|
+
* Used unchanged in `<meta name="parachute-mount">`; appended with `/`
|
|
72
|
+
* in `<base href>` (the trailing slash is browser-URL-resolution
|
|
73
|
+
* critical).
|
|
74
|
+
* - `hubOrigin` is the absolute hub URL (`http://127.0.0.1:1939` or
|
|
75
|
+
* `https://parachute.example.com`).
|
|
76
|
+
*
|
|
77
|
+
* Returns `{ html, injected, skipped? }`:
|
|
78
|
+
* - `injected: true` + `skipped: undefined` on the happy path.
|
|
79
|
+
* - `injected: false` + `skipped: "already-present"` when the source
|
|
80
|
+
* already declares `parachute-mount` (idempotent).
|
|
81
|
+
* - `injected: false` + `skipped: "no-head"` when the document has no
|
|
82
|
+
* `<head>` (malformed; caller logs + serves unmodified).
|
|
83
|
+
*
|
|
84
|
+
* Note we insert AFTER the opening `<head>` tag, not before `</head>` like
|
|
85
|
+
* `dev-injection.ts` does. The contract tags should come BEFORE any
|
|
86
|
+
* existing `<base>` / `<link rel=icon>` / `<script>` so the browser's
|
|
87
|
+
* URL resolver picks up the injected `<base href>` for everything in the
|
|
88
|
+
* document. (Per HTML spec, the first `<base>` wins — if the bundle ships
|
|
89
|
+
* its own and we insert after it, the bundle's wins; inserting at head-top
|
|
90
|
+
* lets the host's mount-aware base take precedence.)
|
|
91
|
+
*/
|
|
92
|
+
export function injectTenancyContract(
|
|
93
|
+
html: string,
|
|
94
|
+
mount: string,
|
|
95
|
+
hubOrigin: string,
|
|
96
|
+
): TenancyInjectionResult {
|
|
97
|
+
// Idempotent: bail if the marker is already in the doc.
|
|
98
|
+
if (MOUNT_META_REGEX.test(html)) {
|
|
99
|
+
return { html, injected: false, skipped: "already-present" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Find the first <head> that's NOT inside an HTML comment. Vite-built
|
|
103
|
+
// HTML never has `<!-- <head> -->` comments, but be defensive — a
|
|
104
|
+
// false match inside a comment would inject malformed HTML.
|
|
105
|
+
let searchFrom = 0;
|
|
106
|
+
let headMatch: RegExpExecArray | null = null;
|
|
107
|
+
while (true) {
|
|
108
|
+
HEAD_OPEN_REGEX.lastIndex = 0;
|
|
109
|
+
const candidate = HEAD_OPEN_REGEX.exec(html.slice(searchFrom));
|
|
110
|
+
if (!candidate) break;
|
|
111
|
+
const absoluteIndex = searchFrom + candidate.index;
|
|
112
|
+
if (!isInsideComment(html, absoluteIndex)) {
|
|
113
|
+
headMatch = candidate;
|
|
114
|
+
headMatch.index = absoluteIndex;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
// Skip past this comment and search again.
|
|
118
|
+
const commentClose = html.indexOf("-->", absoluteIndex);
|
|
119
|
+
if (commentClose === -1) break;
|
|
120
|
+
searchFrom = commentClose + 3;
|
|
121
|
+
}
|
|
122
|
+
if (!headMatch) {
|
|
123
|
+
return { html, injected: false, skipped: "no-head" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const baseHref = `${mount}/`;
|
|
127
|
+
const block = [
|
|
128
|
+
`<base href="${escapeHtmlAttr(baseHref)}">`,
|
|
129
|
+
`<meta name="parachute-mount" content="${escapeHtmlAttr(mount)}">`,
|
|
130
|
+
`<meta name="parachute-hub" content="${escapeHtmlAttr(hubOrigin)}">`,
|
|
131
|
+
].join("\n ");
|
|
132
|
+
|
|
133
|
+
const insertAt = headMatch.index + headMatch[0].length;
|
|
134
|
+
return {
|
|
135
|
+
html: `${html.slice(0, insertAt)}\n ${block}${html.slice(insertAt)}`,
|
|
136
|
+
injected: true,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* True if `pos` falls inside an unclosed HTML comment that opens before
|
|
142
|
+
* it. Used to skip `<head>` matches that are really `<!-- ... <head> ... -->`.
|
|
143
|
+
*/
|
|
144
|
+
function isInsideComment(html: string, pos: number): boolean {
|
|
145
|
+
const lastOpen = html.lastIndexOf("<!--", pos);
|
|
146
|
+
if (lastOpen === -1) return false;
|
|
147
|
+
const closeAfterOpen = html.indexOf("-->", lastOpen);
|
|
148
|
+
return closeAfterOpen !== -1 && closeAfterOpen > pos;
|
|
149
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI discovery for parachute-app.
|
|
3
|
+
*
|
|
4
|
+
* Scans `$PARACHUTE_HOME/app/uis/` for subdirectories. For each:
|
|
5
|
+
* 1. Look for `meta.json` — skip + warn if missing.
|
|
6
|
+
* 2. Look for `dist/index.html` — skip + warn if missing.
|
|
7
|
+
* 3. Parse + validate meta.json via `parseMeta` — skip + warn on invalid.
|
|
8
|
+
* 4. Compute the absolute paths app will resolve assets against at serve-time.
|
|
9
|
+
*
|
|
10
|
+
* Returns the validated `RegisteredUi[]` list. Mount-path collisions are
|
|
11
|
+
* resolved deterministically: alphabetical-by-name wins, others are demoted
|
|
12
|
+
* to `status: "collision"` and dropped from the active mount set (still
|
|
13
|
+
* surfaced in `parachute-app list` once that lands in Phase 1.2). Same shape
|
|
14
|
+
* the design doc landed in section 8, modulo the alphabetical tie-break
|
|
15
|
+
* which the Phase 1.1 brief asked for explicitly.
|
|
16
|
+
*
|
|
17
|
+
* `RegisteredUi.distDir` is the absolute path to the `dist/` directory; the
|
|
18
|
+
* HTTP layer resolves each asset request against it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
|
|
24
|
+
import { resolveUisDir } from "./config.ts";
|
|
25
|
+
import { InvalidMetaError, type UiMeta, parseMeta } from "./meta-schema.ts";
|
|
26
|
+
|
|
27
|
+
export type UiStatus =
|
|
28
|
+
| "active"
|
|
29
|
+
| "missing-meta"
|
|
30
|
+
| "missing-dist"
|
|
31
|
+
| "invalid-meta"
|
|
32
|
+
| "collision"
|
|
33
|
+
| "reserved-path";
|
|
34
|
+
|
|
35
|
+
export type RegisteredUi = {
|
|
36
|
+
/** Directory name under `uis/`. May differ from `meta.name` until Phase 1.2 enforces alignment. */
|
|
37
|
+
dirName: string;
|
|
38
|
+
/** Absolute path to the `<uis>/<dirName>/` directory. */
|
|
39
|
+
uiDir: string;
|
|
40
|
+
/** Absolute path to the `<uis>/<dirName>/dist/` directory. */
|
|
41
|
+
distDir: string;
|
|
42
|
+
/** Parsed meta.json. */
|
|
43
|
+
meta: UiMeta;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type SkippedUi = {
|
|
47
|
+
dirName: string;
|
|
48
|
+
uiDir: string;
|
|
49
|
+
status: Exclude<UiStatus, "active">;
|
|
50
|
+
reason: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ScanResult = {
|
|
54
|
+
/** UIs that mounted successfully + survived collision resolution. */
|
|
55
|
+
registered: RegisteredUi[];
|
|
56
|
+
/** UIs the scanner found but couldn't activate. */
|
|
57
|
+
skipped: SkippedUi[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type ScanOpts = {
|
|
61
|
+
/** Override the uis-dir location (tests). Defaults to `resolveUisDir()`. */
|
|
62
|
+
uisDir?: string;
|
|
63
|
+
/** Logger override; default console. */
|
|
64
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Path the admin SPA reserves once Phase 1.2 lands. A meta.json that tries
|
|
69
|
+
* to claim it is rejected at scan time. Kept as a const so Phase 1.2's
|
|
70
|
+
* `add` flow can share the same check.
|
|
71
|
+
*/
|
|
72
|
+
export const RESERVED_PATHS: ReadonlySet<string> = new Set(["/app/admin", "/app/dev"]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Scan `uisDir` for declared UIs. Best-effort: a malformed UI is skipped +
|
|
76
|
+
* surfaced in `skipped`; it never throws. Only an exception reading the
|
|
77
|
+
* uisDir itself (permissions, broken symlink at the directory level)
|
|
78
|
+
* propagates — that's a fatal config issue the operator needs to see.
|
|
79
|
+
*/
|
|
80
|
+
export function scanUis(opts: ScanOpts = {}): ScanResult {
|
|
81
|
+
const uisDir = opts.uisDir ?? resolveUisDir();
|
|
82
|
+
const logger = opts.logger ?? console;
|
|
83
|
+
|
|
84
|
+
if (!existsSync(uisDir)) {
|
|
85
|
+
// Fresh install — no `uis/` directory yet. That's fine; just return empty.
|
|
86
|
+
logger.log(`[app] uis dir not found at ${uisDir}; no UIs to mount`);
|
|
87
|
+
return { registered: [], skipped: [] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let entries: string[];
|
|
91
|
+
try {
|
|
92
|
+
entries = readdirSync(uisDir);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
logger.error(`[app] failed to read uis dir ${uisDir}: ${(e as Error).message}`);
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const candidates: Array<RegisteredUi | SkippedUi> = [];
|
|
99
|
+
|
|
100
|
+
for (const dirName of entries.sort()) {
|
|
101
|
+
const uiDir = path.join(uisDir, dirName);
|
|
102
|
+
let st: ReturnType<typeof statSync>;
|
|
103
|
+
try {
|
|
104
|
+
st = statSync(uiDir);
|
|
105
|
+
} catch {
|
|
106
|
+
// Disappearing entries (race, broken symlink) — skip silently.
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!st.isDirectory()) continue;
|
|
110
|
+
|
|
111
|
+
const metaPath = path.join(uiDir, "meta.json");
|
|
112
|
+
const distDir = path.join(uiDir, "dist");
|
|
113
|
+
const indexPath = path.join(distDir, "index.html");
|
|
114
|
+
|
|
115
|
+
if (!existsSync(metaPath)) {
|
|
116
|
+
const reason = `missing meta.json at ${metaPath}`;
|
|
117
|
+
logger.warn(`[app] skip ${dirName}: ${reason}`);
|
|
118
|
+
candidates.push({ dirName, uiDir, status: "missing-meta", reason });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!existsSync(indexPath)) {
|
|
123
|
+
const reason = `missing dist/index.html at ${indexPath}`;
|
|
124
|
+
logger.warn(`[app] skip ${dirName}: ${reason}`);
|
|
125
|
+
candidates.push({ dirName, uiDir, status: "missing-dist", reason });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let raw: unknown;
|
|
130
|
+
try {
|
|
131
|
+
raw = JSON.parse(readFileSync(metaPath, "utf8"));
|
|
132
|
+
} catch (e) {
|
|
133
|
+
const reason = `meta.json is not valid JSON: ${(e as Error).message}`;
|
|
134
|
+
logger.warn(`[app] skip ${dirName}: ${reason}`);
|
|
135
|
+
candidates.push({ dirName, uiDir, status: "invalid-meta", reason });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let meta: UiMeta;
|
|
140
|
+
try {
|
|
141
|
+
meta = parseMeta(raw);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
const reason =
|
|
144
|
+
e instanceof InvalidMetaError
|
|
145
|
+
? e.message
|
|
146
|
+
: `meta.json validation failed: ${(e as Error).message}`;
|
|
147
|
+
logger.warn(`[app] skip ${dirName}: ${reason}`);
|
|
148
|
+
candidates.push({ dirName, uiDir, status: "invalid-meta", reason });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (RESERVED_PATHS.has(meta.path)) {
|
|
153
|
+
const reason = `meta.path "${meta.path}" is reserved (admin SPA)`;
|
|
154
|
+
logger.warn(`[app] skip ${dirName}: ${reason}`);
|
|
155
|
+
candidates.push({ dirName, uiDir, status: "reserved-path", reason });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
candidates.push({ dirName, uiDir, distDir, meta });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Resolve mount-path collisions deterministically: among UIs declaring the
|
|
163
|
+
// same `meta.path`, the lexicographically-smallest `meta.name` wins; the
|
|
164
|
+
// others are demoted to `status: "collision"`.
|
|
165
|
+
const byPath = new Map<string, RegisteredUi[]>();
|
|
166
|
+
const skipped: SkippedUi[] = [];
|
|
167
|
+
for (const c of candidates) {
|
|
168
|
+
if ("meta" in c) {
|
|
169
|
+
const list = byPath.get(c.meta.path) ?? [];
|
|
170
|
+
list.push(c);
|
|
171
|
+
byPath.set(c.meta.path, list);
|
|
172
|
+
} else {
|
|
173
|
+
skipped.push(c);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const registered: RegisteredUi[] = [];
|
|
178
|
+
for (const [mountPath, list] of byPath) {
|
|
179
|
+
if (list.length === 1) {
|
|
180
|
+
registered.push(list[0]!);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
list.sort((a, b) => a.meta.name.localeCompare(b.meta.name));
|
|
184
|
+
const winner = list[0]!;
|
|
185
|
+
registered.push(winner);
|
|
186
|
+
for (let i = 1; i < list.length; i++) {
|
|
187
|
+
const loser = list[i]!;
|
|
188
|
+
const reason = `mount path ${mountPath} also claimed by "${winner.meta.name}" — alphabetical tie-break wins`;
|
|
189
|
+
logger.warn(`[app] skip ${loser.dirName}: ${reason}`);
|
|
190
|
+
skipped.push({
|
|
191
|
+
dirName: loser.dirName,
|
|
192
|
+
uiDir: loser.uiDir,
|
|
193
|
+
status: "collision",
|
|
194
|
+
reason,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Stable ordering for downstream consumers (HTTP routing + admin list).
|
|
200
|
+
registered.sort((a, b) => a.meta.path.localeCompare(b.meta.path));
|
|
201
|
+
return { registered, skipped };
|
|
202
|
+
}
|