@ggui-ai/iframe-runtime 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +33 -0
- package/dist/bootstrap.d.ts +72 -0
- package/dist/bootstrap.js +500 -0
- package/dist/canvas/animator/animator-host.d.ts +63 -0
- package/dist/canvas/animator/animator-host.js +105 -0
- package/dist/canvas/animator/animator-pill.d.ts +42 -0
- package/dist/canvas/animator/animator-pill.js +119 -0
- package/dist/canvas/animator/state-machine.d.ts +106 -0
- package/dist/canvas/animator/state-machine.js +150 -0
- package/dist/canvas/canvas-shell.d.ts +77 -0
- package/dist/canvas/canvas-shell.js +117 -0
- package/dist/canvas/display-mode-policy.d.ts +104 -0
- package/dist/canvas/display-mode-policy.js +98 -0
- package/dist/canvas/mount.d.ts +150 -0
- package/dist/canvas/mount.js +202 -0
- package/dist/canvas/nav-stack.d.ts +133 -0
- package/dist/canvas/nav-stack.js +172 -0
- package/dist/channel-transport.d.ts +182 -0
- package/dist/channel-transport.js +344 -0
- package/dist/channels/channel-payload.d.ts +20 -0
- package/dist/channels/channel-payload.js +34 -0
- package/dist/channels/data.d.ts +48 -0
- package/dist/channels/data.js +103 -0
- package/dist/channels/drain-ack.d.ts +13 -0
- package/dist/channels/drain-ack.js +15 -0
- package/dist/channels/feedback.d.ts +14 -0
- package/dist/channels/feedback.js +19 -0
- package/dist/channels/index.d.ts +7 -0
- package/dist/channels/index.js +7 -0
- package/dist/channels/props-update.d.ts +53 -0
- package/dist/channels/props-update.js +130 -0
- package/dist/channels/push.d.ts +47 -0
- package/dist/channels/push.js +54 -0
- package/dist/channels/system.d.ts +26 -0
- package/dist/channels/system.js +44 -0
- package/dist/compiled-validators.d.ts +40 -0
- package/dist/compiled-validators.js +74 -0
- package/dist/context-observer.d.ts +205 -0
- package/dist/context-observer.js +312 -0
- package/dist/gadget-loader.d.ts +118 -0
- package/dist/gadget-loader.js +136 -0
- package/dist/globals.d.ts +234 -0
- package/dist/globals.js +101 -0
- package/dist/host-context-emitter.d.ts +91 -0
- package/dist/host-context-emitter.js +213 -0
- package/dist/iframe-runtime.js +261 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +4 -0
- package/dist/lifecycle.d.ts +66 -0
- package/dist/lifecycle.js +49 -0
- package/dist/mcp-app-iframe-host.d.ts +69 -0
- package/dist/mcp-app-iframe-host.js +214 -0
- package/dist/observability.d.ts +225 -0
- package/dist/observability.js +28 -0
- package/dist/protocol-error.d.ts +199 -0
- package/dist/protocol-error.js +116 -0
- package/dist/provisional-renderer.d.ts +62 -0
- package/dist/provisional-renderer.js +323 -0
- package/dist/react-renderer.d.ts +95 -0
- package/dist/react-renderer.js +343 -0
- package/dist/registry-subscribe.d.ts +102 -0
- package/dist/registry-subscribe.js +319 -0
- package/dist/runtime.d.ts +531 -0
- package/dist/runtime.js +2623 -0
- package/dist/server.d.ts +36 -0
- package/dist/server.js +72 -0
- package/dist/stack-item-renderer.d.ts +103 -0
- package/dist/stack-item-renderer.js +270 -0
- package/dist/stack.d.ts +90 -0
- package/dist/stack.js +118 -0
- package/dist/status-dom.d.ts +72 -0
- package/dist/status-dom.js +51 -0
- package/dist/system-cards/NoCredentialsCard.d.ts +33 -0
- package/dist/system-cards/NoCredentialsCard.js +189 -0
- package/dist/system-cards/ProtocolProbeCard.d.ts +64 -0
- package/dist/system-cards/ProtocolProbeCard.js +463 -0
- package/dist/system-cards/QrCode.d.ts +46 -0
- package/dist/system-cards/QrCode.js +55 -0
- package/dist/system-cards/host-detect.d.ts +31 -0
- package/dist/system-cards/host-detect.js +108 -0
- package/dist/system-cards/host-intents.d.ts +85 -0
- package/dist/system-cards/host-intents.js +170 -0
- package/dist/system-cards/index.d.ts +70 -0
- package/dist/system-cards/index.js +134 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.js +3 -0
- package/dist/validation.d.ts +83 -0
- package/dist/validation.js +106 -0
- package/dist/wire-config.d.ts +214 -0
- package/dist/wire-config.js +290 -0
- package/package.json +81 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `props_update` channel handler — factored out of `runtime.ts` into
|
|
3
|
+
* the `@ggui-ai/channel-client` layer.
|
|
4
|
+
*
|
|
5
|
+
* Receives a `{stackItemId, props}` payload from the live channel, validates
|
|
6
|
+
* the new props against the stack item's cached `propsSpec`, patches
|
|
7
|
+
* the in-model entry, and re-applies the renderer so React picks up
|
|
8
|
+
* the change.
|
|
9
|
+
*
|
|
10
|
+
* Skips when:
|
|
11
|
+
* - `stackItemId` is empty or not a string.
|
|
12
|
+
* - `props` is null / not an object (defensive — server can't emit
|
|
13
|
+
* this shape, but the dispatcher routes the frame on type alone).
|
|
14
|
+
* - No matching stack item exists (server raced ahead of our pop).
|
|
15
|
+
* - The matched item is `mcpApps` / `system` (no `propsSpec`; server
|
|
16
|
+
* should never emit `props_update` for these).
|
|
17
|
+
* - The new props fail validation against the cached spec.
|
|
18
|
+
*
|
|
19
|
+
* An OPTIONAL polling descriptor provides a fallback transport —
|
|
20
|
+
* when `polling` is supplied at factory time, `PollingTransport`
|
|
21
|
+
* polls `/api/bootstrap/<shortCode>` and synthesizes a frame
|
|
22
|
+
* whenever `propsJson` diffs from the last seen hash. WS hosts are
|
|
23
|
+
* unaffected (the `polling` descriptor is inert under `WSTransport`).
|
|
24
|
+
*/
|
|
25
|
+
import type { ChannelHandler } from '@ggui-ai/channel-client';
|
|
26
|
+
import type { PropsUpdatePayload } from '@ggui-ai/protocol';
|
|
27
|
+
import type { StackRenderer } from '../stack-item-renderer.js';
|
|
28
|
+
import type { StackModel } from '../stack.js';
|
|
29
|
+
export interface PropsUpdateHandlerDeps {
|
|
30
|
+
readonly stackModel: StackModel;
|
|
31
|
+
readonly getStackRenderer: () => StackRenderer;
|
|
32
|
+
/**
|
|
33
|
+
* Optional polling fallback URL. When the registry's chosen transport
|
|
34
|
+
* is `PollingTransport` (e.g. bootstrap missing wsUrl, or
|
|
35
|
+
* `FailoverHandle` swapped WS → polling after a hard failure), this
|
|
36
|
+
* URL is fetched on each tick. The response body is expected to carry
|
|
37
|
+
* `propsJson` + `stackItemId` (the same shape `/api/bootstrap/<code>`
|
|
38
|
+
* returns); the handler diffs `propsJson` between ticks and synthesizes
|
|
39
|
+
* a `props_update` frame whenever it changes. WS hosts ignore this
|
|
40
|
+
* descriptor (polling is inert under `WSTransport`).
|
|
41
|
+
*
|
|
42
|
+
* Producer: server stamps `bootstrap.pollingUrl` on the bootstrap
|
|
43
|
+
* envelope. Consumer: iframe-runtime threads it here at handler
|
|
44
|
+
* registration time. Absent → no polling fallback (WS-only mode).
|
|
45
|
+
*/
|
|
46
|
+
readonly pollingUrl?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Polling cadence in milliseconds. Defaults to 2000ms per the B5
|
|
49
|
+
* plan (well inside the 10s drain-claim budget; 5 ticks of safety).
|
|
50
|
+
*/
|
|
51
|
+
readonly pollingIntervalMs?: number;
|
|
52
|
+
}
|
|
53
|
+
export declare function createPropsUpdateHandler(deps: PropsUpdateHandlerDeps): ChannelHandler<PropsUpdatePayload>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `props_update` channel handler — factored out of `runtime.ts` into
|
|
3
|
+
* the `@ggui-ai/channel-client` layer.
|
|
4
|
+
*
|
|
5
|
+
* Receives a `{stackItemId, props}` payload from the live channel, validates
|
|
6
|
+
* the new props against the stack item's cached `propsSpec`, patches
|
|
7
|
+
* the in-model entry, and re-applies the renderer so React picks up
|
|
8
|
+
* the change.
|
|
9
|
+
*
|
|
10
|
+
* Skips when:
|
|
11
|
+
* - `stackItemId` is empty or not a string.
|
|
12
|
+
* - `props` is null / not an object (defensive — server can't emit
|
|
13
|
+
* this shape, but the dispatcher routes the frame on type alone).
|
|
14
|
+
* - No matching stack item exists (server raced ahead of our pop).
|
|
15
|
+
* - The matched item is `mcpApps` / `system` (no `propsSpec`; server
|
|
16
|
+
* should never emit `props_update` for these).
|
|
17
|
+
* - The new props fail validation against the cached spec.
|
|
18
|
+
*
|
|
19
|
+
* An OPTIONAL polling descriptor provides a fallback transport —
|
|
20
|
+
* when `polling` is supplied at factory time, `PollingTransport`
|
|
21
|
+
* polls `/api/bootstrap/<shortCode>` and synthesizes a frame
|
|
22
|
+
* whenever `propsJson` diffs from the last seen hash. WS hosts are
|
|
23
|
+
* unaffected (the `polling` descriptor is inert under `WSTransport`).
|
|
24
|
+
*/
|
|
25
|
+
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
|
+
export function createPropsUpdateHandler(deps) {
|
|
42
|
+
const polling = buildPollingDescriptor(deps);
|
|
43
|
+
return {
|
|
44
|
+
type: 'props_update',
|
|
45
|
+
onMessage: async (payload) => {
|
|
46
|
+
const { stackItemId, props } = payload;
|
|
47
|
+
if (typeof stackItemId !== 'string' || stackItemId.length === 0)
|
|
48
|
+
return;
|
|
49
|
+
if (props === null || typeof props !== 'object')
|
|
50
|
+
return;
|
|
51
|
+
const target = deps.stackModel
|
|
52
|
+
.snapshot()
|
|
53
|
+
.find((item) => item.id === stackItemId);
|
|
54
|
+
if (target === undefined)
|
|
55
|
+
return;
|
|
56
|
+
if (target.type === 'mcpApps' || target.type === 'system')
|
|
57
|
+
return;
|
|
58
|
+
const result = validateInboundPropsPayload(target.propsSpec, props);
|
|
59
|
+
if (!result.valid)
|
|
60
|
+
return;
|
|
61
|
+
const nextSnapshot = deps.stackModel
|
|
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
|
+
};
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `push` channel handler — folds an inbound `push` frame into the
|
|
3
|
+
* stack model, re-renders the placeholder DOM, refreshes the status
|
|
4
|
+
* line, then (when triad wiring is present) applies the new stack
|
|
5
|
+
* through the React renderer + activates the channel-transport router
|
|
6
|
+
* against the new top of stack.
|
|
7
|
+
*
|
|
8
|
+
* The placeholder side-effects (stack model upsert + DOM render +
|
|
9
|
+
* status update) are unconditional — they're load-bearing for the
|
|
10
|
+
* C7a-era specs that boot WITHOUT triad wiring (boot.test.ts in
|
|
11
|
+
* particular). Triad-only consumers (production runtime) pass the
|
|
12
|
+
* `getStackRenderer` + `getChannelTransport` thunks; tests that
|
|
13
|
+
* exercise the placeholder path only leave them off.
|
|
14
|
+
*
|
|
15
|
+
* Pre-B3b: the placeholder fold lived in a separate
|
|
16
|
+
* `handleServerMessage` call in `runtime.ts`, fired before triad
|
|
17
|
+
* dispatch. B3b absorbed both into this single handler so the
|
|
18
|
+
* registry-owned WS transport is the sole dispatch site.
|
|
19
|
+
*/
|
|
20
|
+
import type { ChannelHandler } from '@ggui-ai/channel-client';
|
|
21
|
+
import type { PushPayload } from '@ggui-ai/protocol';
|
|
22
|
+
import type { ChannelTransportRouter } from '../channel-transport.js';
|
|
23
|
+
import type { StackRenderer } from '../stack-item-renderer.js';
|
|
24
|
+
import type { StackModel } from '../stack.js';
|
|
25
|
+
import { type StatusRefs } from '../status-dom.js';
|
|
26
|
+
export interface PushHandlerDeps {
|
|
27
|
+
readonly stackModel: StackModel;
|
|
28
|
+
/**
|
|
29
|
+
* Status DOM refs the placeholder fold updates. Required — even
|
|
30
|
+
* triad-mode consumers want the status text to reflect the live
|
|
31
|
+
* count for operator-visible boot debugging.
|
|
32
|
+
*/
|
|
33
|
+
readonly statusRefs: StatusRefs;
|
|
34
|
+
/**
|
|
35
|
+
* Triad-mode hook — returns the React stack renderer. Absent in
|
|
36
|
+
* placeholder-only mode (tests, and callers that don't mount the
|
|
37
|
+
* React stack).
|
|
38
|
+
*/
|
|
39
|
+
readonly getStackRenderer?: () => StackRenderer;
|
|
40
|
+
/**
|
|
41
|
+
* Triad-mode hook — returns the per-channel transport router so the
|
|
42
|
+
* push handler can activate / tear down `source.tool` channels on
|
|
43
|
+
* the new top of stack. Absent in placeholder-only mode.
|
|
44
|
+
*/
|
|
45
|
+
readonly getChannelTransport?: () => ChannelTransportRouter;
|
|
46
|
+
}
|
|
47
|
+
export declare function createPushHandler(deps: PushHandlerDeps): ChannelHandler<PushPayload>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `push` channel handler — folds an inbound `push` frame into the
|
|
3
|
+
* stack model, re-renders the placeholder DOM, refreshes the status
|
|
4
|
+
* line, then (when triad wiring is present) applies the new stack
|
|
5
|
+
* through the React renderer + activates the channel-transport router
|
|
6
|
+
* against the new top of stack.
|
|
7
|
+
*
|
|
8
|
+
* The placeholder side-effects (stack model upsert + DOM render +
|
|
9
|
+
* status update) are unconditional — they're load-bearing for the
|
|
10
|
+
* C7a-era specs that boot WITHOUT triad wiring (boot.test.ts in
|
|
11
|
+
* particular). Triad-only consumers (production runtime) pass the
|
|
12
|
+
* `getStackRenderer` + `getChannelTransport` thunks; tests that
|
|
13
|
+
* exercise the placeholder path only leave them off.
|
|
14
|
+
*
|
|
15
|
+
* Pre-B3b: the placeholder fold lived in a separate
|
|
16
|
+
* `handleServerMessage` call in `runtime.ts`, fired before triad
|
|
17
|
+
* dispatch. B3b absorbed both into this single handler so the
|
|
18
|
+
* registry-owned WS transport is the sole dispatch site.
|
|
19
|
+
*/
|
|
20
|
+
import { refreshStackDom, setConnectedStatus, } from '../status-dom.js';
|
|
21
|
+
export function createPushHandler(deps) {
|
|
22
|
+
return {
|
|
23
|
+
type: 'push',
|
|
24
|
+
onMessage: async (payload) => {
|
|
25
|
+
// 1. Placeholder fold — unconditional. Mirrors the pre-B3b
|
|
26
|
+
// handleServerMessage path so non-triad consumers see the
|
|
27
|
+
// correct stack count + placeholder DOM.
|
|
28
|
+
deps.stackModel.upsert(payload.stackItem);
|
|
29
|
+
refreshStackDom(deps.statusRefs, deps.stackModel);
|
|
30
|
+
setConnectedStatus(deps.statusRefs, deps.stackModel);
|
|
31
|
+
// 2. Triad-mode work — only fires when the production runtime
|
|
32
|
+
// supplied the renderer + transport thunks.
|
|
33
|
+
const stackRenderer = deps.getStackRenderer?.();
|
|
34
|
+
const channelTransport = deps.getChannelTransport?.();
|
|
35
|
+
if (stackRenderer === undefined)
|
|
36
|
+
return;
|
|
37
|
+
const snapshot = deps.stackModel.snapshot();
|
|
38
|
+
await stackRenderer.applyStack(snapshot);
|
|
39
|
+
if (channelTransport === undefined)
|
|
40
|
+
return;
|
|
41
|
+
const top = snapshot[snapshot.length - 1];
|
|
42
|
+
if (top !== undefined &&
|
|
43
|
+
top.type !== 'mcpApps' &&
|
|
44
|
+
top.type !== 'system') {
|
|
45
|
+
channelTransport.applyStackItem({
|
|
46
|
+
stackItemId: top.id,
|
|
47
|
+
...(top.streamSpec !== undefined
|
|
48
|
+
? { streamSpec: top.streamSpec }
|
|
49
|
+
: {}),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `system` channel handler — emits the `auth-required` observability
|
|
3
|
+
* event when the server signals an OAuth consent flow.
|
|
4
|
+
*
|
|
5
|
+
* Absorbed from `handleObservableMessage` in `runtime.ts`. The
|
|
6
|
+
* handler is now the sole dispatch surface for `system` frames in
|
|
7
|
+
* the iframe runtime; other system actions (e.g.
|
|
8
|
+
* `credential_ready`) have downstream listeners elsewhere and are
|
|
9
|
+
* intentionally skipped at this layer.
|
|
10
|
+
*
|
|
11
|
+
* Skip conditions (all silent — host gets no row, which is the
|
|
12
|
+
* correct behaviour: a system frame the host can't act on shouldn't
|
|
13
|
+
* surface as a spurious consent overlay trigger):
|
|
14
|
+
* - `action !== 'auth_required'` — e.g. `'credential_ready'` is a
|
|
15
|
+
* different downstream signal.
|
|
16
|
+
* - `consentUrl` absent — the host cannot redirect the user without
|
|
17
|
+
* it, so there's nothing actionable to surface.
|
|
18
|
+
* - `onObserve` absent — the observation has nowhere to go.
|
|
19
|
+
*/
|
|
20
|
+
import type { ChannelHandler } from '@ggui-ai/channel-client';
|
|
21
|
+
import type { SystemPayload } from '@ggui-ai/protocol';
|
|
22
|
+
import type { ObservabilityEmitter } from '../observability.js';
|
|
23
|
+
export interface SystemHandlerDeps {
|
|
24
|
+
readonly onObserve?: ObservabilityEmitter;
|
|
25
|
+
}
|
|
26
|
+
export declare function createSystemHandler(deps: SystemHandlerDeps): ChannelHandler<SystemPayload>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `system` channel handler — emits the `auth-required` observability
|
|
3
|
+
* event when the server signals an OAuth consent flow.
|
|
4
|
+
*
|
|
5
|
+
* Absorbed from `handleObservableMessage` in `runtime.ts`. The
|
|
6
|
+
* handler is now the sole dispatch surface for `system` frames in
|
|
7
|
+
* the iframe runtime; other system actions (e.g.
|
|
8
|
+
* `credential_ready`) have downstream listeners elsewhere and are
|
|
9
|
+
* intentionally skipped at this layer.
|
|
10
|
+
*
|
|
11
|
+
* Skip conditions (all silent — host gets no row, which is the
|
|
12
|
+
* correct behaviour: a system frame the host can't act on shouldn't
|
|
13
|
+
* surface as a spurious consent overlay trigger):
|
|
14
|
+
* - `action !== 'auth_required'` — e.g. `'credential_ready'` is a
|
|
15
|
+
* different downstream signal.
|
|
16
|
+
* - `consentUrl` absent — the host cannot redirect the user without
|
|
17
|
+
* it, so there's nothing actionable to surface.
|
|
18
|
+
* - `onObserve` absent — the observation has nowhere to go.
|
|
19
|
+
*/
|
|
20
|
+
export function createSystemHandler(deps) {
|
|
21
|
+
return {
|
|
22
|
+
type: 'system',
|
|
23
|
+
onMessage: (payload) => {
|
|
24
|
+
if (deps.onObserve === undefined)
|
|
25
|
+
return;
|
|
26
|
+
emitAuthRequiredFromSystemFrame(payload, deps.onObserve);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function emitAuthRequiredFromSystemFrame(payload, emit) {
|
|
31
|
+
if (payload.action !== 'auth_required')
|
|
32
|
+
return;
|
|
33
|
+
if (typeof payload.consentUrl !== 'string' || payload.consentUrl.length === 0)
|
|
34
|
+
return;
|
|
35
|
+
const event = {
|
|
36
|
+
kind: 'auth-required',
|
|
37
|
+
provider: payload.serviceId,
|
|
38
|
+
authUrl: payload.consentUrl,
|
|
39
|
+
...(payload.displayName !== undefined ? { displayName: payload.displayName } : {}),
|
|
40
|
+
...(payload.scopes !== undefined ? { scopes: payload.scopes } : {}),
|
|
41
|
+
...(payload.message !== undefined ? { message: payload.message } : {}),
|
|
42
|
+
};
|
|
43
|
+
emit(event);
|
|
44
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loader for precompiled, eval-free contract validators.
|
|
3
|
+
*
|
|
4
|
+
* The renderer iframe runs under a strict CSP with no `'unsafe-eval'`,
|
|
5
|
+
* so it cannot call `ajv.compile()` (which builds validators via
|
|
6
|
+
* `new Function`). The server precompiles each contract sub-schema into
|
|
7
|
+
* a standalone, self-contained ESM validator-module source string and
|
|
8
|
+
* ships them on the bootstrap as `compiledValidators`. This module
|
|
9
|
+
* loads each source via a `blob:` dynamic import — governed by
|
|
10
|
+
* `script-src` (which permits `blob:`), not by `unsafe-eval` — and
|
|
11
|
+
* exposes the resulting `ValidateFunction`s as a
|
|
12
|
+
* {@link CompiledValidatorSet}.
|
|
13
|
+
*/
|
|
14
|
+
import type { CompiledContractValidators } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
15
|
+
import type { ValidateFunction } from '@ggui-ai/protocol';
|
|
16
|
+
/**
|
|
17
|
+
* Precompiled validators for the active stack item's contract, keyed to
|
|
18
|
+
* match the four runtime-validated surfaces. `props` is a single
|
|
19
|
+
* validator (the synthesized props wrapper); the other three are keyed
|
|
20
|
+
* by action name / channel name / slot name.
|
|
21
|
+
*/
|
|
22
|
+
export interface CompiledValidatorSet {
|
|
23
|
+
readonly props?: ValidateFunction;
|
|
24
|
+
readonly actions: ReadonlyMap<string, ValidateFunction>;
|
|
25
|
+
readonly streams: ReadonlyMap<string, ValidateFunction>;
|
|
26
|
+
readonly context: ReadonlyMap<string, ValidateFunction>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* An empty set — no precompiled validators. Callers fall back to
|
|
30
|
+
* in-iframe `ajv.compile()`, which the CSP may then block; the empty
|
|
31
|
+
* set is the honest "nothing precompiled" state, not a fix on its own.
|
|
32
|
+
*/
|
|
33
|
+
export declare const EMPTY_COMPILED_VALIDATOR_SET: CompiledValidatorSet;
|
|
34
|
+
/**
|
|
35
|
+
* Load every precompiled validator on the bootstrap into a
|
|
36
|
+
* {@link CompiledValidatorSet}. Each module loads independently; one
|
|
37
|
+
* failure never blocks the others. Returns the empty set when the
|
|
38
|
+
* bootstrap carries no `compiledValidators`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function loadCompiledValidators(meta: CompiledContractValidators | undefined, warn?: (message: string, detail: unknown) => void): Promise<CompiledValidatorSet>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { loadModule } from '@ggui-ai/design/module-loader';
|
|
2
|
+
/**
|
|
3
|
+
* An empty set — no precompiled validators. Callers fall back to
|
|
4
|
+
* in-iframe `ajv.compile()`, which the CSP may then block; the empty
|
|
5
|
+
* set is the honest "nothing precompiled" state, not a fix on its own.
|
|
6
|
+
*/
|
|
7
|
+
export const EMPTY_COMPILED_VALIDATOR_SET = {
|
|
8
|
+
actions: new Map(),
|
|
9
|
+
streams: new Map(),
|
|
10
|
+
context: new Map(),
|
|
11
|
+
};
|
|
12
|
+
/** A precompiled validator module's `default` export is an Ajv
|
|
13
|
+
* standalone validate function. The runtime check is a `typeof`
|
|
14
|
+
* function test; the assertion that it is specifically a
|
|
15
|
+
* `ValidateFunction` is sound because the server emitted it as one. */
|
|
16
|
+
function isValidateFunction(value) {
|
|
17
|
+
return typeof value === 'function';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Load one ESM validator-module source into a `ValidateFunction` via a
|
|
21
|
+
* `blob:` dynamic import. Returns `undefined` on any failure — a
|
|
22
|
+
* missing validator degrades to the in-iframe fallback rather than
|
|
23
|
+
* crashing the boot.
|
|
24
|
+
*/
|
|
25
|
+
async function loadOne(source, label, warn) {
|
|
26
|
+
try {
|
|
27
|
+
const mod = await loadModule(source);
|
|
28
|
+
const fn = mod['default'];
|
|
29
|
+
if (!isValidateFunction(fn)) {
|
|
30
|
+
warn?.(`[ggui:validators] ${label}: module has no default-export function`, mod);
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return fn;
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
warn?.(`[ggui:validators] ${label}: failed to load precompiled validator`, err);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Load every precompiled validator on the bootstrap into a
|
|
42
|
+
* {@link CompiledValidatorSet}. Each module loads independently; one
|
|
43
|
+
* failure never blocks the others. Returns the empty set when the
|
|
44
|
+
* bootstrap carries no `compiledValidators`.
|
|
45
|
+
*/
|
|
46
|
+
export async function loadCompiledValidators(meta, warn) {
|
|
47
|
+
if (meta === undefined)
|
|
48
|
+
return EMPTY_COMPILED_VALIDATOR_SET;
|
|
49
|
+
const loadGroup = async (group, kind) => {
|
|
50
|
+
const map = new Map();
|
|
51
|
+
if (group === undefined)
|
|
52
|
+
return map;
|
|
53
|
+
await Promise.all(Object.entries(group).map(async ([name, source]) => {
|
|
54
|
+
const fn = await loadOne(source, `${kind}.${name}`, warn);
|
|
55
|
+
if (fn !== undefined)
|
|
56
|
+
map.set(name, fn);
|
|
57
|
+
}));
|
|
58
|
+
return map;
|
|
59
|
+
};
|
|
60
|
+
const [props, actions, streams, context] = await Promise.all([
|
|
61
|
+
meta.props !== undefined
|
|
62
|
+
? loadOne(meta.props, 'props', warn)
|
|
63
|
+
: Promise.resolve(undefined),
|
|
64
|
+
loadGroup(meta.actions, 'action'),
|
|
65
|
+
loadGroup(meta.streams, 'stream'),
|
|
66
|
+
loadGroup(meta.context, 'context'),
|
|
67
|
+
]);
|
|
68
|
+
return {
|
|
69
|
+
...(props !== undefined ? { props } : {}),
|
|
70
|
+
actions,
|
|
71
|
+
streams,
|
|
72
|
+
context,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime owns Provider tree + useState per slot.
|
|
3
|
+
*
|
|
4
|
+
* **Architecture:**
|
|
5
|
+
*
|
|
6
|
+
* 1. At iframe boot, for each slot declared in the bootstrap's
|
|
7
|
+
* `contextSlots`, the runtime synthesizes one
|
|
8
|
+
* `React.createContext([default, () => {}])` and registers it under
|
|
9
|
+
* `globalThis.__ggui__.contexts[contextName]`. The Context value
|
|
10
|
+
* type is `readonly [value, setter]` — same shape `useState`
|
|
11
|
+
* returns — so the wire-package `useGguiContext` hook can read it
|
|
12
|
+
* via plain `useContext`.
|
|
13
|
+
* 2. The runtime mounts the user's component INSIDE a nested tree of
|
|
14
|
+
* `<SingleSlotProvider>`s. Each SingleSlotProvider owns the slot's
|
|
15
|
+
* `useState`, debounces value changes, validates against the slot's
|
|
16
|
+
* `schema`, and posts `ui/update-model-context` to the parent.
|
|
17
|
+
* 3. The Provider value passed downward is `[value, setValue] as const`
|
|
18
|
+
* — the user component reads both via
|
|
19
|
+
* `const [v, setV] = useGguiContext<T>(slotName)` (auto-generated
|
|
20
|
+
* by the boilerplate, one line per declared slot).
|
|
21
|
+
* 4. The last-emitted value per slot lives in a module-level
|
|
22
|
+
* `Map<slotName, value>` so the reconnect path can re-emit after a
|
|
23
|
+
* re-mount.
|
|
24
|
+
*
|
|
25
|
+
* **Why the runtime hoists the Provider tree.** An earlier design had
|
|
26
|
+
* the boilerplate emit `useState` + `<Provider>` wraps INSIDE the user
|
|
27
|
+
* component's return; the runtime mounted observers as SIBLINGS,
|
|
28
|
+
* outside the user component, which meant every observer's
|
|
29
|
+
* `useContext` read the `createContext(default)` argument — never the
|
|
30
|
+
* live Provider value. The fix is to move both useState AND the
|
|
31
|
+
* Provider into the runtime layer; the user component now reads via a
|
|
32
|
+
* plain `useContext`-backed hook (`useGguiContext`).
|
|
33
|
+
*
|
|
34
|
+
* **Idempotency on re-mount.** Context references must stay stable
|
|
35
|
+
* across re-mounts so the LLM's destructured component code keeps
|
|
36
|
+
* working. {@link ensureContext} reuses an existing entry by
|
|
37
|
+
* `contextName` and only creates when absent. The `setter` placeholder
|
|
38
|
+
* stored alongside the default is overwritten at mount time by each
|
|
39
|
+
* SingleSlotProvider's real `setValue`.
|
|
40
|
+
*
|
|
41
|
+
* **JSON safety.** Each post validates the value against the slot's
|
|
42
|
+
* `schema` via {@link validateContextValue}. Mismatches log a dev-only
|
|
43
|
+
* `console.warn` and drop silently in production (per Q4 design lock).
|
|
44
|
+
*/
|
|
45
|
+
import type { Context, ReactElement, ReactNode } from 'react';
|
|
46
|
+
import type { JsonSchema } from '@ggui-ai/protocol';
|
|
47
|
+
import type { GguiContextRegistry } from './globals.js';
|
|
48
|
+
/**
|
|
49
|
+
* Wire-shape entry for a single contextSpec slot, mirrored from
|
|
50
|
+
* {@link import('@ggui-ai/protocol/integrations/mcp-apps').GguiBootstrapMeta.contextSlots}.
|
|
51
|
+
*/
|
|
52
|
+
export interface ContextSlotInfo {
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly contextName: string;
|
|
55
|
+
readonly schema: JsonSchema;
|
|
56
|
+
/** Provider seed value. Always populated by the server — the
|
|
57
|
+
* runtime owns useState per slot, so the seed is load-bearing. */
|
|
58
|
+
readonly default: unknown;
|
|
59
|
+
readonly debounceMs?: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Resolved slot metadata after the runtime has synthesized (or reused)
|
|
63
|
+
* the React Context object. Carried into {@link SingleSlotProvider} so
|
|
64
|
+
* the Provider can reuse the same Context reference across renders.
|
|
65
|
+
*/
|
|
66
|
+
export interface ResolvedContextSlot {
|
|
67
|
+
readonly name: string;
|
|
68
|
+
readonly contextName: string;
|
|
69
|
+
readonly schema: JsonSchema;
|
|
70
|
+
readonly debounceMs: number;
|
|
71
|
+
readonly default: unknown;
|
|
72
|
+
readonly contextRef: Context<readonly [unknown, (next: unknown) => void]>;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Module-level map keyed by `name` (the camelCase slot key, NOT the
|
|
76
|
+
* PascalCase contextName) tracking the most-recently-emitted value
|
|
77
|
+
* per slot. Used by the reconnect re-emission path so a new mount
|
|
78
|
+
* after WS reopen seeds the LLM context with the last-known values.
|
|
79
|
+
*
|
|
80
|
+
* Exported for unit tests.
|
|
81
|
+
*/
|
|
82
|
+
export declare const contextSlotLastValues: Map<string, unknown>;
|
|
83
|
+
/**
|
|
84
|
+
* Ensure a React Context is registered for `slot.contextName`. Idempotent —
|
|
85
|
+
* an entry already in the registry is REUSED (the LLM's destructured
|
|
86
|
+
* Context reference must remain stable across re-mounts). Only the
|
|
87
|
+
* absent case calls `react.createContext`.
|
|
88
|
+
*
|
|
89
|
+
* The Context value shape is `readonly [value, setter]` — same as
|
|
90
|
+
* `useState`, so the user-component's `useGguiContext` hook reads it
|
|
91
|
+
* via plain `useContext`. The setter stored at registration time is a
|
|
92
|
+
* no-op placeholder — each {@link SingleSlotProvider} mount overwrites
|
|
93
|
+
* it via the Provider's `value` prop.
|
|
94
|
+
*
|
|
95
|
+
* Returns the resolved slot with the live Context reference attached.
|
|
96
|
+
*/
|
|
97
|
+
export declare function ensureContext(registry: GguiContextRegistry, reactMod: {
|
|
98
|
+
createContext: <T>(defaultValue: T) => Context<T>;
|
|
99
|
+
}, slot: ContextSlotInfo): ResolvedContextSlot;
|
|
100
|
+
/**
|
|
101
|
+
* Install React Context objects for every entry in `slots` into
|
|
102
|
+
* `registry`. Returns the resolved slot list so callers can mount one
|
|
103
|
+
* SingleSlotProvider per entry. Idempotent — slots whose `contextName`
|
|
104
|
+
* is already in the registry reuse the existing Context.
|
|
105
|
+
*/
|
|
106
|
+
export declare function installContextRegistry(registry: GguiContextRegistry, reactMod: {
|
|
107
|
+
createContext: <T>(defaultValue: T) => Context<T>;
|
|
108
|
+
}, slots: ReadonlyArray<ContextSlotInfo>): ReadonlyArray<ResolvedContextSlot>;
|
|
109
|
+
/**
|
|
110
|
+
* Identity bundle carried alongside every snapshot post. The runtime
|
|
111
|
+
* captures these from the bootstrap envelope at boot and threads
|
|
112
|
+
* them through `createContextStateHost` so each snapshot's two
|
|
113
|
+
* destinations (host + server) carry the same authoritative session
|
|
114
|
+
* binding. Absent on dev / test code paths that don't have a real
|
|
115
|
+
* bootstrap; the server mirror is skipped in that case.
|
|
116
|
+
*/
|
|
117
|
+
export interface ContextPostIdentity {
|
|
118
|
+
readonly sessionId: string;
|
|
119
|
+
readonly appId: string;
|
|
120
|
+
readonly stackItemId: string;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* React surface the host factories need. Production callers pass
|
|
124
|
+
* `typeof import('react')` directly; tests pass the same namespace
|
|
125
|
+
* (the real `react` module). Picking specific keys from the React
|
|
126
|
+
* module type lets the factories accept the namespace without
|
|
127
|
+
* hand-rolling structural-compat shims.
|
|
128
|
+
*/
|
|
129
|
+
export type ReactCoreForHost = Pick<typeof import('react'), 'createElement' | 'Fragment' | 'useState' | 'useEffect' | 'useRef'>;
|
|
130
|
+
/**
|
|
131
|
+
* Build the `<SingleSlotProvider>` React component — owns one slot's
|
|
132
|
+
* useState + observer + Provider wrap. The runtime composes one of
|
|
133
|
+
* these per declared slot (via {@link createContextStateHost}).
|
|
134
|
+
*
|
|
135
|
+
* Built as a factory so the runtime can pass dependencies (`react`,
|
|
136
|
+
* `postToParent`, `consoleWarn`) without re-importing in this module
|
|
137
|
+
* — keeps the file pure and unit-testable.
|
|
138
|
+
*
|
|
139
|
+
* Exported because `__tests__/context-observer.test.ts` mounts it
|
|
140
|
+
* standalone to verify the value→post→Provider cycle.
|
|
141
|
+
*/
|
|
142
|
+
export declare function createSingleSlotProvider(deps: {
|
|
143
|
+
readonly react: ReactCoreForHost;
|
|
144
|
+
readonly postToParent: (envelope: unknown) => void;
|
|
145
|
+
readonly consoleWarn?: (...args: unknown[]) => void;
|
|
146
|
+
/** Identity bundle threaded into every snapshot post — server
|
|
147
|
+
* mirror skipped when undefined. */
|
|
148
|
+
readonly identity?: ContextPostIdentity;
|
|
149
|
+
}): (props: {
|
|
150
|
+
readonly slot: ResolvedContextSlot;
|
|
151
|
+
readonly children?: ReactNode;
|
|
152
|
+
}) => ReactElement;
|
|
153
|
+
/**
|
|
154
|
+
* Build the `ContextStateHost` component — composes
|
|
155
|
+
* {@link createSingleSlotProvider} once per declared slot to nest
|
|
156
|
+
* Providers around the user's component.
|
|
157
|
+
*
|
|
158
|
+
* The composed shape is:
|
|
159
|
+
*
|
|
160
|
+
* ```tsx
|
|
161
|
+
* <SingleSlotProvider slot={slots[0]}>
|
|
162
|
+
* <SingleSlotProvider slot={slots[1]}>
|
|
163
|
+
* {userComponent}
|
|
164
|
+
* </SingleSlotProvider>
|
|
165
|
+
* </SingleSlotProvider>
|
|
166
|
+
* ```
|
|
167
|
+
*
|
|
168
|
+
* Outermost Provider = first declared slot. Order doesn't affect
|
|
169
|
+
* `useGguiContext` lookups (each Context is independent), but it's
|
|
170
|
+
* stable for snapshot debugging.
|
|
171
|
+
*
|
|
172
|
+
* When `slots` is empty, the host renders children unchanged (no
|
|
173
|
+
* extra wrapper).
|
|
174
|
+
*/
|
|
175
|
+
export declare function createContextStateHost(deps: {
|
|
176
|
+
readonly react: ReactCoreForHost;
|
|
177
|
+
readonly postToParent: (envelope: unknown) => void;
|
|
178
|
+
readonly consoleWarn?: (...args: unknown[]) => void;
|
|
179
|
+
/** Identity bundle for the server-mirror destination. Threaded
|
|
180
|
+
* unchanged into `createSingleSlotProvider` so every slot's debounced
|
|
181
|
+
* post carries the same authoritative session binding. */
|
|
182
|
+
readonly identity?: ContextPostIdentity;
|
|
183
|
+
}): (props: {
|
|
184
|
+
readonly slots: ReadonlyArray<ResolvedContextSlot>;
|
|
185
|
+
readonly children: ReactNode;
|
|
186
|
+
}) => ReactElement;
|
|
187
|
+
/**
|
|
188
|
+
* Walk {@link contextSlotLastValues} and re-post each entry to the
|
|
189
|
+
* parent, filtered to slot names that the new mount actually
|
|
190
|
+
* declares. Used by the re-mount path — when a fresh boot completes
|
|
191
|
+
* after the previous mount torn down, re-seed the LLM context with
|
|
192
|
+
* the LAST values from the previous mount.
|
|
193
|
+
*
|
|
194
|
+
* Filter behavior. An earlier version walked the whole map without
|
|
195
|
+
* filtering, which leaked stale values across contracts: a re-mount
|
|
196
|
+
* with a different `contextSpec` (different slot names) re-emitted
|
|
197
|
+
* entries the new contract didn't declare. The current signature takes
|
|
198
|
+
* the active slot names; entries whose key is NOT in the set get
|
|
199
|
+
* dropped from the map AND skipped on emit.
|
|
200
|
+
*
|
|
201
|
+
* Pass `undefined` to skip filtering (kept for tests that exercise the
|
|
202
|
+
* bare module). Runtime call sites pass the active slots so the leak
|
|
203
|
+
* path is closed by construction.
|
|
204
|
+
*/
|
|
205
|
+
export declare function reemitLastContextValues(postToParent: (envelope: unknown) => void, activeSlotNames?: ReadonlySet<string>, identity?: ContextPostIdentity): void;
|