@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.
@@ -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
+ }