@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,344 @@
1
+ /**
2
+ * Default polling cadence for the iframe-polling transport (ms).
3
+ * Mirrors the server-side default on
4
+ * {@link DEFAULT_CHANNEL_POLL_DEFAULT_MS} in
5
+ * `packages/mcp-server/src/session-channel.ts`. Conservative: 10s
6
+ * matches a "data is fresh enough" bar without burning the parent
7
+ * host's `tools/call` quota.
8
+ */
9
+ export const DEFAULT_IFRAME_POLL_INTERVAL_MS = 10_000;
10
+ /**
11
+ * Factory.
12
+ *
13
+ * Per-channel transport routing — owns the `(stackItemId, channelName)`
14
+ * registry, the WS-vs-poll decision per channel, and the cross-state
15
+ * transitions (disconnect → poll fallback, reconnect → re-subscribe).
16
+ */
17
+ export function createChannelTransportRouter(opts) {
18
+ /**
19
+ * Registry keyed by `${stackItemId}:${channelName}`. The composite
20
+ * key matches the server-side `channelSubs` map shape exactly, so
21
+ * test snapshots line up.
22
+ */
23
+ const channels = new Map();
24
+ const allowlist = new Set(opts.streamWebSocketLocalTools ?? []);
25
+ const defaultPollMs = opts.defaultPollIntervalMs ?? DEFAULT_IFRAME_POLL_INTERVAL_MS;
26
+ /**
27
+ * WS lifecycle flag. We start in `'connected'` because the router
28
+ * is created AFTER the subscribe ack — the runtime's bootSequence
29
+ * resolves `subscribeFn(...)` before threading the manager into
30
+ * triadWiring's `attachManager` (where the router is constructed).
31
+ * Status transitions update this; the router uses it to decide
32
+ * whether to start WS subscribes or skip straight to polling.
33
+ */
34
+ let wsStatus = 'connected';
35
+ let disposed = false;
36
+ const observe = opts.onObserve ?? (() => { });
37
+ function keyOf(stackItemId, channelName) {
38
+ return `${stackItemId}:${channelName}`;
39
+ }
40
+ function stopPolling(state) {
41
+ if (state.pollTimer !== null) {
42
+ clearInterval(state.pollTimer);
43
+ state.pollTimer = null;
44
+ }
45
+ }
46
+ function emitToBus(state, payload, complete) {
47
+ const envelope = {
48
+ sessionId: opts.sessionId,
49
+ channel: state.channelName,
50
+ mode: state.mode,
51
+ payload,
52
+ ...(complete === true ? { complete: true } : {}),
53
+ };
54
+ opts.streamBus.emit(envelope);
55
+ }
56
+ /**
57
+ * Start an iframe-polling loop for one channel. Idempotent — if a
58
+ * timer is already running we leave it alone. Fires the first poll
59
+ * IMMEDIATELY (no `setInterval` lead-in delay) so the first
60
+ * `useStream` render isn't blocked on a 10-second wait.
61
+ */
62
+ function startPolling(state) {
63
+ if (state.pollTimer !== null)
64
+ return;
65
+ const tick = async () => {
66
+ if (disposed)
67
+ return;
68
+ try {
69
+ const payload = await opts.toolsCall({
70
+ toolName: state.toolName,
71
+ args: { ...(state.args ?? {}) },
72
+ });
73
+ if (disposed)
74
+ return;
75
+ emitToBus(state, payload);
76
+ }
77
+ catch {
78
+ // Swallow — next tick may succeed. The renderer's
79
+ // observability path already surfaces transport failures
80
+ // via the WS error route; per-tool poll failures stay quiet
81
+ // to avoid a noisy bus during transient outages.
82
+ }
83
+ };
84
+ // Fire-and-forget the leading tick + schedule the recurring
85
+ // ticks. Both are wrapped in the `disposed` guard above.
86
+ void tick();
87
+ state.pollTimer = setInterval(() => {
88
+ void tick();
89
+ }, defaultPollMs);
90
+ }
91
+ /**
92
+ * Send a `channel_subscribe` WS frame. Server-side bookkeeping is
93
+ * idempotent on `(sessionId, stackItemId, channelName)`, so re-sends
94
+ * on reconnect are safe.
95
+ */
96
+ function sendSubscribe(state) {
97
+ opts.send({
98
+ type: 'channel_subscribe',
99
+ payload: {
100
+ sessionId: opts.sessionId,
101
+ appId: opts.appId,
102
+ stackItemId: state.stackItemId,
103
+ channelName: state.channelName,
104
+ ...(state.args !== undefined ? { args: { ...state.args } } : {}),
105
+ },
106
+ });
107
+ }
108
+ /**
109
+ * Send a `channel_unsubscribe` WS frame for a removed channel.
110
+ * Server-side is also idempotent on unknown pairs.
111
+ */
112
+ function sendUnsubscribe(state) {
113
+ if (wsStatus !== 'connected')
114
+ return;
115
+ opts.send({
116
+ type: 'channel_unsubscribe',
117
+ payload: {
118
+ sessionId: opts.sessionId,
119
+ appId: opts.appId,
120
+ stackItemId: state.stackItemId,
121
+ channelName: state.channelName,
122
+ },
123
+ });
124
+ }
125
+ /**
126
+ * Bootstrap a channel onto its preferred transport.
127
+ *
128
+ * - `preferWs && wsStatus === 'connected'` → send subscribe,
129
+ * observe `'ws'` pick.
130
+ * - `preferWs && wsStatus !== 'connected'` → start polling
131
+ * fallback NOW so the user sees data while the WS reconnects,
132
+ * observe `'poll'` pick. When the WS comes up, we'll attempt
133
+ * `sendSubscribe` from `onWsStatusChange`.
134
+ * - `!preferWs` → polling, observe `'poll'`.
135
+ */
136
+ function activate(state) {
137
+ if (state.preferWs && !state.permanentPollFallback) {
138
+ if (wsStatus === 'connected') {
139
+ sendSubscribe(state);
140
+ observe({
141
+ kind: 'channel-transport-picked',
142
+ stackItemId: state.stackItemId,
143
+ channelName: state.channelName,
144
+ transport: 'ws',
145
+ });
146
+ return;
147
+ }
148
+ // WS not ready yet — fall through to polling. We'll re-attempt
149
+ // the subscribe on the next 'connected' transition.
150
+ startPolling(state);
151
+ observe({
152
+ kind: 'channel-transport-picked',
153
+ stackItemId: state.stackItemId,
154
+ channelName: state.channelName,
155
+ transport: 'poll',
156
+ });
157
+ return;
158
+ }
159
+ startPolling(state);
160
+ observe({
161
+ kind: 'channel-transport-picked',
162
+ stackItemId: state.stackItemId,
163
+ channelName: state.channelName,
164
+ transport: 'poll',
165
+ });
166
+ }
167
+ return {
168
+ applyStackItem: (item) => {
169
+ if (disposed)
170
+ return;
171
+ const seenKeys = new Set();
172
+ const spec = item.streamSpec ?? {};
173
+ for (const [channelName, entry] of Object.entries(spec)) {
174
+ const source = entry.source;
175
+ // Channels without a `source.tool` declaration stay on the
176
+ // legacy `data` frame path — agent → server → fan-out. The
177
+ // router only manages the source-fed subset (1c scope).
178
+ if (source === undefined ||
179
+ source === null ||
180
+ typeof source !== 'object' ||
181
+ typeof source.tool !== 'string' ||
182
+ source.tool.length === 0) {
183
+ continue;
184
+ }
185
+ const toolName = source.tool;
186
+ const channelArgs = source.args !== undefined && source.args !== null
187
+ ? source.args
188
+ : undefined;
189
+ const k = keyOf(item.stackItemId, channelName);
190
+ seenKeys.add(k);
191
+ const existing = channels.get(k);
192
+ if (existing !== undefined) {
193
+ // Same (stackItemId, channelName) — leave the transport
194
+ // bookkeeping alone. Spec changes that flip preferWs
195
+ // mid-session are out of 1c scope (would require
196
+ // re-handshake).
197
+ continue;
198
+ }
199
+ const state = {
200
+ stackItemId: item.stackItemId,
201
+ channelName,
202
+ toolName,
203
+ ...(channelArgs !== undefined ? { args: channelArgs } : {}),
204
+ mode: entry.mode ?? 'append',
205
+ preferWs: allowlist.has(toolName),
206
+ pollTimer: null,
207
+ hasReceivedWsPayload: false,
208
+ permanentPollFallback: false,
209
+ };
210
+ channels.set(k, state);
211
+ activate(state);
212
+ }
213
+ // Tear down any channel for THIS stack item that's no longer in
214
+ // the spec. Channels for OTHER stack items stay — they belong
215
+ // to other items that haven't been re-applied yet. Stack-pop /
216
+ // close-session paths call `dispose()` for the wholesale
217
+ // teardown.
218
+ for (const [k, state] of channels) {
219
+ if (state.stackItemId === item.stackItemId &&
220
+ !seenKeys.has(k)) {
221
+ stopPolling(state);
222
+ sendUnsubscribe(state);
223
+ channels.delete(k);
224
+ }
225
+ }
226
+ },
227
+ handleWsFrame: (msg) => {
228
+ if (disposed)
229
+ return false;
230
+ if (msg.type === 'channel_payload') {
231
+ const p = msg.payload;
232
+ const k = keyOf(p.stackItemId, p.channelName);
233
+ const state = channels.get(k);
234
+ if (state === undefined)
235
+ return false;
236
+ // First WS payload after a disconnect-fallback → cancel the
237
+ // polling loop. Server has demonstrably resumed fan-out, so
238
+ // we don't need the redundant iframe-poll source for this
239
+ // channel any more (on this lifecycle).
240
+ if (!state.hasReceivedWsPayload && state.pollTimer !== null) {
241
+ stopPolling(state);
242
+ }
243
+ state.hasReceivedWsPayload = true;
244
+ emitToBus(state, p.payload, p.complete);
245
+ return true;
246
+ }
247
+ if (msg.type === 'channel_error') {
248
+ const p = msg.payload;
249
+ // Locate by `channelName` only — the error payload doesn't
250
+ // carry `stackItemId`. We match by channel-name across active
251
+ // items and apply the permanent-fallback flag to the matching
252
+ // entries. Same channel name across stack items is rare; the
253
+ // policy is conservative (fall back ALL matches rather than
254
+ // miss one).
255
+ for (const state of channels.values()) {
256
+ if (state.channelName !== p.channelName)
257
+ continue;
258
+ if (p.code === 'CHANNEL_NOT_LOCAL' ||
259
+ p.code === 'CHANNEL_UNKNOWN' ||
260
+ p.code === 'STACK_ITEM_NOT_FOUND') {
261
+ state.permanentPollFallback = true;
262
+ startPolling(state);
263
+ observe({
264
+ kind: 'channel-transport-fallback',
265
+ stackItemId: state.stackItemId,
266
+ channelName: state.channelName,
267
+ reason: 'channel-not-local',
268
+ });
269
+ }
270
+ // POLL_FAILED / SUBSCRIBE_UNAUTHORIZED are transient — the
271
+ // server may recover. Don't flip the permanent fallback;
272
+ // leave the WS path active. (UNAUTHORIZED would normally
273
+ // imply the bootstrap token expired, in which case the
274
+ // outer subscribe-fail path tears down the whole socket;
275
+ // we don't need to special-case it here.)
276
+ }
277
+ return true;
278
+ }
279
+ return false;
280
+ },
281
+ onWsStatusChange: (status) => {
282
+ if (disposed)
283
+ return;
284
+ const prevStatus = wsStatus;
285
+ wsStatus = status;
286
+ if (status === 'disconnected' || status === 'reconnecting') {
287
+ // Every WS-preferring channel falls back to polling
288
+ // IMMEDIATELY. The user sees fresh data while the reconnect
289
+ // ladder runs. Reset the `hasReceivedWsPayload` flag so the
290
+ // first post-reconnect payload re-triggers the
291
+ // "stop the polling fallback" transition.
292
+ for (const state of channels.values()) {
293
+ if (!state.preferWs)
294
+ continue;
295
+ if (state.permanentPollFallback)
296
+ continue;
297
+ if (state.hasReceivedWsPayload) {
298
+ state.hasReceivedWsPayload = false;
299
+ }
300
+ if (state.pollTimer === null) {
301
+ startPolling(state);
302
+ observe({
303
+ kind: 'channel-transport-fallback',
304
+ stackItemId: state.stackItemId,
305
+ channelName: state.channelName,
306
+ reason: 'ws-disconnect',
307
+ });
308
+ }
309
+ }
310
+ return;
311
+ }
312
+ if (status === 'connected' &&
313
+ (prevStatus === 'disconnected' || prevStatus === 'reconnecting')) {
314
+ // Reconnected. Re-send `channel_subscribe` for every
315
+ // WS-preferring channel. Server is idempotent on the (session,
316
+ // stackItem, channel) triple, so duplicates are safe. We
317
+ // intentionally LEAVE the polling fallback running until the
318
+ // first `channel_payload` lands — bridges the gap where the
319
+ // server's first post-reconnect poll cycle hasn't fired yet.
320
+ for (const state of channels.values()) {
321
+ if (!state.preferWs)
322
+ continue;
323
+ if (state.permanentPollFallback)
324
+ continue;
325
+ sendSubscribe(state);
326
+ observe({
327
+ kind: 'channel-transport-resubscribed',
328
+ stackItemId: state.stackItemId,
329
+ channelName: state.channelName,
330
+ });
331
+ }
332
+ }
333
+ },
334
+ dispose: () => {
335
+ if (disposed)
336
+ return;
337
+ disposed = true;
338
+ for (const state of channels.values()) {
339
+ stopPolling(state);
340
+ }
341
+ channels.clear();
342
+ },
343
+ };
344
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `channel_payload` + `channel_error` handlers — forward to the
3
+ * per-channel transport router (`channel-transport.ts`).
4
+ *
5
+ * The router owns the streamSpec subscription registry (which channels
6
+ * are WS-subscribed vs iframe-polling). Forwarding here is a thin
7
+ * passthrough — the router parses the frame contents against its
8
+ * subscription map and dispatches to the matching consumer.
9
+ *
10
+ * Factored out of `handleTriadMessage` as part of the B3a handler
11
+ * extraction.
12
+ */
13
+ import type { ChannelHandler } from '@ggui-ai/channel-client';
14
+ import type { ChannelErrorPayload, ChannelPayloadFrame } from '@ggui-ai/protocol';
15
+ import type { ChannelTransportRouter } from '../channel-transport.js';
16
+ export interface ChannelRouterHandlerDeps {
17
+ readonly getChannelTransport: () => ChannelTransportRouter;
18
+ }
19
+ export declare function createChannelPayloadHandler(deps: ChannelRouterHandlerDeps): ChannelHandler<ChannelPayloadFrame>;
20
+ export declare function createChannelErrorHandler(deps: ChannelRouterHandlerDeps): ChannelHandler<ChannelErrorPayload>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `channel_payload` + `channel_error` handlers — forward to the
3
+ * per-channel transport router (`channel-transport.ts`).
4
+ *
5
+ * The router owns the streamSpec subscription registry (which channels
6
+ * are WS-subscribed vs iframe-polling). Forwarding here is a thin
7
+ * passthrough — the router parses the frame contents against its
8
+ * subscription map and dispatches to the matching consumer.
9
+ *
10
+ * Factored out of `handleTriadMessage` as part of the B3a handler
11
+ * extraction.
12
+ */
13
+ export function createChannelPayloadHandler(deps) {
14
+ return {
15
+ type: 'channel_payload',
16
+ onMessage: (payload) => {
17
+ deps.getChannelTransport().handleWsFrame({
18
+ type: 'channel_payload',
19
+ payload,
20
+ });
21
+ },
22
+ };
23
+ }
24
+ export function createChannelErrorHandler(deps) {
25
+ return {
26
+ type: 'channel_error',
27
+ onMessage: (payload) => {
28
+ deps.getChannelTransport().handleWsFrame({
29
+ type: 'channel_error',
30
+ payload,
31
+ });
32
+ },
33
+ };
34
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `data` channel handler — validates inbound stream-envelope payloads
3
+ * against the active stack item's `streamSpec`, then fans the validated
4
+ * envelope out via `streamBus`. Invalid payloads drop silently
5
+ * (matching `GguiSession.handleServerMessage`'s surfacing policy —
6
+ * the violation is already logged by the validator).
7
+ *
8
+ * Also fires `contract-error-emitted` on the optional observability
9
+ * sink when the envelope arrives on the reserved
10
+ * `CONTRACT_ERROR_CHANNEL`. Absorbed from `handleObservableMessage`
11
+ * in `runtime.ts` as part of the B3b cleanup — the handler is now the
12
+ * sole dispatch surface for `data` frames.
13
+ *
14
+ * Factored out of `handleTriadMessage` as part of the B3a handler
15
+ * extraction; expanded in B3b to absorb the observability-axis
16
+ * emission previously fired in parallel from `bootSequence`.
17
+ */
18
+ import type { ChannelHandler } from '@ggui-ai/channel-client';
19
+ import { type CanvasLifecyclePayload, type StreamEnvelope } from '@ggui-ai/protocol';
20
+ import type { ObservabilityEmitter } from '../observability.js';
21
+ import type { StackModel } from '../stack.js';
22
+ import { type RendererValidatorContext } from '../validation.js';
23
+ import type { StreamBus } from '../wire-config.js';
24
+ export interface DataHandlerDeps {
25
+ readonly stackModel: StackModel;
26
+ readonly streamBus: StreamBus;
27
+ readonly validatorCtx: RendererValidatorContext;
28
+ /**
29
+ * Optional observability sink. When present, every envelope on the
30
+ * reserved `_ggui:contract-error` channel fires a
31
+ * {@link ContractErrorEmittedEvent}. Absent = the observation skips
32
+ * silently (matches the pre-B3b posture when no `onObserve` is
33
+ * bound).
34
+ */
35
+ readonly onObserve?: ObservabilityEmitter;
36
+ /**
37
+ * Optional canvas-mode lifecycle sink. When present, every envelope
38
+ * on the reserved `_ggui:lifecycle` channel fires this callback
39
+ * with the parsed payload. Production wires it to mount.tsx's
40
+ * AnimatorEventBus so the animator pill state machine advances.
41
+ * Non-canvas iframes leave it absent and the envelopes drop
42
+ * silently — `validateInboundStreamPayload` rejects lifecycle
43
+ * payloads when no reserved validator is registered for them
44
+ * client-side, so they never reach `streamBus.emit` either.
45
+ */
46
+ readonly onCanvasLifecycle?: (payload: CanvasLifecyclePayload) => void;
47
+ }
48
+ export declare function createDataHandler(deps: DataHandlerDeps): ChannelHandler<StreamEnvelope>;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `data` channel handler — validates inbound stream-envelope payloads
3
+ * against the active stack item's `streamSpec`, then fans the validated
4
+ * envelope out via `streamBus`. Invalid payloads drop silently
5
+ * (matching `GguiSession.handleServerMessage`'s surfacing policy —
6
+ * the violation is already logged by the validator).
7
+ *
8
+ * Also fires `contract-error-emitted` on the optional observability
9
+ * sink when the envelope arrives on the reserved
10
+ * `CONTRACT_ERROR_CHANNEL`. Absorbed from `handleObservableMessage`
11
+ * in `runtime.ts` as part of the B3b cleanup — the handler is now the
12
+ * sole dispatch surface for `data` frames.
13
+ *
14
+ * Factored out of `handleTriadMessage` as part of the B3a handler
15
+ * extraction; expanded in B3b to absorb the observability-axis
16
+ * emission previously fired in parallel from `bootSequence`.
17
+ */
18
+ import { CONTRACT_ERROR_CHANNEL, LIFECYCLE_CHANNEL, } from '@ggui-ai/protocol';
19
+ import { validateInboundStreamPayload, } from '../validation.js';
20
+ export function createDataHandler(deps) {
21
+ return {
22
+ type: 'data',
23
+ onMessage: (envelope) => {
24
+ if (envelope === undefined ||
25
+ envelope === null ||
26
+ typeof envelope.channel !== 'string') {
27
+ return;
28
+ }
29
+ // Observability-axis emission for the reserved contract-error
30
+ // channel. Fires BEFORE validation because the envelope shape
31
+ // is owned by the server's reserved validator
32
+ // (`BUILTIN_RESERVED_VALIDATORS`) — invalid payloads at this
33
+ // boundary are server-side bugs the host inspector should still
34
+ // surface, not silently drop.
35
+ if (envelope.channel === CONTRACT_ERROR_CHANNEL &&
36
+ deps.onObserve !== undefined) {
37
+ emitContractErrorFromDataFrame(envelope, deps.onObserve);
38
+ }
39
+ // Canvas-mode lifecycle
40
+ // envelopes — fires BEFORE validation for the same reason as
41
+ // the contract-error path: the wire shape is server-owned, and
42
+ // any reachable envelope is a server-emitted signal. The
43
+ // payload's `kind` discriminator narrows downstream; the
44
+ // animator host treats unknown kinds as no-ops. Invalid shapes
45
+ // silently drop here (typeof check) — defensive against future
46
+ // wire shape drift.
47
+ if (envelope.channel === LIFECYCLE_CHANNEL &&
48
+ deps.onCanvasLifecycle !== undefined) {
49
+ const payload = envelope.payload;
50
+ if (payload !== null &&
51
+ typeof payload === 'object' &&
52
+ !Array.isArray(payload) &&
53
+ typeof payload.kind === 'string') {
54
+ deps.onCanvasLifecycle(payload);
55
+ }
56
+ }
57
+ // Active stack item carries the streamSpec — mirrors
58
+ // `GguiSession.handleServerMessage`. Top of stack only.
59
+ const snapshot = deps.stackModel.snapshot();
60
+ const activeItem = snapshot[snapshot.length - 1];
61
+ const streamSpec = activeItem !== undefined &&
62
+ activeItem.type !== 'mcpApps' &&
63
+ activeItem.type !== 'system'
64
+ ? activeItem.streamSpec
65
+ : undefined;
66
+ const result = validateInboundStreamPayload(streamSpec, envelope.channel, envelope.payload, deps.validatorCtx);
67
+ if (!result.valid)
68
+ return;
69
+ deps.streamBus.emit(envelope);
70
+ },
71
+ };
72
+ }
73
+ /**
74
+ * Narrow a contract-error envelope and emit
75
+ * {@link ContractErrorEmittedEvent}. Malformed envelopes silently
76
+ * skip (trust-boundary posture — host inspector gets a row only when
77
+ * the wire payload carries the required discriminator fields).
78
+ */
79
+ function emitContractErrorFromDataFrame(envelope, emit) {
80
+ const payload = envelope.payload;
81
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
82
+ return;
83
+ }
84
+ const shaped = payload;
85
+ const code = shaped.error !== undefined &&
86
+ shaped.error !== null &&
87
+ typeof shaped.error === 'object' &&
88
+ typeof shaped.error.code === 'string'
89
+ ? shaped.error.code
90
+ : undefined;
91
+ const toolName = typeof shaped.toolName === 'string' ? shaped.toolName : undefined;
92
+ if (code === undefined || toolName === undefined)
93
+ return;
94
+ const event = {
95
+ kind: 'contract-error-emitted',
96
+ code,
97
+ toolName,
98
+ ...(typeof shaped.actionName === 'string'
99
+ ? { actionName: shaped.actionName }
100
+ : {}),
101
+ };
102
+ emit(event);
103
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `drain_ack` channel handler — factored out of `runtime.ts` into
3
+ * the `@ggui-ai/channel-client` layer. The `onMessage` body fans the
4
+ * payload out to the module-scoped `subscribeDrainAck` listener
5
+ * registry so per-action state machines stay decoupled from how the
6
+ * frame arrives on the wire.
7
+ */
8
+ import type { ChannelHandler } from '@ggui-ai/channel-client';
9
+ import type { DrainAckPayload } from '@ggui-ai/protocol';
10
+ export interface DrainAckHandlerDeps {
11
+ readonly dispatch: (payload: DrainAckPayload) => void;
12
+ }
13
+ export declare function createDrainAckHandler(deps: DrainAckHandlerDeps): ChannelHandler<DrainAckPayload>;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `drain_ack` channel handler — factored out of `runtime.ts` into
3
+ * the `@ggui-ai/channel-client` layer. The `onMessage` body fans the
4
+ * payload out to the module-scoped `subscribeDrainAck` listener
5
+ * registry so per-action state machines stay decoupled from how the
6
+ * frame arrives on the wire.
7
+ */
8
+ export function createDrainAckHandler(deps) {
9
+ return {
10
+ type: 'drain_ack',
11
+ onMessage: (payload) => {
12
+ deps.dispatch(payload);
13
+ },
14
+ };
15
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `feedback` channel handler — no-op stub.
3
+ *
4
+ * Legacy `feedback` frame: older servers emitted this as the round-trip
5
+ * half of the retired `tool:<name>` client-tool RPC path. Silently
6
+ * ignored under the `clientCapabilities` reframe; kept as a recognized
7
+ * type until the WS message union drops `'feedback'`. Registering a
8
+ * no-op handler is what keeps the registry's dispatch from logging
9
+ * "no handler for type 'feedback'" warnings — the silence is
10
+ * intentional, the handler documents that.
11
+ */
12
+ import type { ChannelHandler } from '@ggui-ai/channel-client';
13
+ import type { JsonObject } from '@ggui-ai/protocol';
14
+ export declare function createFeedbackHandler(): ChannelHandler<JsonObject>;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `feedback` channel handler — no-op stub.
3
+ *
4
+ * Legacy `feedback` frame: older servers emitted this as the round-trip
5
+ * half of the retired `tool:<name>` client-tool RPC path. Silently
6
+ * ignored under the `clientCapabilities` reframe; kept as a recognized
7
+ * type until the WS message union drops `'feedback'`. Registering a
8
+ * no-op handler is what keeps the registry's dispatch from logging
9
+ * "no handler for type 'feedback'" warnings — the silence is
10
+ * intentional, the handler documents that.
11
+ */
12
+ export function createFeedbackHandler() {
13
+ return {
14
+ type: 'feedback',
15
+ onMessage: () => {
16
+ /* no-op — legacy stub */
17
+ },
18
+ };
19
+ }
@@ -0,0 +1,7 @@
1
+ export { createPropsUpdateHandler, type PropsUpdateHandlerDeps, } from './props-update.js';
2
+ export { createDrainAckHandler, type DrainAckHandlerDeps, } from './drain-ack.js';
3
+ export { createPushHandler, type PushHandlerDeps } from './push.js';
4
+ export { createDataHandler, type DataHandlerDeps } from './data.js';
5
+ export { createFeedbackHandler } from './feedback.js';
6
+ export { createChannelPayloadHandler, createChannelErrorHandler, type ChannelRouterHandlerDeps, } from './channel-payload.js';
7
+ export { createSystemHandler, type SystemHandlerDeps } from './system.js';
@@ -0,0 +1,7 @@
1
+ export { createPropsUpdateHandler, } from './props-update.js';
2
+ export { createDrainAckHandler, } from './drain-ack.js';
3
+ export { createPushHandler } from './push.js';
4
+ export { createDataHandler } from './data.js';
5
+ export { createFeedbackHandler } from './feedback.js';
6
+ export { createChannelPayloadHandler, createChannelErrorHandler, } from './channel-payload.js';
7
+ export { createSystemHandler } from './system.js';