@ggui-ai/iframe-runtime 0.7.0 → 0.9.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/events-polling.d.ts +123 -1
- package/dist/events-polling.js +181 -53
- package/dist/iframe-runtime.js +74 -62
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/mcp-app-iframe-host.d.ts +1 -1
- package/dist/meta-parse.d.ts +3 -3
- package/dist/meta-parse.js +26 -10
- 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 +1 -1
- package/dist/react-renderer.js +110 -30
- package/dist/registry-subscribe.d.ts +42 -3
- package/dist/registry-subscribe.js +33 -10
- package/dist/render-item.js +1 -1
- package/dist/runtime-telemetry.d.ts +53 -0
- package/dist/runtime-telemetry.js +74 -0
- package/dist/runtime.d.ts +45 -5
- package/dist/runtime.js +412 -93
- 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/package.json +7 -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
|
@@ -11,9 +11,9 @@ import type { McpAppAiGguiMetaParseResult } from './types.js';
|
|
|
11
11
|
* direct-call callers like `parseMetaFromGlobal` / tests; the
|
|
12
12
|
* envelope-driven extractors already enforce this in the protocol-
|
|
13
13
|
* level parser).
|
|
14
|
-
* - At least one of `{live mode (wsUrl+wsToken), codeUrl,
|
|
15
|
-
* be present. Without any mode discriminator the iframe
|
|
16
|
-
* to mount.
|
|
14
|
+
* - At least one of `{live mode (wsUrl+wsToken), codeUrl, codeB64,
|
|
15
|
+
* kind}` MUST be present. Without any mode discriminator the iframe
|
|
16
|
+
* has nothing to mount.
|
|
17
17
|
*/
|
|
18
18
|
export declare function validateMeta(meta: McpAppAiGguiRenderMeta): McpAppAiGguiMetaParseResult;
|
|
19
19
|
/**
|
package/dist/meta-parse.js
CHANGED
|
@@ -40,9 +40,10 @@
|
|
|
40
40
|
*
|
|
41
41
|
* - **live** — slice has `wsUrl` + `wsToken`; runtime opens
|
|
42
42
|
* live-channel WS and renders agent-driven frames.
|
|
43
|
-
* - **static-component** — slice has `codeUrl
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* - **static-component** — slice has `codeUrl` (runtime fetches the
|
|
44
|
+
* content-addressable URL) and/or `codeB64` (runtime decodes the
|
|
45
|
+
* inline bytes, no fetch); mounts the React component, never
|
|
46
|
+
* opens WS.
|
|
46
47
|
* - **system-card** — slice has `kind`; runtime maps to a
|
|
47
48
|
* built-in component via the system-card registry.
|
|
48
49
|
*
|
|
@@ -177,9 +178,9 @@ function projectPublicEnv(raw) {
|
|
|
177
178
|
*
|
|
178
179
|
* Invariants enforced here that the combiner doesn't:
|
|
179
180
|
* - `expiresAt` (when present) MUST parse to a valid timestamp. If
|
|
180
|
-
* in the past AND the slice carries static content (codeUrl /
|
|
181
|
-
* DEGRADE to static-only (drop `wsUrl` /
|
|
182
|
-
* otherwise return EXPIRED_BOOTSTRAP.
|
|
181
|
+
* in the past AND the slice carries static content (codeUrl /
|
|
182
|
+
* codeB64 / kind), DEGRADE to static-only (drop `wsUrl` /
|
|
183
|
+
* `wsToken` / `expiresAt`); otherwise return EXPIRED_BOOTSTRAP.
|
|
183
184
|
* - `gadgets` / `publicEnv` / `permissionsPolicy` /
|
|
184
185
|
* `streamWebSocketLocalTools` / `contextSlots` get defensive
|
|
185
186
|
* entry-level validation; malformed → defaulted.
|
|
@@ -238,6 +239,12 @@ function projectMeta(meta, hasStaticContent) {
|
|
|
238
239
|
...(meta.pollingUrl !== undefined
|
|
239
240
|
? { pollingUrl: meta.pollingUrl }
|
|
240
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 } : {}),
|
|
241
248
|
...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
|
|
242
249
|
...(themeMode !== undefined ? { themeMode } : {}),
|
|
243
250
|
// Per-app theme overlay carried through verbatim. Already validated
|
|
@@ -250,6 +257,13 @@ function projectMeta(meta, hasStaticContent) {
|
|
|
250
257
|
: {}),
|
|
251
258
|
...(permissionsPolicy !== undefined ? { permissionsPolicy } : {}),
|
|
252
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 } : {}),
|
|
253
267
|
...(meta.propsJson !== undefined ? { propsJson: meta.propsJson } : {}),
|
|
254
268
|
...(contextSlots !== undefined ? { contextSlots } : {}),
|
|
255
269
|
...(meta.contractHash !== undefined && meta.validatorsUrl !== undefined
|
|
@@ -260,6 +274,7 @@ function projectMeta(meta, hasStaticContent) {
|
|
|
260
274
|
: {}),
|
|
261
275
|
...(meta.codeUrl !== undefined ? { codeUrl: meta.codeUrl } : {}),
|
|
262
276
|
...(meta.codeHash !== undefined ? { codeHash: meta.codeHash } : {}),
|
|
277
|
+
...(meta.codeB64 !== undefined ? { codeB64: meta.codeB64 } : {}),
|
|
263
278
|
...(meta.kind !== undefined ? { kind: meta.kind } : {}),
|
|
264
279
|
};
|
|
265
280
|
return { ok: true, meta: projected };
|
|
@@ -275,9 +290,9 @@ function projectMeta(meta, hasStaticContent) {
|
|
|
275
290
|
* direct-call callers like `parseMetaFromGlobal` / tests; the
|
|
276
291
|
* envelope-driven extractors already enforce this in the protocol-
|
|
277
292
|
* level parser).
|
|
278
|
-
* - At least one of `{live mode (wsUrl+wsToken), codeUrl,
|
|
279
|
-
* be present. Without any mode discriminator the iframe
|
|
280
|
-
* to mount.
|
|
293
|
+
* - At least one of `{live mode (wsUrl+wsToken), codeUrl, codeB64,
|
|
294
|
+
* kind}` MUST be present. Without any mode discriminator the iframe
|
|
295
|
+
* has nothing to mount.
|
|
281
296
|
*/
|
|
282
297
|
export function validateMeta(meta) {
|
|
283
298
|
// `runtimeUrl` is a hard wire-required field on every render slice
|
|
@@ -292,8 +307,9 @@ export function validateMeta(meta) {
|
|
|
292
307
|
}
|
|
293
308
|
const hasLive = isNonEmptyString(meta.wsUrl) && isNonEmptyString(meta.wsToken);
|
|
294
309
|
const hasCodeUrl = isNonEmptyString(meta.codeUrl);
|
|
310
|
+
const hasCodeB64 = isNonEmptyString(meta.codeB64);
|
|
295
311
|
const hasKind = isNonEmptyString(meta.kind);
|
|
296
|
-
const hasStaticContent = hasCodeUrl || hasKind;
|
|
312
|
+
const hasStaticContent = hasCodeUrl || hasCodeB64 || hasKind;
|
|
297
313
|
if (!hasLive && !hasStaticContent) {
|
|
298
314
|
return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
|
|
299
315
|
}
|
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
|
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
|
|
@@ -32,7 +32,18 @@
|
|
|
32
32
|
import React, { Component, createElement, Fragment, } from 'react';
|
|
33
33
|
import { createRoot } from 'react-dom/client';
|
|
34
34
|
import { stripMarkers, rewriteImports, getScopedThemeCss, getScopedCssTokens, getThemeCss, getCssTokens, } from '@ggui-ai/design/rendering';
|
|
35
|
-
import { hoistImports, loadModule } from '@ggui-ai/design/module-loader';
|
|
35
|
+
import { hoistImports, loadModule, loadModuleInline, probeUrlModuleLoad, } from '@ggui-ai/design/module-loader';
|
|
36
|
+
/**
|
|
37
|
+
* Document-lifetime verdict: URL-scheme module loading (`blob:` main
|
|
38
|
+
* module + `data:` import shims) is blocked by the host CSP. Set only
|
|
39
|
+
* when the inline path succeeded where the URL path failed AND the
|
|
40
|
+
* known-good `probeUrlModuleLoad()` module ALSO fails — an arbitrary
|
|
41
|
+
* module's failure is not CSP evidence (an import-shim allowlist miss
|
|
42
|
+
* rejects blob-instantiation of that one module while URL loading is
|
|
43
|
+
* healthy, and latching on it would force every later evaluation in
|
|
44
|
+
* the document onto the inline path's narrower semantics).
|
|
45
|
+
*/
|
|
46
|
+
let urlModuleLoadBlocked = false;
|
|
36
47
|
// =============================================================================
|
|
37
48
|
// Scope class helpers — port of RCR's `makeScopeClass` + counter.
|
|
38
49
|
// =============================================================================
|
|
@@ -80,12 +91,17 @@ class RcrErrorBoundary extends Component {
|
|
|
80
91
|
clearTimeout(this.retryTimer);
|
|
81
92
|
}
|
|
82
93
|
render() {
|
|
83
|
-
const { error, autoRetrying
|
|
94
|
+
const { error, autoRetrying } = this.state;
|
|
84
95
|
if (error === null)
|
|
85
96
|
return this.props.children;
|
|
86
97
|
// The two fallback UIs match the host-SDK error boundary
|
|
87
98
|
// verbatim (same inline styles so operator-visible DOM is
|
|
88
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).
|
|
89
105
|
const FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
90
106
|
if (autoRetrying) {
|
|
91
107
|
return createElement('div', {
|
|
@@ -98,14 +114,15 @@ class RcrErrorBoundary extends Component {
|
|
|
98
114
|
gap: 8,
|
|
99
115
|
fontFamily: FONT,
|
|
100
116
|
fontSize: 13,
|
|
101
|
-
color: '
|
|
117
|
+
color: 'var(--ggui-color-onSurface, inherit)',
|
|
118
|
+
opacity: 0.6,
|
|
102
119
|
},
|
|
103
120
|
}, createElement('div', {
|
|
104
121
|
style: {
|
|
105
122
|
width: 16,
|
|
106
123
|
height: 16,
|
|
107
|
-
border: '2px solid rgba(
|
|
108
|
-
borderTopColor: '
|
|
124
|
+
border: '2px solid rgba(128,128,128,0.25)',
|
|
125
|
+
borderTopColor: 'currentColor',
|
|
109
126
|
borderRadius: '50%',
|
|
110
127
|
animation: 'ggui-err-spin 0.6s linear infinite',
|
|
111
128
|
},
|
|
@@ -122,6 +139,7 @@ class RcrErrorBoundary extends Component {
|
|
|
122
139
|
gap: 12,
|
|
123
140
|
textAlign: 'center',
|
|
124
141
|
fontFamily: FONT,
|
|
142
|
+
color: 'var(--ggui-color-onSurface, inherit)',
|
|
125
143
|
},
|
|
126
144
|
}, createElement('div', {
|
|
127
145
|
style: {
|
|
@@ -129,27 +147,48 @@ class RcrErrorBoundary extends Component {
|
|
|
129
147
|
height: 48,
|
|
130
148
|
borderRadius: 14,
|
|
131
149
|
background: 'rgba(239, 68, 68, 0.1)',
|
|
150
|
+
color: '#ef4444',
|
|
132
151
|
display: 'flex',
|
|
133
152
|
alignItems: 'center',
|
|
134
153
|
justifyContent: 'center',
|
|
135
154
|
fontSize: 22,
|
|
136
155
|
},
|
|
137
|
-
}, '!'), 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', {
|
|
138
157
|
onClick: () => this.setState({ error: null, catchCount: 0 }),
|
|
139
158
|
style: {
|
|
140
159
|
marginTop: 4,
|
|
141
160
|
padding: '8px 20px',
|
|
142
161
|
borderRadius: 8,
|
|
143
|
-
border: '1px solid
|
|
144
|
-
background: '
|
|
145
|
-
color: '
|
|
162
|
+
border: '1px solid currentColor',
|
|
163
|
+
background: 'transparent',
|
|
164
|
+
color: 'inherit',
|
|
165
|
+
opacity: 0.75,
|
|
146
166
|
fontSize: 13,
|
|
147
167
|
fontWeight: 500,
|
|
148
168
|
cursor: 'pointer',
|
|
149
169
|
},
|
|
150
|
-
}, 'Retry'),
|
|
151
|
-
|
|
152
|
-
|
|
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)));
|
|
153
192
|
}
|
|
154
193
|
}
|
|
155
194
|
/**
|
|
@@ -177,25 +216,66 @@ export async function mountReactRoot(container, opts) {
|
|
|
177
216
|
}
|
|
178
217
|
// 1. Strip metadata markers (__GGUI_META__ / __GGUI_STREAM_SPEC__).
|
|
179
218
|
const cleaned = stripMarkers(resolved);
|
|
180
|
-
// 2. Hoist imports above var declarations.
|
|
219
|
+
// 2. Hoist imports above var declarations. Load-bearing for BOTH
|
|
220
|
+
// execution paths: the inline transform places its bindings at
|
|
221
|
+
// the import statement's position, so a pre-import statement
|
|
222
|
+
// referencing an imported name needs the hoist exactly as the
|
|
223
|
+
// real module semantics do.
|
|
181
224
|
const hoisted = hoistImports(cleaned);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
225
|
+
const pick = (mod) => {
|
|
226
|
+
const Comp = (mod.default ??
|
|
227
|
+
Object.values(mod).find((v) => typeof v === 'function'));
|
|
228
|
+
if (Comp === undefined) {
|
|
229
|
+
throw new Error('Module does not export a default component');
|
|
230
|
+
}
|
|
231
|
+
return Comp;
|
|
232
|
+
};
|
|
233
|
+
const evaluateInline = () => pick(loadModuleInline(hoisted, {
|
|
234
|
+
...(currentOpts.gadgetPackages !== undefined
|
|
235
|
+
? { gadgetPackages: currentOpts.gadgetPackages }
|
|
236
|
+
: {}),
|
|
237
|
+
}));
|
|
238
|
+
// Once URL-scheme module loading has been proven blocked in this
|
|
239
|
+
// document, go straight to inline execution — the CSP verdict
|
|
240
|
+
// cannot change within the document's lifetime.
|
|
241
|
+
if (urlModuleLoadBlocked) {
|
|
242
|
+
return evaluateInline();
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
// 3. Rewrite bare specifiers to data-url shims reading from
|
|
246
|
+
// `globalThis.__ggui__`. The __ggui__ registry is installed
|
|
247
|
+
// by `runtime.ts::bootSequence` BEFORE any mountReactRoot call
|
|
248
|
+
// — see globals.ts docstring on TOCTOU ordering.
|
|
249
|
+
const rewritten = rewriteImports(hoisted, {
|
|
250
|
+
mode: 'data-url',
|
|
251
|
+
gadgetPackages: currentOpts.gadgetPackages,
|
|
252
|
+
});
|
|
253
|
+
// 4. Dynamically import the module.
|
|
254
|
+
const mod = await loadModule(rewritten);
|
|
255
|
+
// 5. Extract default export (or first function export).
|
|
256
|
+
return pick(mod);
|
|
257
|
+
}
|
|
258
|
+
catch (urlError) {
|
|
259
|
+
// A host CSP granting only 'unsafe-inline' rejects the blob:/
|
|
260
|
+
// data: imports above. Attempt the inline classic-script path;
|
|
261
|
+
// a buggy component fails on both paths, and the original error
|
|
262
|
+
// is the honest one to surface.
|
|
263
|
+
try {
|
|
264
|
+
const Comp = evaluateInline();
|
|
265
|
+
// Latch the inline path for the rest of the document ONLY on
|
|
266
|
+
// proven CSP evidence: the known-good probe module must also
|
|
267
|
+
// fail. A module-specific blob failure (import-shim allowlist
|
|
268
|
+
// drift) leaves the latch off so the next evaluation gets real
|
|
269
|
+
// module semantics again.
|
|
270
|
+
if (!(await probeUrlModuleLoad())) {
|
|
271
|
+
urlModuleLoadBlocked = true;
|
|
272
|
+
}
|
|
273
|
+
return Comp;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
throw urlError;
|
|
277
|
+
}
|
|
197
278
|
}
|
|
198
|
-
return Comp;
|
|
199
279
|
}
|
|
200
280
|
function renderTree(opts) {
|
|
201
281
|
const themeCss = opts.themeId
|
|
@@ -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.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
|
|
@@ -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
|
+
}
|