@ggui-ai/iframe-runtime 0.24.0 → 0.26.0

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 (41) hide show
  1. package/README.md +2 -2
  2. package/dist/channels/data.d.ts +8 -0
  3. package/dist/channels/data.js +2 -0
  4. package/dist/credential-controller.d.ts +71 -0
  5. package/dist/credential-controller.js +135 -0
  6. package/dist/iframe-runtime.js +97 -93
  7. package/dist/iframe-runtime.meta.json +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/ladders.d.ts +77 -0
  10. package/dist/ladders.js +383 -0
  11. package/dist/meta-parse.js +38 -8
  12. package/dist/observability.d.ts +38 -11
  13. package/dist/react-renderer.d.ts +22 -0
  14. package/dist/react-renderer.js +118 -3
  15. package/dist/registry-subscribe.d.ts +53 -2
  16. package/dist/registry-subscribe.js +63 -8
  17. package/dist/render-failure-report.d.ts +15 -0
  18. package/dist/render-failure-report.js +61 -0
  19. package/dist/render-item.d.ts +6 -1
  20. package/dist/render-item.js +7 -2
  21. package/dist/runtime-telemetry.d.ts +28 -18
  22. package/dist/runtime-telemetry.js +68 -29
  23. package/dist/runtime.d.ts +157 -39
  24. package/dist/runtime.js +724 -368
  25. package/dist/shims/wire.js +1 -1
  26. package/dist/stream-seq.d.ts +56 -0
  27. package/dist/stream-seq.js +46 -0
  28. package/dist/system-cards/ProtocolProbeCard.d.ts +16 -0
  29. package/dist/system-cards/ProtocolProbeCard.js +17 -14
  30. package/dist/tool-result-error.d.ts +6 -0
  31. package/dist/tool-result-error.js +32 -0
  32. package/dist/types.d.ts +49 -1
  33. package/dist/view-origin.d.ts +29 -0
  34. package/dist/view-origin.js +33 -0
  35. package/dist/view-proof-signer.d.ts +31 -0
  36. package/dist/view-proof-signer.js +99 -0
  37. package/dist/view-root.d.ts +51 -0
  38. package/dist/view-root.js +56 -0
  39. package/dist/wire-config.d.ts +15 -1
  40. package/dist/wire-config.js +42 -1
  41. package/package.json +8 -7
package/dist/index.d.ts CHANGED
@@ -32,12 +32,12 @@
32
32
  * Those are the renderer's INTERNAL runtime contract and should not
33
33
  * become a load-bearing public API.
34
34
  */
35
- export type { McpAppAiGguiMetaParseFailureReason, McpAppAiGguiMetaParseResult, } from './types.js';
35
+ export type { HeldCredential, McpAppAiGguiMetaParseFailureReason, McpAppAiGguiMetaParseResult, ViewRoot, } from './types.js';
36
36
  export { parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
37
37
  export type { RendererBootFailureReason, RendererBootFailedMessage, } from './runtime.js';
38
38
  export type { ProtocolError, ProtocolErrorEmitter, BootstrapFailureReason, } from './protocol-error.js';
39
39
  export { defaultProtocolErrorEmitter, fromBootstrapFailure, fromClientContractViolation, fromTransportFailure, fromAuthFailure, fromUpgradeRequired, fromUnknown, } from './protocol-error.js';
40
- export type { ObservabilityEvent, ObservabilityEmitter, ObservabilityMessage, SchemaVersionMismatchEvent, FontFaceBlockedEvent, AppThemeInvalidEvent, AppThemeMemberStrippedEvent, ActionSpecInvalidEvent, ActionSpecMemberStrippedEvent, SpentOneShotsInvalidEvent, ThemeReinjectFailedEvent, SubscribeFailedEvent, ChannelTransportPickedEvent, ChannelTransportFallbackEvent, ChannelTransportResubscribedEvent, ChannelPollDegradedEvent, ChannelPollRecoveredEvent, UiFeedbackEvent, RelayDeadTapEvent, RelayIncapabilityEvent, RelayLatchTrigger, ComponentEmptyEvent, OneShotUnenforceableEvent, UnknownObservabilityEvent, } from './observability.js';
40
+ export type { ObservabilityEvent, ObservabilityEmitter, ObservabilityMessage, SchemaVersionMismatchEvent, FontFaceBlockedEvent, AppThemeInvalidEvent, AppThemeMemberStrippedEvent, ActionSpecInvalidEvent, ActionSpecMemberStrippedEvent, SpentOneShotsInvalidEvent, ThemeReinjectFailedEvent, SubscribeFailedEvent, ChannelTransportPickedEvent, ChannelTransportFallbackEvent, ChannelTransportResubscribedEvent, ChannelPollDegradedEvent, ChannelPollRecoveredEvent, UiFeedbackEvent, RelayDeadTapEvent, RelayIncapabilityEvent, RelayLatchTrigger, ComponentEmptyEvent, OneShotUnenforceableEvent, ActionRefusedEvent, UnknownObservabilityEvent, } from './observability.js';
41
41
  export { isObservabilityMessage, isRelayDeadTapEvent, isRelayIncapabilityEvent, postObservabilityToParent, } from './observability.js';
42
42
  export type { LifecycleEmitter } from './lifecycle.js';
43
43
  export { postLifecycleToParent, makeLifecycleEvent } from './lifecycle.js';
@@ -0,0 +1,77 @@
1
+ import type { ChannelHandler, ChannelLogger, WsTransportHandle } from '@ggui-ai/live-channel';
2
+ import type { AckPayload } from '@ggui-ai/protocol/wire';
3
+ import type { ConnectionStatus } from '@ggui-ai/protocol/transport/websocket';
4
+ import type { McpAppAiGguiRenderMeta } from '@ggui-ai/protocol/integrations/mcp-apps';
5
+ import type { CredentialController, ExpirySource, RefreshOutcome } from './credential-controller.js';
6
+ import { type BuildBridgePollingOptions, type SequenceCursor } from './events-polling.js';
7
+ import { type ConnectFn, type RegistrySubscribeHandle, type PostAckErrorPayload } from './registry-subscribe.js';
8
+ import type { StreamSeqTracker } from './stream-seq.js';
9
+ import type { HeldCredential } from './types.js';
10
+ import type { ObservabilityEmitter } from './observability.js';
11
+ import type { ProtocolErrorEmitter } from './protocol-error.js';
12
+ /** What one ladder is bound with. */
13
+ export interface LadderSpec {
14
+ /** The credential its token-bearing rungs carry; `undefined` on a trio-less bind. */
15
+ readonly credential: HeldCredential | undefined;
16
+ readonly sseUrl: string | undefined;
17
+ readonly pollingUrl: string | undefined;
18
+ }
19
+ /** Why the live channel ended (R4): the confirmation that fired. */
20
+ export type LiveChannelEnd = {
21
+ readonly confirmation: 'relay-refusal';
22
+ readonly code: number;
23
+ } | {
24
+ readonly confirmation: 'session-not-found';
25
+ readonly after: 'a-successful-pull' | 'a-second-not-found';
26
+ };
27
+ export interface LadderSetOptions {
28
+ /** The boot meta: identity (`sessionId`, `appId`) and the slice fields `connectFn` passes through. */
29
+ readonly meta: McpAppAiGguiRenderMeta;
30
+ /** The handlers every ladder delivers through, shared by all of them. */
31
+ readonly handlers: readonly ChannelHandler[];
32
+ /** The shared ledger cursor; `undefined` when the view has no fallback rung. */
33
+ readonly cursor: SequenceCursor | undefined;
34
+ /** The view's stream cursor, read into a rebuilt ladder's `fromSeq`. */
35
+ readonly streamSeq: StreamSeqTracker | undefined;
36
+ readonly controller: CredentialController | null;
37
+ readonly connectFn: ConnectFn;
38
+ /** The host relay the bridge rung pulls through; `undefined` → no bridge rung. */
39
+ readonly bridgeCallTool: BuildBridgePollingOptions['callTool'] | undefined;
40
+ /** Status of the ACTIVE ladder. */
41
+ readonly onStatus: (status: ConnectionStatus) => void;
42
+ /** Every ack after the boot's first one, from the ladder holding data (a re-snapshot to apply). */
43
+ readonly onAck: (ack: AckPayload) => void;
44
+ /** Every post-ack `error` frame, as sent (ggui#1424): the runtime reads a spent-one-shot refusal there. */
45
+ readonly onPostAckError?: (payload: PostAckErrorPayload) => void;
46
+ /** Each refresh a ladder asked for, and what it got. */
47
+ readonly onRefresh?: (source: ExpirySource, outcome: RefreshOutcome) => void;
48
+ /** An ack showed the server's stream counter restarted (a new epoch, or a count below the view's cursor), and the cursor was forgotten. */
49
+ readonly onStreamRestart?: (streamSeq: number | undefined, streamEpoch: string | undefined) => void;
50
+ /** The runtime's registry of relay refusal codes that confirm the host cannot relay `tools/call`. */
51
+ readonly isConfirmedRelayRefusalCode: (code: number) => boolean;
52
+ /** The live channel ended (R4); every ladder is already disposed. */
53
+ readonly onEnded: (end: LiveChannelEnd) => void;
54
+ readonly logger?: ChannelLogger;
55
+ readonly onProtocolError?: ProtocolErrorEmitter;
56
+ readonly onObserve?: ObservabilityEmitter;
57
+ /** Test seams, passed to every ladder's registry. */
58
+ readonly webSocketFactory?: (url: string) => WebSocket;
59
+ readonly eventSourceFactory?: (url: string) => EventSource;
60
+ readonly fetchImpl?: typeof fetch;
61
+ /** Test seam for the bridge-entry expiry check. */
62
+ readonly now?: () => number;
63
+ }
64
+ export interface LadderSet {
65
+ /** Bind the boot ladder. The caller handles its first ack, or its refusal. */
66
+ connectBoot(spec: LadderSpec): Promise<RegistrySubscribeHandle>;
67
+ /**
68
+ * The boot ladder's first refresh, when it reported an expiry; `undefined`
69
+ * when it reported none. A WS frame is classified in the same task that
70
+ * dispatches it, so a `BOOTSTRAP_EXPIRED` refusal has already asked for its
71
+ * refresh by the time `connectBoot` resolves with it.
72
+ */
73
+ bootRefresh(): Promise<RefreshOutcome> | undefined;
74
+ /** Send on the active ladder's WS, when it has one. */
75
+ send(msg: Parameters<WsTransportHandle['send']>[0]): void;
76
+ }
77
+ export declare function createLadderSet(opts: LadderSetOptions): LadderSet;
@@ -0,0 +1,383 @@
1
+ /**
2
+ * The view's live channel across credentials (ggui#1496 part B, R3).
3
+ *
4
+ * A LADDER is one registry bound with one credential: WS → SSE → HTTP
5
+ * polling → bridge. The boot binds the first; when the view adopts a
6
+ * refreshed credential, a new ladder is built beside the old one, and this
7
+ * module owns the hand-over:
8
+ *
9
+ * - **Data.** The new ladder becomes the data holder when it starts reading
10
+ * from the resume point: its WS subscribe (which carries `fromSeq`, the
11
+ * view's stream cursor), its SSE open, or its first polling or bridge
12
+ * read of the shared ledger cursor. From then on the old ladder's
13
+ * deliveries drop, and its writes to the ledger cursor are no-ops
14
+ * (rev 2.1), so every ledger event after the resume point is applied
15
+ * once, by whichever ladder carries it. A lagging old ladder can
16
+ * therefore never overwrite the new ack's snapshot with older props.
17
+ * Stream envelopes are also deduped on their `seq` by the `data` handler.
18
+ * - **Status and send** follow the ACTIVE ladder. It switches at the new
19
+ * ladder's first token-rung acceptance (a WS or SSE ack, or a polling
20
+ * `ok`), or when the new ladder reaches its bridge rung. The old ladder
21
+ * is disposed at that switch, so only one bridge loop runs at a time.
22
+ * - **Expiry** is reported to the credential controller from three places:
23
+ * a WS `BOOTSTRAP_EXPIRED` frame (the only auth code that triggers), the
24
+ * first polling `410` whose body is not a replay horizon, and entering
25
+ * the bridge rung while the ladder's credential is past `expiresAt`. An
26
+ * adopted credential builds the next ladder.
27
+ *
28
+ * A new ladder omits WS when the one it replaces left WS without ever
29
+ * receiving a frame (the never-opened fail-fast): a jailed socket would
30
+ * only fail again.
31
+ *
32
+ * **An honest end (R4).** The bridge pulls are the one place the view can
33
+ * learn that its live channel cannot come back, and they are the only
34
+ * input (a refused refresh is quiet). A pull the host's relay refuses with
35
+ * a confirmed code, or a `session_not_found` that is confirmed — the first
36
+ * after an earlier successful pull, or the second in a row — ends the live
37
+ * channel: every ladder is disposed, which stops the bridge loop, and the
38
+ * caller is told which confirmation fired. Anything else keeps its backoff.
39
+ */
40
+ import { ChannelRegistry } from '@ggui-ai/live-channel';
41
+ import { buildBridgePolling, buildEventsPolling, } from './events-polling.js';
42
+ import { classifyChannelFrame, } from './registry-subscribe.js';
43
+ import { domainErrorCodeOf, isErrorToolResult } from './tool-result-error.js';
44
+ const REPLAY_HORIZON_PASSED = 'REPLAY_HORIZON_PASSED';
45
+ function errorCodeOf(frame) {
46
+ if (frame.type !== 'error')
47
+ return undefined;
48
+ const payload = frame.payload;
49
+ if (typeof payload !== 'object' || payload === null)
50
+ return undefined;
51
+ const code = Reflect.get(payload, 'code');
52
+ return typeof code === 'string' ? code : undefined;
53
+ }
54
+ /** Whether a 410 body is the ledger's replay-horizon answer (read from a clone, so the transport's own read is untouched). */
55
+ async function isReplayHorizon(response) {
56
+ try {
57
+ const parsed = JSON.parse(await response.text());
58
+ return (typeof parsed === 'object' &&
59
+ parsed !== null &&
60
+ Reflect.get(parsed, 'reason') === REPLAY_HORIZON_PASSED);
61
+ }
62
+ catch {
63
+ // Not JSON: the expired-token answer is plain text.
64
+ return false;
65
+ }
66
+ }
67
+ export function createLadderSet(opts) {
68
+ const now = opts.now ?? Date.now;
69
+ const baseFetch = opts.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
70
+ let nextId = 0;
71
+ const built = new Map();
72
+ let ended = false;
73
+ /** R4: dispose every ladder (the bridge loop stops with its ladder) and say why. */
74
+ const end = (why) => {
75
+ if (ended)
76
+ return;
77
+ ended = true;
78
+ for (const ladder of ladders) {
79
+ ladder.disposed = true;
80
+ if (ladder.handle !== undefined)
81
+ void ladder.handle.dispose();
82
+ }
83
+ ladders = [];
84
+ opts.onEnded(why);
85
+ };
86
+ /** Read one bridge pull's answer for R4: a confirmed ending, or nothing (its backoff stands). */
87
+ const classifyPull = (ladder, answer) => {
88
+ if ('error' in answer) {
89
+ const error = answer.error;
90
+ const code = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
91
+ if (typeof code === 'number' && opts.isConfirmedRelayRefusalCode(code))
92
+ end({ confirmation: 'relay-refusal', code });
93
+ return;
94
+ }
95
+ if (domainErrorCodeOf(answer.result) === 'session_not_found') {
96
+ ladder.notFoundStreak += 1;
97
+ if (ladder.pulled)
98
+ end({ confirmation: 'session-not-found', after: 'a-successful-pull' });
99
+ else if (ladder.notFoundStreak >= 2)
100
+ end({ confirmation: 'session-not-found', after: 'a-second-not-found' });
101
+ return;
102
+ }
103
+ if (!isErrorToolResult(answer.result)) {
104
+ ladder.pulled = true;
105
+ ladder.notFoundStreak = 0;
106
+ }
107
+ };
108
+ let ladders = [];
109
+ let active;
110
+ let holder;
111
+ let newest;
112
+ /** The newest ladder takes the data when it starts reading from the resume point. */
113
+ const claim = (ladder) => {
114
+ if (ladder.rebuilt && ladder === newest && holder !== ladder && !ladder.disposed)
115
+ holder = ladder;
116
+ };
117
+ /** Hand status, send and data to `ladder`, and dispose every other one. */
118
+ const switchTo = (state) => {
119
+ const ladder = built.get(state);
120
+ if (ladder === undefined || active === ladder || ladder.disposed)
121
+ return;
122
+ const others = ladders.filter((l) => l !== ladder);
123
+ ladders = [ladder];
124
+ active = ladder;
125
+ holder = ladder;
126
+ for (const old of others) {
127
+ old.disposed = true;
128
+ if (old.handle !== undefined)
129
+ void old.handle.dispose();
130
+ }
131
+ if (ladder.lastStatus !== undefined)
132
+ opts.onStatus(ladder.lastStatus);
133
+ };
134
+ const accept = (ladder) => {
135
+ if (ladder.disposed)
136
+ return;
137
+ if (ladder.spec.credential !== undefined)
138
+ opts.controller?.markAccepted(ladder.spec.credential);
139
+ switchTo(ladder);
140
+ };
141
+ /** An ack's `streamSeq` below the view's stream cursor means the server's counter restarted. */
142
+ const readStreamSeq = (ack) => {
143
+ if (opts.streamSeq?.observeAck(ack.streamSeq, ack.streamEpoch) === true) {
144
+ opts.onStreamRestart?.(ack.streamSeq, ack.streamEpoch);
145
+ }
146
+ };
147
+ const onAck = (ladder, ack) => {
148
+ accept(ladder);
149
+ if (holder !== ladder)
150
+ return;
151
+ // Before the replay that follows the ack is applied.
152
+ readStreamSeq(ack);
153
+ opts.onAck(ack);
154
+ };
155
+ const reportExpired = async (ladder, source) => {
156
+ const credential = ladder.spec.credential;
157
+ const controller = opts.controller;
158
+ if (controller === null || credential === undefined || ladder.disposed || ended)
159
+ return;
160
+ const pending = controller.onExpired(credential, source);
161
+ if (ladder.refresh === undefined)
162
+ ladder.refresh = pending;
163
+ const outcome = await pending;
164
+ opts.onRefresh?.(source, outcome);
165
+ if (outcome.kind === 'adopted')
166
+ rebind(ladder, outcome.credential, source);
167
+ };
168
+ const build = (spec, rebuilt, omitWs) => {
169
+ const hasWs = spec.credential !== undefined && !omitWs;
170
+ const ladder = {
171
+ id: nextId++,
172
+ spec,
173
+ rebuilt,
174
+ hasWs,
175
+ handle: undefined,
176
+ disposed: false,
177
+ wsFrameSeen: false,
178
+ bridgeEntered: false,
179
+ polling410Seen: false,
180
+ lastStatus: undefined,
181
+ refresh: undefined,
182
+ pulled: false,
183
+ notFoundStreak: 0,
184
+ };
185
+ const classifyFrame = (frame) => {
186
+ ladder.wsFrameSeen = true;
187
+ if (errorCodeOf(frame) === 'BOOTSTRAP_EXPIRED')
188
+ void reportExpired(ladder, 'ws');
189
+ return classifyChannelFrame(frame);
190
+ };
191
+ const fetchImpl = async (input, init) => {
192
+ const response = await baseFetch(input, init);
193
+ if (response.ok) {
194
+ accept(ladder);
195
+ }
196
+ else if (response.status === 410 && !ladder.polling410Seen) {
197
+ ladder.polling410Seen = true;
198
+ if (!(await isReplayHorizon(response.clone())))
199
+ void reportExpired(ladder, 'polling');
200
+ }
201
+ return response;
202
+ };
203
+ const registry = new ChannelRegistry({
204
+ subscribeFrameBuilder: () => {
205
+ claim(ladder);
206
+ const wsToken = ladder.spec.credential?.wsToken;
207
+ const fromSeq = ladder.rebuilt ? opts.streamSeq?.last() : undefined;
208
+ // `fromEpoch` rides only beside `fromSeq` (ggui#1531): it names the generation that count is in.
209
+ const fromEpoch = fromSeq !== undefined ? opts.streamSeq?.epoch() : undefined;
210
+ return {
211
+ type: 'subscribe',
212
+ payload: {
213
+ sessionId: opts.meta.sessionId,
214
+ appId: opts.meta.appId,
215
+ ...(wsToken !== undefined ? { wsToken } : {}),
216
+ ...(fromSeq !== undefined ? { fromSeq } : {}),
217
+ ...(fromEpoch !== undefined ? { fromEpoch } : {}),
218
+ },
219
+ };
220
+ },
221
+ classifyFrame,
222
+ fetchImpl,
223
+ ...(opts.webSocketFactory !== undefined ? { webSocketFactory: opts.webSocketFactory } : {}),
224
+ ...(opts.eventSourceFactory !== undefined ? { eventSourceFactory: opts.eventSourceFactory } : {}),
225
+ });
226
+ for (const handler of opts.handlers) {
227
+ registry.register({
228
+ type: handler.type,
229
+ onMessage: (payload) => (holder === ladder ? handler.onMessage(payload) : undefined),
230
+ });
231
+ }
232
+ const complete = Object.assign(ladder, { registry });
233
+ built.set(ladder, complete);
234
+ ladders.push(complete);
235
+ newest = complete;
236
+ return complete;
237
+ };
238
+ /** The ledger cursor as `ladder` sees it: shared reads; writes only while it holds the data. */
239
+ const cursorView = (ladder, shared) => ({
240
+ get: () => {
241
+ claim(ladder);
242
+ return shared.get();
243
+ },
244
+ advance: (seq) => {
245
+ if (holder === ladder)
246
+ shared.advance(seq);
247
+ },
248
+ reset: (seq) => {
249
+ if (holder === ladder)
250
+ shared.reset(seq);
251
+ },
252
+ });
253
+ const connectOptions = (ladder) => {
254
+ const { spec } = ladder;
255
+ const shared = opts.cursor;
256
+ const view = shared !== undefined ? cursorView(ladder, shared) : undefined;
257
+ const credential = spec.credential;
258
+ const { wsUrl: _wsUrl, wsToken: _wsToken, expiresAt: _expiresAt, sseUrl: _sse, pollingUrl: _polling, ...identity } = opts.meta;
259
+ const meta = {
260
+ ...identity,
261
+ ...(ladder.hasWs && credential !== undefined
262
+ ? { wsUrl: credential.wsUrl, wsToken: credential.wsToken }
263
+ : {}),
264
+ ...(credential?.expiresAt !== undefined ? { expiresAt: credential.expiresAt } : {}),
265
+ };
266
+ const sse = spec.sseUrl !== undefined && view !== undefined
267
+ ? {
268
+ url: spec.sseUrl,
269
+ initialSinceSequence: () => view.get(),
270
+ ...(ladder.rebuilt && opts.streamSeq !== undefined
271
+ ? { fromSeq: opts.streamSeq.last, fromEpoch: opts.streamSeq.epoch }
272
+ : {}),
273
+ onSequence: (seq) => view.advance(seq),
274
+ }
275
+ : undefined;
276
+ const polling = spec.pollingUrl !== undefined && view !== undefined
277
+ ? buildEventsPolling({ baseUrl: spec.pollingUrl, cursor: view })
278
+ : undefined;
279
+ const callTool = opts.bridgeCallTool;
280
+ const bridge = callTool !== undefined && view !== undefined
281
+ ? buildBridgePolling({
282
+ callTool: async (name, args) => {
283
+ enterBridge(ladder);
284
+ let result;
285
+ try {
286
+ result = await callTool(name, args);
287
+ }
288
+ catch (error) {
289
+ classifyPull(ladder, { error });
290
+ throw error;
291
+ }
292
+ classifyPull(ladder, { result });
293
+ return result;
294
+ },
295
+ sessionId: opts.meta.sessionId,
296
+ cursor: view,
297
+ })
298
+ : undefined;
299
+ return {
300
+ meta,
301
+ registry: ladder.registry,
302
+ ...(opts.logger !== undefined ? { logger: opts.logger } : {}),
303
+ ...(opts.onProtocolError !== undefined ? { onProtocolError: opts.onProtocolError } : {}),
304
+ ...(opts.onObserve !== undefined ? { onObserve: opts.onObserve } : {}),
305
+ onStatusChange: (status) => {
306
+ ladder.lastStatus = status;
307
+ if (ladder === active)
308
+ opts.onStatus(status);
309
+ },
310
+ onResubscribeAck: (ack) => onAck(ladder, ack),
311
+ ...(opts.onPostAckError !== undefined ? { onPostAckError: opts.onPostAckError } : {}),
312
+ onBound: (handle) => {
313
+ ladder.handle = handle;
314
+ if (ladder.disposed)
315
+ void handle.dispose();
316
+ },
317
+ ...(sse !== undefined ? { sse } : {}),
318
+ ...(polling !== undefined ? { polling } : {}),
319
+ ...(bridge !== undefined ? { bridge } : {}),
320
+ };
321
+ };
322
+ /** The ladder's first bridge pull: it has left its token rungs (R3 (b), fact 8). */
323
+ const enterBridge = (ladder) => {
324
+ if (ladder.bridgeEntered)
325
+ return;
326
+ ladder.bridgeEntered = true;
327
+ switchTo(ladder);
328
+ const expiresAt = ladder.spec.credential?.expiresAt;
329
+ if (expiresAt !== undefined && Date.parse(expiresAt) <= now())
330
+ void reportExpired(ladder, 'bridge');
331
+ };
332
+ // The handle arrives through `onBound` as soon as the ladder is bound;
333
+ // the settled result carries the same one, for a `connectFn` that
334
+ // reports none earlier.
335
+ const bind = async (ladder) => {
336
+ const result = await opts.connectFn(connectOptions(ladder));
337
+ if (ladder.handle === undefined) {
338
+ ladder.handle = result.handle;
339
+ if (ladder.disposed)
340
+ void result.handle.dispose();
341
+ }
342
+ return result;
343
+ };
344
+ const rebind = (from, credential, source) => {
345
+ if (ended)
346
+ return;
347
+ // A jailed socket that never delivered a frame would only fail again.
348
+ const omitWs = source !== 'ws' && from.hasWs && !from.wsFrameSeen;
349
+ const ladder = build({ credential, sseUrl: credential.sseUrl, pollingUrl: credential.pollingUrl }, true, omitWs);
350
+ void bind(ladder).then((result) => {
351
+ if (result.ack !== undefined)
352
+ onAck(ladder, result.ack);
353
+ // A refusal before acceptance: the ladder demotes on its own and
354
+ // takes over when it reaches its bridge rung. Its credential is
355
+ // never refreshed again unaccepted (the loop guard).
356
+ }, () => {
357
+ // UPGRADE_REQUIRED or a bind failure: nothing takes over, and the
358
+ // old ladder keeps the view.
359
+ });
360
+ };
361
+ let bootLadder;
362
+ return {
363
+ async connectBoot(spec) {
364
+ const ladder = build(spec, false, false);
365
+ bootLadder = ladder;
366
+ active = ladder;
367
+ holder = ladder;
368
+ const result = await bind(ladder);
369
+ if (result.ack !== undefined) {
370
+ readStreamSeq(result.ack);
371
+ if (spec.credential !== undefined)
372
+ opts.controller?.markAccepted(spec.credential);
373
+ }
374
+ return result;
375
+ },
376
+ bootRefresh: () => bootLadder?.refresh,
377
+ send(msg) {
378
+ const handle = active?.handle;
379
+ if (handle !== undefined && handle.kind === 'ws')
380
+ handle.send(msg);
381
+ },
382
+ };
383
+ }
@@ -53,6 +53,7 @@
53
53
  import { PUBLIC_ENV_APP_KEY_RE } from '@ggui-ai/protocol/wire';
54
54
  import { parseMcpAppAiGguiRenderMeta, } from '@ggui-ai/protocol/integrations/mcp-apps';
55
55
  import { postObservabilityToParent } from './observability.js';
56
+ import { viewRootOf } from './view-root.js';
56
57
  /**
57
58
  * Type guard — true iff `value` is a non-null, non-array plain object.
58
59
  */
@@ -189,16 +190,26 @@ function projectPublicEnv(raw) {
189
190
  function projectMeta(meta, hasStaticContent) {
190
191
  let expiresAt = meta.expiresAt;
191
192
  let dropLiveCreds = false;
193
+ let held;
192
194
  if (expiresAt !== undefined) {
193
195
  const ts = Date.parse(expiresAt);
194
196
  if (Number.isNaN(ts)) {
195
197
  return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
196
198
  }
197
199
  if (ts <= Date.now()) {
198
- // Expired — only hard-fail when no static content is present.
199
- // With static content, mount the UI without live mode.
200
- if (!hasStaticContent) {
201
- return { ok: false, reason: 'EXPIRED_BOOTSTRAP' };
200
+ // Expired: the live trio leaves the meta, so nothing subscribes with
201
+ // a dead token, and comes back as `held`, so the view can refresh it
202
+ // through its host (ggui#1496). Without static content the slice is
203
+ // still EXPIRED_BOOTSTRAP, now carrying what a refresh needs.
204
+ if (isNonEmptyString(meta.wsUrl) && isNonEmptyString(meta.wsToken)) {
205
+ held = {
206
+ wsToken: meta.wsToken,
207
+ wsUrl: meta.wsUrl,
208
+ expiresAt,
209
+ ...(meta.sseUrl !== undefined ? { sseUrl: meta.sseUrl } : {}),
210
+ ...(meta.pollingUrl !== undefined ? { pollingUrl: meta.pollingUrl } : {}),
211
+ origin: 'root',
212
+ };
202
213
  }
203
214
  dropLiveCreds = true;
204
215
  expiresAt = undefined;
@@ -290,7 +301,14 @@ function projectMeta(meta, hasStaticContent) {
290
301
  ...(meta.codeB64 !== undefined ? { codeB64: meta.codeB64 } : {}),
291
302
  ...(meta.kind !== undefined ? { kind: meta.kind } : {}),
292
303
  };
293
- return { ok: true, meta: projected };
304
+ if (dropLiveCreds && !hasStaticContent) {
305
+ // Live-only and expired. `held` is always set here: validateMeta admits a
306
+ // live-only slice only with a live trio.
307
+ if (held === undefined)
308
+ return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
309
+ return { ok: false, reason: 'EXPIRED_BOOTSTRAP', meta: projected, held };
310
+ }
311
+ return { ok: true, meta: projected, ...(held !== undefined ? { held } : {}) };
294
312
  }
295
313
  /**
296
314
  * Validate an {@link McpAppAiGguiRenderMeta} slice. Enforces mode
@@ -326,10 +344,22 @@ export function validateMeta(meta) {
326
344
  if (!hasLive && !hasStaticContent) {
327
345
  return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
328
346
  }
347
+ // The view key root (ggui#1415) is read from the slice as delivered,
348
+ // before the projection drops an expired live pair: a view proves with the
349
+ // root the server issued whether or not the credential beside it is still
350
+ // live. It rides beside the meta, never inside it.
351
+ const viewRoot = viewRootOf(meta);
352
+ const withRoot = viewRoot !== undefined ? { viewRoot } : {};
329
353
  const result = projectMeta(meta, hasStaticContent);
330
- if (!result.ok)
331
- return result;
332
- return { ok: true, meta: result.meta };
354
+ if (!result.ok) {
355
+ return result.reason === 'EXPIRED_BOOTSTRAP' ? { ...result, ...withRoot } : result;
356
+ }
357
+ return {
358
+ ok: true,
359
+ meta: result.meta,
360
+ ...(result.held !== undefined ? { held: result.held } : {}),
361
+ ...withRoot,
362
+ };
333
363
  }
334
364
  /**
335
365
  * The wire parser refused the slice's `theme` (ggui#987 §3.4): report it
@@ -33,7 +33,7 @@ import { MCP_APP_OBSERVE_TYPE } from '@ggui-ai/protocol/integrations/mcp-apps';
33
33
  *
34
34
  * @public
35
35
  */
36
- export type ObservabilityEvent = SchemaVersionMismatchEvent | FontFaceBlockedEvent | AppThemeInvalidEvent | AppThemeMemberStrippedEvent | ActionSpecInvalidEvent | ActionSpecMemberStrippedEvent | OneShotUnenforceableEvent | SpentOneShotsInvalidEvent | ThemeReinjectFailedEvent | SubscribeFailedEvent | ChannelTransportPickedEvent | ChannelTransportFallbackEvent | ChannelTransportResubscribedEvent | ChannelPollDegradedEvent | ChannelPollRecoveredEvent | UiFeedbackEvent | RelayIncapabilityEvent | RelayDeadTapEvent | ComponentEmptyEvent | UnknownObservabilityEvent;
36
+ export type ObservabilityEvent = SchemaVersionMismatchEvent | FontFaceBlockedEvent | AppThemeInvalidEvent | AppThemeMemberStrippedEvent | ActionSpecInvalidEvent | ActionSpecMemberStrippedEvent | OneShotUnenforceableEvent | ActionRefusedEvent | SpentOneShotsInvalidEvent | ThemeReinjectFailedEvent | SubscribeFailedEvent | ChannelTransportPickedEvent | ChannelTransportFallbackEvent | ChannelTransportResubscribedEvent | ChannelPollDegradedEvent | ChannelPollRecoveredEvent | UiFeedbackEvent | RelayIncapabilityEvent | RelayDeadTapEvent | ComponentEmptyEvent | UnknownObservabilityEvent;
37
37
  /**
38
38
  * Fired when the protocol-version handshake rejects the connection.
39
39
  * Parallel to `UpgradeRequiredError` — the host can choose to render
@@ -296,14 +296,20 @@ export interface RelayDeadTapEvent {
296
296
  readonly appId?: string;
297
297
  }
298
298
  /**
299
- * The mount painted NOTHING (ggui#1103): the renderer held no component,
300
- * so the scope element carries only its stylesheet — a blank card, with no
301
- * error boundary to catch anything (the boundary wraps a null child). The
302
- * host is told which mount moment it was and why, because a card that
303
- * renders nothing is invisible on every other channel: `reason: 'no-code'`
304
- * is a payload that carried no component code, `'eval-failed'` a module
305
- * that threw while evaluating. Names the moment only — never the code, the
306
- * props or the error text, which stay in the operator-visible console line.
299
+ * The mount painted NOTHING. The host is told which mount moment it was and
300
+ * why, because a card that renders nothing is invisible on every other
301
+ * channel. Two reasons are a renderer that held no component (ggui#1103), so
302
+ * the scope carries only its stylesheet and no error boundary engages:
303
+ * `'no-code'` is a payload that carried no component code, `'eval-failed'` a
304
+ * module that threw while evaluating. Three are a component that WAS held
305
+ * and still painted nothing (ggui#1104): `'rendered-nothing'` — the tree
306
+ * committed and the scope holds no element; `'detached'` — the tree
307
+ * committed into a scope no longer in the document (another mount took the
308
+ * container); `'no-commit'` — the tree never committed within the renderer's
309
+ * deadline (a component that suspends with no Suspense boundary). Names the
310
+ * moment only — never the code, the props or the error text, which stay in
311
+ * the operator-visible console line. A host MUST treat an unknown reason as
312
+ * a blank of unknown cause.
307
313
  *
308
314
  * @public
309
315
  */
@@ -311,8 +317,8 @@ export interface ComponentEmptyEvent {
311
317
  readonly kind: 'component-empty';
312
318
  /** Which lifecycle moment painted nothing. */
313
319
  readonly where: 'mount' | 'update';
314
- /** Why the renderer held no component. */
315
- readonly reason: 'no-code' | 'eval-failed';
320
+ /** Why the moment painted nothing (see above). */
321
+ readonly reason: 'no-code' | 'eval-failed' | 'rendered-nothing' | 'detached' | 'no-commit';
316
322
  }
317
323
  /**
318
324
  * Catch-all branch for event kinds the host's typings don't recognize.
@@ -402,6 +408,27 @@ export interface OneShotUnenforceableEvent {
402
408
  readonly renderId: string;
403
409
  readonly actionName: string;
404
410
  }
411
+ /**
412
+ * A dispatch the card's own action spec refused (ggui#1536): the outbound
413
+ * contract check found the envelope outside the contract, so nothing was
414
+ * sent and the visitor's tap did nothing. `violations` name each failure by
415
+ * its field path and, when a schema keyword produced it, that keyword, never
416
+ * by value: the data is the visitor's input. Posted per refused dispatch, at
417
+ * most ten per render; the tenth carries `capped: true` and later ones are
418
+ * not posted.
419
+ *
420
+ * @public
421
+ */
422
+ export interface ActionRefusedEvent {
423
+ readonly kind: 'action-refused';
424
+ readonly renderId: string;
425
+ readonly actionName: string;
426
+ readonly violations: ReadonlyArray<{
427
+ readonly field: string;
428
+ readonly keyword?: string;
429
+ }>;
430
+ readonly capped?: true;
431
+ }
405
432
  /**
406
433
  * The render slice carried a `spentOneShots` the wire schema refused
407
434
  * (ggui#1223): the parser dropped it, and the card mounted with only its