@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.
- package/README.md +2 -2
- package/dist/channels/data.d.ts +8 -0
- package/dist/channels/data.js +2 -0
- package/dist/credential-controller.d.ts +71 -0
- package/dist/credential-controller.js +135 -0
- package/dist/iframe-runtime.js +97 -94
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/ladders.d.ts +77 -0
- package/dist/ladders.js +383 -0
- package/dist/meta-parse.js +38 -8
- package/dist/observability.d.ts +22 -1
- package/dist/react-renderer.d.ts +20 -0
- package/dist/react-renderer.js +22 -0
- package/dist/registry-subscribe.d.ts +37 -1
- package/dist/registry-subscribe.js +34 -8
- package/dist/render-failure-report.d.ts +15 -0
- package/dist/render-failure-report.js +61 -0
- package/dist/render-item.d.ts +3 -0
- package/dist/render-item.js +2 -0
- package/dist/runtime-telemetry.d.ts +28 -18
- package/dist/runtime-telemetry.js +68 -29
- package/dist/runtime.d.ts +114 -35
- package/dist/runtime.js +556 -309
- package/dist/stream-seq.d.ts +56 -0
- package/dist/stream-seq.js +46 -0
- package/dist/system-cards/ProtocolProbeCard.d.ts +16 -0
- package/dist/system-cards/ProtocolProbeCard.js +17 -14
- package/dist/tool-result-error.d.ts +6 -0
- package/dist/tool-result-error.js +32 -0
- package/dist/types.d.ts +49 -1
- package/dist/view-origin.d.ts +29 -0
- package/dist/view-origin.js +33 -0
- package/dist/view-proof-signer.d.ts +31 -0
- package/dist/view-proof-signer.js +99 -0
- package/dist/view-root.d.ts +51 -0
- package/dist/view-root.js +56 -0
- package/dist/wire-config.d.ts +13 -0
- package/dist/wire-config.js +39 -1
- 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;
|
package/dist/ladders.js
ADDED
|
@@ -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
|
+
}
|
package/dist/meta-parse.js
CHANGED
|
@@ -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
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/observability.d.ts
CHANGED
|
@@ -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
|
package/dist/react-renderer.d.ts
CHANGED
|
@@ -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
|