@adia-ai/a2ui-runtime 0.3.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/stream.js ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * A2UI Stream Adapters — connect to various transports and yield A2UI messages.
3
+ *
4
+ * Each adapter returns an AsyncIterable<A2UIMessage>.
5
+ *
6
+ * Usage:
7
+ * const stream = sseStream('/api/agent');
8
+ * for await (const message of stream) { renderer.process(message); }
9
+ */
10
+
11
+ /**
12
+ * SSE (Server-Sent Events) stream adapter.
13
+ */
14
+ export function sseStream(url, options = {}) {
15
+ let finalUrl = url;
16
+ if (options.catalog) {
17
+ const types = options.catalog instanceof Map ? [...options.catalog.keys()] : Object.keys(options.catalog);
18
+ const sep = url.includes('?') ? '&' : '?';
19
+ finalUrl = `${url}${sep}a2ui_catalog=${encodeURIComponent(types.join(','))}`;
20
+ }
21
+
22
+ return {
23
+ [Symbol.asyncIterator]() {
24
+ const eventSource = new EventSource(finalUrl);
25
+ const queue = [];
26
+ let resolve = null;
27
+ let done = false;
28
+
29
+ eventSource.onmessage = (e) => {
30
+ try {
31
+ const message = JSON.parse(e.data);
32
+ if (resolve) { const r = resolve; resolve = null; r({ value: message, done: false }); }
33
+ else queue.push(message);
34
+ } catch { console.warn('A2UI SSE: invalid JSON', e.data); }
35
+ };
36
+
37
+ eventSource.onerror = () => {
38
+ done = true;
39
+ eventSource.close();
40
+ if (resolve) { const r = resolve; resolve = null; r({ value: undefined, done: true }); }
41
+ };
42
+
43
+ if (options.signal) {
44
+ options.signal.addEventListener('abort', () => {
45
+ done = true;
46
+ eventSource.close();
47
+ if (resolve) { const r = resolve; resolve = null; r({ value: undefined, done: true }); }
48
+ });
49
+ }
50
+
51
+ return {
52
+ next() {
53
+ if (queue.length > 0) return Promise.resolve({ value: queue.shift(), done: false });
54
+ if (done) return Promise.resolve({ value: undefined, done: true });
55
+ return new Promise(r => { resolve = r; });
56
+ },
57
+ return() {
58
+ done = true;
59
+ eventSource.close();
60
+ return Promise.resolve({ value: undefined, done: true });
61
+ },
62
+ };
63
+ },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * WebSocket stream adapter.
69
+ */
70
+ export function wsStream(url, options = {}) {
71
+ return {
72
+ [Symbol.asyncIterator]() {
73
+ const ws = new WebSocket(url);
74
+ const queue = [];
75
+ let resolve = null;
76
+ let done = false;
77
+
78
+ ws.onopen = () => {
79
+ if (options.catalog) {
80
+ const types = options.catalog instanceof Map ? [...options.catalog.keys()] : Object.keys(options.catalog);
81
+ ws.send(JSON.stringify({ type: 'a2ui:catalog', supportedTypes: types }));
82
+ }
83
+ };
84
+
85
+ ws.onmessage = (e) => {
86
+ try {
87
+ const message = JSON.parse(e.data);
88
+ if (resolve) { const r = resolve; resolve = null; r({ value: message, done: false }); }
89
+ else queue.push(message);
90
+ } catch { console.warn('A2UI WS: invalid JSON', e.data); }
91
+ };
92
+
93
+ ws.onclose = () => {
94
+ done = true;
95
+ if (resolve) { const r = resolve; resolve = null; r({ value: undefined, done: true }); }
96
+ };
97
+
98
+ ws.onerror = () => { done = true; ws.close(); };
99
+
100
+ return {
101
+ next() {
102
+ if (queue.length > 0) return Promise.resolve({ value: queue.shift(), done: false });
103
+ if (done) return Promise.resolve({ value: undefined, done: true });
104
+ return new Promise(r => { resolve = r; });
105
+ },
106
+ return() { done = true; ws.close(); return Promise.resolve({ value: undefined, done: true }); },
107
+ };
108
+ },
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Array/mock stream adapter — for testing.
114
+ */
115
+ export function mockStream(messages, delay = 0) {
116
+ return {
117
+ async *[Symbol.asyncIterator]() {
118
+ for (const msg of messages) {
119
+ if (delay > 0) await new Promise(r => setTimeout(r, delay));
120
+ yield msg;
121
+ }
122
+ },
123
+ };
124
+ }
125
+
126
+ /**
127
+ * MCP stream adapter — connects to MCP server, yields A2UI messages.
128
+ */
129
+ export function mcpStream(url, options = {}) {
130
+ return {
131
+ async *[Symbol.asyncIterator]() {
132
+ const { signal, catalog, onAction } = options;
133
+
134
+ const ws = new WebSocket(url);
135
+ await new Promise((resolve, reject) => {
136
+ ws.onopen = resolve;
137
+ ws.onerror = reject;
138
+ if (signal) signal.addEventListener('abort', () => {
139
+ ws.close();
140
+ reject(new DOMException('Aborted', 'AbortError'));
141
+ }, { once: true });
142
+ });
143
+
144
+ if (catalog) {
145
+ const types = catalog instanceof Map ? [...catalog.keys()] : Object.keys(catalog);
146
+ ws.send(JSON.stringify({
147
+ jsonrpc: '2.0',
148
+ method: 'a2ui/catalog',
149
+ params: { supportedTypes: types },
150
+ }));
151
+ }
152
+
153
+ const queue = [];
154
+ let resolve = null;
155
+ let done = false;
156
+
157
+ ws.onmessage = (e) => {
158
+ try {
159
+ const rpc = JSON.parse(e.data);
160
+
161
+ if (rpc.result?.content) {
162
+ for (const block of rpc.result.content) {
163
+ if (block.type === 'resource' && block.resource?.mimeType === 'application/json+a2ui') {
164
+ const messages = JSON.parse(block.resource.text);
165
+ for (const msg of (Array.isArray(messages) ? messages : [messages])) {
166
+ if (resolve) { const r = resolve; resolve = null; r({ value: msg, done: false }); }
167
+ else queue.push(msg);
168
+ }
169
+ } else if (block.type === 'text') {
170
+ try {
171
+ const msg = JSON.parse(block.text);
172
+ if (msg.type && (msg.type.startsWith('create') || msg.type.startsWith('update') || msg.type.startsWith('delete'))) {
173
+ if (resolve) { const r = resolve; resolve = null; r({ value: msg, done: false }); }
174
+ else queue.push(msg);
175
+ }
176
+ } catch { /* not A2UI */ }
177
+ }
178
+ }
179
+ }
180
+
181
+ if (rpc.method === 'a2ui/action' && onAction) {
182
+ onAction(rpc.params?.name, rpc.params?.arguments).then(result => {
183
+ ws.send(JSON.stringify({ jsonrpc: '2.0', id: rpc.id, result }));
184
+ }).catch(err => {
185
+ ws.send(JSON.stringify({ jsonrpc: '2.0', id: rpc.id, error: { code: -1, message: err.message } }));
186
+ });
187
+ }
188
+ } catch { console.warn('A2UI MCP: invalid message', e.data); }
189
+ };
190
+
191
+ ws.onclose = () => { done = true; if (resolve) { const r = resolve; resolve = null; r({ value: undefined, done: true }); } };
192
+ ws.onerror = () => { done = true; ws.close(); };
193
+ if (signal) signal.addEventListener('abort', () => { done = true; ws.close(); if (resolve) { const r = resolve; resolve = null; r({ value: undefined, done: true }); } });
194
+
195
+ while (!done || queue.length > 0) {
196
+ if (queue.length > 0) { yield queue.shift(); }
197
+ else if (done) { break; }
198
+ else {
199
+ const v = await new Promise(r => { resolve = r; });
200
+ if (v.done) break;
201
+ yield v.value;
202
+ }
203
+ }
204
+ },
205
+ };
206
+ }
207
+
208
+ /**
209
+ * JSONL (newline-delimited JSON) stream adapter via fetch.
210
+ */
211
+ export function jsonlStream(url, options = {}) {
212
+ return {
213
+ async *[Symbol.asyncIterator]() {
214
+ const headers = {};
215
+ if (options.catalog) {
216
+ const types = options.catalog instanceof Map ? [...options.catalog.keys()] : Object.keys(options.catalog);
217
+ headers['X-A2UI-Catalog'] = types.join(',');
218
+ }
219
+ const response = await fetch(url, { signal: options.signal, headers });
220
+ const reader = response.body.getReader();
221
+ const decoder = new TextDecoder();
222
+ let buffer = '';
223
+
224
+ while (true) {
225
+ const { done, value } = await reader.read();
226
+ if (done) break;
227
+ buffer += decoder.decode(value, { stream: true });
228
+ const lines = buffer.split('\n');
229
+ buffer = lines.pop();
230
+ for (const line of lines) {
231
+ const trimmed = line.trim();
232
+ if (!trimmed) continue;
233
+ try { yield JSON.parse(trimmed); }
234
+ catch { console.warn('A2UI JSONL: invalid JSON', trimmed); }
235
+ }
236
+ }
237
+ if (buffer.trim()) {
238
+ try { yield JSON.parse(buffer.trim()); }
239
+ catch { /* ignore */ }
240
+ }
241
+ },
242
+ };
243
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Surface Manifest — Multi-surface relationship document (A008).
3
+ *
4
+ * Manages a graph of surfaces and their associations:
5
+ * routes-to, feeds, shares-context, depends-on, triggers, contains, slots-into
6
+ *
7
+ * The manifest is the design-time document that describes application topology.
8
+ * The runtime reads it to set up navigation, pre-fetch data, and manage
9
+ * cross-surface context.
10
+ */
11
+
12
+ /**
13
+ * @typedef {object} SurfaceDescriptor
14
+ * @property {string} name — Human-readable name
15
+ * @property {string} [route] — URL pattern with :param placeholders
16
+ * @property {boolean} [entryPoint] — Can user navigate here directly?
17
+ * @property {string[]} [requiredParams] — Params needed to render
18
+ * @property {Record<string, object>} [produces] — Data this surface outputs
19
+ * @property {Record<string, { keys: string[] }>} [consumes] — Named contexts consumed
20
+ * @property {string} [status] — generated | manual | template | placeholder
21
+ * @property {string} [generatedBy] — Execution ID from gen-ui pipeline
22
+ * @property {string[]} [tags] — Freeform tags
23
+ */
24
+
25
+ /**
26
+ * @typedef {object} Association
27
+ * @property {string} type — routes-to | feeds | shares-context | depends-on | triggers | contains | slots-into
28
+ * @property {string} from — Source surface ID
29
+ * @property {string} to — Target surface ID
30
+ * @property {string} [trigger] — What activates this association
31
+ * @property {Record<string, object>} [params] — Data passed from source to target
32
+ * @property {object} [mapping] — Data flow mapping (for feeds)
33
+ * @property {string} [context] — Shared context name (for shares-context)
34
+ * @property {string} [condition] — Dependency condition (for depends-on)
35
+ * @property {string} [fallback] — Fallback action (for depends-on)
36
+ * @property {string} [effect] — Side effect (for triggers)
37
+ * @property {string} [slot] — Composition slot (for contains/slots-into)
38
+ * @property {number} [position] — Order within slot
39
+ * @property {object} [meta] — Freeform metadata
40
+ */
41
+
42
+ export class SurfaceManifest {
43
+ #id;
44
+ #name;
45
+ #version;
46
+ /** @type {Map<string, SurfaceDescriptor>} */
47
+ #surfaces = new Map();
48
+ /** @type {Association[]} */
49
+ #associations = [];
50
+ /** @type {Map<string, object>} */
51
+ #sharedContexts = new Map();
52
+
53
+ /**
54
+ * @param {object} opts
55
+ * @param {string} opts.id — Manifest ID
56
+ * @param {string} opts.name — Human-readable name
57
+ * @param {string} [opts.version]
58
+ */
59
+ constructor({ id, name, version = '1.0.0' }) {
60
+ this.#id = id;
61
+ this.#name = name;
62
+ this.#version = version;
63
+ }
64
+
65
+ // ── Surface CRUD ──
66
+
67
+ /**
68
+ * Add or update a surface descriptor.
69
+ * @param {string} surfaceId
70
+ * @param {SurfaceDescriptor} descriptor
71
+ */
72
+ addSurface(surfaceId, descriptor) {
73
+ this.#surfaces.set(surfaceId, { ...descriptor });
74
+ }
75
+
76
+ /**
77
+ * Remove a surface and all its associations.
78
+ * @param {string} surfaceId
79
+ */
80
+ removeSurface(surfaceId) {
81
+ this.#surfaces.delete(surfaceId);
82
+ this.#associations = this.#associations.filter(
83
+ a => a.from !== surfaceId && a.to !== surfaceId
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Get a surface descriptor.
89
+ * @param {string} surfaceId
90
+ * @returns {SurfaceDescriptor | null}
91
+ */
92
+ getSurface(surfaceId) {
93
+ return this.#surfaces.get(surfaceId) ?? null;
94
+ }
95
+
96
+ /** @returns {string[]} */
97
+ get surfaceIds() {
98
+ return [...this.#surfaces.keys()];
99
+ }
100
+
101
+ // ── Associations ──
102
+
103
+ /**
104
+ * Add an association between surfaces.
105
+ * @param {Association} association
106
+ */
107
+ addAssociation(association) {
108
+ // Validate surfaces exist
109
+ if (!this.#surfaces.has(association.from)) {
110
+ console.warn(`Manifest: surface "${association.from}" not found`);
111
+ return;
112
+ }
113
+ if (!this.#surfaces.has(association.to)) {
114
+ console.warn(`Manifest: surface "${association.to}" not found`);
115
+ return;
116
+ }
117
+
118
+ // Deduplicate: same type + from + to replaces existing
119
+ this.#associations = this.#associations.filter(
120
+ a => !(a.type === association.type && a.from === association.from && a.to === association.to)
121
+ );
122
+ this.#associations.push({ ...association });
123
+ }
124
+
125
+ /**
126
+ * Get associations for a surface (outgoing).
127
+ * @param {string} surfaceId
128
+ * @param {string} [type] — Filter by association type
129
+ * @returns {Association[]}
130
+ */
131
+ getAssociationsFrom(surfaceId, type) {
132
+ return this.#associations.filter(
133
+ a => a.from === surfaceId && (!type || a.type === type)
134
+ );
135
+ }
136
+
137
+ /**
138
+ * Get associations targeting a surface (incoming).
139
+ * @param {string} surfaceId
140
+ * @param {string} [type]
141
+ * @returns {Association[]}
142
+ */
143
+ getAssociationsTo(surfaceId, type) {
144
+ return this.#associations.filter(
145
+ a => a.to === surfaceId && (!type || a.type === type)
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Get all surfaces sharing a context with the given surface.
151
+ * @param {string} surfaceId
152
+ * @returns {{ surfaceId: string, context: string }[]}
153
+ */
154
+ getSharedContextPeers(surfaceId) {
155
+ const peers = [];
156
+ for (const a of this.#associations) {
157
+ if (a.type !== 'shares-context') continue;
158
+ if (a.from === surfaceId) peers.push({ surfaceId: a.to, context: a.context });
159
+ if (a.to === surfaceId) peers.push({ surfaceId: a.from, context: a.context });
160
+ }
161
+ return peers;
162
+ }
163
+
164
+ // ── Shared Contexts ──
165
+
166
+ /**
167
+ * Define a shared context.
168
+ * @param {string} name
169
+ * @param {object} config — { shape, source, params }
170
+ */
171
+ defineSharedContext(name, config) {
172
+ this.#sharedContexts.set(name, { ...config });
173
+ }
174
+
175
+ /**
176
+ * Get a shared context definition.
177
+ * @param {string} name
178
+ * @returns {object | null}
179
+ */
180
+ getSharedContext(name) {
181
+ return this.#sharedContexts.get(name) ?? null;
182
+ }
183
+
184
+ // ── Validation ──
185
+
186
+ /**
187
+ * Validate the manifest for common issues.
188
+ * @returns {{ valid: boolean, issues: { severity: string, message: string }[] }}
189
+ */
190
+ validate() {
191
+ const issues = [];
192
+
193
+ // Check for orphan surfaces (no associations)
194
+ for (const id of this.#surfaces.keys()) {
195
+ const hasAssoc = this.#associations.some(a => a.from === id || a.to === id);
196
+ if (!hasAssoc && this.#surfaces.size > 1) {
197
+ issues.push({ severity: 'warning', message: `Surface "${id}" has no associations` });
198
+ }
199
+ }
200
+
201
+ // Check for missing entry points
202
+ const entryPoints = [...this.#surfaces.entries()].filter(([, d]) => d.entryPoint);
203
+ if (entryPoints.length === 0 && this.#surfaces.size > 0) {
204
+ issues.push({ severity: 'warning', message: 'No surface marked as entryPoint' });
205
+ }
206
+
207
+ // Check depends-on has fallback
208
+ for (const a of this.#associations) {
209
+ if (a.type === 'depends-on' && !a.fallback) {
210
+ issues.push({ severity: 'warning', message: `depends-on from "${a.from}" to "${a.to}" has no fallback` });
211
+ }
212
+ }
213
+
214
+ // Check shares-context references defined contexts
215
+ for (const a of this.#associations) {
216
+ if (a.type === 'shares-context' && a.context && !this.#sharedContexts.has(a.context)) {
217
+ issues.push({ severity: 'error', message: `Shared context "${a.context}" referenced but not defined` });
218
+ }
219
+ }
220
+
221
+ // Check feeds associations have trigger
222
+ for (const a of this.#associations) {
223
+ if (a.type === 'feeds' && !a.trigger) {
224
+ issues.push({ severity: 'warning', message: `feeds from "${a.from}" to "${a.to}" has no trigger` });
225
+ }
226
+ }
227
+
228
+ // Check routes-to targets have routes
229
+ for (const a of this.#associations) {
230
+ if (a.type === 'routes-to') {
231
+ const target = this.#surfaces.get(a.to);
232
+ if (target && !target.route) {
233
+ issues.push({ severity: 'error', message: `routes-to target "${a.to}" has no route defined` });
234
+ }
235
+ }
236
+ }
237
+
238
+ return {
239
+ valid: !issues.some(i => i.severity === 'error'),
240
+ issues,
241
+ };
242
+ }
243
+
244
+ // ── Serialization ──
245
+
246
+ /**
247
+ * Export as a JSON-serializable object.
248
+ * @returns {object}
249
+ */
250
+ toJSON() {
251
+ return {
252
+ $schema: 'https://a2ui.dev/schema/relationships/v1',
253
+ id: this.#id,
254
+ name: this.#name,
255
+ version: this.#version,
256
+ surfaces: Object.fromEntries(this.#surfaces),
257
+ associations: [...this.#associations],
258
+ sharedContexts: Object.fromEntries(this.#sharedContexts),
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Import from a JSON object.
264
+ * @param {object} json
265
+ * @returns {SurfaceManifest}
266
+ */
267
+ static fromJSON(json) {
268
+ const manifest = new SurfaceManifest({
269
+ id: json.id,
270
+ name: json.name,
271
+ version: json.version,
272
+ });
273
+
274
+ if (json.surfaces) {
275
+ for (const [id, desc] of Object.entries(json.surfaces)) {
276
+ manifest.addSurface(id, desc);
277
+ }
278
+ }
279
+
280
+ if (json.sharedContexts) {
281
+ for (const [name, config] of Object.entries(json.sharedContexts)) {
282
+ manifest.defineSharedContext(name, config);
283
+ }
284
+ }
285
+
286
+ if (json.associations) {
287
+ for (const assoc of json.associations) {
288
+ manifest.addAssociation(assoc);
289
+ }
290
+ }
291
+
292
+ return manifest;
293
+ }
294
+ }