@ggui-ai/iframe-runtime 0.1.0-rc.3 → 0.2.0-alpha.3
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
|
@@ -1,130 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `props_update` channel handler —
|
|
3
|
-
* the
|
|
2
|
+
* `props_update` channel handler — patches the currently-mounted
|
|
3
|
+
* render's props in place via the renderer's `applyRender`
|
|
4
|
+
* callback.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
6
|
+
* Post-stack-removal (2026-05-27): each iframe holds exactly one
|
|
7
|
+
* mounted render. The handler reads the current render via
|
|
8
|
+
* `getCurrentRender()`, validates the inbound props against its
|
|
9
|
+
* cached `propsSpec`, and re-applies the patched render through
|
|
10
|
+
* `applyRender` — the same callback the render handler uses, so the
|
|
11
|
+
* React update surface is unified.
|
|
9
12
|
*
|
|
10
13
|
* Skips when:
|
|
11
|
-
* - `
|
|
14
|
+
* - `renderId` is empty or not a string.
|
|
12
15
|
* - `props` is null / not an object (defensive — server can't emit
|
|
13
16
|
* this shape, but the dispatcher routes the frame on type alone).
|
|
14
|
-
* - No
|
|
15
|
-
* - The
|
|
16
|
-
*
|
|
17
|
+
* - No render is currently mounted (`getCurrentRender` returns null).
|
|
18
|
+
* - The current render's id doesn't match `payload.renderId` —
|
|
19
|
+
* the server may have raced ahead of an in-flight render swap.
|
|
20
|
+
* - The current render is `mcpApps` / `system` (no `propsSpec`;
|
|
21
|
+
* server should never emit `props_update` for these).
|
|
17
22
|
* - The new props fail validation against the cached spec.
|
|
18
23
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
+
* R6 (2026-05-26) retired the per-handler polling descriptor. Polling
|
|
25
|
+
* is now registry-level — the iframe-runtime composes the
|
|
26
|
+
* `/api/renders/:id/state?wsToken=<token>` URL and a snapshot-parsing
|
|
27
|
+
* function once at bind time (see `runtime.ts`); a single fetch per
|
|
28
|
+
* tick projects the slice envelope into per-handler frames the
|
|
29
|
+
* `PollingTransport` dispatches.
|
|
24
30
|
*/
|
|
25
31
|
import { validateInboundPropsPayload } from '../validation.js';
|
|
26
|
-
const DEFAULT_POLLING_INTERVAL_MS = 2000;
|
|
27
|
-
/**
|
|
28
|
-
* Tiny FNV-1a 32-bit hash — stable, no protocol dep, ~5 LOC. We only
|
|
29
|
-
* need diff detection (not collision resistance), so the trade-off is
|
|
30
|
-
* fine. Returns a hex string for human-readable logs.
|
|
31
|
-
*/
|
|
32
|
-
function fnv1a(input) {
|
|
33
|
-
let hash = 0x811c9dc5;
|
|
34
|
-
for (let i = 0; i < input.length; i += 1) {
|
|
35
|
-
hash ^= input.charCodeAt(i);
|
|
36
|
-
// 32-bit FNV prime multiplication via additive bit-shifts.
|
|
37
|
-
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
|
|
38
|
-
}
|
|
39
|
-
return hash.toString(16);
|
|
40
|
-
}
|
|
41
32
|
export function createPropsUpdateHandler(deps) {
|
|
42
|
-
const polling = buildPollingDescriptor(deps);
|
|
43
33
|
return {
|
|
44
34
|
type: 'props_update',
|
|
45
35
|
onMessage: async (payload) => {
|
|
46
|
-
const {
|
|
47
|
-
if (typeof
|
|
36
|
+
const { renderId, props } = payload;
|
|
37
|
+
if (typeof renderId !== 'string' || renderId.length === 0)
|
|
48
38
|
return;
|
|
49
39
|
if (props === null || typeof props !== 'object')
|
|
50
40
|
return;
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (
|
|
41
|
+
const current = deps.getCurrentRender();
|
|
42
|
+
if (current === null)
|
|
43
|
+
return;
|
|
44
|
+
if (current.id !== renderId)
|
|
55
45
|
return;
|
|
56
|
-
if (
|
|
46
|
+
if (current.type === 'mcpApps' || current.type === 'system')
|
|
57
47
|
return;
|
|
58
|
-
const result = validateInboundPropsPayload(
|
|
48
|
+
const result = validateInboundPropsPayload(current.propsSpec, props);
|
|
59
49
|
if (!result.valid)
|
|
60
50
|
return;
|
|
61
|
-
|
|
62
|
-
.snapshot()
|
|
63
|
-
.map((item) => {
|
|
64
|
-
if (item.id !== stackItemId)
|
|
65
|
-
return item;
|
|
66
|
-
if (item.type === 'mcpApps' || item.type === 'system')
|
|
67
|
-
return item;
|
|
68
|
-
return { ...item, props };
|
|
69
|
-
});
|
|
70
|
-
deps.stackModel.setAll(nextSnapshot);
|
|
71
|
-
await deps.getStackRenderer().applyStack(deps.stackModel.snapshot());
|
|
72
|
-
},
|
|
73
|
-
...(polling !== undefined ? { polling } : {}),
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
function buildPollingDescriptor(deps) {
|
|
77
|
-
const { pollingUrl } = deps;
|
|
78
|
-
if (typeof pollingUrl !== 'string' || pollingUrl.length === 0) {
|
|
79
|
-
return undefined;
|
|
80
|
-
}
|
|
81
|
-
const url = pollingUrl;
|
|
82
|
-
// Closure state — last-seen propsJson hash + last-seen stackItemId.
|
|
83
|
-
// Diff detection: emit only on change. First-poll fires (lastSeenHash
|
|
84
|
-
// is null initially) so the iframe gets the current props as its
|
|
85
|
-
// starting state when WS is absent.
|
|
86
|
-
let lastSeenHash = null;
|
|
87
|
-
let lastSeenStackItemId = null;
|
|
88
|
-
return {
|
|
89
|
-
url,
|
|
90
|
-
intervalMs: deps.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS,
|
|
91
|
-
parse: (body) => {
|
|
92
|
-
if (body === null || typeof body !== 'object')
|
|
93
|
-
return null;
|
|
94
|
-
const propsJson = body.propsJson;
|
|
95
|
-
const bootstrapStackItemId = body
|
|
96
|
-
.stackItemId;
|
|
97
|
-
if (typeof propsJson !== 'string' ||
|
|
98
|
-
typeof bootstrapStackItemId !== 'string' ||
|
|
99
|
-
bootstrapStackItemId.length === 0) {
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
const hash = fnv1a(propsJson);
|
|
103
|
-
if (hash === lastSeenHash &&
|
|
104
|
-
bootstrapStackItemId === lastSeenStackItemId) {
|
|
105
|
-
return null; // Unchanged — skip dispatch.
|
|
106
|
-
}
|
|
107
|
-
lastSeenHash = hash;
|
|
108
|
-
lastSeenStackItemId = bootstrapStackItemId;
|
|
109
|
-
let parsedProps;
|
|
110
|
-
try {
|
|
111
|
-
parsedProps = JSON.parse(propsJson);
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
if (parsedProps === null ||
|
|
117
|
-
typeof parsedProps !== 'object' ||
|
|
118
|
-
Array.isArray(parsedProps)) {
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
// JSON.parse of an object literal yields a JsonObject by
|
|
122
|
-
// construction — JSON only carries JsonValue-compatible
|
|
123
|
-
// structure. Narrow to the protocol's typed shape.
|
|
124
|
-
return {
|
|
125
|
-
stackItemId: bootstrapStackItemId,
|
|
126
|
-
props: parsedProps,
|
|
127
|
-
};
|
|
51
|
+
await deps.applyRender({ ...current, props });
|
|
128
52
|
},
|
|
129
53
|
};
|
|
130
54
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `render` channel handler — folds an inbound `render` frame into the
|
|
3
|
+
* single-render mount slot and (when transport wiring is present)
|
|
4
|
+
* activates the per-channel transport router against the newly-
|
|
5
|
+
* mounted render.
|
|
6
|
+
*
|
|
7
|
+
* Post-render-identity-collapse (2026-05-27): each iframe holds
|
|
8
|
+
* exactly one mounted render for its lifetime. The handler routes the
|
|
9
|
+
* inbound `render` to a caller-supplied `applyRender` callback that
|
|
10
|
+
* either mounts (first frame) or re-applies (subsequent frames to the
|
|
11
|
+
* same id). Frames addressed to a different `renderId` are out-of-spec
|
|
12
|
+
* and drop with a console warning — the host spawns a fresh iframe
|
|
13
|
+
* per unique render id.
|
|
14
|
+
*
|
|
15
|
+
* Placeholder mode (boot.test.ts and the C7a placeholder-only spec)
|
|
16
|
+
* omits `applyRender` + `getChannelTransport`. The handler still fires
|
|
17
|
+
* for those callers — status log keeps firing — but no React mount
|
|
18
|
+
* happens. The boot-without-renderer path remains testable.
|
|
19
|
+
*/
|
|
20
|
+
import type { ChannelHandler } from '@ggui-ai/live-channel';
|
|
21
|
+
import type { RenderPayload, Render } from '@ggui-ai/protocol';
|
|
22
|
+
import type { ChannelTransportRouter } from '../channel-transport.js';
|
|
23
|
+
import { type StatusRefs } from '../status-dom.js';
|
|
24
|
+
export interface RenderHandlerDeps {
|
|
25
|
+
/**
|
|
26
|
+
* Status DOM refs the connected-status log updates. Required —
|
|
27
|
+
* even renderer-mode consumers want a `[ggui:connected]` console
|
|
28
|
+
* log on every render frame for operator-visible boot debugging.
|
|
29
|
+
*/
|
|
30
|
+
readonly statusRefs: StatusRefs;
|
|
31
|
+
/**
|
|
32
|
+
* Pin — when set, render frames with a different `renderId`
|
|
33
|
+
* are dropped with a console warning. Set by the renderer hook to
|
|
34
|
+
* the render id the iframe was bootstrapped against (every
|
|
35
|
+
* post-displayMode iframe is pinned to a single render).
|
|
36
|
+
*/
|
|
37
|
+
readonly pinnedRenderId?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Apply the inbound render to the single mount slot. Absent in
|
|
40
|
+
* placeholder mode (no React mount). When present, the handler
|
|
41
|
+
* `await`s the apply before activating the per-channel transport
|
|
42
|
+
* so the router targets the render React already mounted.
|
|
43
|
+
*/
|
|
44
|
+
readonly applyRender?: (render: Render) => Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Renderer-mode hook — returns the per-channel transport router so
|
|
47
|
+
* the render handler can activate `source.tool` channel subscriptions
|
|
48
|
+
* against the newly-mounted render. Absent in placeholder-only mode.
|
|
49
|
+
*/
|
|
50
|
+
readonly getChannelTransport?: () => ChannelTransportRouter;
|
|
51
|
+
}
|
|
52
|
+
export declare function createRenderHandler(deps: RenderHandlerDeps): ChannelHandler<RenderPayload>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `render` channel handler — folds an inbound `render` frame into the
|
|
3
|
+
* single-render mount slot and (when transport wiring is present)
|
|
4
|
+
* activates the per-channel transport router against the newly-
|
|
5
|
+
* mounted render.
|
|
6
|
+
*
|
|
7
|
+
* Post-render-identity-collapse (2026-05-27): each iframe holds
|
|
8
|
+
* exactly one mounted render for its lifetime. The handler routes the
|
|
9
|
+
* inbound `render` to a caller-supplied `applyRender` callback that
|
|
10
|
+
* either mounts (first frame) or re-applies (subsequent frames to the
|
|
11
|
+
* same id). Frames addressed to a different `renderId` are out-of-spec
|
|
12
|
+
* and drop with a console warning — the host spawns a fresh iframe
|
|
13
|
+
* per unique render id.
|
|
14
|
+
*
|
|
15
|
+
* Placeholder mode (boot.test.ts and the C7a placeholder-only spec)
|
|
16
|
+
* omits `applyRender` + `getChannelTransport`. The handler still fires
|
|
17
|
+
* for those callers — status log keeps firing — but no React mount
|
|
18
|
+
* happens. The boot-without-renderer path remains testable.
|
|
19
|
+
*/
|
|
20
|
+
import { setConnectedStatus } from '../status-dom.js';
|
|
21
|
+
export function createRenderHandler(deps) {
|
|
22
|
+
return {
|
|
23
|
+
type: 'render',
|
|
24
|
+
onMessage: async (payload) => {
|
|
25
|
+
const render = payload.render;
|
|
26
|
+
if (deps.pinnedRenderId !== undefined && render.id !== deps.pinnedRenderId) {
|
|
27
|
+
// Out-of-spec: each iframe is pinned to exactly one render id
|
|
28
|
+
// post-displayMode-divergence; the host spawns a fresh iframe
|
|
29
|
+
// per render. A frame for some other id means the host wired
|
|
30
|
+
// the wrong render or the server is broadcasting cross-render
|
|
31
|
+
// frames.
|
|
32
|
+
// eslint-disable-next-line no-console
|
|
33
|
+
console.warn(`[ggui:render] ignoring render for ${render.id} — iframe pinned to ${deps.pinnedRenderId}`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (deps.applyRender !== undefined) {
|
|
37
|
+
await deps.applyRender(render);
|
|
38
|
+
}
|
|
39
|
+
setConnectedStatus(deps.statusRefs);
|
|
40
|
+
const channelTransport = deps.getChannelTransport?.();
|
|
41
|
+
if (channelTransport === undefined)
|
|
42
|
+
return;
|
|
43
|
+
if (render.type === 'mcpApps' || render.type === 'system')
|
|
44
|
+
return;
|
|
45
|
+
channelTransport.applyRender({
|
|
46
|
+
renderId: render.id,
|
|
47
|
+
...(render.streamSpec !== undefined
|
|
48
|
+
? { streamSpec: render.streamSpec }
|
|
49
|
+
: {}),
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* it, so there's nothing actionable to surface.
|
|
18
18
|
* - `onObserve` absent — the observation has nowhere to go.
|
|
19
19
|
*/
|
|
20
|
-
import type { ChannelHandler } from '@ggui-ai/channel
|
|
20
|
+
import type { ChannelHandler } from '@ggui-ai/live-channel';
|
|
21
21
|
import type { SystemPayload } from '@ggui-ai/protocol';
|
|
22
22
|
import type { ObservabilityEmitter } from '../observability.js';
|
|
23
23
|
export interface SystemHandlerDeps {
|
|
@@ -38,3 +38,18 @@ export declare const EMPTY_COMPILED_VALIDATOR_SET: CompiledValidatorSet;
|
|
|
38
38
|
* bootstrap carries no `compiledValidators`.
|
|
39
39
|
*/
|
|
40
40
|
export declare function loadCompiledValidators(meta: CompiledContractValidators | undefined, warn?: (message: string, detail: unknown) => void): Promise<CompiledValidatorSet>;
|
|
41
|
+
/**
|
|
42
|
+
* #109 content-addressable variant: fetch the validator-bundle URL,
|
|
43
|
+
* dynamic-import the response, then delegate to
|
|
44
|
+
* {@link loadCompiledValidators}. Returns {@link EMPTY_COMPILED_VALIDATOR_SET}
|
|
45
|
+
* when `url` is undefined OR the fetch/import fails — sidecar
|
|
46
|
+
* semantics, the server-side `assertActionContract` gate remains
|
|
47
|
+
* authoritative on validator absence.
|
|
48
|
+
*
|
|
49
|
+
* Response shape: an ES module whose `default` export is a
|
|
50
|
+
* {@link CompiledContractValidators} object (produced server-side by
|
|
51
|
+
* `bundleCompiledValidatorsAsModule`). `Cache-Control: immutable` so
|
|
52
|
+
* repeat pushes with the same contract hit browser cache with no
|
|
53
|
+
* round-trip.
|
|
54
|
+
*/
|
|
55
|
+
export declare function loadCompiledValidatorsFromUrl(url: string | undefined, warn?: (message: string, detail: unknown) => void): Promise<CompiledValidatorSet>;
|
|
@@ -72,3 +72,34 @@ export async function loadCompiledValidators(meta, warn) {
|
|
|
72
72
|
context,
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* #109 content-addressable variant: fetch the validator-bundle URL,
|
|
77
|
+
* dynamic-import the response, then delegate to
|
|
78
|
+
* {@link loadCompiledValidators}. Returns {@link EMPTY_COMPILED_VALIDATOR_SET}
|
|
79
|
+
* when `url` is undefined OR the fetch/import fails — sidecar
|
|
80
|
+
* semantics, the server-side `assertActionContract` gate remains
|
|
81
|
+
* authoritative on validator absence.
|
|
82
|
+
*
|
|
83
|
+
* Response shape: an ES module whose `default` export is a
|
|
84
|
+
* {@link CompiledContractValidators} object (produced server-side by
|
|
85
|
+
* `bundleCompiledValidatorsAsModule`). `Cache-Control: immutable` so
|
|
86
|
+
* repeat pushes with the same contract hit browser cache with no
|
|
87
|
+
* round-trip.
|
|
88
|
+
*/
|
|
89
|
+
export async function loadCompiledValidatorsFromUrl(url, warn) {
|
|
90
|
+
if (url === undefined)
|
|
91
|
+
return EMPTY_COMPILED_VALIDATOR_SET;
|
|
92
|
+
try {
|
|
93
|
+
const mod = (await import(/* @vite-ignore */ url));
|
|
94
|
+
const compiled = mod.default;
|
|
95
|
+
if (compiled === null || typeof compiled !== 'object') {
|
|
96
|
+
warn?.('[ggui:validators] contract bundle: default export is not an object', compiled);
|
|
97
|
+
return EMPTY_COMPILED_VALIDATOR_SET;
|
|
98
|
+
}
|
|
99
|
+
return loadCompiledValidators(compiled, warn);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
warn?.(`[ggui:validators] contract bundle: failed to load ${url}`, err);
|
|
103
|
+
return EMPTY_COMPILED_VALIDATOR_SET;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -47,7 +47,7 @@ import type { JsonSchema } from '@ggui-ai/protocol';
|
|
|
47
47
|
import type { GguiContextRegistry } from './globals.js';
|
|
48
48
|
/**
|
|
49
49
|
* Wire-shape entry for a single contextSpec slot, mirrored from
|
|
50
|
-
* {@link import('@ggui-ai/protocol/integrations/mcp-apps').
|
|
50
|
+
* {@link import('@ggui-ai/protocol/integrations/mcp-apps').McpAppAiGguiRenderMeta.contextSlots}.
|
|
51
51
|
*/
|
|
52
52
|
export interface ContextSlotInfo {
|
|
53
53
|
readonly name: string;
|
|
@@ -110,14 +110,74 @@ export declare function installContextRegistry(registry: GguiContextRegistry, re
|
|
|
110
110
|
* Identity bundle carried alongside every snapshot post. The runtime
|
|
111
111
|
* captures these from the bootstrap envelope at boot and threads
|
|
112
112
|
* them through `createContextStateHost` so each snapshot's two
|
|
113
|
-
* destinations (host + server) carry the same authoritative
|
|
113
|
+
* destinations (host + server) carry the same authoritative render
|
|
114
114
|
* binding. Absent on dev / test code paths that don't have a real
|
|
115
115
|
* bootstrap; the server mirror is skipped in that case.
|
|
116
|
+
*
|
|
117
|
+
* Post-render-identity-collapse (2026-05-27): the previous
|
|
118
|
+
* `{sessionId, appId, stackItemId}` tuple collapsed to
|
|
119
|
+
* `{renderId, appId}` — render is the single identity key.
|
|
116
120
|
*/
|
|
117
121
|
export interface ContextPostIdentity {
|
|
118
|
-
readonly
|
|
122
|
+
readonly renderId: string;
|
|
119
123
|
readonly appId: string;
|
|
120
|
-
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Snapshot poster contract — fans the same snapshot out to two
|
|
127
|
+
* destinations:
|
|
128
|
+
*
|
|
129
|
+
* 1. {@link postUpdateModelContext} — spec-canonical
|
|
130
|
+
* `ui/update-model-context` notification. Production wires this
|
|
131
|
+
* to `app.updateModelContext(...)`; tests inject a spy.
|
|
132
|
+
* 2. {@link postContextMirror} — server mirror via the
|
|
133
|
+
* `ggui_runtime_sync_context` tools/call. Production wires this
|
|
134
|
+
* to a raw `postToParent` envelope (the existing host-relay
|
|
135
|
+
* path); tests inject a spy.
|
|
136
|
+
*
|
|
137
|
+
* Splitting the seam keeps spec-canonical MCP-Apps notifications
|
|
138
|
+
* (Destination 1) on the App method surface while leaving the
|
|
139
|
+
* non-spec `tools/call` server mirror (Destination 2) on the raw
|
|
140
|
+
* postMessage channel — matches the broader Phase 1.19b.3 followup
|
|
141
|
+
* scope (notifications via App; tools/call audit stays on raw post
|
|
142
|
+
* until a separate cleanup migrates it).
|
|
143
|
+
*/
|
|
144
|
+
/**
|
|
145
|
+
* Params shape accepted by the spec-canonical
|
|
146
|
+
* `app.updateModelContext` method — re-imported from
|
|
147
|
+
* `@modelcontextprotocol/ext-apps` via `Parameters<App['updateModelContext']>`
|
|
148
|
+
* in the runtime so the production poster can forward verbatim
|
|
149
|
+
* without a type cast.
|
|
150
|
+
*
|
|
151
|
+
* Tests building a recording {@link ContextSnapshotPoster} construct
|
|
152
|
+
* a `{ content: [{type: 'text', text}] }` payload — the only shape
|
|
153
|
+
* the iframe-runtime emits today.
|
|
154
|
+
*/
|
|
155
|
+
export interface UpdateModelContextParams {
|
|
156
|
+
content?: Array<{
|
|
157
|
+
type: 'text';
|
|
158
|
+
text: string;
|
|
159
|
+
}>;
|
|
160
|
+
structuredContent?: Record<string, unknown>;
|
|
161
|
+
}
|
|
162
|
+
export interface ContextSnapshotPoster {
|
|
163
|
+
/**
|
|
164
|
+
* Fire the spec-canonical `ui/update-model-context` notification
|
|
165
|
+
* carrying the prose snapshot text. Production: routes through
|
|
166
|
+
* `app.updateModelContext` (no-op when no App is bound).
|
|
167
|
+
*/
|
|
168
|
+
readonly postUpdateModelContext: (params: UpdateModelContextParams) => void;
|
|
169
|
+
/**
|
|
170
|
+
* Fire the `ggui_runtime_sync_context` tools/call server mirror.
|
|
171
|
+
* Production: raw postMessage envelope (the host relays to the
|
|
172
|
+
* MCP server). Caller is responsible for honoring `identity`
|
|
173
|
+
* presence — pass an undefined-returning closure to disable the
|
|
174
|
+
* mirror.
|
|
175
|
+
*/
|
|
176
|
+
readonly postContextMirror: (params: {
|
|
177
|
+
readonly renderId: string;
|
|
178
|
+
readonly appId: string;
|
|
179
|
+
readonly snapshot: Record<string, unknown>;
|
|
180
|
+
}) => void;
|
|
121
181
|
}
|
|
122
182
|
/**
|
|
123
183
|
* React surface the host factories need. Production callers pass
|
|
@@ -133,7 +193,7 @@ export type ReactCoreForHost = Pick<typeof import('react'), 'createElement' | 'F
|
|
|
133
193
|
* these per declared slot (via {@link createContextStateHost}).
|
|
134
194
|
*
|
|
135
195
|
* Built as a factory so the runtime can pass dependencies (`react`,
|
|
136
|
-
* `
|
|
196
|
+
* `poster`, `consoleWarn`) without re-importing in this module
|
|
137
197
|
* — keeps the file pure and unit-testable.
|
|
138
198
|
*
|
|
139
199
|
* Exported because `__tests__/context-observer.test.ts` mounts it
|
|
@@ -141,7 +201,7 @@ export type ReactCoreForHost = Pick<typeof import('react'), 'createElement' | 'F
|
|
|
141
201
|
*/
|
|
142
202
|
export declare function createSingleSlotProvider(deps: {
|
|
143
203
|
readonly react: ReactCoreForHost;
|
|
144
|
-
readonly
|
|
204
|
+
readonly poster: ContextSnapshotPoster;
|
|
145
205
|
readonly consoleWarn?: (...args: unknown[]) => void;
|
|
146
206
|
/** Identity bundle threaded into every snapshot post — server
|
|
147
207
|
* mirror skipped when undefined. */
|
|
@@ -174,7 +234,7 @@ export declare function createSingleSlotProvider(deps: {
|
|
|
174
234
|
*/
|
|
175
235
|
export declare function createContextStateHost(deps: {
|
|
176
236
|
readonly react: ReactCoreForHost;
|
|
177
|
-
readonly
|
|
237
|
+
readonly poster: ContextSnapshotPoster;
|
|
178
238
|
readonly consoleWarn?: (...args: unknown[]) => void;
|
|
179
239
|
/** Identity bundle for the server-mirror destination. Threaded
|
|
180
240
|
* unchanged into `createSingleSlotProvider` so every slot's debounced
|
|
@@ -202,4 +262,4 @@ export declare function createContextStateHost(deps: {
|
|
|
202
262
|
* bare module). Runtime call sites pass the active slots so the leak
|
|
203
263
|
* path is closed by construction.
|
|
204
264
|
*/
|
|
205
|
-
export declare function reemitLastContextValues(
|
|
265
|
+
export declare function reemitLastContextValues(poster: ContextSnapshotPoster, activeSlotNames?: ReadonlySet<string>, identity?: ContextPostIdentity): void;
|
package/dist/context-observer.js
CHANGED
|
@@ -82,64 +82,44 @@ function specFor(slot) {
|
|
|
82
82
|
* — the existing path; agent's LLM reads these via
|
|
83
83
|
* `read_widget_context` for in-turn awareness.
|
|
84
84
|
* 2. `tools/call` for `ggui_runtime_sync_context` — the server mirror.
|
|
85
|
-
* Server stores the snapshot on the active
|
|
85
|
+
* Server stores the snapshot on the active Render; chat-
|
|
86
86
|
* history rehydrate seeds `contextSlots[i].default` with the
|
|
87
87
|
* snapshotted values, restoring the user's last-known state.
|
|
88
88
|
*
|
|
89
|
-
* Both
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* is needed: postMessage isn't a promise-returning API; both calls
|
|
94
|
-
* return synchronously and the channel handles the ordering. This
|
|
95
|
-
* is the right primitive for a snapshot mirror — neither destination
|
|
96
|
-
* needs to wait for the other to ACK.
|
|
89
|
+
* Both destinations are fire-and-forget; the channel queues them
|
|
90
|
+
* back-to-back, no sequential dependency between them. This is the
|
|
91
|
+
* right primitive for a snapshot mirror — neither destination needs
|
|
92
|
+
* to wait for the other to ACK.
|
|
97
93
|
*
|
|
98
94
|
* Snapshot replaces an earlier per-slot delta format
|
|
99
95
|
* (`[ggui:context-slot] {slot,value}`). Empirical: claude.ai's host
|
|
100
96
|
* treats each `ui/update-model-context` post as a REPLACE of the
|
|
101
97
|
* widget's tracked context, not a per-slot merge. Server mirror
|
|
102
|
-
* matches: REPLACE-per-
|
|
103
|
-
*
|
|
98
|
+
* matches: REPLACE-per-renderId, last-write-wins. Both destinations
|
|
99
|
+
* therefore stay structurally consistent.
|
|
104
100
|
*
|
|
105
101
|
* Server-mirror skipped when `identity` is undefined — dev/test code
|
|
106
102
|
* paths that don't supply a real bootstrap shouldn't pollute the
|
|
107
103
|
* server's store with synthetic ids.
|
|
108
104
|
*/
|
|
109
|
-
function postContextSnapshot(
|
|
105
|
+
function postContextSnapshot(poster, snapshot, identity) {
|
|
110
106
|
// Destination 1: host. Always fires.
|
|
111
107
|
const text = `[ggui:context] ${JSON.stringify(snapshot)}`;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
type: 'text',
|
|
120
|
-
text,
|
|
121
|
-
},
|
|
122
|
-
],
|
|
123
|
-
},
|
|
108
|
+
poster.postUpdateModelContext({
|
|
109
|
+
content: [
|
|
110
|
+
{
|
|
111
|
+
type: 'text',
|
|
112
|
+
text,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
124
115
|
});
|
|
125
116
|
// Destination 2: server mirror. Skipped when identity is absent
|
|
126
|
-
// (dev / test paths without a real bootstrap).
|
|
127
|
-
// are postMessage — the channel queues these back-to-back, no
|
|
128
|
-
// sequential dependency between them.
|
|
117
|
+
// (dev / test paths without a real bootstrap).
|
|
129
118
|
if (identity) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
params: {
|
|
135
|
-
name: 'ggui_runtime_sync_context',
|
|
136
|
-
arguments: {
|
|
137
|
-
sessionId: identity.sessionId,
|
|
138
|
-
appId: identity.appId,
|
|
139
|
-
stackItemId: identity.stackItemId,
|
|
140
|
-
snapshot,
|
|
141
|
-
},
|
|
142
|
-
},
|
|
119
|
+
poster.postContextMirror({
|
|
120
|
+
renderId: identity.renderId,
|
|
121
|
+
appId: identity.appId,
|
|
122
|
+
snapshot,
|
|
143
123
|
});
|
|
144
124
|
}
|
|
145
125
|
}
|
|
@@ -163,14 +143,14 @@ function buildSnapshot() {
|
|
|
163
143
|
* these per declared slot (via {@link createContextStateHost}).
|
|
164
144
|
*
|
|
165
145
|
* Built as a factory so the runtime can pass dependencies (`react`,
|
|
166
|
-
* `
|
|
146
|
+
* `poster`, `consoleWarn`) without re-importing in this module
|
|
167
147
|
* — keeps the file pure and unit-testable.
|
|
168
148
|
*
|
|
169
149
|
* Exported because `__tests__/context-observer.test.ts` mounts it
|
|
170
150
|
* standalone to verify the value→post→Provider cycle.
|
|
171
151
|
*/
|
|
172
152
|
export function createSingleSlotProvider(deps) {
|
|
173
|
-
const { react,
|
|
153
|
+
const { react, poster, consoleWarn, identity } = deps;
|
|
174
154
|
const SingleSlotProvider = (props) => {
|
|
175
155
|
const { slot, children } = props;
|
|
176
156
|
// Slot-owned state. Re-mounts re-seed from `slot.default` because
|
|
@@ -204,7 +184,7 @@ export function createSingleSlotProvider(deps) {
|
|
|
204
184
|
// treats `ui/update-model-context` as REPLACE, so deltas
|
|
205
185
|
// wiped the un-mentioned slots).
|
|
206
186
|
contextSlotLastValues.set(slot.name, value);
|
|
207
|
-
postContextSnapshot(
|
|
187
|
+
postContextSnapshot(poster, buildSnapshot(), identity);
|
|
208
188
|
};
|
|
209
189
|
if (slot.debounceMs <= 0) {
|
|
210
190
|
// Immediate path — tests + step / tab switches use this.
|
|
@@ -292,7 +272,7 @@ export function createContextStateHost(deps) {
|
|
|
292
272
|
* bare module). Runtime call sites pass the active slots so the leak
|
|
293
273
|
* path is closed by construction.
|
|
294
274
|
*/
|
|
295
|
-
export function reemitLastContextValues(
|
|
275
|
+
export function reemitLastContextValues(poster, activeSlotNames, identity) {
|
|
296
276
|
if (activeSlotNames !== undefined) {
|
|
297
277
|
// Drain stale keys before re-emitting. Keep the map a faithful
|
|
298
278
|
// reflection of the active mount.
|
|
@@ -307,6 +287,6 @@ export function reemitLastContextValues(postToParent, activeSlotNames, identity)
|
|
|
307
287
|
// delta on the host side; the host ended up with only the
|
|
308
288
|
// LAST-fired slot. One snapshot delivers all slots atomically.
|
|
309
289
|
if (contextSlotLastValues.size > 0) {
|
|
310
|
-
postContextSnapshot(
|
|
290
|
+
postContextSnapshot(poster, buildSnapshot(), identity);
|
|
311
291
|
}
|
|
312
292
|
}
|