@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,500 @@
|
|
|
1
|
+
import { PUBLIC_ENV_APP_KEY_RE, projectHostContext } from '@ggui-ai/protocol';
|
|
2
|
+
/**
|
|
3
|
+
* Type guard — true iff `value` is a non-null, non-array plain object.
|
|
4
|
+
* Used as the gate before every property access in this module.
|
|
5
|
+
*/
|
|
6
|
+
function isPlainObject(value) {
|
|
7
|
+
return (value !== null &&
|
|
8
|
+
typeof value === 'object' &&
|
|
9
|
+
!Array.isArray(value));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Type guard — true iff `value` is a non-empty string.
|
|
13
|
+
*
|
|
14
|
+
* Empty strings are rejected because they're equivalent to "field
|
|
15
|
+
* missing" semantically — a bootstrap with `appId: ''` will fail
|
|
16
|
+
* server-side validation just as quickly as one with the field absent.
|
|
17
|
+
*/
|
|
18
|
+
function isNonEmptyString(value) {
|
|
19
|
+
return typeof value === 'string' && value.length > 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Shared validator core. Accepts an unwrapped bootstrap object (NOT
|
|
23
|
+
* the full `_meta.ggui.bootstrap` envelope — the caller has already
|
|
24
|
+
* stripped envelopes down to this layer).
|
|
25
|
+
*
|
|
26
|
+
* Validates the three-mode discriminator + the optional fields per
|
|
27
|
+
* the {@link GguiBootstrapMeta} contract. Returns a discriminated
|
|
28
|
+
* {@link BootstrapParseResult} the caller surfaces verbatim.
|
|
29
|
+
*
|
|
30
|
+
* Invariants enforced here (see module docblock for the wire contract):
|
|
31
|
+
* - `runtimeUrl` MUST be a non-empty string. All three modes need
|
|
32
|
+
* it because the shell dynamic-script-loads the runtime bundle
|
|
33
|
+
* from this URL.
|
|
34
|
+
* - `sessionId` + `appId` MUST be non-empty strings.
|
|
35
|
+
* - At least one of `{wsUrl-with-token, codeUrl, kind}` MUST be
|
|
36
|
+
* present as a non-empty string. Half-live (wsUrl without token)
|
|
37
|
+
* is MALFORMED.
|
|
38
|
+
* - `expiresAt`, when present + relevant (live mode), MUST parse
|
|
39
|
+
* to a future timestamp.
|
|
40
|
+
*/
|
|
41
|
+
export function validateBootstrapMeta(raw) {
|
|
42
|
+
if (!isPlainObject(raw)) {
|
|
43
|
+
return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
|
|
44
|
+
}
|
|
45
|
+
const sessionId = raw['sessionId'];
|
|
46
|
+
const appId = raw['appId'];
|
|
47
|
+
const runtimeUrl = raw['runtimeUrl'];
|
|
48
|
+
if (!isNonEmptyString(sessionId) ||
|
|
49
|
+
!isNonEmptyString(appId) ||
|
|
50
|
+
!isNonEmptyString(runtimeUrl)) {
|
|
51
|
+
return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
|
|
52
|
+
}
|
|
53
|
+
// Mode discriminator. Live mode requires wsUrl AND token together;
|
|
54
|
+
// half-live (one without the other) is MALFORMED. Static-component
|
|
55
|
+
// mode keys off `codeUrl` (the content-addressable URL channel; the
|
|
56
|
+
// inline base64 `componentCode` channel was retired in T3-1, 2026-05-13).
|
|
57
|
+
// System-card mode keys off `kind`. Reject any system+code mix as a
|
|
58
|
+
// malformed wire envelope — we don't guess which branch the producer
|
|
59
|
+
// intended.
|
|
60
|
+
const wsUrlRaw = raw['wsUrl'];
|
|
61
|
+
const tokenRaw = raw['token'];
|
|
62
|
+
const codeUrlRawForDisc = raw['codeUrl'];
|
|
63
|
+
const kindRaw = raw['kind'];
|
|
64
|
+
const hasWsUrl = isNonEmptyString(wsUrlRaw);
|
|
65
|
+
const hasToken = isNonEmptyString(tokenRaw);
|
|
66
|
+
const hasCodeUrl = isNonEmptyString(codeUrlRawForDisc);
|
|
67
|
+
const hasKind = isNonEmptyString(kindRaw);
|
|
68
|
+
if (hasWsUrl !== hasToken) {
|
|
69
|
+
return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
|
|
70
|
+
}
|
|
71
|
+
if (!hasWsUrl && !hasCodeUrl && !hasKind) {
|
|
72
|
+
return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
|
|
73
|
+
}
|
|
74
|
+
// System-card mode is mutually exclusive with codeUrl.
|
|
75
|
+
if (hasKind && hasCodeUrl) {
|
|
76
|
+
return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
|
|
77
|
+
}
|
|
78
|
+
// expiresAt validation + auth-degraded fallback. When absent we
|
|
79
|
+
// synthesize the unbounded sentinel so downstream consumers don't
|
|
80
|
+
// have to special-case `undefined`.
|
|
81
|
+
//
|
|
82
|
+
// Auth-degraded fallback: `expiresAt` gates the live-mode
|
|
83
|
+
// `wsUrl`/`token` pair only — `bootSelfContained` mounts the static
|
|
84
|
+
// UI from `codeUrl`/`kind` + `propsJson` and never reads the auth
|
|
85
|
+
// fields. So when the token has expired but static renderable
|
|
86
|
+
// content is present (e.g., chat-history rehydrate after the 2-min
|
|
87
|
+
// bootstrap-token TTL), DEGRADE to static-only mode — drop
|
|
88
|
+
// `wsUrl`/`token`/`expiresAt`, keep everything else. The runtime
|
|
89
|
+
// mounts the card from the inline view; live updates (WS
|
|
90
|
+
// subscriptions, server-pushed streams) silently no-op until a fresh
|
|
91
|
+
// push refreshes the bootstrap. Without this fallback, rehydrate
|
|
92
|
+
// produces a hard `MISSING_TOOL_OUTPUT` instead of a visible static
|
|
93
|
+
// UI.
|
|
94
|
+
const expiresRaw = raw['expiresAt'];
|
|
95
|
+
let expiresAt;
|
|
96
|
+
let authDegraded = false;
|
|
97
|
+
if (expiresRaw === undefined) {
|
|
98
|
+
expiresAt = '9999-12-31T23:59:59.999Z';
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
if (!isNonEmptyString(expiresRaw)) {
|
|
102
|
+
return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
|
|
103
|
+
}
|
|
104
|
+
const ts = Date.parse(expiresRaw);
|
|
105
|
+
if (Number.isNaN(ts)) {
|
|
106
|
+
return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
|
|
107
|
+
}
|
|
108
|
+
if (ts <= Date.now()) {
|
|
109
|
+
// Expired — only fail when no static content is present. With
|
|
110
|
+
// static content (codeUrl / system kind), we can still mount the
|
|
111
|
+
// UI; we just lose live mode.
|
|
112
|
+
if (!hasCodeUrl && !hasKind) {
|
|
113
|
+
return { ok: false, reason: 'EXPIRED_BOOTSTRAP' };
|
|
114
|
+
}
|
|
115
|
+
authDegraded = true;
|
|
116
|
+
expiresAt = undefined;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
expiresAt = expiresRaw;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Optional shape-preserving fields. Each block keeps the same
|
|
123
|
+
// posture as the original parseBootstrap: malformed shapes default
|
|
124
|
+
// to absent / empty rather than failing the whole parse. Producers
|
|
125
|
+
// MAY add new optional fields without forcing a parser bump; older
|
|
126
|
+
// renderers legitimately render as if the new field were absent.
|
|
127
|
+
const stackItemIdRaw = raw['stackItemId'];
|
|
128
|
+
const stackItemId = typeof stackItemIdRaw === 'string' && stackItemIdRaw.length > 0
|
|
129
|
+
? stackItemIdRaw
|
|
130
|
+
: undefined;
|
|
131
|
+
const themeIdRaw = raw['themeId'];
|
|
132
|
+
const themeId = typeof themeIdRaw === 'string' && themeIdRaw.length > 0
|
|
133
|
+
? themeIdRaw
|
|
134
|
+
: undefined;
|
|
135
|
+
const themeModeRaw = raw['themeMode'];
|
|
136
|
+
const themeMode = themeModeRaw === 'dark' || themeModeRaw === 'light'
|
|
137
|
+
? themeModeRaw
|
|
138
|
+
: undefined;
|
|
139
|
+
const propsJsonRaw = raw['propsJson'];
|
|
140
|
+
const propsJson = typeof propsJsonRaw === 'string' ? propsJsonRaw : undefined;
|
|
141
|
+
const codeUrl = hasCodeUrl ? codeUrlRawForDisc : undefined;
|
|
142
|
+
const codeHashRaw = raw['codeHash'];
|
|
143
|
+
const codeHash = typeof codeHashRaw === 'string' && codeHashRaw.length > 0
|
|
144
|
+
? codeHashRaw
|
|
145
|
+
: undefined;
|
|
146
|
+
// canvasMode — discriminator for the canvas-mount path in
|
|
147
|
+
// `runtime.ts` (consumed at the `parsed.bootstrap.canvasMode === true`
|
|
148
|
+
// gate). Mutually exclusive with stackItemId per the
|
|
149
|
+
// `GguiBootstrapMeta` contract; the validator does not enforce the
|
|
150
|
+
// exclusion here because (a) producers already gate it
|
|
151
|
+
// server-side, and (b) the consumer branches on canvasMode FIRST so
|
|
152
|
+
// a stray stackItemId is harmless. Defensive parse: strictly boolean
|
|
153
|
+
// true ⇒ true; everything else ⇒ undefined (matches the runtime's
|
|
154
|
+
// `=== true` check exactly).
|
|
155
|
+
const canvasModeRaw = raw['canvasMode'];
|
|
156
|
+
const canvasMode = canvasModeRaw === true ? true : undefined;
|
|
157
|
+
// appCallableTools — shape-preserving: legacy / malformed shapes
|
|
158
|
+
// default to `[]`.
|
|
159
|
+
const appCallableToolsRaw = raw['appCallableTools'];
|
|
160
|
+
const appCallableTools = Array.isArray(appCallableToolsRaw) &&
|
|
161
|
+
appCallableToolsRaw.every((s) => typeof s === 'string')
|
|
162
|
+
? appCallableToolsRaw
|
|
163
|
+
: [];
|
|
164
|
+
// actionNextSteps — defaults to `{}` on absent / malformed shapes.
|
|
165
|
+
const actionNextStepsRaw = raw['actionNextSteps'];
|
|
166
|
+
let actionNextSteps = {};
|
|
167
|
+
if (isPlainObject(actionNextStepsRaw) &&
|
|
168
|
+
Object.values(actionNextStepsRaw).every((v) => typeof v === 'string')) {
|
|
169
|
+
actionNextSteps = actionNextStepsRaw;
|
|
170
|
+
}
|
|
171
|
+
// contextSlots — per-slot data the runtime turns into one
|
|
172
|
+
// `React.createContext(default)` each at boot. Optional on the wire;
|
|
173
|
+
// legacy bootstraps without the field default to `[]`. Each entry
|
|
174
|
+
// MUST have non-empty `name`/`contextName` strings + an object
|
|
175
|
+
// `schema` + a present `default`. Malformed entries cause the WHOLE
|
|
176
|
+
// field to default to `[]` rather than partially trusting it.
|
|
177
|
+
const contextSlotsRaw = raw['contextSlots'];
|
|
178
|
+
const contextSlots = (() => {
|
|
179
|
+
if (!Array.isArray(contextSlotsRaw))
|
|
180
|
+
return [];
|
|
181
|
+
const collected = [];
|
|
182
|
+
for (const entry of contextSlotsRaw) {
|
|
183
|
+
if (!isPlainObject(entry))
|
|
184
|
+
return [];
|
|
185
|
+
const name = entry['name'];
|
|
186
|
+
const contextName = entry['contextName'];
|
|
187
|
+
const schema = entry['schema'];
|
|
188
|
+
if (!isNonEmptyString(name))
|
|
189
|
+
return [];
|
|
190
|
+
if (!isNonEmptyString(contextName))
|
|
191
|
+
return [];
|
|
192
|
+
if (!isPlainObject(schema))
|
|
193
|
+
return [];
|
|
194
|
+
const debounceMsRaw = entry['debounceMs'];
|
|
195
|
+
if (debounceMsRaw !== undefined && typeof debounceMsRaw !== 'number') {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
// `default` is required (the runtime owns useState per slot,
|
|
199
|
+
// so the seed is load-bearing). Discriminate via `in` so a
|
|
200
|
+
// literal `null` passes while truly-missing fails.
|
|
201
|
+
if (!('default' in entry))
|
|
202
|
+
return [];
|
|
203
|
+
const slot = {
|
|
204
|
+
name,
|
|
205
|
+
contextName,
|
|
206
|
+
schema: schema,
|
|
207
|
+
default: entry['default'],
|
|
208
|
+
...(debounceMsRaw !== undefined ? { debounceMs: debounceMsRaw } : {}),
|
|
209
|
+
};
|
|
210
|
+
collected.push(slot);
|
|
211
|
+
}
|
|
212
|
+
return collected;
|
|
213
|
+
})();
|
|
214
|
+
// `permissionsPolicy` — Permissions-Policy directive list
|
|
215
|
+
// mirrored from the active stack item's
|
|
216
|
+
// `clientCapabilities.gadgets[*].permission`. Defensive parse:
|
|
217
|
+
// malformed payload defaults to undefined rather than failing the
|
|
218
|
+
// whole bootstrap (informational field). Browser-enforced gates
|
|
219
|
+
// come from the HTTP `Permissions-Policy` header (public-render
|
|
220
|
+
// path) or iframe `allow=""` attribute (MCP-Apps host path) — the
|
|
221
|
+
// iframe-runtime surfaces this list for in-iframe inspection only.
|
|
222
|
+
const permissionsPolicyRaw = raw['permissionsPolicy'];
|
|
223
|
+
let permissionsPolicy;
|
|
224
|
+
if (Array.isArray(permissionsPolicyRaw) &&
|
|
225
|
+
permissionsPolicyRaw.every((s) => typeof s === 'string' && s.length > 0)) {
|
|
226
|
+
permissionsPolicy = permissionsPolicyRaw;
|
|
227
|
+
}
|
|
228
|
+
// `publicEnv` — operator-stamped public values the
|
|
229
|
+
// iframe-runtime installs at `globalThis.__ggui__.publicEnv` for
|
|
230
|
+
// wrapper hooks to read via `getPublicEnv(key)`. Defensive parse:
|
|
231
|
+
// EVERY key MUST match `PUBLIC_ENV_APP_KEY_RE`; one bad key (or
|
|
232
|
+
// a non-string value, or the field itself being non-object)
|
|
233
|
+
// collapses the WHOLE field to undefined. Matches the parser
|
|
234
|
+
// posture of `gadgets` / `contextSlots`.
|
|
235
|
+
const publicEnvRaw = raw['publicEnv'];
|
|
236
|
+
const publicEnv = (() => {
|
|
237
|
+
if (publicEnvRaw === undefined)
|
|
238
|
+
return undefined;
|
|
239
|
+
if (!isPlainObject(publicEnvRaw))
|
|
240
|
+
return undefined;
|
|
241
|
+
const collected = {};
|
|
242
|
+
for (const [key, value] of Object.entries(publicEnvRaw)) {
|
|
243
|
+
if (!PUBLIC_ENV_APP_KEY_RE.test(key))
|
|
244
|
+
return undefined;
|
|
245
|
+
if (typeof value !== 'string')
|
|
246
|
+
return undefined;
|
|
247
|
+
collected[key] = value;
|
|
248
|
+
}
|
|
249
|
+
return collected;
|
|
250
|
+
})();
|
|
251
|
+
// `gadgets` — resolved gadget-package catalog the iframe-runtime
|
|
252
|
+
// dynamically imports at boot to populate
|
|
253
|
+
// `globalThis.__ggui__.gadgets`. One entry per registered package
|
|
254
|
+
// (GG.8.2 — per-package, not per-hook). Defensive parse: malformed
|
|
255
|
+
// entries cause the WHOLE field to default to undefined rather than
|
|
256
|
+
// partially trusting it (an unreachable package at boot is better
|
|
257
|
+
// than a half-populated registry with a corrupted entry).
|
|
258
|
+
const gadgetsRaw = raw['gadgets'];
|
|
259
|
+
const gadgets = (() => {
|
|
260
|
+
if (gadgetsRaw === undefined)
|
|
261
|
+
return undefined;
|
|
262
|
+
if (!Array.isArray(gadgetsRaw))
|
|
263
|
+
return undefined;
|
|
264
|
+
const collected = [];
|
|
265
|
+
for (const entry of gadgetsRaw) {
|
|
266
|
+
if (!isPlainObject(entry))
|
|
267
|
+
return undefined;
|
|
268
|
+
// `package` is the registry key the runtime stores the loaded
|
|
269
|
+
// namespace under — REQUIRED. A missing package makes the entry
|
|
270
|
+
// unkeyable; reject the whole field.
|
|
271
|
+
const pkgRaw = entry['package'];
|
|
272
|
+
if (!isNonEmptyString(pkgRaw))
|
|
273
|
+
return undefined;
|
|
274
|
+
const bundleRaw = entry['bundleUrl'];
|
|
275
|
+
const sriRaw = entry['bundleSri'];
|
|
276
|
+
const bundleUrl = isNonEmptyString(bundleRaw) ? bundleRaw : undefined;
|
|
277
|
+
// The SRI gate only kicks in when a bundleUrl is present (bare
|
|
278
|
+
// `package` import has no SRI surface). A stray SRI on a
|
|
279
|
+
// package-only entry is dropped silently — better than rejecting
|
|
280
|
+
// the whole field for a benign producer mismatch.
|
|
281
|
+
const bundleSri = isNonEmptyString(sriRaw) && bundleUrl !== undefined
|
|
282
|
+
? sriRaw
|
|
283
|
+
: undefined;
|
|
284
|
+
collected.push({
|
|
285
|
+
package: pkgRaw,
|
|
286
|
+
...(bundleUrl !== undefined ? { bundleUrl } : {}),
|
|
287
|
+
...(bundleSri !== undefined ? { bundleSri } : {}),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return collected;
|
|
291
|
+
})();
|
|
292
|
+
// `compiledValidators` — precompiled, eval-free contract validators
|
|
293
|
+
// (one ESM validator-module source per propsSpec / actionSpec /
|
|
294
|
+
// streamSpec / contextSpec). The renderer iframe's strict CSP blocks
|
|
295
|
+
// runtime `ajv.compile()`, so it loads these modules via `blob:`
|
|
296
|
+
// import instead. Defensive parse: malformed payload collapses the
|
|
297
|
+
// WHOLE field to undefined (the runtime then falls back to in-iframe
|
|
298
|
+
// compilation, which is no worse than pre-A4 behavior).
|
|
299
|
+
const compiledValidatorsRaw = raw['compiledValidators'];
|
|
300
|
+
const compiledValidators = (() => {
|
|
301
|
+
if (compiledValidatorsRaw === undefined)
|
|
302
|
+
return undefined;
|
|
303
|
+
if (!isPlainObject(compiledValidatorsRaw))
|
|
304
|
+
return undefined;
|
|
305
|
+
const out = {};
|
|
306
|
+
const propsRaw = compiledValidatorsRaw['props'];
|
|
307
|
+
if (propsRaw !== undefined) {
|
|
308
|
+
if (!isNonEmptyString(propsRaw))
|
|
309
|
+
return undefined;
|
|
310
|
+
out.props = propsRaw;
|
|
311
|
+
}
|
|
312
|
+
for (const group of ['actions', 'streams', 'context']) {
|
|
313
|
+
const groupRaw = compiledValidatorsRaw[group];
|
|
314
|
+
if (groupRaw === undefined)
|
|
315
|
+
continue;
|
|
316
|
+
if (!isPlainObject(groupRaw))
|
|
317
|
+
return undefined;
|
|
318
|
+
const collected = {};
|
|
319
|
+
for (const [name, src] of Object.entries(groupRaw)) {
|
|
320
|
+
if (!isNonEmptyString(src))
|
|
321
|
+
return undefined;
|
|
322
|
+
collected[name] = src;
|
|
323
|
+
}
|
|
324
|
+
out[group] = collected;
|
|
325
|
+
}
|
|
326
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
327
|
+
})();
|
|
328
|
+
// `streamWebSocketLocalTools` — mirror of the handshake's
|
|
329
|
+
// `serverCapabilities.streamWebSocketLocalTools` so the
|
|
330
|
+
// channel-transport router can decide WS-subscribe vs iframe-polling
|
|
331
|
+
// per channel without re-querying the handshake. Defensive parse:
|
|
332
|
+
// malformed payload defaults to undefined (universal iframe-poll).
|
|
333
|
+
// An empty array is a meaningful value ("server supports the field,
|
|
334
|
+
// no tool is local") and preserved verbatim.
|
|
335
|
+
const streamWebSocketLocalToolsRaw = raw['streamWebSocketLocalTools'];
|
|
336
|
+
let streamWebSocketLocalTools;
|
|
337
|
+
if (Array.isArray(streamWebSocketLocalToolsRaw) &&
|
|
338
|
+
streamWebSocketLocalToolsRaw.every((s) => typeof s === 'string' && s.length > 0)) {
|
|
339
|
+
streamWebSocketLocalTools = streamWebSocketLocalToolsRaw;
|
|
340
|
+
}
|
|
341
|
+
// When auth is degraded (expired token + static content), drop the
|
|
342
|
+
// live-mode fields so downstream readers (e.g. `readGguiBootstrap-
|
|
343
|
+
// MetaShape`) don't shortcut into `runBootProduction`. The static
|
|
344
|
+
// mount path in `bootSelfContained` doesn't need them.
|
|
345
|
+
const result = {
|
|
346
|
+
sessionId,
|
|
347
|
+
appId,
|
|
348
|
+
runtimeUrl,
|
|
349
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
350
|
+
appCallableTools,
|
|
351
|
+
actionNextSteps,
|
|
352
|
+
contextSlots,
|
|
353
|
+
...(hasWsUrl && hasToken && !authDegraded
|
|
354
|
+
? { wsUrl: wsUrlRaw, token: tokenRaw }
|
|
355
|
+
: {}),
|
|
356
|
+
...(hasKind ? { kind: kindRaw } : {}),
|
|
357
|
+
...(stackItemId !== undefined ? { stackItemId } : {}),
|
|
358
|
+
...(themeId !== undefined ? { themeId } : {}),
|
|
359
|
+
...(themeMode !== undefined ? { themeMode } : {}),
|
|
360
|
+
...(propsJson !== undefined ? { propsJson } : {}),
|
|
361
|
+
...(codeUrl !== undefined ? { codeUrl } : {}),
|
|
362
|
+
...(codeHash !== undefined ? { codeHash } : {}),
|
|
363
|
+
...(permissionsPolicy !== undefined ? { permissionsPolicy } : {}),
|
|
364
|
+
...(streamWebSocketLocalTools !== undefined
|
|
365
|
+
? { streamWebSocketLocalTools }
|
|
366
|
+
: {}),
|
|
367
|
+
...(gadgets !== undefined ? { gadgets } : {}),
|
|
368
|
+
...(publicEnv !== undefined ? { publicEnv } : {}),
|
|
369
|
+
...(canvasMode !== undefined ? { canvasMode } : {}),
|
|
370
|
+
...(compiledValidators !== undefined ? { compiledValidators } : {}),
|
|
371
|
+
};
|
|
372
|
+
return { ok: true, bootstrap: result };
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Parse a bootstrap from a `ui/initialize` postMessage response. The
|
|
376
|
+
* argument is the JSON-RPC `result` field — typically
|
|
377
|
+
* `{ toolOutput: { _meta: { ggui: { bootstrap: {...} } }, structuredContent: ... } }`.
|
|
378
|
+
*
|
|
379
|
+
* Used by the runtime's legacy `bootProduction` path (live-mode WS
|
|
380
|
+
* subscription). Spec-canonical for first-party `<McpAppIframe>`
|
|
381
|
+
* Reading-B hosts (Studio, Portal, console).
|
|
382
|
+
*/
|
|
383
|
+
export function parseBootstrapFromUiInitialize(uiInitializeResult) {
|
|
384
|
+
if (!isPlainObject(uiInitializeResult)) {
|
|
385
|
+
return { ok: false, reason: 'MISSING_TOOL_OUTPUT' };
|
|
386
|
+
}
|
|
387
|
+
const toolOutput = uiInitializeResult['toolOutput'];
|
|
388
|
+
if (!isPlainObject(toolOutput)) {
|
|
389
|
+
return { ok: false, reason: 'MISSING_TOOL_OUTPUT' };
|
|
390
|
+
}
|
|
391
|
+
const meta = toolOutput['_meta'];
|
|
392
|
+
if (!isPlainObject(meta)) {
|
|
393
|
+
return { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
|
|
394
|
+
}
|
|
395
|
+
const ggui = meta['ggui'];
|
|
396
|
+
if (!isPlainObject(ggui)) {
|
|
397
|
+
return { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
|
|
398
|
+
}
|
|
399
|
+
const bootstrap = ggui['bootstrap'];
|
|
400
|
+
if (!isPlainObject(bootstrap)) {
|
|
401
|
+
return { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
|
|
402
|
+
}
|
|
403
|
+
const validated = validateBootstrapMeta(bootstrap);
|
|
404
|
+
if (!validated.ok)
|
|
405
|
+
return validated;
|
|
406
|
+
// opportunistically capture HostContext from
|
|
407
|
+
// the sibling `result.hostContext` field. Per the MCP Apps spec
|
|
408
|
+
// (`McpUiInitializeResult.hostContext`), this carries
|
|
409
|
+
// availableDisplayModes / containerDimensions / platform /
|
|
410
|
+
// deviceCapabilities — the data canvas-mode display-mode
|
|
411
|
+
// escalation and the agent (via handshake/consume echo) rely on.
|
|
412
|
+
//
|
|
413
|
+
// Best-effort: a malformed or absent HostContext never affects the
|
|
414
|
+
// bootstrap parse. `projectHostContext` returns undefined for
|
|
415
|
+
// non-object input and an empty projection for "object but no
|
|
416
|
+
// recognized fields" — both are passed through as-is.
|
|
417
|
+
const hostContext = projectHostContext(uiInitializeResult['hostContext']);
|
|
418
|
+
return hostContext !== undefined
|
|
419
|
+
? { ok: true, bootstrap: validated.bootstrap, hostContext }
|
|
420
|
+
: validated;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Back-compat alias for {@link parseBootstrapFromUiInitialize}.
|
|
424
|
+
*
|
|
425
|
+
* `parseBootstrap` was the original name when the iframe-runtime
|
|
426
|
+
* accepted only the ui/initialize envelope. The parser was later
|
|
427
|
+
* generalized to three envelope shapes (ui/initialize,
|
|
428
|
+
* `__GGUI_BOOTSTRAP__` global, postMessage tool-result), each with its
|
|
429
|
+
* own thin extractor; the shared {@link validateBootstrapMeta} core
|
|
430
|
+
* does the per-envelope shape validation. The original function name
|
|
431
|
+
* is kept exported so downstream consumers (`@ggui-ai/react`, runtime
|
|
432
|
+
* call sites) don't need a sweep — the name still resolves to the
|
|
433
|
+
* same behavior.
|
|
434
|
+
*/
|
|
435
|
+
export const parseBootstrap = parseBootstrapFromUiInitialize;
|
|
436
|
+
/**
|
|
437
|
+
* Parse a bootstrap from `globalThis.__GGUI_BOOTSTRAP__` — the
|
|
438
|
+
* synchronous self-contained shell delivery channel. Returns
|
|
439
|
+
* `MALFORMED_BOOTSTRAP` when the global is absent OR not an object;
|
|
440
|
+
* the caller falls through to a postMessage path on either signal.
|
|
441
|
+
*
|
|
442
|
+
* Used by the runtime's autostart resolver as the highest-priority
|
|
443
|
+
* boot source: per-session shells (`/r/<shortCode>`,
|
|
444
|
+
* `ui://ggui/session/<sessionId>`) populate this synchronously
|
|
445
|
+
* BEFORE the runtime bundle's `<script type="module">` evaluates,
|
|
446
|
+
* so the runtime can mount without any postMessage round-trip.
|
|
447
|
+
*/
|
|
448
|
+
export function parseBootstrapFromGlobal() {
|
|
449
|
+
if (typeof globalThis === 'undefined') {
|
|
450
|
+
return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
|
|
451
|
+
}
|
|
452
|
+
const raw = globalThis
|
|
453
|
+
.__GGUI_BOOTSTRAP__;
|
|
454
|
+
return validateBootstrapMeta(raw);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Parse a bootstrap from a `ui/notifications/tool-result` postMessage
|
|
458
|
+
* params payload. Looks at BOTH the spec-canonical location
|
|
459
|
+
* (`params._meta.ggui.bootstrap`) and the Reading-B `<McpAppIframe>`
|
|
460
|
+
* convention (`params.toolOutput._meta.ggui.bootstrap`) — Claude
|
|
461
|
+
* Desktop / claude.ai Connector use the first; first-party hosts the
|
|
462
|
+
* second.
|
|
463
|
+
*
|
|
464
|
+
* Used by the runtime's autostart resolver as the second / third
|
|
465
|
+
* boot source (drained from the pre-runtime-load buffer or caught by
|
|
466
|
+
* the live message listener).
|
|
467
|
+
*/
|
|
468
|
+
export function parseBootstrapFromToolResult(params) {
|
|
469
|
+
if (!isPlainObject(params)) {
|
|
470
|
+
return { ok: false, reason: 'MISSING_TOOL_OUTPUT' };
|
|
471
|
+
}
|
|
472
|
+
// Spec-canonical: params IS the CallToolResult; _meta is at the top.
|
|
473
|
+
let meta;
|
|
474
|
+
const topMeta = params['_meta'];
|
|
475
|
+
if (isPlainObject(topMeta)) {
|
|
476
|
+
meta = topMeta;
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
// Reading-B: bootstrap nested under params.toolOutput._meta.
|
|
480
|
+
const toolOutput = params['toolOutput'];
|
|
481
|
+
if (isPlainObject(toolOutput)) {
|
|
482
|
+
const nestedMeta = toolOutput['_meta'];
|
|
483
|
+
if (isPlainObject(nestedMeta)) {
|
|
484
|
+
meta = nestedMeta;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (meta === undefined) {
|
|
489
|
+
return { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
|
|
490
|
+
}
|
|
491
|
+
const ggui = meta['ggui'];
|
|
492
|
+
if (!isPlainObject(ggui)) {
|
|
493
|
+
return { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
|
|
494
|
+
}
|
|
495
|
+
const bootstrap = ggui['bootstrap'];
|
|
496
|
+
if (!isPlainObject(bootstrap)) {
|
|
497
|
+
return { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
|
|
498
|
+
}
|
|
499
|
+
return validateBootstrapMeta(bootstrap);
|
|
500
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animator host.
|
|
3
|
+
*
|
|
4
|
+
* Wires the pure animator state-machine to its real-world inputs:
|
|
5
|
+
*
|
|
6
|
+
* - live-channel `_ggui:lifecycle` envelopes (handshake_started,
|
|
7
|
+
* handshake_completed, push_started, consume_polling)
|
|
8
|
+
* - the `push` / `pop` / `drain_ack` envelopes
|
|
9
|
+
* - `_ggui:contract-error` envelopes
|
|
10
|
+
* - transport online/offline events
|
|
11
|
+
*
|
|
12
|
+
* Renders `<AnimatorPill>` with the current state. Sub-component, so
|
|
13
|
+
* the canvas shell mounts one of these alongside the active stack-item
|
|
14
|
+
* mount.
|
|
15
|
+
*
|
|
16
|
+
* Subscription wiring:
|
|
17
|
+
* - The shell passes an `events` stream (an EventEmitter-shaped
|
|
18
|
+
* handle, or just a React-friendly state) into the host. The
|
|
19
|
+
* host's job is purely "feed events into the reducer + render the
|
|
20
|
+
* pill." Subscription plumbing lives at the shell layer where
|
|
21
|
+
* live-channel wiring is already established.
|
|
22
|
+
*
|
|
23
|
+
* Flicker suppression (Decision 4 in decisions.md):
|
|
24
|
+
* - When handshake_started fires, the host waits 150ms before
|
|
25
|
+
* showing the `handshake` state visually. If handshake_completed
|
|
26
|
+
* arrives within that window, the visible state never enters
|
|
27
|
+
* `handshake` — avoids the flicker on cached handshakes.
|
|
28
|
+
* - Implemented via a small ref+setTimeout dance in the host;
|
|
29
|
+
* state-machine itself stays pure.
|
|
30
|
+
*/
|
|
31
|
+
import { type ReactNode } from 'react';
|
|
32
|
+
import { type AnimatorEvent, type AnimatorState } from './state-machine.js';
|
|
33
|
+
export interface AnimatorEventStream {
|
|
34
|
+
/**
|
|
35
|
+
* Subscribe to events. Returns an unsubscribe function. Callers MUST
|
|
36
|
+
* call unsubscribe in their effect-cleanup path.
|
|
37
|
+
*/
|
|
38
|
+
readonly subscribe: (listener: (event: AnimatorEvent) => void) => () => void;
|
|
39
|
+
}
|
|
40
|
+
export interface AnimatorHostProps {
|
|
41
|
+
readonly events: AnimatorEventStream;
|
|
42
|
+
readonly layout: 'centered' | 'navbar';
|
|
43
|
+
readonly readyLabel?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Optional handshake-flicker suppression window in ms. Defaults to
|
|
46
|
+
* 150 per the design doc; tests inject 0 to disable suppression.
|
|
47
|
+
*/
|
|
48
|
+
readonly handshakeFlickerMs?: number;
|
|
49
|
+
}
|
|
50
|
+
export declare function AnimatorHost({ events, layout, readyLabel, handshakeFlickerMs, }: AnimatorHostProps): ReactNode;
|
|
51
|
+
/**
|
|
52
|
+
* Test-only: drive the animator host with a synthetic events stream
|
|
53
|
+
* built from an array of events. The host's `useEffect` subscribes
|
|
54
|
+
* once; pushing more events post-mount requires a real stream impl.
|
|
55
|
+
* Exported for the canvas-shell integration test in.
|
|
56
|
+
*
|
|
57
|
+
* @internal
|
|
58
|
+
*/
|
|
59
|
+
export declare function createSyntheticEventsStream(): {
|
|
60
|
+
readonly stream: AnimatorEventStream;
|
|
61
|
+
readonly emit: (event: AnimatorEvent) => void;
|
|
62
|
+
};
|
|
63
|
+
export type { AnimatorState };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Animator host.
|
|
4
|
+
*
|
|
5
|
+
* Wires the pure animator state-machine to its real-world inputs:
|
|
6
|
+
*
|
|
7
|
+
* - live-channel `_ggui:lifecycle` envelopes (handshake_started,
|
|
8
|
+
* handshake_completed, push_started, consume_polling)
|
|
9
|
+
* - the `push` / `pop` / `drain_ack` envelopes
|
|
10
|
+
* - `_ggui:contract-error` envelopes
|
|
11
|
+
* - transport online/offline events
|
|
12
|
+
*
|
|
13
|
+
* Renders `<AnimatorPill>` with the current state. Sub-component, so
|
|
14
|
+
* the canvas shell mounts one of these alongside the active stack-item
|
|
15
|
+
* mount.
|
|
16
|
+
*
|
|
17
|
+
* Subscription wiring:
|
|
18
|
+
* - The shell passes an `events` stream (an EventEmitter-shaped
|
|
19
|
+
* handle, or just a React-friendly state) into the host. The
|
|
20
|
+
* host's job is purely "feed events into the reducer + render the
|
|
21
|
+
* pill." Subscription plumbing lives at the shell layer where
|
|
22
|
+
* live-channel wiring is already established.
|
|
23
|
+
*
|
|
24
|
+
* Flicker suppression (Decision 4 in decisions.md):
|
|
25
|
+
* - When handshake_started fires, the host waits 150ms before
|
|
26
|
+
* showing the `handshake` state visually. If handshake_completed
|
|
27
|
+
* arrives within that window, the visible state never enters
|
|
28
|
+
* `handshake` — avoids the flicker on cached handshakes.
|
|
29
|
+
* - Implemented via a small ref+setTimeout dance in the host;
|
|
30
|
+
* state-machine itself stays pure.
|
|
31
|
+
*/
|
|
32
|
+
import { useEffect, useReducer, useRef } from 'react';
|
|
33
|
+
import { AnimatorPill } from './animator-pill.js';
|
|
34
|
+
import { INITIAL_STATE, transition, } from './state-machine.js';
|
|
35
|
+
export function AnimatorHost({ events, layout, readyLabel, handshakeFlickerMs = 150, }) {
|
|
36
|
+
const [state, dispatch] = useReducer(transition, INITIAL_STATE);
|
|
37
|
+
// Handshake-flicker suppression. Hold pending `handshake_started`
|
|
38
|
+
// events for `handshakeFlickerMs` before dispatching; if a matching
|
|
39
|
+
// `handshake_completed` arrives in the window, discard both.
|
|
40
|
+
const pendingHandshakeRef = useRef(null);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const unsubscribe = events.subscribe((event) => {
|
|
43
|
+
if (handshakeFlickerMs > 0 && event.kind === 'handshake_started') {
|
|
44
|
+
// Cancel any previously-pending handshake (rare; defensive).
|
|
45
|
+
if (pendingHandshakeRef.current) {
|
|
46
|
+
clearTimeout(pendingHandshakeRef.current.timer);
|
|
47
|
+
}
|
|
48
|
+
const handshakeId = event.payload.handshakeId;
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
// Fire deferred dispatch.
|
|
51
|
+
dispatch(event);
|
|
52
|
+
pendingHandshakeRef.current = null;
|
|
53
|
+
}, handshakeFlickerMs);
|
|
54
|
+
pendingHandshakeRef.current = { handshakeId, timer, event };
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (event.kind === 'handshake_completed' && pendingHandshakeRef.current) {
|
|
58
|
+
// Completion arrived before the visible window opened — cancel
|
|
59
|
+
// the pending dispatch entirely. The state machine never sees
|
|
60
|
+
// either event, so the visual stays at its prior state (avoids
|
|
61
|
+
// a flicker through `handshake`).
|
|
62
|
+
if (pendingHandshakeRef.current.handshakeId === event.payload.handshakeId) {
|
|
63
|
+
clearTimeout(pendingHandshakeRef.current.timer);
|
|
64
|
+
pendingHandshakeRef.current = null;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
dispatch(event);
|
|
69
|
+
});
|
|
70
|
+
return () => {
|
|
71
|
+
unsubscribe();
|
|
72
|
+
if (pendingHandshakeRef.current) {
|
|
73
|
+
clearTimeout(pendingHandshakeRef.current.timer);
|
|
74
|
+
pendingHandshakeRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}, [events, handshakeFlickerMs]);
|
|
78
|
+
return _jsx(AnimatorPill, { state: state, layout: layout, readyLabel: readyLabel });
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Test-only: drive the animator host with a synthetic events stream
|
|
82
|
+
* built from an array of events. The host's `useEffect` subscribes
|
|
83
|
+
* once; pushing more events post-mount requires a real stream impl.
|
|
84
|
+
* Exported for the canvas-shell integration test in.
|
|
85
|
+
*
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
export function createSyntheticEventsStream() {
|
|
89
|
+
let listener = null;
|
|
90
|
+
return {
|
|
91
|
+
stream: {
|
|
92
|
+
subscribe(l) {
|
|
93
|
+
listener = l;
|
|
94
|
+
return () => {
|
|
95
|
+
if (listener === l)
|
|
96
|
+
listener = null;
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
emit(event) {
|
|
101
|
+
if (listener)
|
|
102
|
+
listener(event);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|