@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.
- package/README.md +7 -6
- package/dist/channel-transport.d.ts +15 -15
- package/dist/channel-transport.js +32 -35
- package/dist/channels/channel-payload.d.ts +2 -2
- package/dist/channels/channel-payload.js +1 -1
- package/dist/channels/data.d.ts +14 -19
- package/dist/channels/data.js +13 -31
- package/dist/channels/drain-ack.d.ts +2 -2
- package/dist/channels/drain-ack.js +1 -1
- package/dist/channels/feedback.d.ts +1 -1
- package/dist/channels/index.d.ts +1 -1
- package/dist/channels/index.js +1 -1
- package/dist/channels/props-update.d.ts +33 -37
- package/dist/channels/props-update.js +30 -106
- package/dist/channels/render-frame.d.ts +52 -0
- package/dist/channels/render-frame.js +53 -0
- package/dist/channels/system.d.ts +1 -1
- package/dist/compiled-validators.d.ts +15 -0
- package/dist/compiled-validators.js +31 -0
- package/dist/context-observer.d.ts +68 -8
- package/dist/context-observer.js +25 -45
- package/dist/events-polling.d.ts +95 -0
- package/dist/events-polling.js +143 -0
- package/dist/gadget-loader.d.ts +1 -1
- package/dist/globals.d.ts +4 -4
- package/dist/host-context-emitter.d.ts +84 -20
- package/dist/host-context-emitter.js +159 -29
- package/dist/iframe-runtime.js +98 -59
- package/dist/index.d.ts +18 -13
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +2 -2
- package/dist/lifecycle.js +5 -5
- package/dist/mcp-app-iframe-host.d.ts +9 -9
- package/dist/mcp-app-iframe-host.js +16 -17
- package/dist/meta-parse.d.ts +44 -0
- package/dist/meta-parse.js +390 -0
- package/dist/observability.d.ts +6 -6
- package/dist/protocol-error.d.ts +7 -7
- package/dist/provisional-renderer.d.ts +1 -1
- package/dist/provisional-renderer.js +1 -1
- package/dist/react-renderer.d.ts +10 -11
- package/dist/react-renderer.js +8 -8
- package/dist/registry-subscribe.d.ts +27 -9
- package/dist/registry-subscribe.js +32 -27
- package/dist/render-item.d.ts +83 -0
- package/dist/{stack-item-renderer.js → render-item.js} +106 -106
- package/dist/runtime.d.ts +183 -131
- package/dist/runtime.js +1264 -1330
- package/dist/status-dom.d.ts +52 -47
- package/dist/status-dom.js +51 -34
- package/dist/system-cards/NoCredentialsCard.d.ts +1 -1
- package/dist/system-cards/NoCredentialsCard.js +1 -1
- package/dist/system-cards/ProtocolProbeCard.js +2 -2
- package/dist/system-cards/index.d.ts +4 -4
- package/dist/system-cards/index.js +4 -4
- package/dist/types.d.ts +70 -34
- package/dist/types.js +1 -1
- package/dist/validation.d.ts +2 -2
- package/dist/validation.js +2 -2
- package/dist/wire-config.d.ts +54 -82
- package/dist/wire-config.js +61 -93
- package/package.json +9 -8
- package/dist/bootstrap.d.ts +0 -72
- package/dist/bootstrap.js +0 -500
- package/dist/canvas/animator/animator-host.d.ts +0 -63
- package/dist/canvas/animator/animator-host.js +0 -105
- package/dist/canvas/animator/animator-pill.d.ts +0 -42
- package/dist/canvas/animator/animator-pill.js +0 -119
- package/dist/canvas/animator/state-machine.d.ts +0 -106
- package/dist/canvas/animator/state-machine.js +0 -150
- package/dist/canvas/canvas-shell.d.ts +0 -77
- package/dist/canvas/canvas-shell.js +0 -117
- package/dist/canvas/display-mode-policy.d.ts +0 -104
- package/dist/canvas/display-mode-policy.js +0 -98
- package/dist/canvas/mount.d.ts +0 -150
- package/dist/canvas/mount.js +0 -202
- package/dist/canvas/nav-stack.d.ts +0 -133
- package/dist/canvas/nav-stack.js +0 -172
- package/dist/channels/push.d.ts +0 -47
- package/dist/channels/push.js +0 -54
- package/dist/stack-item-renderer.d.ts +0 -103
- package/dist/stack.d.ts +0 -90
- 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
|
-
* - `
|
|
10
|
-
* failure reasons.
|
|
11
|
-
* - `
|
|
12
|
-
*
|
|
13
|
-
* - `
|
|
14
|
-
*
|
|
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: `
|
|
27
|
-
*
|
|
28
|
-
* runtime contract and should not
|
|
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 {
|
|
31
|
-
export {
|
|
32
|
-
export type { RendererBootFailureReason, RendererBootFailedMessage,
|
|
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 {
|
|
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';
|
package/dist/lifecycle.d.ts
CHANGED
|
@@ -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 `
|
|
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
|
|
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 `
|
|
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 +
|
|
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 {
|
|
41
|
+
const { renderId, error } = options;
|
|
42
42
|
return {
|
|
43
43
|
state,
|
|
44
|
-
...(typeof
|
|
45
|
-
? {
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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/
|
|
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 {
|
|
37
|
+
import type { McpAppsRender } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
38
38
|
export interface McpAppIframeMountOptions {
|
|
39
|
-
readonly
|
|
40
|
-
/**
|
|
39
|
+
readonly render: McpAppsRender;
|
|
40
|
+
/** Render id — threaded into the proxy URL so server-side
|
|
41
41
|
* connector scoping works. */
|
|
42
|
-
readonly
|
|
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
|
-
|
|
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(
|
|
31
|
-
const perms =
|
|
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
|
-
|
|
62
|
-
itemId: opts.
|
|
61
|
+
renderId: opts.renderId,
|
|
62
|
+
itemId: opts.render.id,
|
|
63
63
|
});
|
|
64
64
|
const toolsCallUrl = composeToolsCallUrl(serverBaseUrl);
|
|
65
65
|
const dims = {
|
|
66
|
-
width: opts.
|
|
67
|
-
height: opts.
|
|
68
|
-
maxWidth: opts.
|
|
69
|
-
maxHeight: opts.
|
|
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-
|
|
74
|
-
iframe.setAttribute('data-ggui-connector-id', opts.
|
|
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.
|
|
76
|
+
iframe.title = opts.render.description ?? 'MCP App';
|
|
77
77
|
iframe.setAttribute('sandbox', buildSandboxAttr());
|
|
78
|
-
const allowAttr = buildAllowAttr(opts.
|
|
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
|
-
|
|
144
|
-
item: opts.
|
|
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
|
+
}
|