@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,234 @@
1
+ /**
2
+ * `globalThis.__ggui__` registry setup — the one seam generated
3
+ * component code reads from.
4
+ *
5
+ * The renderer bundles React + ReactDOM + `@ggui-ai/wire` +
6
+ * `@ggui-ai/design` INSIDE itself, then exposes those runtime
7
+ * values on a single
8
+ * `globalThis.__ggui__` object so generated ESM modules can
9
+ * destructure them via the data-URL shim rewrite (see
10
+ * `@ggui-ai/design/rendering/rewrite-imports.ts`).
11
+ *
12
+ * Shape
13
+ * -----
14
+ *
15
+ * ```ts
16
+ * globalThis.__ggui__ = {
17
+ * react, reactDom,
18
+ * primitives, components, compositions, interact,
19
+ * wire,
20
+ * };
21
+ * ```
22
+ *
23
+ * Keys match the `gguiKey` vocabulary the design package's
24
+ * `rewriteImports()` resolves against (`'react'`, `'reactDom'`,
25
+ * `'primitives'`, `'components'`, `'compositions'`, `'interact'`,
26
+ * `'wire'`). Legacy window globals (`__REACT`, `__GGUI_PRIMITIVES`,
27
+ * …) are ALSO populated so data-URL shims that still fall back to
28
+ * the legacy path keep working.
29
+ *
30
+ * TOCTOU footgun
31
+ * --------------
32
+ *
33
+ * Generated code's data-URL ESM module imports resolve synchronously
34
+ * (the shim runs `globalThis.__ggui__[key]` at import time). That
35
+ * means the registry MUST be populated BEFORE any `loadModule()` call
36
+ * evaluates generated code. The boot orchestration in
37
+ * `runtime.ts::bootSequence` enforces this by calling
38
+ * `installGlobalRegistry()` BEFORE the subscribe ack's first stack
39
+ * item renders. Race-free: Node/browser event loop is single-threaded
40
+ * and no other code runs between `installGlobalRegistry()` and the
41
+ * first stack-item render invocation.
42
+ */
43
+ import type { Context } from 'react';
44
+ /**
45
+ * Module-namespace-compatible shape. Accepts both:
46
+ * - ES module namespace objects returned by `import(...)` (the
47
+ * production case; `typeof import('react')` has typed exports).
48
+ * - Plain records of string keys → unknown (the test case; fakes
49
+ * stand in for the real module).
50
+ *
51
+ * The mapped-type widening is what frees the real module-namespace
52
+ * types from needing a runtime cast.
53
+ */
54
+ export type ModuleNamespace = {
55
+ readonly [key: string]: unknown;
56
+ };
57
+ /**
58
+ * Gadget-package registry shape — package name → that package's whole
59
+ * loaded module namespace. The runtime `gadgets` slot (GG.8.2). The
60
+ * per-package data-URL shims the rewriter substitutes for gadget
61
+ * imports read `__ggui__.gadgets[package][export]` at call/render time.
62
+ */
63
+ export type GadgetPackageRegistry = {
64
+ readonly [packageName: string]: ModuleNamespace;
65
+ };
66
+ /**
67
+ * Internal type for the object installed at `globalThis.__ggui__`.
68
+ *
69
+ * Intentionally NOT exported from the package's public `index.ts` —
70
+ * generated code doesn't type-check against it (it reads via
71
+ * data-URL shims at runtime). Keeping the type local to the renderer
72
+ * avoids leaking a public contract we'd have to version.
73
+ *
74
+ * Module shapes (`ReactModule` / `ReactDomModule` / design-layer
75
+ * modules) are `readonly Record<string, unknown>` because the
76
+ * generated code's data-URL shims pluck named exports by string key;
77
+ * exhaustively typing every React / design-system export would
78
+ * duplicate the upstream package contracts with zero TS benefit for
79
+ * generated code (which is eval'd, not type-checked). This is NOT the
80
+ * `Record<string, unknown>` anti-pattern — the shape IS unbounded by
81
+ * construction (the whole point is: every import the shim might ask
82
+ * for is present).
83
+ */
84
+ export interface GguiGlobalRegistry {
85
+ readonly react: ModuleNamespace;
86
+ readonly reactDom: ModuleNamespace;
87
+ readonly primitives: ModuleNamespace;
88
+ readonly components: ModuleNamespace;
89
+ readonly compositions: ModuleNamespace;
90
+ readonly interact: ModuleNamespace;
91
+ readonly wire: ModuleNamespace;
92
+ /**
93
+ * Design tokens module (`@ggui-ai/design/tokens`). Static design-system
94
+ * constants — color scales, spacing, animation presets, typography
95
+ * styles. Generated component code imports from this path via the
96
+ * data-URL shim, which reads from `globalThis.__ggui__.tokens` at
97
+ * load time. 2026-05-15 audit fix.
98
+ */
99
+ readonly tokens: ModuleNamespace;
100
+ /**
101
+ * Gadget-package registry. Read at call/render time by the
102
+ * per-package data-URL shims that
103
+ * `@ggui-ai/design/rendering/rewrite-imports.ts` substitutes for
104
+ * `@ggui-ai/gadgets` AND each operator-registered gadget package
105
+ * specifier.
106
+ *
107
+ * **Keyed by PACKAGE NAME** (GG.8.2 — e.g. `'@ggui-ai/gadgets'`,
108
+ * `'@ggui-samples/gadget-leaflet'`), each slot holding that
109
+ * package's whole loaded module namespace. The shim for
110
+ * `import { useLeafletMap } from '@ggui-samples/gadget-leaflet'`
111
+ * resolves to `gadgets['@ggui-samples/gadget-leaflet'].useLeafletMap`
112
+ * — package-keyed so two packages exporting the same name never
113
+ * collide.
114
+ *
115
+ * Populated at boot with:
116
+ * - The whole `@ggui-ai/gadgets` STDLIB namespace under
117
+ * `gadgets['@ggui-ai/gadgets']` (the seed).
118
+ * - One namespace per registered 3rd-party package, keyed by
119
+ * package name, from `loadGadgetRegistry()`'s dynamic imports.
120
+ *
121
+ * Why an open record (no exhaustive type): the operator-registered
122
+ * catalog is dynamic; static typing would force a code-gen step on
123
+ * every operator config change.
124
+ */
125
+ readonly gadgets: GadgetPackageRegistry;
126
+ /**
127
+ * Public env values. Populated by the iframe-runtime's boot
128
+ * sequence from `bootstrap.publicEnv` — the union-filtered subset
129
+ * of `App.publicEnv` declared wrappers' `requires` cover.
130
+ * Read at hook-mount time by `getPublicEnv(key)` from
131
+ * `@ggui-ai/gadgets`.
132
+ *
133
+ * Empty record when the bootstrap has no `publicEnv` field (no
134
+ * wrappers required keys). The hook will throw on access to any
135
+ * key, which is the correct fail-fast behavior — the operator
136
+ * either forgot to configure publicEnv OR the wrapper didn't
137
+ * declare `requires`.
138
+ */
139
+ readonly publicEnv: Readonly<Record<string, string>>;
140
+ /**
141
+ * React Context registry. Populated by
142
+ * `runtime.ts::installContextRegistry()` during boot from the
143
+ * bootstrap's `contextSlots` field. Keyed by PascalCase
144
+ * `contextName` (e.g. `'CurrentStepContext'`). The boilerplate
145
+ * destructures these so the LLM-authored component can wrap a tree
146
+ * in `<CurrentStepContext.Provider value={…}>` and the runtime
147
+ * observer reads values back via `useContext`.
148
+ *
149
+ * Mutable across re-mounts: on a second `bootSelfContained` call,
150
+ * the registry is REUSED (entries are keyed by contextName and
151
+ * skipped on re-create) so the LLM's destructured Context
152
+ * references stay live across re-mounts. Only NEW context names
153
+ * append; existing ones are never replaced.
154
+ */
155
+ readonly contexts: GguiContextRegistry;
156
+ }
157
+ /**
158
+ * React Context registry installed at `globalThis.__ggui__.contexts`.
159
+ * Open-ended record — the runtime adds one entry per declared
160
+ * `contextSpec` slot at boot, and the boilerplate destructures by
161
+ * `contextName`. Values are typed loosely (`Context<unknown>`) at the
162
+ * registry boundary because each slot's value type is schema-derived;
163
+ * the boilerplate's destructure cast narrows for the LLM's component.
164
+ *
165
+ * Mutable to allow per-slot append on re-mount when a new
166
+ * contextSpec adds a slot the previous mount didn't declare. Existing
167
+ * entries are NEVER replaced — the same Context reference must stay
168
+ * stable across re-mounts so the LLM's destructured component code
169
+ * keeps working.
170
+ */
171
+ export interface GguiContextRegistry {
172
+ [contextName: string]: Context<unknown>;
173
+ }
174
+ declare global {
175
+ var __ggui__: GguiGlobalRegistry | undefined;
176
+ }
177
+ export interface InstallGlobalRegistryOptions {
178
+ readonly react: ModuleNamespace;
179
+ readonly reactDom: ModuleNamespace;
180
+ readonly primitives: ModuleNamespace;
181
+ readonly components: ModuleNamespace;
182
+ readonly compositions: ModuleNamespace;
183
+ readonly interact: ModuleNamespace;
184
+ readonly wire: ModuleNamespace;
185
+ readonly tokens: ModuleNamespace;
186
+ /**
187
+ * Gadget-package registry — package name → that package's loaded
188
+ * module namespace. Production callers compose this from the
189
+ * `@ggui-ai/gadgets` STDLIB seed plus each operator-registered
190
+ * package's dynamic import (see `loadGadgetRegistry`). Tests pass
191
+ * fakes.
192
+ *
193
+ * Optional: callers may omit this and the registry installs an
194
+ * empty record. Generated code that imports a gadget export then
195
+ * fails at the iframe's ESM module-eval (the rewriter emits a
196
+ * per-package shim only for registered packages). Operators who
197
+ * don't register any 3rd-party packages should leave
198
+ * `clientCapabilities.gadgets` off their contracts.
199
+ */
200
+ readonly gadgets?: GadgetPackageRegistry;
201
+ /**
202
+ * Public env values. Forwarded from `bootstrap.publicEnv`
203
+ * verbatim — the server-side projection already filtered down to
204
+ * the union of declared wrappers' `requires`. Wrappers read via
205
+ * `getPublicEnv(key)` from `@ggui-ai/gadgets`.
206
+ *
207
+ * Optional: callers may omit this and the registry installs an
208
+ * empty record (wrappers that need
209
+ * env values will throw at hook-mount with a clear "not provided"
210
+ * message, which is the right fail-fast behavior).
211
+ */
212
+ readonly publicEnv?: Readonly<Record<string, string>>;
213
+ }
214
+ /**
215
+ * Install the `globalThis.__ggui__` registry. Idempotent — calling
216
+ * twice replaces the module-slot references wholesale; the `contexts`
217
+ * sub-object is the ONE thing reused across re-installs so the LLM's
218
+ * destructured Context references stay stable. The boot sequence calls
219
+ * this exactly once.
220
+ *
221
+ * Caller supplies every module reference. The renderer's `runtime.ts`
222
+ * does the imports at module-load time and passes the resolved
223
+ * namespaces in; tests pass fakes. Dependency injection rather than
224
+ * hard-coded imports keeps globals.ts free of the heavy React +
225
+ * design + wire module graph — makes unit testing a 200-line file
226
+ * rather than a 150KB bundle slice.
227
+ */
228
+ export declare function installGlobalRegistry(opts: InstallGlobalRegistryOptions, target?: typeof globalThis): GguiGlobalRegistry;
229
+ /**
230
+ * Read the current registry. Returns `undefined` when
231
+ * `installGlobalRegistry()` has not yet been called — callers MUST
232
+ * handle the absence honestly rather than assuming.
233
+ */
234
+ export declare function getGlobalRegistry(target?: typeof globalThis): GguiGlobalRegistry | undefined;
@@ -0,0 +1,101 @@
1
+ // =============================================================================
2
+ // Registry install
3
+ // =============================================================================
4
+ /**
5
+ * The design package's `rewrite-imports.ts` falls back to legacy
6
+ * window globals (`window.__REACT`, `window.__GGUI_PRIMITIVES`, …)
7
+ * when `globalThis.__ggui__[key]` is absent. We populate BOTH paths
8
+ * so any in-flight cached generated code (which pre-dated the
9
+ * __ggui__ consolidation) keeps resolving.
10
+ *
11
+ * Pre-rebuilt design-package fallback — matches the list today's
12
+ * `ReactComponentRenderer.tsx` sets in the host SDK path.
13
+ */
14
+ const LEGACY_GLOBAL_KEYS = {
15
+ react: '__REACT',
16
+ primitives: '__GGUI_PRIMITIVES',
17
+ components: '__GGUI_COMPONENTS',
18
+ compositions: '__GGUI_COMPOSITIONS',
19
+ interact: '__GGUI_INTERACT',
20
+ tokens: '__GGUI_TOKENS',
21
+ // Symmetric with the other layer globals. The rewriter's data-URL
22
+ // shim reads from `globalThis.__ggui__.gadgets` directly (no
23
+ // legacy fallback in the shim), but populating the legacy global
24
+ // keeps console debugging
25
+ // (`window.__GGUI_CLIENT_LIBRARIES.useGeolocation`) working
26
+ // uniformly with the other layers.
27
+ gadgets: '__GGUI_CLIENT_LIBRARIES',
28
+ // Symmetric with the other registry layers. The rewriter's
29
+ // `getPublicEnv` shim reads `globalThis.__ggui__.publicEnv`
30
+ // directly; the legacy global is for console-debugging parity
31
+ // (`window.__GGUI_PUBLIC_ENV.GGUI_PUBLIC_APP_FOO`).
32
+ publicEnv: '__GGUI_PUBLIC_ENV',
33
+ };
34
+ /**
35
+ * Install the `globalThis.__ggui__` registry. Idempotent — calling
36
+ * twice replaces the module-slot references wholesale; the `contexts`
37
+ * sub-object is the ONE thing reused across re-installs so the LLM's
38
+ * destructured Context references stay stable. The boot sequence calls
39
+ * this exactly once.
40
+ *
41
+ * Caller supplies every module reference. The renderer's `runtime.ts`
42
+ * does the imports at module-load time and passes the resolved
43
+ * namespaces in; tests pass fakes. Dependency injection rather than
44
+ * hard-coded imports keeps globals.ts free of the heavy React +
45
+ * design + wire module graph — makes unit testing a 200-line file
46
+ * rather than a 150KB bundle slice.
47
+ */
48
+ export function installGlobalRegistry(opts, target = globalThis) {
49
+ // Idempotency on re-mount: the LLM's component holds Context
50
+ // references it destructured from `globalThis.__ggui__.contexts` at
51
+ // first boot. Re-creating the contexts record on every re-mount
52
+ // would invalidate those references. Reuse the existing `contexts`
53
+ // sub-object when present; only the module/wire references swap.
54
+ const existing = target.__ggui__;
55
+ const contexts = existing?.contexts ?? {};
56
+ const registry = {
57
+ react: opts.react,
58
+ reactDom: opts.reactDom,
59
+ primitives: opts.primitives,
60
+ components: opts.components,
61
+ compositions: opts.compositions,
62
+ interact: opts.interact,
63
+ wire: opts.wire,
64
+ tokens: opts.tokens,
65
+ // Gadget hooks (STDLIB + registered wrappers). Defaults to an
66
+ // empty record so callers who don't bind libraries don't have to
67
+ // thread the slot — generated code that references a hook in
68
+ // that case fails at the LLM's `useFoo()` call (which is
69
+ // correct: there's no impl to call).
70
+ gadgets: opts.gadgets ?? {},
71
+ // Public env values for wrapper hooks to read via
72
+ // `getPublicEnv(key)`. Defaults to an empty record; wrappers
73
+ // that need values throw clearly at hook-mount.
74
+ publicEnv: opts.publicEnv ?? {},
75
+ contexts,
76
+ };
77
+ // Install the consolidated registry.
78
+ target.__ggui__ = registry;
79
+ // Also populate the legacy window globals — today's
80
+ // `rewrite-imports.ts` shim path falls back to these when
81
+ // `globalThis.__ggui__[key]` misses. Setting both keeps any
82
+ // pre-registry cached generated code working.
83
+ const win = target;
84
+ win[LEGACY_GLOBAL_KEYS.react] = opts.react;
85
+ win[LEGACY_GLOBAL_KEYS.primitives] = opts.primitives;
86
+ win[LEGACY_GLOBAL_KEYS.components] = opts.components;
87
+ win[LEGACY_GLOBAL_KEYS.compositions] = opts.compositions;
88
+ win[LEGACY_GLOBAL_KEYS.interact] = opts.interact;
89
+ win[LEGACY_GLOBAL_KEYS.tokens] = opts.tokens;
90
+ win[LEGACY_GLOBAL_KEYS.gadgets] = registry.gadgets;
91
+ win[LEGACY_GLOBAL_KEYS.publicEnv] = registry.publicEnv;
92
+ return registry;
93
+ }
94
+ /**
95
+ * Read the current registry. Returns `undefined` when
96
+ * `installGlobalRegistry()` has not yet been called — callers MUST
97
+ * handle the absence honestly rather than assuming.
98
+ */
99
+ export function getGlobalRegistry(target = globalThis) {
100
+ return target.__ggui__;
101
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Host-context emitter.
3
+ *
4
+ * Responsibilities:
5
+ *
6
+ * 1. Hold the latest `HostContextProjection` the runtime has captured.
7
+ * Seeded from the iframe's `ui/initialize` response (Reading-A
8
+ * path, via `parseBootstrapFromUiInitialize`); updated on every
9
+ * `ui/notifications/host-context-changed` postMessage from the host.
10
+ * 2. Echo each fresh value as a `type: 'host_context_observed'`
11
+ * WebSocket envelope so the server can persist it on
12
+ * `SessionRecord.hostContext` for agent visibility.
13
+ * 3. Suppress no-op re-emissions (via deep-equality on the projection
14
+ * shape) — avoids server-side write traffic when a notification
15
+ * arrives but no projection-visible field actually changed (e.g.,
16
+ * the host's `theme` updated but ggui doesn't project theme).
17
+ *
18
+ * Lifecycle:
19
+ *
20
+ * - Initial seed: `seed(projection, sessionId)` called from
21
+ * `bootProduction` right after the WS transport is attached and we
22
+ * know the initial projection from `parseBootstrap.hostContext`.
23
+ * - Live updates: `attachListener()` installs a `window`-scoped
24
+ * `message` listener for the spec-defined notification method
25
+ * `'ui/notifications/host-context-changed'`. The notification's
26
+ * `params` carry a partial `McpUiHostContext`; we merge into the
27
+ * held projection and emit if changed.
28
+ * - Teardown: `detach()` removes the listener and clears state.
29
+ * Used by tests + future host-switch flows.
30
+ *
31
+ * Why a dedicated module:
32
+ *
33
+ * - `runtime.ts` is already ~3100 lines; adding another postMessage
34
+ * listener + state slot bloats it.
35
+ * - The emission policy (seed + change-merge + dedupe) is testable in
36
+ * isolation without the WS transport / bootstrap parse / DOM mount.
37
+ * - Future widening (echo more fields, debounce burst changes, etc.)
38
+ * happens in one place.
39
+ */
40
+ import { type HostContextObservedPayload, type HostContextProjection } from '@ggui-ai/protocol';
41
+ /**
42
+ * Function the emitter calls to ship a `host_context_observed` envelope.
43
+ * Wired by `bootProduction` to the live WS transport's `send` —
44
+ * typically `(msg) => handle.handle.send(msg)` after subscribe ack.
45
+ *
46
+ * The emitter does NOT buffer pre-WS-up emissions: `seed()` MUST only
47
+ * be called once the send seam is live. If seed is called before send
48
+ * is wired, the initial echo is silently dropped (the WS hasn't been
49
+ * established; the server isn't listening). This matches the
50
+ * fire-and-forget posture of the protocol — agent visibility is
51
+ * eventually-consistent, never blocking.
52
+ */
53
+ export type HostContextSendFn = (msg: {
54
+ readonly type: 'host_context_observed';
55
+ readonly payload: HostContextObservedPayload;
56
+ }) => void;
57
+ /**
58
+ * Seed the emitter with the initial projection captured at boot time +
59
+ * the live WS send seam. Idempotent: a re-seed (e.g. on reconnect with
60
+ * the same projection) suppresses the duplicate emission via the
61
+ * equality check.
62
+ *
63
+ * Re-seeding with a DIFFERENT `sessionId` is undefined behavior; the
64
+ * iframe-runtime is one-session-per-mount by construction. Tests
65
+ * exercising multi-session flows MUST call `detach()` between seeds.
66
+ */
67
+ export declare function seed(args: {
68
+ readonly sessionId: string;
69
+ readonly send: HostContextSendFn;
70
+ readonly initial: HostContextProjection;
71
+ }): void;
72
+ /**
73
+ * Install the `host-context-changed` notification listener on the
74
+ * iframe `window`. Safe to call multiple times — duplicate listeners
75
+ * are deduped via `state.listener !== null` guard.
76
+ *
77
+ * Detaches when `detach()` is called or when the module is torn down.
78
+ */
79
+ export declare function attachListener(targetWindow?: Window): void;
80
+ /**
81
+ * Tear down: remove the listener, clear module state. Called from
82
+ * unit tests between scenarios and from the future host-switch flow.
83
+ */
84
+ export declare function detach(targetWindow?: Window): void;
85
+ /**
86
+ * Test-only read of the current held projection. Production code
87
+ * MUST NOT depend on this; the emitter is fire-and-forget by design.
88
+ * @internal
89
+ */
90
+ export declare function _peekCurrent(): HostContextProjection | undefined;
91
+ export declare function subscribeLocal(listener: (projection: HostContextProjection) => void): () => void;
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Host-context emitter.
3
+ *
4
+ * Responsibilities:
5
+ *
6
+ * 1. Hold the latest `HostContextProjection` the runtime has captured.
7
+ * Seeded from the iframe's `ui/initialize` response (Reading-A
8
+ * path, via `parseBootstrapFromUiInitialize`); updated on every
9
+ * `ui/notifications/host-context-changed` postMessage from the host.
10
+ * 2. Echo each fresh value as a `type: 'host_context_observed'`
11
+ * WebSocket envelope so the server can persist it on
12
+ * `SessionRecord.hostContext` for agent visibility.
13
+ * 3. Suppress no-op re-emissions (via deep-equality on the projection
14
+ * shape) — avoids server-side write traffic when a notification
15
+ * arrives but no projection-visible field actually changed (e.g.,
16
+ * the host's `theme` updated but ggui doesn't project theme).
17
+ *
18
+ * Lifecycle:
19
+ *
20
+ * - Initial seed: `seed(projection, sessionId)` called from
21
+ * `bootProduction` right after the WS transport is attached and we
22
+ * know the initial projection from `parseBootstrap.hostContext`.
23
+ * - Live updates: `attachListener()` installs a `window`-scoped
24
+ * `message` listener for the spec-defined notification method
25
+ * `'ui/notifications/host-context-changed'`. The notification's
26
+ * `params` carry a partial `McpUiHostContext`; we merge into the
27
+ * held projection and emit if changed.
28
+ * - Teardown: `detach()` removes the listener and clears state.
29
+ * Used by tests + future host-switch flows.
30
+ *
31
+ * Why a dedicated module:
32
+ *
33
+ * - `runtime.ts` is already ~3100 lines; adding another postMessage
34
+ * listener + state slot bloats it.
35
+ * - The emission policy (seed + change-merge + dedupe) is testable in
36
+ * isolation without the WS transport / bootstrap parse / DOM mount.
37
+ * - Future widening (echo more fields, debounce burst changes, etc.)
38
+ * happens in one place.
39
+ */
40
+ import { hostContextProjectionsEqual, projectHostContext, } from '@ggui-ai/protocol';
41
+ let state = null;
42
+ // =============================================================================
43
+ // Public surface
44
+ // =============================================================================
45
+ /**
46
+ * Seed the emitter with the initial projection captured at boot time +
47
+ * the live WS send seam. Idempotent: a re-seed (e.g. on reconnect with
48
+ * the same projection) suppresses the duplicate emission via the
49
+ * equality check.
50
+ *
51
+ * Re-seeding with a DIFFERENT `sessionId` is undefined behavior; the
52
+ * iframe-runtime is one-session-per-mount by construction. Tests
53
+ * exercising multi-session flows MUST call `detach()` between seeds.
54
+ */
55
+ export function seed(args) {
56
+ // First seed — record state + emit initial.
57
+ if (state === null) {
58
+ state = {
59
+ sessionId: args.sessionId,
60
+ send: args.send,
61
+ current: args.initial,
62
+ listener: null,
63
+ };
64
+ emit();
65
+ return;
66
+ }
67
+ // Re-seed — only emit if the projection actually changed. Keep the
68
+ // existing listener registration if any.
69
+ if (!hostContextProjectionsEqual(state.current, args.initial)) {
70
+ state.current = args.initial;
71
+ emit();
72
+ }
73
+ }
74
+ /**
75
+ * Install the `host-context-changed` notification listener on the
76
+ * iframe `window`. Safe to call multiple times — duplicate listeners
77
+ * are deduped via `state.listener !== null` guard.
78
+ *
79
+ * Detaches when `detach()` is called or when the module is torn down.
80
+ */
81
+ export function attachListener(targetWindow = window) {
82
+ if (state === null) {
83
+ // Not yet seeded — listener can't usefully fire (no send seam).
84
+ // Caller should `seed()` first; this defensive bail keeps the
85
+ // surface idempotent rather than throwing.
86
+ return;
87
+ }
88
+ if (state.listener !== null)
89
+ return;
90
+ const listener = (ev) => {
91
+ handleHostContextChangedMessage(ev.data);
92
+ };
93
+ state.listener = listener;
94
+ targetWindow.addEventListener('message', listener);
95
+ }
96
+ /**
97
+ * Tear down: remove the listener, clear module state. Called from
98
+ * unit tests between scenarios and from the future host-switch flow.
99
+ */
100
+ export function detach(targetWindow = window) {
101
+ if (state !== null && state.listener !== null) {
102
+ targetWindow.removeEventListener('message', state.listener);
103
+ }
104
+ state = null;
105
+ // Reset the local subscriber set too — module-level state is the
106
+ // singleton's identity, and tests need clean isolation between
107
+ // scenarios. Without this, a subscriber registered in test 1 leaks
108
+ // into test 2's seed and fires against a stale closure.
109
+ localSubscribers.clear();
110
+ }
111
+ /**
112
+ * Test-only read of the current held projection. Production code
113
+ * MUST NOT depend on this; the emitter is fire-and-forget by design.
114
+ * @internal
115
+ */
116
+ export function _peekCurrent() {
117
+ return state?.current;
118
+ }
119
+ /**
120
+ * Subscribe to local projection updates so
121
+ * iframe-side consumers (canvas mount handle's display-mode state)
122
+ * can react to `host-context-changed` notifications without re-
123
+ * parsing the postMessage envelope themselves.
124
+ *
125
+ * Fires:
126
+ * - Immediately on subscribe IF `state` is already seeded (so the
127
+ * subscriber doesn't have to also read `parsed.hostContext`).
128
+ * - On every `host-context-changed` notification that updates
129
+ * `state.current` (post-projection, post-equality-check).
130
+ *
131
+ * Returns an unsubscribe function. Multi-subscriber by design —
132
+ * tests + production canvas mount can coexist.
133
+ */
134
+ const localSubscribers = new Set();
135
+ export function subscribeLocal(listener) {
136
+ localSubscribers.add(listener);
137
+ // Fire immediately if seed has already happened.
138
+ if (state !== null)
139
+ listener(state.current);
140
+ return () => {
141
+ localSubscribers.delete(listener);
142
+ };
143
+ }
144
+ // =============================================================================
145
+ // Internals
146
+ // =============================================================================
147
+ function isPlainObject(value) {
148
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
149
+ }
150
+ /**
151
+ * Process an incoming postMessage payload. Filters to JSON-RPC
152
+ * notifications with method `ui/notifications/host-context-changed`;
153
+ * everything else is ignored. The notification's `params` is a partial
154
+ * `McpUiHostContext` — we project and merge into the held value.
155
+ *
156
+ * Exported via `attachListener` indirection; isolated here so it's
157
+ * unit-testable without dispatching real `MessageEvent`s.
158
+ */
159
+ function handleHostContextChangedMessage(raw) {
160
+ if (state === null)
161
+ return;
162
+ if (!isPlainObject(raw))
163
+ return;
164
+ if (raw['method'] !== 'ui/notifications/host-context-changed')
165
+ return;
166
+ const params = raw['params'];
167
+ if (!isPlainObject(params))
168
+ return;
169
+ // Spec: the notification's params carry a partial McpUiHostContext.
170
+ // Project it (defensive — malformed inputs drop to undefined fields)
171
+ // and merge with the held projection so a partial update overlays
172
+ // unchanged fields cleanly.
173
+ const partial = projectHostContext(params);
174
+ if (partial === undefined)
175
+ return;
176
+ const merged = { ...state.current, ...partial };
177
+ if (hostContextProjectionsEqual(state.current, merged))
178
+ return;
179
+ state.current = merged;
180
+ emit();
181
+ }
182
+ function emit() {
183
+ if (state === null)
184
+ return;
185
+ try {
186
+ state.send({
187
+ type: 'host_context_observed',
188
+ payload: {
189
+ sessionId: state.sessionId,
190
+ hostContext: state.current,
191
+ },
192
+ });
193
+ }
194
+ catch {
195
+ // Send seam errored (WS detached, host disconnected, etc.). The
196
+ // emitter is fire-and-forget; swallow + carry on. The next change
197
+ // notification will retry. Server-side agent visibility is
198
+ // eventually-consistent, not transactional.
199
+ }
200
+ // Fan out to local iframe-side subscribers
201
+ // (canvas mount's display-mode state). Independent from the
202
+ // WS-send try/catch above — a send failure must not skip the
203
+ // local fan-out, and a subscriber throwing must not skip other
204
+ // subscribers.
205
+ for (const listener of localSubscribers) {
206
+ try {
207
+ listener(state.current);
208
+ }
209
+ catch {
210
+ // Subscriber threw — swallow to keep the broadcast loop alive.
211
+ }
212
+ }
213
+ }