@ggui-ai/iframe-runtime 0.1.0-rc.3 → 0.2.0-alpha.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 (83) hide show
  1. package/README.md +7 -6
  2. package/dist/channel-transport.d.ts +15 -15
  3. package/dist/channel-transport.js +32 -35
  4. package/dist/channels/channel-payload.d.ts +2 -2
  5. package/dist/channels/channel-payload.js +1 -1
  6. package/dist/channels/data.d.ts +14 -19
  7. package/dist/channels/data.js +13 -31
  8. package/dist/channels/drain-ack.d.ts +2 -2
  9. package/dist/channels/drain-ack.js +1 -1
  10. package/dist/channels/feedback.d.ts +1 -1
  11. package/dist/channels/index.d.ts +1 -1
  12. package/dist/channels/index.js +1 -1
  13. package/dist/channels/props-update.d.ts +33 -37
  14. package/dist/channels/props-update.js +30 -106
  15. package/dist/channels/render-frame.d.ts +52 -0
  16. package/dist/channels/render-frame.js +53 -0
  17. package/dist/channels/system.d.ts +1 -1
  18. package/dist/compiled-validators.d.ts +15 -0
  19. package/dist/compiled-validators.js +31 -0
  20. package/dist/context-observer.d.ts +68 -8
  21. package/dist/context-observer.js +25 -45
  22. package/dist/events-polling.d.ts +95 -0
  23. package/dist/events-polling.js +143 -0
  24. package/dist/gadget-loader.d.ts +1 -1
  25. package/dist/globals.d.ts +4 -4
  26. package/dist/host-context-emitter.d.ts +84 -20
  27. package/dist/host-context-emitter.js +159 -29
  28. package/dist/iframe-runtime.js +98 -59
  29. package/dist/index.d.ts +18 -13
  30. package/dist/index.js +1 -1
  31. package/dist/lifecycle.d.ts +2 -2
  32. package/dist/lifecycle.js +5 -5
  33. package/dist/mcp-app-iframe-host.d.ts +9 -9
  34. package/dist/mcp-app-iframe-host.js +16 -17
  35. package/dist/meta-parse.d.ts +44 -0
  36. package/dist/meta-parse.js +390 -0
  37. package/dist/observability.d.ts +6 -6
  38. package/dist/protocol-error.d.ts +7 -7
  39. package/dist/provisional-renderer.d.ts +1 -1
  40. package/dist/provisional-renderer.js +1 -1
  41. package/dist/react-renderer.d.ts +10 -11
  42. package/dist/react-renderer.js +8 -8
  43. package/dist/registry-subscribe.d.ts +27 -9
  44. package/dist/registry-subscribe.js +32 -27
  45. package/dist/render-item.d.ts +83 -0
  46. package/dist/{stack-item-renderer.js → render-item.js} +106 -106
  47. package/dist/runtime.d.ts +183 -131
  48. package/dist/runtime.js +1264 -1330
  49. package/dist/status-dom.d.ts +52 -47
  50. package/dist/status-dom.js +51 -34
  51. package/dist/system-cards/NoCredentialsCard.d.ts +1 -1
  52. package/dist/system-cards/NoCredentialsCard.js +1 -1
  53. package/dist/system-cards/ProtocolProbeCard.js +2 -2
  54. package/dist/system-cards/index.d.ts +4 -4
  55. package/dist/system-cards/index.js +4 -4
  56. package/dist/types.d.ts +70 -34
  57. package/dist/types.js +1 -1
  58. package/dist/validation.d.ts +2 -2
  59. package/dist/validation.js +2 -2
  60. package/dist/wire-config.d.ts +54 -82
  61. package/dist/wire-config.js +61 -93
  62. package/package.json +9 -8
  63. package/dist/bootstrap.d.ts +0 -72
  64. package/dist/bootstrap.js +0 -500
  65. package/dist/canvas/animator/animator-host.d.ts +0 -63
  66. package/dist/canvas/animator/animator-host.js +0 -105
  67. package/dist/canvas/animator/animator-pill.d.ts +0 -42
  68. package/dist/canvas/animator/animator-pill.js +0 -119
  69. package/dist/canvas/animator/state-machine.d.ts +0 -106
  70. package/dist/canvas/animator/state-machine.js +0 -150
  71. package/dist/canvas/canvas-shell.d.ts +0 -77
  72. package/dist/canvas/canvas-shell.js +0 -117
  73. package/dist/canvas/display-mode-policy.d.ts +0 -104
  74. package/dist/canvas/display-mode-policy.js +0 -98
  75. package/dist/canvas/mount.d.ts +0 -150
  76. package/dist/canvas/mount.js +0 -202
  77. package/dist/canvas/nav-stack.d.ts +0 -133
  78. package/dist/canvas/nav-stack.js +0 -172
  79. package/dist/channels/push.d.ts +0 -47
  80. package/dist/channels/push.js +0 -54
  81. package/dist/stack-item-renderer.d.ts +0 -103
  82. package/dist/stack.d.ts +0 -90
  83. package/dist/stack.js +0 -118
package/dist/index.d.ts CHANGED
@@ -6,12 +6,15 @@
6
6
  * re-export the runtime side-effects.
7
7
  *
8
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).
9
+ * - `McpAppAiGguiMetaParseFailureReason` — closed union of slice-meta
10
+ * parse failure reasons.
11
+ * - `McpAppAiGguiMetaParseResult` — discriminated union returned by
12
+ * the slice-meta extractors.
13
+ * - `parseMetaFromGlobal` / `parseMetaFromToolResult` — the two
14
+ * production extractors (consumers wanting to validate a slice
15
+ * meta before spawning the iframe).
16
+ * - `validateMeta` — the shared mode-discriminator + expiresAt
17
+ * validator the extractors funnel into.
15
18
  * - `RendererBootFailureReason` / `RendererBootFailedMessage` — the
16
19
  * postMessage envelope shape parents observe on boot failure.
17
20
  * - `ProtocolError` — canonical typed union for every failure
@@ -19,17 +22,19 @@
19
22
  * envelope and the `_ggui:contract-error` WS channel.
20
23
  * - `BootstrapFailureReason` — extensibly-closed union of every
21
24
  * reason the 'bootstrap' variant can carry. Consolidates
22
- * parse-time and post-parse codes in one place.
25
+ * parse-time and post-parse codes in one place. (Wire-visible
26
+ * name; kept stable for host postMessage observers.)
23
27
  * - `ProtocolErrorEmitter` — function signature for the
24
28
  * caller-side sink.
25
29
  *
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.
30
+ * Deliberately NOT exported: `bootSequence`, `connectViaRegistry`, the
31
+ * per-channel handler factories, and the single-item render mount.
32
+ * Those are the renderer's INTERNAL runtime contract and should not
33
+ * become a load-bearing public API.
29
34
  */
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';
35
+ export type { McpAppAiGguiMetaParseFailureReason, McpAppAiGguiMetaParseResult, } from './types.js';
36
+ export { parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
37
+ export type { RendererBootFailureReason, RendererBootFailedMessage, SelfContainedMcpAppAiGguiMeta, } from './runtime.js';
33
38
  export type { ProtocolError, ProtocolErrorEmitter, BootstrapFailureReason, } from './protocol-error.js';
34
39
  export { defaultProtocolErrorEmitter, fromBootstrapFailure, fromClientContractViolation, fromContractErrorPayload, fromTransportFailure, fromAuthFailure, fromUpgradeRequired, fromUnknown, } from './protocol-error.js';
35
40
  export type { ObservabilityEvent, ObservabilityEmitter, ObservabilityMessage, WiredToolInvokedEvent, ContractErrorEmittedEvent, SchemaVersionMismatchEvent, SubscribeFailedEvent, AuthRequiredEvent, UnknownObservabilityEvent, } from './observability.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { parseBootstrap, parseBootstrapFromUiInitialize, parseBootstrapFromGlobal, parseBootstrapFromToolResult, validateBootstrapMeta, } from './bootstrap.js';
1
+ export { parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
2
2
  export { defaultProtocolErrorEmitter, fromBootstrapFailure, fromClientContractViolation, fromContractErrorPayload, fromTransportFailure, fromAuthFailure, fromUpgradeRequired, fromUnknown, } from './protocol-error.js';
3
3
  export { postObservabilityToParent } from './observability.js';
4
4
  export { postLifecycleToParent, makeLifecycleEvent } from './lifecycle.js';
@@ -52,13 +52,13 @@ export declare function postLifecycleToParent(event: McpAppLifecycleEvent): void
52
52
  * Build a lifecycle event payload. Centralises the `state` →
53
53
  * `McpAppLifecycleEvent` projection so call sites in
54
54
  * `runtime.ts` don't repeat the `{state, ...optional}` shape and so
55
- * the additive-extension semantic of `stackItemId` / `error` is
55
+ * the additive-extension semantic of `renderId` / `error` is
56
56
  * single-sourced.
57
57
  *
58
58
  * @public
59
59
  */
60
60
  export declare function makeLifecycleEvent(state: McpAppLifecycleState, options?: {
61
- readonly stackItemId?: string;
61
+ readonly renderId?: string;
62
62
  readonly error?: {
63
63
  readonly code: string;
64
64
  readonly message: string;
package/dist/lifecycle.js CHANGED
@@ -26,7 +26,7 @@ export function postLifecycleToParent(event) {
26
26
  * Build a lifecycle event payload. Centralises the `state` →
27
27
  * `McpAppLifecycleEvent` projection so call sites in
28
28
  * `runtime.ts` don't repeat the `{state, ...optional}` shape and so
29
- * the additive-extension semantic of `stackItemId` / `error` is
29
+ * the additive-extension semantic of `renderId` / `error` is
30
30
  * single-sourced.
31
31
  *
32
32
  * @public
@@ -34,15 +34,15 @@ export function postLifecycleToParent(event) {
34
34
  export function makeLifecycleEvent(state, options) {
35
35
  // Avoid emitting `undefined` keys — the protocol's structural lock
36
36
  // (mcp-apps.test.ts "shape lock") asserts a populated event has
37
- // exactly state + stackItemId + error keys, not an Object.keys-
37
+ // exactly state + renderId + error keys, not an Object.keys-
38
38
  // observable `undefined` field.
39
39
  if (options === undefined)
40
40
  return { state };
41
- const { stackItemId, error } = options;
41
+ const { renderId, error } = options;
42
42
  return {
43
43
  state,
44
- ...(typeof stackItemId === 'string' && stackItemId.length > 0
45
- ? { stackItemId }
44
+ ...(typeof renderId === 'string' && renderId.length > 0
45
+ ? { renderId }
46
46
  : {}),
47
47
  ...(error !== undefined ? { error } : {}),
48
48
  };
@@ -1,8 +1,8 @@
1
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.
2
+ * Vanilla-TS port of `@ggui-ai/react::components/GguiRender.tsx`'s
3
+ * MCP-Apps branch. Used when a ggui render's `type === 'mcpApps'`
4
+ * — the renderer mounts a nested iframe that embeds a foreign
5
+ * MCP App.
6
6
  *
7
7
  * The iframe plays the MCP Apps HOST role for the embedded content:
8
8
  *
@@ -27,19 +27,19 @@
27
27
  * at mount so the teardown message can still reach the view.
28
28
  *
29
29
  * Recursive mount safety: a foreign MCP App loaded inside this
30
- * iframe can in turn render a `ui://ggui/session` resource via its
30
+ * iframe can in turn render a `ui://ggui/render` resource via its
31
31
  * own host implementation. That recursion stays bounded because
32
32
  * each nested iframe runs the same adapter-boundary rule — outer
33
33
  * ggui state never leaks into the inner renderer's session through
34
34
  * this host path. Host-to-host sandboxing is browser-enforced by
35
35
  * the iframe's `sandbox="allow-scripts"` attribute.
36
36
  */
37
- import type { McpAppsStackItem } from '@ggui-ai/protocol/integrations/mcp-apps';
37
+ import type { McpAppsRender } from '@ggui-ai/protocol/integrations/mcp-apps';
38
38
  export interface McpAppIframeMountOptions {
39
- readonly stackItem: McpAppsStackItem;
40
- /** Session id — threaded into the proxy URL so server-side
39
+ readonly render: McpAppsRender;
40
+ /** Render id — threaded into the proxy URL so server-side
41
41
  * connector scoping works. */
42
- readonly sessionId: string;
42
+ readonly renderId: string;
43
43
  /**
44
44
  * Base URL of the ggui server (origin). The host appends
45
45
  * `/mcp-apps/resource` + `/mcp-apps/tools-call` to this. Should
@@ -12,7 +12,7 @@ const DEFAULT_THEME = {
12
12
  function composeResourceUrl(opts) {
13
13
  const base = opts.serverBaseUrl.replace(/\/$/, '');
14
14
  const qs = new URLSearchParams({
15
- session: opts.sessionId,
15
+ render: opts.renderId,
16
16
  item: opts.itemId,
17
17
  });
18
18
  return `${base}/mcp-apps/resource?${qs.toString()}`;
@@ -27,8 +27,8 @@ function buildSandboxAttr() {
27
27
  // host document). Ports the host-SDK version verbatim.
28
28
  return 'allow-scripts allow-forms';
29
29
  }
30
- function buildAllowAttr(stackItem) {
31
- const perms = stackItem.permissions;
30
+ function buildAllowAttr(render) {
31
+ const perms = render.permissions;
32
32
  if (!perms)
33
33
  return undefined;
34
34
  const parts = [];
@@ -58,24 +58,24 @@ export function mountMcpAppIframe(container, opts) {
58
58
  const serverBaseUrl = opts.serverBaseUrl ?? '';
59
59
  const resourceUrl = composeResourceUrl({
60
60
  serverBaseUrl,
61
- sessionId: opts.sessionId,
62
- itemId: opts.stackItem.id,
61
+ renderId: opts.renderId,
62
+ itemId: opts.render.id,
63
63
  });
64
64
  const toolsCallUrl = composeToolsCallUrl(serverBaseUrl);
65
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,
66
+ width: opts.render.containerDimensions?.width,
67
+ height: opts.render.containerDimensions?.height,
68
+ maxWidth: opts.render.containerDimensions?.maxWidth,
69
+ maxHeight: opts.render.containerDimensions?.maxHeight,
70
70
  };
71
71
  const iframe = container.ownerDocument.createElement('iframe');
72
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);
73
+ iframe.setAttribute('data-ggui-render-id', opts.render.id);
74
+ iframe.setAttribute('data-ggui-connector-id', opts.render.source.connectorId);
75
75
  iframe.src = resourceUrl;
76
- iframe.title = opts.stackItem.description ?? 'MCP App';
76
+ iframe.title = opts.render.description ?? 'MCP App';
77
77
  iframe.setAttribute('sandbox', buildSandboxAttr());
78
- const allowAttr = buildAllowAttr(opts.stackItem);
78
+ const allowAttr = buildAllowAttr(opts.render);
79
79
  if (allowAttr !== undefined)
80
80
  iframe.setAttribute('allow', allowAttr);
81
81
  iframe.style.width = dims.width !== undefined ? `${dims.width}px` : '100%';
@@ -96,8 +96,7 @@ export function mountMcpAppIframe(container, opts) {
96
96
  };
97
97
  async function handleHostBridgeRequest(ev) {
98
98
  // Adapter-boundary guard: only our own iframe's contentWindow is
99
- // allowed. This matches the ref-guard in
100
- // McpAppsStackItemRenderer.tsx.
99
+ // allowed. This matches the ref-guard in `GguiRender.tsx`.
101
100
  if (iframe.contentWindow === null || ev.source !== iframe.contentWindow)
102
101
  return;
103
102
  const req = ev.data;
@@ -140,8 +139,8 @@ export function mountMcpAppIframe(container, opts) {
140
139
  method: 'POST',
141
140
  headers: { 'Content-Type': 'application/json' },
142
141
  body: JSON.stringify({
143
- session: opts.sessionId,
144
- item: opts.stackItem.id,
142
+ render: opts.renderId,
143
+ item: opts.render.id,
145
144
  tool,
146
145
  arguments: args,
147
146
  }),
@@ -0,0 +1,44 @@
1
+ import type { McpAppAiGguiRenderMeta } from '@ggui-ai/protocol/integrations/mcp-apps';
2
+ import type { McpAppAiGguiMetaParseResult } from './types.js';
3
+ /**
4
+ * Validate an {@link McpAppAiGguiRenderMeta} slice. Enforces mode
5
+ * discriminator + applies field-level defensive parsing. Returns a
6
+ * discriminated {@link McpAppAiGguiMetaParseResult} the caller
7
+ * surfaces verbatim.
8
+ *
9
+ * Invariants:
10
+ * - `runtimeUrl` MUST be a non-empty string (re-asserted for
11
+ * direct-call callers like `parseMetaFromGlobal` / tests; the
12
+ * envelope-driven extractors already enforce this in the protocol-
13
+ * level parser).
14
+ * - At least one of `{live mode (wsUrl+wsToken), codeUrl, kind}` MUST
15
+ * be present. Without any mode discriminator the iframe has nothing
16
+ * to mount.
17
+ */
18
+ export declare function validateMeta(meta: McpAppAiGguiRenderMeta): McpAppAiGguiMetaParseResult;
19
+ /**
20
+ * Parse the render slice from `globalThis.__GGUI_META__` — the
21
+ * synchronous self-contained shell delivery channel.
22
+ *
23
+ * The global carries the SAME slice envelope shape as the wire `_meta`
24
+ * (`{ "ai.ggui/render": {...} }`), so we can defer to the same
25
+ * combiner.
26
+ *
27
+ * Used by the runtime's autostart resolver as the highest-priority
28
+ * boot source: per-render shells populate this synchronously
29
+ * BEFORE the runtime bundle's `<script type="module">` evaluates,
30
+ * so the runtime can mount without any postMessage round-trip.
31
+ */
32
+ export declare function parseMetaFromGlobal(): McpAppAiGguiMetaParseResult;
33
+ /**
34
+ * Parse the render slice from a `ui/notifications/tool-result`
35
+ * postMessage params payload. Looks at BOTH the spec-canonical
36
+ * location (`params._meta`) and the Reading-B `<McpAppIframe>`
37
+ * convention (`params.toolOutput._meta`) — Claude Desktop / claude.ai
38
+ * Connector use the first; first-party hosts the second.
39
+ *
40
+ * Used by the runtime's autostart resolver as the second / third boot
41
+ * source (drained from the pre-runtime-load buffer or caught by the
42
+ * live message listener).
43
+ */
44
+ export declare function parseMetaFromToolResult(params: unknown): McpAppAiGguiMetaParseResult;
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Slice-meta extraction — two thin envelope extractors that funnel
3
+ * into one shared validator + projector.
4
+ *
5
+ * The runtime accepts the single `ai.ggui/render` slice from two
6
+ * envelope shapes, in spec-canonical priority order:
7
+ *
8
+ * 1. `globalThis.__GGUI_META__` — slice envelope inlined
9
+ * synchronously by `buildSelfContainedShell`. Same slice shape
10
+ * as the wire `_meta` (just hoisted out of the JSON-RPC frame).
11
+ * Used by self-contained shells (per-render HTML shells, console
12
+ * embeds, anywhere a per-render HTML shell can inline JSON
13
+ * before this bundle loads). Fastest path — no async wait, no
14
+ * JSON-RPC round-trip.
15
+ * 2. `ui/notifications/tool-result` postMessage — spec-canonical
16
+ * per MCP-Apps SEP-1865. `params` is a `CallToolResult` per the
17
+ * spec, so slices live under `params._meta`. We also accept
18
+ * `params.toolOutput._meta` as a back-compat aliased shape
19
+ * (some in-house emitters wrap the CallToolResult inside a
20
+ * `toolOutput` field). Spec-strict hosts (`<AppRenderer>` from
21
+ * `@mcp-ui/client`, ChatGPT MCP-Apps connector, claude.ai)
22
+ * deliver slice meta through this channel exclusively.
23
+ *
24
+ * Each extractor reaches the `_meta` object and calls the protocol's
25
+ * {@link parseMcpAppAiGguiRenderMeta} to produce a typed slice.
26
+ * The combiner does STRUCTURAL slice-shape validation; this module's
27
+ * {@link validateMeta} layers in mode-discriminator + expiresAt
28
+ * expiration checks and field-level defensive parsing for the
29
+ * optional containers (`contextSlots`, `gadgets`, `publicEnv`,
30
+ * `permissionsPolicy`, `appCallableTools`, `actionNextSteps`,
31
+ * `streamWebSocketLocalTools`).
32
+ *
33
+ * Reading-B (`result.toolOutput._meta` on the `ui/initialize`
34
+ * response) was retired in Phase 1.19b.3 — the
35
+ * `@modelcontextprotocol/ext-apps` `App` class drives the handshake
36
+ * via `App.connect(transport)` which does not expose `result.toolOutput`
37
+ * to consumers. HostContext is now captured from
38
+ * `app.getHostContext()` (App's spec-canonical `ui/initialize` capture
39
+ * plus the `hostcontextchanged` notification listener) rather than via
40
+ * a sibling field on the parsed envelope.
41
+ *
42
+ * **Three boot modes** (discriminated by which fields are present):
43
+ *
44
+ * - **live** — slice has `wsUrl` + `wsToken`; runtime opens
45
+ * live-channel WS and renders agent-driven frames.
46
+ * - **static-component** — slice has `codeUrl`; runtime fetches
47
+ * the content-addressable URL, dynamic-imports the React
48
+ * component, mounts it, never opens WS.
49
+ * - **system-card** — slice has `kind`; runtime maps to a
50
+ * built-in component via the system-card registry.
51
+ *
52
+ * At least one mode MUST be present. Half-live (wsUrl without wsToken)
53
+ * is structurally rejected by the combiner.
54
+ */
55
+ import { PUBLIC_ENV_APP_KEY_RE } from '@ggui-ai/protocol';
56
+ import { parseMcpAppAiGguiRenderMeta } from '@ggui-ai/protocol/integrations/mcp-apps';
57
+ /**
58
+ * Type guard — true iff `value` is a non-null, non-array plain object.
59
+ */
60
+ function isPlainObject(value) {
61
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
62
+ }
63
+ /**
64
+ * Type guard — true iff `value` is a non-empty string. Empty strings
65
+ * are rejected because they're equivalent to "field missing"
66
+ * semantically — server-side validation rejects them just as quickly
67
+ * as it rejects an absent field.
68
+ */
69
+ function isNonEmptyString(value) {
70
+ return typeof value === 'string' && value.length > 0;
71
+ }
72
+ /**
73
+ * Apply field-level defensive parsing to an optional render
74
+ * `contextSlots` array. Malformed entries collapse the whole field to
75
+ * `[]` rather than partially trusting it — a renderer with a corrupted
76
+ * context registry is worse than a renderer with none.
77
+ */
78
+ function projectContextSlots(raw) {
79
+ if (raw === undefined)
80
+ return undefined;
81
+ if (!Array.isArray(raw))
82
+ return [];
83
+ const collected = [];
84
+ for (const entry of raw) {
85
+ if (!isPlainObject(entry))
86
+ return [];
87
+ const name = entry['name'];
88
+ const contextName = entry['contextName'];
89
+ const schema = entry['schema'];
90
+ if (!isNonEmptyString(name))
91
+ return [];
92
+ if (!isNonEmptyString(contextName))
93
+ return [];
94
+ if (!isPlainObject(schema))
95
+ return [];
96
+ const debounceMsRaw = entry['debounceMs'];
97
+ if (debounceMsRaw !== undefined && typeof debounceMsRaw !== 'number') {
98
+ return [];
99
+ }
100
+ // `default` is required (runtime owns useState per slot; the seed
101
+ // is load-bearing). `in` discrimination so literal `null` passes
102
+ // while truly-missing fails.
103
+ if (!('default' in entry))
104
+ return [];
105
+ const slot = {
106
+ name,
107
+ contextName,
108
+ schema: schema,
109
+ default: entry['default'],
110
+ ...(debounceMsRaw !== undefined ? { debounceMs: debounceMsRaw } : {}),
111
+ };
112
+ collected.push(slot);
113
+ }
114
+ return collected;
115
+ }
116
+ /**
117
+ * Apply field-level defensive parsing to the render-slice `gadgets`
118
+ * catalog. Malformed entries collapse the WHOLE field to `undefined` —
119
+ * an unreachable package at boot is better than a half-populated
120
+ * registry with a corrupted entry.
121
+ */
122
+ function projectGadgets(raw) {
123
+ if (raw === undefined)
124
+ return undefined;
125
+ if (!Array.isArray(raw))
126
+ return undefined;
127
+ const collected = [];
128
+ for (const entry of raw) {
129
+ if (!isPlainObject(entry))
130
+ return undefined;
131
+ const pkgRaw = entry['package'];
132
+ if (!isNonEmptyString(pkgRaw))
133
+ return undefined;
134
+ const bundleRaw = entry['bundleUrl'];
135
+ const sriRaw = entry['bundleSri'];
136
+ const bundleUrl = isNonEmptyString(bundleRaw) ? bundleRaw : undefined;
137
+ // SRI gate only kicks in when a bundleUrl is present (bare-package
138
+ // import has no SRI surface). Stray SRI on a package-only entry is
139
+ // dropped silently — better than rejecting the whole field for a
140
+ // benign producer mismatch.
141
+ const bundleSri = isNonEmptyString(sriRaw) && bundleUrl !== undefined ? sriRaw : undefined;
142
+ collected.push({
143
+ package: pkgRaw,
144
+ ...(bundleUrl !== undefined ? { bundleUrl } : {}),
145
+ ...(bundleSri !== undefined ? { bundleSri } : {}),
146
+ });
147
+ }
148
+ return collected;
149
+ }
150
+ /**
151
+ * Apply field-level defensive parsing to the render-slice `publicEnv`
152
+ * map. EVERY key MUST match {@link PUBLIC_ENV_APP_KEY_RE}; one bad
153
+ * key (or non-string value, or non-object container) collapses the
154
+ * WHOLE field to `undefined`. Matches the parser posture of
155
+ * `gadgets` / `contextSlots`.
156
+ */
157
+ function projectPublicEnv(raw) {
158
+ if (raw === undefined)
159
+ return undefined;
160
+ if (!isPlainObject(raw))
161
+ return undefined;
162
+ const collected = {};
163
+ for (const [key, value] of Object.entries(raw)) {
164
+ if (!PUBLIC_ENV_APP_KEY_RE.test(key))
165
+ return undefined;
166
+ if (typeof value !== 'string')
167
+ return undefined;
168
+ collected[key] = value;
169
+ }
170
+ // Empty map = wire-equivalent to absent (per #109 splitter posture).
171
+ // Consumers default at their read site.
172
+ if (Object.keys(collected).length === 0)
173
+ return undefined;
174
+ return collected;
175
+ }
176
+ /**
177
+ * Apply field-level defensive parsing + business rules to the render
178
+ * slice. Returns a freshly-projected slice, OR a failure reason that
179
+ * the parser surfaces verbatim.
180
+ *
181
+ * Invariants enforced here that the combiner doesn't:
182
+ * - `expiresAt` (when present) MUST parse to a valid timestamp. If
183
+ * in the past AND the slice carries static content (codeUrl / kind),
184
+ * DEGRADE to static-only (drop `wsUrl` / `wsToken` / `expiresAt`);
185
+ * otherwise return EXPIRED_BOOTSTRAP.
186
+ * - `gadgets` / `publicEnv` / `appCallableTools` /
187
+ * `permissionsPolicy` / `streamWebSocketLocalTools` /
188
+ * `contextSlots` / `actionNextSteps` get defensive entry-level
189
+ * validation; malformed → defaulted.
190
+ */
191
+ function projectMeta(meta, hasStaticContent) {
192
+ let expiresAt = meta.expiresAt;
193
+ let dropLiveCreds = false;
194
+ if (expiresAt !== undefined) {
195
+ const ts = Date.parse(expiresAt);
196
+ if (Number.isNaN(ts)) {
197
+ return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
198
+ }
199
+ if (ts <= Date.now()) {
200
+ // Expired — only hard-fail when no static content is present.
201
+ // With static content, mount the UI without live mode.
202
+ if (!hasStaticContent) {
203
+ return { ok: false, reason: 'EXPIRED_BOOTSTRAP' };
204
+ }
205
+ dropLiveCreds = true;
206
+ expiresAt = undefined;
207
+ }
208
+ }
209
+ const gadgets = projectGadgets(meta.gadgets);
210
+ const publicEnv = projectPublicEnv(meta.publicEnv);
211
+ const appCallableToolsRaw = meta.appCallableTools;
212
+ const appCallableTools = Array.isArray(appCallableToolsRaw) &&
213
+ appCallableToolsRaw.every((s) => typeof s === 'string')
214
+ ? appCallableToolsRaw
215
+ : [];
216
+ const permissionsPolicyRaw = meta.permissionsPolicy;
217
+ const permissionsPolicy = Array.isArray(permissionsPolicyRaw) &&
218
+ permissionsPolicyRaw.every((s) => typeof s === 'string' && s.length > 0)
219
+ ? permissionsPolicyRaw
220
+ : undefined;
221
+ const streamRaw = meta.streamWebSocketLocalTools;
222
+ const streamWebSocketLocalTools = Array.isArray(streamRaw) &&
223
+ streamRaw.length > 0 &&
224
+ streamRaw.every((s) => typeof s === 'string' && s.length > 0)
225
+ ? streamRaw
226
+ : undefined;
227
+ // themeMode is a closed `'light' | 'dark'` enum at the type level;
228
+ // unknown values collapse to undefined (consumers fall back to the
229
+ // host-context default).
230
+ const themeModeRaw = meta.themeMode;
231
+ const themeMode = themeModeRaw === 'light' || themeModeRaw === 'dark'
232
+ ? themeModeRaw
233
+ : undefined;
234
+ const actionNextStepsRaw = meta.actionNextSteps;
235
+ let actionNextSteps;
236
+ if (isPlainObject(actionNextStepsRaw) &&
237
+ Object.values(actionNextStepsRaw).every((v) => typeof v === 'string')) {
238
+ actionNextSteps = actionNextStepsRaw;
239
+ }
240
+ const contextSlots = projectContextSlots(meta.contextSlots);
241
+ const projected = {
242
+ renderId: meta.renderId,
243
+ appId: meta.appId,
244
+ runtimeUrl: meta.runtimeUrl,
245
+ appCallableTools,
246
+ ...(dropLiveCreds
247
+ ? {}
248
+ : {
249
+ ...(meta.wsUrl !== undefined && meta.wsToken !== undefined
250
+ ? { wsUrl: meta.wsUrl, wsToken: meta.wsToken }
251
+ : {}),
252
+ }),
253
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
254
+ ...(meta.pollingUrl !== undefined
255
+ ? { pollingUrl: meta.pollingUrl }
256
+ : {}),
257
+ ...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
258
+ ...(themeMode !== undefined ? { themeMode } : {}),
259
+ ...(gadgets !== undefined ? { gadgets } : {}),
260
+ ...(publicEnv !== undefined ? { publicEnv } : {}),
261
+ ...(streamWebSocketLocalTools !== undefined
262
+ ? { streamWebSocketLocalTools }
263
+ : {}),
264
+ ...(permissionsPolicy !== undefined ? { permissionsPolicy } : {}),
265
+ ...(meta.lastSequence !== undefined ? { lastSequence: meta.lastSequence } : {}),
266
+ ...(meta.propsJson !== undefined ? { propsJson: meta.propsJson } : {}),
267
+ ...(actionNextSteps !== undefined ? { actionNextSteps } : {}),
268
+ ...(contextSlots !== undefined ? { contextSlots } : {}),
269
+ ...(meta.contractHash !== undefined && meta.validatorsUrl !== undefined
270
+ ? {
271
+ contractHash: meta.contractHash,
272
+ validatorsUrl: meta.validatorsUrl,
273
+ }
274
+ : {}),
275
+ ...(meta.codeUrl !== undefined ? { codeUrl: meta.codeUrl } : {}),
276
+ ...(meta.codeHash !== undefined ? { codeHash: meta.codeHash } : {}),
277
+ ...(meta.kind !== undefined ? { kind: meta.kind } : {}),
278
+ };
279
+ return { ok: true, meta: projected };
280
+ }
281
+ /**
282
+ * Validate an {@link McpAppAiGguiRenderMeta} slice. Enforces mode
283
+ * discriminator + applies field-level defensive parsing. Returns a
284
+ * discriminated {@link McpAppAiGguiMetaParseResult} the caller
285
+ * surfaces verbatim.
286
+ *
287
+ * Invariants:
288
+ * - `runtimeUrl` MUST be a non-empty string (re-asserted for
289
+ * direct-call callers like `parseMetaFromGlobal` / tests; the
290
+ * envelope-driven extractors already enforce this in the protocol-
291
+ * level parser).
292
+ * - At least one of `{live mode (wsUrl+wsToken), codeUrl, kind}` MUST
293
+ * be present. Without any mode discriminator the iframe has nothing
294
+ * to mount.
295
+ */
296
+ export function validateMeta(meta) {
297
+ // `runtimeUrl` is a hard wire-required field on every render slice
298
+ // (no mode discriminator escapes this — the iframe needs to know
299
+ // where to fetch the runtime bundle to mount anything at all). The
300
+ // protocol-level `parseMcpAppAiGguiRenderMeta` already enforces this
301
+ // for the envelope-driven extractors, but `validateMeta` is also
302
+ // called directly with pre-built slices (tests, `parseMetaFromGlobal`),
303
+ // so we re-assert here.
304
+ if (!isNonEmptyString(meta.runtimeUrl)) {
305
+ return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
306
+ }
307
+ const hasLive = isNonEmptyString(meta.wsUrl) && isNonEmptyString(meta.wsToken);
308
+ const hasCodeUrl = isNonEmptyString(meta.codeUrl);
309
+ const hasKind = isNonEmptyString(meta.kind);
310
+ const hasStaticContent = hasCodeUrl || hasKind;
311
+ if (!hasLive && !hasStaticContent) {
312
+ return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
313
+ }
314
+ const result = projectMeta(meta, hasStaticContent);
315
+ if (!result.ok)
316
+ return result;
317
+ return { ok: true, meta: result.meta };
318
+ }
319
+ /**
320
+ * Parse the render slice from `globalThis.__GGUI_META__` — the
321
+ * synchronous self-contained shell delivery channel.
322
+ *
323
+ * The global carries the SAME slice envelope shape as the wire `_meta`
324
+ * (`{ "ai.ggui/render": {...} }`), so we can defer to the same
325
+ * combiner.
326
+ *
327
+ * Used by the runtime's autostart resolver as the highest-priority
328
+ * boot source: per-render shells populate this synchronously
329
+ * BEFORE the runtime bundle's `<script type="module">` evaluates,
330
+ * so the runtime can mount without any postMessage round-trip.
331
+ */
332
+ export function parseMetaFromGlobal() {
333
+ if (typeof globalThis === 'undefined') {
334
+ return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
335
+ }
336
+ const raw = globalThis
337
+ .__GGUI_META__;
338
+ if (!isPlainObject(raw)) {
339
+ return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
340
+ }
341
+ const parsed = parseMcpAppAiGguiRenderMeta(raw);
342
+ if (!parsed.ok) {
343
+ return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
344
+ }
345
+ if (parsed.meta === undefined) {
346
+ return { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
347
+ }
348
+ return validateMeta(parsed.meta);
349
+ }
350
+ /**
351
+ * Parse the render slice from a `ui/notifications/tool-result`
352
+ * postMessage params payload. Looks at BOTH the spec-canonical
353
+ * location (`params._meta`) and the Reading-B `<McpAppIframe>`
354
+ * convention (`params.toolOutput._meta`) — Claude Desktop / claude.ai
355
+ * Connector use the first; first-party hosts the second.
356
+ *
357
+ * Used by the runtime's autostart resolver as the second / third boot
358
+ * source (drained from the pre-runtime-load buffer or caught by the
359
+ * live message listener).
360
+ */
361
+ export function parseMetaFromToolResult(params) {
362
+ if (!isPlainObject(params)) {
363
+ return { ok: false, reason: 'MISSING_TOOL_OUTPUT' };
364
+ }
365
+ let meta;
366
+ const topMeta = params['_meta'];
367
+ if (isPlainObject(topMeta)) {
368
+ meta = topMeta;
369
+ }
370
+ else {
371
+ const toolOutput = params['toolOutput'];
372
+ if (isPlainObject(toolOutput)) {
373
+ const nestedMeta = toolOutput['_meta'];
374
+ if (isPlainObject(nestedMeta)) {
375
+ meta = nestedMeta;
376
+ }
377
+ }
378
+ }
379
+ if (meta === undefined) {
380
+ return { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
381
+ }
382
+ const parsed = parseMcpAppAiGguiRenderMeta(meta);
383
+ if (!parsed.ok) {
384
+ return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
385
+ }
386
+ if (parsed.meta === undefined) {
387
+ return { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
388
+ }
389
+ return validateMeta(parsed.meta);
390
+ }