@echothink-ui/runtime 0.1.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/src/index.tsx ADDED
@@ -0,0 +1,377 @@
1
+ import * as React from "react";
2
+ import { satisfies, valid } from "semver";
3
+ import { ErrorState, EmptyState, Surface } from "@echothink-ui/core";
4
+ import { compositionDocumentSchema, validateWithSchema } from "@echothink-ui/validators";
5
+
6
+ export type EthSurfaceType =
7
+ | "page"
8
+ | "shell"
9
+ | "rail"
10
+ | "panel"
11
+ | "drawer"
12
+ | "modal"
13
+ | "table"
14
+ | "chart"
15
+ | "form"
16
+ | "inspector"
17
+ | "inline"
18
+ | "composite";
19
+
20
+ export interface EthCompositionDocument {
21
+ schemaVersion: "eth.ui.composition.v1";
22
+ id: string;
23
+ title: string;
24
+ generatedBy: "echothink-core" | "app-domain" | "human-authored";
25
+ versionLock: EthVersionLock;
26
+ audit: EthAuditMetadata;
27
+ permissions?: EthPermissionConstraint[];
28
+ dataSources?: Record<string, EthDataSource>;
29
+ resources?: Record<string, EthResourceReference>;
30
+ layout: EthLayoutNode;
31
+ eventHandlers?: Record<string, EthEventHandler>;
32
+ fallbacks?: Record<string, EthFallbackSpec>;
33
+ telemetry?: EthTelemetrySpec;
34
+ }
35
+
36
+ export interface EthVersionLock {
37
+ runtime: string;
38
+ skills: Record<string, string>;
39
+ components?: Record<string, string>;
40
+ tokens?: string;
41
+ }
42
+ export interface EthAuditMetadata {
43
+ requestId: string;
44
+ traceId?: string;
45
+ projectId?: string;
46
+ userId?: string;
47
+ generatedAt: string;
48
+ reason?: string;
49
+ sourceRefs?: string[];
50
+ riskLevel?: "low" | "medium" | "high" | "critical";
51
+ }
52
+ export interface EthLayoutNode {
53
+ id: string;
54
+ type: EthSurfaceType;
55
+ component?: EthComponentReference;
56
+ skill?: EthSkillReference;
57
+ props?: Record<string, unknown>;
58
+ bindings?: Record<string, EthBinding>;
59
+ permissions?: EthPermissionConstraint[];
60
+ children?: EthLayoutNode[];
61
+ slots?: Record<string, EthLayoutNode | EthLayoutNode[]>;
62
+ loading?: EthStateSpec;
63
+ empty?: EthStateSpec;
64
+ errorBoundary?: EthErrorBoundarySpec;
65
+ responsive?: EthResponsiveSpec;
66
+ fallback?: EthFallbackSpec;
67
+ visibility?: EthVisibilityRule;
68
+ }
69
+ export interface EthSkillReference {
70
+ id: string;
71
+ version: string;
72
+ input?: Record<string, unknown>;
73
+ childPolicy?: "none" | "declared-only" | "any-registered";
74
+ }
75
+ export interface EthComponentReference {
76
+ package: string;
77
+ exportName: string;
78
+ version?: string;
79
+ }
80
+ export interface EthDataSource {
81
+ kind: "query" | "stream" | "resource" | "wall" | "inline" | "computed";
82
+ ref: string;
83
+ schema?: string;
84
+ params?: Record<string, unknown>;
85
+ refresh?: { mode: "manual" | "interval" | "realtime"; intervalMs?: number };
86
+ cache?: { ttlMs?: number; key?: string };
87
+ redaction?: EthRedactionPolicy;
88
+ }
89
+ export interface EthResourceReference {
90
+ kind: "project" | "task" | "document" | "file" | "app-domain" | "agent-run" | "custom";
91
+ id: string;
92
+ wallRef?: string;
93
+ version?: string;
94
+ tenantBoundary?: string;
95
+ }
96
+ export interface EthBinding {
97
+ source: string;
98
+ path?: string;
99
+ transform?: string;
100
+ required?: boolean;
101
+ }
102
+ export interface EthPermissionConstraint {
103
+ action: string;
104
+ resource?: string;
105
+ mode?: "render" | "read" | "mutate" | "approve" | "export";
106
+ fallback?: "hide" | "disable" | "redact" | "readonly" | "error";
107
+ }
108
+ export interface EthEventHandler {
109
+ event: string;
110
+ target: "echothink-core" | "local-runtime" | "app-domain-service";
111
+ action: string;
112
+ payloadBinding?: Record<string, EthBinding | unknown>;
113
+ requiresPermission?: EthPermissionConstraint[];
114
+ audit?: boolean;
115
+ optimistic?: boolean;
116
+ }
117
+ export interface EthStateSpec {
118
+ kind: "default" | "component" | "skill" | "message";
119
+ message?: string;
120
+ component?: EthComponentReference;
121
+ skill?: EthSkillReference;
122
+ }
123
+ export interface EthErrorBoundarySpec {
124
+ mode: "local" | "region" | "page";
125
+ fallback: EthStateSpec;
126
+ reportToTelemetry?: boolean;
127
+ }
128
+ export interface EthResponsiveSpec {
129
+ breakpoints?: Record<string, Partial<EthLayoutNode>>;
130
+ density?: "compact" | "default" | "comfortable";
131
+ collapseBehavior?: "stack" | "tabs" | "drawer" | "hide-secondary";
132
+ }
133
+ export interface EthFallbackSpec {
134
+ reason?: string;
135
+ state: EthStateSpec;
136
+ }
137
+ export interface EthVisibilityRule {
138
+ when?: string;
139
+ defaultVisible?: boolean;
140
+ }
141
+ export interface EthTelemetrySpec {
142
+ impressions?: boolean;
143
+ interactions?: boolean;
144
+ performance?: boolean;
145
+ errors?: boolean;
146
+ }
147
+ export interface EthRedactionPolicy {
148
+ policyId: string;
149
+ fields?: string[];
150
+ replacement?: "mask" | "placeholder" | "omit";
151
+ }
152
+
153
+ export interface EthSkillDefinition<TInput = unknown> {
154
+ id: string;
155
+ version: string;
156
+ name: string;
157
+ category: string;
158
+ description?: string;
159
+ inputSchema?: {
160
+ safeParse: (value: unknown) => { success: boolean; data?: TInput; error?: unknown };
161
+ };
162
+ outputSurfaceType: EthSurfaceType;
163
+ allowedChildSkills?: string[];
164
+ requiredPermissions?: EthPermissionConstraint[];
165
+ requiredDataSources?: string[];
166
+ events?: string[];
167
+ accessibility?: Record<string, unknown>;
168
+ deprecated?: { since: string; replacement: string; removeIn: string };
169
+ load: () => Promise<{ default: React.ComponentType<any> }>;
170
+ }
171
+
172
+ export class SkillRegistry {
173
+ private skills = new Map<string, EthSkillDefinition<any>>();
174
+ register(skill: EthSkillDefinition<any>) {
175
+ const key = this.key(skill.id, skill.version);
176
+ if (this.skills.has(key)) throw new Error(`Skill already registered: ${key}`);
177
+ this.skills.set(key, skill);
178
+ }
179
+ registerMany(skills: EthSkillDefinition<any>[]) {
180
+ skills.forEach((skill) => this.register(skill));
181
+ }
182
+ resolve(id: string, versionRange: string) {
183
+ const candidates = [...this.skills.values()].filter((skill) => skill.id === id);
184
+ const match =
185
+ candidates.find(
186
+ (skill) =>
187
+ valid(skill.version) &&
188
+ satisfies(skill.version, versionRange, { includePrerelease: true })
189
+ ) ?? candidates.find((skill) => skill.version === versionRange);
190
+ if (!match) throw new Error(`No compatible skill found for ${id}@${versionRange}`);
191
+ return match;
192
+ }
193
+ list() {
194
+ return [...this.skills.values()];
195
+ }
196
+ private key(id: string, version: string) {
197
+ return `${id}@${version}`;
198
+ }
199
+ }
200
+
201
+ export interface EthDataBindingResolver {
202
+ resolve(binding: EthBinding, document: EthCompositionDocument): unknown | Promise<unknown>;
203
+ }
204
+ export interface EthPermissionGuard {
205
+ evaluate(constraints: EthPermissionConstraint[]): EthPermissionResult;
206
+ }
207
+ export interface EthEventBridge {
208
+ dispatch(event: string, payload: unknown, context: EthEventContext): void | Promise<void>;
209
+ }
210
+ export interface EthEventContext {
211
+ document: EthCompositionDocument;
212
+ handler?: EthEventHandler;
213
+ node?: EthLayoutNode;
214
+ }
215
+ export interface EthPermissionResult {
216
+ allowed: boolean;
217
+ fallback?: "hide" | "disable" | "redact" | "readonly" | "error";
218
+ reason?: string;
219
+ }
220
+
221
+ export const allowAllPermissions: EthPermissionGuard = { evaluate: () => ({ allowed: true }) };
222
+ export const noopEventBridge: EthEventBridge = { dispatch: () => undefined };
223
+ export const fixtureDataResolver: EthDataBindingResolver = {
224
+ resolve(binding, document) {
225
+ const source = document.dataSources?.[binding.source] ?? document.resources?.[binding.source];
226
+ return binding.path ? getByPath(source, binding.path) : source;
227
+ }
228
+ };
229
+
230
+ export interface CompositionRendererProps {
231
+ document: EthCompositionDocument;
232
+ registry: SkillRegistry;
233
+ dataResolver?: EthDataBindingResolver;
234
+ permissionGuard?: EthPermissionGuard;
235
+ eventBridge?: EthEventBridge;
236
+ components?: Record<string, React.ComponentType<any>>;
237
+ }
238
+
239
+ const RuntimeContext = React.createContext<
240
+ | (Required<Omit<CompositionRendererProps, "document">> & { document: EthCompositionDocument })
241
+ | null
242
+ >(null);
243
+
244
+ export function CompositionRenderer({
245
+ document,
246
+ registry,
247
+ dataResolver = fixtureDataResolver,
248
+ permissionGuard = allowAllPermissions,
249
+ eventBridge = noopEventBridge,
250
+ components = {}
251
+ }: CompositionRendererProps) {
252
+ const validation = validateCompositionDocument(document);
253
+ if (!validation.ok)
254
+ return (
255
+ <ErrorState
256
+ title="Invalid composition"
257
+ description={validation.errors.map((error) => error.message).join("; ")}
258
+ />
259
+ );
260
+ return (
261
+ <RuntimeContext.Provider
262
+ value={{ document, registry, dataResolver, permissionGuard, eventBridge, components }}
263
+ >
264
+ <RenderNode node={document.layout} />
265
+ </RuntimeContext.Provider>
266
+ );
267
+ }
268
+
269
+ export function RenderNode({ node }: { node: EthLayoutNode }) {
270
+ const ctx = useRuntimeContext();
271
+ const permission = ctx.permissionGuard.evaluate([
272
+ ...(ctx.document.permissions ?? []),
273
+ ...(node.permissions ?? [])
274
+ ]);
275
+ if (!permission.allowed && permission.fallback === "hide") return null;
276
+ if (!permission.allowed && permission.fallback === "error")
277
+ return <ErrorState title="Permission denied" description={permission.reason} />;
278
+ if (node.visibility?.defaultVisible === false) return null;
279
+ const children = node.children?.map((child) => <RenderNode key={child.id} node={child} />);
280
+ if (node.skill)
281
+ return (
282
+ <RenderSkillNode
283
+ node={node}
284
+ disabled={!permission.allowed && permission.fallback === "disable"}
285
+ >
286
+ {children}
287
+ </RenderSkillNode>
288
+ );
289
+ if (node.component) {
290
+ const Component = ctx.components[node.component.exportName] ?? Surface;
291
+ return (
292
+ <Component {...node.props} data-eth-node-id={node.id}>
293
+ {children}
294
+ </Component>
295
+ );
296
+ }
297
+ return (
298
+ <Surface title={node.props?.title as string | undefined} data-eth-node-id={node.id}>
299
+ {children}
300
+ </Surface>
301
+ );
302
+ }
303
+
304
+ function RenderSkillNode({
305
+ node,
306
+ disabled,
307
+ children
308
+ }: {
309
+ node: EthLayoutNode;
310
+ disabled?: boolean;
311
+ children?: React.ReactNode;
312
+ }) {
313
+ const ctx = useRuntimeContext();
314
+ const [Component, setComponent] = React.useState<React.ComponentType<any> | null>(null);
315
+ const [error, setError] = React.useState<Error | null>(null);
316
+ React.useEffect(() => {
317
+ let mounted = true;
318
+ Promise.resolve()
319
+ .then(() => ctx.registry.resolve(node.skill!.id, node.skill!.version).load())
320
+ .then((module) => mounted && setComponent(() => module.default))
321
+ .catch(
322
+ (reason) =>
323
+ mounted && setError(reason instanceof Error ? reason : new Error(String(reason)))
324
+ );
325
+ return () => {
326
+ mounted = false;
327
+ };
328
+ }, [ctx.registry, node.skill]);
329
+ if (error) return <ErrorState title="Skill failed to load" description={error.message} />;
330
+ if (!Component) return <EmptyState title="Loading skill" description={node.skill?.id} />;
331
+ return (
332
+ <Component
333
+ {...node.props}
334
+ {...node.skill?.input}
335
+ disabled={disabled}
336
+ data-eth-node-id={node.id}
337
+ >
338
+ {children}
339
+ </Component>
340
+ );
341
+ }
342
+
343
+ function useRuntimeContext() {
344
+ const value = React.useContext(RuntimeContext);
345
+ if (!value) throw new Error("Composition runtime context is missing");
346
+ return value;
347
+ }
348
+
349
+ export function validateCompositionDocument(value: unknown) {
350
+ return validateWithSchema(compositionDocumentSchema, value);
351
+ }
352
+
353
+ export function redactValue(value: unknown, policy?: EthRedactionPolicy): unknown {
354
+ if (!policy || !value || typeof value !== "object") return value;
355
+ if (Array.isArray(value)) return value.map((item) => redactValue(item, policy));
356
+ const output: Record<string, unknown> = {};
357
+ for (const [key, fieldValue] of Object.entries(value)) {
358
+ if (policy.fields?.includes(key)) {
359
+ if (policy.replacement === "omit") continue;
360
+ output[key] = policy.replacement === "placeholder" ? "[redacted]" : "••••";
361
+ } else {
362
+ output[key] = redactValue(fieldValue, policy);
363
+ }
364
+ }
365
+ return output;
366
+ }
367
+
368
+ function getByPath(value: unknown, path: string) {
369
+ return path
370
+ .replace(/^\$\.?/, "")
371
+ .split(".")
372
+ .filter(Boolean)
373
+ .reduce<unknown>((current, key) => {
374
+ if (current && typeof current === "object") return (current as Record<string, unknown>)[key];
375
+ return undefined;
376
+ }, value);
377
+ }