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