Skip to content

Commit 6efe74c

Browse files
feat(web-core): implement client capabilities API in MessageProcessor (#826)
1 parent dae4256 commit 6efe74c

File tree

11 files changed

+462
-81
lines changed

11 files changed

+462
-81
lines changed

renderers/web_core/package-lock.json

Lines changed: 3 additions & 4 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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,12 @@
9191
"@types/node": "^24.11.0",
9292
"c8": "^11.0.0",
9393
"typescript": "^5.8.3",
94-
"wireit": "^0.15.0-pre.2",
95-
"zod-to-json-schema": "^3.25.1"
94+
"wireit": "^0.15.0-pre.2"
9695
},
9796
"dependencies": {
9897
"@preact/signals-core": "^1.13.0",
9998
"date-fns": "^4.1.0",
100-
"zod": "^3.25.76"
99+
"zod": "^3.25.76",
100+
"zod-to-json-schema": "^3.25.1"
101101
}
102102
}

renderers/web_core/src/v0_9/catalog/types.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,23 @@ export class Catalog<T extends ComponentApi> {
113113
*/
114114
readonly functions: ReadonlyMap<string, FunctionImplementation>;
115115

116+
/**
117+
* The schema for theme parameters used by this catalog.
118+
*/
119+
readonly themeSchema?: z.ZodObject<any>;
120+
116121
/**
117122
* A ready-to-use FunctionInvoker callback that delegates to this catalog's functions.
118123
* Can be passed directly to a DataContext.
119124
*/
120125
readonly invoker: FunctionInvoker;
121126

122-
constructor(id: string, components: T[], functions: FunctionImplementation[] = []) {
127+
constructor(
128+
id: string,
129+
components: T[],
130+
functions: FunctionImplementation[] = [],
131+
themeSchema?: z.ZodObject<any>,
132+
) {
123133
this.id = id;
124134

125135
const compMap = new Map<string, T>();
@@ -134,6 +144,8 @@ export class Catalog<T extends ComponentApi> {
134144
}
135145
this.functions = funcMap;
136146

147+
this.themeSchema = themeSchema;
148+
137149
this.invoker = (name, rawArgs, ctx, abortSignal) => {
138150
const fn = this.functions.get(name);
139151
if (!fn) {

renderers/web_core/src/v0_9/processing/message-processor.test.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import assert from "node:assert";
1818
import { describe, it, beforeEach } from "node:test";
1919
import { MessageProcessor } from "./message-processor.js";
2020
import { Catalog, ComponentApi } from "../catalog/types.js";
21+
import { z } from "zod";
2122

2223
describe("MessageProcessor", () => {
2324
let processor: MessageProcessor<ComponentApi>;
@@ -32,6 +33,196 @@ describe("MessageProcessor", () => {
3233
});
3334
});
3435

36+
describe("getClientCapabilities", () => {
37+
it("generates basic client capabilities with supportedCatalogIds", () => {
38+
const caps: any = processor.getClientCapabilities();
39+
assert.strictEqual((caps["v0.9"] as any).inlineCatalogs, undefined);
40+
assert.deepStrictEqual(caps, {
41+
"v0.9": {
42+
supportedCatalogIds: ["test-catalog"],
43+
},
44+
});
45+
});
46+
47+
it("generates inline catalogs when requested", () => {
48+
const buttonApi: ComponentApi = {
49+
name: "Button",
50+
schema: z.object({
51+
label: z.string().describe("The button label"),
52+
}),
53+
};
54+
const cat = new Catalog("cat-1", [buttonApi]);
55+
const proc = new MessageProcessor([cat]);
56+
57+
const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
58+
const inlineCat = caps["v0.9"].inlineCatalogs![0];
59+
60+
assert.strictEqual(inlineCat.catalogId, "cat-1");
61+
const buttonSchema = inlineCat.components!.Button;
62+
63+
assert.ok(buttonSchema.allOf);
64+
assert.strictEqual(
65+
buttonSchema.allOf[0].$ref,
66+
"common_types.json#/$defs/ComponentCommon",
67+
);
68+
assert.strictEqual(buttonSchema.allOf[1].properties.component.const, "Button");
69+
assert.strictEqual(
70+
buttonSchema.allOf[1].properties.label.description,
71+
"The button label",
72+
);
73+
assert.deepStrictEqual(buttonSchema.allOf[1].required, ["component", "label"]);
74+
});
75+
76+
it("transforms REF: descriptions into valid $ref nodes", () => {
77+
const customApi: ComponentApi = {
78+
name: "Custom",
79+
schema: z.object({
80+
title: z
81+
.string()
82+
.describe("REF:common_types.json#/$defs/DynamicString|The title"),
83+
}),
84+
};
85+
const cat = new Catalog("cat-ref", [customApi]);
86+
const proc = new MessageProcessor([cat]);
87+
88+
const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
89+
const titleSchema =
90+
caps["v0.9"].inlineCatalogs![0].components!.Custom.allOf[1].properties.title;
91+
92+
assert.strictEqual(titleSchema.$ref, "common_types.json#/$defs/DynamicString");
93+
assert.strictEqual(titleSchema.description, "The title");
94+
// Ensure Zod's 'type: string' was removed
95+
assert.strictEqual(titleSchema.type, undefined);
96+
});
97+
98+
it("generates inline catalogs with functions and theme schema", () => {
99+
const buttonApi: ComponentApi = {
100+
name: "Button",
101+
schema: z.object({
102+
label: z.string(),
103+
}),
104+
};
105+
const addFn = {
106+
name: "add",
107+
returnType: "number" as const,
108+
schema: z.object({
109+
a: z.number().describe("First number"),
110+
b: z.number().describe("Second number"),
111+
}),
112+
execute: (args: any) => args.a + args.b,
113+
};
114+
115+
const themeSchema = z.object({
116+
primaryColor: z.string().describe("REF:common_types.json#/$defs/Color|The main color"),
117+
});
118+
119+
const cat = new Catalog("cat-full", [buttonApi], [addFn], themeSchema);
120+
const proc = new MessageProcessor([cat]);
121+
122+
const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
123+
const inlineCat = caps["v0.9"].inlineCatalogs![0];
124+
125+
assert.strictEqual(inlineCat.catalogId, "cat-full");
126+
127+
// Verify Functions
128+
assert.ok(inlineCat.functions);
129+
assert.strictEqual(inlineCat.functions.length, 1);
130+
const fn = inlineCat.functions[0];
131+
assert.strictEqual(fn.name, "add");
132+
assert.strictEqual(fn.returnType, "number");
133+
assert.strictEqual(fn.parameters.properties.a.description, "First number");
134+
135+
// Verify Theme
136+
assert.ok(inlineCat.theme);
137+
assert.ok(inlineCat.theme.primaryColor);
138+
assert.strictEqual(inlineCat.theme.primaryColor.$ref, "common_types.json#/$defs/Color");
139+
assert.strictEqual(inlineCat.theme.primaryColor.description, "The main color");
140+
});
141+
142+
it("omits functions and theme when catalog has none", () => {
143+
const compApi: ComponentApi = { name: "EmptyComp", schema: z.object({}) };
144+
const cat = new Catalog("cat-empty", [compApi]);
145+
const proc = new MessageProcessor([cat]);
146+
const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
147+
const inlineCat = caps["v0.9"].inlineCatalogs![0];
148+
149+
assert.strictEqual(inlineCat.catalogId, "cat-empty");
150+
assert.strictEqual(inlineCat.functions, undefined);
151+
assert.strictEqual(inlineCat.theme, undefined);
152+
});
153+
154+
it("processes REF: tags deeply nested in schema arrays and objects", () => {
155+
const deepApi: ComponentApi = {
156+
name: "DeepComp",
157+
schema: z.object({
158+
items: z.array(z.object({
159+
action: z.string().describe("REF:common_types.json#/$defs/Action|The action to perform")
160+
}))
161+
})
162+
};
163+
const cat = new Catalog("cat-deep", [deepApi]);
164+
const proc = new MessageProcessor([cat]);
165+
const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
166+
167+
const properties = caps["v0.9"].inlineCatalogs![0].components!.DeepComp.allOf[1].properties;
168+
const actionSchema = properties.items.items.properties.action;
169+
170+
assert.strictEqual(actionSchema.$ref, "common_types.json#/$defs/Action");
171+
assert.strictEqual(actionSchema.description, "The action to perform");
172+
assert.strictEqual(actionSchema.type, undefined);
173+
});
174+
175+
it("handles REF: tags without pipes or with multiple pipes", () => {
176+
const edgeApi: ComponentApi = {
177+
name: "EdgeComp",
178+
schema: z.object({
179+
noPipe: z.string().describe("REF:common_types.json#/$defs/NoPipe"),
180+
multiPipe: z.string().describe("REF:common_types.json#/$defs/MultiPipe|First|Second"),
181+
})
182+
};
183+
const cat = new Catalog("cat-edge", [edgeApi]);
184+
const proc = new MessageProcessor([cat]);
185+
const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
186+
187+
const properties = caps["v0.9"].inlineCatalogs![0].components!.EdgeComp.allOf[1].properties;
188+
189+
assert.strictEqual(properties.noPipe.$ref, "common_types.json#/$defs/NoPipe");
190+
assert.strictEqual(properties.noPipe.description, undefined);
191+
192+
assert.strictEqual(properties.multiPipe.$ref, "common_types.json#/$defs/MultiPipe");
193+
assert.strictEqual(properties.multiPipe.description, "First");
194+
});
195+
196+
it("handles multiple catalogs correctly", () => {
197+
const compApi: ComponentApi = { name: "C1", schema: z.object({}) };
198+
const cat1 = new Catalog("cat-1", [compApi]);
199+
200+
const addFn = {
201+
name: "add",
202+
returnType: "number" as const,
203+
schema: z.object({}),
204+
execute: () => 0,
205+
};
206+
const themeSchema = z.object({ color: z.string() });
207+
const cat2 = new Catalog("cat-2", [], [addFn], themeSchema);
208+
209+
const proc = new MessageProcessor([cat1, cat2]);
210+
const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
211+
212+
assert.strictEqual(caps["v0.9"].inlineCatalogs!.length, 2);
213+
214+
const inlineCat1 = caps["v0.9"].inlineCatalogs![0];
215+
assert.strictEqual(inlineCat1.catalogId, "cat-1");
216+
assert.strictEqual(inlineCat1.functions, undefined);
217+
assert.strictEqual(inlineCat1.theme, undefined);
218+
219+
const inlineCat2 = caps["v0.9"].inlineCatalogs![1];
220+
assert.strictEqual(inlineCat2.catalogId, "cat-2");
221+
assert.strictEqual(inlineCat2.functions!.length, 1);
222+
assert.ok(inlineCat2.theme);
223+
});
224+
});
225+
35226
it("creates surface", () => {
36227
processor.processMessages([
37228
{

0 commit comments

Comments
 (0)