@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,290 @@
1
+ import { ClientContractViolationError, buildActionEnvelope, } from '@ggui-ai/wire';
2
+ import { validateOutboundActionEnvelope } from './validation.js';
3
+ import { defaultProtocolErrorEmitter, fromClientContractViolation, } from './protocol-error.js';
4
+ // =============================================================================
5
+ // StreamBus — the in-renderer bridge between inbound `data` frames
6
+ // and per-component `useStream(channel)` subscribers.
7
+ // =============================================================================
8
+ /**
9
+ * Reserved-channel namespace prefix (mirror of
10
+ * `@ggui-ai/protocol::RESERVED_CHANNEL_PREFIX`). Inlined to avoid
11
+ * widening the protocol import surface here — the constant is
12
+ * load-bearing for the late-subscriber replay rule below; equality
13
+ * with the protocol export is locked by the StreamBus tests.
14
+ */
15
+ const RESERVED_CHANNEL_PREFIX = '_ggui:';
16
+ /**
17
+ * Per-channel ring cap for late-subscriber replay on reserved channels.
18
+ * Mirrors the server-side `DEFAULT_SESSION_STREAM_BUFFER_MAX` posture:
19
+ * bounded so a long-lived iframe can't grow memory unbounded if a
20
+ * reserved channel emits in a hot loop.
21
+ *
22
+ * 256 is enough headroom for the deterministic A2UI emitter (one frame
23
+ * per fragment, ~5–15 frames per push) plus several refresh cycles, and
24
+ * still fits comfortably in memory.
25
+ */
26
+ const RESERVED_CHANNEL_REPLAY_MAX = 256;
27
+ /**
28
+ * Tiny fan-out bus keyed by channel name. `subscribe.ts`'s inbound-
29
+ * message handler pushes validated `data` envelopes here; wire's
30
+ * `useStream(channel)` subscribes per-component via the config's
31
+ * `subscribe()` method.
32
+ *
33
+ * Late-subscriber replay for reserved channels:
34
+ *
35
+ * The renderer iframe boots in a strict ordering:
36
+ * `subscribe()` → first `ack` → render stack items → mount
37
+ * `mountProvisional` → it then calls `streamBus.subscribe('_ggui:
38
+ * preview', …)`. Server-side replay frames for `_ggui:preview` (the
39
+ * provisional A2UI preamble fired during the agent's `ggui_push`
40
+ * BEFORE the user's browser navigated to the viewer) arrive over the
41
+ * WS in the window between ack-resolve and the provisional mount —
42
+ * they hit `emit()` before any listener has subscribed. Without a
43
+ * bounded reserved-channel ring, those frames vanish and the preview
44
+ * surface stays stuck on the spinner.
45
+ *
46
+ * Server-owned `_ggui:*` channels are buffered in a per-channel ring
47
+ * capped at {@link RESERVED_CHANNEL_REPLAY_MAX}. New subscribers
48
+ * receive the buffered envelopes synchronously before the unsubscribe
49
+ * handle returns — same mental model as the server-side
50
+ * `SessionStreamBuffer.replay(...)` walk over reserved channels at
51
+ * ack time, but mirrored at the inner bus boundary so the host
52
+ * transport stays portable (Claude Desktop / ChatGPT / Cursor /
53
+ * `<McpAppIframe>` all behave the same).
54
+ *
55
+ * Agent-declared (non-reserved) channels are NOT buffered here. Their
56
+ * replay is the server's job — the renderer's `subscribe()` handshake
57
+ * pulls history per `streamSpec.replay` policy when reconnecting with
58
+ * `fromSeq`. Buffering them at this layer would double-count and
59
+ * change the semantics of agent contracts.
60
+ *
61
+ * No observable mutation outside the renderer boundary — the bus is
62
+ * internal plumbing.
63
+ */
64
+ export class StreamBus {
65
+ listeners = new Map();
66
+ /**
67
+ * Per-channel bounded ring of envelopes for reserved (`_ggui:*`)
68
+ * channels only. FIFO eviction at `RESERVED_CHANNEL_REPLAY_MAX`.
69
+ * Replayed to every new subscriber for that channel.
70
+ */
71
+ reservedReplay = new Map();
72
+ subscribe(channel, listener) {
73
+ let bucket = this.listeners.get(channel);
74
+ if (bucket === undefined) {
75
+ bucket = new Set();
76
+ this.listeners.set(channel, bucket);
77
+ }
78
+ bucket.add(listener);
79
+ // Replay buffered reserved-channel envelopes BEFORE returning the
80
+ // unsubscribe handle so the new listener is fully caught up by the
81
+ // time `subscribe()` returns. Order matches arrival order at
82
+ // `emit()` (FIFO ring).
83
+ if (channel.startsWith(RESERVED_CHANNEL_PREFIX)) {
84
+ const ring = this.reservedReplay.get(channel);
85
+ if (ring !== undefined) {
86
+ for (const env of ring) {
87
+ listener(env);
88
+ }
89
+ }
90
+ }
91
+ return () => {
92
+ const current = this.listeners.get(channel);
93
+ if (current === undefined)
94
+ return;
95
+ current.delete(listener);
96
+ if (current.size === 0)
97
+ this.listeners.delete(channel);
98
+ };
99
+ }
100
+ emit(envelope) {
101
+ if (envelope.channel.startsWith(RESERVED_CHANNEL_PREFIX)) {
102
+ let ring = this.reservedReplay.get(envelope.channel);
103
+ if (ring === undefined) {
104
+ ring = [];
105
+ this.reservedReplay.set(envelope.channel, ring);
106
+ }
107
+ ring.push(envelope);
108
+ if (ring.length > RESERVED_CHANNEL_REPLAY_MAX) {
109
+ // FIFO drop — keeps the most recent
110
+ // `RESERVED_CHANNEL_REPLAY_MAX` frames. Late subscribers may
111
+ // miss the createSurface fragment if the ring overflows (would
112
+ // render as Spinner-stuck), but that's an upper-bound contract
113
+ // on a bounded buffer, not a silent failure mode in practice.
114
+ ring.shift();
115
+ }
116
+ }
117
+ const bucket = this.listeners.get(envelope.channel);
118
+ if (bucket === undefined)
119
+ return;
120
+ for (const listener of bucket) {
121
+ listener(envelope);
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Build the unscoped root `WireConfig` + its scope-factory closure.
127
+ * Bootstraps the renderer's outbound emission + inbound subscription
128
+ * seams.
129
+ *
130
+ * Returns a {@link RootWireConfigBundle} rather than a bare
131
+ * `WireConfig` — the scope factory is paired with the root so
132
+ * `dispatchByItem` internals don't leak onto the public WireConfig
133
+ * shape. `scopeWireConfig` (the exported standalone) is the low-level
134
+ * primitive; `buildScopedConfig` is the pre-bound convenience.
135
+ */
136
+ export function buildRootWireConfig(opts) {
137
+ const clientSeqBox = { current: 0 };
138
+ function nextSeq() {
139
+ clientSeqBox.current += 1;
140
+ return clientSeqBox.current;
141
+ }
142
+ const emitProtocolError = opts.onProtocolError ?? defaultProtocolErrorEmitter;
143
+ const emitObserve = opts.onObserve ?? (() => { });
144
+ function surfaceViolation(err) {
145
+ // Dual emission — the narrow `onContractViolation` sink stays
146
+ // for in-renderer tests that assert the raw class shape; the
147
+ // typed `onProtocolError` sink receives the widened
148
+ // ProtocolError ('kind: protocol' / code: CLIENT_CONTRACT_VIOLATION)
149
+ // that the `<McpAppIframe>` host wrapper surfaces via `onError`.
150
+ if (opts.onContractViolation) {
151
+ opts.onContractViolation(err);
152
+ }
153
+ else if (opts.onProtocolError === undefined) {
154
+ // No caller-supplied sink at all → operator-visible fallback so
155
+ // dev consoles see the violation. Matches
156
+ // `GguiSession.surfaceContractViolation`'s console.warn posture.
157
+ // eslint-disable-next-line no-console
158
+ console.warn('[ggui:contract] ' + err.message, {
159
+ direction: err.direction,
160
+ violations: err.violations,
161
+ });
162
+ }
163
+ emitProtocolError(fromClientContractViolation(err));
164
+ }
165
+ function dispatchByItem(actionName, data, ctx) {
166
+ const envelope = buildActionEnvelope({
167
+ sessionId: opts.sessionId,
168
+ type: 'data:submit',
169
+ payload: {
170
+ action: actionName,
171
+ data: data,
172
+ ...(ctx.tool ? { tool: ctx.tool } : {}),
173
+ },
174
+ stackIndex: ctx.stackIndex,
175
+ ...(ctx.stackItemId ? { stackItemId: ctx.stackItemId } : {}),
176
+ clientSeq: nextSeq(),
177
+ });
178
+ const result = validateOutboundActionEnvelope(ctx.actionSpec, envelope);
179
+ if (!result.valid) {
180
+ surfaceViolation(new ClientContractViolationError('outbound-action', result.violations));
181
+ return;
182
+ }
183
+ sendActionEnvelope(opts.manager, envelope);
184
+ // Observability: a wired-tool dispatch is a `data:submit` whose
185
+ // resolved action has a `tool` binding. Plain agent-routed actions
186
+ // (no `tool` on the actionSpec) fire a `dispatch` activity row in
187
+ // the console via the session-side ring buffer; only wired tools
188
+ // get this observability event. The server separately emits the
189
+ // operational telemetry variant on its TelemetrySink.
190
+ if (ctx.tool !== undefined) {
191
+ emitObserve({
192
+ kind: 'wired-tool-invoked',
193
+ toolName: ctx.tool,
194
+ actionName,
195
+ dispatchedAt: new Date().toISOString(),
196
+ });
197
+ }
198
+ }
199
+ const root = {
200
+ app: { appId: opts.appId, appName: opts.appId },
201
+ session: { sessionId: opts.sessionId, isConnected: true },
202
+ auth: { isAuthenticated: false },
203
+ dispatch: (actionName, data) => {
204
+ // Unscoped fallback — resolve from top of stack. Matches
205
+ // `GguiSession.wireConfig.dispatch` (the non-scoped path).
206
+ const stack = opts.getStack();
207
+ const activeItem = stack[stack.length - 1];
208
+ const activeIndex = stack.length > 0 ? stack.length - 1 : 0;
209
+ // McpApps + system items never carry `actionSpec` (their wire is
210
+ // owned by their own host) — narrow before reading.
211
+ const activeActionSpec = activeItem !== undefined &&
212
+ activeItem.type !== 'mcpApps' &&
213
+ activeItem.type !== 'system'
214
+ ? activeItem.actionSpec
215
+ : undefined;
216
+ const entry = activeActionSpec?.[actionName];
217
+ const tool = entry?.nextStep;
218
+ dispatchByItem(actionName, data, {
219
+ stackItemId: activeItem?.id,
220
+ stackIndex: activeIndex,
221
+ ...(tool !== undefined ? { tool } : {}),
222
+ ...(activeActionSpec !== undefined
223
+ ? { actionSpec: activeActionSpec }
224
+ : {}),
225
+ });
226
+ },
227
+ subscribe: (channelName, handler) => {
228
+ return opts.streamBus.subscribe(channelName, (env) => {
229
+ // `WireConfig<DataContract>.subscribe`'s handler expects a
230
+ // `StreamDelivery<unknown>`; the bus ships the raw envelope
231
+ // payload unmodified. The type-level `unknown` narrowing
232
+ // lives on the caller's generic boundary (useStream /
233
+ // useContract).
234
+ handler({
235
+ payload: env.payload,
236
+ mode: env.mode,
237
+ ...(env.complete !== undefined ? { complete: env.complete } : {}),
238
+ });
239
+ });
240
+ },
241
+ // `callWiredTool` is retired — `agentTools` is now a catalog the
242
+ // AGENT invokes; the component never reaches the underlying tool
243
+ // from inside the renderer. Cross-refs surface via
244
+ // `actionSpec[*].nextStep` (event metadata) and
245
+ // `streamSpec[*].source.tool` (channel data source).
246
+ };
247
+ // `WireConfig` carries no `scope(item)` method.
248
+ // Callers that want a per-item config use `buildScopedConfig(item)`
249
+ // on the returned bundle, which bakes in the `dispatchByItem` +
250
+ // `getStack` internals so they never leak onto the public
251
+ // WireConfig shape.
252
+ const buildScopedConfig = (item) => scopeWireConfig(root, item, {
253
+ getStack: opts.getStack,
254
+ dispatchByItem,
255
+ });
256
+ return { config: root, buildScopedConfig };
257
+ }
258
+ /**
259
+ * Produce a per-item scoped `WireConfig`. The only override is
260
+ * `dispatch` — every other seam (subscribe) stays shared.
261
+ */
262
+ export function scopeWireConfig(root, item, internals) {
263
+ return {
264
+ ...root,
265
+ dispatch: (actionName, data) => {
266
+ const entry = item.actionSpec?.[actionName];
267
+ const tool = entry?.nextStep;
268
+ const stack = internals.getStack();
269
+ const idx = stack.findIndex((s) => s.id === item.stackItemId);
270
+ internals.dispatchByItem(actionName, data, {
271
+ ...(item.stackItemId !== undefined ? { stackItemId: item.stackItemId } : {}),
272
+ stackIndex: idx >= 0 ? idx : 0,
273
+ ...(tool !== undefined ? { tool } : {}),
274
+ ...(item.actionSpec !== undefined ? { actionSpec: item.actionSpec } : {}),
275
+ });
276
+ },
277
+ };
278
+ }
279
+ // =============================================================================
280
+ // Outbound envelope emission
281
+ // =============================================================================
282
+ /**
283
+ * Send a validated action envelope over the live channel. The WS frame shape
284
+ * is `{type:'action', payload: envelope}` — matches today's
285
+ * `useWebSocket.sendAction` exactly, so wire-emitted dispatches from
286
+ * the renderer are byte-equivalent to `GguiSession`-emitted ones.
287
+ */
288
+ function sendActionEnvelope(manager, envelope) {
289
+ manager.send({ type: 'action', payload: envelope });
290
+ }
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@ggui-ai/iframe-runtime",
3
+ "version": "0.1.0-rc.1",
4
+ "description": "The iframe-local ggui runtime: a self-contained ESM bundle that boots inside an MCP Apps host iframe, runs the protocol-version handshake, opens the live-channel WebSocket, and renders the session stack. Bundles React, ReactDOM, and the ggui design system so generated component code has a single seam to read from.",
5
+ "keywords": [
6
+ "ggui",
7
+ "mcp",
8
+ "mcp-apps",
9
+ "iframe",
10
+ "runtime",
11
+ "react"
12
+ ],
13
+ "license": "Apache-2.0",
14
+ "type": "module",
15
+ "main": "dist/index.js",
16
+ "types": "dist/index.d.ts",
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./server": {
28
+ "types": "./dist/server.d.ts",
29
+ "import": "./dist/server.js",
30
+ "default": "./dist/server.js"
31
+ },
32
+ "./runtime.js": {
33
+ "default": "./dist/iframe-runtime.js"
34
+ }
35
+ },
36
+ "dependencies": {
37
+ "qrcode-generator": "^2.0.4",
38
+ "react": "^19.0.0",
39
+ "react-dom": "^19.0.0",
40
+ "@ggui-ai/channel-client": "0.1.0-rc.1",
41
+ "@ggui-ai/gadgets": "0.1.0-rc.1",
42
+ "@ggui-ai/preview-a2ui": "0.1.0-rc.1",
43
+ "@ggui-ai/protocol": "0.1.0-rc.1",
44
+ "@ggui-ai/design": "0.1.0-rc.1",
45
+ "@ggui-ai/wire": "0.1.0-rc.1"
46
+ },
47
+ "devDependencies": {
48
+ "@testing-library/react": "^16.0.0",
49
+ "@types/node": "^24.0.0",
50
+ "@types/react": "^19.0.0",
51
+ "@types/react-dom": "^19.0.0",
52
+ "esbuild": "^0.25.12",
53
+ "jsdom": "^24.0.0",
54
+ "tsx": "^4.21.0",
55
+ "typescript": "^5.0.0",
56
+ "vitest": "^3.0.0"
57
+ },
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "https://github.com/ggui-ai/ggui.git",
61
+ "directory": "packages/iframe-runtime"
62
+ },
63
+ "homepage": "https://github.com/ggui-ai/ggui/tree/main/packages/iframe-runtime",
64
+ "bugs": {
65
+ "url": "https://github.com/ggui-ai/ggui/issues"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public"
69
+ },
70
+ "engines": {
71
+ "node": ">=20.0.0"
72
+ },
73
+ "author": "ggui contributors <hello@ggui.ai>",
74
+ "scripts": {
75
+ "build": "node esbuild.config.mjs && tsc -p tsconfig.types.json && pnpm run check:bundle-size",
76
+ "check:bundle-size": "tsx scripts/check-bundle-size.ts",
77
+ "typecheck": "tsc --noEmit",
78
+ "test": "vitest run",
79
+ "test:watch": "vitest"
80
+ }
81
+ }