@ggui-ai/iframe-runtime 0.1.0-rc.1

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 (92) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +33 -0
  3. package/dist/bootstrap.d.ts +72 -0
  4. package/dist/bootstrap.js +500 -0
  5. package/dist/canvas/animator/animator-host.d.ts +63 -0
  6. package/dist/canvas/animator/animator-host.js +105 -0
  7. package/dist/canvas/animator/animator-pill.d.ts +42 -0
  8. package/dist/canvas/animator/animator-pill.js +119 -0
  9. package/dist/canvas/animator/state-machine.d.ts +106 -0
  10. package/dist/canvas/animator/state-machine.js +150 -0
  11. package/dist/canvas/canvas-shell.d.ts +77 -0
  12. package/dist/canvas/canvas-shell.js +117 -0
  13. package/dist/canvas/display-mode-policy.d.ts +104 -0
  14. package/dist/canvas/display-mode-policy.js +98 -0
  15. package/dist/canvas/mount.d.ts +150 -0
  16. package/dist/canvas/mount.js +202 -0
  17. package/dist/canvas/nav-stack.d.ts +133 -0
  18. package/dist/canvas/nav-stack.js +172 -0
  19. package/dist/channel-transport.d.ts +182 -0
  20. package/dist/channel-transport.js +344 -0
  21. package/dist/channels/channel-payload.d.ts +20 -0
  22. package/dist/channels/channel-payload.js +34 -0
  23. package/dist/channels/data.d.ts +48 -0
  24. package/dist/channels/data.js +103 -0
  25. package/dist/channels/drain-ack.d.ts +13 -0
  26. package/dist/channels/drain-ack.js +15 -0
  27. package/dist/channels/feedback.d.ts +14 -0
  28. package/dist/channels/feedback.js +19 -0
  29. package/dist/channels/index.d.ts +7 -0
  30. package/dist/channels/index.js +7 -0
  31. package/dist/channels/props-update.d.ts +53 -0
  32. package/dist/channels/props-update.js +130 -0
  33. package/dist/channels/push.d.ts +47 -0
  34. package/dist/channels/push.js +54 -0
  35. package/dist/channels/system.d.ts +26 -0
  36. package/dist/channels/system.js +44 -0
  37. package/dist/compiled-validators.d.ts +40 -0
  38. package/dist/compiled-validators.js +74 -0
  39. package/dist/context-observer.d.ts +205 -0
  40. package/dist/context-observer.js +312 -0
  41. package/dist/gadget-loader.d.ts +118 -0
  42. package/dist/gadget-loader.js +136 -0
  43. package/dist/globals.d.ts +234 -0
  44. package/dist/globals.js +101 -0
  45. package/dist/host-context-emitter.d.ts +91 -0
  46. package/dist/host-context-emitter.js +213 -0
  47. package/dist/iframe-runtime.js +261 -0
  48. package/dist/index.d.ts +38 -0
  49. package/dist/index.js +4 -0
  50. package/dist/lifecycle.d.ts +66 -0
  51. package/dist/lifecycle.js +49 -0
  52. package/dist/mcp-app-iframe-host.d.ts +69 -0
  53. package/dist/mcp-app-iframe-host.js +214 -0
  54. package/dist/observability.d.ts +225 -0
  55. package/dist/observability.js +28 -0
  56. package/dist/protocol-error.d.ts +199 -0
  57. package/dist/protocol-error.js +116 -0
  58. package/dist/provisional-renderer.d.ts +62 -0
  59. package/dist/provisional-renderer.js +323 -0
  60. package/dist/react-renderer.d.ts +95 -0
  61. package/dist/react-renderer.js +343 -0
  62. package/dist/registry-subscribe.d.ts +102 -0
  63. package/dist/registry-subscribe.js +319 -0
  64. package/dist/runtime.d.ts +531 -0
  65. package/dist/runtime.js +2623 -0
  66. package/dist/server.d.ts +36 -0
  67. package/dist/server.js +72 -0
  68. package/dist/stack-item-renderer.d.ts +103 -0
  69. package/dist/stack-item-renderer.js +270 -0
  70. package/dist/stack.d.ts +90 -0
  71. package/dist/stack.js +118 -0
  72. package/dist/status-dom.d.ts +72 -0
  73. package/dist/status-dom.js +51 -0
  74. package/dist/system-cards/NoCredentialsCard.d.ts +33 -0
  75. package/dist/system-cards/NoCredentialsCard.js +189 -0
  76. package/dist/system-cards/ProtocolProbeCard.d.ts +64 -0
  77. package/dist/system-cards/ProtocolProbeCard.js +463 -0
  78. package/dist/system-cards/QrCode.d.ts +46 -0
  79. package/dist/system-cards/QrCode.js +55 -0
  80. package/dist/system-cards/host-detect.d.ts +31 -0
  81. package/dist/system-cards/host-detect.js +108 -0
  82. package/dist/system-cards/host-intents.d.ts +85 -0
  83. package/dist/system-cards/host-intents.js +170 -0
  84. package/dist/system-cards/index.d.ts +70 -0
  85. package/dist/system-cards/index.js +134 -0
  86. package/dist/types.d.ts +69 -0
  87. package/dist/types.js +3 -0
  88. package/dist/validation.d.ts +83 -0
  89. package/dist/validation.js +106 -0
  90. package/dist/wire-config.d.ts +214 -0
  91. package/dist/wire-config.js +290 -0
  92. package/package.json +81 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `props_update` channel handler — factored out of `runtime.ts` into
3
+ * the `@ggui-ai/channel-client` layer.
4
+ *
5
+ * Receives a `{stackItemId, props}` payload from the live channel, validates
6
+ * the new props against the stack item's cached `propsSpec`, patches
7
+ * the in-model entry, and re-applies the renderer so React picks up
8
+ * the change.
9
+ *
10
+ * Skips when:
11
+ * - `stackItemId` is empty or not a string.
12
+ * - `props` is null / not an object (defensive — server can't emit
13
+ * this shape, but the dispatcher routes the frame on type alone).
14
+ * - No matching stack item exists (server raced ahead of our pop).
15
+ * - The matched item is `mcpApps` / `system` (no `propsSpec`; server
16
+ * should never emit `props_update` for these).
17
+ * - The new props fail validation against the cached spec.
18
+ *
19
+ * An OPTIONAL polling descriptor provides a fallback transport —
20
+ * when `polling` is supplied at factory time, `PollingTransport`
21
+ * polls `/api/bootstrap/<shortCode>` and synthesizes a frame
22
+ * whenever `propsJson` diffs from the last seen hash. WS hosts are
23
+ * unaffected (the `polling` descriptor is inert under `WSTransport`).
24
+ */
25
+ import type { ChannelHandler } from '@ggui-ai/channel-client';
26
+ import type { PropsUpdatePayload } from '@ggui-ai/protocol';
27
+ import type { StackRenderer } from '../stack-item-renderer.js';
28
+ import type { StackModel } from '../stack.js';
29
+ export interface PropsUpdateHandlerDeps {
30
+ readonly stackModel: StackModel;
31
+ readonly getStackRenderer: () => StackRenderer;
32
+ /**
33
+ * Optional polling fallback URL. When the registry's chosen transport
34
+ * is `PollingTransport` (e.g. bootstrap missing wsUrl, or
35
+ * `FailoverHandle` swapped WS → polling after a hard failure), this
36
+ * URL is fetched on each tick. The response body is expected to carry
37
+ * `propsJson` + `stackItemId` (the same shape `/api/bootstrap/<code>`
38
+ * returns); the handler diffs `propsJson` between ticks and synthesizes
39
+ * a `props_update` frame whenever it changes. WS hosts ignore this
40
+ * descriptor (polling is inert under `WSTransport`).
41
+ *
42
+ * Producer: server stamps `bootstrap.pollingUrl` on the bootstrap
43
+ * envelope. Consumer: iframe-runtime threads it here at handler
44
+ * registration time. Absent → no polling fallback (WS-only mode).
45
+ */
46
+ readonly pollingUrl?: string;
47
+ /**
48
+ * Polling cadence in milliseconds. Defaults to 2000ms per the B5
49
+ * plan (well inside the 10s drain-claim budget; 5 ticks of safety).
50
+ */
51
+ readonly pollingIntervalMs?: number;
52
+ }
53
+ export declare function createPropsUpdateHandler(deps: PropsUpdateHandlerDeps): ChannelHandler<PropsUpdatePayload>;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `props_update` channel handler — factored out of `runtime.ts` into
3
+ * the `@ggui-ai/channel-client` layer.
4
+ *
5
+ * Receives a `{stackItemId, props}` payload from the live channel, validates
6
+ * the new props against the stack item's cached `propsSpec`, patches
7
+ * the in-model entry, and re-applies the renderer so React picks up
8
+ * the change.
9
+ *
10
+ * Skips when:
11
+ * - `stackItemId` is empty or not a string.
12
+ * - `props` is null / not an object (defensive — server can't emit
13
+ * this shape, but the dispatcher routes the frame on type alone).
14
+ * - No matching stack item exists (server raced ahead of our pop).
15
+ * - The matched item is `mcpApps` / `system` (no `propsSpec`; server
16
+ * should never emit `props_update` for these).
17
+ * - The new props fail validation against the cached spec.
18
+ *
19
+ * An OPTIONAL polling descriptor provides a fallback transport —
20
+ * when `polling` is supplied at factory time, `PollingTransport`
21
+ * polls `/api/bootstrap/<shortCode>` and synthesizes a frame
22
+ * whenever `propsJson` diffs from the last seen hash. WS hosts are
23
+ * unaffected (the `polling` descriptor is inert under `WSTransport`).
24
+ */
25
+ import { validateInboundPropsPayload } from '../validation.js';
26
+ const DEFAULT_POLLING_INTERVAL_MS = 2000;
27
+ /**
28
+ * Tiny FNV-1a 32-bit hash — stable, no protocol dep, ~5 LOC. We only
29
+ * need diff detection (not collision resistance), so the trade-off is
30
+ * fine. Returns a hex string for human-readable logs.
31
+ */
32
+ function fnv1a(input) {
33
+ let hash = 0x811c9dc5;
34
+ for (let i = 0; i < input.length; i += 1) {
35
+ hash ^= input.charCodeAt(i);
36
+ // 32-bit FNV prime multiplication via additive bit-shifts.
37
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
38
+ }
39
+ return hash.toString(16);
40
+ }
41
+ export function createPropsUpdateHandler(deps) {
42
+ const polling = buildPollingDescriptor(deps);
43
+ return {
44
+ type: 'props_update',
45
+ onMessage: async (payload) => {
46
+ const { stackItemId, props } = payload;
47
+ if (typeof stackItemId !== 'string' || stackItemId.length === 0)
48
+ return;
49
+ if (props === null || typeof props !== 'object')
50
+ return;
51
+ const target = deps.stackModel
52
+ .snapshot()
53
+ .find((item) => item.id === stackItemId);
54
+ if (target === undefined)
55
+ return;
56
+ if (target.type === 'mcpApps' || target.type === 'system')
57
+ return;
58
+ const result = validateInboundPropsPayload(target.propsSpec, props);
59
+ if (!result.valid)
60
+ return;
61
+ const nextSnapshot = deps.stackModel
62
+ .snapshot()
63
+ .map((item) => {
64
+ if (item.id !== stackItemId)
65
+ return item;
66
+ if (item.type === 'mcpApps' || item.type === 'system')
67
+ return item;
68
+ return { ...item, props };
69
+ });
70
+ deps.stackModel.setAll(nextSnapshot);
71
+ await deps.getStackRenderer().applyStack(deps.stackModel.snapshot());
72
+ },
73
+ ...(polling !== undefined ? { polling } : {}),
74
+ };
75
+ }
76
+ function buildPollingDescriptor(deps) {
77
+ const { pollingUrl } = deps;
78
+ if (typeof pollingUrl !== 'string' || pollingUrl.length === 0) {
79
+ return undefined;
80
+ }
81
+ const url = pollingUrl;
82
+ // Closure state — last-seen propsJson hash + last-seen stackItemId.
83
+ // Diff detection: emit only on change. First-poll fires (lastSeenHash
84
+ // is null initially) so the iframe gets the current props as its
85
+ // starting state when WS is absent.
86
+ let lastSeenHash = null;
87
+ let lastSeenStackItemId = null;
88
+ return {
89
+ url,
90
+ intervalMs: deps.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS,
91
+ parse: (body) => {
92
+ if (body === null || typeof body !== 'object')
93
+ return null;
94
+ const propsJson = body.propsJson;
95
+ const bootstrapStackItemId = body
96
+ .stackItemId;
97
+ if (typeof propsJson !== 'string' ||
98
+ typeof bootstrapStackItemId !== 'string' ||
99
+ bootstrapStackItemId.length === 0) {
100
+ return null;
101
+ }
102
+ const hash = fnv1a(propsJson);
103
+ if (hash === lastSeenHash &&
104
+ bootstrapStackItemId === lastSeenStackItemId) {
105
+ return null; // Unchanged — skip dispatch.
106
+ }
107
+ lastSeenHash = hash;
108
+ lastSeenStackItemId = bootstrapStackItemId;
109
+ let parsedProps;
110
+ try {
111
+ parsedProps = JSON.parse(propsJson);
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ if (parsedProps === null ||
117
+ typeof parsedProps !== 'object' ||
118
+ Array.isArray(parsedProps)) {
119
+ return null;
120
+ }
121
+ // JSON.parse of an object literal yields a JsonObject by
122
+ // construction — JSON only carries JsonValue-compatible
123
+ // structure. Narrow to the protocol's typed shape.
124
+ return {
125
+ stackItemId: bootstrapStackItemId,
126
+ props: parsedProps,
127
+ };
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `push` channel handler — folds an inbound `push` frame into the
3
+ * stack model, re-renders the placeholder DOM, refreshes the status
4
+ * line, then (when triad wiring is present) applies the new stack
5
+ * through the React renderer + activates the channel-transport router
6
+ * against the new top of stack.
7
+ *
8
+ * The placeholder side-effects (stack model upsert + DOM render +
9
+ * status update) are unconditional — they're load-bearing for the
10
+ * C7a-era specs that boot WITHOUT triad wiring (boot.test.ts in
11
+ * particular). Triad-only consumers (production runtime) pass the
12
+ * `getStackRenderer` + `getChannelTransport` thunks; tests that
13
+ * exercise the placeholder path only leave them off.
14
+ *
15
+ * Pre-B3b: the placeholder fold lived in a separate
16
+ * `handleServerMessage` call in `runtime.ts`, fired before triad
17
+ * dispatch. B3b absorbed both into this single handler so the
18
+ * registry-owned WS transport is the sole dispatch site.
19
+ */
20
+ import type { ChannelHandler } from '@ggui-ai/channel-client';
21
+ import type { PushPayload } from '@ggui-ai/protocol';
22
+ import type { ChannelTransportRouter } from '../channel-transport.js';
23
+ import type { StackRenderer } from '../stack-item-renderer.js';
24
+ import type { StackModel } from '../stack.js';
25
+ import { type StatusRefs } from '../status-dom.js';
26
+ export interface PushHandlerDeps {
27
+ readonly stackModel: StackModel;
28
+ /**
29
+ * Status DOM refs the placeholder fold updates. Required — even
30
+ * triad-mode consumers want the status text to reflect the live
31
+ * count for operator-visible boot debugging.
32
+ */
33
+ readonly statusRefs: StatusRefs;
34
+ /**
35
+ * Triad-mode hook — returns the React stack renderer. Absent in
36
+ * placeholder-only mode (tests, and callers that don't mount the
37
+ * React stack).
38
+ */
39
+ readonly getStackRenderer?: () => StackRenderer;
40
+ /**
41
+ * Triad-mode hook — returns the per-channel transport router so the
42
+ * push handler can activate / tear down `source.tool` channels on
43
+ * the new top of stack. Absent in placeholder-only mode.
44
+ */
45
+ readonly getChannelTransport?: () => ChannelTransportRouter;
46
+ }
47
+ export declare function createPushHandler(deps: PushHandlerDeps): ChannelHandler<PushPayload>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `push` channel handler — folds an inbound `push` frame into the
3
+ * stack model, re-renders the placeholder DOM, refreshes the status
4
+ * line, then (when triad wiring is present) applies the new stack
5
+ * through the React renderer + activates the channel-transport router
6
+ * against the new top of stack.
7
+ *
8
+ * The placeholder side-effects (stack model upsert + DOM render +
9
+ * status update) are unconditional — they're load-bearing for the
10
+ * C7a-era specs that boot WITHOUT triad wiring (boot.test.ts in
11
+ * particular). Triad-only consumers (production runtime) pass the
12
+ * `getStackRenderer` + `getChannelTransport` thunks; tests that
13
+ * exercise the placeholder path only leave them off.
14
+ *
15
+ * Pre-B3b: the placeholder fold lived in a separate
16
+ * `handleServerMessage` call in `runtime.ts`, fired before triad
17
+ * dispatch. B3b absorbed both into this single handler so the
18
+ * registry-owned WS transport is the sole dispatch site.
19
+ */
20
+ import { refreshStackDom, setConnectedStatus, } from '../status-dom.js';
21
+ export function createPushHandler(deps) {
22
+ return {
23
+ type: 'push',
24
+ onMessage: async (payload) => {
25
+ // 1. Placeholder fold — unconditional. Mirrors the pre-B3b
26
+ // handleServerMessage path so non-triad consumers see the
27
+ // correct stack count + placeholder DOM.
28
+ deps.stackModel.upsert(payload.stackItem);
29
+ refreshStackDom(deps.statusRefs, deps.stackModel);
30
+ setConnectedStatus(deps.statusRefs, deps.stackModel);
31
+ // 2. Triad-mode work — only fires when the production runtime
32
+ // supplied the renderer + transport thunks.
33
+ const stackRenderer = deps.getStackRenderer?.();
34
+ const channelTransport = deps.getChannelTransport?.();
35
+ if (stackRenderer === undefined)
36
+ return;
37
+ const snapshot = deps.stackModel.snapshot();
38
+ await stackRenderer.applyStack(snapshot);
39
+ if (channelTransport === undefined)
40
+ return;
41
+ const top = snapshot[snapshot.length - 1];
42
+ if (top !== undefined &&
43
+ top.type !== 'mcpApps' &&
44
+ top.type !== 'system') {
45
+ channelTransport.applyStackItem({
46
+ stackItemId: top.id,
47
+ ...(top.streamSpec !== undefined
48
+ ? { streamSpec: top.streamSpec }
49
+ : {}),
50
+ });
51
+ }
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `system` channel handler — emits the `auth-required` observability
3
+ * event when the server signals an OAuth consent flow.
4
+ *
5
+ * Absorbed from `handleObservableMessage` in `runtime.ts`. The
6
+ * handler is now the sole dispatch surface for `system` frames in
7
+ * the iframe runtime; other system actions (e.g.
8
+ * `credential_ready`) have downstream listeners elsewhere and are
9
+ * intentionally skipped at this layer.
10
+ *
11
+ * Skip conditions (all silent — host gets no row, which is the
12
+ * correct behaviour: a system frame the host can't act on shouldn't
13
+ * surface as a spurious consent overlay trigger):
14
+ * - `action !== 'auth_required'` — e.g. `'credential_ready'` is a
15
+ * different downstream signal.
16
+ * - `consentUrl` absent — the host cannot redirect the user without
17
+ * it, so there's nothing actionable to surface.
18
+ * - `onObserve` absent — the observation has nowhere to go.
19
+ */
20
+ import type { ChannelHandler } from '@ggui-ai/channel-client';
21
+ import type { SystemPayload } from '@ggui-ai/protocol';
22
+ import type { ObservabilityEmitter } from '../observability.js';
23
+ export interface SystemHandlerDeps {
24
+ readonly onObserve?: ObservabilityEmitter;
25
+ }
26
+ export declare function createSystemHandler(deps: SystemHandlerDeps): ChannelHandler<SystemPayload>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `system` channel handler — emits the `auth-required` observability
3
+ * event when the server signals an OAuth consent flow.
4
+ *
5
+ * Absorbed from `handleObservableMessage` in `runtime.ts`. The
6
+ * handler is now the sole dispatch surface for `system` frames in
7
+ * the iframe runtime; other system actions (e.g.
8
+ * `credential_ready`) have downstream listeners elsewhere and are
9
+ * intentionally skipped at this layer.
10
+ *
11
+ * Skip conditions (all silent — host gets no row, which is the
12
+ * correct behaviour: a system frame the host can't act on shouldn't
13
+ * surface as a spurious consent overlay trigger):
14
+ * - `action !== 'auth_required'` — e.g. `'credential_ready'` is a
15
+ * different downstream signal.
16
+ * - `consentUrl` absent — the host cannot redirect the user without
17
+ * it, so there's nothing actionable to surface.
18
+ * - `onObserve` absent — the observation has nowhere to go.
19
+ */
20
+ export function createSystemHandler(deps) {
21
+ return {
22
+ type: 'system',
23
+ onMessage: (payload) => {
24
+ if (deps.onObserve === undefined)
25
+ return;
26
+ emitAuthRequiredFromSystemFrame(payload, deps.onObserve);
27
+ },
28
+ };
29
+ }
30
+ function emitAuthRequiredFromSystemFrame(payload, emit) {
31
+ if (payload.action !== 'auth_required')
32
+ return;
33
+ if (typeof payload.consentUrl !== 'string' || payload.consentUrl.length === 0)
34
+ return;
35
+ const event = {
36
+ kind: 'auth-required',
37
+ provider: payload.serviceId,
38
+ authUrl: payload.consentUrl,
39
+ ...(payload.displayName !== undefined ? { displayName: payload.displayName } : {}),
40
+ ...(payload.scopes !== undefined ? { scopes: payload.scopes } : {}),
41
+ ...(payload.message !== undefined ? { message: payload.message } : {}),
42
+ };
43
+ emit(event);
44
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Loader for precompiled, eval-free contract validators.
3
+ *
4
+ * The renderer iframe runs under a strict CSP with no `'unsafe-eval'`,
5
+ * so it cannot call `ajv.compile()` (which builds validators via
6
+ * `new Function`). The server precompiles each contract sub-schema into
7
+ * a standalone, self-contained ESM validator-module source string and
8
+ * ships them on the bootstrap as `compiledValidators`. This module
9
+ * loads each source via a `blob:` dynamic import — governed by
10
+ * `script-src` (which permits `blob:`), not by `unsafe-eval` — and
11
+ * exposes the resulting `ValidateFunction`s as a
12
+ * {@link CompiledValidatorSet}.
13
+ */
14
+ import type { CompiledContractValidators } from '@ggui-ai/protocol/integrations/mcp-apps';
15
+ import type { ValidateFunction } from '@ggui-ai/protocol';
16
+ /**
17
+ * Precompiled validators for the active stack item's contract, keyed to
18
+ * match the four runtime-validated surfaces. `props` is a single
19
+ * validator (the synthesized props wrapper); the other three are keyed
20
+ * by action name / channel name / slot name.
21
+ */
22
+ export interface CompiledValidatorSet {
23
+ readonly props?: ValidateFunction;
24
+ readonly actions: ReadonlyMap<string, ValidateFunction>;
25
+ readonly streams: ReadonlyMap<string, ValidateFunction>;
26
+ readonly context: ReadonlyMap<string, ValidateFunction>;
27
+ }
28
+ /**
29
+ * An empty set — no precompiled validators. Callers fall back to
30
+ * in-iframe `ajv.compile()`, which the CSP may then block; the empty
31
+ * set is the honest "nothing precompiled" state, not a fix on its own.
32
+ */
33
+ export declare const EMPTY_COMPILED_VALIDATOR_SET: CompiledValidatorSet;
34
+ /**
35
+ * Load every precompiled validator on the bootstrap into a
36
+ * {@link CompiledValidatorSet}. Each module loads independently; one
37
+ * failure never blocks the others. Returns the empty set when the
38
+ * bootstrap carries no `compiledValidators`.
39
+ */
40
+ export declare function loadCompiledValidators(meta: CompiledContractValidators | undefined, warn?: (message: string, detail: unknown) => void): Promise<CompiledValidatorSet>;
@@ -0,0 +1,74 @@
1
+ import { loadModule } from '@ggui-ai/design/module-loader';
2
+ /**
3
+ * An empty set — no precompiled validators. Callers fall back to
4
+ * in-iframe `ajv.compile()`, which the CSP may then block; the empty
5
+ * set is the honest "nothing precompiled" state, not a fix on its own.
6
+ */
7
+ export const EMPTY_COMPILED_VALIDATOR_SET = {
8
+ actions: new Map(),
9
+ streams: new Map(),
10
+ context: new Map(),
11
+ };
12
+ /** A precompiled validator module's `default` export is an Ajv
13
+ * standalone validate function. The runtime check is a `typeof`
14
+ * function test; the assertion that it is specifically a
15
+ * `ValidateFunction` is sound because the server emitted it as one. */
16
+ function isValidateFunction(value) {
17
+ return typeof value === 'function';
18
+ }
19
+ /**
20
+ * Load one ESM validator-module source into a `ValidateFunction` via a
21
+ * `blob:` dynamic import. Returns `undefined` on any failure — a
22
+ * missing validator degrades to the in-iframe fallback rather than
23
+ * crashing the boot.
24
+ */
25
+ async function loadOne(source, label, warn) {
26
+ try {
27
+ const mod = await loadModule(source);
28
+ const fn = mod['default'];
29
+ if (!isValidateFunction(fn)) {
30
+ warn?.(`[ggui:validators] ${label}: module has no default-export function`, mod);
31
+ return undefined;
32
+ }
33
+ return fn;
34
+ }
35
+ catch (err) {
36
+ warn?.(`[ggui:validators] ${label}: failed to load precompiled validator`, err);
37
+ return undefined;
38
+ }
39
+ }
40
+ /**
41
+ * Load every precompiled validator on the bootstrap into a
42
+ * {@link CompiledValidatorSet}. Each module loads independently; one
43
+ * failure never blocks the others. Returns the empty set when the
44
+ * bootstrap carries no `compiledValidators`.
45
+ */
46
+ export async function loadCompiledValidators(meta, warn) {
47
+ if (meta === undefined)
48
+ return EMPTY_COMPILED_VALIDATOR_SET;
49
+ const loadGroup = async (group, kind) => {
50
+ const map = new Map();
51
+ if (group === undefined)
52
+ return map;
53
+ await Promise.all(Object.entries(group).map(async ([name, source]) => {
54
+ const fn = await loadOne(source, `${kind}.${name}`, warn);
55
+ if (fn !== undefined)
56
+ map.set(name, fn);
57
+ }));
58
+ return map;
59
+ };
60
+ const [props, actions, streams, context] = await Promise.all([
61
+ meta.props !== undefined
62
+ ? loadOne(meta.props, 'props', warn)
63
+ : Promise.resolve(undefined),
64
+ loadGroup(meta.actions, 'action'),
65
+ loadGroup(meta.streams, 'stream'),
66
+ loadGroup(meta.context, 'context'),
67
+ ]);
68
+ return {
69
+ ...(props !== undefined ? { props } : {}),
70
+ actions,
71
+ streams,
72
+ context,
73
+ };
74
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Runtime owns Provider tree + useState per slot.
3
+ *
4
+ * **Architecture:**
5
+ *
6
+ * 1. At iframe boot, for each slot declared in the bootstrap's
7
+ * `contextSlots`, the runtime synthesizes one
8
+ * `React.createContext([default, () => {}])` and registers it under
9
+ * `globalThis.__ggui__.contexts[contextName]`. The Context value
10
+ * type is `readonly [value, setter]` — same shape `useState`
11
+ * returns — so the wire-package `useGguiContext` hook can read it
12
+ * via plain `useContext`.
13
+ * 2. The runtime mounts the user's component INSIDE a nested tree of
14
+ * `<SingleSlotProvider>`s. Each SingleSlotProvider owns the slot's
15
+ * `useState`, debounces value changes, validates against the slot's
16
+ * `schema`, and posts `ui/update-model-context` to the parent.
17
+ * 3. The Provider value passed downward is `[value, setValue] as const`
18
+ * — the user component reads both via
19
+ * `const [v, setV] = useGguiContext<T>(slotName)` (auto-generated
20
+ * by the boilerplate, one line per declared slot).
21
+ * 4. The last-emitted value per slot lives in a module-level
22
+ * `Map<slotName, value>` so the reconnect path can re-emit after a
23
+ * re-mount.
24
+ *
25
+ * **Why the runtime hoists the Provider tree.** An earlier design had
26
+ * the boilerplate emit `useState` + `<Provider>` wraps INSIDE the user
27
+ * component's return; the runtime mounted observers as SIBLINGS,
28
+ * outside the user component, which meant every observer's
29
+ * `useContext` read the `createContext(default)` argument — never the
30
+ * live Provider value. The fix is to move both useState AND the
31
+ * Provider into the runtime layer; the user component now reads via a
32
+ * plain `useContext`-backed hook (`useGguiContext`).
33
+ *
34
+ * **Idempotency on re-mount.** Context references must stay stable
35
+ * across re-mounts so the LLM's destructured component code keeps
36
+ * working. {@link ensureContext} reuses an existing entry by
37
+ * `contextName` and only creates when absent. The `setter` placeholder
38
+ * stored alongside the default is overwritten at mount time by each
39
+ * SingleSlotProvider's real `setValue`.
40
+ *
41
+ * **JSON safety.** Each post validates the value against the slot's
42
+ * `schema` via {@link validateContextValue}. Mismatches log a dev-only
43
+ * `console.warn` and drop silently in production (per Q4 design lock).
44
+ */
45
+ import type { Context, ReactElement, ReactNode } from 'react';
46
+ import type { JsonSchema } from '@ggui-ai/protocol';
47
+ import type { GguiContextRegistry } from './globals.js';
48
+ /**
49
+ * Wire-shape entry for a single contextSpec slot, mirrored from
50
+ * {@link import('@ggui-ai/protocol/integrations/mcp-apps').GguiBootstrapMeta.contextSlots}.
51
+ */
52
+ export interface ContextSlotInfo {
53
+ readonly name: string;
54
+ readonly contextName: string;
55
+ readonly schema: JsonSchema;
56
+ /** Provider seed value. Always populated by the server — the
57
+ * runtime owns useState per slot, so the seed is load-bearing. */
58
+ readonly default: unknown;
59
+ readonly debounceMs?: number;
60
+ }
61
+ /**
62
+ * Resolved slot metadata after the runtime has synthesized (or reused)
63
+ * the React Context object. Carried into {@link SingleSlotProvider} so
64
+ * the Provider can reuse the same Context reference across renders.
65
+ */
66
+ export interface ResolvedContextSlot {
67
+ readonly name: string;
68
+ readonly contextName: string;
69
+ readonly schema: JsonSchema;
70
+ readonly debounceMs: number;
71
+ readonly default: unknown;
72
+ readonly contextRef: Context<readonly [unknown, (next: unknown) => void]>;
73
+ }
74
+ /**
75
+ * Module-level map keyed by `name` (the camelCase slot key, NOT the
76
+ * PascalCase contextName) tracking the most-recently-emitted value
77
+ * per slot. Used by the reconnect re-emission path so a new mount
78
+ * after WS reopen seeds the LLM context with the last-known values.
79
+ *
80
+ * Exported for unit tests.
81
+ */
82
+ export declare const contextSlotLastValues: Map<string, unknown>;
83
+ /**
84
+ * Ensure a React Context is registered for `slot.contextName`. Idempotent —
85
+ * an entry already in the registry is REUSED (the LLM's destructured
86
+ * Context reference must remain stable across re-mounts). Only the
87
+ * absent case calls `react.createContext`.
88
+ *
89
+ * The Context value shape is `readonly [value, setter]` — same as
90
+ * `useState`, so the user-component's `useGguiContext` hook reads it
91
+ * via plain `useContext`. The setter stored at registration time is a
92
+ * no-op placeholder — each {@link SingleSlotProvider} mount overwrites
93
+ * it via the Provider's `value` prop.
94
+ *
95
+ * Returns the resolved slot with the live Context reference attached.
96
+ */
97
+ export declare function ensureContext(registry: GguiContextRegistry, reactMod: {
98
+ createContext: <T>(defaultValue: T) => Context<T>;
99
+ }, slot: ContextSlotInfo): ResolvedContextSlot;
100
+ /**
101
+ * Install React Context objects for every entry in `slots` into
102
+ * `registry`. Returns the resolved slot list so callers can mount one
103
+ * SingleSlotProvider per entry. Idempotent — slots whose `contextName`
104
+ * is already in the registry reuse the existing Context.
105
+ */
106
+ export declare function installContextRegistry(registry: GguiContextRegistry, reactMod: {
107
+ createContext: <T>(defaultValue: T) => Context<T>;
108
+ }, slots: ReadonlyArray<ContextSlotInfo>): ReadonlyArray<ResolvedContextSlot>;
109
+ /**
110
+ * Identity bundle carried alongside every snapshot post. The runtime
111
+ * captures these from the bootstrap envelope at boot and threads
112
+ * them through `createContextStateHost` so each snapshot's two
113
+ * destinations (host + server) carry the same authoritative session
114
+ * binding. Absent on dev / test code paths that don't have a real
115
+ * bootstrap; the server mirror is skipped in that case.
116
+ */
117
+ export interface ContextPostIdentity {
118
+ readonly sessionId: string;
119
+ readonly appId: string;
120
+ readonly stackItemId: string;
121
+ }
122
+ /**
123
+ * React surface the host factories need. Production callers pass
124
+ * `typeof import('react')` directly; tests pass the same namespace
125
+ * (the real `react` module). Picking specific keys from the React
126
+ * module type lets the factories accept the namespace without
127
+ * hand-rolling structural-compat shims.
128
+ */
129
+ export type ReactCoreForHost = Pick<typeof import('react'), 'createElement' | 'Fragment' | 'useState' | 'useEffect' | 'useRef'>;
130
+ /**
131
+ * Build the `<SingleSlotProvider>` React component — owns one slot's
132
+ * useState + observer + Provider wrap. The runtime composes one of
133
+ * these per declared slot (via {@link createContextStateHost}).
134
+ *
135
+ * Built as a factory so the runtime can pass dependencies (`react`,
136
+ * `postToParent`, `consoleWarn`) without re-importing in this module
137
+ * — keeps the file pure and unit-testable.
138
+ *
139
+ * Exported because `__tests__/context-observer.test.ts` mounts it
140
+ * standalone to verify the value→post→Provider cycle.
141
+ */
142
+ export declare function createSingleSlotProvider(deps: {
143
+ readonly react: ReactCoreForHost;
144
+ readonly postToParent: (envelope: unknown) => void;
145
+ readonly consoleWarn?: (...args: unknown[]) => void;
146
+ /** Identity bundle threaded into every snapshot post — server
147
+ * mirror skipped when undefined. */
148
+ readonly identity?: ContextPostIdentity;
149
+ }): (props: {
150
+ readonly slot: ResolvedContextSlot;
151
+ readonly children?: ReactNode;
152
+ }) => ReactElement;
153
+ /**
154
+ * Build the `ContextStateHost` component — composes
155
+ * {@link createSingleSlotProvider} once per declared slot to nest
156
+ * Providers around the user's component.
157
+ *
158
+ * The composed shape is:
159
+ *
160
+ * ```tsx
161
+ * <SingleSlotProvider slot={slots[0]}>
162
+ * <SingleSlotProvider slot={slots[1]}>
163
+ * {userComponent}
164
+ * </SingleSlotProvider>
165
+ * </SingleSlotProvider>
166
+ * ```
167
+ *
168
+ * Outermost Provider = first declared slot. Order doesn't affect
169
+ * `useGguiContext` lookups (each Context is independent), but it's
170
+ * stable for snapshot debugging.
171
+ *
172
+ * When `slots` is empty, the host renders children unchanged (no
173
+ * extra wrapper).
174
+ */
175
+ export declare function createContextStateHost(deps: {
176
+ readonly react: ReactCoreForHost;
177
+ readonly postToParent: (envelope: unknown) => void;
178
+ readonly consoleWarn?: (...args: unknown[]) => void;
179
+ /** Identity bundle for the server-mirror destination. Threaded
180
+ * unchanged into `createSingleSlotProvider` so every slot's debounced
181
+ * post carries the same authoritative session binding. */
182
+ readonly identity?: ContextPostIdentity;
183
+ }): (props: {
184
+ readonly slots: ReadonlyArray<ResolvedContextSlot>;
185
+ readonly children: ReactNode;
186
+ }) => ReactElement;
187
+ /**
188
+ * Walk {@link contextSlotLastValues} and re-post each entry to the
189
+ * parent, filtered to slot names that the new mount actually
190
+ * declares. Used by the re-mount path — when a fresh boot completes
191
+ * after the previous mount torn down, re-seed the LLM context with
192
+ * the LAST values from the previous mount.
193
+ *
194
+ * Filter behavior. An earlier version walked the whole map without
195
+ * filtering, which leaked stale values across contracts: a re-mount
196
+ * with a different `contextSpec` (different slot names) re-emitted
197
+ * entries the new contract didn't declare. The current signature takes
198
+ * the active slot names; entries whose key is NOT in the set get
199
+ * dropped from the map AND skipped on emit.
200
+ *
201
+ * Pass `undefined` to skip filtering (kept for tests that exercise the
202
+ * bare module). Runtime call sites pass the active slots so the leak
203
+ * path is closed by construction.
204
+ */
205
+ export declare function reemitLastContextValues(postToParent: (envelope: unknown) => void, activeSlotNames?: ReadonlySet<string>, identity?: ContextPostIdentity): void;