@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,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-mode reload-script injection — Phase 1.3.
|
|
3
|
+
*
|
|
4
|
+
* When a UI is in dev mode AND its `index.html` is served, we inject a
|
|
5
|
+
* small `<script>` that opens an EventSource against the SSE reload
|
|
6
|
+
* endpoint and reloads the tab on a `reload` event. The script is
|
|
7
|
+
* idempotent — re-injection doesn't duplicate because we tag it with a
|
|
8
|
+
* known `id` and skip if that id is already present.
|
|
9
|
+
*
|
|
10
|
+
* Why string scanning instead of cheerio:
|
|
11
|
+
*
|
|
12
|
+
* We considered `cheerio` (the brief explicitly invites it), but a
|
|
13
|
+
* 500KB+ HTML-parser dep for ONE conservative insertion is the wrong
|
|
14
|
+
* shape. The injection point is well-defined: just before `</head>`.
|
|
15
|
+
* The regex is case-insensitive and tolerates whitespace. The
|
|
16
|
+
* fallback chain (head → first `<script>` → first `<body>` → append)
|
|
17
|
+
* handles the unusual cases the brief calls out.
|
|
18
|
+
*
|
|
19
|
+
* Cheerio also serializes the document on output, which would re-emit
|
|
20
|
+
* the operator's HTML in cheerio's canonical form. For a dev-mode
|
|
21
|
+
* shim we want the document untouched apart from one inserted line.
|
|
22
|
+
*
|
|
23
|
+
* If we ever need richer manipulation (CSP rewrites, link-prefetch
|
|
24
|
+
* stripping, etc.) we revisit. For now, regex.
|
|
25
|
+
*
|
|
26
|
+
* Idempotency contract:
|
|
27
|
+
*
|
|
28
|
+
* - `id="parachute-app-dev-reload"` is the marker. Any earlier
|
|
29
|
+
* injection sets it, so a re-render finds it and skips.
|
|
30
|
+
* - The marker check is regex-based; we don't parse the HTML to find
|
|
31
|
+
* it. False positives (a comment containing the exact marker)
|
|
32
|
+
* would suppress injection harmlessly — dev mode would still work,
|
|
33
|
+
* it just wouldn't re-inject. Conservative.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Marker id used to deduplicate the injected `<script>`. Exported because
|
|
38
|
+
* tests assert on it.
|
|
39
|
+
*/
|
|
40
|
+
export const DEV_RELOAD_SCRIPT_ID = "parachute-app-dev-reload" as const;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Marker regex matching the script tag's `id` attribute. We accept both
|
|
44
|
+
* quote styles (`id="..."` and `id='...'`).
|
|
45
|
+
*/
|
|
46
|
+
const ID_MARKER_REGEX = new RegExp(
|
|
47
|
+
`id\\s*=\\s*['"]${DEV_RELOAD_SCRIPT_ID.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}['"]`,
|
|
48
|
+
"i",
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/** Find `</head>` (case-insensitive, whitespace-tolerant). */
|
|
52
|
+
const HEAD_CLOSE_REGEX = /<\/\s*head\s*>/i;
|
|
53
|
+
/** Find the first `<script ...>` tag. */
|
|
54
|
+
const FIRST_SCRIPT_REGEX = /<script\b/i;
|
|
55
|
+
/** Find the opening `<body ...>` tag. */
|
|
56
|
+
const BODY_OPEN_REGEX = /<body\b[^>]*>/i;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the dev-reload script tag. The script:
|
|
60
|
+
* - Opens an EventSource against `<endpoint>` (mount-relative path).
|
|
61
|
+
* - On `reload`, schedules a `window.location.reload()` 200ms out
|
|
62
|
+
* (debounce — covers the case where a Phase 2 watcher fires the same
|
|
63
|
+
* event twice in quick succession).
|
|
64
|
+
* - Silently ignores errors; EventSource auto-reconnects on transient
|
|
65
|
+
* drops by default.
|
|
66
|
+
*
|
|
67
|
+
* `endpoint` is relative to the UI's mount path (e.g. `/app/notes/_dev/reload`)
|
|
68
|
+
* — passed in so we can build absolute URLs the browser will navigate to
|
|
69
|
+
* correctly regardless of the document's `<base>` tag.
|
|
70
|
+
*/
|
|
71
|
+
export function buildDevReloadScript(endpoint: string): string {
|
|
72
|
+
// The endpoint is interpolated as a string literal; escape any embedded
|
|
73
|
+
// quote / backslash so a hostile mount path can't break out. Mount
|
|
74
|
+
// paths are constrained by PATH_PATTERN so this is belt-and-braces.
|
|
75
|
+
const safeEndpoint = JSON.stringify(endpoint);
|
|
76
|
+
return `<script id="${DEV_RELOAD_SCRIPT_ID}">
|
|
77
|
+
(() => {
|
|
78
|
+
try {
|
|
79
|
+
const es = new EventSource(${safeEndpoint});
|
|
80
|
+
let pending = false;
|
|
81
|
+
es.addEventListener("reload", () => {
|
|
82
|
+
if (pending) return;
|
|
83
|
+
pending = true;
|
|
84
|
+
setTimeout(() => { window.location.reload(); }, 200);
|
|
85
|
+
});
|
|
86
|
+
es.addEventListener("error", () => {
|
|
87
|
+
/* EventSource auto-reconnects; nothing to do */
|
|
88
|
+
});
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.warn("[parachute-app dev-reload] failed to start:", e);
|
|
91
|
+
}
|
|
92
|
+
})();
|
|
93
|
+
</script>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Inject the dev-reload script into `html`. If the marker is already
|
|
98
|
+
* present, return `html` unchanged (idempotent). Otherwise insert the
|
|
99
|
+
* `<script>` immediately before `</head>`. Fallback chain when there's
|
|
100
|
+
* no `</head>`:
|
|
101
|
+
*
|
|
102
|
+
* 1. Before the first `<script>` tag.
|
|
103
|
+
* 2. After the opening `<body>` tag.
|
|
104
|
+
* 3. Append to end (with a `console.warn` from the script itself —
|
|
105
|
+
* callers also log a warning so operators see the affordance).
|
|
106
|
+
*
|
|
107
|
+
* Returns `{ html, injected, fallback }`:
|
|
108
|
+
* - `html`: the (maybe-modified) document.
|
|
109
|
+
* - `injected`: did we change anything?
|
|
110
|
+
* - `fallback`: which fallback branch fired (or `undefined` for the
|
|
111
|
+
* happy path). Tests + log surface this.
|
|
112
|
+
*/
|
|
113
|
+
export function injectDevReloadScript(
|
|
114
|
+
html: string,
|
|
115
|
+
endpoint: string,
|
|
116
|
+
): {
|
|
117
|
+
html: string;
|
|
118
|
+
injected: boolean;
|
|
119
|
+
fallback?: "before-script" | "after-body" | "append";
|
|
120
|
+
} {
|
|
121
|
+
// Idempotent: bail if the marker is already in the doc.
|
|
122
|
+
if (ID_MARKER_REGEX.test(html)) {
|
|
123
|
+
return { html, injected: false };
|
|
124
|
+
}
|
|
125
|
+
const script = `${buildDevReloadScript(endpoint)}\n`;
|
|
126
|
+
|
|
127
|
+
// Happy path: just before </head>.
|
|
128
|
+
const headMatch = HEAD_CLOSE_REGEX.exec(html);
|
|
129
|
+
if (headMatch) {
|
|
130
|
+
const idx = headMatch.index;
|
|
131
|
+
return {
|
|
132
|
+
html: `${html.slice(0, idx)}${script}${html.slice(idx)}`,
|
|
133
|
+
injected: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Fallback 1: before the first <script>.
|
|
138
|
+
const scriptMatch = FIRST_SCRIPT_REGEX.exec(html);
|
|
139
|
+
if (scriptMatch) {
|
|
140
|
+
const idx = scriptMatch.index;
|
|
141
|
+
return {
|
|
142
|
+
html: `${html.slice(0, idx)}${script}${html.slice(idx)}`,
|
|
143
|
+
injected: true,
|
|
144
|
+
fallback: "before-script",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Fallback 2: after the opening <body>.
|
|
149
|
+
const bodyMatch = BODY_OPEN_REGEX.exec(html);
|
|
150
|
+
if (bodyMatch) {
|
|
151
|
+
const idx = bodyMatch.index + bodyMatch[0].length;
|
|
152
|
+
return {
|
|
153
|
+
html: `${html.slice(0, idx)}\n${script}${html.slice(idx)}`,
|
|
154
|
+
injected: true,
|
|
155
|
+
fallback: "after-body",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Fallback 3: append. Operators with a malformed document still get
|
|
160
|
+
// the affordance.
|
|
161
|
+
return {
|
|
162
|
+
html: `${html}\n${script}`,
|
|
163
|
+
injected: true,
|
|
164
|
+
fallback: "append",
|
|
165
|
+
};
|
|
166
|
+
}
|
package/src/dev-mode.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-UI dev-mode state — Phase 1.3.
|
|
3
|
+
*
|
|
4
|
+
* Solves the "edit code, build, browser still shows old" frustration
|
|
5
|
+
* (parachute-notes#151) at the platform level. Each registered UI carries
|
|
6
|
+
* an optional dev-mode flag the operator toggles via `parachute-app dev
|
|
7
|
+
* <name>` (Phase 1.3) or the admin SPA. When dev mode is on:
|
|
8
|
+
*
|
|
9
|
+
* 1. The HTTP server emits `Cache-Control: no-cache, no-store,
|
|
10
|
+
* must-revalidate` on every response from that UI (overrides smart
|
|
11
|
+
* caching for hashed assets + 1h-default for non-hashed).
|
|
12
|
+
* 2. The UI's `index.html` gets an injected `<script>` tag that opens
|
|
13
|
+
* an EventSource against `/app/<name>/_dev/reload`. Operator-triggered
|
|
14
|
+
* reload events broadcast on the stream cause the tab to reload.
|
|
15
|
+
* 3. The operator-flow trigger is manual at MVP — `parachute-app dev
|
|
16
|
+
* <name> --trigger`. Phase 2 will wire a file watcher to fire the
|
|
17
|
+
* same broadcast on dist/ change.
|
|
18
|
+
*
|
|
19
|
+
* State design choices:
|
|
20
|
+
*
|
|
21
|
+
* - Process-local, in-memory. A daemon restart returns every UI to
|
|
22
|
+
* production cache headers. This is deliberate — dev mode is an
|
|
23
|
+
* interactive operator concern, not a persisted property of the UI
|
|
24
|
+
* itself. If an operator wants persistence later, meta.json could
|
|
25
|
+
* grow a `dev_mode_default` field (Phase 2+).
|
|
26
|
+
* - One map module-wide. Lookup is O(name) on every request, but the
|
|
27
|
+
* map is at most a handful of entries (operator iterating on UIs).
|
|
28
|
+
* - SSE controllers live in a separate `Set` per-UI; broadcast iterates
|
|
29
|
+
* and tolerates per-client errors (disconnects are normal).
|
|
30
|
+
*
|
|
31
|
+
* Concurrency notes: Bun runs the event loop single-threaded, so the
|
|
32
|
+
* mutations here (Map.set, Set.add) are atomic relative to one another;
|
|
33
|
+
* no locking needed.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export type DevModeState = {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
/** ms since epoch when `enabled` was last flipped to `true`. 0 when disabled. */
|
|
39
|
+
enabledAt: number;
|
|
40
|
+
/** Phase 2 — file watcher source dir override. Stored for forward-compat. */
|
|
41
|
+
watchDir?: string;
|
|
42
|
+
/** Phase 2 — auto-rebuild command override. Stored for forward-compat. */
|
|
43
|
+
buildCmd?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* SSE subscriber — a connected browser tab listening on
|
|
48
|
+
* `/app/<name>/_dev/reload`. We keep both the controller (for `enqueue`)
|
|
49
|
+
* and the encoder (we always emit utf8) so the broadcast path stays
|
|
50
|
+
* allocation-light.
|
|
51
|
+
*
|
|
52
|
+
* Per-subscriber `closed` flag short-circuits the broadcast loop if a
|
|
53
|
+
* `controller.enqueue` already threw on this client — we mark it dead
|
|
54
|
+
* and reap on the next pass instead of re-throwing on every event.
|
|
55
|
+
*/
|
|
56
|
+
export type DevReloadSubscriber = {
|
|
57
|
+
controller: ReadableStreamDefaultController<Uint8Array>;
|
|
58
|
+
closed: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const STATE = new Map<string, DevModeState>();
|
|
62
|
+
const SUBSCRIBERS = new Map<string, Set<DevReloadSubscriber>>();
|
|
63
|
+
|
|
64
|
+
/** Return the dev-mode state for a UI, or the default (disabled). */
|
|
65
|
+
export function getDevMode(name: string): DevModeState {
|
|
66
|
+
return STATE.get(name) ?? { enabled: false, enabledAt: 0 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Pure predicate, used everywhere the cache + injection branches read. */
|
|
70
|
+
export function isDevMode(name: string): boolean {
|
|
71
|
+
return STATE.get(name)?.enabled === true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** List every UI currently in dev mode (for the `dev list` CLI). */
|
|
75
|
+
export function listDevMode(): Array<{ name: string; state: DevModeState }> {
|
|
76
|
+
const out: Array<{ name: string; state: DevModeState }> = [];
|
|
77
|
+
for (const [name, state] of STATE) {
|
|
78
|
+
if (state.enabled) out.push({ name, state });
|
|
79
|
+
}
|
|
80
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Enable dev mode for `name`. Idempotent — calling twice doesn't reset
|
|
86
|
+
* the timestamp. Returns the resulting state.
|
|
87
|
+
*/
|
|
88
|
+
export function enableDevMode(
|
|
89
|
+
name: string,
|
|
90
|
+
opts: { watchDir?: string; buildCmd?: string } = {},
|
|
91
|
+
): DevModeState {
|
|
92
|
+
const existing = STATE.get(name);
|
|
93
|
+
if (existing?.enabled) {
|
|
94
|
+
// Idempotent — preserve the earlier `enabledAt`.
|
|
95
|
+
return existing;
|
|
96
|
+
}
|
|
97
|
+
const next: DevModeState = {
|
|
98
|
+
enabled: true,
|
|
99
|
+
enabledAt: Date.now(),
|
|
100
|
+
watchDir: opts.watchDir,
|
|
101
|
+
buildCmd: opts.buildCmd,
|
|
102
|
+
};
|
|
103
|
+
STATE.set(name, next);
|
|
104
|
+
return next;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Disable dev mode for `name`. Also closes every connected SSE subscriber
|
|
109
|
+
* so the next page load resumes production cache headers cleanly. Returns
|
|
110
|
+
* the resulting state (always `enabled: false`).
|
|
111
|
+
*/
|
|
112
|
+
export function disableDevMode(name: string): DevModeState {
|
|
113
|
+
STATE.set(name, { enabled: false, enabledAt: 0 });
|
|
114
|
+
// Close any active SSE streams so the browser's EventSource auto-reconnect
|
|
115
|
+
// doesn't keep retrying against a UI that's no longer in dev mode.
|
|
116
|
+
closeAllSubscribers(name);
|
|
117
|
+
return STATE.get(name)!;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Reset all dev-mode state. Tests use this. */
|
|
121
|
+
export function resetDevMode(): void {
|
|
122
|
+
for (const name of [...SUBSCRIBERS.keys()]) {
|
|
123
|
+
closeAllSubscribers(name);
|
|
124
|
+
}
|
|
125
|
+
STATE.clear();
|
|
126
|
+
SUBSCRIBERS.clear();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Register a new SSE subscriber. The caller holds the controller. */
|
|
130
|
+
export function addSubscriber(name: string, subscriber: DevReloadSubscriber): void {
|
|
131
|
+
let set = SUBSCRIBERS.get(name);
|
|
132
|
+
if (!set) {
|
|
133
|
+
set = new Set();
|
|
134
|
+
SUBSCRIBERS.set(name, set);
|
|
135
|
+
}
|
|
136
|
+
set.add(subscriber);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Drop a subscriber (called from the stream's `cancel` hook). */
|
|
140
|
+
export function removeSubscriber(name: string, subscriber: DevReloadSubscriber): void {
|
|
141
|
+
const set = SUBSCRIBERS.get(name);
|
|
142
|
+
if (!set) return;
|
|
143
|
+
set.delete(subscriber);
|
|
144
|
+
if (set.size === 0) SUBSCRIBERS.delete(name);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Count of currently-connected subscribers (used by the trigger response). */
|
|
148
|
+
export function subscriberCount(name: string): number {
|
|
149
|
+
return SUBSCRIBERS.get(name)?.size ?? 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Broadcast a `reload` event to every subscriber of `name`. Returns the
|
|
154
|
+
* number of subscribers we successfully enqueued to — a controller that
|
|
155
|
+
* errors mid-broadcast (disconnect) is marked closed + removed.
|
|
156
|
+
*
|
|
157
|
+
* SSE wire format:
|
|
158
|
+
*
|
|
159
|
+
* event: reload\n
|
|
160
|
+
* data: {"timestamp": 1716345600000}\n
|
|
161
|
+
* \n
|
|
162
|
+
*
|
|
163
|
+
* The empty line terminates the event; without it most browsers buffer
|
|
164
|
+
* the event without dispatching.
|
|
165
|
+
*/
|
|
166
|
+
export function broadcastReload(name: string, timestamp = Date.now()): number {
|
|
167
|
+
const set = SUBSCRIBERS.get(name);
|
|
168
|
+
if (!set) return 0;
|
|
169
|
+
const encoder = new TextEncoder();
|
|
170
|
+
const payload = encoder.encode(`event: reload\ndata: ${JSON.stringify({ timestamp })}\n\n`);
|
|
171
|
+
let notified = 0;
|
|
172
|
+
const dead: DevReloadSubscriber[] = [];
|
|
173
|
+
for (const sub of set) {
|
|
174
|
+
if (sub.closed) {
|
|
175
|
+
dead.push(sub);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
sub.controller.enqueue(payload);
|
|
180
|
+
notified++;
|
|
181
|
+
} catch {
|
|
182
|
+
sub.closed = true;
|
|
183
|
+
dead.push(sub);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const d of dead) set.delete(d);
|
|
187
|
+
if (set.size === 0) SUBSCRIBERS.delete(name);
|
|
188
|
+
return notified;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Close + drop every subscriber for a UI. Used on disableDevMode + tests. */
|
|
192
|
+
export function closeAllSubscribers(name: string): void {
|
|
193
|
+
const set = SUBSCRIBERS.get(name);
|
|
194
|
+
if (!set) return;
|
|
195
|
+
for (const sub of set) {
|
|
196
|
+
if (sub.closed) continue;
|
|
197
|
+
sub.closed = true;
|
|
198
|
+
try {
|
|
199
|
+
sub.controller.close();
|
|
200
|
+
} catch {
|
|
201
|
+
// already closed by the runtime — fine
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
SUBSCRIBERS.delete(name);
|
|
205
|
+
}
|