@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/CHANGELOG.md +215 -0
- package/README.md +87 -0
- package/controllers/accordion.js +73 -0
- package/controllers/base.js +68 -0
- package/controllers/data-stream.js +281 -0
- package/controllers/form.js +81 -0
- package/controllers/index.js +6 -0
- package/controllers/selection.js +82 -0
- package/controllers/state-machine.js +135 -0
- package/controllers/toggle.js +40 -0
- package/dockables/action.js +152 -0
- package/dockables/base.js +30 -0
- package/dockables/controller.js +97 -0
- package/dockables/data-source.js +103 -0
- package/dockables/index.js +6 -0
- package/dockables/lifecycle.js +84 -0
- package/dockables/provider.js +59 -0
- package/index.js +45 -0
- package/package.json +31 -0
- package/registry.js +205 -0
- package/renderer.js +395 -0
- package/stream.js +243 -0
- package/surface-manifest.js +294 -0
- package/surface.js +222 -0
- package/wire-factory.js +134 -0
- package/wiring-engine.js +209 -0
- package/wiring-registry.js +342 -0
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
|
+
}
|