Skip to content

Commit e865988

Browse files
Add a framework-agnostic data layer and docs to the web core library (#631)
1 parent 5db8030 commit e865988

25 files changed

Lines changed: 1695 additions & 62 deletions

renderers/web_core/package-lock.json

Lines changed: 25 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

renderers/web_core/package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"prepack": "npm run build",
3636
"build": "wireit",
3737
"build:tsc": "wireit",
38-
"copy-spec": "wireit"
38+
"test": "wireit"
3939
},
4040
"wireit": {
4141
"copy-spec": {
@@ -68,6 +68,12 @@
6868
"!dist/**/*.min.js{,.map}"
6969
],
7070
"clean": "if-file-deleted"
71+
},
72+
"test": {
73+
"command": "node --test dist/**/*.test.js",
74+
"dependencies": [
75+
"build"
76+
]
7177
}
7278
},
7379
"author": "Google",
@@ -76,5 +82,9 @@
7682
"@types/node": "^24.10.1",
7783
"typescript": "^5.8.3",
7884
"wireit": "^0.15.0-pre.2"
85+
},
86+
"dependencies": {
87+
"zod": "^3.25.76",
88+
"zod-to-json-schema": "^3.25.1"
7989
}
8090
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { z } from 'zod';
2+
3+
/**
4+
* A definition of a UI component's API.
5+
* This interface defines the contract for a component's capabilities and properties,
6+
* independent of any specific rendering implementation.
7+
*/
8+
export interface ComponentApi {
9+
/** The name of the component as it appears in the A2UI JSON (e.g., 'Button'). */
10+
name: string;
11+
12+
/**
13+
* The Zod schema describing the **properties** of this component.
14+
*
15+
* - MUST include catalog-specific common properties (e.g. 'weight', 'accessibility').
16+
* - MUST NOT include 'component' or 'id' as those are handled by the framework/envelope.
17+
*/
18+
readonly schema: z.ZodType<any>;
19+
}
20+
21+
export class Catalog<T extends ComponentApi> {
22+
readonly id: string;
23+
24+
/**
25+
* A map of available components.
26+
* This is readonly to encourage immutable extension patterns.
27+
*/
28+
readonly components: ReadonlyMap<string, T>;
29+
30+
constructor(id: string, components: T[]) {
31+
this.id = id;
32+
const map = new Map<string, T>();
33+
for (const comp of components) {
34+
map.set(comp.name, comp);
35+
}
36+
this.components = map;
37+
}
38+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/** Standard cleanup interface returned by all subscriptions. */
2+
export interface Subscription {
3+
unsubscribe(): void;
4+
}
5+
6+
/** The listener function signature. */
7+
export type EventListener<T> = (data: T) => void | Promise<void>;
8+
9+
/**
10+
* Public interface exposed by models.
11+
* Allows ONLY subscribing to events.
12+
*/
13+
export interface EventSource<T> {
14+
subscribe(listener: EventListener<T>): Subscription;
15+
}
16+
17+
/**
18+
* Internal implementation used by the model.
19+
* Implements EventSource but also provides the 'emit' method.
20+
*/
21+
export class EventEmitter<T> implements EventSource<T> {
22+
private listeners = new Set<EventListener<T>>();
23+
24+
subscribe(listener: EventListener<T>): Subscription {
25+
this.listeners.add(listener);
26+
return {
27+
unsubscribe: () => this.listeners.delete(listener)
28+
};
29+
}
30+
31+
async emit(data: T): Promise<void> {
32+
for (const listener of this.listeners) {
33+
try {
34+
await listener(data);
35+
} catch (e) {
36+
console.error('EventEmitter error:', e);
37+
}
38+
}
39+
}
40+
41+
dispose(): void {
42+
this.listeners.clear();
43+
}
44+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
2+
export * from './state/data-model.js';
3+
export * from './common/events.js';
4+
export * from './rendering/data-context.js';
5+
export * from './state/surface-model.js';
6+
export * from './processing/message-processor.js';
7+
export * from './catalog/types.js';
8+
export * from './rendering/component-context.js';
9+
export * from './state/surface-group-model.js';
10+
export * from './state/surface-components-model.js';
11+
export * from './schema/index.js';
12+
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import assert from 'node:assert';
2+
import { test, describe, it, beforeEach } from 'node:test';
3+
import { MessageProcessor } from './message-processor.js';
4+
import { Catalog, ComponentApi } from '../catalog/types.js';
5+
6+
describe('MessageProcessor', () => {
7+
let processor: MessageProcessor<ComponentApi>;
8+
let testCatalog: Catalog<ComponentApi>;
9+
let actions: any[] = [];
10+
11+
beforeEach(() => {
12+
actions = [];
13+
testCatalog = new Catalog('test-catalog', []);
14+
processor = new MessageProcessor<ComponentApi>([testCatalog], async (a) => { actions.push(a); });
15+
});
16+
17+
it('creates surface', () => {
18+
processor.processMessages([{
19+
createSurface: {
20+
surfaceId: 's1',
21+
catalogId: 'test-catalog',
22+
theme: {}
23+
}
24+
}]);
25+
const surface = processor.model.getSurface('s1');
26+
assert.ok(surface);
27+
assert.strictEqual(surface.id, 's1');
28+
});
29+
30+
it('updates components on correct surface', () => {
31+
processor.processMessages([{
32+
createSurface: { surfaceId: 's1', catalogId: 'test-catalog' }
33+
}]);
34+
35+
processor.processMessages([{
36+
updateComponents: {
37+
surfaceId: 's1',
38+
components: [{ id: 'root', component: 'Box' }]
39+
}
40+
}]);
41+
42+
const surface = processor.model.getSurface('s1');
43+
assert.ok(surface?.componentsModel.get('root'));
44+
});
45+
46+
it('updates existing components via message', () => {
47+
processor.processMessages([{
48+
createSurface: { surfaceId: 's1', catalogId: 'test-catalog' }
49+
}]);
50+
51+
// Create
52+
processor.processMessages([{
53+
updateComponents: {
54+
surfaceId: 's1',
55+
components: [{ id: 'btn', component: 'Button', label: 'Initial' }]
56+
}
57+
}]);
58+
59+
const surface = processor.model.getSurface('s1');
60+
const btn = surface?.componentsModel.get('btn');
61+
assert.strictEqual(btn?.properties.label, 'Initial');
62+
63+
// Update
64+
processor.processMessages([{
65+
updateComponents: {
66+
surfaceId: 's1',
67+
components: [{ id: 'btn', component: 'Button', label: 'Updated' }]
68+
}
69+
}]);
70+
71+
assert.strictEqual(btn?.properties.label, 'Updated');
72+
});
73+
74+
it('deletes surface', () => {
75+
processor.processMessages([{
76+
createSurface: { surfaceId: 's1', catalogId: 'test-catalog' }
77+
}]);
78+
assert.ok(processor.model.getSurface('s1'));
79+
80+
processor.processMessages([{
81+
deleteSurface: { surfaceId: 's1' }
82+
}]);
83+
assert.strictEqual(processor.model.getSurface('s1'), undefined);
84+
});
85+
86+
it('routes data model updates', () => {
87+
processor.processMessages([{
88+
createSurface: { surfaceId: 's1', catalogId: 'test-catalog' }
89+
}]);
90+
91+
processor.processMessages([{
92+
updateDataModel: {
93+
surfaceId: 's1',
94+
path: '/foo',
95+
value: 'bar'
96+
}
97+
}]);
98+
99+
const surface = processor.model.getSurface('s1');
100+
assert.strictEqual(surface?.dataModel.get('/foo'), 'bar');
101+
});
102+
103+
it('notifies lifecycle listeners', () => {
104+
let created: any = null;
105+
let deletedId: string | null = null;
106+
107+
const sub = processor.onSurfaceCreated((s) => { created = s; });
108+
const sub2 = processor.onSurfaceDeleted((id) => { deletedId = id; });
109+
110+
// Create
111+
processor.processMessages([{
112+
createSurface: { surfaceId: 's1', catalogId: 'test-catalog' }
113+
}]);
114+
assert.ok(created);
115+
assert.strictEqual(created.id, 's1');
116+
117+
// Delete
118+
processor.processMessages([{
119+
deleteSurface: { surfaceId: 's1' }
120+
}]);
121+
assert.strictEqual(deletedId, 's1');
122+
123+
// Test Unsubscribe
124+
created = null;
125+
sub.unsubscribe();
126+
processor.processMessages([{
127+
createSurface: { surfaceId: 's2', catalogId: 'test-catalog' }
128+
}]);
129+
assert.strictEqual(created, null);
130+
131+
sub2.unsubscribe();
132+
});
133+
});

0 commit comments

Comments
 (0)