@saga-a2ui/core 0.5.4-rc.1 → 0.7.6-rc.1

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.
@@ -12,6 +12,7 @@ import type {
12
12
  UpdateComponentsMessage,
13
13
  UpdateDataModelMessage,
14
14
  } from '@a2ui/web_core/v0_9';
15
+ import { deepFreeze } from './deep-freeze.js';
15
16
  import type { ValidatedMessage } from './message.js';
16
17
 
17
18
  /** Why input was rejected. `message` is plain text that is safe to display. */
@@ -39,15 +40,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
39
40
  return typeof value === 'object' && value !== null && !Array.isArray(value);
40
41
  }
41
42
 
42
- const malformed = (message: string): DecodeFailure => ({ kind: 'malformed', message });
43
-
44
- function deepFreeze<T>(value: T): T {
45
- if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
46
- Object.freeze(value);
47
- for (const child of Object.values(value)) deepFreeze(child);
48
- }
49
- return value;
50
- }
43
+ const malformed = (message: string): DecodeFailure => ({
44
+ kind: 'malformed',
45
+ message,
46
+ });
51
47
 
52
48
  /**
53
49
  * Wrap a message that `decodeCase` has just validated against its upstream schema. Private
@@ -82,12 +78,16 @@ function decodeCase<Schema extends z.ZodTypeAny>(
82
78
  ): ValidatedMessage | DecodeFailure {
83
79
  const parsed = schema.safeParse(raw);
84
80
  if (!parsed.success) {
85
- return malformed(`Invalid ${tag} message: ${parsed.error.errors.map(formatZodIssue).join(', ')}`);
81
+ return malformed(
82
+ `Invalid ${tag} message: ${parsed.error.errors.map(formatZodIssue).join(', ')}`,
83
+ );
86
84
  }
87
85
  try {
88
86
  return build(parsed.data);
89
87
  } catch {
90
- return malformed(`Invalid ${tag} message: it contains a value that cannot be copied.`);
88
+ return malformed(
89
+ `Invalid ${tag} message: it contains a value that cannot be copied.`,
90
+ );
91
91
  }
92
92
  }
93
93
 
@@ -103,21 +103,44 @@ function describeVersion(version: unknown): string {
103
103
  function decodeMessage(raw: unknown): ValidatedMessage | DecodeFailure {
104
104
  if (!isRecord(raw)) return malformed('Each message must be an object.');
105
105
  if (!SUPPORTED_VERSIONS.includes(raw.version)) {
106
- return { kind: 'unsupported-version', message: `Unsupported A2UI version: ${describeVersion(raw.version)}. Supported: v0.9.` };
106
+ return {
107
+ kind: 'unsupported-version',
108
+ message: `Unsupported A2UI version: ${describeVersion(raw.version)}. Supported: v0.9.`,
109
+ };
107
110
  }
108
111
  // The only place wire tags are inspected. Upstream schemas are strict, so a message with
109
112
  // extra operations or fields fails validation of its case.
110
113
  if ('createSurface' in raw) {
111
- return decodeCase('createSurface', CreateSurfaceMessageSchema, raw, validatedMessage.createSurface);
114
+ return decodeCase(
115
+ 'createSurface',
116
+ CreateSurfaceMessageSchema,
117
+ raw,
118
+ validatedMessage.createSurface,
119
+ );
112
120
  }
113
121
  if ('updateComponents' in raw) {
114
- return decodeCase('updateComponents', UpdateComponentsMessageSchema, raw, validatedMessage.updateComponents);
122
+ return decodeCase(
123
+ 'updateComponents',
124
+ UpdateComponentsMessageSchema,
125
+ raw,
126
+ validatedMessage.updateComponents,
127
+ );
115
128
  }
116
129
  if ('updateDataModel' in raw) {
117
- return decodeCase('updateDataModel', UpdateDataModelMessageSchema, raw, validatedMessage.updateDataModel);
130
+ return decodeCase(
131
+ 'updateDataModel',
132
+ UpdateDataModelMessageSchema,
133
+ raw,
134
+ validatedMessage.updateDataModel,
135
+ );
118
136
  }
119
137
  if ('deleteSurface' in raw) {
120
- return decodeCase('deleteSurface', DeleteSurfaceMessageSchema, raw, validatedMessage.deleteSurface);
138
+ return decodeCase(
139
+ 'deleteSurface',
140
+ DeleteSurfaceMessageSchema,
141
+ raw,
142
+ validatedMessage.deleteSurface,
143
+ );
121
144
  }
122
145
  return malformed('Message has no supported operation.');
123
146
  }
@@ -126,7 +149,9 @@ function rawMessages(input: unknown): readonly unknown[] | DecodeFailure {
126
149
  if (Array.isArray(input)) return input;
127
150
  if (isRecord(input) && 'messages' in input) {
128
151
  if (!Array.isArray(input.messages) || Object.keys(input).length !== 1) {
129
- return malformed('A message list must be an object with only a "messages" array.');
152
+ return malformed(
153
+ 'A message list must be an object with only a "messages" array.',
154
+ );
130
155
  }
131
156
  return input.messages;
132
157
  }
@@ -135,7 +160,8 @@ function rawMessages(input: unknown): readonly unknown[] | DecodeFailure {
135
160
 
136
161
  function decodeInput(input: unknown): DecodeResult {
137
162
  const raws = rawMessages(input);
138
- if (!Array.isArray(raws)) return { ok: false, failure: raws as DecodeFailure };
163
+ if (!Array.isArray(raws))
164
+ return { ok: false, failure: raws as DecodeFailure };
139
165
  const messages: ValidatedMessage[] = [];
140
166
  for (const raw of raws) {
141
167
  const decoded = decodeMessage(raw);
@@ -155,7 +181,10 @@ export const a2uiV0_9Decoder: MessageDecoder = {
155
181
  try {
156
182
  return decodeInput(input);
157
183
  } catch {
158
- return { ok: false, failure: malformed('Input could not be inspected or parsed.') };
184
+ return {
185
+ ok: false,
186
+ failure: malformed('Input could not be inspected or parsed.'),
187
+ };
159
188
  }
160
189
  },
161
190
  };
@@ -0,0 +1,8 @@
1
+ /** Freezes a value and everything reachable from it, and returns it. */
2
+ export function deepFreeze<T>(value: T): T {
3
+ if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
4
+ Object.freeze(value);
5
+ for (const child of Object.values(value)) deepFreeze(child);
6
+ }
7
+ return value;
8
+ }
@@ -1,7 +1,20 @@
1
1
  // Public interface of the A2UI v0.9 protocol module. Decoding, validated message cases and the
2
2
  // message session are one coherent interface; nothing here imports a DOM, MCP or renderer.
3
- export { a2uiV0_9Decoder, type DecodeFailure, type DecodeResult, type MessageDecoder } from './decoder.js';
3
+ export {
4
+ a2uiV0_9Decoder,
5
+ type DecodeFailure,
6
+ type DecodeResult,
7
+ type MessageDecoder,
8
+ } from './decoder.js';
4
9
  export type { A2uiMessageVisitor, ValidatedMessage } from './message.js';
10
+ export {
11
+ defineSurface,
12
+ type ComponentDefinition,
13
+ type SurfaceLayout,
14
+ type SurfaceSpec,
15
+ type SurfaceUpdateFailure,
16
+ type UpdateInput,
17
+ } from './surface-layout.js';
5
18
  export {
6
19
  createMessageSession,
7
20
  type IngestFailure,
@@ -16,7 +16,9 @@ export interface IngestFailure {
16
16
  readonly message: string;
17
17
  }
18
18
 
19
- export type IngestResult = { readonly ok: true } | { readonly ok: false; readonly failure: IngestFailure };
19
+ export type IngestResult =
20
+ | { readonly ok: true }
21
+ | { readonly ok: false; readonly failure: IngestFailure };
20
22
 
21
23
  /**
22
24
  * Owns one upstream `MessageProcessor` and the mutable surface state inside it.
@@ -44,7 +46,9 @@ export interface MessageSessionOptions<T extends ComponentApi> {
44
46
  readonly decoder: MessageDecoder;
45
47
  }
46
48
 
47
- export function createMessageSession<T extends ComponentApi>(options: MessageSessionOptions<T>): MessageSession<T> {
49
+ export function createMessageSession<T extends ComponentApi>(
50
+ options: MessageSessionOptions<T>,
51
+ ): MessageSession<T> {
48
52
  const processor = new MessageProcessor<T>([...options.catalogs], undefined, {
49
53
  validationConfig: STRICT_VALIDATION,
50
54
  });
@@ -56,10 +60,14 @@ export function createMessageSession<T extends ComponentApi>(options: MessageSes
56
60
  // Upstream keeps supplied objects (e.g. the data-model value) and mutates them in place on
57
61
  // later updates, so it gets its own copy; the validated, frozen message stays unchanged.
58
62
  const apply: A2uiMessageVisitor<void> = {
59
- createSurface: (message) => processor.processMessages(structuredClone(message)),
60
- updateComponents: (message) => processor.processMessages(structuredClone(message)),
61
- updateDataModel: (message) => processor.processMessages(structuredClone(message)),
62
- deleteSurface: (message) => processor.processMessages(structuredClone(message)),
63
+ createSurface: (message) =>
64
+ processor.processMessages(structuredClone(message)),
65
+ updateComponents: (message) =>
66
+ processor.processMessages(structuredClone(message)),
67
+ updateDataModel: (message) =>
68
+ processor.processMessages(structuredClone(message)),
69
+ deleteSurface: (message) =>
70
+ processor.processMessages(structuredClone(message)),
63
71
  };
64
72
 
65
73
  const applyAll = (messages: readonly ValidatedMessage[]): IngestResult => {
@@ -69,16 +77,25 @@ export function createMessageSession<T extends ComponentApi>(options: MessageSes
69
77
  } catch (error) {
70
78
  return {
71
79
  ok: false,
72
- failure: { kind: 'rejected', message: error instanceof Error ? error.message : 'Unknown error' },
80
+ failure: {
81
+ kind: 'rejected',
82
+ message: error instanceof Error ? error.message : 'Unknown error',
83
+ },
73
84
  };
74
85
  }
75
86
  };
76
87
 
77
88
  return {
78
89
  ingest(input) {
79
- if (disposed) return { ok: false, failure: { kind: 'rejected', message: 'The session was disposed.' } };
90
+ if (disposed)
91
+ return {
92
+ ok: false,
93
+ failure: { kind: 'rejected', message: 'The session was disposed.' },
94
+ };
80
95
  const decoded = options.decoder.decode(input);
81
- return decoded.ok ? applyAll(decoded.messages) : { ok: false, failure: decoded.failure };
96
+ return decoded.ok
97
+ ? applyAll(decoded.messages)
98
+ : { ok: false, failure: decoded.failure };
82
99
  },
83
100
  onSurfaceCreated: (handler) => processor.onSurfaceCreated(handler),
84
101
  onSurfaceDeleted: (handler) => processor.onSurfaceDeleted(handler),
@@ -0,0 +1,256 @@
1
+ import type { z } from '../zod3.js';
2
+ import { deepFreeze } from './deep-freeze.js';
3
+
4
+ /**
5
+ * Surface authoring for A2UI v0.9: an application states a surface's data and its component
6
+ * layout, and this module is the one place that writes the protocol's messages for it. Wire
7
+ * names and the version literal stay here (see "Protocol input becomes a value object" in the
8
+ * coding standards).
9
+ *
10
+ * Bounded on purpose: one root component over a flat data model, plus an explicit list of
11
+ * further components passed through unchanged. It is not a binding engine, and it does not
12
+ * validate component names or properties itself. Every generated message is meant to go
13
+ * through `a2uiV0_9Decoder` and the upstream processor like any other input, which is where
14
+ * catalog support is decided.
15
+ */
16
+
17
+ /** The fixed id of a layout's root component, as the upstream renderer expects. */
18
+ const ROOT_ID = 'root';
19
+ const VERSION = 'v0.9';
20
+
21
+ /** One component of a layout as the wire format spells it; the catalog decides what it means. */
22
+ export interface ComponentDefinition {
23
+ readonly id: string;
24
+ readonly component: string;
25
+ readonly [property: string]: unknown;
26
+ }
27
+
28
+ /** Why `update` refused data. `message` is plain text that is safe to display. */
29
+ export interface SurfaceUpdateFailure {
30
+ readonly kind: 'invalid-data';
31
+ readonly message: string;
32
+ }
33
+
34
+ /** Decoder input for one data update, or the reason no message was produced. */
35
+ export type UpdateInput =
36
+ | { readonly ok: true; readonly messages: readonly unknown[] }
37
+ | { readonly ok: false; readonly failure: SurfaceUpdateFailure };
38
+
39
+ /** A surface as an application defines it: identity, typed data and the messages that open it. */
40
+ export interface SurfaceLayout<TData> {
41
+ readonly surfaceId: string;
42
+ readonly catalogId: string;
43
+ /** The data the surface opens with. */
44
+ readonly initial: TData;
45
+ /**
46
+ * `createSurface`, `updateComponents` and the initial `updateDataModel`, as decoder input.
47
+ * Deeply frozen, so one layout can open any number of sessions.
48
+ */
49
+ readonly opening: readonly unknown[];
50
+ /**
51
+ * The `updateDataModel` message that replaces the whole data model with `data`, as decoder
52
+ * input. The data must satisfy the layout's schema exactly: an unknown field (at any depth) is
53
+ * refused, never dropped, and no default is filled in and nothing is coerced.
54
+ */
55
+ update(data: TData): UpdateInput;
56
+ }
57
+
58
+ export interface SurfaceSpec<Schema extends z.ZodObject<z.ZodRawShape>> {
59
+ readonly surfaceId: string;
60
+ readonly catalogId: string;
61
+ /** The flat data model: a Zod 3 object whose fields are the surface's data. */
62
+ readonly data: Schema;
63
+ readonly root: {
64
+ readonly component: string;
65
+ /** Root property name -> the data field it shows, as a path binding. */
66
+ readonly bind: Readonly<
67
+ Record<string, Extract<keyof z.infer<Schema>, string>>
68
+ >;
69
+ /** Root properties that are plain values rather than bindings. */
70
+ readonly props?: Readonly<Record<string, unknown>>;
71
+ };
72
+ /** The other components, passed through exactly as written. Their ids must be distinct. */
73
+ readonly components?: readonly ComponentDefinition[];
74
+ readonly initial: z.infer<Schema>;
75
+ }
76
+
77
+ /** The text of every issue, each led by the field it is about (empty path means the whole value). */
78
+ function describeIssues(label: string, error: z.ZodError): string {
79
+ return error.issues
80
+ .map((issue) =>
81
+ issue.path.length > 0
82
+ ? `${label}.${issue.path.join('.')}: ${issue.message}`
83
+ : `${label}: ${issue.message}`,
84
+ )
85
+ .join('; ');
86
+ }
87
+
88
+ /** The path of the first place where `parsed` differs from `given`, or undefined when equal. */
89
+ function firstChange(
90
+ given: unknown,
91
+ parsed: unknown,
92
+ path: readonly (string | number)[],
93
+ ): string | undefined {
94
+ if (Object.is(given, parsed)) return undefined;
95
+ const here = path.join('.');
96
+ const bothArrays = Array.isArray(given) && Array.isArray(parsed);
97
+ const bothRecords =
98
+ !bothArrays &&
99
+ typeof given === 'object' &&
100
+ given !== null &&
101
+ typeof parsed === 'object' &&
102
+ parsed !== null &&
103
+ !Array.isArray(given) &&
104
+ !Array.isArray(parsed);
105
+ if (!bothArrays && !bothRecords) return here;
106
+ const keys = new Set([
107
+ ...Object.keys(given as object),
108
+ ...Object.keys(parsed as object),
109
+ ]);
110
+ for (const key of keys) {
111
+ const before = (given as Record<string, unknown>)[key];
112
+ const after = (parsed as Record<string, unknown>)[key];
113
+ // An optional field given as `undefined` and one that is absent mean the same thing.
114
+ if (before === undefined && after === undefined) continue;
115
+ const changed = firstChange(before, after, [...path, key]);
116
+ if (changed !== undefined) return changed;
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ /**
122
+ * Checks `value` against the schema and requires that parsing leaves it exactly as given. Zod
123
+ * strips unknown keys of nested objects, applies defaults and runs coercions and transforms; any
124
+ * of those would change what the surface shows from what the caller passed, so each is refused,
125
+ * naming the first changed field.
126
+ */
127
+ function checkExact<Schema extends z.ZodObject<z.ZodRawShape>>(
128
+ schema: Schema,
129
+ label: string,
130
+ value: unknown,
131
+ ): { ok: true; data: z.infer<Schema> } | { ok: false; message: string } {
132
+ const parsed = schema.safeParse(value);
133
+ if (!parsed.success)
134
+ return { ok: false, message: describeIssues(label, parsed.error) };
135
+ const changed = firstChange(value, parsed.data, [label]);
136
+ if (changed !== undefined)
137
+ return {
138
+ ok: false,
139
+ message: `${changed}: the schema would change this value (an unknown field dropped, a default applied or a conversion made); give exactly the data the schema describes.`,
140
+ };
141
+ return { ok: true, data: parsed.data };
142
+ }
143
+
144
+ /** A JSON Pointer to a top-level field (RFC 6901 escapes). */
145
+ const pointerTo = (field: string): string =>
146
+ `/${field.replaceAll('~', '~0').replaceAll('/', '~1')}`;
147
+
148
+ function requireText(name: string, value: string, surfaceId: string): void {
149
+ if (value.length === 0)
150
+ throw new Error(`defineSurface(${surfaceId}): ${name} must not be empty.`);
151
+ }
152
+
153
+ /**
154
+ * Defines a surface. Throws, naming the field, when the definition is inconsistent: an
155
+ * `initial` that fails the schema, a binding to a field the schema does not have, a property
156
+ * given both as a binding and as a value, or component ids that repeat or reuse the root's.
157
+ * Whether the catalog supports the components is not decided here; opening the surface in a
158
+ * session reports that.
159
+ */
160
+ export function defineSurface<Schema extends z.ZodObject<z.ZodRawShape>>(
161
+ spec: SurfaceSpec<Schema>,
162
+ ): SurfaceLayout<z.infer<Schema>> {
163
+ const { surfaceId, catalogId, root } = spec;
164
+ requireText('surfaceId', surfaceId, surfaceId);
165
+ requireText('catalogId', catalogId, surfaceId);
166
+ requireText('root.component', root.component, surfaceId);
167
+
168
+ // Strict: data with a field the schema lacks is refused, not silently dropped.
169
+ const schema = spec.data.strict();
170
+ const label = `defineSurface(${surfaceId})`;
171
+
172
+ const fields = Object.keys(spec.data.shape);
173
+ const props = root.props ?? {};
174
+ for (const [property, field] of Object.entries(root.bind)) {
175
+ if (!fields.includes(field))
176
+ throw new Error(
177
+ `${label}: root.bind.${property} names the field "${field}", which the data schema does not define.`,
178
+ );
179
+ if (property in props)
180
+ throw new Error(
181
+ `${label}: root property "${property}" is both bound and given a value.`,
182
+ );
183
+ }
184
+ const reserved = ['id', 'component'];
185
+ for (const property of [...Object.keys(root.bind), ...Object.keys(props)]) {
186
+ if (reserved.includes(property))
187
+ throw new Error(
188
+ `${label}: root property "${property}" is set by the layout and cannot be supplied.`,
189
+ );
190
+ }
191
+ const ids = new Set<string>([ROOT_ID]);
192
+ for (const entry of spec.components ?? []) {
193
+ if (ids.has(entry.id))
194
+ throw new Error(
195
+ `${label}: component id "${entry.id}" is repeated or reuses the root's id "${ROOT_ID}".`,
196
+ );
197
+ ids.add(entry.id);
198
+ }
199
+
200
+ const initial = checkExact(schema, 'initial', spec.initial);
201
+ if (!initial.ok) throw new Error(`${label}: ${initial.message}`);
202
+ const initialData: z.infer<Schema> = deepFreeze(
203
+ structuredClone(initial.data),
204
+ );
205
+
206
+ const bindings = Object.fromEntries(
207
+ Object.entries(root.bind).map(([property, field]) => [
208
+ property,
209
+ { path: pointerTo(field) },
210
+ ]),
211
+ );
212
+ const rootComponent = {
213
+ id: ROOT_ID,
214
+ component: root.component,
215
+ ...props,
216
+ ...bindings,
217
+ };
218
+
219
+ const replaceData = (value: unknown): unknown => ({
220
+ version: VERSION,
221
+ updateDataModel: { surfaceId, value },
222
+ });
223
+
224
+ const opening = deepFreeze(
225
+ structuredClone([
226
+ { version: VERSION, createSurface: { surfaceId, catalogId } },
227
+ {
228
+ version: VERSION,
229
+ updateComponents: {
230
+ surfaceId,
231
+ components: [rootComponent, ...(spec.components ?? [])],
232
+ },
233
+ },
234
+ replaceData(initialData),
235
+ ]),
236
+ );
237
+
238
+ return {
239
+ surfaceId,
240
+ catalogId,
241
+ initial: initialData,
242
+ opening,
243
+ update(data) {
244
+ const checked = checkExact(schema, 'data', data);
245
+ if (!checked.ok)
246
+ return {
247
+ ok: false,
248
+ failure: { kind: 'invalid-data', message: checked.message },
249
+ };
250
+ return {
251
+ ok: true,
252
+ messages: deepFreeze(structuredClone([replaceData(checked.data)])),
253
+ };
254
+ },
255
+ };
256
+ }