@ggui-ai/iframe-runtime 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +33 -0
  3. package/dist/bootstrap.d.ts +72 -0
  4. package/dist/bootstrap.js +500 -0
  5. package/dist/canvas/animator/animator-host.d.ts +63 -0
  6. package/dist/canvas/animator/animator-host.js +105 -0
  7. package/dist/canvas/animator/animator-pill.d.ts +42 -0
  8. package/dist/canvas/animator/animator-pill.js +119 -0
  9. package/dist/canvas/animator/state-machine.d.ts +106 -0
  10. package/dist/canvas/animator/state-machine.js +150 -0
  11. package/dist/canvas/canvas-shell.d.ts +77 -0
  12. package/dist/canvas/canvas-shell.js +117 -0
  13. package/dist/canvas/display-mode-policy.d.ts +104 -0
  14. package/dist/canvas/display-mode-policy.js +98 -0
  15. package/dist/canvas/mount.d.ts +150 -0
  16. package/dist/canvas/mount.js +202 -0
  17. package/dist/canvas/nav-stack.d.ts +133 -0
  18. package/dist/canvas/nav-stack.js +172 -0
  19. package/dist/channel-transport.d.ts +182 -0
  20. package/dist/channel-transport.js +344 -0
  21. package/dist/channels/channel-payload.d.ts +20 -0
  22. package/dist/channels/channel-payload.js +34 -0
  23. package/dist/channels/data.d.ts +48 -0
  24. package/dist/channels/data.js +103 -0
  25. package/dist/channels/drain-ack.d.ts +13 -0
  26. package/dist/channels/drain-ack.js +15 -0
  27. package/dist/channels/feedback.d.ts +14 -0
  28. package/dist/channels/feedback.js +19 -0
  29. package/dist/channels/index.d.ts +7 -0
  30. package/dist/channels/index.js +7 -0
  31. package/dist/channels/props-update.d.ts +53 -0
  32. package/dist/channels/props-update.js +130 -0
  33. package/dist/channels/push.d.ts +47 -0
  34. package/dist/channels/push.js +54 -0
  35. package/dist/channels/system.d.ts +26 -0
  36. package/dist/channels/system.js +44 -0
  37. package/dist/compiled-validators.d.ts +40 -0
  38. package/dist/compiled-validators.js +74 -0
  39. package/dist/context-observer.d.ts +205 -0
  40. package/dist/context-observer.js +312 -0
  41. package/dist/gadget-loader.d.ts +118 -0
  42. package/dist/gadget-loader.js +136 -0
  43. package/dist/globals.d.ts +234 -0
  44. package/dist/globals.js +101 -0
  45. package/dist/host-context-emitter.d.ts +91 -0
  46. package/dist/host-context-emitter.js +213 -0
  47. package/dist/iframe-runtime.js +261 -0
  48. package/dist/index.d.ts +38 -0
  49. package/dist/index.js +4 -0
  50. package/dist/lifecycle.d.ts +66 -0
  51. package/dist/lifecycle.js +49 -0
  52. package/dist/mcp-app-iframe-host.d.ts +69 -0
  53. package/dist/mcp-app-iframe-host.js +214 -0
  54. package/dist/observability.d.ts +225 -0
  55. package/dist/observability.js +28 -0
  56. package/dist/protocol-error.d.ts +199 -0
  57. package/dist/protocol-error.js +116 -0
  58. package/dist/provisional-renderer.d.ts +62 -0
  59. package/dist/provisional-renderer.js +323 -0
  60. package/dist/react-renderer.d.ts +95 -0
  61. package/dist/react-renderer.js +343 -0
  62. package/dist/registry-subscribe.d.ts +102 -0
  63. package/dist/registry-subscribe.js +319 -0
  64. package/dist/runtime.d.ts +531 -0
  65. package/dist/runtime.js +2623 -0
  66. package/dist/server.d.ts +36 -0
  67. package/dist/server.js +72 -0
  68. package/dist/stack-item-renderer.d.ts +103 -0
  69. package/dist/stack-item-renderer.js +270 -0
  70. package/dist/stack.d.ts +90 -0
  71. package/dist/stack.js +118 -0
  72. package/dist/status-dom.d.ts +72 -0
  73. package/dist/status-dom.js +51 -0
  74. package/dist/system-cards/NoCredentialsCard.d.ts +33 -0
  75. package/dist/system-cards/NoCredentialsCard.js +189 -0
  76. package/dist/system-cards/ProtocolProbeCard.d.ts +64 -0
  77. package/dist/system-cards/ProtocolProbeCard.js +463 -0
  78. package/dist/system-cards/QrCode.d.ts +46 -0
  79. package/dist/system-cards/QrCode.js +55 -0
  80. package/dist/system-cards/host-detect.d.ts +31 -0
  81. package/dist/system-cards/host-detect.js +108 -0
  82. package/dist/system-cards/host-intents.d.ts +85 -0
  83. package/dist/system-cards/host-intents.js +170 -0
  84. package/dist/system-cards/index.d.ts +70 -0
  85. package/dist/system-cards/index.js +134 -0
  86. package/dist/types.d.ts +69 -0
  87. package/dist/types.js +3 -0
  88. package/dist/validation.d.ts +83 -0
  89. package/dist/validation.js +106 -0
  90. package/dist/wire-config.d.ts +214 -0
  91. package/dist/wire-config.js +290 -0
  92. package/package.json +81 -0
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `@ggui-ai/iframe-runtime` package entry — type-export surface for
3
+ * non-iframe consumers (host apps, the `<McpAppIframe>` wrapper,
4
+ * tests). The runtime artifact is `dist/iframe-runtime.js` (built by
5
+ * `esbuild.config.mjs` from `src/runtime.ts`); this index does NOT
6
+ * re-export the runtime side-effects.
7
+ *
8
+ * Surface:
9
+ * - `BootstrapParseFailureReason` — closed union of bootstrap-parse
10
+ * failure reasons.
11
+ * - `BootstrapParseResult` — discriminated union returned by
12
+ * `parseBootstrap()`.
13
+ * - `parseBootstrap` — the parser itself (consumers wanting to
14
+ * validate a bootstrap before spawning the iframe).
15
+ * - `RendererBootFailureReason` / `RendererBootFailedMessage` — the
16
+ * postMessage envelope shape parents observe on boot failure.
17
+ * - `ProtocolError` — canonical typed union for every failure
18
+ * the renderer classifies outward, wired to the postMessage
19
+ * envelope and the `_ggui:contract-error` WS channel.
20
+ * - `BootstrapFailureReason` — extensibly-closed union of every
21
+ * reason the 'bootstrap' variant can carry. Consolidates
22
+ * parse-time and post-parse codes in one place.
23
+ * - `ProtocolErrorEmitter` — function signature for the
24
+ * caller-side sink.
25
+ *
26
+ * Deliberately NOT exported: `RendererWebSocketManager`, `subscribe`,
27
+ * `bootSequence`, `StackModel`. Those are the renderer's INTERNAL
28
+ * runtime contract and should not become a load-bearing public API.
29
+ */
30
+ export type { BootstrapParseFailureReason, BootstrapParseResult, } from './types.js';
31
+ export { parseBootstrap, parseBootstrapFromUiInitialize, parseBootstrapFromGlobal, parseBootstrapFromToolResult, validateBootstrapMeta, } from './bootstrap.js';
32
+ export type { RendererBootFailureReason, RendererBootFailedMessage, SelfContainedBootstrap, } from './runtime.js';
33
+ export type { ProtocolError, ProtocolErrorEmitter, BootstrapFailureReason, } from './protocol-error.js';
34
+ export { defaultProtocolErrorEmitter, fromBootstrapFailure, fromClientContractViolation, fromContractErrorPayload, fromTransportFailure, fromAuthFailure, fromUpgradeRequired, fromUnknown, } from './protocol-error.js';
35
+ export type { ObservabilityEvent, ObservabilityEmitter, ObservabilityMessage, WiredToolInvokedEvent, ContractErrorEmittedEvent, SchemaVersionMismatchEvent, SubscribeFailedEvent, AuthRequiredEvent, UnknownObservabilityEvent, } from './observability.js';
36
+ export { postObservabilityToParent } from './observability.js';
37
+ export type { LifecycleEmitter } from './lifecycle.js';
38
+ export { postLifecycleToParent, makeLifecycleEvent } from './lifecycle.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { parseBootstrap, parseBootstrapFromUiInitialize, parseBootstrapFromGlobal, parseBootstrapFromToolResult, validateBootstrapMeta, } from './bootstrap.js';
2
+ export { defaultProtocolErrorEmitter, fromBootstrapFailure, fromClientContractViolation, fromContractErrorPayload, fromTransportFailure, fromAuthFailure, fromUpgradeRequired, fromUnknown, } from './protocol-error.js';
3
+ export { postObservabilityToParent } from './observability.js';
4
+ export { postLifecycleToParent, makeLifecycleEvent } from './lifecycle.js';
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Renderer-side emission of MCP App lifecycle events — the postMessage
3
+ * envelope the iframe posts to its parent on every mount-state
4
+ * transition. Mirrors the `<McpAppIframe>` host's outer-DOM mirror
5
+ * (`data-ggui-mcp-app-iframe-lifecycle="<state>"`); together they form
6
+ * a first-class wire surface so observers (E2E tests, accessibility
7
+ * scanners, third-party hosts, console inspectors) can read mount
8
+ * state without reaching into iframe-internal DOM.
9
+ *
10
+ * Lives here (not in `runtime.ts`) so spec-level tests can import the
11
+ * helper without dragging in the runtime's autostart side effects.
12
+ *
13
+ * **Producer obligations** (locked by
14
+ * {@link import('@ggui-ai/protocol/integrations/mcp-apps').McpAppLifecycleMessage}):
15
+ * - `mounting` MUST fire before bundle eval / first WS attempt.
16
+ * - Exactly one terminal state (`code-ready` or `error`) MUST follow
17
+ * for every successful boot attempt.
18
+ * - `disconnected` MAY follow `code-ready` on WS close; a subsequent
19
+ * `code-ready` MAY follow on successful reconnect (not required).
20
+ *
21
+ * @public
22
+ */
23
+ import type { McpAppLifecycleEvent, McpAppLifecycleState } from '@ggui-ai/protocol/integrations/mcp-apps';
24
+ /**
25
+ * Caller sink for lifecycle events. Mirrors the
26
+ * {@link import('./observability.js').ObservabilityEmitter} posture —
27
+ * tests inject a recorder; production binds the postMessage-to-parent
28
+ * default {@link postLifecycleToParent}.
29
+ *
30
+ * Handlers MUST NOT throw — lifecycle events are fire-and-forget. A
31
+ * throwing handler would mask the underlying state transition (the
32
+ * renderer has already committed to the new state by the time emit
33
+ * runs).
34
+ *
35
+ * @public
36
+ */
37
+ export type LifecycleEmitter = (event: McpAppLifecycleEvent) => void;
38
+ /**
39
+ * Default emitter — posts a {@link McpAppLifecycleMessage} envelope to
40
+ * `window.parent`. Mirrors `postObservabilityToParent` /
41
+ * `postBootFailure` posture: swallows postMessage failure when the
42
+ * parent is unreachable so a detached parent doesn't crash the iframe.
43
+ *
44
+ * Non-browser import graphs (vitest+jsdom before a window mock lands)
45
+ * have no `window.parent`; the early return guards that case so tests
46
+ * importing this helper can do so before staging their `window` mocks.
47
+ *
48
+ * @public
49
+ */
50
+ export declare function postLifecycleToParent(event: McpAppLifecycleEvent): void;
51
+ /**
52
+ * Build a lifecycle event payload. Centralises the `state` →
53
+ * `McpAppLifecycleEvent` projection so call sites in
54
+ * `runtime.ts` don't repeat the `{state, ...optional}` shape and so
55
+ * the additive-extension semantic of `stackItemId` / `error` is
56
+ * single-sourced.
57
+ *
58
+ * @public
59
+ */
60
+ export declare function makeLifecycleEvent(state: McpAppLifecycleState, options?: {
61
+ readonly stackItemId?: string;
62
+ readonly error?: {
63
+ readonly code: string;
64
+ readonly message: string;
65
+ };
66
+ }): McpAppLifecycleEvent;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Default emitter — posts a {@link McpAppLifecycleMessage} envelope to
3
+ * `window.parent`. Mirrors `postObservabilityToParent` /
4
+ * `postBootFailure` posture: swallows postMessage failure when the
5
+ * parent is unreachable so a detached parent doesn't crash the iframe.
6
+ *
7
+ * Non-browser import graphs (vitest+jsdom before a window mock lands)
8
+ * have no `window.parent`; the early return guards that case so tests
9
+ * importing this helper can do so before staging their `window` mocks.
10
+ *
11
+ * @public
12
+ */
13
+ export function postLifecycleToParent(event) {
14
+ if (typeof window === 'undefined' || window.parent === null)
15
+ return;
16
+ const message = { type: 'ggui:lifecycle', event };
17
+ try {
18
+ window.parent.postMessage(message, '*');
19
+ }
20
+ catch {
21
+ // Parent unreachable (detached window) — best-effort fire-and-
22
+ // forget. Matches postObservabilityToParent's swallow posture.
23
+ }
24
+ }
25
+ /**
26
+ * Build a lifecycle event payload. Centralises the `state` →
27
+ * `McpAppLifecycleEvent` projection so call sites in
28
+ * `runtime.ts` don't repeat the `{state, ...optional}` shape and so
29
+ * the additive-extension semantic of `stackItemId` / `error` is
30
+ * single-sourced.
31
+ *
32
+ * @public
33
+ */
34
+ export function makeLifecycleEvent(state, options) {
35
+ // Avoid emitting `undefined` keys — the protocol's structural lock
36
+ // (mcp-apps.test.ts "shape lock") asserts a populated event has
37
+ // exactly state + stackItemId + error keys, not an Object.keys-
38
+ // observable `undefined` field.
39
+ if (options === undefined)
40
+ return { state };
41
+ const { stackItemId, error } = options;
42
+ return {
43
+ state,
44
+ ...(typeof stackItemId === 'string' && stackItemId.length > 0
45
+ ? { stackItemId }
46
+ : {}),
47
+ ...(error !== undefined ? { error } : {}),
48
+ };
49
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Vanilla-TS port of
3
+ * `@ggui-ai/react::components/McpAppsStackItemRenderer.tsx`. Used
4
+ * when a ggui stack item's `kind === 'mcpApps'` — the renderer
5
+ * mounts a nested iframe that embeds a foreign MCP App.
6
+ *
7
+ * The iframe plays the MCP Apps HOST role for the embedded content:
8
+ *
9
+ * - `ui/initialize` → respond with minimal context (theme +
10
+ * containerDimensions + locale).
11
+ * - `tools/call` → forward to the ggui server's
12
+ * `/mcp-apps/tools-call` endpoint. The server enforces source
13
+ * connector scoping + `_meta.ui.visibility: ['app']`.
14
+ * - any other method → respond with `method_not_supported`.
15
+ *
16
+ * Adapter-boundary rule (ENFORCED):
17
+ * - MCP Apps lifecycle messages from the embedded iframe do NOT
18
+ * mutate the outer ggui session state / contracts — the host
19
+ * never forwards them to ggui-server as session-level events.
20
+ * - `ui/initialize` intentionally DOES NOT forward outer ggui
21
+ * session state (stack, actionSpec, streamSpec) to the iframe.
22
+ *
23
+ * On detach, a host-initiated `ui/resource-teardown` notification
24
+ * is posted to the iframe BEFORE the element leaves the DOM so the
25
+ * embedded app can persist state / release resources cleanly. Post-
26
+ * detach the contentWindow is null in browsers + jsdom; we cache it
27
+ * at mount so the teardown message can still reach the view.
28
+ *
29
+ * Recursive mount safety: a foreign MCP App loaded inside this
30
+ * iframe can in turn render a `ui://ggui/session` resource via its
31
+ * own host implementation. That recursion stays bounded because
32
+ * each nested iframe runs the same adapter-boundary rule — outer
33
+ * ggui state never leaks into the inner renderer's session through
34
+ * this host path. Host-to-host sandboxing is browser-enforced by
35
+ * the iframe's `sandbox="allow-scripts"` attribute.
36
+ */
37
+ import type { McpAppsStackItem } from '@ggui-ai/protocol/integrations/mcp-apps';
38
+ export interface McpAppIframeMountOptions {
39
+ readonly stackItem: McpAppsStackItem;
40
+ /** Session id — threaded into the proxy URL so server-side
41
+ * connector scoping works. */
42
+ readonly sessionId: string;
43
+ /**
44
+ * Base URL of the ggui server (origin). The host appends
45
+ * `/mcp-apps/resource` + `/mcp-apps/tools-call` to this. Should
46
+ * NOT have a trailing slash. Empty string = same-origin.
47
+ */
48
+ readonly serverBaseUrl?: string;
49
+ /** Optional theme override for the `ui/initialize` context. */
50
+ readonly theme?: Readonly<Record<string, string>>;
51
+ /** Optional locale; defaults to `navigator.language` when
52
+ * available. */
53
+ readonly locale?: string;
54
+ }
55
+ export interface McpAppIframeMount {
56
+ /** Tear down — posts `ui/resource-teardown` then removes the iframe. */
57
+ unmount(): void;
58
+ /** The mounted iframe element — exposed for test assertions. */
59
+ readonly element: HTMLIFrameElement;
60
+ }
61
+ /**
62
+ * Mount the MCP Apps iframe into `container`. The container is
63
+ * cleared; the iframe becomes its sole child.
64
+ *
65
+ * The host-bridge listener attaches to `window.message` globally;
66
+ * `source !== iframe.contentWindow` guard ensures we only respond
67
+ * to our own iframe's messages.
68
+ */
69
+ export declare function mountMcpAppIframe(container: HTMLElement, opts: McpAppIframeMountOptions): McpAppIframeMount;
@@ -0,0 +1,214 @@
1
+ /** Default permissive-but-safe theme for iframes that don't override. */
2
+ const DEFAULT_THEME = {
3
+ '--color-primary': '#0284c7',
4
+ '--color-surface': '#ffffff',
5
+ '--color-text': '#111111',
6
+ '--font-family': 'system-ui, -apple-system, sans-serif',
7
+ '--border-radius-md': '8px',
8
+ };
9
+ // =============================================================================
10
+ // Helpers
11
+ // =============================================================================
12
+ function composeResourceUrl(opts) {
13
+ const base = opts.serverBaseUrl.replace(/\/$/, '');
14
+ const qs = new URLSearchParams({
15
+ session: opts.sessionId,
16
+ item: opts.itemId,
17
+ });
18
+ return `${base}/mcp-apps/resource?${qs.toString()}`;
19
+ }
20
+ function composeToolsCallUrl(serverBaseUrl) {
21
+ return `${serverBaseUrl.replace(/\/$/, '')}/mcp-apps/tools-call`;
22
+ }
23
+ function buildSandboxAttr() {
24
+ // MCP Apps hosts grant the minimum sandbox tokens needed. V1 keeps
25
+ // us permissive-enough for scripted + form-submitting content but
26
+ // does NOT grant same-origin (that would let the iframe read the
27
+ // host document). Ports the host-SDK version verbatim.
28
+ return 'allow-scripts allow-forms';
29
+ }
30
+ function buildAllowAttr(stackItem) {
31
+ const perms = stackItem.permissions;
32
+ if (!perms)
33
+ return undefined;
34
+ const parts = [];
35
+ if (perms.camera)
36
+ parts.push("camera 'self'");
37
+ if (perms.microphone)
38
+ parts.push("microphone 'self'");
39
+ if (perms.geolocation)
40
+ parts.push("geolocation 'self'");
41
+ if (perms.clipboardWrite)
42
+ parts.push("clipboard-write 'self'");
43
+ return parts.length > 0 ? parts.join('; ') : undefined;
44
+ }
45
+ // =============================================================================
46
+ // Mount
47
+ // =============================================================================
48
+ /**
49
+ * Mount the MCP Apps iframe into `container`. The container is
50
+ * cleared; the iframe becomes its sole child.
51
+ *
52
+ * The host-bridge listener attaches to `window.message` globally;
53
+ * `source !== iframe.contentWindow` guard ensures we only respond
54
+ * to our own iframe's messages.
55
+ */
56
+ export function mountMcpAppIframe(container, opts) {
57
+ container.replaceChildren();
58
+ const serverBaseUrl = opts.serverBaseUrl ?? '';
59
+ const resourceUrl = composeResourceUrl({
60
+ serverBaseUrl,
61
+ sessionId: opts.sessionId,
62
+ itemId: opts.stackItem.id,
63
+ });
64
+ const toolsCallUrl = composeToolsCallUrl(serverBaseUrl);
65
+ const dims = {
66
+ width: opts.stackItem.containerDimensions?.width,
67
+ height: opts.stackItem.containerDimensions?.height,
68
+ maxWidth: opts.stackItem.containerDimensions?.maxWidth,
69
+ maxHeight: opts.stackItem.containerDimensions?.maxHeight,
70
+ };
71
+ const iframe = container.ownerDocument.createElement('iframe');
72
+ iframe.setAttribute('data-ggui-mcp-apps', 'iframe');
73
+ iframe.setAttribute('data-ggui-stack-item-id', opts.stackItem.id);
74
+ iframe.setAttribute('data-ggui-connector-id', opts.stackItem.source.connectorId);
75
+ iframe.src = resourceUrl;
76
+ iframe.title = opts.stackItem.description ?? 'MCP App';
77
+ iframe.setAttribute('sandbox', buildSandboxAttr());
78
+ const allowAttr = buildAllowAttr(opts.stackItem);
79
+ if (allowAttr !== undefined)
80
+ iframe.setAttribute('allow', allowAttr);
81
+ iframe.style.width = dims.width !== undefined ? `${dims.width}px` : '100%';
82
+ iframe.style.height = dims.height !== undefined ? `${dims.height}px` : '480px';
83
+ iframe.style.maxWidth = dims.maxWidth !== undefined ? `${dims.maxWidth}px` : '100%';
84
+ if (dims.maxHeight !== undefined)
85
+ iframe.style.maxHeight = `${dims.maxHeight}px`;
86
+ iframe.style.border = '1px solid #e5e5e5';
87
+ iframe.style.borderRadius = '8px';
88
+ iframe.style.display = 'block';
89
+ container.appendChild(iframe);
90
+ // Cache contentWindow — on detach, `iframe.contentWindow` is null
91
+ // so the cached handle is the only way `ui/resource-teardown` can
92
+ // still reach the view.
93
+ let cachedIframeWindow = iframe.contentWindow;
94
+ const listener = (ev) => {
95
+ void handleHostBridgeRequest(ev);
96
+ };
97
+ async function handleHostBridgeRequest(ev) {
98
+ // Adapter-boundary guard: only our own iframe's contentWindow is
99
+ // allowed. This matches the ref-guard in
100
+ // McpAppsStackItemRenderer.tsx.
101
+ if (iframe.contentWindow === null || ev.source !== iframe.contentWindow)
102
+ return;
103
+ const req = ev.data;
104
+ if (req === null || typeof req !== 'object')
105
+ return;
106
+ if (req.jsonrpc !== '2.0' || typeof req.method !== 'string')
107
+ return;
108
+ const id = req.id ?? 0;
109
+ let response;
110
+ switch (req.method) {
111
+ case 'ui/initialize': {
112
+ response = {
113
+ jsonrpc: '2.0',
114
+ id,
115
+ result: {
116
+ theme: opts.theme ?? DEFAULT_THEME,
117
+ containerDimensions: dims,
118
+ locale: opts.locale ??
119
+ (typeof navigator !== 'undefined' ? navigator.language : 'en-US'),
120
+ },
121
+ };
122
+ break;
123
+ }
124
+ case 'tools/call': {
125
+ const tool = typeof req.params?.['name'] === 'string' ? req.params['name'] : '';
126
+ const rawArgs = req.params?.['arguments'];
127
+ const args = rawArgs !== null && typeof rawArgs === 'object' && !Array.isArray(rawArgs)
128
+ ? rawArgs
129
+ : {};
130
+ if (tool.length === 0) {
131
+ response = {
132
+ jsonrpc: '2.0',
133
+ id,
134
+ error: { code: -32602, message: 'tools/call requires params.name' },
135
+ };
136
+ break;
137
+ }
138
+ try {
139
+ const resp = await fetch(toolsCallUrl, {
140
+ method: 'POST',
141
+ headers: { 'Content-Type': 'application/json' },
142
+ body: JSON.stringify({
143
+ session: opts.sessionId,
144
+ item: opts.stackItem.id,
145
+ tool,
146
+ arguments: args,
147
+ }),
148
+ });
149
+ if (!resp.ok) {
150
+ const body = (await resp.json().catch(() => ({})));
151
+ response = {
152
+ jsonrpc: '2.0',
153
+ id,
154
+ error: {
155
+ code: resp.status === 403 ? -32003 : -32000,
156
+ message: body.error ?? `proxy_error_${resp.status}`,
157
+ },
158
+ };
159
+ }
160
+ else {
161
+ const result = (await resp.json());
162
+ response = { jsonrpc: '2.0', id, result };
163
+ }
164
+ }
165
+ catch (err) {
166
+ response = {
167
+ jsonrpc: '2.0',
168
+ id,
169
+ error: {
170
+ code: -32000,
171
+ message: `proxy_fetch_failed: ${String(err)}`,
172
+ },
173
+ };
174
+ }
175
+ break;
176
+ }
177
+ default: {
178
+ response = {
179
+ jsonrpc: '2.0',
180
+ id,
181
+ error: { code: -32601, message: 'method_not_supported' },
182
+ };
183
+ }
184
+ }
185
+ iframe.contentWindow?.postMessage(response, '*');
186
+ }
187
+ window.addEventListener('message', listener);
188
+ return {
189
+ element: iframe,
190
+ unmount() {
191
+ window.removeEventListener('message', listener);
192
+ // Host-initiated `ui/resource-teardown` — posted BEFORE the
193
+ // iframe leaves the DOM so its contentWindow is still live.
194
+ // Post-detach the contentWindow is null; the cached handle is
195
+ // a belt-and-suspenders fallback.
196
+ const win = iframe.contentWindow ?? cachedIframeWindow;
197
+ try {
198
+ win?.postMessage({
199
+ jsonrpc: '2.0',
200
+ method: 'ui/resource-teardown',
201
+ params: { reason: 'host_unmount' },
202
+ }, '*');
203
+ }
204
+ catch {
205
+ // Best-effort — teardown notification is a courtesy, not a
206
+ // correctness contract. A thrown postMessage (detached win)
207
+ // silently falls through to element removal.
208
+ }
209
+ cachedIframeWindow = null;
210
+ if (iframe.parentNode !== null)
211
+ iframe.parentNode.removeChild(iframe);
212
+ },
213
+ };
214
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Observability events emitted by the renderer iframe → surfaced to
3
+ * the MCP Apps host via the `<McpAppIframe>` wrapper's `onObserve`
4
+ * prop. Complementary to `ProtocolError`:
5
+ *
6
+ * - `ProtocolError` fires on FAILURES (typed classification of a
7
+ * protocol violation or transport error).
8
+ * - `ObservabilityEvent` fires on HAPPY PATHS + failures alike
9
+ * (telemetry + inspection signal the host can display in a
10
+ * SessionInspector-style view).
11
+ *
12
+ * The host wrapper passes events through to `onObserve` opaquely.
13
+ *
14
+ * Design:
15
+ *
16
+ * - Discriminated union on `kind`. Each arm carries only the fields
17
+ * the event semantically requires — no optional everything-bags.
18
+ * - Extensibly closed via the `(string & {})` tail on `kind`. Adding
19
+ * a new arm does NOT bump the protocol version —
20
+ * hosts built against older typings fall through to the unknown
21
+ * branch and MUST render gracefully (e.g., `JSON.stringify(event)`
22
+ * fallback in an inspector row).
23
+ * - `@ggui-ai/iframe-runtime` owns this union, NOT `@ggui-ai/protocol`.
24
+ * Observability events are a renderer ↔ host implementation seam,
25
+ * not a wire-format contract between arbitrary protocol peers, so
26
+ * they stay out of the protocol package.
27
+ *
28
+ * @public
29
+ */
30
+ export type ObservabilityEvent = WiredToolInvokedEvent | ContractErrorEmittedEvent | SchemaVersionMismatchEvent | SubscribeFailedEvent | AuthRequiredEvent | ChannelTransportPickedEvent | ChannelTransportFallbackEvent | ChannelTransportResubscribedEvent | UnknownObservabilityEvent;
31
+ /**
32
+ * Fired when a wired action successfully dispatched to a registered
33
+ * MCP tool. Hosts surface this as a dispatch row in SessionInspector.
34
+ * Emitted by the server-side router (session-channel.ts) via
35
+ * postMessage relay through the renderer.
36
+ *
37
+ * @public
38
+ */
39
+ export interface WiredToolInvokedEvent {
40
+ readonly kind: 'wired-tool-invoked';
41
+ readonly toolName: string;
42
+ readonly actionName?: string;
43
+ readonly dispatchedAt: string;
44
+ readonly latencyMs?: number;
45
+ }
46
+ /**
47
+ * Fired when the server emits a `_ggui:contract-error` envelope on
48
+ * the live channel. Mirrors the envelope's `code` field for host-side
49
+ * inspection without requiring the host to parse channel frames.
50
+ *
51
+ * @public
52
+ */
53
+ export interface ContractErrorEmittedEvent {
54
+ readonly kind: 'contract-error-emitted';
55
+ readonly code: string;
56
+ readonly toolName: string;
57
+ readonly actionName?: string;
58
+ }
59
+ /**
60
+ * Fired when the protocol-version handshake rejects the connection.
61
+ * Parallel to `UpgradeRequiredError` — the host can choose to render
62
+ * an inline upgrade prompt instead of treating it as a terminal
63
+ * failure.
64
+ *
65
+ * @public
66
+ */
67
+ export interface SchemaVersionMismatchEvent {
68
+ readonly kind: 'schema-version-mismatch';
69
+ readonly observedVersion: string;
70
+ readonly acceptedVersions: readonly string[];
71
+ readonly observedBy: 'client' | 'server';
72
+ }
73
+ /**
74
+ * Fired when subscribe fails for any reason the renderer classifies
75
+ * as non-fatal to the session (e.g., transient network jitter that
76
+ * the reconnect ladder is handling). Terminal subscribe failures
77
+ * still route through `ProtocolError`.
78
+ *
79
+ * @public
80
+ */
81
+ export interface SubscribeFailedEvent {
82
+ readonly kind: 'subscribe-failed';
83
+ readonly reason: string;
84
+ readonly message?: string;
85
+ }
86
+ /**
87
+ * Fired when the server emits a `system` frame with
88
+ * `action: 'auth_required'` — the agent needs the end-user to authorize
89
+ * an OAuth service (Google, Slack, etc.) before a wired tool can
90
+ * proceed. The host listens via `<McpAppIframe onObserve>` and renders
91
+ * a consent overlay OUTSIDE the iframe, then redirects the user to
92
+ * `authUrl` to complete the OAuth flow.
93
+ *
94
+ * Observability-only — the wire signal is already fully typed on
95
+ * `@ggui-ai/protocol`'s `SystemPayload` (live-channel.ts). This event is a
96
+ * renderer↔host projection of that payload, not a new protocol surface,
97
+ * matching the vendor-neutral-separation posture of every
98
+ * other `ObservabilityEvent` kind.
99
+ *
100
+ * Fields map 1:1 from `SystemPayload`:
101
+ * - `provider` ← `serviceId` (canonical identifier the credential
102
+ * store keys off — e.g., `"google"`, `"slack"`).
103
+ * - `authUrl` ← `consentUrl` (URL the host opens in a popup / new tab
104
+ * to initiate the OAuth consent flow).
105
+ * - `displayName` / `scopes` / `message` are optional hints the host
106
+ * can surface in the consent overlay; absent means "render with
107
+ * generic copy".
108
+ *
109
+ * @public
110
+ */
111
+ export interface AuthRequiredEvent {
112
+ readonly kind: 'auth-required';
113
+ /**
114
+ * Canonical identifier of the service needing authorization (e.g.
115
+ * `"google"`, `"slack"`). Maps to `SystemPayload.serviceId`.
116
+ */
117
+ readonly provider: string;
118
+ /**
119
+ * URL the host opens in a popup / new tab to initiate the OAuth
120
+ * consent flow. Maps to `SystemPayload.consentUrl`.
121
+ */
122
+ readonly authUrl: string;
123
+ /**
124
+ * Human-readable service name (e.g. `"Google"`, `"Slack"`). Optional
125
+ * — absent when the server doesn't supply one.
126
+ */
127
+ readonly displayName?: string;
128
+ /**
129
+ * OAuth scopes the agent is requesting. Optional.
130
+ */
131
+ readonly scopes?: readonly string[];
132
+ /**
133
+ * Human-readable message explaining why access is needed. Optional.
134
+ */
135
+ readonly message?: string;
136
+ }
137
+ /**
138
+ * Fired by the channel-transport router when it picks a transport
139
+ * for a `streamSpec[ch].source.tool` channel. Hosts can inspect the
140
+ * WS-vs-poll decision per channel in the SessionInspector activity
141
+ * feed.
142
+ *
143
+ * @public
144
+ */
145
+ export interface ChannelTransportPickedEvent {
146
+ readonly kind: 'channel-transport-picked';
147
+ readonly stackItemId: string;
148
+ readonly channelName: string;
149
+ readonly transport: 'ws' | 'poll';
150
+ }
151
+ /**
152
+ * Fired by the channel-transport router when a WS-bound channel
153
+ * falls back to iframe polling. `'ws-disconnect'`
154
+ * = WS dropped, transient. `'channel-not-local'` = server explicitly
155
+ * said it can't subscribe-for this tool (sticky for the channel's
156
+ * lifetime).
157
+ *
158
+ * @public
159
+ */
160
+ export interface ChannelTransportFallbackEvent {
161
+ readonly kind: 'channel-transport-fallback';
162
+ readonly stackItemId: string;
163
+ readonly channelName: string;
164
+ readonly reason: 'ws-disconnect' | 'channel-not-local';
165
+ }
166
+ /**
167
+ * Fired by the channel-transport router when it re-sends
168
+ * `channel_subscribe` for a channel after the WS reconnects.
169
+ *
170
+ * @public
171
+ */
172
+ export interface ChannelTransportResubscribedEvent {
173
+ readonly kind: 'channel-transport-resubscribed';
174
+ readonly stackItemId: string;
175
+ readonly channelName: string;
176
+ }
177
+ /**
178
+ * Catch-all branch for event kinds the host's typings don't recognize.
179
+ * Lets the union stay extensible without forcing protocol-version
180
+ * bumps when new kinds are added. Hosts MUST render unknown events
181
+ * gracefully — default: show as raw JSON in the inspector.
182
+ *
183
+ * @public
184
+ */
185
+ export interface UnknownObservabilityEvent {
186
+ readonly kind: string & {};
187
+ readonly [field: string]: unknown;
188
+ }
189
+ /**
190
+ * Shape of the postMessage envelope the renderer emits to its parent
191
+ * when an observability event fires. `<McpAppIframe>` listens for this
192
+ * and forwards the `event` field to `onObserve`.
193
+ *
194
+ * @public
195
+ */
196
+ export interface ObservabilityMessage {
197
+ readonly type: 'ggui:observe';
198
+ readonly event: ObservabilityEvent;
199
+ }
200
+ /**
201
+ * Caller sink for every {@link ObservabilityEvent} the renderer
202
+ * classifies. Mirrors the {@link import('./protocol-error.js').ProtocolErrorEmitter}
203
+ * posture — injection slot so tests can record, production binds a
204
+ * postMessage-to-parent default.
205
+ *
206
+ * Handlers MUST NOT throw. Observability is fire-and-forget — the
207
+ * renderer has already completed the observed side-effect by the time
208
+ * the emitter runs; a throwing handler would mask the real signal.
209
+ *
210
+ * @public
211
+ */
212
+ export type ObservabilityEmitter = (event: ObservabilityEvent) => void;
213
+ /**
214
+ * Default emitter — posts an {@link ObservabilityMessage} to
215
+ * `window.parent`. Mirrors `postBootFailure` / `postRendererReady`
216
+ * posture in `runtime.ts`: swallows postMessage failure so a detached
217
+ * parent doesn't crash the iframe.
218
+ *
219
+ * Lives here (not in `runtime.ts`) so tests exercising individual
220
+ * emission sites can import the same default without dragging in the
221
+ * runtime's module side-effects.
222
+ *
223
+ * @public
224
+ */
225
+ export declare function postObservabilityToParent(event: ObservabilityEvent): void;