@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.
- package/LICENSE +201 -0
- package/README.md +33 -0
- package/dist/bootstrap.d.ts +72 -0
- package/dist/bootstrap.js +500 -0
- package/dist/canvas/animator/animator-host.d.ts +63 -0
- package/dist/canvas/animator/animator-host.js +105 -0
- package/dist/canvas/animator/animator-pill.d.ts +42 -0
- package/dist/canvas/animator/animator-pill.js +119 -0
- package/dist/canvas/animator/state-machine.d.ts +106 -0
- package/dist/canvas/animator/state-machine.js +150 -0
- package/dist/canvas/canvas-shell.d.ts +77 -0
- package/dist/canvas/canvas-shell.js +117 -0
- package/dist/canvas/display-mode-policy.d.ts +104 -0
- package/dist/canvas/display-mode-policy.js +98 -0
- package/dist/canvas/mount.d.ts +150 -0
- package/dist/canvas/mount.js +202 -0
- package/dist/canvas/nav-stack.d.ts +133 -0
- package/dist/canvas/nav-stack.js +172 -0
- package/dist/channel-transport.d.ts +182 -0
- package/dist/channel-transport.js +344 -0
- package/dist/channels/channel-payload.d.ts +20 -0
- package/dist/channels/channel-payload.js +34 -0
- package/dist/channels/data.d.ts +48 -0
- package/dist/channels/data.js +103 -0
- package/dist/channels/drain-ack.d.ts +13 -0
- package/dist/channels/drain-ack.js +15 -0
- package/dist/channels/feedback.d.ts +14 -0
- package/dist/channels/feedback.js +19 -0
- package/dist/channels/index.d.ts +7 -0
- package/dist/channels/index.js +7 -0
- package/dist/channels/props-update.d.ts +53 -0
- package/dist/channels/props-update.js +130 -0
- package/dist/channels/push.d.ts +47 -0
- package/dist/channels/push.js +54 -0
- package/dist/channels/system.d.ts +26 -0
- package/dist/channels/system.js +44 -0
- package/dist/compiled-validators.d.ts +40 -0
- package/dist/compiled-validators.js +74 -0
- package/dist/context-observer.d.ts +205 -0
- package/dist/context-observer.js +312 -0
- package/dist/gadget-loader.d.ts +118 -0
- package/dist/gadget-loader.js +136 -0
- package/dist/globals.d.ts +234 -0
- package/dist/globals.js +101 -0
- package/dist/host-context-emitter.d.ts +91 -0
- package/dist/host-context-emitter.js +213 -0
- package/dist/iframe-runtime.js +261 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +4 -0
- package/dist/lifecycle.d.ts +66 -0
- package/dist/lifecycle.js +49 -0
- package/dist/mcp-app-iframe-host.d.ts +69 -0
- package/dist/mcp-app-iframe-host.js +214 -0
- package/dist/observability.d.ts +225 -0
- package/dist/observability.js +28 -0
- package/dist/protocol-error.d.ts +199 -0
- package/dist/protocol-error.js +116 -0
- package/dist/provisional-renderer.d.ts +62 -0
- package/dist/provisional-renderer.js +323 -0
- package/dist/react-renderer.d.ts +95 -0
- package/dist/react-renderer.js +343 -0
- package/dist/registry-subscribe.d.ts +102 -0
- package/dist/registry-subscribe.js +319 -0
- package/dist/runtime.d.ts +531 -0
- package/dist/runtime.js +2623 -0
- package/dist/server.d.ts +36 -0
- package/dist/server.js +72 -0
- package/dist/stack-item-renderer.d.ts +103 -0
- package/dist/stack-item-renderer.js +270 -0
- package/dist/stack.d.ts +90 -0
- package/dist/stack.js +118 -0
- package/dist/status-dom.d.ts +72 -0
- package/dist/status-dom.js +51 -0
- package/dist/system-cards/NoCredentialsCard.d.ts +33 -0
- package/dist/system-cards/NoCredentialsCard.js +189 -0
- package/dist/system-cards/ProtocolProbeCard.d.ts +64 -0
- package/dist/system-cards/ProtocolProbeCard.js +463 -0
- package/dist/system-cards/QrCode.d.ts +46 -0
- package/dist/system-cards/QrCode.js +55 -0
- package/dist/system-cards/host-detect.d.ts +31 -0
- package/dist/system-cards/host-detect.js +108 -0
- package/dist/system-cards/host-intents.d.ts +85 -0
- package/dist/system-cards/host-intents.js +170 -0
- package/dist/system-cards/index.d.ts +70 -0
- package/dist/system-cards/index.js +134 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.js +3 -0
- package/dist/validation.d.ts +83 -0
- package/dist/validation.js +106 -0
- package/dist/wire-config.d.ts +214 -0
- package/dist/wire-config.js +290 -0
- 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
|
+
}
|