@ggui-ai/iframe-runtime 0.8.0 → 0.10.0
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/dist/call-tool-unwrap.d.ts +29 -0
- package/dist/call-tool-unwrap.js +56 -0
- package/dist/channels/props-update.d.ts +19 -0
- package/dist/channels/props-update.js +14 -1
- package/dist/compiled-validators.d.ts +16 -7
- package/dist/compiled-validators.js +41 -8
- package/dist/events-polling.d.ts +123 -1
- package/dist/events-polling.js +181 -53
- package/dist/iframe-runtime.js +65 -64
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/mcp-app-iframe-host.d.ts +1 -1
- package/dist/meta-parse.d.ts +13 -0
- package/dist/meta-parse.js +41 -0
- package/dist/observability.d.ts +1 -1
- package/dist/provisional-renderer.d.ts +1 -1
- package/dist/provisional-renderer.js +1 -1
- package/dist/react-renderer.d.ts +13 -1
- package/dist/react-renderer.js +87 -17
- package/dist/registry-subscribe.d.ts +42 -3
- package/dist/registry-subscribe.js +33 -10
- package/dist/render-item.d.ts +9 -0
- package/dist/render-item.js +7 -1
- package/dist/runtime-telemetry.d.ts +53 -0
- package/dist/runtime-telemetry.js +74 -0
- package/dist/runtime.d.ts +84 -0
- package/dist/runtime.js +522 -100
- package/dist/server.d.ts +19 -0
- package/dist/server.js +19 -0
- package/dist/shims/app-components.js +1 -0
- package/dist/shims/components.js +1 -0
- package/dist/shims/compositions.js +1 -0
- package/dist/shims/design.js +1 -0
- package/dist/shims/gadgets.js +1 -0
- package/dist/shims/interact.js +1 -0
- package/dist/shims/jsx-runtime.js +1 -0
- package/dist/shims/primitives.js +1 -0
- package/dist/shims/react-dom.js +1 -0
- package/dist/shims/react.js +1 -0
- package/dist/shims/templates.js +1 -0
- package/dist/shims/tokens.js +1 -0
- package/dist/shims/wire.js +1 -0
- package/dist/system-cards/ProtocolProbeCard.js +1 -1
- package/dist/system-cards/UiFeedbackCard.d.ts +1 -1
- package/dist/system-cards/UiFeedbackCard.js +1 -1
- package/dist/ui-feedback-chrome.js +7 -4
- package/dist/zod-jitless.d.ts +1 -0
- package/dist/zod-jitless.js +18 -0
- package/package.json +8 -7
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vanilla-TS port of `@ggui-ai/react::components/GguiRender.tsx`'s
|
|
2
|
+
* Vanilla-TS port of `@ggui-ai/mcp-apps-react::components/GguiRender.tsx`'s
|
|
3
3
|
* MCP-Apps branch. Used when a ggui render's `type === 'mcpApps'`
|
|
4
4
|
* — the renderer mounts a nested iframe that embeds a foreign
|
|
5
5
|
* MCP App.
|
package/dist/meta-parse.d.ts
CHANGED
|
@@ -30,6 +30,19 @@ export declare function validateMeta(meta: McpAppAiGguiRenderMeta): McpAppAiGgui
|
|
|
30
30
|
* so the runtime can mount without any postMessage round-trip.
|
|
31
31
|
*/
|
|
32
32
|
export declare function parseMetaFromGlobal(): McpAppAiGguiMetaParseResult;
|
|
33
|
+
/**
|
|
34
|
+
* The `ui://ggui/render/…` LOCATOR a tool result publishes when a server
|
|
35
|
+
* runs the read-plane-only posture (`withholdResultMeta`, guuey#209 C3 /
|
|
36
|
+
* ggui#537): the bootstrap material is absent, but the view's durable
|
|
37
|
+
* identity rides `structuredContent.resourceUri` (ggui-aware hosts) and
|
|
38
|
+
* the spec-canonical `_meta.ui.resourceUri` (MCP Apps hosts) — same
|
|
39
|
+
* value, either slot. The runtime resolves such a result through the
|
|
40
|
+
* read-plane door (`app.readServerResource` → the per-render
|
|
41
|
+
* self-contained shell → its inlined envelope). `null` for anything else
|
|
42
|
+
* — a result that carries the slice inline never needs this, and a
|
|
43
|
+
* result with neither is not a view.
|
|
44
|
+
*/
|
|
45
|
+
export declare function extractLocatorFromToolResult(params: unknown): string | null;
|
|
33
46
|
/**
|
|
34
47
|
* Parse the render slice from a `ui/notifications/tool-result`
|
|
35
48
|
* postMessage params payload. `params` is a `CallToolResult` per the
|
package/dist/meta-parse.js
CHANGED
|
@@ -239,6 +239,12 @@ function projectMeta(meta, hasStaticContent) {
|
|
|
239
239
|
...(meta.pollingUrl !== undefined
|
|
240
240
|
? { pollingUrl: meta.pollingUrl }
|
|
241
241
|
: {}),
|
|
242
|
+
// sseUrl mirrors pollingUrl's posture: unconditional pass-through,
|
|
243
|
+
// OUTSIDE the dropLiveCreds guard. Both are server-stamped
|
|
244
|
+
// wsToken-gated URLs; on the expired-creds degrade path they're
|
|
245
|
+
// harmless dead data because runtime.ts early-returns static-only
|
|
246
|
+
// before any live bind.
|
|
247
|
+
...(meta.sseUrl !== undefined ? { sseUrl: meta.sseUrl } : {}),
|
|
242
248
|
...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
|
|
243
249
|
...(themeMode !== undefined ? { themeMode } : {}),
|
|
244
250
|
// Per-app theme overlay carried through verbatim. Already validated
|
|
@@ -251,6 +257,13 @@ function projectMeta(meta, hasStaticContent) {
|
|
|
251
257
|
: {}),
|
|
252
258
|
...(permissionsPolicy !== undefined ? { permissionsPolicy } : {}),
|
|
253
259
|
...(meta.lastSequence !== undefined ? { lastSequence: meta.lastSequence } : {}),
|
|
260
|
+
// Freeze-latch self-epoch (#483): this mount's own history number.
|
|
261
|
+
// Non-number collapses to undefined (⇒ boot treats itself as epoch
|
|
262
|
+
// 0). Dropping this field is catastrophic-but-quiet: every mount
|
|
263
|
+
// then boots believing epoch 0, and the first epoch-stamped live
|
|
264
|
+
// frame — including an amend's, addressed to the HEAD — freezes it
|
|
265
|
+
// (probe 18 round 2: the head card froze on its own amend).
|
|
266
|
+
...(typeof meta.epoch === 'number' ? { epoch: meta.epoch } : {}),
|
|
254
267
|
...(meta.propsJson !== undefined ? { propsJson: meta.propsJson } : {}),
|
|
255
268
|
...(contextSlots !== undefined ? { contextSlots } : {}),
|
|
256
269
|
...(meta.contractHash !== undefined && meta.validatorsUrl !== undefined
|
|
@@ -261,6 +274,11 @@ function projectMeta(meta, hasStaticContent) {
|
|
|
261
274
|
: {}),
|
|
262
275
|
...(meta.codeUrl !== undefined ? { codeUrl: meta.codeUrl } : {}),
|
|
263
276
|
...(meta.codeHash !== undefined ? { codeHash: meta.codeHash } : {}),
|
|
277
|
+
// Strict-CSP module-variant twin (ggui#522 slice 2). The protocol
|
|
278
|
+
// parser already enforces the with-a-raw-carrier pairing.
|
|
279
|
+
...(meta.codeModuleUrl !== undefined
|
|
280
|
+
? { codeModuleUrl: meta.codeModuleUrl }
|
|
281
|
+
: {}),
|
|
264
282
|
...(meta.codeB64 !== undefined ? { codeB64: meta.codeB64 } : {}),
|
|
265
283
|
...(meta.kind !== undefined ? { kind: meta.kind } : {}),
|
|
266
284
|
};
|
|
@@ -336,6 +354,29 @@ export function parseMetaFromGlobal() {
|
|
|
336
354
|
}
|
|
337
355
|
return validateMeta(parsed.meta);
|
|
338
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* The `ui://ggui/render/…` LOCATOR a tool result publishes when a server
|
|
359
|
+
* runs the read-plane-only posture (`withholdResultMeta`, guuey#209 C3 /
|
|
360
|
+
* ggui#537): the bootstrap material is absent, but the view's durable
|
|
361
|
+
* identity rides `structuredContent.resourceUri` (ggui-aware hosts) and
|
|
362
|
+
* the spec-canonical `_meta.ui.resourceUri` (MCP Apps hosts) — same
|
|
363
|
+
* value, either slot. The runtime resolves such a result through the
|
|
364
|
+
* read-plane door (`app.readServerResource` → the per-render
|
|
365
|
+
* self-contained shell → its inlined envelope). `null` for anything else
|
|
366
|
+
* — a result that carries the slice inline never needs this, and a
|
|
367
|
+
* result with neither is not a view.
|
|
368
|
+
*/
|
|
369
|
+
export function extractLocatorFromToolResult(params) {
|
|
370
|
+
if (!isPlainObject(params))
|
|
371
|
+
return null;
|
|
372
|
+
const sc = params['structuredContent'];
|
|
373
|
+
const fromStructured = isPlainObject(sc) && typeof sc['resourceUri'] === 'string' ? sc['resourceUri'] : null;
|
|
374
|
+
const meta = params['_meta'];
|
|
375
|
+
const ui = isPlainObject(meta) ? meta['ui'] : undefined;
|
|
376
|
+
const fromUi = isPlainObject(ui) && typeof ui['resourceUri'] === 'string' ? ui['resourceUri'] : null;
|
|
377
|
+
const uri = fromStructured ?? fromUi;
|
|
378
|
+
return uri !== null && uri.startsWith('ui://ggui/render/') ? uri : null;
|
|
379
|
+
}
|
|
339
380
|
/**
|
|
340
381
|
* Parse the render slice from a `ui/notifications/tool-result`
|
|
341
382
|
* postMessage params payload. `params` is a `CallToolResult` per the
|
package/dist/observability.d.ts
CHANGED
|
@@ -148,7 +148,7 @@ export interface ChannelPollRecoveredEvent {
|
|
|
148
148
|
/**
|
|
149
149
|
* Fired when the end user submits the runtime's in-iframe UI-feedback
|
|
150
150
|
* affordance ("did this generated UI work for you?"). Field semantics
|
|
151
|
-
* mirror `UiFeedbackPayload` in `@ggui-ai/react` / `@ggui-ai/react-native`
|
|
151
|
+
* mirror `UiFeedbackPayload` in `@ggui-ai/mcp-apps-react` / `@ggui-ai/mcp-apps-react-native`
|
|
152
152
|
* (the host-chrome twin of this affordance):
|
|
153
153
|
*
|
|
154
154
|
* - `verdict` — `'love'` / `'dislike'`, or `'other'` for the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Provisional (A2UI preview) renderer for the iframe runtime.
|
|
3
3
|
*
|
|
4
|
-
* Port of `@ggui-ai/react::components/ProvisionalRenderer.tsx` with
|
|
4
|
+
* Port of `@ggui-ai/mcp-apps-react::components/ProvisionalRenderer.tsx` with
|
|
5
5
|
* the React-hook consumer stripped. The A2UI reduce + adjacency-list
|
|
6
6
|
* render is preserved verbatim; the inputs + mount lifecycle differ:
|
|
7
7
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Provisional (A2UI preview) renderer for the iframe runtime.
|
|
3
3
|
*
|
|
4
|
-
* Port of `@ggui-ai/react::components/ProvisionalRenderer.tsx` with
|
|
4
|
+
* Port of `@ggui-ai/mcp-apps-react::components/ProvisionalRenderer.tsx` with
|
|
5
5
|
* the React-hook consumer stripped. The A2UI reduce + adjacency-list
|
|
6
6
|
* render is preserved verbatim; the inputs + mount lifecycle differ:
|
|
7
7
|
*
|
package/dist/react-renderer.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* React component renderer for the iframe runtime.
|
|
3
3
|
*
|
|
4
|
-
* Port of `@ggui-ai/react::components/ReactComponentRenderer.tsx` (L10-384)
|
|
4
|
+
* Port of `@ggui-ai/mcp-apps-react::components/ReactComponentRenderer.tsx` (L10-384)
|
|
5
5
|
* with the enclosing React component stripped — the iframe runtime
|
|
6
6
|
* does not have an outer React tree; it owns the root `createRoot`
|
|
7
7
|
* mount itself. The inner eval + data-URL-shim + error-boundary
|
|
@@ -41,6 +41,18 @@ export interface ReactRootMountOptions {
|
|
|
41
41
|
readonly id?: string;
|
|
42
42
|
readonly componentCode: string;
|
|
43
43
|
readonly props?: Record<string, unknown>;
|
|
44
|
+
/**
|
|
45
|
+
* Strict-CSP module variant of `componentCode` (ggui#522 slice 2):
|
|
46
|
+
* a server-side import-rewritten twin of the SAME bytes, directly
|
|
47
|
+
* `import()`able (its imports resolve to static shim assets on an
|
|
48
|
+
* allowed origin — no `data:`, no `blob:`, no eval). Tried FIRST;
|
|
49
|
+
* any failure falls through to the blob/inline ladder on the raw
|
|
50
|
+
* bytes. Callers MUST pass it only when it names exactly these
|
|
51
|
+
* `componentCode` bytes — a stale variant would paint the WRONG
|
|
52
|
+
* component, not fail (the runtime's dispatcher guards this by
|
|
53
|
+
* attaching it to the inline seed only).
|
|
54
|
+
*/
|
|
55
|
+
readonly codeModuleUrl?: string;
|
|
44
56
|
};
|
|
45
57
|
readonly themeId?: string;
|
|
46
58
|
/**
|
package/dist/react-renderer.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* React component renderer for the iframe runtime.
|
|
3
3
|
*
|
|
4
|
-
* Port of `@ggui-ai/react::components/ReactComponentRenderer.tsx` (L10-384)
|
|
4
|
+
* Port of `@ggui-ai/mcp-apps-react::components/ReactComponentRenderer.tsx` (L10-384)
|
|
5
5
|
* with the enclosing React component stripped — the iframe runtime
|
|
6
6
|
* does not have an outer React tree; it owns the root `createRoot`
|
|
7
7
|
* mount itself. The inner eval + data-URL-shim + error-boundary
|
|
@@ -91,12 +91,17 @@ class RcrErrorBoundary extends Component {
|
|
|
91
91
|
clearTimeout(this.retryTimer);
|
|
92
92
|
}
|
|
93
93
|
render() {
|
|
94
|
-
const { error, autoRetrying
|
|
94
|
+
const { error, autoRetrying } = this.state;
|
|
95
95
|
if (error === null)
|
|
96
96
|
return this.props.children;
|
|
97
97
|
// The two fallback UIs match the host-SDK error boundary
|
|
98
98
|
// verbatim (same inline styles so operator-visible DOM is
|
|
99
99
|
// identical between the host-SDK and iframe-renderer paths).
|
|
100
|
+
// Colors inherit the themed body (`--ggui-color-onSurface`) with
|
|
101
|
+
// opacity for hierarchy — hardcoded white-alpha text was
|
|
102
|
+
// illegible on light shells (#481); the boundary must stay
|
|
103
|
+
// readable on WHATEVER background the theme painted, including
|
|
104
|
+
// when theme CSS itself failed to inject (browser-default white).
|
|
100
105
|
const FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
101
106
|
if (autoRetrying) {
|
|
102
107
|
return createElement('div', {
|
|
@@ -109,14 +114,15 @@ class RcrErrorBoundary extends Component {
|
|
|
109
114
|
gap: 8,
|
|
110
115
|
fontFamily: FONT,
|
|
111
116
|
fontSize: 13,
|
|
112
|
-
color: '
|
|
117
|
+
color: 'var(--ggui-color-onSurface, inherit)',
|
|
118
|
+
opacity: 0.6,
|
|
113
119
|
},
|
|
114
120
|
}, createElement('div', {
|
|
115
121
|
style: {
|
|
116
122
|
width: 16,
|
|
117
123
|
height: 16,
|
|
118
|
-
border: '2px solid rgba(
|
|
119
|
-
borderTopColor: '
|
|
124
|
+
border: '2px solid rgba(128,128,128,0.25)',
|
|
125
|
+
borderTopColor: 'currentColor',
|
|
120
126
|
borderRadius: '50%',
|
|
121
127
|
animation: 'ggui-err-spin 0.6s linear infinite',
|
|
122
128
|
},
|
|
@@ -133,6 +139,7 @@ class RcrErrorBoundary extends Component {
|
|
|
133
139
|
gap: 12,
|
|
134
140
|
textAlign: 'center',
|
|
135
141
|
fontFamily: FONT,
|
|
142
|
+
color: 'var(--ggui-color-onSurface, inherit)',
|
|
136
143
|
},
|
|
137
144
|
}, createElement('div', {
|
|
138
145
|
style: {
|
|
@@ -140,27 +147,48 @@ class RcrErrorBoundary extends Component {
|
|
|
140
147
|
height: 48,
|
|
141
148
|
borderRadius: 14,
|
|
142
149
|
background: 'rgba(239, 68, 68, 0.1)',
|
|
150
|
+
color: '#ef4444',
|
|
143
151
|
display: 'flex',
|
|
144
152
|
alignItems: 'center',
|
|
145
153
|
justifyContent: 'center',
|
|
146
154
|
fontSize: 22,
|
|
147
155
|
},
|
|
148
|
-
}, '!'), createElement('div', { style: { fontSize: 14, fontWeight: 600,
|
|
156
|
+
}, '!'), createElement('div', { style: { fontSize: 14, fontWeight: 600, opacity: 0.9 } }, 'Something went wrong'), createElement('div', { style: { fontSize: 12, opacity: 0.6, maxWidth: 280, lineHeight: 1.4 } }, 'This component encountered an error. Try asking for a different view.'), createElement('button', {
|
|
149
157
|
onClick: () => this.setState({ error: null, catchCount: 0 }),
|
|
150
158
|
style: {
|
|
151
159
|
marginTop: 4,
|
|
152
160
|
padding: '8px 20px',
|
|
153
161
|
borderRadius: 8,
|
|
154
|
-
border: '1px solid
|
|
155
|
-
background: '
|
|
156
|
-
color: '
|
|
162
|
+
border: '1px solid currentColor',
|
|
163
|
+
background: 'transparent',
|
|
164
|
+
color: 'inherit',
|
|
165
|
+
opacity: 0.75,
|
|
157
166
|
fontSize: 13,
|
|
158
167
|
fontWeight: 500,
|
|
159
168
|
cursor: 'pointer',
|
|
160
169
|
},
|
|
161
|
-
}, 'Retry'),
|
|
162
|
-
|
|
163
|
-
|
|
170
|
+
}, 'Retry'),
|
|
171
|
+
// Diagnostic is ALWAYS reachable (#481 ruling: errors must be
|
|
172
|
+
// visible) — collapsed by default, legible when opened.
|
|
173
|
+
createElement('details', {
|
|
174
|
+
style: {
|
|
175
|
+
marginTop: 8,
|
|
176
|
+
maxWidth: 320,
|
|
177
|
+
width: '100%',
|
|
178
|
+
textAlign: 'left',
|
|
179
|
+
opacity: 0.65,
|
|
180
|
+
},
|
|
181
|
+
}, createElement('summary', { style: { fontSize: 11, cursor: 'pointer' } }, 'Error details'), createElement('pre', {
|
|
182
|
+
style: {
|
|
183
|
+
fontSize: 11,
|
|
184
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
185
|
+
whiteSpace: 'pre-wrap',
|
|
186
|
+
overflowWrap: 'anywhere',
|
|
187
|
+
margin: '6px 0 0',
|
|
188
|
+
maxHeight: 140,
|
|
189
|
+
overflow: 'auto',
|
|
190
|
+
},
|
|
191
|
+
}, error.message)));
|
|
164
192
|
}
|
|
165
193
|
}
|
|
166
194
|
/**
|
|
@@ -207,6 +235,30 @@ export async function mountReactRoot(container, opts) {
|
|
|
207
235
|
? { gadgetPackages: currentOpts.gadgetPackages }
|
|
208
236
|
: {}),
|
|
209
237
|
}));
|
|
238
|
+
// Asset-module path (ggui#522 slice 2) — FIRST when the mount
|
|
239
|
+
// carries the strict-CSP variant URL: a plain https module import,
|
|
240
|
+
// legal under `script-src` origins alone, no scheme sources, no
|
|
241
|
+
// eval. Under a strict host CSP it is the ONLY path that works;
|
|
242
|
+
// under a permissive one it costs a CDN round-trip on cold cache
|
|
243
|
+
// and nothing on warm (the URL is immutable). Any failure — CSP
|
|
244
|
+
// block, network fault, a 404 from a redeployed variant family —
|
|
245
|
+
// falls through to the raw-bytes ladder below. No document latch
|
|
246
|
+
// on failure: unlike the per-evaluation blob probe, this branch
|
|
247
|
+
// runs only when componentCode changes (rare — usually once per
|
|
248
|
+
// mount), so the retry cost is one fetch, and the failure classes
|
|
249
|
+
// (transient network vs permanent CSP) are indistinguishable in
|
|
250
|
+
// the import error anyway.
|
|
251
|
+
const moduleUrl = currentOpts.render.codeModuleUrl;
|
|
252
|
+
if (moduleUrl !== undefined) {
|
|
253
|
+
try {
|
|
254
|
+
const mod = (await import(
|
|
255
|
+
/* webpackIgnore: true */ /* @vite-ignore */ moduleUrl));
|
|
256
|
+
return pick(mod);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// Fall through — the raw bytes are the authoritative carrier.
|
|
260
|
+
}
|
|
261
|
+
}
|
|
210
262
|
// Once URL-scheme module loading has been proven blocked in this
|
|
211
263
|
// document, go straight to inline execution — the CSP verdict
|
|
212
264
|
// cannot change within the document's lifetime.
|
|
@@ -250,9 +302,25 @@ export async function mountReactRoot(container, opts) {
|
|
|
250
302
|
}
|
|
251
303
|
}
|
|
252
304
|
function renderTree(opts) {
|
|
253
|
-
|
|
305
|
+
let themeCss = opts.themeId
|
|
254
306
|
? getScopedThemeCss(opts.themeId, scopeClass, opts.themeMode)
|
|
255
307
|
: getScopedCssTokens(scopeClass, opts.themeMode);
|
|
308
|
+
if (opts.appTheme) {
|
|
309
|
+
const decls = Object.entries(opts.appTheme.cssVariables)
|
|
310
|
+
.map(([k, v]) => `${k}: ${v};`)
|
|
311
|
+
.join('');
|
|
312
|
+
// The overlay must be merged HERE, into the scoped style that
|
|
313
|
+
// mounts INSIDE the scope div — the `:root` append below is
|
|
314
|
+
// cascade-dead for tree content: the in-scope block sits later
|
|
315
|
+
// in document order than anything in `<head>`, so at equal
|
|
316
|
+
// specificity the base scoped tokens always win over a head-level
|
|
317
|
+
// override (browser-verified in rnd/gen-ui/beauty/experiments/001,
|
|
318
|
+
// the probe sequence that found this). Appended AFTER the base
|
|
319
|
+
// block and BEFORE `cssOverrides`, so: base < operator overlay <
|
|
320
|
+
// explicit overrides. Values validated injection-safe upstream
|
|
321
|
+
// (same guarantee the `:root` append cites).
|
|
322
|
+
themeCss += `.${scopeClass}{${decls}}`;
|
|
323
|
+
}
|
|
256
324
|
// Also inject theme CSS at `:root` on `document.head`. The scoped
|
|
257
325
|
// injection above isolates token resolution to the React tree, but
|
|
258
326
|
// the embedding shell's body styles (font-family, color, background)
|
|
@@ -270,10 +338,12 @@ export async function mountReactRoot(container, opts) {
|
|
|
270
338
|
const decls = Object.entries(opts.appTheme.cssVariables)
|
|
271
339
|
.map(([k, v]) => `${k}: ${v};`)
|
|
272
340
|
.join('');
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
341
|
+
// BODY-CHROME ONLY. This `:root` append themes the embedding
|
|
342
|
+
// shell's body styles (font/color/background outside the scope
|
|
343
|
+
// div). It does NOT reach tree content — the scoped in-body
|
|
344
|
+
// token block wins by document order (see the scoped merge in
|
|
345
|
+
// `renderTree`, and rnd/gen-ui/beauty/experiments/001 for the
|
|
346
|
+
// probe that falsified the old "inherits into the tree" claim).
|
|
277
347
|
// Values were validated injection-safe upstream (write-side
|
|
278
348
|
// `appThemeSchema` + the wire parser re-validates), so the
|
|
279
349
|
// string-join here is safe — do NOT re-sanitize.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AckPayload } from '@ggui-ai/protocol';
|
|
2
2
|
import type { ConnectionStatus } from '@ggui-ai/protocol/transport/websocket';
|
|
3
3
|
import type { McpAppAiGguiRenderMeta } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
4
|
-
import type { ChannelRegistry, RegistryPollingOptions, WsTransportHandle } from '@ggui-ai/live-channel';
|
|
4
|
+
import type { AnyTransportHandle, ChannelLogger, ChannelRegistry, RegistryPollingOptions, RegistrySseOptions, WsTransportHandle } from '@ggui-ai/live-channel';
|
|
5
5
|
import { type ProtocolErrorEmitter } from './protocol-error.js';
|
|
6
6
|
import type { ObservabilityEmitter } from './observability.js';
|
|
7
7
|
/**
|
|
@@ -12,8 +12,19 @@ import type { ObservabilityEmitter } from './observability.js';
|
|
|
12
12
|
* render slot.
|
|
13
13
|
*/
|
|
14
14
|
export interface RegistrySubscribeHandle {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The bound transport. `kind: 'ws'` on trio binds (the FailoverHandle
|
|
17
|
+
* facade keeps it across demotions); `kind: 'polling'` on trio-less
|
|
18
|
+
* bridge-only binds — narrow before using the ws-only `send` surface.
|
|
19
|
+
*/
|
|
20
|
+
readonly handle: AnyTransportHandle;
|
|
21
|
+
/**
|
|
22
|
+
* First subscribe ack — present on trio binds only. A trio-less
|
|
23
|
+
* bridge-only bind has no subscribe handshake (the static seed
|
|
24
|
+
* already painted; the bridge delivers deltas), so the key is
|
|
25
|
+
* absent entirely (exact-optional).
|
|
26
|
+
*/
|
|
27
|
+
readonly ack?: AckPayload;
|
|
17
28
|
}
|
|
18
29
|
/**
|
|
19
30
|
* Caller-provided callbacks. The runtime's `bootSequence` owns the
|
|
@@ -90,6 +101,34 @@ export interface ConnectViaRegistryOptions {
|
|
|
90
101
|
* URL- and shape-agnostic.
|
|
91
102
|
*/
|
|
92
103
|
readonly polling?: RegistryPollingOptions;
|
|
104
|
+
/**
|
|
105
|
+
* SSE middle-rung descriptor (WS → SSE → polling ladder). Composed
|
|
106
|
+
* at the runtime layer from `meta.sseUrl` + the shared ladder
|
|
107
|
+
* cursor — the live-channel is URL- and auth-agnostic. Absent →
|
|
108
|
+
* two-rung WS→polling ladder (or WS-only when `polling` is also
|
|
109
|
+
* absent).
|
|
110
|
+
*/
|
|
111
|
+
readonly sse?: RegistrySseOptions;
|
|
112
|
+
/**
|
|
113
|
+
* Bridge-pull terminal-rung descriptor (WS → SSE → polling → bridge
|
|
114
|
+
* ladder). Same {@link RegistryPollingOptions} shape as `polling`
|
|
115
|
+
* because bridge-pull IS the polling algorithm on the `fetchBody`
|
|
116
|
+
* carrier — the event ledger pulled via `ggui_runtime_pull`
|
|
117
|
+
* `tools/call`s over the host's postMessage bridge instead of an
|
|
118
|
+
* HTTP URL. Composed at the runtime layer (`buildBridgePolling`
|
|
119
|
+
* bound to `app.callServerTool` + the shared ladder cursor)
|
|
120
|
+
* whenever the connected App handle exists; threaded verbatim to
|
|
121
|
+
* `bind()` like `sse`/`polling`. Absent → the ladder ends at
|
|
122
|
+
* whichever of the rungs above is armed.
|
|
123
|
+
*/
|
|
124
|
+
readonly bridge?: RegistryPollingOptions;
|
|
125
|
+
/**
|
|
126
|
+
* Diagnostics tap threaded into the live-channel bind — the
|
|
127
|
+
* transports' own `channel_*` events flow to it. The runtime passes
|
|
128
|
+
* its telemetry sink's `ChannelLogger` facade here so ladder
|
|
129
|
+
* behavior on console-less sandboxed hosts reaches the server.
|
|
130
|
+
*/
|
|
131
|
+
readonly logger?: ChannelLogger;
|
|
93
132
|
}
|
|
94
133
|
/**
|
|
95
134
|
* Open the WS via the registry, send subscribe (composed by the
|
|
@@ -146,14 +146,22 @@ export function connectViaRegistry(opts) {
|
|
|
146
146
|
let seenOpen = false;
|
|
147
147
|
const emitProtocolError = opts.onProtocolError ?? (() => { });
|
|
148
148
|
const emitObserve = opts.onObserve ?? (() => { });
|
|
149
|
-
// Defense-in-depth —
|
|
150
|
-
//
|
|
151
|
-
//
|
|
149
|
+
// Defense-in-depth — the WS rung requires both wsUrl + wsToken.
|
|
150
|
+
// A trio-less meta is still connectable when a BRIDGE descriptor
|
|
151
|
+
// is supplied (#471 round-6 live finding): CSP-jailed MCP-Apps
|
|
152
|
+
// hosts mount statically with NO trio at all, and the bridge-pull
|
|
153
|
+
// terminal rung — whose only precondition is the host's tools/call
|
|
154
|
+
// postMessage bridge — is precisely their subscription. The
|
|
155
|
+
// registry handles a trio-less bootstrap by starting the ladder at
|
|
156
|
+
// its tail. Trio-less AND bridge-less remains the typed failure.
|
|
152
157
|
const wsUrl = opts.meta.wsUrl;
|
|
153
158
|
const wsToken = opts.meta.wsToken;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
const hasTrio = typeof wsUrl === 'string' &&
|
|
160
|
+
wsUrl.length > 0 &&
|
|
161
|
+
typeof wsToken === 'string' &&
|
|
162
|
+
wsToken.length > 0;
|
|
163
|
+
if (!hasTrio && opts.bridge === undefined) {
|
|
164
|
+
const err = new Error('connectViaRegistry: render-meta missing wsUrl/wsToken and no bridge descriptor — nothing to subscribe');
|
|
157
165
|
emitProtocolError(fromTransportFailure('DISCONNECTED', false, err.message));
|
|
158
166
|
reject(err);
|
|
159
167
|
return;
|
|
@@ -281,25 +289,40 @@ export function connectViaRegistry(opts) {
|
|
|
281
289
|
reject(new Error('connectViaRegistry: transport went to failed state before ack'));
|
|
282
290
|
}
|
|
283
291
|
};
|
|
284
|
-
const composedUrl = composeWsUrl(wsUrl, wsToken);
|
|
285
292
|
// `bind()` selects the transport (WS for wsUrl+wsToken sessions;
|
|
286
293
|
// the live-channel narrows on its `bootstrap` shape) and starts
|
|
287
294
|
// it. We override `bootstrap.wsUrl` with the composed URL so the
|
|
288
295
|
// registry's WS transport gets the wsToken-threaded variant. The
|
|
289
296
|
// live-channel's `bootstrap` arg is its internal name; the field
|
|
290
|
-
// on the wire is `wsToken`.
|
|
297
|
+
// on the wire is `wsToken`. Trio-less (bridge-capable) binds carry
|
|
298
|
+
// NEITHER key — the registry then starts the ladder at its tail.
|
|
291
299
|
void opts.registry
|
|
292
300
|
.bind({
|
|
293
301
|
bootstrap: {
|
|
294
|
-
|
|
295
|
-
|
|
302
|
+
...(hasTrio
|
|
303
|
+
? { wsUrl: composeWsUrl(wsUrl, wsToken), wsToken }
|
|
304
|
+
: {}),
|
|
296
305
|
sessionId: opts.meta.sessionId,
|
|
297
306
|
appId: opts.meta.appId,
|
|
298
307
|
},
|
|
308
|
+
...(opts.logger !== undefined ? { logger: opts.logger } : {}),
|
|
299
309
|
onStatusChange: mappedStatusCallback,
|
|
310
|
+
...(opts.sse !== undefined ? { sse: opts.sse } : {}),
|
|
300
311
|
...(opts.polling !== undefined ? { polling: opts.polling } : {}),
|
|
312
|
+
...(opts.bridge !== undefined ? { bridge: opts.bridge } : {}),
|
|
301
313
|
})
|
|
302
314
|
.then((bound) => {
|
|
315
|
+
if (!hasTrio) {
|
|
316
|
+
// Bridge-only bind — no subscribe handshake, no ack. The
|
|
317
|
+
// polling facade reports 'open' immediately (mapped to
|
|
318
|
+
// 'connected' for the consumer); resolve with the handle
|
|
319
|
+
// alone and let the ladder deliver deltas.
|
|
320
|
+
if (!settled) {
|
|
321
|
+
settled = true;
|
|
322
|
+
resolve({ handle: bound });
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
303
326
|
// bound is `AnyTransportHandle`. Composed URL + wsToken means
|
|
304
327
|
// the registry selected WSTransport — `kind === 'ws'` is the
|
|
305
328
|
// structural invariant. Narrow defensively.
|
package/dist/render-item.d.ts
CHANGED
|
@@ -29,6 +29,15 @@ export interface RenderItemOptions {
|
|
|
29
29
|
* this. STDLIB `@ggui-ai/gadgets` is always rewritten regardless.
|
|
30
30
|
*/
|
|
31
31
|
readonly gadgetPackages?: readonly string[];
|
|
32
|
+
/**
|
|
33
|
+
* Strict-CSP module variant of the render's `componentCode`
|
|
34
|
+
* (ggui#522 slice 2), forwarded to
|
|
35
|
+
* {@link ReactRootMountOptions.render.codeModuleUrl}. The CALLER
|
|
36
|
+
* owns the bytes↔URL association: pass it only when it names
|
|
37
|
+
* exactly `render.componentCode`'s bytes (the runtime attaches it
|
|
38
|
+
* to the inline seed mount only — a WS-delivered render drops it).
|
|
39
|
+
*/
|
|
40
|
+
readonly codeModuleUrl?: string;
|
|
32
41
|
/** Theme id passed to ReactComponentRenderer for scoped CSS. */
|
|
33
42
|
readonly themeId?: string;
|
|
34
43
|
/**
|
package/dist/render-item.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* `StackRenderer` / `StackRenderContext` orchestrator (a stack-of-N
|
|
11
11
|
* map keyed by item id) was retired along with `StackModel`.
|
|
12
12
|
*
|
|
13
|
-
* The dispatch tree mirrors `@ggui-ai/react::DynamicComponent` +
|
|
13
|
+
* The dispatch tree mirrors `@ggui-ai/mcp-apps-react::DynamicComponent` +
|
|
14
14
|
* `GguiRender`'s MCP-Apps branch:
|
|
15
15
|
*
|
|
16
16
|
* - `type === 'mcpApps'` → `mountMcpAppIframe` (vanilla iframe
|
|
@@ -169,6 +169,9 @@ export async function mountRender(container, opts) {
|
|
|
169
169
|
...(render.props !== undefined
|
|
170
170
|
? { props: render.props }
|
|
171
171
|
: {}),
|
|
172
|
+
...(currentOpts.codeModuleUrl !== undefined
|
|
173
|
+
? { codeModuleUrl: currentOpts.codeModuleUrl }
|
|
174
|
+
: {}),
|
|
172
175
|
},
|
|
173
176
|
...(gadgetPackages !== undefined ? { gadgetPackages } : {}),
|
|
174
177
|
...(currentOpts.themeId !== undefined ? { themeId: currentOpts.themeId } : {}),
|
|
@@ -234,6 +237,9 @@ export async function mountRender(container, opts) {
|
|
|
234
237
|
...(render.props !== undefined
|
|
235
238
|
? { props: render.props }
|
|
236
239
|
: {}),
|
|
240
|
+
...(next.codeModuleUrl !== undefined
|
|
241
|
+
? { codeModuleUrl: next.codeModuleUrl }
|
|
242
|
+
: {}),
|
|
237
243
|
},
|
|
238
244
|
...(next.themeId !== undefined ? { themeId: next.themeId } : {}),
|
|
239
245
|
...(next.themeMode !== undefined ? { themeMode: next.themeMode } : {}),
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-telemetry sink — the iframe runtime's self-report channel
|
|
3
|
+
* (`ggui_runtime_telemetry`).
|
|
4
|
+
*
|
|
5
|
+
* Why: on sandboxed MCP-Apps hosts (claude.ai's `claudemcpcontent.com`
|
|
6
|
+
* frames) the console is unreadable from outside and the CSP blocks
|
|
7
|
+
* every network channel — when the delivery ladder (WS → SSE → polling
|
|
8
|
+
* → bridge-pull) misbehaves there, the failure is invisible by
|
|
9
|
+
* construction. This sink batches the ladder's own events and flushes
|
|
10
|
+
* them over the host's `tools/call` postMessage bridge, the one
|
|
11
|
+
* carrier a CSP jail cannot block.
|
|
12
|
+
*
|
|
13
|
+
* What flows through it:
|
|
14
|
+
* - `record()` calls from the boot sequence (path decisions, status
|
|
15
|
+
* transitions, doorbell rings);
|
|
16
|
+
* - the live-channel's OWN diagnostics via {@link channelLogger} —
|
|
17
|
+
* a `ChannelLogger` facade that forwards every
|
|
18
|
+
* `channel_failover_swap` / `channel_polling_*` / SSE event the
|
|
19
|
+
* transports already emit. Tapping the logger means ZERO new
|
|
20
|
+
* instrumentation inside `@ggui-ai/live-channel`.
|
|
21
|
+
*
|
|
22
|
+
* Posture: bounded, fire-and-forget, diagnostics-only.
|
|
23
|
+
* - Buffer caps at the protocol's per-batch limit; overflow drops
|
|
24
|
+
* the OLDEST entries (the tail of a failure story beats its
|
|
25
|
+
* preamble).
|
|
26
|
+
* - First flush is delayed (~4s) so one batch carries the whole
|
|
27
|
+
* boot+ladder story; later flushes throttle.
|
|
28
|
+
* - A hard per-session flush cap bounds host load; flush failures
|
|
29
|
+
* are swallowed (a diagnostics channel must never cause the
|
|
30
|
+
* symptoms it reports).
|
|
31
|
+
*/
|
|
32
|
+
import type { ChannelLogger } from '@ggui-ai/live-channel';
|
|
33
|
+
/** Narrow slice of `App.callServerTool` the sink needs. */
|
|
34
|
+
export type TelemetryCallTool = (args: {
|
|
35
|
+
name: string;
|
|
36
|
+
arguments: Record<string, unknown>;
|
|
37
|
+
}) => Promise<unknown>;
|
|
38
|
+
export interface TelemetrySink {
|
|
39
|
+
/** Append one event; schedules a (throttled) flush. */
|
|
40
|
+
record(kind: string, detail?: string): void;
|
|
41
|
+
/**
|
|
42
|
+
* `ChannelLogger` facade for the live-channel bind — every event the
|
|
43
|
+
* transports emit lands in the buffer verbatim (event name = kind,
|
|
44
|
+
* fields JSON = detail).
|
|
45
|
+
*/
|
|
46
|
+
readonly channelLogger: ChannelLogger;
|
|
47
|
+
/** Cancel timers; buffered-but-unflushed events are dropped. */
|
|
48
|
+
dispose(): void;
|
|
49
|
+
}
|
|
50
|
+
export declare function createTelemetrySink(opts: {
|
|
51
|
+
readonly sessionId: string;
|
|
52
|
+
readonly callTool: TelemetryCallTool;
|
|
53
|
+
}): TelemetrySink;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { RUNTIME_TELEMETRY_MAX_EVENTS } from '@ggui-ai/protocol';
|
|
2
|
+
const FIRST_FLUSH_DELAY_MS = 4_000;
|
|
3
|
+
const FLUSH_THROTTLE_MS = 8_000;
|
|
4
|
+
const MAX_FLUSHES_PER_SESSION = 12;
|
|
5
|
+
const MAX_DETAIL_CHARS = 500;
|
|
6
|
+
export function createTelemetrySink(opts) {
|
|
7
|
+
const bootAt = Date.now();
|
|
8
|
+
const buffer = [];
|
|
9
|
+
let flushes = 0;
|
|
10
|
+
let timer = null;
|
|
11
|
+
let disposed = false;
|
|
12
|
+
let firstFlushDone = false;
|
|
13
|
+
const flush = () => {
|
|
14
|
+
timer = null;
|
|
15
|
+
if (disposed || buffer.length === 0)
|
|
16
|
+
return;
|
|
17
|
+
if (flushes >= MAX_FLUSHES_PER_SESSION)
|
|
18
|
+
return;
|
|
19
|
+
flushes += 1;
|
|
20
|
+
firstFlushDone = true;
|
|
21
|
+
const events = buffer.splice(0, buffer.length);
|
|
22
|
+
void opts
|
|
23
|
+
.callTool({
|
|
24
|
+
name: 'ggui_runtime_telemetry',
|
|
25
|
+
arguments: { sessionId: opts.sessionId, events },
|
|
26
|
+
})
|
|
27
|
+
.catch(() => {
|
|
28
|
+
// Swallowed by design — the diagnostics channel must never
|
|
29
|
+
// cause the failures it exists to report.
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
const scheduleFlush = () => {
|
|
33
|
+
if (disposed || timer !== null)
|
|
34
|
+
return;
|
|
35
|
+
if (flushes >= MAX_FLUSHES_PER_SESSION)
|
|
36
|
+
return;
|
|
37
|
+
timer = setTimeout(flush, firstFlushDone ? FLUSH_THROTTLE_MS : FIRST_FLUSH_DELAY_MS);
|
|
38
|
+
};
|
|
39
|
+
const record = (kind, detail) => {
|
|
40
|
+
if (disposed)
|
|
41
|
+
return;
|
|
42
|
+
if (buffer.length >= RUNTIME_TELEMETRY_MAX_EVENTS)
|
|
43
|
+
buffer.shift();
|
|
44
|
+
buffer.push({
|
|
45
|
+
at: Math.max(0, Date.now() - bootAt),
|
|
46
|
+
kind: kind.slice(0, 64),
|
|
47
|
+
...(detail !== undefined ? { detail: detail.slice(0, MAX_DETAIL_CHARS) } : {}),
|
|
48
|
+
});
|
|
49
|
+
scheduleFlush();
|
|
50
|
+
};
|
|
51
|
+
const fieldsToDetail = (fields) => {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.stringify(fields).slice(0, MAX_DETAIL_CHARS);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return '[unserializable]';
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
record,
|
|
61
|
+
channelLogger: {
|
|
62
|
+
info: (event, fields) => record(event, fieldsToDetail(fields)),
|
|
63
|
+
warn: (event, fields) => record(event, fieldsToDetail(fields)),
|
|
64
|
+
debug: (event, fields) => record(event, fieldsToDetail(fields)),
|
|
65
|
+
},
|
|
66
|
+
dispose() {
|
|
67
|
+
disposed = true;
|
|
68
|
+
if (timer !== null) {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
timer = null;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|