a2ui-project / a2ui-project/a2ui

[FEATURE]: Abstract dependency on Zod on web_core.

Abierto
#2,160 1 comentario 0 reacciones 0 asignados Ver en GitHub
P2 status: first-line-handled type: feature/enhancement
Lenguaje dominante
TypeScript
Estrellas
16.4k
Forks
1.3k
Merge medio
2 d 13 h
PR fusionados (30 d)
134

Descripción

# A2UI Schema Abstraction & Zod Decoupling Plan

- [x] I have searched the existing issues to make sure this feature has not already been requested.

## Is your feature request related to a problem? Please describe.

Currently, `@a2ui/web_core` depends on `zod: ^3.25.X`. This is a problem because Zod 3 is in _maintenance mode_/EOL, and ideally it should be using zod 4 at least, *however* Google does *not* have zod v4 available internally yet, so we still need to support zod3.

Similarly to what we did with `@a2ui/markdown-it` (where internal google wants a different rendering library), we should abstract our dependency on Zod by some interface that can be implemented using different libraries; zod3 (to preserve compatibility with existing code), zod4 (for future-proofing), and others... ideally simultaneously to allow for different catalog implementers to select their own.

---

## Describe the Proposed Solution

### Core interface design (`A2uiSchema`)

We replace `z.ZodTypeAny` across `@a2ui/web_core` with `A2uiSchema`.

#### Types

```typescript
/**
* Result of a validation operation, modeled as a TypeScript discriminated union.
* - When `success` is true, `.data` is strongly typed as `T`.
* - When `success` is false, `.errors` contains validation error messages or issues.
*/
export type ValidationResult =
| { success: true; data: T }
| { success: false; errors: string[] | unknown };

/**
* Framework-agnostic schema interface for validating data and generating JSON schemas.
*/
export interface A2uiSchema {
/** Phantom type marker for compile-time TypeScript inference. */
readonly _type?: T;

/**
* Human-readable description of the schema.
* Used by `MessageProcessor.generateInlineCatalog()` to populate LLM function descriptions
* in `InlineCatalog` client capability reports sent to the server.
*/
readonly description?: string;

/** Validates data without throwing an exception. */
validate(data: unknown): ValidationResult;

/** Converts this schema into a standard JSON Schema object for InlineCatalog reporting. */
toJsonSchema(): Record;
}

/** Extracts the inferred TypeScript type from an A2uiSchema at compile time. */
export type InferSchemaType =
S extends A2uiSchema ? T : never;
```

#### Catalog definitions

```typescript
/**
* Definition of a UI component's API contract in an A2UI catalog.
* Defines the component's name and property schema independent of any rendering implementation.
*
* @template Schema - The A2uiSchema type describing the component's properties.
*/
export interface ComponentApi {
/** The unique name of the component as it appears in A2UI JSON (e.g., 'Button'). */
name: string;
/** The schema describing and validating this component's properties. */
readonly schema: Schema;
}

/**
* Type helper that extracts the static TypeScript property interface from a ComponentApi.
* Used by renderers and catalogs to access component property types with compile-time safety.
*/
export type InferredComponentApiSchemaType =
Api extends ComponentApi ? InferSchemaType : never;

/**
* Definition of a callable function's API contract in an A2UI catalog.
*/
export interface FunctionApi {
/** The unique name of the function (e.g., 'openUrl'). */
readonly name: string;
/** The expected return type category ('string', 'number', 'boolean', 'object', etc.). */
readonly returnType: A2uiReturnType;
/** The schema describing and validating the function's parameter arguments. */
readonly schema: A2uiSchema;
}
```

---

### How `@a2ui/web_core` connects to renderers: Static props typing & runtime dataflow

Client renderers (`@a2ui/angular`, `@a2ui/react`, `@a2ui/lit`) connect to `@a2ui/web_core` at two distinct levels: **compile-time static props inference** and **runtime validated property dataflow**.

#### 1. Compile-time static props inference (`InferredComponentApiSchemaType`)

All three client renderers deduce the static TypeScript interface of a component's properties using `InferredComponentApiSchemaType` from `@a2ui/web_core`.

Under the new architecture, `InferredComponentApiSchemaType` inspects `Api['schema']['_type']` at compile time and evaluates to the underlying TypeScript interface `T`:

- **React (`@a2ui/react`):**
React components define their prop types using `ResolveA2uiProps>`. With `A2uiSchema`, this automatically resolves to `T` with zero code changes:
```tsx
// adapter.tsx in React renderer
type Props = ResolveA2uiProps>;
```

- **Lit (`@a2ui/lit`):**
Lit components define their controller and host element types using `InferredComponentApiSchemaType`. With `A2uiSchema`, `this.props` is automatically typed as `T`:
```typescript
// a2ui-controller.ts in Lit renderer
public props: ResolveA2uiProps>;
```

- **Angular (`@a2ui/angular`):**
Angular's `ComponentApiToProps` utility currently uses `z.infer`. We update Angular's `core/types.ts` to import `InferredComponentApiSchemaType` from `@a2ui/web_core`:
```diff
-export type ComponentApiToProps = InferredInterfaceToProps<
- ExtendedProps>
->;
+export type ComponentApiToProps = InferredInterfaceToProps<
+ ExtendedProps>
+>;
```
With this one-line diff, every Angular component (`TextComponent`, `ButtonComponent`, etc.) extending `BasicCatalogComponent` receives `this.props()` typed as `ComponentApiToProps`, where each property is wrapped in an Angular Signal (`BoundProperty`) with **100% static compile-time safety** and zero Zod imports.

#### 2. Runtime validated property dataflow

At runtime, properties flow from the server to the actual renderer render methods in a 4-step pipeline:

```mermaid
flowchart LR
A[Server JSON Message] --> B[MessageProcessor]
B -->|api.schema.validate| C[ComponentModel.properties]
C --> D[Renderer Binders]
D -->|Props / Signals| E[UI Render Methods]
```

1. **Payload Arrival (`MessageProcessor`):**
An `UpdateComponentsMessage` arrives in `@a2ui/web_core`'s `MessageProcessor` containing unvalidated JSON properties (`rawProperties`).
2. **Schema Validation (`A2uiSchema.validate`):**
`MessageProcessor` calls `componentApi.schema.validate(rawProperties)`. Whether the schema under the hood is Zod 3, Zod 4, Valibot, or static JSON Schema, it validates the data and returns `ValidationResult`.
3. **Model Update (`ComponentModel`):**
`MessageProcessor` assigns `validationResult.data` (now safely typed as `T`) to `ComponentModel.properties`.
4. **Renderer Binding & Rendering:**
- **React:** `GenericBinder` reads `ComponentModel.properties` (type `T`), resolves data-binding expressions against `DataContext`, and passes the resolved `props` object into ``.
- **Lit:** `A2uiController` reads `ComponentModel.properties` (type `T`), resolves data-bindings via `GenericBinder`, and triggers Lit's reactive render cycle with `this.props`.
- **Angular:** `ComponentBinder` reads `ComponentModel.properties` (type `T`), updates the corresponding reactive Angular Signals (`BoundProperty`), and triggers `OnPush` change detection in `CatalogComponent`.

---

### Runtime validation & multi-catalog interoperability

Catalogs using different schema libraries (Zod 3, Zod 4, Valibot, or static JSON Schema) can interoperate within the same application:
```tsx

```

#### Validating mixed-catalog responses in `MessageProcessor`
When an `UpdateComponentsMessage` arrives containing components from multiple catalogs, `MessageProcessor` validates each component independently against its respective catalog schema:

```typescript
for (const instance of message.components) {
const [componentType, rawProperties] = Object.entries(instance.component)[0];

const componentApi = surface.catalog.components.get(componentType);
if (!componentApi) {
throw new A2uiValidationError(`Unknown component type '${componentType}'`);
}

// Validate against the component's specific schema
const validationResult = componentApi.schema.validate(rawProperties);

// (Optionally) Throw on error when parsing
if (!validationResult.success) {
throw new A2uiValidationError(
`Validation failed for '${componentType}'`,
validationResult.errors,
);
}

// ValidationResult.data is of known type T
surfaceModel.setComponentProperties(instance.id, componentType, validationResult.data);
}
```

---

### Basic catalog schemas

Currently, the basic catalog uses zod3. In order to keep `web_core` free of dependencies on `zod`, we can:

* Separate the `basic_catalog` to a separate package with the dependencies that it needs (preferred)
* Implement a JSON Schema by hand with a simple validation

#### (Optional) Static JSON-Schema basic catalog

`basic_catalog` components could use static `A2uiSchema` objects backed by prebuilt JSON Schema definitions and a noop validator:

```typescript
export function createStaticSchema(
jsonSchema: Record,
validator?: (data: unknown) => ValidationResult,
): A2uiSchema {
return {
validate: validator ?? ((data) => ({ success: true, data: data as T })),
toJsonSchema: () => jsonSchema,
};
}
```

---

### Adapter packages (`@a2ui/schema-zod3` & future `@a2ui/schema-zod4`)

Converting `ComponentApi.schema` from `z.ZodTypeAny` to `A2uiSchema` is a breaking change for custom catalog authors.
- **`@a2ui/schema-zod3` MUST be vended simultaneously** with the core release so existing Zod 3 users have an immediate drop-in migration path (`zod3Schema(...)`).
- **`@a2ui/schema-zod4` is optional** and can be added in a later release as Zod 4 adoption grows, or as a sample for others' to implement their own schema adapters.

#### Zod 3 adapter (`@a2ui/schema-zod3`)

Implements A2uiSchema using `zod/v3` and `zod-to-json-schema`.

```typescript
import type {z} from 'zod/v3';
import {zodToJsonSchema} from 'zod-to-json-schema';
import type {A2uiSchema, ValidationResult} from '@a2ui/web_core';

export class Zod3Schema implements A2uiSchema {
constructor(private readonly zodSchema: z.ZodType) {}

get description(): string | undefined {
return this.zodSchema.description;
}

validate(data: unknown): ValidationResult {
const result = this.zodSchema.safeParse(data);
if (result.success) {
return {success: true, data: result.data};
}
return {
success: false,
errors: result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`),
};
}

toJsonSchema(): Record {
return zodToJsonSchema(this.zodSchema, {
target: 'jsonSchema2019-09',
}) as Record;
}
}

/** Wraps a Zod 3 schema into an A2uiSchema. */
export function zod3Schema(schema: z.ZodType): A2uiSchema {
return new Zod3Schema(schema);
}
```

#### Custom catalog authoring example

This is how a component API would use the `zod3Schema` builder:

```typescript
import {z} from 'zod';
import {zod3Schema} from '@a2ui/schema-zod3';
import type {ComponentApi} from '@a2ui/web_core';

export const CustomCardApi: ComponentApi = {
name: 'CustomCard',
schema: zod3Schema(
z.object({
title: z.string(),
elevated: z.boolean().optional(),
}),
),
};
```

---

## Describe Alternatives Considered

I've tried supporting both zod3 and zod4 by broadening the dependency that we have on zod, but we end up needing some _unsavory_ code to differentiate between zod3 and 4 at runtime.

* See: #2135

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.