@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/surface.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Surface — A dock host where typed objects connect and disconnect cleanly.
3
+ *
4
+ * A Surface is the runtime representation of a rendered UI region.
5
+ * It holds a data model, resolved params, and a registry of docked objects
6
+ * (controllers, data sources, actions, providers, lifecycle hooks).
7
+ *
8
+ * Dockables attach via dock(surface) and clean up via undock().
9
+ * The Surface doesn't know what dockables do internally — it just manages
10
+ * their lifecycle and provides a shared context.
11
+ */
12
+
13
+ // ── Dock order (lowest docks first, undocks last) ──
14
+ const DOCK_ORDER = { provider: 0, controller: 1, source: 2, action: 3, lifecycle: 4 };
15
+
16
+ /**
17
+ * @typedef {object} DockEntry
18
+ * @property {import('./dockables/base.js').Dockable} dockable
19
+ * @property {Function|null} cleanup — returned from dock()
20
+ */
21
+
22
+ export class Surface {
23
+ /** @type {string} */
24
+ surfaceId;
25
+
26
+ /** @type {Map<string, DockEntry>} keyed by dockable.id */
27
+ #docked = new Map();
28
+
29
+ /** @type {object} reactive-ish data model */
30
+ #model = {};
31
+
32
+ /** @type {object} resolved params (route, store, literal) */
33
+ #params = {};
34
+
35
+ /** @type {Map<string, Set<Function>>} model watchers keyed by path */
36
+ #watchers = new Map();
37
+
38
+ /** @type {HTMLElement} surface root element */
39
+ #rootElement;
40
+
41
+ /** @type {Map<string, HTMLElement>} componentId → DOM element */
42
+ #elements;
43
+
44
+ /**
45
+ * @param {string} surfaceId
46
+ * @param {HTMLElement} rootElement — the surface's root DOM node
47
+ * @param {Map<string, HTMLElement>} elements — componentId → element map
48
+ */
49
+ constructor(surfaceId, rootElement, elements) {
50
+ this.surfaceId = surfaceId;
51
+ this.#rootElement = rootElement;
52
+ this.#elements = elements;
53
+ }
54
+
55
+ // ── Context (passed to dockables) ─────────────────────────
56
+
57
+ /** @returns {SurfaceContext} */
58
+ get context() {
59
+ return {
60
+ surfaceId: this.surfaceId,
61
+ getElement: (id) => this.#elements.get(id) || null,
62
+ getRootElement: () => this.#rootElement,
63
+ getModel: (path) => path ? getPath(this.#model, path) : this.#model,
64
+ setModel: (path, value) => this.#setModel(path, value),
65
+ getDockable: (kind, id) => this.#getDockable(kind, id),
66
+ listDockables: (kind) => this.#listDockables(kind),
67
+ emit: (adiaEvent) => this.#emit(adiaEvent),
68
+ getParam: (key) => this.#params[key],
69
+ watchModel: (path, fn) => this.#watchModel(path, fn),
70
+ };
71
+ }
72
+
73
+ // ── Dock Protocol ─────────────────────────────────────────
74
+
75
+ /**
76
+ * Dock a new object. Calls dockable.dock(context).
77
+ * @param {import('./dockables/base.js').Dockable} dockable
78
+ */
79
+ dock(dockable) {
80
+ // If same id already docked, redock (hot-swap)
81
+ if (this.#docked.has(dockable.id)) {
82
+ this.undock(dockable.id);
83
+ }
84
+
85
+ const cleanup = dockable.dock(this.context);
86
+ this.#docked.set(dockable.id, {
87
+ dockable,
88
+ cleanup: typeof cleanup === 'function' ? cleanup : null,
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Dock multiple objects in dependency order.
94
+ * @param {import('./dockables/base.js').Dockable[]} dockables
95
+ */
96
+ dockAll(dockables) {
97
+ const sorted = [...dockables].sort(
98
+ (a, b) => (DOCK_ORDER[a.kind] ?? 9) - (DOCK_ORDER[b.kind] ?? 9)
99
+ );
100
+ for (const d of sorted) this.dock(d);
101
+ }
102
+
103
+ /**
104
+ * Undock by id. Calls cleanup then dockable.undock().
105
+ * @param {string} id
106
+ */
107
+ undock(id) {
108
+ const entry = this.#docked.get(id);
109
+ if (!entry) return;
110
+ entry.cleanup?.();
111
+ entry.dockable.undock();
112
+ this.#docked.delete(id);
113
+ }
114
+
115
+ /**
116
+ * Undock everything in reverse dependency order.
117
+ */
118
+ undockAll() {
119
+ const entries = [...this.#docked.entries()].sort(
120
+ (a, b) => (DOCK_ORDER[b[1].dockable.kind] ?? 9) - (DOCK_ORDER[a[1].dockable.kind] ?? 9)
121
+ );
122
+ for (const [id] of entries) this.undock(id);
123
+ }
124
+
125
+ /**
126
+ * Undock specific ids.
127
+ * @param {string[]} ids
128
+ */
129
+ undockMany(ids) {
130
+ for (const id of ids) this.undock(id);
131
+ }
132
+
133
+ // ── Model ─────────────────────────────────────────────────
134
+
135
+ /** Set initial model state (before docking). */
136
+ setInitialModel(model) {
137
+ Object.assign(this.#model, model);
138
+ }
139
+
140
+ /** Set resolved params. */
141
+ setParams(params) {
142
+ Object.assign(this.#params, params);
143
+ }
144
+
145
+ /** Update element map (after re-render). */
146
+ updateElements(elements) {
147
+ this.#elements = elements;
148
+ }
149
+
150
+ // ── Private ───────────────────────────────────────────────
151
+
152
+ #setModel(path, value) {
153
+ setPath(this.#model, path, value);
154
+ // Notify watchers for this path and any parent paths
155
+ for (const [watchPath, fns] of this.#watchers) {
156
+ if (path === watchPath || path.startsWith(watchPath + '/')) {
157
+ for (const fn of fns) fn(getPath(this.#model, watchPath));
158
+ }
159
+ }
160
+ }
161
+
162
+ #watchModel(path, fn) {
163
+ if (!this.#watchers.has(path)) this.#watchers.set(path, new Set());
164
+ this.#watchers.get(path).add(fn);
165
+ return () => {
166
+ const set = this.#watchers.get(path);
167
+ set?.delete(fn);
168
+ if (set?.size === 0) this.#watchers.delete(path);
169
+ };
170
+ }
171
+
172
+ #getDockable(kind, id) {
173
+ const entry = this.#docked.get(id);
174
+ if (entry && entry.dockable.kind === kind) return entry.dockable;
175
+ return null;
176
+ }
177
+
178
+ #listDockables(kind) {
179
+ const result = [];
180
+ for (const { dockable } of this.#docked.values()) {
181
+ if (!kind || dockable.kind === kind) result.push(dockable);
182
+ }
183
+ return result;
184
+ }
185
+
186
+ #emit(adiaEvent) {
187
+ const target = adiaEvent.target
188
+ ? this.#elements.get(adiaEvent.target)
189
+ : this.#rootElement;
190
+ if (!target) return;
191
+
192
+ target.dispatchEvent(new CustomEvent(adiaEvent.event, {
193
+ bubbles: true,
194
+ detail: adiaEvent,
195
+ }));
196
+ }
197
+ }
198
+
199
+ // ── JSON Pointer helpers (simplified, "/" delimited) ────────
200
+
201
+ function getPath(obj, path) {
202
+ if (!path || path === '/') return obj;
203
+ const keys = path.replace(/^\//, '').split('/');
204
+ let current = obj;
205
+ for (const key of keys) {
206
+ if (current == null) return undefined;
207
+ current = current[key];
208
+ }
209
+ return current;
210
+ }
211
+
212
+ function setPath(obj, path, value) {
213
+ if (!path || path === '/') return;
214
+ const keys = path.replace(/^\//, '').split('/');
215
+ let current = obj;
216
+ for (let i = 0; i < keys.length - 1; i++) {
217
+ const key = keys[i];
218
+ if (current[key] == null) current[key] = {};
219
+ current = current[key];
220
+ }
221
+ current[keys[keys.length - 1]] = value;
222
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Wire Factory — Translates wireComponents messages into typed Dockable objects.
3
+ *
4
+ * This is the bridge between the LLM's declarative JSON and the runtime dock system.
5
+ * Each section of the wireComponents message maps to a Dockable type:
6
+ *
7
+ * data.sources → DataSourceDock[]
8
+ * state.controllers → ControllerDock[]
9
+ * actions → ActionDock[]
10
+ * provides → ProviderDock
11
+ * lifecycle → LifecycleDock
12
+ * state.model → set directly on Surface
13
+ */
14
+ import { ControllerDock } from './dockables/controller.js';
15
+ import { DataSourceDock } from './dockables/data-source.js';
16
+ import { ActionDock } from './dockables/action.js';
17
+ import { ProviderDock } from './dockables/provider.js';
18
+ import { LifecycleDock } from './dockables/lifecycle.js';
19
+ import { resolveController, resolveData, resolveHandler } from './wiring-registry.js';
20
+
21
+ /**
22
+ * Adapt a registry handler (old ctx shape) to the dock signature (config, surfaceCtx, domEvent).
23
+ * The old handlers read from ctx.action.*, ctx.event, ctx.source, ctx.dataModel, ctx.updateModel, etc.
24
+ * The new signature passes (config, surfaceCtx, domEventOrResult).
25
+ */
26
+ function adaptHandler(handlerFn) {
27
+ return async (config, surfaceCtx, domEventOrResult) => {
28
+ // Build a backward-compatible HandlerContext from the new args
29
+ const ctx = {
30
+ // Config props spread as "action" (old handlers read ctx.action.path, ctx.action.uri, etc.)
31
+ action: config,
32
+ // DOM event if present
33
+ event: domEventOrResult instanceof Event ? domEventOrResult : null,
34
+ source: domEventOrResult instanceof Event ? domEventOrResult.target : null,
35
+ // Model access
36
+ dataModel: surfaceCtx.getModel(),
37
+ updateModel: (path, value) => surfaceCtx.setModel(path, value),
38
+ // Controllers lookup
39
+ controllers: Object.fromEntries(
40
+ surfaceCtx.listDockables('controller').map(d => [d.id, d.controller])
41
+ ),
42
+ // Params
43
+ params: {},
44
+ // Data resolver
45
+ resolveData,
46
+ };
47
+ return handlerFn(ctx);
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Create a handler resolver function that looks up from the registry
53
+ * and wraps with the adapter.
54
+ * @returns {(name: string) => Function|null}
55
+ */
56
+ function createHandlerResolver() {
57
+ const cache = new Map();
58
+ return (name) => {
59
+ if (cache.has(name)) return cache.get(name);
60
+ const raw = resolveHandler(name);
61
+ if (!raw) return null;
62
+ const adapted = adaptHandler(raw);
63
+ cache.set(name, adapted);
64
+ return adapted;
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Create a controller class resolver (async, lazy-loaded).
70
+ * @returns {(type: string) => Promise<Function|null>}
71
+ */
72
+ function createControllerResolver() {
73
+ return async (type) => resolveController(type);
74
+ }
75
+
76
+ /**
77
+ * Create a data resolver that uses the wiring registry.
78
+ * @returns {(uri: string, ctx: object) => Promise<*>}
79
+ */
80
+ function createDataResolver() {
81
+ return async (uri, ctx) => resolveData(uri, {});
82
+ }
83
+
84
+ /**
85
+ * Translate a wireComponents message into an array of Dockable objects.
86
+ *
87
+ * @param {object} msg — wireComponents message
88
+ * @returns {{ dockables: import('./dockables/base.js').Dockable[], initialModel: object|null }}
89
+ */
90
+ export function createDockables(msg) {
91
+ const dockables = [];
92
+ const handlerResolver = createHandlerResolver();
93
+ const controllerResolver = createControllerResolver();
94
+ const dataResolver = createDataResolver();
95
+
96
+ // Data sources
97
+ if (msg.data?.sources) {
98
+ for (const source of msg.data.sources) {
99
+ dockables.push(new DataSourceDock(source, dataResolver));
100
+ }
101
+ }
102
+
103
+ // Controllers
104
+ if (msg.state?.controllers) {
105
+ for (const ctrl of msg.state.controllers) {
106
+ dockables.push(new ControllerDock(ctrl, controllerResolver));
107
+ }
108
+ }
109
+
110
+ // Actions
111
+ if (msg.actions) {
112
+ for (const action of msg.actions) {
113
+ dockables.push(new ActionDock(action, handlerResolver));
114
+ }
115
+ }
116
+
117
+ // Provides
118
+ if (msg.provides) {
119
+ const provides = Array.isArray(msg.provides) ? msg.provides : [msg.provides];
120
+ for (const p of provides) {
121
+ dockables.push(new ProviderDock(p));
122
+ }
123
+ }
124
+
125
+ // Lifecycle
126
+ if (msg.lifecycle) {
127
+ dockables.push(new LifecycleDock(msg.lifecycle, handlerResolver));
128
+ }
129
+
130
+ // Initial model (not a dockable — set directly on Surface)
131
+ const initialModel = msg.state?.model || null;
132
+
133
+ return { dockables, initialModel };
134
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Wiring Engine — Processes wireComponents messages via the Surface dock system.
3
+ *
4
+ * This is the bridge between the renderer (which receives raw messages)
5
+ * and the Surface (which manages dockable objects). It:
6
+ *
7
+ * 1. Creates/retrieves a Surface for the surfaceId
8
+ * 2. Resolves params from the message
9
+ * 3. Translates the message sections into typed Dockables via wire-factory
10
+ * 4. Docks them in dependency order on the Surface
11
+ *
12
+ * The external API is unchanged: process(msg), teardown(surfaceId), teardownAll().
13
+ */
14
+
15
+ import { Surface } from './surface.js';
16
+ import { createDockables } from './wire-factory.js';
17
+
18
+ export class WiringEngine {
19
+ /** @type {Map<string, Surface>} */
20
+ #surfaces = new Map();
21
+
22
+ /** @type {(surfaceId: string, path: string, data: unknown) => void} */
23
+ #updateDataModel;
24
+
25
+ /** @type {(surfaceId: string, componentId: string) => HTMLElement | null} */
26
+ #getElement;
27
+
28
+ /**
29
+ * @param {object} opts
30
+ * @param {(surfaceId: string, path: string, data: unknown) => void} opts.updateDataModel
31
+ * @param {(surfaceId: string, componentId: string) => HTMLElement | null} opts.getElement
32
+ */
33
+ constructor({ updateDataModel, getElement }) {
34
+ this.#updateDataModel = updateDataModel;
35
+ this.#getElement = getElement;
36
+ }
37
+
38
+ /**
39
+ * Process a wireComponents message.
40
+ * Multiple calls for the same surfaceId are additive (new dockables added,
41
+ * same-id dockables hot-swapped).
42
+ *
43
+ * @param {object} message
44
+ */
45
+ async process(message) {
46
+ const { surfaceId } = message;
47
+ if (!surfaceId) return;
48
+
49
+ let surface = this.#surfaces.get(surfaceId);
50
+ if (!surface) {
51
+ surface = this.#createSurface(surfaceId);
52
+ this.#surfaces.set(surfaceId, surface);
53
+ }
54
+
55
+ try {
56
+ // Resolve params (before creating dockables, they may need params)
57
+ if (message.data?.params) {
58
+ surface.setParams(this.#resolveParams(message.data.params));
59
+ }
60
+
61
+ // Translate message → dockables
62
+ const { dockables, initialModel } = createDockables(message);
63
+
64
+ // Set initial model state before docking
65
+ if (initialModel) {
66
+ surface.setInitialModel(initialModel);
67
+ }
68
+
69
+ // Handle explicit undocking
70
+ if (message.undock) {
71
+ surface.undockMany(message.undock);
72
+ }
73
+
74
+ // Dock all in dependency order
75
+ // Some dockables have async dock() (controllers), so we dock sequentially
76
+ // by kind to maintain order guarantees
77
+ for (const dockable of dockables) {
78
+ await surface.dock(dockable);
79
+ }
80
+ } catch (err) {
81
+ console.warn(`Wiring: error processing wireComponents for "${surfaceId}":`, err.message);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Tear down all wiring for a surface.
87
+ * @param {string} surfaceId
88
+ */
89
+ teardown(surfaceId) {
90
+ const surface = this.#surfaces.get(surfaceId);
91
+ if (!surface) return;
92
+ surface.undockAll();
93
+ this.#surfaces.delete(surfaceId);
94
+ }
95
+
96
+ /**
97
+ * Tear down everything.
98
+ */
99
+ teardownAll() {
100
+ for (const surfaceId of this.#surfaces.keys()) this.teardown(surfaceId);
101
+ }
102
+
103
+ /**
104
+ * Get a surface for inspection/testing.
105
+ * @param {string} surfaceId
106
+ * @returns {Surface|null}
107
+ */
108
+ getSurface(surfaceId) {
109
+ return this.#surfaces.get(surfaceId) || null;
110
+ }
111
+
112
+ /**
113
+ * Get a wired surface's state (for debugging/inspection).
114
+ * @param {string} surfaceId
115
+ */
116
+ getSurfaceState(surfaceId) {
117
+ const surface = this.#surfaces.get(surfaceId);
118
+ if (!surface) return null;
119
+ const dockables = surface.context.listDockables();
120
+ return {
121
+ surfaceId,
122
+ controllerCount: dockables.filter(d => d.kind === 'controller').length,
123
+ sourceCount: dockables.filter(d => d.kind === 'source').length,
124
+ actionCount: dockables.filter(d => d.kind === 'action').length,
125
+ providerCount: dockables.filter(d => d.kind === 'provider').length,
126
+ hasLifecycle: dockables.some(d => d.kind === 'lifecycle'),
127
+ totalDocked: dockables.length,
128
+ };
129
+ }
130
+
131
+ // ── Private ────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Create a Surface wired to the renderer's element map.
135
+ */
136
+ #createSurface(surfaceId) {
137
+ // Create an element proxy that delegates to the renderer's getElement
138
+ const elementsProxy = {
139
+ get: (componentId) => this.#getElement(surfaceId, componentId),
140
+ has: (componentId) => !!this.#getElement(surfaceId, componentId),
141
+ };
142
+
143
+ // The root element is the surface container in the renderer
144
+ const rootElement = this.#getElement(surfaceId, 'root') || document.createElement('div');
145
+
146
+ // Build a Map-like wrapper so Surface can use elements.get()
147
+ const elements = new Proxy(new Map(), {
148
+ get(target, prop) {
149
+ if (prop === 'get') return (id) => elementsProxy.get(id);
150
+ if (prop === 'has') return (id) => elementsProxy.has(id);
151
+ return target[prop];
152
+ },
153
+ });
154
+
155
+ // Wrap updateDataModel so dockables that call ctx.setModel
156
+ // also flow through to the renderer's data binding system
157
+ const surface = new Surface(surfaceId, rootElement, elements);
158
+
159
+ // Monkey-patch setModel on context to also notify the renderer
160
+ const originalContext = surface.context;
161
+ const origSetModel = originalContext.setModel;
162
+ const updateDataModel = this.#updateDataModel;
163
+ Object.defineProperty(surface, 'context', {
164
+ get() {
165
+ const ctx = originalContext;
166
+ return {
167
+ ...ctx,
168
+ setModel(path, value) {
169
+ origSetModel(path, value);
170
+ updateDataModel(surfaceId, path, value);
171
+ },
172
+ };
173
+ },
174
+ });
175
+
176
+ return surface;
177
+ }
178
+
179
+ /**
180
+ * Resolve parameter declarations to values.
181
+ * @param {Record<string, { from: string, key: string }>} params
182
+ * @returns {Record<string, string>}
183
+ */
184
+ #resolveParams(params) {
185
+ const resolved = {};
186
+ for (const [name, spec] of Object.entries(params)) {
187
+ switch (spec.from) {
188
+ case 'route': {
189
+ const hash = location.hash.slice(1);
190
+ const match = hash.match(new RegExp(`${spec.key}/([^/]+)`));
191
+ if (match) resolved[name] = match[1];
192
+ break;
193
+ }
194
+ case 'query': {
195
+ const url = new URL(location.href);
196
+ const val = url.searchParams.get(spec.key);
197
+ if (val) resolved[name] = val;
198
+ break;
199
+ }
200
+ case 'literal':
201
+ resolved[name] = spec.value;
202
+ break;
203
+ default:
204
+ if (typeof spec === 'string') resolved[name] = spec;
205
+ }
206
+ }
207
+ return resolved;
208
+ }
209
+ }