@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.
- package/LICENSE +201 -0
- package/README.md +63 -0
- package/dist/dev-server/auth.d.ts +44 -0
- package/dist/dev-server/auth.d.ts.map +1 -0
- package/dist/dev-server/auth.js +176 -0
- package/dist/dev-server/events.d.ts +43 -0
- package/dist/dev-server/events.d.ts.map +1 -0
- package/dist/dev-server/events.js +61 -0
- package/dist/dev-server/http.d.ts +79 -0
- package/dist/dev-server/http.d.ts.map +1 -0
- package/dist/dev-server/http.js +277 -0
- package/dist/dev-server/hub-preview.d.ts +56 -0
- package/dist/dev-server/hub-preview.d.ts.map +1 -0
- package/dist/dev-server/hub-preview.js +237 -0
- package/dist/dev-server/hub.d.ts +59 -0
- package/dist/dev-server/hub.d.ts.map +1 -0
- package/dist/dev-server/hub.js +723 -0
- package/dist/hub-preview/client.js +52346 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/local-registry/compile-ui.d.ts +67 -0
- package/dist/local-registry/compile-ui.d.ts.map +1 -0
- package/dist/local-registry/compile-ui.js +148 -0
- package/dist/local-registry/local-registry.d.ts +93 -0
- package/dist/local-registry/local-registry.d.ts.map +1 -0
- package/dist/local-registry/local-registry.js +272 -0
- package/dist/local-registry/watcher.d.ts +42 -0
- package/dist/local-registry/watcher.d.ts.map +1 -0
- package/dist/local-registry/watcher.js +208 -0
- package/dist/run-dev.d.ts +147 -0
- package/dist/run-dev.d.ts.map +1 -0
- package/dist/run-dev.js +216 -0
- package/dist/runtime-supervisor.d.ts +140 -0
- package/dist/runtime-supervisor.d.ts.map +1 -0
- package/dist/runtime-supervisor.js +91 -0
- 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
|
+
'&': '&',
|
|
121
|
+
'<': '<',
|
|
122
|
+
'>': '>',
|
|
123
|
+
'"': '"',
|
|
124
|
+
"'": ''',
|
|
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
|
+
`;
|