@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,312 @@
1
+ import { DEFAULT_CONTEXT_DEBOUNCE_MS } from '@ggui-ai/protocol';
2
+ import { validateContextValue } from './validation.js';
3
+ /**
4
+ * Module-level map keyed by `name` (the camelCase slot key, NOT the
5
+ * PascalCase contextName) tracking the most-recently-emitted value
6
+ * per slot. Used by the reconnect re-emission path so a new mount
7
+ * after WS reopen seeds the LLM context with the last-known values.
8
+ *
9
+ * Exported for unit tests.
10
+ */
11
+ export const contextSlotLastValues = new Map();
12
+ /**
13
+ * Ensure a React Context is registered for `slot.contextName`. Idempotent —
14
+ * an entry already in the registry is REUSED (the LLM's destructured
15
+ * Context reference must remain stable across re-mounts). Only the
16
+ * absent case calls `react.createContext`.
17
+ *
18
+ * The Context value shape is `readonly [value, setter]` — same as
19
+ * `useState`, so the user-component's `useGguiContext` hook reads it
20
+ * via plain `useContext`. The setter stored at registration time is a
21
+ * no-op placeholder — each {@link SingleSlotProvider} mount overwrites
22
+ * it via the Provider's `value` prop.
23
+ *
24
+ * Returns the resolved slot with the live Context reference attached.
25
+ */
26
+ export function ensureContext(registry, reactMod, slot) {
27
+ // The registry is typed as `Context<unknown>` at the boundary
28
+ // (open-ended — different slots have different value types). When
29
+ // we read OR create, the runtime invariant is that every entry is
30
+ // a `Context<readonly [unknown, (next: unknown) => void]>`.
31
+ const existing = registry[slot.contextName];
32
+ let contextRef;
33
+ if (existing !== undefined) {
34
+ contextRef = existing;
35
+ }
36
+ else {
37
+ contextRef = reactMod.createContext([slot.default, () => { }]);
38
+ contextRef.displayName = slot.contextName;
39
+ registry[slot.contextName] = contextRef;
40
+ }
41
+ return {
42
+ name: slot.name,
43
+ contextName: slot.contextName,
44
+ schema: slot.schema,
45
+ debounceMs: slot.debounceMs ?? DEFAULT_CONTEXT_DEBOUNCE_MS,
46
+ default: slot.default,
47
+ contextRef,
48
+ };
49
+ }
50
+ /**
51
+ * Install React Context objects for every entry in `slots` into
52
+ * `registry`. Returns the resolved slot list so callers can mount one
53
+ * SingleSlotProvider per entry. Idempotent — slots whose `contextName`
54
+ * is already in the registry reuse the existing Context.
55
+ */
56
+ export function installContextRegistry(registry, reactMod, slots) {
57
+ const resolved = [];
58
+ for (const slot of slots) {
59
+ resolved.push(ensureContext(registry, reactMod, slot));
60
+ // Pre-seed the shared snapshot map with each slot's default
61
+ // value at boot. Snapshot posts (REPLACE semantics) need every
62
+ // declared slot present from the very first post; if a Provider's
63
+ // initial useEffect happens before another's, the early post would
64
+ // otherwise omit the un-fired slot and the host would track it as
65
+ // missing. Pre-seeding closes that race.
66
+ contextSlotLastValues.set(slot.name, slot.default);
67
+ }
68
+ return resolved;
69
+ }
70
+ /**
71
+ * Build a ContextSpec from the resolved slot list. Used internally by
72
+ * the SingleSlotProvider to validate values before posting
73
+ * ({@link validateContextValue} expects a ContextSpec; we hold
74
+ * resolved slots, so re-derive the spec shape on demand).
75
+ */
76
+ function specFor(slot) {
77
+ return { [slot.name]: { schema: slot.schema } };
78
+ }
79
+ /**
80
+ * Post a context snapshot to BOTH destinations:
81
+ * 1. `ui/update-model-context` to the parent host (claude.ai et al)
82
+ * — the existing path; agent's LLM reads these via
83
+ * `read_widget_context` for in-turn awareness.
84
+ * 2. `tools/call` for `ggui_runtime_sync_context` — the server mirror.
85
+ * Server stores the snapshot on the active StackItem; chat-
86
+ * history rehydrate seeds `contextSlots[i].default` with the
87
+ * snapshotted values, restoring the user's last-known state.
88
+ *
89
+ * Both posts are postMessage to the parent — fire-and-forget,
90
+ * non-blocking, naturally concurrent (queued back-to-back to the
91
+ * postMessage channel; the host receives them in order without
92
+ * blocking either delivery on the other). No explicit `Promise.all`
93
+ * is needed: postMessage isn't a promise-returning API; both calls
94
+ * return synchronously and the channel handles the ordering. This
95
+ * is the right primitive for a snapshot mirror — neither destination
96
+ * needs to wait for the other to ACK.
97
+ *
98
+ * Snapshot replaces an earlier per-slot delta format
99
+ * (`[ggui:context-slot] {slot,value}`). Empirical: claude.ai's host
100
+ * treats each `ui/update-model-context` post as a REPLACE of the
101
+ * widget's tracked context, not a per-slot merge. Server mirror
102
+ * matches: REPLACE-per-(sessionId, stackItemId), last-write-wins.
103
+ * Both destinations therefore stay structurally consistent.
104
+ *
105
+ * Server-mirror skipped when `identity` is undefined — dev/test code
106
+ * paths that don't supply a real bootstrap shouldn't pollute the
107
+ * server's store with synthetic ids.
108
+ */
109
+ function postContextSnapshot(postToParent, snapshot, identity) {
110
+ // Destination 1: host. Always fires.
111
+ const text = `[ggui:context] ${JSON.stringify(snapshot)}`;
112
+ postToParent({
113
+ jsonrpc: '2.0',
114
+ id: Math.floor(Math.random() * 1e9),
115
+ method: 'ui/update-model-context',
116
+ params: {
117
+ content: [
118
+ {
119
+ type: 'text',
120
+ text,
121
+ },
122
+ ],
123
+ },
124
+ });
125
+ // Destination 2: server mirror. Skipped when identity is absent
126
+ // (dev / test paths without a real bootstrap). Both destinations
127
+ // are postMessage — the channel queues these back-to-back, no
128
+ // sequential dependency between them.
129
+ if (identity) {
130
+ postToParent({
131
+ jsonrpc: '2.0',
132
+ id: Math.floor(Math.random() * 1e9),
133
+ method: 'tools/call',
134
+ params: {
135
+ name: 'ggui_runtime_sync_context',
136
+ arguments: {
137
+ sessionId: identity.sessionId,
138
+ appId: identity.appId,
139
+ stackItemId: identity.stackItemId,
140
+ snapshot,
141
+ },
142
+ },
143
+ });
144
+ }
145
+ }
146
+ /**
147
+ * Build a snapshot object from the shared `contextSlotLastValues` map.
148
+ * Each post sees the full current state of every slot the runtime is
149
+ * tracking — including slots whose Providers haven't yet fired their
150
+ * initial `useEffect` (those entries come from the boot-time pre-seed
151
+ * in {@link installContextRegistry}).
152
+ */
153
+ function buildSnapshot() {
154
+ const out = {};
155
+ for (const [slotName, value] of contextSlotLastValues) {
156
+ out[slotName] = value;
157
+ }
158
+ return out;
159
+ }
160
+ /**
161
+ * Build the `<SingleSlotProvider>` React component — owns one slot's
162
+ * useState + observer + Provider wrap. The runtime composes one of
163
+ * these per declared slot (via {@link createContextStateHost}).
164
+ *
165
+ * Built as a factory so the runtime can pass dependencies (`react`,
166
+ * `postToParent`, `consoleWarn`) without re-importing in this module
167
+ * — keeps the file pure and unit-testable.
168
+ *
169
+ * Exported because `__tests__/context-observer.test.ts` mounts it
170
+ * standalone to verify the value→post→Provider cycle.
171
+ */
172
+ export function createSingleSlotProvider(deps) {
173
+ const { react, postToParent, consoleWarn, identity } = deps;
174
+ const SingleSlotProvider = (props) => {
175
+ const { slot, children } = props;
176
+ // Slot-owned state. Re-mounts re-seed from `slot.default` because
177
+ // a fresh useState begins at the seed; cross-mount state survival
178
+ // is NOT a contextSpec promise (the agent owns durable state via
179
+ // propsSpec / streamSpec — contextSpec is an ephemeral
180
+ // client-side reflection).
181
+ const [value, setValue] = react.useState(slot.default);
182
+ // Debounced post-on-change. The timer ref keeps a fluttering
183
+ // value (multiple changes inside the debounce window) coalesced
184
+ // to a single post.
185
+ const timerRef = react.useRef(null);
186
+ react.useEffect(() => {
187
+ if (timerRef.current !== null) {
188
+ clearTimeout(timerRef.current);
189
+ timerRef.current = null;
190
+ }
191
+ const post = () => {
192
+ // Validate against the slot's schema. Per Q4 design lock:
193
+ // dev `console.warn` + silent drop on mismatch.
194
+ const validation = validateContextValue(specFor(slot), slot.name, value);
195
+ if (!validation.valid) {
196
+ if (consoleWarn !== undefined) {
197
+ consoleWarn(`[ggui:context] slot '${slot.name}' value rejected:`, validation.violations);
198
+ }
199
+ return;
200
+ }
201
+ // Write to the shared map first, then post the FULL
202
+ // snapshot. Snapshot semantics give the host a complete +
203
+ // internally-consistent state at every post (claude.ai
204
+ // treats `ui/update-model-context` as REPLACE, so deltas
205
+ // wiped the un-mentioned slots).
206
+ contextSlotLastValues.set(slot.name, value);
207
+ postContextSnapshot(postToParent, buildSnapshot(), identity);
208
+ };
209
+ if (slot.debounceMs <= 0) {
210
+ // Immediate path — tests + step / tab switches use this.
211
+ post();
212
+ }
213
+ else {
214
+ timerRef.current = setTimeout(post, slot.debounceMs);
215
+ }
216
+ return () => {
217
+ if (timerRef.current !== null) {
218
+ clearTimeout(timerRef.current);
219
+ timerRef.current = null;
220
+ }
221
+ };
222
+ },
223
+ // `value` drives. `slot.name`/`slot.debounceMs` are stable across
224
+ // a slot's lifetime; listing them keeps exhaustive-deps quiet.
225
+ [value, slot.name, slot.debounceMs, slot.schema]);
226
+ // Provider passes the LIVE `[value, setValue]` tuple — overrides
227
+ // the placeholder seed registered in `ensureContext`. The user
228
+ // component's `useGguiContext` reads this exact tuple.
229
+ return react.createElement(slot.contextRef.Provider,
230
+ // The runtime invariant: every Context registered under
231
+ // `globalThis.__ggui__.contexts` is `Context<readonly [unknown,
232
+ // setter]>`. The value passed here matches that shape. We
233
+ // construct a tuple via `as const` semantics inline.
234
+ { value: [value, setValue] }, children);
235
+ };
236
+ return SingleSlotProvider;
237
+ }
238
+ /**
239
+ * Build the `ContextStateHost` component — composes
240
+ * {@link createSingleSlotProvider} once per declared slot to nest
241
+ * Providers around the user's component.
242
+ *
243
+ * The composed shape is:
244
+ *
245
+ * ```tsx
246
+ * <SingleSlotProvider slot={slots[0]}>
247
+ * <SingleSlotProvider slot={slots[1]}>
248
+ * {userComponent}
249
+ * </SingleSlotProvider>
250
+ * </SingleSlotProvider>
251
+ * ```
252
+ *
253
+ * Outermost Provider = first declared slot. Order doesn't affect
254
+ * `useGguiContext` lookups (each Context is independent), but it's
255
+ * stable for snapshot debugging.
256
+ *
257
+ * When `slots` is empty, the host renders children unchanged (no
258
+ * extra wrapper).
259
+ */
260
+ export function createContextStateHost(deps) {
261
+ const SingleSlotProvider = createSingleSlotProvider(deps);
262
+ const ContextStateHost = (props) => {
263
+ const { slots, children } = props;
264
+ if (slots.length === 0) {
265
+ // Render children inside a no-op Fragment so the return type is
266
+ // ReactElement uniformly (children is ReactNode).
267
+ return deps.react.createElement(deps.react.Fragment, null, children);
268
+ }
269
+ // Compose right-to-left so slots[0] is the outermost Provider.
270
+ return slots.reduceRight((inner, slot) => deps.react.createElement(SingleSlotProvider, { key: slot.contextName, slot }, inner),
271
+ // Initial accumulator must be a ReactElement so reduceRight's
272
+ // generic resolves; wrap children in a Fragment.
273
+ deps.react.createElement(deps.react.Fragment, null, children));
274
+ };
275
+ return ContextStateHost;
276
+ }
277
+ /**
278
+ * Walk {@link contextSlotLastValues} and re-post each entry to the
279
+ * parent, filtered to slot names that the new mount actually
280
+ * declares. Used by the re-mount path — when a fresh boot completes
281
+ * after the previous mount torn down, re-seed the LLM context with
282
+ * the LAST values from the previous mount.
283
+ *
284
+ * Filter behavior. An earlier version walked the whole map without
285
+ * filtering, which leaked stale values across contracts: a re-mount
286
+ * with a different `contextSpec` (different slot names) re-emitted
287
+ * entries the new contract didn't declare. The current signature takes
288
+ * the active slot names; entries whose key is NOT in the set get
289
+ * dropped from the map AND skipped on emit.
290
+ *
291
+ * Pass `undefined` to skip filtering (kept for tests that exercise the
292
+ * bare module). Runtime call sites pass the active slots so the leak
293
+ * path is closed by construction.
294
+ */
295
+ export function reemitLastContextValues(postToParent, activeSlotNames, identity) {
296
+ if (activeSlotNames !== undefined) {
297
+ // Drain stale keys before re-emitting. Keep the map a faithful
298
+ // reflection of the active mount.
299
+ for (const key of Array.from(contextSlotLastValues.keys())) {
300
+ if (!activeSlotNames.has(key)) {
301
+ contextSlotLastValues.delete(key);
302
+ }
303
+ }
304
+ }
305
+ // Single snapshot post on reattach. An earlier version fired one
306
+ // delta per slot which then got REPLACE-wiped by the next-fired
307
+ // delta on the host side; the host ended up with only the
308
+ // LAST-fired slot. One snapshot delivers all slots atomically.
309
+ if (contextSlotLastValues.size > 0) {
310
+ postContextSnapshot(postToParent, buildSnapshot(), identity);
311
+ }
312
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `loadGadgetRegistry()` — composes the gadget-package registry the
3
+ * iframe runtime exposes to generated component code (GG.8.2 —
4
+ * per-package, not per-hook).
5
+ *
6
+ * The iframe-runtime's boot sequence calls this immediately before
7
+ * `installGlobalRegistry()` to compose the value that lands at
8
+ * `globalThis.__ggui__.gadgets`. The composition is:
9
+ *
10
+ * - STDLIB seed: the whole `@ggui-ai/gadgets` module namespace
11
+ * (pre-loaded so the 7 first-party browser-capability hooks
12
+ * always work), stored under `registry['@ggui-ai/gadgets']`.
13
+ * - PLUS one namespace per operator-registered package from the
14
+ * bootstrap's `gadgets` field — dynamically imported here once per
15
+ * package (target = `bundleUrl` if present else `package`) and
16
+ * stored under the package-name key.
17
+ *
18
+ * Every export the package ships — hooks AND components — is reachable
19
+ * because the whole namespace is stored; the per-package data-URL shim
20
+ * the rewriter emits resolves `__ggui__.gadgets[package][export]`.
21
+ *
22
+ * When a registration carries `bundleSri` alongside `bundleUrl`, the
23
+ * loader routes through a `<link rel="modulepreload" integrity>` gate
24
+ * so the browser refuses execution on hash mismatch with the
25
+ * registry-served bytes. The integrity-less dynamic `import()` path is
26
+ * preserved for refs that don't publish bundles (in-tree packages,
27
+ * hand-authored ggui.json).
28
+ *
29
+ * Failure handling: a package that fails to load (network error,
30
+ * bad bundle, etc.) is logged and skipped — the rest of the registry
31
+ * still installs. The per-package shim's lazy thunks throw a clear
32
+ * "not loaded" error at call/render time instead of silently crashing
33
+ * inside React.
34
+ *
35
+ * STDLIB always wins: an operator-registered package named
36
+ * `@ggui-ai/gadgets` cannot shadow the STDLIB seed (defense-in-depth).
37
+ */
38
+ import type { GadgetPackageRegistry, ModuleNamespace } from './globals.js';
39
+ /**
40
+ * Minimal shape consumed by the loader — mirrors the protocol's
41
+ * `GguiBootstrapMeta.gadgets[*]` entry but typed locally so the loader
42
+ * is testable without dragging the protocol package into unit setups.
43
+ */
44
+ export interface GadgetRegistration {
45
+ /** Bare npm package name — the registry key the loaded module
46
+ * namespace is stored under, and the bare-specifier load source when
47
+ * `bundleUrl` is absent. */
48
+ readonly package: string;
49
+ /** ggui-hosted ESM bundle URL — preferred load source when present. */
50
+ readonly bundleUrl?: string;
51
+ /** `sha384-<base64>` subresource-integrity hash. When present
52
+ * alongside `bundleUrl`, the loader injects a `<link
53
+ * rel="modulepreload" integrity>` to enforce hash-match on the
54
+ * registry-served bytes before the subsequent dynamic `import()`
55
+ * resolves. Absent → fall back to integrity-less dynamic
56
+ * `import()`. */
57
+ readonly bundleSri?: string;
58
+ }
59
+ /**
60
+ * Injectable dynamic-importer — production passes
61
+ * `(target) => import(target)`; tests pass a fake that returns a
62
+ * canned module from a Map. Keeping it injectable lets the unit
63
+ * tests pin the merge / collision / failure semantics without
64
+ * touching the network.
65
+ */
66
+ export type DynamicImporter = (target: string) => Promise<ModuleNamespace>;
67
+ /**
68
+ * Injectable SRI-aware bundle loader. The production path
69
+ * (`defaultIntegrityLoader`) emits a `<link rel="modulepreload"
70
+ * integrity="<sri>" crossorigin="anonymous" href="<url>">` element,
71
+ * waits for the preload to settle (or fail on hash mismatch), then
72
+ * resolves the bundle via a normal dynamic `import(url)` — the
73
+ * `import` call reuses the cached preload bytes, so the integrity
74
+ * gate has already executed by the time the module instantiates.
75
+ *
76
+ * Why preload + import rather than `<script type="module" integrity>`:
77
+ * a plain `<script type="module">` runs the bundle's top-level but
78
+ * its exports are NOT exposed on `globalThis`. The bundle would have
79
+ * to plant exports on a global by side effect — a contract we'd have
80
+ * to invent and document. `link rel="modulepreload"` reuses the same
81
+ * SRI primitive while keeping the import surface a standard module.
82
+ *
83
+ * Import-map integrity (Chrome 127+, draft) is the long-term answer
84
+ * — when it lands universally, we can drop the preload step and
85
+ * declare integrity on the import map. Until then, preload + import
86
+ * is the cross-browser path.
87
+ *
88
+ * Tests inject a fake to assert the emitted element shape without a
89
+ * real DOM round-trip.
90
+ */
91
+ export type IntegrityLoader = (args: {
92
+ readonly url: string;
93
+ readonly integrity: string;
94
+ /** Package name — used only in diagnostic messages. */
95
+ readonly name: string;
96
+ }) => Promise<ModuleNamespace>;
97
+ interface LoaderLogger {
98
+ warn(...args: unknown[]): void;
99
+ }
100
+ /**
101
+ * Compose the runtime `gadgets` registry — package name → that
102
+ * package's loaded module namespace (GG.8.2).
103
+ *
104
+ * @param stdlibModule Pre-imported `@ggui-ai/gadgets` module
105
+ * namespace. Lands at
106
+ * `registry['@ggui-ai/gadgets']` unconditionally.
107
+ * @param registrations Bootstrap's resolved package catalog (one
108
+ * entry per operator-registered 3rd-party
109
+ * package).
110
+ * @param opts.importer Override dynamic-import for tests.
111
+ * @param opts.logger Override logger sink for tests.
112
+ */
113
+ export declare function loadGadgetRegistry(stdlibModule: ModuleNamespace, registrations: readonly GadgetRegistration[], opts?: {
114
+ readonly importer?: DynamicImporter;
115
+ readonly integrityLoader?: IntegrityLoader;
116
+ readonly logger?: LoaderLogger;
117
+ }): Promise<GadgetPackageRegistry>;
118
+ export {};
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `loadGadgetRegistry()` — composes the gadget-package registry the
3
+ * iframe runtime exposes to generated component code (GG.8.2 —
4
+ * per-package, not per-hook).
5
+ *
6
+ * The iframe-runtime's boot sequence calls this immediately before
7
+ * `installGlobalRegistry()` to compose the value that lands at
8
+ * `globalThis.__ggui__.gadgets`. The composition is:
9
+ *
10
+ * - STDLIB seed: the whole `@ggui-ai/gadgets` module namespace
11
+ * (pre-loaded so the 7 first-party browser-capability hooks
12
+ * always work), stored under `registry['@ggui-ai/gadgets']`.
13
+ * - PLUS one namespace per operator-registered package from the
14
+ * bootstrap's `gadgets` field — dynamically imported here once per
15
+ * package (target = `bundleUrl` if present else `package`) and
16
+ * stored under the package-name key.
17
+ *
18
+ * Every export the package ships — hooks AND components — is reachable
19
+ * because the whole namespace is stored; the per-package data-URL shim
20
+ * the rewriter emits resolves `__ggui__.gadgets[package][export]`.
21
+ *
22
+ * When a registration carries `bundleSri` alongside `bundleUrl`, the
23
+ * loader routes through a `<link rel="modulepreload" integrity>` gate
24
+ * so the browser refuses execution on hash mismatch with the
25
+ * registry-served bytes. The integrity-less dynamic `import()` path is
26
+ * preserved for refs that don't publish bundles (in-tree packages,
27
+ * hand-authored ggui.json).
28
+ *
29
+ * Failure handling: a package that fails to load (network error,
30
+ * bad bundle, etc.) is logged and skipped — the rest of the registry
31
+ * still installs. The per-package shim's lazy thunks throw a clear
32
+ * "not loaded" error at call/render time instead of silently crashing
33
+ * inside React.
34
+ *
35
+ * STDLIB always wins: an operator-registered package named
36
+ * `@ggui-ai/gadgets` cannot shadow the STDLIB seed (defense-in-depth).
37
+ */
38
+ const defaultImporter = (target) => import(/* @vite-ignore */ target);
39
+ const defaultIntegrityLoader = ({ url, integrity, name, }) => {
40
+ return new Promise((resolve, reject) => {
41
+ if (typeof document === 'undefined') {
42
+ reject(new Error(`[gadgets] SRI loader requires a document; cannot load '${name}' from '${url}' in this runtime.`));
43
+ return;
44
+ }
45
+ const link = document.createElement('link');
46
+ link.rel = 'modulepreload';
47
+ link.href = url;
48
+ link.integrity = integrity;
49
+ // crossorigin is REQUIRED for SRI on cross-origin resources (the
50
+ // browser otherwise can't read the response body to hash it).
51
+ // Same-origin requests ignore the attribute, so setting it
52
+ // unconditionally is safe.
53
+ link.crossOrigin = 'anonymous';
54
+ link.addEventListener('load', () => {
55
+ // Preload succeeded (network + integrity both passed). Now
56
+ // import the module by URL — the browser dedups to the
57
+ // already-verified bytes. Any subsequent dynamic-import for
58
+ // the same URL also hits the cache.
59
+ import(/* @vite-ignore */ url).then((mod) => resolve(mod), (err) => reject(err instanceof Error
60
+ ? err
61
+ : new Error(`[gadgets] post-preload import failed for '${name}' from '${url}'`)));
62
+ }, { once: true });
63
+ link.addEventListener('error', () => {
64
+ reject(new Error(`[gadgets] integrity-load failed for '${name}' from '${url}' (likely SRI mismatch or network failure)`));
65
+ }, { once: true });
66
+ document.head.appendChild(link);
67
+ });
68
+ };
69
+ /**
70
+ * Compose the runtime `gadgets` registry — package name → that
71
+ * package's loaded module namespace (GG.8.2).
72
+ *
73
+ * @param stdlibModule Pre-imported `@ggui-ai/gadgets` module
74
+ * namespace. Lands at
75
+ * `registry['@ggui-ai/gadgets']` unconditionally.
76
+ * @param registrations Bootstrap's resolved package catalog (one
77
+ * entry per operator-registered 3rd-party
78
+ * package).
79
+ * @param opts.importer Override dynamic-import for tests.
80
+ * @param opts.logger Override logger sink for tests.
81
+ */
82
+ export async function loadGadgetRegistry(stdlibModule, registrations, opts = {}) {
83
+ const importer = opts.importer ?? defaultImporter;
84
+ const integrityLoader = opts.integrityLoader ?? defaultIntegrityLoader;
85
+ const logger = opts.logger ?? console;
86
+ // Seed with the STDLIB package namespace under its package key. A
87
+ // plain mutable record we populate; the return narrows back to the
88
+ // readonly `GadgetPackageRegistry` shape.
89
+ const registry = {
90
+ '@ggui-ai/gadgets': stdlibModule,
91
+ };
92
+ for (const entry of registrations) {
93
+ if (entry.package in registry) {
94
+ // STDLIB wins; a later duplicate registration for an
95
+ // already-loaded package is also skipped (first wins).
96
+ continue;
97
+ }
98
+ const target = entry.bundleUrl ?? entry.package;
99
+ if (target.length === 0) {
100
+ logger.warn(`[gadgets] package '${entry.package}' has an empty load target — skipping.`);
101
+ continue;
102
+ }
103
+ // Registry-published bundles carry an SRI hash. Route through the
104
+ // `<link rel="modulepreload" integrity>` gate so the browser
105
+ // refuses execution on hash mismatch. Falls back to dynamic
106
+ // `import()` when SRI is absent (in-tree packages, hand-authored
107
+ // ggui.json refs).
108
+ //
109
+ // Local hoist to thread the narrowed `bundleUrl` + `bundleSri`
110
+ // through the loader call without re-narrowing inside the ternary
111
+ // (the loader signature wants concrete strings, not unions).
112
+ const sriUrl = entry.bundleUrl !== undefined &&
113
+ typeof entry.bundleSri === 'string' &&
114
+ entry.bundleSri.length > 0
115
+ ? { url: entry.bundleUrl, integrity: entry.bundleSri }
116
+ : undefined;
117
+ try {
118
+ const mod = sriUrl !== undefined
119
+ ? await integrityLoader({
120
+ url: sriUrl.url,
121
+ integrity: sriUrl.integrity,
122
+ name: entry.package,
123
+ })
124
+ : await importer(target);
125
+ // Store the whole module namespace under the package key — both
126
+ // hook and component exports become reachable through it.
127
+ registry[entry.package] = mod;
128
+ }
129
+ catch (err) {
130
+ logger.warn(`[gadgets] Failed to load package '${entry.package}' from '${target}':`, err);
131
+ // Continue with other registrations — one broken package must
132
+ // not block the whole iframe from booting.
133
+ }
134
+ }
135
+ return registry;
136
+ }