@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,79 @@
1
+ /**
2
+ * Read-only HTTP surface for `LocalUiRegistry`.
3
+ *
4
+ * Endpoints (v1):
5
+ *
6
+ * GET /health → `{ ok: true, app, uiCount, issues }`
7
+ * GET /uis → `UiManifestEntry[]` (JSON)
8
+ * GET /uis/:id → `UiManifestEntry` or 404
9
+ * GET /uis/:id/bundle → compiled JS bundle, or 404
10
+ *
11
+ * No write verbs, no subscribe. The `UiRegistry.capabilities` probe
12
+ * exposed on `LocalUiRegistry` already declares this publicly; the
13
+ * endpoints are strictly GETs so hosts / Studio can't accidentally
14
+ * attempt a publish against a read-only source.
15
+ *
16
+ * Security posture:
17
+ *
18
+ * - Binds to `127.0.0.1` by default (loopback only). A user who
19
+ * wants LAN access passes `host: '0.0.0.0'` and accepts the
20
+ * implication.
21
+ * - Token / CORS policy is supplied by the caller via the
22
+ * security-policy seam (see `./auth.ts`).
23
+ *
24
+ * Node's native `http` module is used directly — no framework
25
+ * dependency. The handler is small enough (≈4 routes) that a router
26
+ * would be overkill, and keeps the open CLI's dependency footprint
27
+ * minimal.
28
+ */
29
+ import { type Server } from 'node:http';
30
+ import type { LocalUiRegistry } from '../local-registry/local-registry.js';
31
+ import type { GguiJsonV1 } from '@ggui-ai/project-config';
32
+ import type { DevServerSecurityPolicy } from './auth.js';
33
+ import { type RuntimeSupervisor } from '../runtime-supervisor.js';
34
+ export interface DevServerOptions {
35
+ registry: LocalUiRegistry;
36
+ /** Loaded `ggui.json`, used for the `/health` payload. */
37
+ manifest: GguiJsonV1;
38
+ /**
39
+ * Port to bind. `0` asks the OS for a free port — the chosen port
40
+ * is available via `DevServerHandle.port`.
41
+ */
42
+ port?: number;
43
+ /**
44
+ * Bind address. Defaults to `127.0.0.1` — loopback-only. Pass
45
+ * `'0.0.0.0'` only if you know you want LAN exposure.
46
+ */
47
+ host?: string;
48
+ /**
49
+ * Auth + CORS policy. Optional — when omitted the server is open
50
+ * to any reachable caller, appropriate only for tests. The CLI
51
+ * always constructs a real policy via
52
+ * {@link createSecurityPolicy} (see `./auth.ts`).
53
+ */
54
+ security?: DevServerSecurityPolicy;
55
+ /**
56
+ * Late-binding accessor for the runtime supervisor. The dev
57
+ * server asks the accessor per-request so a supervisor that
58
+ * attaches AFTER `startDevServer` resolves is still visible at
59
+ * `/runtime/*`. Returning `null` yields a consistent
60
+ * `{ present: false }` payload for the "no runtime" case.
61
+ */
62
+ getRuntimeSupervisor?: () => RuntimeSupervisor | null;
63
+ }
64
+ export interface DevServerHandle {
65
+ /** Host the server is actually listening on. */
66
+ host: string;
67
+ /** Port the server is actually listening on (resolved if opts.port was 0). */
68
+ port: number;
69
+ /** Graceful shutdown. Resolves after all active connections close. */
70
+ close(): Promise<void>;
71
+ /** Raw server, in case callers need direct access for tests. */
72
+ raw: Server;
73
+ }
74
+ /**
75
+ * Start the read-only dev server. Resolves after `listen` completes
76
+ * so callers can safely fire requests at the returned `port`.
77
+ */
78
+ export declare function startDevServer(options: DevServerOptions): Promise<DevServerHandle>;
79
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/dev-server/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAGL,KAAK,MAAM,EAEZ,MAAM,WAAW,CAAC;AAEnB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qCAAqC,CAAC;AAC3E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC;AAEzD,OAAO,EAEL,KAAK,iBAAiB,EACvB,MAAM,0BAA0B,CAAC;AAQlC,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,eAAe,CAAC;IAC1B,0DAA0D;IAC1D,QAAQ,EAAE,UAAU,CAAC;IACrB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,uBAAuB,CAAC;IACnC;;;;;;OAMG;IACH,oBAAoB,CAAC,EAAE,MAAM,iBAAiB,GAAG,IAAI,CAAC;CACvD;AAED,MAAM,WAAW,eAAe;IAC9B,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,gEAAgE;IAChE,GAAG,EAAE,MAAM,CAAC;CACb;AAKD;;;GAGG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,eAAe,CAAC,CA4C1B"}
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Read-only HTTP surface for `LocalUiRegistry`.
3
+ *
4
+ * Endpoints (v1):
5
+ *
6
+ * GET /health → `{ ok: true, app, uiCount, issues }`
7
+ * GET /uis → `UiManifestEntry[]` (JSON)
8
+ * GET /uis/:id → `UiManifestEntry` or 404
9
+ * GET /uis/:id/bundle → compiled JS bundle, or 404
10
+ *
11
+ * No write verbs, no subscribe. The `UiRegistry.capabilities` probe
12
+ * exposed on `LocalUiRegistry` already declares this publicly; the
13
+ * endpoints are strictly GETs so hosts / Studio can't accidentally
14
+ * attempt a publish against a read-only source.
15
+ *
16
+ * Security posture:
17
+ *
18
+ * - Binds to `127.0.0.1` by default (loopback only). A user who
19
+ * wants LAN access passes `host: '0.0.0.0'` and accepts the
20
+ * implication.
21
+ * - Token / CORS policy is supplied by the caller via the
22
+ * security-policy seam (see `./auth.ts`).
23
+ *
24
+ * Node's native `http` module is used directly — no framework
25
+ * dependency. The handler is small enough (≈4 routes) that a router
26
+ * would be overkill, and keeps the open CLI's dependency footprint
27
+ * minimal.
28
+ */
29
+ import { createServer, } from 'node:http';
30
+ import { openEventStream } from './events.js';
31
+ import { emptyRuntimeSnapshot, } from '../runtime-supervisor.js';
32
+ import { serveHubShell } from './hub.js';
33
+ import { extractSelectedId, serveHubPreviewBundle, serveHubPreviewShell, } from './hub-preview.js';
34
+ const UI_ROUTE = /^\/uis(?:\/([^/]+)(?:\/(bundle))?)?\/?$/;
35
+ const RUNTIME_ROUTE = /^\/runtime(?:\/(status|events))?\/?$/;
36
+ /**
37
+ * Start the read-only dev server. Resolves after `listen` completes
38
+ * so callers can safely fire requests at the returned `port`.
39
+ */
40
+ export async function startDevServer(options) {
41
+ const host = options.host ?? '127.0.0.1';
42
+ const requestedPort = options.port ?? 0;
43
+ const server = createServer((req, res) => {
44
+ void handleRequest(req, res, options).catch((err) => {
45
+ if (!res.headersSent) {
46
+ writeJson(res, 500, { error: 'internal', message: errorMessage(err) });
47
+ }
48
+ else {
49
+ res.destroy();
50
+ }
51
+ });
52
+ });
53
+ await new Promise((resolve, reject) => {
54
+ server.once('error', reject);
55
+ server.listen(requestedPort, host, () => {
56
+ server.removeListener('error', reject);
57
+ resolve();
58
+ });
59
+ });
60
+ const address = server.address();
61
+ if (address === null || typeof address === 'string') {
62
+ throw new Error(`dev server bound to unexpected address ${String(address)}`);
63
+ }
64
+ return {
65
+ host: address.address,
66
+ port: address.port,
67
+ raw: server,
68
+ async close() {
69
+ // Shut down the registry watcher FIRST so open SSE streams
70
+ // stop receiving events; otherwise write attempts after
71
+ // `server.close()` land on half-closed sockets.
72
+ await options.registry.close();
73
+ // Force-destroy open sockets so SSE clients don't hold the
74
+ // close promise open for their full keep-alive window.
75
+ server.closeAllConnections?.();
76
+ await new Promise((resolve, reject) => {
77
+ server.close((err) => (err ? reject(err) : resolve()));
78
+ });
79
+ },
80
+ };
81
+ }
82
+ async function handleRequest(req, res, options) {
83
+ if (options.security) {
84
+ const result = options.security.apply(req, res);
85
+ if (result.outcome === 'handled')
86
+ return;
87
+ }
88
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
89
+ writeJson(res, 405, { error: 'method-not-allowed' });
90
+ return;
91
+ }
92
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
93
+ const path = url.pathname;
94
+ if (path === '/events' || path === '/events/') {
95
+ // SSE stream — lifecycle is owned by the stream handler; it
96
+ // returns after writing the preamble and keeps running until
97
+ // the client disconnects.
98
+ openEventStream(req, res, options.registry);
99
+ return;
100
+ }
101
+ if (path === '/hub' || path === '/hub/') {
102
+ // Hub shell is served unauthenticated (see `auth.ts` /
103
+ // `PUBLIC_PATHS`). The same-origin browser tab that opens the
104
+ // page still gets bearer-gated XHR responses — the shell
105
+ // embeds the token so its JS carries it automatically.
106
+ serveHubShell(req, res, {
107
+ token: options.security?.token ?? null,
108
+ manifest: options.manifest,
109
+ });
110
+ return;
111
+ }
112
+ if (path === '/hub/preview.js') {
113
+ // Public JS bundle for the iframe's module script. See
114
+ // `hub-preview.ts` for the rationale (browsers don't attach
115
+ // Authorization on element-triggered script loads).
116
+ await serveHubPreviewBundle(req, res);
117
+ return;
118
+ }
119
+ if (path === '/hub/preview' || path === '/hub/preview/') {
120
+ // Preview iframe shell. Public for the same reason `/hub` is
121
+ // public — the browser loads it before any XHR fires. The
122
+ // iframe reads `?ui=<id>` from its own URL; the shell sanitises
123
+ // the value defensively before echoing it back.
124
+ const selectedId = extractSelectedId(url.search.slice(1));
125
+ serveHubPreviewShell(req, res, {
126
+ token: options.security?.token ?? null,
127
+ selectedId,
128
+ });
129
+ return;
130
+ }
131
+ if (path === '/health' || path === '/health/') {
132
+ const list = await options.registry.list();
133
+ const issues = options.registry.getIssues();
134
+ writeJson(res, 200, {
135
+ ok: true,
136
+ app: options.manifest.app,
137
+ protocol: options.manifest.protocol,
138
+ uiCount: list.length,
139
+ issueCount: issues.length,
140
+ });
141
+ return;
142
+ }
143
+ const runtimeMatch = RUNTIME_ROUTE.exec(path);
144
+ if (runtimeMatch) {
145
+ const [, kind] = runtimeMatch;
146
+ const supervisor = options.getRuntimeSupervisor?.() ?? null;
147
+ if (!supervisor) {
148
+ // Always return a consistent "absent" payload rather than
149
+ // 404 — hosts that poll `/runtime/status` don't need to
150
+ // branch on HTTP status for the no-runtime case.
151
+ writeJson(res, 200, emptyRuntimeSnapshot());
152
+ return;
153
+ }
154
+ const snapshot = supervisor.snapshot();
155
+ if (!kind || kind === 'status') {
156
+ // Status endpoint returns everything EXCEPT the buffered
157
+ // events, so clients that only need lifecycle state don't
158
+ // pay the payload cost.
159
+ const { recentEvents: _omit, ...rest } = snapshot;
160
+ void _omit;
161
+ writeJson(res, 200, rest);
162
+ return;
163
+ }
164
+ if (kind === 'events') {
165
+ writeJson(res, 200, {
166
+ present: snapshot.present,
167
+ name: snapshot.name,
168
+ runId: snapshot.runId,
169
+ status: snapshot.status,
170
+ lastEventAt: snapshot.lastEventAt,
171
+ events: snapshot.recentEvents,
172
+ });
173
+ return;
174
+ }
175
+ }
176
+ const match = UI_ROUTE.exec(path);
177
+ if (match) {
178
+ const [, rawId, subresource] = match;
179
+ if (!rawId) {
180
+ const list = await options.registry.list();
181
+ writeJson(res, 200, list);
182
+ return;
183
+ }
184
+ const id = decodeURIComponent(rawId);
185
+ const entry = await options.registry.get(id);
186
+ if (!entry) {
187
+ writeJson(res, 404, { error: 'not-found', id });
188
+ return;
189
+ }
190
+ if (!subresource) {
191
+ writeJson(res, 200, entry);
192
+ return;
193
+ }
194
+ if (subresource === 'bundle') {
195
+ const result = await options.registry.fetchBundle(id);
196
+ switch (result.kind) {
197
+ case 'ok':
198
+ writeBundle(res, result.bundle);
199
+ return;
200
+ case 'not-found':
201
+ writeJson(res, 404, { error: 'not-found', id });
202
+ return;
203
+ case 'missing-entry':
204
+ // 404 still — the contract is "no bundle," Studio collapses
205
+ // this to the "no preview available" state. The body gives
206
+ // the developer the exact list of paths we searched so they
207
+ // know what to add (a `ggui.ui.tsx` beside the manifest, or
208
+ // an `entryPoint` field in `ggui.ui.json`).
209
+ writeJson(res, 404, {
210
+ error: 'missing-entry',
211
+ id,
212
+ message: 'No compiled bundle AND no TSX entry could be resolved. Declare `entryPoint` in ggui.ui.json, ' +
213
+ 'or colocate a ggui.ui.tsx / index.tsx / component.tsx beside the manifest.',
214
+ tried: result.tried,
215
+ });
216
+ return;
217
+ case 'compile-failed':
218
+ // 422 Unprocessable Entity — the server understood the
219
+ // request but couldn't produce output because the source
220
+ // fails to compile. Body carries structured esbuild errors
221
+ // so Studio can render the first error with its file /
222
+ // line / column / highlighted line.
223
+ writeJson(res, 422, {
224
+ error: 'compile-failed',
225
+ id,
226
+ entry: result.entry,
227
+ errors: result.errors,
228
+ warnings: result.warnings,
229
+ });
230
+ return;
231
+ }
232
+ }
233
+ }
234
+ writeJson(res, 404, { error: 'not-found', path });
235
+ }
236
+ function writeJson(res, status, body) {
237
+ const payload = JSON.stringify(body);
238
+ res.writeHead(status, {
239
+ 'content-type': 'application/json; charset=utf-8',
240
+ 'content-length': Buffer.byteLength(payload).toString(),
241
+ });
242
+ res.end(payload);
243
+ }
244
+ function writeBundle(res, bundle) {
245
+ // Streams are valid per the contract, but the LocalUiRegistry
246
+ // always produces string bundles today; accept both defensively so
247
+ // future cloud-registry wrappers drop in without a change here.
248
+ if (typeof bundle.code === 'string') {
249
+ const buffer = Buffer.from(bundle.code, 'utf-8');
250
+ res.writeHead(200, {
251
+ 'content-type': bundle.contentType,
252
+ 'content-length': buffer.byteLength.toString(),
253
+ });
254
+ res.end(buffer);
255
+ return;
256
+ }
257
+ res.writeHead(200, { 'content-type': bundle.contentType });
258
+ const reader = bundle.code.getReader();
259
+ void (async () => {
260
+ try {
261
+ for (;;) {
262
+ const { value, done } = await reader.read();
263
+ if (done)
264
+ break;
265
+ if (value !== undefined) {
266
+ res.write(typeof value === 'string' ? value : Buffer.from(value));
267
+ }
268
+ }
269
+ }
270
+ finally {
271
+ res.end();
272
+ }
273
+ })();
274
+ }
275
+ function errorMessage(err) {
276
+ return err instanceof Error ? err.message : String(err);
277
+ }
@@ -0,0 +1,56 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ /** Context the preview shell needs at boot. */
3
+ export interface HubPreviewShellContext {
4
+ /** Bearer token the preview's own JS sends on data XHRs. `null`
5
+ * when the server was started without a security policy (tests). */
6
+ readonly token: string | null;
7
+ /** Selected UI id from the `?ui=` query param. Empty string for
8
+ * the no-selection state — the shell renders a tiny "pick a UI"
9
+ * hint rather than a blank frame. */
10
+ readonly selectedId: string;
11
+ }
12
+ /**
13
+ * Build the preview iframe's HTML document. Pure function so tests
14
+ * can exercise the rendered shape without binding a port.
15
+ */
16
+ export declare function renderHubPreviewHtml(ctx: HubPreviewShellContext): string;
17
+ /**
18
+ * Read the `?ui=` query param and sanitise it. A UI id coming from
19
+ * the URL bar is untrusted; the shell already escapes when it renders,
20
+ * but stripping obviously-hostile characters keeps the accepted
21
+ * surface tight. IDs produced by the registry are plain kebab/dot
22
+ * strings, so any control char or slash is a sign of tampering.
23
+ */
24
+ export declare function extractSelectedId(rawQuery: string): string;
25
+ /**
26
+ * Write the preview shell to `res` with no-cache headers.
27
+ *
28
+ * `X-Frame-Options: SAMEORIGIN` — the hub is the only expected
29
+ * embedder. A different origin trying to frame this page is blocked
30
+ * so the iframe's bearer-embedded bootstrap can't leak to a hostile
31
+ * parent.
32
+ */
33
+ export declare function serveHubPreviewShell(req: IncomingMessage, res: ServerResponse, ctx: HubPreviewShellContext): void;
34
+ export declare function resolveHubPreviewBundlePath(): string;
35
+ /** Test seam — set an explicit bundle path (usually a temp file) so
36
+ * the HTTP layer can be exercised without running the esbuild step. */
37
+ export declare function __setHubPreviewBundlePathForTest(path: string | null): void;
38
+ /**
39
+ * Serve the compiled preview-client bundle at `/hub/preview.js`.
40
+ *
41
+ * The iframe's `<script type="module" src="/hub/preview.js">` tag
42
+ * cannot attach an `Authorization` header — browsers don't allow it
43
+ * on a plain script load. Matching the HTML shell's approach, this
44
+ * route is public; the existing origin allowlist blocks cross-origin
45
+ * reads, and the bundle itself is harmless code (reads from the
46
+ * iframe's same-origin `window.__GGUI_DEV_PREVIEW__`).
47
+ *
48
+ * On first boot + before `pnpm build` has run, the file may not
49
+ * exist. We return a 503 with an actionable message rather than a
50
+ * bare stack trace so the developer knows the build step is
51
+ * missing — the HTML shell in the iframe will paint a "Loading
52
+ * renderer…" placeholder and the console error will point to this
53
+ * response.
54
+ */
55
+ export declare function serveHubPreviewBundle(_req: IncomingMessage, res: ServerResponse): Promise<void>;
56
+ //# sourceMappingURL=hub-preview.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hub-preview.d.ts","sourceRoot":"","sources":["../../src/dev-server/hub-preview.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,+CAA+C;AAC/C,MAAM,WAAW,sBAAsB;IACrC;wEACoE;IACpE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B;;yCAEqC;IACrC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,sBAAsB,GAAG,MAAM,CAqCxE;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAW1D;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,sBAAsB,GAC1B,IAAI,CAUN;AAkCD,wBAAgB,2BAA2B,IAAI,MAAM,CAIpD;AAED;uEACuE;AACvE,wBAAgB,gCAAgC,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAE1E;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,eAAe,EACrB,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,IAAI,CAAC,CAwBf"}
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Local dev hub — preview iframe shell.
3
+ *
4
+ * The hub at `/hub` is a code-first observability dashboard (vanilla
5
+ * JS, no framework). Rendering a React component there would drag the
6
+ * whole renderer stack into the hub's surface and violate the charter.
7
+ * Instead, the hub embeds `<iframe src="/hub/preview?ui=<id>">` and
8
+ * this module owns the iframe's HTML shell.
9
+ *
10
+ * Shape (locked 2026-04-18):
11
+ *
12
+ * - One self-contained HTML document per iframe. Dep-free at this
13
+ * layer — the React-based renderer arrives in the next commit and
14
+ * mounts against a root node this shell already provides.
15
+ * - Public path (bearer-exempt) for the same reason `/hub` is
16
+ * public: a browser loads the HTML before it has the token. The
17
+ * origin allowlist in `auth.ts` still blocks cross-origin reads,
18
+ * and the shell embeds the bearer so same-origin XHRs carry it.
19
+ * - Bootstrap globals sit at `window.__GGUI_DEV_PREVIEW__` (distinct
20
+ * from the hub's `window.__GGUI_DEV__`) so the two pages never
21
+ * accidentally alias each other's state.
22
+ * - `noindex` / `no-store` / `X-Frame-Options: SAMEORIGIN` — hub
23
+ * loads this iframe from the same origin; any other framing is
24
+ * rejected.
25
+ *
26
+ * Invariants for this commit:
27
+ *
28
+ * - No component rendering yet. The shell shows a loading placeholder
29
+ * and the selected UI id. The actual DynamicComponent-based render
30
+ * lands as the next commit on top of this plumbing.
31
+ * - Selected id is validated defensively (non-empty, no control
32
+ * chars, no slashes) so a crafted URL can't inject HTML into the
33
+ * shell even though the server escapes it.
34
+ */
35
+ import { readFile, stat } from 'node:fs/promises';
36
+ import { dirname, resolve as resolvePath } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+ /**
39
+ * Build the preview iframe's HTML document. Pure function so tests
40
+ * can exercise the rendered shape without binding a port.
41
+ */
42
+ export function renderHubPreviewHtml(ctx) {
43
+ const idForDisplay = escapeHtml(ctx.selectedId);
44
+ const bootstrap = JSON.stringify({
45
+ token: ctx.token,
46
+ selectedId: ctx.selectedId,
47
+ }).replace(/</g, '\\u003c');
48
+ const title = ctx.selectedId.length > 0
49
+ ? `Preview: ${idForDisplay}`
50
+ : 'Preview';
51
+ return `<!doctype html>
52
+ <html lang="en">
53
+ <head>
54
+ <meta charset="utf-8" />
55
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
56
+ <meta name="robots" content="noindex" />
57
+ <title>${title}</title>
58
+ <style>${PREVIEW_CSS}</style>
59
+ </head>
60
+ <body>
61
+ <main id="preview-root">
62
+ <div class="frame" data-state="loading">
63
+ <div class="pane">
64
+ ${ctx.selectedId.length > 0
65
+ ? `<div class="meta">Preview of <code>${idForDisplay}</code></div>
66
+ <div class="status">Loading renderer…</div>`
67
+ : `<div class="meta">No UI selected</div>
68
+ <div class="status">Pick a UI from the Discovered UIs panel to preview it here.</div>`}
69
+ </div>
70
+ </div>
71
+ </main>
72
+ <script>window.__GGUI_DEV_PREVIEW__ = ${bootstrap};</script>
73
+ <script type="module" src="/hub/preview.js"></script>
74
+ </body>
75
+ </html>
76
+ `;
77
+ }
78
+ /**
79
+ * Read the `?ui=` query param and sanitise it. A UI id coming from
80
+ * the URL bar is untrusted; the shell already escapes when it renders,
81
+ * but stripping obviously-hostile characters keeps the accepted
82
+ * surface tight. IDs produced by the registry are plain kebab/dot
83
+ * strings, so any control char or slash is a sign of tampering.
84
+ */
85
+ export function extractSelectedId(rawQuery) {
86
+ const params = new URLSearchParams(rawQuery);
87
+ const value = (params.get('ui') ?? '').trim();
88
+ // Reject anything that isn't plain identifier-ish text. Registry ids
89
+ // are kebab/snake with dots at most; this keeps the accepted shape
90
+ // narrow without needing to cross-check against the live registry
91
+ // (the renderer's bundle fetch will 404 honestly anyway).
92
+ if (value.length === 0)
93
+ return '';
94
+ if (value.length > 200)
95
+ return '';
96
+ if (!/^[a-zA-Z0-9._\-:@/]+$/.test(value))
97
+ return '';
98
+ return value;
99
+ }
100
+ /**
101
+ * Write the preview shell to `res` with no-cache headers.
102
+ *
103
+ * `X-Frame-Options: SAMEORIGIN` — the hub is the only expected
104
+ * embedder. A different origin trying to frame this page is blocked
105
+ * so the iframe's bearer-embedded bootstrap can't leak to a hostile
106
+ * parent.
107
+ */
108
+ export function serveHubPreviewShell(req, res, ctx) {
109
+ const html = renderHubPreviewHtml(ctx);
110
+ const body = Buffer.from(html, 'utf-8');
111
+ res.writeHead(200, {
112
+ 'content-type': 'text/html; charset=utf-8',
113
+ 'content-length': body.byteLength.toString(),
114
+ 'cache-control': 'no-store',
115
+ 'x-frame-options': 'SAMEORIGIN',
116
+ });
117
+ res.end(body);
118
+ }
119
+ const HTML_ESCAPES = {
120
+ '&': '&amp;',
121
+ '<': '&lt;',
122
+ '>': '&gt;',
123
+ '"': '&quot;',
124
+ "'": '&#39;',
125
+ };
126
+ function escapeHtml(value) {
127
+ return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch);
128
+ }
129
+ /**
130
+ * Absolute path to the compiled preview-client bundle. The bundle is
131
+ * produced by `scripts/build-hub-preview.mjs` during `pnpm build` and
132
+ * lives at `dist/hub-preview/client.js`.
133
+ *
134
+ * Resolution is relative to this module's compiled location in `dist/`
135
+ * via `import.meta.url`:
136
+ *
137
+ * dist/dev-server/hub-preview.js
138
+ * ↓ dirname
139
+ * dist/dev-server
140
+ * ↓ ../hub-preview/client.js
141
+ * dist/hub-preview/client.js
142
+ *
143
+ * Computed once at module load and cached. Tests override via
144
+ * `__setHubPreviewBundlePathForTest` (export below) so they can
145
+ * assert serving logic without a real build being in place.
146
+ */
147
+ let bundlePathOverride = null;
148
+ export function resolveHubPreviewBundlePath() {
149
+ if (bundlePathOverride !== null)
150
+ return bundlePathOverride;
151
+ const here = dirname(fileURLToPath(import.meta.url));
152
+ return resolvePath(here, '..', 'hub-preview', 'client.js');
153
+ }
154
+ /** Test seam — set an explicit bundle path (usually a temp file) so
155
+ * the HTTP layer can be exercised without running the esbuild step. */
156
+ export function __setHubPreviewBundlePathForTest(path) {
157
+ bundlePathOverride = path;
158
+ }
159
+ /**
160
+ * Serve the compiled preview-client bundle at `/hub/preview.js`.
161
+ *
162
+ * The iframe's `<script type="module" src="/hub/preview.js">` tag
163
+ * cannot attach an `Authorization` header — browsers don't allow it
164
+ * on a plain script load. Matching the HTML shell's approach, this
165
+ * route is public; the existing origin allowlist blocks cross-origin
166
+ * reads, and the bundle itself is harmless code (reads from the
167
+ * iframe's same-origin `window.__GGUI_DEV_PREVIEW__`).
168
+ *
169
+ * On first boot + before `pnpm build` has run, the file may not
170
+ * exist. We return a 503 with an actionable message rather than a
171
+ * bare stack trace so the developer knows the build step is
172
+ * missing — the HTML shell in the iframe will paint a "Loading
173
+ * renderer…" placeholder and the console error will point to this
174
+ * response.
175
+ */
176
+ export async function serveHubPreviewBundle(_req, res) {
177
+ const path = resolveHubPreviewBundlePath();
178
+ try {
179
+ await stat(path);
180
+ }
181
+ catch {
182
+ const message = 'hub preview bundle is missing. Run `pnpm --filter @ggui-ai/dev-stack build` to produce it.';
183
+ const body = JSON.stringify({ error: 'preview-bundle-missing', message });
184
+ res.writeHead(503, {
185
+ 'content-type': 'application/json; charset=utf-8',
186
+ 'content-length': Buffer.byteLength(body).toString(),
187
+ 'cache-control': 'no-store',
188
+ });
189
+ res.end(body);
190
+ return;
191
+ }
192
+ const buffer = await readFile(path);
193
+ res.writeHead(200, {
194
+ 'content-type': 'application/javascript; charset=utf-8',
195
+ 'content-length': buffer.byteLength.toString(),
196
+ 'cache-control': 'no-store',
197
+ });
198
+ res.end(buffer);
199
+ }
200
+ /**
201
+ * Preview CSS. Matches the hub's dark-terminal palette so the iframe
202
+ * blends seamlessly with the surrounding panel. Kept minimal — once
203
+ * the renderer mounts, the compiled UI brings its own theme.
204
+ */
205
+ const PREVIEW_CSS = `
206
+ :root { color-scheme: dark; }
207
+ * { box-sizing: border-box; }
208
+ html, body { margin: 0; padding: 0; background: #0a0a0a; color: #e7e7e7;
209
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
210
+ body { min-height: 100vh; display: flex; }
211
+ main { flex: 1; display: flex; }
212
+ .frame { flex: 1; display: flex; align-items: center; justify-content: center;
213
+ padding: 24px; }
214
+ .pane { max-width: 480px; display: flex; flex-direction: column; gap: 10px;
215
+ text-align: center; }
216
+ .meta { font-size: 12px; color: #8a8a8a; text-transform: uppercase;
217
+ letter-spacing: 0.06em; }
218
+ .meta code { color: #7cb2e8; font-family: inherit; text-transform: none;
219
+ letter-spacing: 0; }
220
+ .status { color: #b8b8b8; font-size: 13px; }
221
+ .status .dim { color: #666; }
222
+ .tried { background: #141414; color: #8a8a8a; padding: 8px 12px;
223
+ border-radius: 3px; font-size: 11px; text-align: left;
224
+ white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; }
225
+ ul.errors { list-style: none; margin: 0; padding: 0; text-align: left; }
226
+ ul.errors li { padding: 6px 0; border-top: 1px solid #181818; }
227
+ ul.errors li:first-child { border-top: none; }
228
+ pre.location { margin: 0; color: #7cb2e8; font-size: 11px;
229
+ font-family: inherit; text-align: left; }
230
+ pre.error { margin: 2px 0 0; color: #e57373; font-size: 12px;
231
+ font-family: inherit; white-space: pre-wrap; text-align: left; }
232
+ pre.line-text { margin: 4px 0 0; background: #141414; color: #ccc;
233
+ padding: 4px 8px; border-radius: 3px; font-size: 11px;
234
+ font-family: inherit; text-align: left; overflow-x: auto; }
235
+ .surface { flex: 1; display: flex; align-items: stretch; padding: 16px; }
236
+ .surface > div { flex: 1; min-width: 0; }
237
+ `;