@ggui-ai/iframe-runtime 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +2 -2
  2. package/dist/channels/data.d.ts +8 -0
  3. package/dist/channels/data.js +2 -0
  4. package/dist/credential-controller.d.ts +71 -0
  5. package/dist/credential-controller.js +135 -0
  6. package/dist/iframe-runtime.js +97 -93
  7. package/dist/iframe-runtime.meta.json +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/ladders.d.ts +77 -0
  10. package/dist/ladders.js +383 -0
  11. package/dist/meta-parse.js +38 -8
  12. package/dist/observability.d.ts +38 -11
  13. package/dist/react-renderer.d.ts +22 -0
  14. package/dist/react-renderer.js +118 -3
  15. package/dist/registry-subscribe.d.ts +53 -2
  16. package/dist/registry-subscribe.js +63 -8
  17. package/dist/render-failure-report.d.ts +15 -0
  18. package/dist/render-failure-report.js +61 -0
  19. package/dist/render-item.d.ts +6 -1
  20. package/dist/render-item.js +7 -2
  21. package/dist/runtime-telemetry.d.ts +28 -18
  22. package/dist/runtime-telemetry.js +68 -29
  23. package/dist/runtime.d.ts +157 -39
  24. package/dist/runtime.js +724 -368
  25. package/dist/shims/wire.js +1 -1
  26. package/dist/stream-seq.d.ts +56 -0
  27. package/dist/stream-seq.js +46 -0
  28. package/dist/system-cards/ProtocolProbeCard.d.ts +16 -0
  29. package/dist/system-cards/ProtocolProbeCard.js +17 -14
  30. package/dist/tool-result-error.d.ts +6 -0
  31. package/dist/tool-result-error.js +32 -0
  32. package/dist/types.d.ts +49 -1
  33. package/dist/view-origin.d.ts +29 -0
  34. package/dist/view-origin.js +33 -0
  35. package/dist/view-proof-signer.d.ts +31 -0
  36. package/dist/view-proof-signer.js +99 -0
  37. package/dist/view-root.d.ts +51 -0
  38. package/dist/view-root.js +56 -0
  39. package/dist/wire-config.d.ts +15 -1
  40. package/dist/wire-config.js +42 -1
  41. package/package.json +8 -7
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # @ggui-ai/iframe-runtime
2
2
 
3
- The iframe-local runtime for [ggui](https://ggui.ai).
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
- ggui design system inside the artifact so generated component code has a
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
@@ -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>;
@@ -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
+ }