@cef-ai/widget-runtime 2.2.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Browser runtime for CEF widgets. It boots from `window.WidgetSandbox.manifest`,
4
4
  resolves the user's identity, exposes `window.WidgetRuntime`
5
- (`query` / `publish` / `connect` / `connectAgent` / `agentStatus` / `identity`),
5
+ (`query` / `publish` / `subscribe` / `connect` / `connectAgent` / `agentStatus` / `identity`),
6
6
  and renders config-driven widget kinds (record / list / dashboard / submit /
7
7
  conversation / composite) plus custom widgets. It also carries the audio
8
8
  capture/upload path. The runtime **reads** a manifest; the `@cef-ai/cli` build
@@ -12,6 +12,28 @@ The esbuild IIFE build emits `dist/widget-runtime.js` (the browser bundle) and
12
12
  `dist/widget.html` (the shell); `tsc` emits the library entry (`dist/index.js`)
13
13
  for consumers that import the helpers directly.
14
14
 
15
+ ## Following a stream
16
+
17
+ `subscribe` polls one stream of the widget's scope (the scope `publish` writes
18
+ to) and hands each poll's new events to the callback as one ordered,
19
+ de-duplicated batch:
20
+
21
+ ```js
22
+ const { requestId } = …;
23
+ await WidgetRuntime.publish('workflow.start', input, requestId, { target });
24
+ const stop = WidgetRuntime.subscribe(
25
+ requestId,
26
+ { types: ['agent.progress'], intervalMs: 2500 },
27
+ (events) => events.forEach(render),
28
+ );
29
+ // later: stop();
30
+ ```
31
+
32
+ It polls every 2500ms by default, backs off (doubling, up to `maxBackoffMs`,
33
+ default 30s) while polls fail and reports each failure to `onError`, and stops
34
+ when the returned function is called. Reading a stream needs `ddc-read` and a
35
+ read grant on the scope, which a widget's delegation carries.
36
+
15
37
  ## Identity (multi-source)
16
38
 
17
39
  The runtime resolves a signing identity from the best available source:
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { buildWidgetManifest, type BuildManifestInput } from './manifest.js';
2
2
  export { injectWidgetBridge, type BridgeInput } from './bridge.js';
3
- export type { WidgetManifest, WidgetRuntimeApi, Identity, QueryResult } from './types.js';
3
+ export type { WidgetManifest, WidgetRuntimeApi, Identity, QueryResult, PublishOptions, SubscribeOptions, WidgetEvent } from './types.js';
4
4
  export { AgentNotConnectedError } from './types.js';
5
5
  export { validateConfig } from './validate-config.js';
6
6
  export { mountKind } from './mount.js';
package/dist/runtime.js CHANGED
@@ -37,7 +37,7 @@ export function createWidgetRuntime(manifest, deps) {
37
37
  }
38
38
  return {
39
39
  async query(ref, params) { return (await ensureClient()).query(ref, params); },
40
- async publish(type, payload, context) { return (await ensureClient()).publish(type, payload, context); },
40
+ async publish(type, payload, context, options) { return (await ensureClient()).publish(type, payload, context, options); },
41
41
  async identity() {
42
42
  await ensureClient();
43
43
  return { publicKey, status, ...(context ? { context } : {}) };
@@ -49,5 +49,28 @@ export function createWidgetRuntime(manifest, deps) {
49
49
  async connectAgent() { await (await ensureClient()).connectAgent(); },
50
50
  async agentStatus() { return (await ensureClient()).agentStatus(); },
51
51
  onIdentityChange(cb) { subs.add(cb); return () => subs.delete(cb); },
52
+ /*
53
+ * SIGN-OUT STOPS IT. The runtime's only way to `anon` is a sign-in that
54
+ * failed — once a client is built it is kept, and nothing upstream says a
55
+ * session ended — so a subscription made before or during that sign-in is
56
+ * the one sign-out can strand, and it ends here, with the reason handed to
57
+ * `onError` so the widget is not left waiting on events that cannot come.
58
+ */
59
+ subscribe(streamContext, opts, onEvents) {
60
+ let cancelled = false;
61
+ let stopClient;
62
+ ensureClient().then((client) => { if (!cancelled)
63
+ stopClient = client.subscribe(streamContext, opts, onEvents); }, (err) => {
64
+ if (cancelled)
65
+ return;
66
+ (opts.onError ?? ((e) => { console.warn('[widget-runtime] subscribe failed:', e); }))(err);
67
+ });
68
+ return () => {
69
+ if (cancelled)
70
+ return;
71
+ cancelled = true;
72
+ stopClient?.();
73
+ };
74
+ },
52
75
  };
53
76
  }
package/dist/types.d.ts CHANGED
@@ -108,9 +108,49 @@ export interface QueryResult {
108
108
  rowCount: number;
109
109
  };
110
110
  }
111
+ export interface PublishOptions {
112
+ /** The one agent this event is for, `<asPubkey>:<name>`. */
113
+ target?: string;
114
+ }
115
+ /** One event from a vault stream, as {@link WidgetRuntimeApi.subscribe} delivers it. */
116
+ export interface WidgetEvent<P = unknown> {
117
+ eventId: string;
118
+ type: string;
119
+ context: string;
120
+ payload: P;
121
+ /** Who published it — `app`, `agent:<id>`, … — when the vault recorded one. */
122
+ from?: string;
123
+ /** When the vault accepted it (RFC 3339). */
124
+ timestamp: string;
125
+ }
126
+ export interface SubscribeOptions {
127
+ /** Deliver only these event types. All types when absent. */
128
+ types?: string[];
129
+ /** Poll interval in ms. Default 2500. */
130
+ intervalMs?: number;
131
+ /**
132
+ * Ceiling for the wait between polls while they keep failing: it doubles
133
+ * from `intervalMs` up to this, and resets on the next success. Default 30000.
134
+ */
135
+ maxBackoffMs?: number;
136
+ /**
137
+ * A failed poll, or a failure to open the vault at all. Polling carries on
138
+ * (backing off) after a failed poll; it stops when the vault cannot be
139
+ * opened. Default: console.warn.
140
+ */
141
+ onError?: (err: unknown) => void;
142
+ }
111
143
  export interface WidgetRuntimeApi {
112
144
  query(ref: string, params?: unknown[]): Promise<QueryResult>;
113
- publish(type: string, payload: unknown, context?: string): Promise<{
145
+ /**
146
+ * Publish an event into the widget's scope.
147
+ *
148
+ * `target` addresses one agent (`<asPubkey>:<name>`). A workflow is started
149
+ * this way: its manifest handles the runner's fixed set (`workflow.start`,
150
+ * …), never the trigger's own event type, so an untargeted `workflow.start`
151
+ * would start every workflow connected in the scope.
152
+ */
153
+ publish(type: string, payload: unknown, context?: string, options?: PublishOptions): Promise<{
114
154
  eventId: string;
115
155
  }>;
116
156
  identity(): Promise<Identity>;
@@ -118,6 +158,20 @@ export interface WidgetRuntimeApi {
118
158
  connectAgent(): Promise<void>;
119
159
  agentStatus(): Promise<'connected' | 'disconnected'>;
120
160
  onIdentityChange(cb: (id: Identity) => void): () => void;
161
+ /**
162
+ * Follow one stream of the widget's scope — the scope `publish` writes to —
163
+ * and get its events as they arrive.
164
+ *
165
+ * `onEvents` receives each poll's new events as one batch, in stream order,
166
+ * each event at most once (de-duplicated by `eventId`); an empty poll calls
167
+ * nothing. Events already in the stream when you subscribe are delivered in
168
+ * the first batch, so subscribing just after a `publish` misses nothing.
169
+ *
170
+ * Returns the unsubscribe function. Polling also stops by itself when the
171
+ * reader's identity drops to `anon`. A failed poll never throws out of the
172
+ * timer: it goes to `onError`, and the next poll waits longer.
173
+ */
174
+ subscribe<P = unknown>(context: string, opts: SubscribeOptions, onEvents: (events: WidgetEvent<P>[]) => void): () => void;
121
175
  }
122
176
  export declare class AgentNotConnectedError extends Error {
123
177
  readonly agentId: string;
@@ -1,4 +1,8 @@
1
- import { type QueryResult, type WidgetManifest } from './types.js';
1
+ import { type QueryResult, type WidgetManifest, type PublishOptions, type SubscribeOptions, type WidgetEvent } from './types.js';
2
+ /** Default poll interval for {@link VaultClient.subscribe}. */
3
+ export declare const SUBSCRIBE_INTERVAL_MS = 2500;
4
+ /** Default ceiling for the error backoff of {@link VaultClient.subscribe}. */
5
+ export declare const SUBSCRIBE_MAX_BACKOFF_MS = 30000;
2
6
  export interface VaultSdkLike {
3
7
  vault: {
4
8
  ensure: (opts?: {
@@ -71,15 +75,7 @@ interface VaultLike {
71
75
  settings?: Record<string, unknown>;
72
76
  }) => Promise<AgentHandleLike>;
73
77
  };
74
- scope: (name: string) => {
75
- publish: (input: {
76
- type: string;
77
- context: string;
78
- payload: unknown;
79
- }) => Promise<{
80
- eventId: string;
81
- }>;
82
- };
78
+ scope: (name: string) => ScopeLike;
83
79
  /**
84
80
  * The agent-SERVICE cubbies: one database per service, global within the
85
81
  * vault, so a widget reads what its agent's siblings wrote wherever the run
@@ -95,6 +91,30 @@ interface VaultLike {
95
91
  */
96
92
  memory?: MemoryLike;
97
93
  }
94
+ interface ScopeLike {
95
+ publish: (input: {
96
+ type: string;
97
+ context: string;
98
+ payload: unknown;
99
+ }) => Promise<{
100
+ eventId: string;
101
+ }>;
102
+ /**
103
+ * vault-sdk's stream poller. Optional on the structural type for the same
104
+ * reason `service` is: an older vault-sdk does not have the backoff option,
105
+ * or (older still) the method.
106
+ */
107
+ subscribe?: (filter: {
108
+ context: string;
109
+ types?: string[];
110
+ }, handler: (event: WidgetEvent) => void, opts: {
111
+ intervalMs?: number;
112
+ maxBackoffMs?: number;
113
+ onError?: (err: unknown) => void;
114
+ }) => {
115
+ unsubscribe(): void;
116
+ };
117
+ }
98
118
  /** The bank's read surface, as much of it as a widget can name. */
99
119
  interface MemoryLike {
100
120
  search: (match: string, opts?: {
@@ -115,11 +135,12 @@ interface AgentHandleLike {
115
135
  }
116
136
  export interface VaultClient {
117
137
  query(ref: string, params?: unknown[]): Promise<QueryResult>;
118
- publish(type: string, payload: unknown, context?: string): Promise<{
138
+ publish(type: string, payload: unknown, context?: string, options?: PublishOptions): Promise<{
119
139
  eventId: string;
120
140
  }>;
121
141
  connectAgent(): Promise<void>;
122
142
  agentStatus(): Promise<'connected' | 'disconnected'>;
143
+ subscribe<P = unknown>(context: string, opts: SubscribeOptions, onEvents: (events: WidgetEvent<P>[]) => void): () => void;
123
144
  }
124
145
  export declare function createVaultClient(manifest: WidgetManifest, makeSdk: () => VaultSdkLike,
125
146
  /**
@@ -1,5 +1,9 @@
1
1
  // packages/widget-runtime/src/vault-client.ts
2
2
  import {} from './types.js';
3
+ /** Default poll interval for {@link VaultClient.subscribe}. */
4
+ export const SUBSCRIBE_INTERVAL_MS = 2500;
5
+ /** Default ceiling for the error backoff of {@link VaultClient.subscribe}. */
6
+ export const SUBSCRIBE_MAX_BACKOFF_MS = 30000;
3
7
  /**
4
8
  * The host named a vault this widget cannot open.
5
9
  *
@@ -218,9 +222,80 @@ hostVaultId) {
218
222
  }
219
223
  return normaliseQueryResult(await queryService(decl ?? { id: ref, label: ref, sql: ref }, params ?? []));
220
224
  },
221
- async publish(type, payload, context) {
225
+ async publish(type, payload, context, options) {
222
226
  const vault = await getVault();
223
- return vault.scope(manifest.scope).publish({ type, context: context ?? '', payload });
227
+ return vault.scope(manifest.scope).publish({
228
+ type,
229
+ context: context ?? '',
230
+ payload,
231
+ ...(options?.target ? { target: options.target } : {}),
232
+ });
233
+ },
234
+ subscribe(context, opts, onEvents) {
235
+ const onError = opts.onError
236
+ ?? ((err) => { console.warn('[widget-runtime] subscribe poll failed:', err); });
237
+ let stopped = false;
238
+ let sub;
239
+ let batch = [];
240
+ let flushTimer;
241
+ /*
242
+ * ONE CALLBACK PER POLL, NOT PER EVENT. vault-sdk hands events over one
243
+ * at a time, synchronously within a poll, and the next poll is a network
244
+ * round-trip away — so a zero-delay timer armed by the first event of a
245
+ * poll fires once that poll's events are all in. A renderer gets the
246
+ * batch it can pace, instead of N re-renders it cannot tell apart.
247
+ */
248
+ const flush = () => {
249
+ flushTimer = undefined;
250
+ const out = batch;
251
+ batch = [];
252
+ if (out.length === 0)
253
+ return;
254
+ try {
255
+ onEvents(out);
256
+ }
257
+ catch (err) {
258
+ onError(err);
259
+ }
260
+ };
261
+ void getVault().then((vault) => {
262
+ if (stopped)
263
+ return;
264
+ const scope = vault.scope(manifest.scope);
265
+ if (typeof scope.subscribe !== 'function') {
266
+ throw new Error('[widget-runtime] this host\'s vault-sdk cannot follow a stream (no scope.subscribe)');
267
+ }
268
+ sub = scope.subscribe({ context, ...(opts.types ? { types: opts.types } : {}) }, (event) => {
269
+ if (stopped)
270
+ return;
271
+ batch.push({
272
+ eventId: event.eventId,
273
+ type: event.type,
274
+ context: event.context,
275
+ payload: event.payload,
276
+ ...(event.from ? { from: event.from } : {}),
277
+ timestamp: event.timestamp,
278
+ });
279
+ flushTimer ?? (flushTimer = setTimeout(flush, 0));
280
+ }, {
281
+ intervalMs: opts.intervalMs ?? SUBSCRIBE_INTERVAL_MS,
282
+ maxBackoffMs: opts.maxBackoffMs ?? SUBSCRIBE_MAX_BACKOFF_MS,
283
+ onError,
284
+ });
285
+ }).catch((err) => {
286
+ // The vault would not open (signed out, unreachable): nothing to poll.
287
+ if (!stopped)
288
+ onError(err);
289
+ });
290
+ return () => {
291
+ if (stopped)
292
+ return;
293
+ stopped = true;
294
+ if (flushTimer !== undefined)
295
+ clearTimeout(flushTimer);
296
+ batch = [];
297
+ sub?.unsubscribe();
298
+ };
224
299
  },
225
300
  async connectAgent() {
226
301
  const vault = await getVault();