@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
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiring Registry — Runtime lookup for controllers, action handlers, and data resolvers.
|
|
3
|
+
*
|
|
4
|
+
* Parallel to standardRegistry (type → tag for components), the wiring registry
|
|
5
|
+
* maps controller types → factories, handler names → functions, and URI schemes → resolvers.
|
|
6
|
+
*
|
|
7
|
+
* All entries are lazily loaded — controllers are async factory functions, only imported
|
|
8
|
+
* when a surface actually declares them. A surface that only uses actions never loads
|
|
9
|
+
* any controller module.
|
|
10
|
+
*
|
|
11
|
+
* Extensible: consumers register custom controllers, handlers, and resolvers at startup.
|
|
12
|
+
* A healthcare app registers CheckinController; a CRM registers PipelineController.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ═══════════════════════════════════════════════════════════════
|
|
16
|
+
// REGISTRY
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════
|
|
18
|
+
|
|
19
|
+
export const wiringRegistry = {
|
|
20
|
+
/** Controller type → async factory function */
|
|
21
|
+
controllers: new Map([
|
|
22
|
+
['FormController', async () => (await import('./controllers/form.js')).FormController],
|
|
23
|
+
['DataStreamController', async () => (await import('./controllers/data-stream.js')).DataStreamController],
|
|
24
|
+
['SelectionController', async () => (await import('./controllers/selection.js')).SelectionController],
|
|
25
|
+
['ToggleController', async () => (await import('./controllers/toggle.js')).ToggleController],
|
|
26
|
+
['AccordionController', async () => (await import('./controllers/accordion.js')).AccordionController],
|
|
27
|
+
['StateMachineController', async () => (await import('./controllers/state-machine.js')).StateMachineController],
|
|
28
|
+
]),
|
|
29
|
+
|
|
30
|
+
/** Action handler name → handler function */
|
|
31
|
+
handlers: new Map([
|
|
32
|
+
['submit-resource', handleSubmitResource],
|
|
33
|
+
['update-model', handleUpdateModel],
|
|
34
|
+
['navigate', handleNavigate],
|
|
35
|
+
['navigate-back', handleNavigateBack],
|
|
36
|
+
['controller-command', handleControllerCommand],
|
|
37
|
+
['emit-event', handleEmitEvent],
|
|
38
|
+
['refresh-source', handleRefreshSource],
|
|
39
|
+
['notify', handleNotify],
|
|
40
|
+
]),
|
|
41
|
+
|
|
42
|
+
/** URI scheme → resolver function */
|
|
43
|
+
resolvers: new Map([
|
|
44
|
+
['resource', defaultResourceResolver],
|
|
45
|
+
['api', defaultApiResolver],
|
|
46
|
+
['mock', defaultMockResolver],
|
|
47
|
+
]),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ═══════════════════════════════════════════════════════════════
|
|
51
|
+
// REGISTRATION API
|
|
52
|
+
// ═══════════════════════════════════════════════════════════════
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Register a custom controller type.
|
|
56
|
+
* @param {string} name — Controller type name
|
|
57
|
+
* @param {() => Promise<new (...args: any[]) => any>} factory — Async factory returning the constructor
|
|
58
|
+
*/
|
|
59
|
+
export function registerController(name, factory) {
|
|
60
|
+
wiringRegistry.controllers.set(name, factory);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Register a custom action handler.
|
|
65
|
+
* @param {string} name — Handler name
|
|
66
|
+
* @param {(context: HandlerContext) => Promise<unknown>} fn — Handler function
|
|
67
|
+
*/
|
|
68
|
+
export function registerHandler(name, fn) {
|
|
69
|
+
wiringRegistry.handlers.set(name, fn);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Register a custom URI resolver.
|
|
74
|
+
* @param {string} scheme — URI scheme (e.g., 'resource', 'api', 'custom')
|
|
75
|
+
* @param {(uri: string, params: Record<string, string>) => Promise<unknown>} fn — Resolver function
|
|
76
|
+
*/
|
|
77
|
+
export function registerResolver(scheme, fn) {
|
|
78
|
+
wiringRegistry.resolvers.set(scheme, fn);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ═══════════════════════════════════════════════════════════════
|
|
82
|
+
// RESOLUTION API
|
|
83
|
+
// ═══════════════════════════════════════════════════════════════
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve a controller type to its constructor.
|
|
87
|
+
* @param {string} type — Controller type name
|
|
88
|
+
* @returns {Promise<(new (...args: any[]) => any) | null>}
|
|
89
|
+
*/
|
|
90
|
+
export async function resolveController(type) {
|
|
91
|
+
const factory = wiringRegistry.controllers.get(type);
|
|
92
|
+
if (!factory) return null;
|
|
93
|
+
return factory();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve a handler name to its function.
|
|
98
|
+
* @param {string} name — Handler name
|
|
99
|
+
* @returns {((context: HandlerContext) => Promise<unknown>) | null}
|
|
100
|
+
*/
|
|
101
|
+
export function resolveHandler(name) {
|
|
102
|
+
return wiringRegistry.handlers.get(name) || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve a resource URI to data.
|
|
107
|
+
* @param {string} uri — Resource URI (e.g., 'resource://patients/123')
|
|
108
|
+
* @param {Record<string, string>} [params] — Resolved parameters
|
|
109
|
+
* @returns {Promise<unknown>}
|
|
110
|
+
*/
|
|
111
|
+
export async function resolveData(uri, params = {}) {
|
|
112
|
+
// Interpolate params into URI
|
|
113
|
+
let resolved = uri;
|
|
114
|
+
for (const [key, value] of Object.entries(params)) {
|
|
115
|
+
resolved = resolved.replace(`{${key}}`, encodeURIComponent(value));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Extract scheme
|
|
119
|
+
const schemeEnd = resolved.indexOf('://');
|
|
120
|
+
if (schemeEnd === -1) throw new Error(`Invalid URI: ${uri}`);
|
|
121
|
+
const scheme = resolved.slice(0, schemeEnd);
|
|
122
|
+
|
|
123
|
+
const resolver = wiringRegistry.resolvers.get(scheme);
|
|
124
|
+
if (!resolver) throw new Error(`No resolver for scheme "${scheme}"`);
|
|
125
|
+
|
|
126
|
+
return resolver(resolved, params);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ═══════════════════════════════════════════════════════════════
|
|
130
|
+
// HANDLER CONTEXT (passed to every action handler)
|
|
131
|
+
// ═══════════════════════════════════════════════════════════════
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @typedef {object} HandlerContext
|
|
135
|
+
* @property {Event} event — The DOM event that triggered the action
|
|
136
|
+
* @property {HTMLElement} source — The source element
|
|
137
|
+
* @property {object} action — The action declaration from wireComponents
|
|
138
|
+
* @property {object} dataModel — The surface's data model
|
|
139
|
+
* @property {(path: string, value: unknown) => void} updateModel — Update the data model
|
|
140
|
+
* @property {Record<string, any>} controllers — Map of controllerId → instance
|
|
141
|
+
* @property {Record<string, string>} params — Resolved parameters
|
|
142
|
+
* @property {(uri: string, params?: object) => Promise<unknown>} resolveData — Data resolver
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
// ═══════════════════════════════════════════════════════════════
|
|
146
|
+
// BUILT-IN HANDLERS
|
|
147
|
+
// ═══════════════════════════════════════════════════════════════
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Submit to a resource URI (POST/PUT/PATCH/DELETE).
|
|
151
|
+
*/
|
|
152
|
+
async function handleSubmitResource(ctx) {
|
|
153
|
+
const { action, params } = ctx;
|
|
154
|
+
const body = extractValue(action.body, ctx);
|
|
155
|
+
|
|
156
|
+
let uri = action.uri;
|
|
157
|
+
for (const [key, value] of Object.entries(params)) {
|
|
158
|
+
uri = uri.replace(`{${key}}`, encodeURIComponent(value));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const schemeEnd = uri.indexOf('://');
|
|
162
|
+
const scheme = schemeEnd > -1 ? uri.slice(0, schemeEnd) : 'api';
|
|
163
|
+
const resolver = wiringRegistry.resolvers.get(scheme);
|
|
164
|
+
|
|
165
|
+
// For submit, we need a POST-capable resolver
|
|
166
|
+
// Default: convert resource:// to /api/ REST convention
|
|
167
|
+
const apiPath = uri.replace(/^\w+:\/\//, '/api/');
|
|
168
|
+
const response = await fetch(apiPath, {
|
|
169
|
+
method: action.method || 'POST',
|
|
170
|
+
headers: { 'Content-Type': 'application/json' },
|
|
171
|
+
body: JSON.stringify(body),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
const error = await response.text().catch(() => 'Request failed');
|
|
176
|
+
throw new Error(error);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return response.json().catch(() => ({}));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Update the surface data model at a path.
|
|
184
|
+
*/
|
|
185
|
+
async function handleUpdateModel(ctx) {
|
|
186
|
+
const { action } = ctx;
|
|
187
|
+
const value = extractValue(action.value, ctx);
|
|
188
|
+
ctx.updateModel(action.path, value);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Navigate to a route.
|
|
193
|
+
*/
|
|
194
|
+
async function handleNavigate(ctx) {
|
|
195
|
+
const { action, params } = ctx;
|
|
196
|
+
let path = action.navigate || action.path || '';
|
|
197
|
+
for (const [key, value] of Object.entries(params)) {
|
|
198
|
+
path = path.replace(`{${key}}`, encodeURIComponent(value));
|
|
199
|
+
}
|
|
200
|
+
if (path === 'back') {
|
|
201
|
+
history.back();
|
|
202
|
+
} else if (path) {
|
|
203
|
+
location.hash = path;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Navigate back in browser history.
|
|
209
|
+
*/
|
|
210
|
+
async function handleNavigateBack() {
|
|
211
|
+
history.back();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Invoke a controller command.
|
|
216
|
+
*/
|
|
217
|
+
async function handleControllerCommand(ctx) {
|
|
218
|
+
const { action, controllers } = ctx;
|
|
219
|
+
const controller = controllers[action.controllerId];
|
|
220
|
+
if (!controller?.commands?.[action.command]) {
|
|
221
|
+
console.warn(`Wiring: controller "${action.controllerId}" or command "${action.command}" not found`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
return controller.commands[action.command](action.args);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Re-emit as a named custom event on the surface root.
|
|
229
|
+
*/
|
|
230
|
+
async function handleEmitEvent(ctx) {
|
|
231
|
+
const { action, source } = ctx;
|
|
232
|
+
const detail = extractValue(action.detail, ctx);
|
|
233
|
+
source.dispatchEvent(new CustomEvent(action.eventName || action.event, {
|
|
234
|
+
bubbles: true,
|
|
235
|
+
detail,
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Re-fetch a named data source.
|
|
241
|
+
*/
|
|
242
|
+
async function handleRefreshSource(ctx) {
|
|
243
|
+
const sourceId = ctx.action.sourceId;
|
|
244
|
+
// In the dock system, the surface context has getDockable.
|
|
245
|
+
// From the handler context, we can access it through the adapted ctx.
|
|
246
|
+
// For now, dispatch a custom event that the DataSourceDock can listen for.
|
|
247
|
+
document.dispatchEvent(new CustomEvent('a2ui-refresh-source', {
|
|
248
|
+
detail: { sourceId },
|
|
249
|
+
}));
|
|
250
|
+
return { refreshSource: sourceId };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Show a toast/alert notification.
|
|
255
|
+
*/
|
|
256
|
+
async function handleNotify(ctx) {
|
|
257
|
+
const { action } = ctx;
|
|
258
|
+
const message = typeof action === 'string' ? action : action.message || action.notify;
|
|
259
|
+
// Dispatch a notification event that a toast-ui can listen for
|
|
260
|
+
document.dispatchEvent(new CustomEvent('a2ui-notify', {
|
|
261
|
+
bubbles: true,
|
|
262
|
+
detail: { message, variant: action.variant || 'info' },
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ═══════════════════════════════════════════════════════════════
|
|
267
|
+
// VALUE EXTRACTION
|
|
268
|
+
// ═══════════════════════════════════════════════════════════════
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Extract a value based on a value descriptor.
|
|
272
|
+
* @param {object|undefined} descriptor — { from, key, path, value }
|
|
273
|
+
* @param {HandlerContext} ctx
|
|
274
|
+
* @returns {unknown}
|
|
275
|
+
*/
|
|
276
|
+
function extractValue(descriptor, ctx) {
|
|
277
|
+
if (!descriptor) return undefined;
|
|
278
|
+
if (typeof descriptor !== 'object') return descriptor;
|
|
279
|
+
|
|
280
|
+
switch (descriptor.from) {
|
|
281
|
+
case 'event-detail':
|
|
282
|
+
return descriptor.key ? ctx.event?.detail?.[descriptor.key] : ctx.event?.detail;
|
|
283
|
+
case 'event-target':
|
|
284
|
+
return descriptor.key ? ctx.source?.[descriptor.key] : ctx.source?.value;
|
|
285
|
+
case 'model':
|
|
286
|
+
return getModelValue(ctx.dataModel, descriptor.path);
|
|
287
|
+
case 'literal':
|
|
288
|
+
return descriptor.value;
|
|
289
|
+
case 'param':
|
|
290
|
+
return ctx.params?.[descriptor.key];
|
|
291
|
+
default:
|
|
292
|
+
return descriptor;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get a value from a data model by JSON Pointer path.
|
|
298
|
+
* @param {object} model
|
|
299
|
+
* @param {string} path — JSON Pointer (e.g., "/patient/name")
|
|
300
|
+
* @returns {unknown}
|
|
301
|
+
*/
|
|
302
|
+
function getModelValue(model, path) {
|
|
303
|
+
if (!path || !model) return undefined;
|
|
304
|
+
const segments = path.split('/').filter(Boolean);
|
|
305
|
+
let current = model;
|
|
306
|
+
for (const seg of segments) {
|
|
307
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
308
|
+
current = current[seg];
|
|
309
|
+
}
|
|
310
|
+
return current;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ═══════════════════════════════════════════════════════════════
|
|
314
|
+
// DEFAULT RESOLVERS
|
|
315
|
+
// ═══════════════════════════════════════════════════════════════
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Default resource:// resolver — maps to REST convention: /api/{path}
|
|
319
|
+
*/
|
|
320
|
+
async function defaultResourceResolver(uri, params) {
|
|
321
|
+
const path = uri.replace(/^resource:\/\//, '/api/');
|
|
322
|
+
const response = await fetch(path);
|
|
323
|
+
if (!response.ok) throw new Error(`Resource fetch failed: ${response.status}`);
|
|
324
|
+
return response.json();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Default api:// resolver — direct URL passthrough.
|
|
329
|
+
*/
|
|
330
|
+
async function defaultApiResolver(uri, params) {
|
|
331
|
+
const url = uri.replace(/^api:\/\//, 'https://');
|
|
332
|
+
const response = await fetch(url);
|
|
333
|
+
if (!response.ok) throw new Error(`API fetch failed: ${response.status}`);
|
|
334
|
+
return response.json();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Default mock:// resolver — returns empty object with the URI as _source.
|
|
339
|
+
*/
|
|
340
|
+
async function defaultMockResolver(uri, params) {
|
|
341
|
+
return { _source: uri, _mock: true };
|
|
342
|
+
}
|