@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.
Files changed (92) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +33 -0
  3. package/dist/bootstrap.d.ts +72 -0
  4. package/dist/bootstrap.js +500 -0
  5. package/dist/canvas/animator/animator-host.d.ts +63 -0
  6. package/dist/canvas/animator/animator-host.js +105 -0
  7. package/dist/canvas/animator/animator-pill.d.ts +42 -0
  8. package/dist/canvas/animator/animator-pill.js +119 -0
  9. package/dist/canvas/animator/state-machine.d.ts +106 -0
  10. package/dist/canvas/animator/state-machine.js +150 -0
  11. package/dist/canvas/canvas-shell.d.ts +77 -0
  12. package/dist/canvas/canvas-shell.js +117 -0
  13. package/dist/canvas/display-mode-policy.d.ts +104 -0
  14. package/dist/canvas/display-mode-policy.js +98 -0
  15. package/dist/canvas/mount.d.ts +150 -0
  16. package/dist/canvas/mount.js +202 -0
  17. package/dist/canvas/nav-stack.d.ts +133 -0
  18. package/dist/canvas/nav-stack.js +172 -0
  19. package/dist/channel-transport.d.ts +182 -0
  20. package/dist/channel-transport.js +344 -0
  21. package/dist/channels/channel-payload.d.ts +20 -0
  22. package/dist/channels/channel-payload.js +34 -0
  23. package/dist/channels/data.d.ts +48 -0
  24. package/dist/channels/data.js +103 -0
  25. package/dist/channels/drain-ack.d.ts +13 -0
  26. package/dist/channels/drain-ack.js +15 -0
  27. package/dist/channels/feedback.d.ts +14 -0
  28. package/dist/channels/feedback.js +19 -0
  29. package/dist/channels/index.d.ts +7 -0
  30. package/dist/channels/index.js +7 -0
  31. package/dist/channels/props-update.d.ts +53 -0
  32. package/dist/channels/props-update.js +130 -0
  33. package/dist/channels/push.d.ts +47 -0
  34. package/dist/channels/push.js +54 -0
  35. package/dist/channels/system.d.ts +26 -0
  36. package/dist/channels/system.js +44 -0
  37. package/dist/compiled-validators.d.ts +40 -0
  38. package/dist/compiled-validators.js +74 -0
  39. package/dist/context-observer.d.ts +205 -0
  40. package/dist/context-observer.js +312 -0
  41. package/dist/gadget-loader.d.ts +118 -0
  42. package/dist/gadget-loader.js +136 -0
  43. package/dist/globals.d.ts +234 -0
  44. package/dist/globals.js +101 -0
  45. package/dist/host-context-emitter.d.ts +91 -0
  46. package/dist/host-context-emitter.js +213 -0
  47. package/dist/iframe-runtime.js +261 -0
  48. package/dist/index.d.ts +38 -0
  49. package/dist/index.js +4 -0
  50. package/dist/lifecycle.d.ts +66 -0
  51. package/dist/lifecycle.js +49 -0
  52. package/dist/mcp-app-iframe-host.d.ts +69 -0
  53. package/dist/mcp-app-iframe-host.js +214 -0
  54. package/dist/observability.d.ts +225 -0
  55. package/dist/observability.js +28 -0
  56. package/dist/protocol-error.d.ts +199 -0
  57. package/dist/protocol-error.js +116 -0
  58. package/dist/provisional-renderer.d.ts +62 -0
  59. package/dist/provisional-renderer.js +323 -0
  60. package/dist/react-renderer.d.ts +95 -0
  61. package/dist/react-renderer.js +343 -0
  62. package/dist/registry-subscribe.d.ts +102 -0
  63. package/dist/registry-subscribe.js +319 -0
  64. package/dist/runtime.d.ts +531 -0
  65. package/dist/runtime.js +2623 -0
  66. package/dist/server.d.ts +36 -0
  67. package/dist/server.js +72 -0
  68. package/dist/stack-item-renderer.d.ts +103 -0
  69. package/dist/stack-item-renderer.js +270 -0
  70. package/dist/stack.d.ts +90 -0
  71. package/dist/stack.js +118 -0
  72. package/dist/status-dom.d.ts +72 -0
  73. package/dist/status-dom.js +51 -0
  74. package/dist/system-cards/NoCredentialsCard.d.ts +33 -0
  75. package/dist/system-cards/NoCredentialsCard.js +189 -0
  76. package/dist/system-cards/ProtocolProbeCard.d.ts +64 -0
  77. package/dist/system-cards/ProtocolProbeCard.js +463 -0
  78. package/dist/system-cards/QrCode.d.ts +46 -0
  79. package/dist/system-cards/QrCode.js +55 -0
  80. package/dist/system-cards/host-detect.d.ts +31 -0
  81. package/dist/system-cards/host-detect.js +108 -0
  82. package/dist/system-cards/host-intents.d.ts +85 -0
  83. package/dist/system-cards/host-intents.js +170 -0
  84. package/dist/system-cards/index.d.ts +70 -0
  85. package/dist/system-cards/index.js +134 -0
  86. package/dist/types.d.ts +69 -0
  87. package/dist/types.js +3 -0
  88. package/dist/validation.d.ts +83 -0
  89. package/dist/validation.js +106 -0
  90. package/dist/wire-config.d.ts +214 -0
  91. package/dist/wire-config.js +290 -0
  92. 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
+ }