@ggui-ai/iframe-runtime 0.25.0 → 0.27.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 (40) 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 -94
  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 +22 -1
  13. package/dist/react-renderer.d.ts +20 -0
  14. package/dist/react-renderer.js +22 -0
  15. package/dist/registry-subscribe.d.ts +37 -1
  16. package/dist/registry-subscribe.js +34 -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 +3 -0
  20. package/dist/render-item.js +2 -0
  21. package/dist/runtime-telemetry.d.ts +28 -18
  22. package/dist/runtime-telemetry.js +68 -29
  23. package/dist/runtime.d.ts +114 -35
  24. package/dist/runtime.js +556 -309
  25. package/dist/stream-seq.d.ts +56 -0
  26. package/dist/stream-seq.js +46 -0
  27. package/dist/system-cards/ProtocolProbeCard.d.ts +16 -0
  28. package/dist/system-cards/ProtocolProbeCard.js +17 -14
  29. package/dist/tool-result-error.d.ts +6 -0
  30. package/dist/tool-result-error.js +32 -0
  31. package/dist/types.d.ts +49 -1
  32. package/dist/view-origin.d.ts +29 -0
  33. package/dist/view-origin.js +33 -0
  34. package/dist/view-proof-signer.d.ts +31 -0
  35. package/dist/view-proof-signer.js +99 -0
  36. package/dist/view-root.d.ts +51 -0
  37. package/dist/view-root.js +56 -0
  38. package/dist/wire-config.d.ts +13 -0
  39. package/dist/wire-config.js +39 -1
  40. 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
@@ -408,6 +408,27 @@ export interface OneShotUnenforceableEvent {
408
408
  readonly renderId: string;
409
409
  readonly actionName: string;
410
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
+ }
411
432
  /**
412
433
  * The render slice carried a `spentOneShots` the wire schema refused
413
434
  * (ggui#1223): the parser dropped it, and the card mounted with only its
@@ -31,8 +31,21 @@
31
31
  */
32
32
  import React, { type ReactNode } from 'react';
33
33
  import type { AppTheme } from '@ggui-ai/protocol/wire';
34
+ import type { RenderFailurePhase } from '@ggui-ai/protocol/render-failure';
34
35
  /** Test hook: shorten the no-commit deadline; `null` restores the default. */
35
36
  export declare function __setNoCommitDeadlineForTest(ms: number | null): void;
37
+ /**
38
+ * The boundary's give-up, named (ggui#1679): what the component threw on
39
+ * its terminal catch, the phase — `mount` when the component had never
40
+ * committed, `update` when a painted card's re-render threw — and the
41
+ * boundary's catch count at that moment (the retry included: 2 today). The
42
+ * runtime reports it to the server in exactly these terms.
43
+ */
44
+ export interface RenderFailure {
45
+ readonly error: Error;
46
+ readonly phase: RenderFailurePhase;
47
+ readonly catches: number;
48
+ }
36
49
  export interface ReactRootMountOptions {
37
50
  /**
38
51
  * GguiSession whose componentCode + props drive the mount. Only
@@ -100,6 +113,13 @@ export interface ReactRootMountOptions {
100
113
  /** `fill` — the root is the whole canvas: no silhouette, fills the page (ggui#1041; from the host's `displayMode`). */
101
114
  readonly fit?: 'fill';
102
115
  readonly onError?: (error: Error) => void;
116
+ /**
117
+ * The error boundary's give-up (ggui#1679): fires once per terminal catch,
118
+ * beside `onError`, with the phase and the catch count the runtime reports
119
+ * to the server. Not fired for an eval failure — a module that does not
120
+ * load is "no component" (ggui#1103), not a component that threw.
121
+ */
122
+ readonly onRenderFailure?: (failure: RenderFailure) => void;
103
123
  /**
104
124
  * Children injected BETWEEN the mount DOM (scope + CSS) and the
105
125
  * evaluated component element. The caller's render dispatcher