@ggui-ai/iframe-runtime 0.25.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 -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/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# @ggui-ai/iframe-runtime
|
|
2
2
|
|
|
3
|
-
The iframe-local runtime for [
|
|
3
|
+
The iframe-local runtime for [GGUI](https://ggui.ai).
|
|
4
4
|
|
|
5
5
|
A self-contained ESM bundle that boots inside an MCP Apps host iframe,
|
|
6
6
|
runs the protocol-version handshake, opens the live-channel WebSocket,
|
|
7
7
|
and mounts the rendered UI. It bundles React, ReactDOM, and the
|
|
8
|
-
|
|
8
|
+
GGUI design system inside the artifact so generated component code has a
|
|
9
9
|
single seam to read from.
|
|
10
10
|
|
|
11
11
|
Most consumers never call this package's API directly — they load the
|
package/dist/channels/data.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { GguiSession, StreamEnvelope } from '@ggui-ai/protocol/wire';
|
|
|
15
15
|
import type { GguiSessionSeedInput } from '../types.js';
|
|
16
16
|
import { type RendererValidatorContext } from '../validation.js';
|
|
17
17
|
import type { StreamBus } from '../wire-config.js';
|
|
18
|
+
import type { StreamSeqTracker } from '../stream-seq.js';
|
|
18
19
|
export interface DataHandlerDeps {
|
|
19
20
|
/**
|
|
20
21
|
* Read the currently-mounted render. Returns `null` when no render
|
|
@@ -24,5 +25,12 @@ export interface DataHandlerDeps {
|
|
|
24
25
|
readonly getCurrentGguiSession: () => GguiSession | GguiSessionSeedInput | null;
|
|
25
26
|
readonly streamBus: StreamBus;
|
|
26
27
|
readonly validatorCtx: RendererValidatorContext;
|
|
28
|
+
/**
|
|
29
|
+
* The view's stream cursor (ggui#1496): a stamped envelope at or below
|
|
30
|
+
* the highest `seq` already applied is a redelivery and drops. Shared by
|
|
31
|
+
* every ladder the view binds, so a stream replayed on a new ladder is
|
|
32
|
+
* applied once.
|
|
33
|
+
*/
|
|
34
|
+
readonly streamSeq: StreamSeqTracker;
|
|
27
35
|
}
|
|
28
36
|
export declare function createDataHandler(deps: DataHandlerDeps): ChannelHandler<StreamEnvelope>;
|
package/dist/channels/data.js
CHANGED
|
@@ -20,6 +20,8 @@ export function createDataHandler(deps) {
|
|
|
20
20
|
typeof envelope.channel !== 'string') {
|
|
21
21
|
return;
|
|
22
22
|
}
|
|
23
|
+
if (!deps.streamSeq.admit(envelope.seq, envelope.streamEpoch))
|
|
24
|
+
return;
|
|
23
25
|
// Active render carries the streamSpec — mirrors
|
|
24
26
|
// `GguiRender.handleServerMessage`.
|
|
25
27
|
const activeRender = deps.getCurrentGguiSession();
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { HeldCredential } from "./types.js";
|
|
2
|
+
export type { HeldCredential };
|
|
3
|
+
/** What reported the expiry. Every source but `boot` follows a drop. */
|
|
4
|
+
export type ExpirySource = "ws" | "polling" | "bridge" | "boot";
|
|
5
|
+
export type RefreshOutcome = {
|
|
6
|
+
readonly kind: "adopted";
|
|
7
|
+
readonly credential: HeldCredential;
|
|
8
|
+
}
|
|
9
|
+
/** A plain refusal: `BOOTSTRAP_INVALID` or `BOOTSTRAP_NOT_SUPPORTED`, or any other code a server sends. */
|
|
10
|
+
| {
|
|
11
|
+
readonly kind: "refused";
|
|
12
|
+
readonly code: string;
|
|
13
|
+
}
|
|
14
|
+
/** The session is gone, or not visible to the caller: the pull's own not-found. */
|
|
15
|
+
| {
|
|
16
|
+
readonly kind: "not-found";
|
|
17
|
+
}
|
|
18
|
+
/** The relay rejected the call, or answered something this reader does not recognise. */
|
|
19
|
+
| {
|
|
20
|
+
readonly kind: "relay-error";
|
|
21
|
+
readonly message: string;
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: "skipped";
|
|
24
|
+
readonly reason: "budget" | "stale-ladder";
|
|
25
|
+
};
|
|
26
|
+
/** The host's `tools/call` relay, bound by the runtime to the App's `callServerTool`. */
|
|
27
|
+
export type RefreshCallTool = (name: "ggui_runtime_refresh_ws_token", args: {
|
|
28
|
+
readonly envelope: string;
|
|
29
|
+
}) => Promise<unknown>;
|
|
30
|
+
export interface CredentialControllerOptions {
|
|
31
|
+
readonly initial: HeldCredential;
|
|
32
|
+
readonly callTool: RefreshCallTool;
|
|
33
|
+
/** Called with each adopted credential, after it becomes current. */
|
|
34
|
+
readonly onAdopt?: (credential: HeldCredential) => void;
|
|
35
|
+
/** Test seam: defaults to a `setTimeout` wait. */
|
|
36
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
37
|
+
/** Test seam: defaults to `Math.random`. */
|
|
38
|
+
readonly random?: () => number;
|
|
39
|
+
}
|
|
40
|
+
export interface CredentialController {
|
|
41
|
+
current(): HeldCredential;
|
|
42
|
+
/** The credential was accepted on a token rung (a WS or SSE ack, or a polling `ok`). */
|
|
43
|
+
markAccepted(credential: HeldCredential): void;
|
|
44
|
+
/**
|
|
45
|
+
* `credential` is the one the reporting ladder was built with. `retry`
|
|
46
|
+
* re-sends the SAME request after a relay error, inside the one budgeted
|
|
47
|
+
* refresh; a definitive answer is never retried.
|
|
48
|
+
*/
|
|
49
|
+
onExpired(credential: HeldCredential, source: ExpirySource, retry?: RefreshRetry): Promise<RefreshOutcome>;
|
|
50
|
+
}
|
|
51
|
+
/** How many times a refresh is re-sent after a relay error. */
|
|
52
|
+
export interface RefreshRetry {
|
|
53
|
+
readonly retries: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A server restart (close code 1012) drops every view at once, so a refresh after a drop
|
|
57
|
+
* waits a uniform random delay up to this bound, spreading the burst at each
|
|
58
|
+
* host's relay. A boot refresh is not synchronised and does not wait.
|
|
59
|
+
*/
|
|
60
|
+
export declare const REFRESH_JITTER_MAX_MS = 5000;
|
|
61
|
+
/**
|
|
62
|
+
* The boot refresh of a live-only slice is the view's only way to a live
|
|
63
|
+
* channel, so a relay error there (the host's relay failed, or the
|
|
64
|
+
* server's store read threw) is re-sent this many times before the boot
|
|
65
|
+
* reports `EXPIRED_BOOTSTRAP`, each after a uniform random wait between
|
|
66
|
+
* these bounds.
|
|
67
|
+
*/
|
|
68
|
+
export declare const BOOT_REFRESH_RETRIES = 2;
|
|
69
|
+
export declare const REFRESH_RETRY_DELAY_MIN_MS = 1000;
|
|
70
|
+
export declare const REFRESH_RETRY_DELAY_MAX_MS = 3000;
|
|
71
|
+
export declare function createCredentialController(opts: CredentialControllerOptions): CredentialController;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view's live credential and its one refresh (ggui#1496 part B,
|
|
3
|
+
* runtime half).
|
|
4
|
+
*
|
|
5
|
+
* A view holds one live credential: the `wsToken` its token-bearing
|
|
6
|
+
* transports carry (the WS subscribe, and the `sseUrl` / `pollingUrl`
|
|
7
|
+
* query). When the server refuses it as expired, the view asks its host
|
|
8
|
+
* to relay `ggui_runtime_refresh_ws_token`, the same `tools/call` relay the
|
|
9
|
+
* bridge-pull rung uses, and adopts the fresh credential. This module owns
|
|
10
|
+
* the credential and the budget; the runtime owns the ladders and tells it
|
|
11
|
+
* when a credential expired or was accepted.
|
|
12
|
+
*
|
|
13
|
+
* The budget is one predicate:
|
|
14
|
+
* - a credential that came back from a refresh and has NOT been accepted
|
|
15
|
+
* on a token rung is never refreshed again, whether it was refused or
|
|
16
|
+
* left the token rungs (the loop guard);
|
|
17
|
+
* - any other expired credential gets exactly one refresh.
|
|
18
|
+
* A credential is marked spent when its refresh is REQUESTED, so triggers
|
|
19
|
+
* that fire together (a WS refusal, the polling 410, entering the bridge)
|
|
20
|
+
* produce one request.
|
|
21
|
+
*
|
|
22
|
+
* Expiry is always reported by the caller, from the slice's `expiresAt` or
|
|
23
|
+
* from a refusal signal, never derived from a token's issue time: a
|
|
24
|
+
* chained token can legitimately live less than a full TTL.
|
|
25
|
+
*/
|
|
26
|
+
import { withWsToken } from "@ggui-ai/protocol/integrations/mcp-apps";
|
|
27
|
+
import { unwrapCallToolResult } from "./call-tool-unwrap.js";
|
|
28
|
+
import { domainErrorCodeOf, isErrorToolResult, toolResultText } from "./tool-result-error.js";
|
|
29
|
+
/**
|
|
30
|
+
* A server restart (close code 1012) drops every view at once, so a refresh after a drop
|
|
31
|
+
* waits a uniform random delay up to this bound, spreading the burst at each
|
|
32
|
+
* host's relay. A boot refresh is not synchronised and does not wait.
|
|
33
|
+
*/
|
|
34
|
+
export const REFRESH_JITTER_MAX_MS = 5_000;
|
|
35
|
+
/**
|
|
36
|
+
* The boot refresh of a live-only slice is the view's only way to a live
|
|
37
|
+
* channel, so a relay error there (the host's relay failed, or the
|
|
38
|
+
* server's store read threw) is re-sent this many times before the boot
|
|
39
|
+
* reports `EXPIRED_BOOTSTRAP`, each after a uniform random wait between
|
|
40
|
+
* these bounds.
|
|
41
|
+
*/
|
|
42
|
+
export const BOOT_REFRESH_RETRIES = 2;
|
|
43
|
+
export const REFRESH_RETRY_DELAY_MIN_MS = 1_000;
|
|
44
|
+
export const REFRESH_RETRY_DELAY_MAX_MS = 3_000;
|
|
45
|
+
const REFRESH_TOOL = "ggui_runtime_refresh_ws_token";
|
|
46
|
+
export function createCredentialController(opts) {
|
|
47
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
48
|
+
const random = opts.random ?? Math.random;
|
|
49
|
+
let current = opts.initial;
|
|
50
|
+
const spent = new Set();
|
|
51
|
+
const accepted = new Set();
|
|
52
|
+
const mayRefresh = (credential) => !spent.has(credential.wsToken) &&
|
|
53
|
+
!(credential.origin === "refreshed" && !accepted.has(credential.wsToken));
|
|
54
|
+
const requestRefresh = async (credential) => {
|
|
55
|
+
let result;
|
|
56
|
+
try {
|
|
57
|
+
result = await opts.callTool(REFRESH_TOOL, { envelope: credential.wsToken });
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return { kind: "relay-error", message: err instanceof Error ? err.message : String(err) };
|
|
61
|
+
}
|
|
62
|
+
return readRefreshResult(credential, result);
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
current: () => current,
|
|
66
|
+
markAccepted(credential) {
|
|
67
|
+
accepted.add(credential.wsToken);
|
|
68
|
+
},
|
|
69
|
+
async onExpired(credential, source, retry) {
|
|
70
|
+
if (credential !== current)
|
|
71
|
+
return { kind: "skipped", reason: "stale-ladder" };
|
|
72
|
+
if (!mayRefresh(credential))
|
|
73
|
+
return { kind: "skipped", reason: "budget" };
|
|
74
|
+
spent.add(credential.wsToken);
|
|
75
|
+
if (source !== "boot")
|
|
76
|
+
await sleep(Math.floor(random() * REFRESH_JITTER_MAX_MS));
|
|
77
|
+
let outcome = await requestRefresh(credential);
|
|
78
|
+
for (let left = retry?.retries ?? 0; left > 0 && outcome.kind === "relay-error"; left--) {
|
|
79
|
+
const spread = REFRESH_RETRY_DELAY_MAX_MS - REFRESH_RETRY_DELAY_MIN_MS;
|
|
80
|
+
await sleep(REFRESH_RETRY_DELAY_MIN_MS + Math.floor(random() * spread));
|
|
81
|
+
outcome = await requestRefresh(credential);
|
|
82
|
+
}
|
|
83
|
+
if (outcome.kind === "adopted") {
|
|
84
|
+
current = outcome.credential;
|
|
85
|
+
opts.onAdopt?.(outcome.credential);
|
|
86
|
+
}
|
|
87
|
+
return outcome;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function readRefreshResult(from, result) {
|
|
92
|
+
if (isErrorToolResult(result)) {
|
|
93
|
+
// A thrown server error reaches the view as an error result whose text is
|
|
94
|
+
// a domain error, exactly as the pull's not-found does.
|
|
95
|
+
const text = toolResultText(result) ?? "";
|
|
96
|
+
if (domainErrorCodeOf(result) === "session_not_found")
|
|
97
|
+
return { kind: "not-found" };
|
|
98
|
+
return {
|
|
99
|
+
kind: "relay-error",
|
|
100
|
+
message: text.length > 0 ? text : "refresh answered an error with no text",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const payload = unwrapCallToolResult(result);
|
|
104
|
+
if (payload !== null) {
|
|
105
|
+
const ok = payload["ok"];
|
|
106
|
+
const envelope = payload["envelope"];
|
|
107
|
+
const expiresAt = payload["expiresAt"];
|
|
108
|
+
if (ok === true &&
|
|
109
|
+
typeof envelope === "string" &&
|
|
110
|
+
envelope.length > 0 &&
|
|
111
|
+
typeof expiresAt === "string") {
|
|
112
|
+
return { kind: "adopted", credential: adopt(from, envelope, expiresAt) };
|
|
113
|
+
}
|
|
114
|
+
const code = payload["code"];
|
|
115
|
+
if (ok === false && typeof code === "string")
|
|
116
|
+
return { kind: "refused", code };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
kind: "relay-error",
|
|
120
|
+
message: "refresh answered a result this runtime does not recognise",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** The refreshed credential: the new token, its expiry, and each token-bearing URL re-derived for it. */
|
|
124
|
+
function adopt(from, wsToken, expiresAt) {
|
|
125
|
+
const sseUrl = from.sseUrl !== undefined ? withWsToken(from.sseUrl, wsToken) : undefined;
|
|
126
|
+
const pollingUrl = from.pollingUrl !== undefined ? withWsToken(from.pollingUrl, wsToken) : undefined;
|
|
127
|
+
return {
|
|
128
|
+
wsToken,
|
|
129
|
+
wsUrl: from.wsUrl,
|
|
130
|
+
expiresAt,
|
|
131
|
+
...(sseUrl !== undefined ? { sseUrl } : {}),
|
|
132
|
+
...(pollingUrl !== undefined ? { pollingUrl } : {}),
|
|
133
|
+
origin: "refreshed",
|
|
134
|
+
};
|
|
135
|
+
}
|