@ggui-ai/dev-stack 0.1.0-rc.1

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.
Files changed (37) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +63 -0
  3. package/dist/dev-server/auth.d.ts +44 -0
  4. package/dist/dev-server/auth.d.ts.map +1 -0
  5. package/dist/dev-server/auth.js +176 -0
  6. package/dist/dev-server/events.d.ts +43 -0
  7. package/dist/dev-server/events.d.ts.map +1 -0
  8. package/dist/dev-server/events.js +61 -0
  9. package/dist/dev-server/http.d.ts +79 -0
  10. package/dist/dev-server/http.d.ts.map +1 -0
  11. package/dist/dev-server/http.js +277 -0
  12. package/dist/dev-server/hub-preview.d.ts +56 -0
  13. package/dist/dev-server/hub-preview.d.ts.map +1 -0
  14. package/dist/dev-server/hub-preview.js +237 -0
  15. package/dist/dev-server/hub.d.ts +59 -0
  16. package/dist/dev-server/hub.d.ts.map +1 -0
  17. package/dist/dev-server/hub.js +723 -0
  18. package/dist/hub-preview/client.js +52346 -0
  19. package/dist/index.d.ts +34 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +49 -0
  22. package/dist/local-registry/compile-ui.d.ts +67 -0
  23. package/dist/local-registry/compile-ui.d.ts.map +1 -0
  24. package/dist/local-registry/compile-ui.js +148 -0
  25. package/dist/local-registry/local-registry.d.ts +93 -0
  26. package/dist/local-registry/local-registry.d.ts.map +1 -0
  27. package/dist/local-registry/local-registry.js +272 -0
  28. package/dist/local-registry/watcher.d.ts +42 -0
  29. package/dist/local-registry/watcher.d.ts.map +1 -0
  30. package/dist/local-registry/watcher.js +208 -0
  31. package/dist/run-dev.d.ts +147 -0
  32. package/dist/run-dev.d.ts.map +1 -0
  33. package/dist/run-dev.js +216 -0
  34. package/dist/runtime-supervisor.d.ts +140 -0
  35. package/dist/runtime-supervisor.d.ts.map +1 -0
  36. package/dist/runtime-supervisor.js +91 -0
  37. package/package.json +72 -0
@@ -0,0 +1,723 @@
1
+ /**
2
+ * Build the self-contained HTML document for the hub. Pure function
3
+ * so tests can exercise the rendered shape without binding a port.
4
+ *
5
+ * HTML escaping is applied to every string that bubbles up from
6
+ * user-controlled data (manifest fields, token). The token itself
7
+ * is hex in practice, but the escape is defensive — a future
8
+ * pairing-protocol token might be arbitrary.
9
+ */
10
+ export function renderHubHtml(ctx) {
11
+ const appName = escapeHtml(ctx.manifest.app.name);
12
+ const appSlug = escapeHtml(ctx.manifest.app.slug);
13
+ const protocol = escapeHtml(ctx.manifest.protocol);
14
+ const bootstrap = JSON.stringify({
15
+ token: ctx.token,
16
+ app: { name: ctx.manifest.app.name, slug: ctx.manifest.app.slug },
17
+ protocol: ctx.manifest.protocol,
18
+ }).replace(/</g, '\\u003c');
19
+ return `<!doctype html>
20
+ <html lang="en">
21
+ <head>
22
+ <meta charset="utf-8" />
23
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
24
+ <meta name="robots" content="noindex" />
25
+ <title>ggui dev — ${appName}</title>
26
+ <style>${HUB_CSS}</style>
27
+ </head>
28
+ <body>
29
+ <main id="app">
30
+ <header class="header">
31
+ <h1>ggui dev — ${appName}</h1>
32
+ <div class="subtitle">slug: ${appSlug} &middot; protocol: ${protocol} &middot; <span id="live" class="live">connecting…</span> &middot; <span id="tick" class="tick">loading…</span></div>
33
+ </header>
34
+ <div class="grid">
35
+ <section class="panel" data-panel="runtime">
36
+ <h2>Runtime</h2>
37
+ <div id="runtime-body" class="panel-body"><div class="placeholder">Loading…</div></div>
38
+ </section>
39
+ <section class="panel" data-panel="registry">
40
+ <h2>Registry</h2>
41
+ <div id="registry-body" class="panel-body"><div class="placeholder">Loading…</div></div>
42
+ </section>
43
+ <section class="panel wide" data-panel="uis">
44
+ <h2>Discovered UIs <span id="uis-count" class="count"></span></h2>
45
+ <div id="uis-body" class="panel-body"><div class="placeholder">Loading…</div></div>
46
+ </section>
47
+ <section class="panel wide" data-panel="preview">
48
+ <div class="panel-head">
49
+ <h2>Preview <span id="preview-label" class="count"></span></h2>
50
+ <button id="preview-clear" class="preview-clear" type="button" hidden>clear</button>
51
+ </div>
52
+ <div id="preview-body" class="panel-body preview-body">
53
+ <div class="placeholder">Click a UI above to preview it here.</div>
54
+ </div>
55
+ </section>
56
+ <section class="panel wide" data-panel="events">
57
+ <h2>Runtime events <span id="events-count" class="count"></span></h2>
58
+ <div id="events-body" class="panel-body events"><div class="placeholder">Loading…</div></div>
59
+ </section>
60
+ <section class="panel wide" data-panel="diagnostics">
61
+ <h2>Diagnostics <span class="count">(click row to copy)</span></h2>
62
+ <div id="diagnostics-body" class="panel-body"></div>
63
+ <div class="hint">Keyboard: press <kbd>r</kbd> to refresh now.</div>
64
+ </section>
65
+ </div>
66
+ </main>
67
+ <script>window.__GGUI_DEV__ = ${bootstrap};</script>
68
+ <script>${HUB_JS}</script>
69
+ </body>
70
+ </html>
71
+ `;
72
+ }
73
+ /**
74
+ * Write the hub shell to `res` with no-cache headers. Response is
75
+ * always 200 — there's no per-request failure mode for the shell.
76
+ */
77
+ export function serveHubShell(_req, res, ctx) {
78
+ const html = renderHubHtml(ctx);
79
+ const body = Buffer.from(html, 'utf-8');
80
+ res.writeHead(200, {
81
+ 'content-type': 'text/html; charset=utf-8',
82
+ 'content-length': body.byteLength.toString(),
83
+ // Dev hub should never be cached — state changes every
84
+ // second and a stale shell masks real regressions.
85
+ 'cache-control': 'no-store',
86
+ // Hub is a loopback UI; don't let it be embedded in a
87
+ // hostile iframe even hypothetically.
88
+ 'x-frame-options': 'DENY',
89
+ });
90
+ res.end(body);
91
+ }
92
+ const HTML_ESCAPES = {
93
+ '&': '&amp;',
94
+ '<': '&lt;',
95
+ '>': '&gt;',
96
+ '"': '&quot;',
97
+ "'": '&#39;',
98
+ };
99
+ function escapeHtml(value) {
100
+ return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch);
101
+ }
102
+ /**
103
+ * Hub CSS. Dark terminal-ish palette, monospace throughout,
104
+ * clearly code-first. No icons, no gradients, no animations beyond
105
+ * a single heartbeat pulse on the tick indicator.
106
+ */
107
+ const HUB_CSS = `
108
+ :root { color-scheme: dark; }
109
+ * { box-sizing: border-box; }
110
+ html, body { margin: 0; padding: 0; background: #0a0a0a; color: #e7e7e7;
111
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
112
+ main { max-width: 1080px; margin: 0 auto; padding: 24px; }
113
+ h1 { font-size: 15px; margin: 0 0 4px; color: #fff; font-weight: 600; }
114
+ h2 { font-size: 11px; margin: 0 0 12px; color: #8a8a8a; font-weight: 600;
115
+ text-transform: uppercase; letter-spacing: 0.08em; }
116
+ .subtitle { color: #8a8a8a; font-size: 12px; margin-bottom: 20px; }
117
+ .tick { color: #666; }
118
+ .tick.ok::before { content: "● "; color: #3db37a; }
119
+ .tick.stale::before { content: "● "; color: #c99; }
120
+ .tick.err::before { content: "● "; color: #e57373; }
121
+ .live { color: #666; }
122
+ .live.live::before { content: "● "; color: #3db37a; animation: pulse 2s ease-in-out infinite; }
123
+ .live.reconnecting::before { content: "● "; color: #e0b870; animation: pulse 1s ease-in-out infinite; }
124
+ .live.offline::before { content: "● "; color: #666; }
125
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
126
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
127
+ .panel { border: 1px solid #1f1f1f; background: #0f0f0f; padding: 16px 18px; border-radius: 6px; }
128
+ .panel.wide { grid-column: 1 / -1; }
129
+ .panel-body { min-height: 48px; }
130
+ .placeholder { color: #555; font-style: italic; }
131
+ .row { display: grid; grid-template-columns: 140px 1fr; gap: 12px; padding: 2px 0; }
132
+ .label { color: #8a8a8a; font-size: 12px; }
133
+ .value { color: #e7e7e7; word-break: break-all; }
134
+ .value.dim { color: #666; }
135
+ .badge { display: inline-block; padding: 1px 8px; border-radius: 3px; font-size: 11px;
136
+ font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
137
+ .badge.absent { background: #1a1a1a; color: #666; border: 1px solid #262626; }
138
+ .badge.starting { background: #2a220d; color: #e0b870; border: 1px solid #3d3117; }
139
+ .badge.ready { background: #0f2a1a; color: #3db37a; border: 1px solid #1c4430; }
140
+ .badge.stopped { background: #1a1a1a; color: #888; border: 1px solid #262626; }
141
+ .badge.crashed { background: #2a0f0f; color: #e57373; border: 1px solid #441c1c; }
142
+ .badge.warn { background: #2a220d; color: #e0b870; border: 1px solid #3d3117; }
143
+ ul.uis { list-style: none; margin: 0; padding: 0; }
144
+ ul.uis li { padding: 0; border-top: 1px solid #181818; }
145
+ ul.uis li:first-child { border-top: none; }
146
+ ul.uis button.ui-row { display: flex; width: 100%; text-align: left;
147
+ gap: 12px; align-items: baseline; padding: 6px 4px; background: none;
148
+ border: 0; cursor: pointer; color: inherit; font: inherit;
149
+ border-radius: 3px; }
150
+ ul.uis button.ui-row:hover { background: #141414; }
151
+ ul.uis button.ui-row:focus-visible { outline: 2px solid #7cb2e8; outline-offset: -2px; }
152
+ ul.uis button.ui-row[aria-pressed="true"] { background: #0f2a1a; }
153
+ ul.uis .ui-id { color: #7cb2e8; font-family: inherit; }
154
+ ul.uis .ui-name { color: #8a8a8a; font-size: 12px; }
155
+ ul.uis .ui-hint { color: #555; font-size: 11px; margin-left: auto; }
156
+ .panel-head { display: flex; align-items: baseline; justify-content: space-between;
157
+ gap: 12px; margin: 0 0 12px; }
158
+ .panel-head h2 { margin: 0; }
159
+ button.preview-clear { background: none; border: 1px solid #262626;
160
+ color: #8a8a8a; font: inherit; font-size: 11px; padding: 2px 8px;
161
+ border-radius: 3px; cursor: pointer; }
162
+ button.preview-clear:hover { color: #e7e7e7; border-color: #3a3a3a; }
163
+ .preview-body { padding: 0; }
164
+ .preview-empty { padding: 32px; color: #666; text-align: center; font-style: italic; }
165
+ .preview-frame { width: 100%; min-height: 320px; border: 0; background: #0a0a0a;
166
+ display: block; }
167
+ .count { color: #555; font-weight: 400; }
168
+ .events { max-height: 320px; overflow-y: auto; font-size: 12px; }
169
+ .events .ev { display: grid; grid-template-columns: 88px 80px 1fr; gap: 8px;
170
+ padding: 2px 0; border-top: 1px solid #141414; }
171
+ .events .ev:first-child { border-top: none; }
172
+ .events .t { color: #555; }
173
+ .events .k-status { color: #3db37a; }
174
+ .events .k-log-stdout { color: #8a8a8a; }
175
+ .events .k-log-stderr { color: #d8a660; }
176
+ .events .k-error { color: #e57373; }
177
+ .events .msg { color: #ccc; white-space: pre-wrap; word-break: break-word; }
178
+ .diag-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px;
179
+ border-radius: 4px; cursor: pointer; color: #b8b8b8; }
180
+ .diag-row:hover { background: #161616; color: #e7e7e7; }
181
+ .diag-row.copied { background: #0f2a1a; color: #3db37a; }
182
+ .diag-label { width: 120px; color: #888; font-size: 11px; text-transform: uppercase;
183
+ letter-spacing: 0.06em; flex-shrink: 0; }
184
+ .diag-cmd { flex: 1; font-size: 12px; white-space: nowrap; overflow: hidden;
185
+ text-overflow: ellipsis; }
186
+ .hint { color: #555; font-size: 11px; margin-top: 12px; padding-top: 12px;
187
+ border-top: 1px solid #181818; }
188
+ .hint kbd { background: #1a1a1a; border: 1px solid #262626; border-radius: 3px;
189
+ padding: 1px 6px; font-family: inherit; font-size: 11px; color: #ccc; }
190
+ @media (max-width: 640px) {
191
+ .grid { grid-template-columns: 1fr; }
192
+ .diag-row { flex-direction: column; align-items: flex-start; }
193
+ .diag-label { width: auto; }
194
+ }
195
+ `;
196
+ /**
197
+ * Hub dashboard JS. Vanilla DOM, no framework.
198
+ *
199
+ * - Polls `/health`, `/uis`, `/runtime/status`, `/runtime/events`
200
+ * every 2 seconds as the baseline data loop.
201
+ * - Subscribes to `/events` via fetch-stream (EventSource can't
202
+ * send `Authorization`). On every `event: ui` frame, refreshes
203
+ * the registry-panel fetches immediately — so the `Discovered
204
+ * UIs` list + registry counts flip on save before the next
205
+ * poll tick fires. Same HMR mechanism Studio uses.
206
+ * - Live indicator (`#live`) paints connecting / live /
207
+ * reconnecting / offline; independent from the data-freshness
208
+ * tick.
209
+ * - Reconnects SSE with a small backoff; existing poll keeps
210
+ * data alive during the reconnect window.
211
+ *
212
+ * Runtime events stay on the poll loop for now — `/runtime/events`
213
+ * is a snapshot endpoint (SSE streaming is future hub scope).
214
+ */
215
+ const HUB_JS = `
216
+ (function () {
217
+ 'use strict';
218
+ var bootstrap = (window.__GGUI_DEV__ || {});
219
+ var token = bootstrap.token;
220
+ var headers = token ? { Authorization: 'Bearer ' + token } : {};
221
+ var POLL_MS = 2000;
222
+ var MAX_EVENTS = 80;
223
+
224
+ var el = {
225
+ tick: document.getElementById('tick'),
226
+ live: document.getElementById('live'),
227
+ runtime: document.getElementById('runtime-body'),
228
+ registry: document.getElementById('registry-body'),
229
+ uis: document.getElementById('uis-body'),
230
+ uisCount: document.getElementById('uis-count'),
231
+ events: document.getElementById('events-body'),
232
+ eventsCount: document.getElementById('events-count'),
233
+ diagnostics: document.getElementById('diagnostics-body'),
234
+ preview: document.getElementById('preview-body'),
235
+ previewLabel: document.getElementById('preview-label'),
236
+ previewClear: document.getElementById('preview-clear'),
237
+ };
238
+
239
+ /** Selected UI id, read from the URL on boot and kept in sync with
240
+ * history.replaceState. Kept as a module-level variable so
241
+ * renderUis + renderPreview stay coordinated. */
242
+ var selectedUi = readSelectedFromUrl();
243
+ // Remember the discovered list so selection changes don't have to
244
+ // re-fetch /uis before re-painting the row highlighting.
245
+ var lastUis = [];
246
+
247
+ function fmtTime(ts) {
248
+ if (ts === null || ts === undefined) return '—';
249
+ var d = new Date(ts);
250
+ return d.toLocaleTimeString('en-US', { hour12: false });
251
+ }
252
+
253
+ function fmtAge(ts) {
254
+ if (ts === null || ts === undefined) return '—';
255
+ var s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
256
+ if (s < 1) return 'now';
257
+ if (s < 60) return s + 's ago';
258
+ if (s < 3600) return Math.floor(s / 60) + 'm ago';
259
+ return Math.floor(s / 3600) + 'h ago';
260
+ }
261
+
262
+ function text(s) { return (s === null || s === undefined) ? '' : String(s); }
263
+
264
+ function escape(s) {
265
+ return text(s)
266
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
267
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
268
+ }
269
+
270
+ function row(label, value, cls) {
271
+ return '<div class="row"><div class="label">' + escape(label) + '</div>' +
272
+ '<div class="value' + (cls ? ' ' + cls : '') + '">' + value + '</div></div>';
273
+ }
274
+
275
+ function badge(status) {
276
+ return '<span class="badge ' + escape(status) + '">' + escape(status) + '</span>';
277
+ }
278
+
279
+ function fetchJson(path) {
280
+ return fetch(path, { headers: headers, cache: 'no-store' })
281
+ .then(function (r) {
282
+ if (!r.ok) {
283
+ var err = new Error('HTTP ' + r.status + ' on ' + path);
284
+ err.status = r.status;
285
+ throw err;
286
+ }
287
+ return r.json();
288
+ });
289
+ }
290
+
291
+ function renderRuntime(data) {
292
+ if (!data || data.present === false) {
293
+ el.runtime.innerHTML = '<div class="row"><div class="label">agent</div>' +
294
+ '<div class="value">' + badge('absent') + '</div></div>' +
295
+ '<div class="row"><div class="label">note</div>' +
296
+ '<div class="value dim">Start with <code>ggui dev --agent &lt;entry&gt;</code> to supervise a local agent.</div></div>';
297
+ return;
298
+ }
299
+ var capsParts = [];
300
+ if (data.capabilities) {
301
+ if (data.capabilities.observable) capsParts.push('observable');
302
+ if (data.capabilities.restartable) capsParts.push('restartable');
303
+ }
304
+ el.runtime.innerHTML =
305
+ row('status', badge(data.status || 'absent')) +
306
+ row('adapter', escape(data.name || '—')) +
307
+ row('run id', '<code>' + escape(data.runId || '—') + '</code>') +
308
+ row('capabilities', escape(capsParts.join(', ') || '—'), 'dim') +
309
+ row('started', escape(fmtTime(data.startedAt) + ' (' + fmtAge(data.startedAt) + ')'), 'dim') +
310
+ row('last event', escape(fmtTime(data.lastEventAt) + ' (' + fmtAge(data.lastEventAt) + ')'), 'dim');
311
+ }
312
+
313
+ function renderRegistry(data) {
314
+ if (!data) {
315
+ el.registry.innerHTML = '<div class="placeholder">—</div>';
316
+ return;
317
+ }
318
+ var issueBadge = data.issueCount > 0 ? ' ' + badge('warn') : '';
319
+ el.registry.innerHTML =
320
+ row('project', escape((data.app && data.app.name) || '—')) +
321
+ row('slug', escape((data.app && data.app.slug) || '—'), 'dim') +
322
+ row('protocol', escape(data.protocol || '—'), 'dim') +
323
+ row('UIs', escape(String(data.uiCount !== undefined ? data.uiCount : '—'))) +
324
+ row('issues', escape(String(data.issueCount !== undefined ? data.issueCount : '—')) + issueBadge);
325
+ }
326
+
327
+ function renderUis(list) {
328
+ lastUis = Array.isArray(list) ? list : [];
329
+ if (lastUis.length === 0) {
330
+ el.uis.innerHTML = '<div class="placeholder">No UIs discovered. Add <code>ggui.ui.json</code> files under <code>blueprints.include</code>.</div>';
331
+ if (el.uisCount) el.uisCount.textContent = '(0)';
332
+ // A removed selection should paint as a dangling state below.
333
+ renderPreview();
334
+ return;
335
+ }
336
+ if (el.uisCount) el.uisCount.textContent = '(' + lastUis.length + ')';
337
+ var html = '<ul class="uis">';
338
+ for (var i = 0; i < lastUis.length; i++) {
339
+ var entry = lastUis[i];
340
+ var id = (entry && entry.id) || '—';
341
+ var name = (entry && entry.manifest && entry.manifest.name) || '';
342
+ var pressed = id === selectedUi ? 'true' : 'false';
343
+ html += '<li><button type="button" class="ui-row" data-ui-id="' + escape(id) +
344
+ '" aria-pressed="' + pressed + '">' +
345
+ '<span class="ui-id">' + escape(id) + '</span>';
346
+ if (name) html += '<span class="ui-name">' + escape(name) + '</span>';
347
+ html += '<span class="ui-hint">' + (pressed === 'true' ? 'previewing' : 'preview →') + '</span>';
348
+ html += '</button></li>';
349
+ }
350
+ html += '</ul>';
351
+ el.uis.innerHTML = html;
352
+ var rows = el.uis.querySelectorAll('button.ui-row');
353
+ rows.forEach(function (row) {
354
+ row.addEventListener('click', function () {
355
+ var id = row.getAttribute('data-ui-id') || '';
356
+ setSelectedUi(id === selectedUi ? '' : id);
357
+ });
358
+ });
359
+ renderPreview();
360
+ }
361
+
362
+ // Render the Preview panel based on the current selectedUi.
363
+ // When nothing is selected we show a hint; otherwise we mount
364
+ // an iframe at /hub/preview?ui=ID — the shell over there is
365
+ // responsible for fetching + rendering the bundle. The iframe
366
+ // is replaced (not reused) across selections so the bundle
367
+ // inside gets a fresh boot every time.
368
+ function renderPreview() {
369
+ if (!el.preview) return;
370
+ if (!selectedUi) {
371
+ el.preview.innerHTML = '<div class="placeholder">Click a UI above to preview it here.</div>';
372
+ if (el.previewLabel) el.previewLabel.textContent = '';
373
+ if (el.previewClear) el.previewClear.hidden = true;
374
+ return;
375
+ }
376
+ if (el.previewLabel) el.previewLabel.textContent = selectedUi;
377
+ if (el.previewClear) el.previewClear.hidden = false;
378
+ var src = '/hub/preview?ui=' + encodeURIComponent(selectedUi);
379
+ // Replace the iframe wholesale rather than updating src —
380
+ // otherwise a stale renderer (e.g. mid-fetch) could paint over
381
+ // the fresh selection after we've moved on.
382
+ el.preview.innerHTML = '';
383
+ var frame = document.createElement('iframe');
384
+ frame.className = 'preview-frame';
385
+ frame.setAttribute('title', 'Preview of ' + selectedUi);
386
+ frame.setAttribute('sandbox', 'allow-scripts allow-same-origin');
387
+ frame.setAttribute('src', src);
388
+ el.preview.appendChild(frame);
389
+ }
390
+
391
+ /** Persist selection in the URL + refresh affected panels. */
392
+ function setSelectedUi(id) {
393
+ if (id === selectedUi) return;
394
+ selectedUi = id;
395
+ writeSelectedToUrl(id);
396
+ // Re-render the UIs list to flip the pressed-state + hints,
397
+ // then bring the preview into (or out of) view.
398
+ if (lastUis.length > 0) renderUis(lastUis);
399
+ else renderPreview();
400
+ }
401
+
402
+ /** Narrow accepted characters so a malformed URL can't smuggle
403
+ * weird values into the preview iframe src. The iframe page
404
+ * applies the same filter server-side; this one is purely a
405
+ * first-pass sanity check. */
406
+ function isValidId(value) {
407
+ if (typeof value !== 'string') return false;
408
+ if (value.length === 0 || value.length > 200) return false;
409
+ return /^[a-zA-Z0-9._\\-:@/]+$/.test(value);
410
+ }
411
+
412
+ function readSelectedFromUrl() {
413
+ try {
414
+ var url = new URL(window.location.href);
415
+ var raw = (url.searchParams.get('ui') || '').trim();
416
+ return isValidId(raw) ? raw : '';
417
+ } catch (e) {
418
+ return '';
419
+ }
420
+ }
421
+
422
+ function writeSelectedToUrl(id) {
423
+ try {
424
+ var url = new URL(window.location.href);
425
+ if (id) url.searchParams.set('ui', id);
426
+ else url.searchParams.delete('ui');
427
+ window.history.replaceState(null, '', url.toString());
428
+ } catch (e) {
429
+ // Swallow — persistence across reloads is a nice-to-have,
430
+ // not load-bearing for the current-session selection.
431
+ }
432
+ }
433
+
434
+ function renderEvents(data) {
435
+ if (!data || data.present === false) {
436
+ el.events.innerHTML = '<div class="placeholder">No runtime attached.</div>';
437
+ if (el.eventsCount) el.eventsCount.textContent = '';
438
+ return;
439
+ }
440
+ var events = Array.isArray(data.events) ? data.events : [];
441
+ if (events.length === 0) {
442
+ el.events.innerHTML = '<div class="placeholder">No events yet.</div>';
443
+ if (el.eventsCount) el.eventsCount.textContent = '(0)';
444
+ return;
445
+ }
446
+ if (el.eventsCount) el.eventsCount.textContent = '(' + events.length + ')';
447
+ // Render newest-first — operators scan top-down.
448
+ var html = '';
449
+ for (var i = events.length - 1; i >= Math.max(0, events.length - MAX_EVENTS); i--) {
450
+ var e = events[i];
451
+ var kindClass = 'k-' + e.type + (e.type === 'log' ? ('-' + (e.stream || '')) : '');
452
+ var kindLabel = e.type === 'log'
453
+ ? ((e.stream || 'log'))
454
+ : (e.type === 'status' ? ('status:' + (e.status || '')) : 'error');
455
+ var body = e.type === 'log'
456
+ ? (e.line || '')
457
+ : e.type === 'status' ? (e.status || '')
458
+ : (e.message || '');
459
+ html += '<div class="ev">'
460
+ + '<div class="t">' + escape(fmtTime(e.timestamp)) + '</div>'
461
+ + '<div class="' + kindClass + '">' + escape(kindLabel) + '</div>'
462
+ + '<div class="msg">' + escape(body) + '</div>'
463
+ + '</div>';
464
+ }
465
+ el.events.innerHTML = html;
466
+ }
467
+
468
+ function setTick(state, message) {
469
+ if (!el.tick) return;
470
+ el.tick.className = 'tick ' + state;
471
+ el.tick.textContent = message;
472
+ }
473
+
474
+ function setLive(state, message) {
475
+ if (!el.live) return;
476
+ el.live.className = 'live ' + state;
477
+ el.live.textContent = message;
478
+ }
479
+
480
+ async function tick() {
481
+ try {
482
+ var results = await Promise.all([
483
+ fetchJson('/health').catch(function (e) { return { __err: e }; }),
484
+ fetchJson('/uis').catch(function (e) { return { __err: e }; }),
485
+ fetchJson('/runtime/status').catch(function (e) { return { __err: e }; }),
486
+ fetchJson('/runtime/events').catch(function (e) { return { __err: e }; }),
487
+ ]);
488
+
489
+ var anyErr = results.find(function (r) { return r && r.__err; });
490
+ if (anyErr) {
491
+ var err = anyErr.__err;
492
+ if (err && err.status === 401) {
493
+ setTick('err', 'auth failed — token may be stale');
494
+ } else if (err && err.status === 403) {
495
+ setTick('err', 'origin rejected');
496
+ } else {
497
+ setTick('err', 'offline — retry in ' + Math.round(POLL_MS / 1000) + 's');
498
+ }
499
+ return;
500
+ }
501
+
502
+ renderRegistry(results[0]);
503
+ renderUis(results[1]);
504
+ renderRuntime(results[2]);
505
+ renderEvents(results[3]);
506
+ setTick('ok', 'updated ' + fmtTime(Date.now()));
507
+ } catch (e) {
508
+ setTick('err', 'unexpected error — see console');
509
+ // eslint-disable-next-line no-console
510
+ console.error('[ggui-dev-hub]', e);
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Refresh only the registry-side panels. Called after every
516
+ * 'event: ui' SSE frame so UI edits feel instant — the
517
+ * runtime poll is separate and keeps ticking at POLL_MS.
518
+ */
519
+ async function refreshRegistry() {
520
+ try {
521
+ var results = await Promise.all([
522
+ fetchJson('/health').catch(function (e) { return { __err: e }; }),
523
+ fetchJson('/uis').catch(function (e) { return { __err: e }; }),
524
+ ]);
525
+ if (results[0] && !results[0].__err) renderRegistry(results[0]);
526
+ if (results[1] && !results[1].__err) renderUis(results[1]);
527
+ } catch (e) {
528
+ // eslint-disable-next-line no-console
529
+ console.error('[ggui-dev-hub] refreshRegistry', e);
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Minimal SSE parser — same shape the dev server emits and the
535
+ * Studio local source consumes. Normalises CRLF, splits on the
536
+ * blank-line delimiter, ignores ':keep-alive' comment frames,
537
+ * and only dispatches 'event: ui' with a valid JSON body.
538
+ */
539
+ async function readSse(reader, onUiEvent, onLive) {
540
+ var decoder = new TextDecoder();
541
+ var buffer = '';
542
+ var sawFirstFrame = false;
543
+ while (true) {
544
+ var chunk = await reader.read();
545
+ if (chunk.done) return;
546
+ buffer += decoder.decode(chunk.value, { stream: true });
547
+ buffer = buffer.replace(/\\r\\n/g, '\\n');
548
+ var sep;
549
+ while ((sep = buffer.indexOf('\\n\\n')) !== -1) {
550
+ var frame = buffer.slice(0, sep);
551
+ buffer = buffer.slice(sep + 2);
552
+ if (!sawFirstFrame) { sawFirstFrame = true; onLive(); }
553
+ if (frame.length === 0 || frame.startsWith(':')) continue;
554
+ var evt = '';
555
+ var data = '';
556
+ var lines = frame.split('\\n');
557
+ for (var i = 0; i < lines.length; i++) {
558
+ var line = lines[i];
559
+ if (line.indexOf('event:') === 0) evt = line.slice(6).trim();
560
+ else if (line.indexOf('data:') === 0) data += line.slice(5).trim();
561
+ }
562
+ if (evt !== 'ui' || !data) continue;
563
+ try {
564
+ var payload = JSON.parse(data);
565
+ onUiEvent(payload);
566
+ } catch (e) {
567
+ // malformed frame — skip
568
+ }
569
+ }
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Keep an SSE stream to '/events' open for the life of the hub.
575
+ * Reconnects with a short backoff on drop / failure.
576
+ */
577
+ async function subscribeRegistry() {
578
+ var backoff = 500;
579
+ setLive('reconnecting', 'connecting');
580
+ while (true) {
581
+ try {
582
+ var res = await fetch('/events', {
583
+ headers: headers,
584
+ cache: 'no-store',
585
+ });
586
+ if (!res.ok) {
587
+ setLive('offline', res.status === 401 ? 'auth failed' : 'offline');
588
+ await sleep(Math.min(backoff, 5000));
589
+ backoff = Math.min(backoff * 2, 5000);
590
+ continue;
591
+ }
592
+ backoff = 500;
593
+ var reader = res.body.getReader();
594
+ await readSse(
595
+ reader,
596
+ function (evt) {
597
+ // Any UI registry event — added / changed / removed —
598
+ // means registry-side panels need to refresh. Debouncing
599
+ // is unnecessary: edits coalesce on chokidar's
600
+ // awaitWriteFinish already.
601
+ void refreshRegistry();
602
+ },
603
+ function () { setLive('live', 'live'); },
604
+ );
605
+ // Stream ended cleanly — reconnect.
606
+ setLive('reconnecting', 'reconnecting');
607
+ } catch (e) {
608
+ setLive('reconnecting', 'reconnecting');
609
+ await sleep(Math.min(backoff, 5000));
610
+ backoff = Math.min(backoff * 2, 5000);
611
+ }
612
+ }
613
+ }
614
+
615
+ function sleep(ms) {
616
+ return new Promise(function (r) { setTimeout(r, ms); });
617
+ }
618
+
619
+ /**
620
+ * Render the Diagnostics panel — one row per endpoint with a
621
+ * click-to-copy curl command. Done once on load (host/port/token
622
+ * don't change for the life of the tab).
623
+ */
624
+ function renderDiagnostics() {
625
+ if (!el.diagnostics) return;
626
+ var origin = window.location.origin;
627
+ var bearer = token ? (' -H "Authorization: Bearer ' + token + '"') : '';
628
+ var endpoints = [
629
+ ['health', 'GET ' + origin + '/health'],
630
+ ['hub', 'GET ' + origin + '/hub'],
631
+ ['preview', 'GET ' + origin + '/hub/preview?ui=<id>'],
632
+ ['uis', 'curl -s' + bearer + ' ' + origin + '/uis'],
633
+ ['bundle', 'curl -s' + bearer + ' ' + origin + '/uis/<id>/bundle'],
634
+ ['events (sse)', 'curl -sN' + bearer + ' ' + origin + '/events'],
635
+ ['runtime status', 'curl -s' + bearer + ' ' + origin + '/runtime/status'],
636
+ ['runtime events', 'curl -s' + bearer + ' ' + origin + '/runtime/events'],
637
+ ];
638
+ if (token) {
639
+ endpoints.push(['token', token]);
640
+ }
641
+ var html = '';
642
+ for (var i = 0; i < endpoints.length; i++) {
643
+ var label = endpoints[i][0];
644
+ var cmd = endpoints[i][1];
645
+ html += '<div class="diag-row" data-cmd="' + escape(cmd) + '">' +
646
+ '<div class="diag-label">' + escape(label) + '</div>' +
647
+ '<div class="diag-cmd">' + escape(cmd) + '</div>' +
648
+ '</div>';
649
+ }
650
+ el.diagnostics.innerHTML = html;
651
+ var rows = el.diagnostics.querySelectorAll('.diag-row');
652
+ rows.forEach(function (row) {
653
+ row.addEventListener('click', function () {
654
+ var text = row.getAttribute('data-cmd') || '';
655
+ copyToClipboard(text, row);
656
+ });
657
+ });
658
+ }
659
+
660
+ function copyToClipboard(text, row) {
661
+ var done = function () {
662
+ row.classList.add('copied');
663
+ setTimeout(function () { row.classList.remove('copied'); }, 900);
664
+ };
665
+ if (navigator.clipboard && navigator.clipboard.writeText) {
666
+ navigator.clipboard.writeText(text).then(done).catch(function () {
667
+ fallbackCopy(text); done();
668
+ });
669
+ } else {
670
+ fallbackCopy(text); done();
671
+ }
672
+ }
673
+
674
+ function fallbackCopy(text) {
675
+ var ta = document.createElement('textarea');
676
+ ta.value = text;
677
+ ta.setAttribute('readonly', '');
678
+ ta.style.position = 'absolute';
679
+ ta.style.left = '-9999px';
680
+ document.body.appendChild(ta);
681
+ ta.select();
682
+ try { document.execCommand('copy'); } catch (e) { /* ignore */ }
683
+ document.body.removeChild(ta);
684
+ }
685
+
686
+ /**
687
+ * Keyboard refresh: press 'r' anywhere not inside an input
688
+ * to force an immediate tick + registry refresh. Cheap but
689
+ * meaningfully faster than waiting for the 2s poll.
690
+ */
691
+ function installKeyboardShortcuts() {
692
+ document.addEventListener('keydown', function (e) {
693
+ if (e.defaultPrevented) return;
694
+ var tag = (e.target && e.target.tagName) || '';
695
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || e.metaKey || e.ctrlKey || e.altKey) return;
696
+ if (e.key === 'r' || e.key === 'R') {
697
+ void tick();
698
+ void refreshRegistry();
699
+ }
700
+ });
701
+ }
702
+
703
+ /** Attach the Clear button's handler once the elements exist. */
704
+ function installPreviewControls() {
705
+ if (!el.previewClear) return;
706
+ el.previewClear.addEventListener('click', function () {
707
+ setSelectedUi('');
708
+ });
709
+ }
710
+
711
+ tick();
712
+ setInterval(tick, POLL_MS);
713
+ renderDiagnostics();
714
+ installKeyboardShortcuts();
715
+ installPreviewControls();
716
+ // Paint an initial preview based on the URL param so a reload /
717
+ // linked URL lands in the same state it came from. The real UI
718
+ // list renders shortly after tick() resolves; until then the
719
+ // preview is already showing the right frame.
720
+ renderPreview();
721
+ void subscribeRegistry();
722
+ })();
723
+ `;