@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.
- 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 -93
- 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 +38 -11
- package/dist/react-renderer.d.ts +22 -0
- package/dist/react-renderer.js +118 -3
- package/dist/registry-subscribe.d.ts +53 -2
- package/dist/registry-subscribe.js +63 -8
- package/dist/render-failure-report.d.ts +15 -0
- package/dist/render-failure-report.js +61 -0
- package/dist/render-item.d.ts +6 -1
- package/dist/render-item.js +7 -2
- package/dist/runtime-telemetry.d.ts +28 -18
- package/dist/runtime-telemetry.js +68 -29
- package/dist/runtime.d.ts +157 -39
- package/dist/runtime.js +724 -368
- package/dist/shims/wire.js +1 -1
- 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 +15 -1
- package/dist/wire-config.js +42 -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
|
|
@@ -296,14 +296,20 @@ export interface RelayDeadTapEvent {
|
|
|
296
296
|
readonly appId?: string;
|
|
297
297
|
}
|
|
298
298
|
/**
|
|
299
|
-
* The mount painted NOTHING
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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
|
|
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
|