openui-telegram 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +106 -2
- package/dist/client-features.js +9 -0
- package/dist/client-text.d.ts +1 -0
- package/dist/client-text.js +6 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +196 -0
- package/dist/compiler.d.ts +94 -0
- package/dist/compiler.js +157 -0
- package/dist/controller.d.ts +42 -0
- package/dist/controller.js +61 -0
- package/dist/dom.js +72 -0
- package/dist/host-client-bundle.d.ts +19 -0
- package/dist/host-client-bundle.js +183 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +8 -0
- package/dist/javascript-data.js +39 -0
- package/dist/message.d.ts +10 -0
- package/dist/message.js +44 -0
- package/dist/runtime.d.ts +174 -0
- package/dist/runtime.js +514 -0
- package/dist/session.d.ts +40 -0
- package/dist/session.js +71 -0
- package/dist/transport.d.ts +37 -0
- package/dist/transport.js +34 -0
- package/dist/wire-apply.d.ts +17 -0
- package/dist/wire-apply.js +48 -0
- package/dist/wire.d.ts +3 -0
- package/dist/wire.js +68 -0
- package/package.json +58 -4
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { ActionEvent, EvaluationContext, Library, OpenUIError, ParseResult, StateField, ValidatorFn } from '@openuidev/lang-core';
|
|
2
|
+
export type Diagnostic = Omit<OpenUIError, 'source' | 'code'> & {
|
|
3
|
+
source: OpenUIError['source'] | 'transport' | 'platform' | 'compiler';
|
|
4
|
+
code: string;
|
|
5
|
+
};
|
|
6
|
+
export interface Operation {
|
|
7
|
+
id: string;
|
|
8
|
+
instanceId: string;
|
|
9
|
+
version: number;
|
|
10
|
+
type: 'query' | 'mutation' | 'action';
|
|
11
|
+
tool: string;
|
|
12
|
+
args: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
export interface OperationReply {
|
|
15
|
+
id: string;
|
|
16
|
+
instanceId: string;
|
|
17
|
+
version: number;
|
|
18
|
+
result?: unknown;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface SavedState {
|
|
22
|
+
state: Record<string, unknown>;
|
|
23
|
+
forms: Record<string, Record<string, unknown>>;
|
|
24
|
+
results?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
export interface ComponentContext<P = any> {
|
|
27
|
+
props: P;
|
|
28
|
+
statementId?: string;
|
|
29
|
+
id: string;
|
|
30
|
+
scope?: string;
|
|
31
|
+
renderNode(value: unknown, options?: {
|
|
32
|
+
scope?: string;
|
|
33
|
+
}): Promise<string>;
|
|
34
|
+
text(value: unknown): string;
|
|
35
|
+
/** Attribute for a delegated click handler. No inline JavaScript or data interpolation. */
|
|
36
|
+
action(value: unknown, options?: {
|
|
37
|
+
label?: string;
|
|
38
|
+
form?: string;
|
|
39
|
+
}): string;
|
|
40
|
+
field<T = unknown>(name: string, binding?: unknown, options?: {
|
|
41
|
+
scope?: string;
|
|
42
|
+
rules?: unknown;
|
|
43
|
+
}): StateField<T>;
|
|
44
|
+
form(scope?: string): {
|
|
45
|
+
values: Record<string, unknown>;
|
|
46
|
+
errors: Record<string, string>;
|
|
47
|
+
};
|
|
48
|
+
runtime: WidgetRuntime;
|
|
49
|
+
}
|
|
50
|
+
export interface ClientContext<P = any> extends Omit<ComponentContext<P>, 'runtime' | 'renderNode' | 'action'> {
|
|
51
|
+
element: HTMLElement;
|
|
52
|
+
on(target: EventTarget, event: string, handler: EventListener): void;
|
|
53
|
+
}
|
|
54
|
+
export interface FieldDescriptor {
|
|
55
|
+
name: string;
|
|
56
|
+
scope: string;
|
|
57
|
+
handle: string;
|
|
58
|
+
reactive: boolean;
|
|
59
|
+
rules?: unknown;
|
|
60
|
+
}
|
|
61
|
+
export interface WidgetComponent<P = any> {
|
|
62
|
+
render(context: ComponentContext<P>): string | Promise<string>;
|
|
63
|
+
/** Runs after insertion; returned cleanup runs before removal or replacement. */
|
|
64
|
+
mount?(context: ClientContext<P>): void | (() => void);
|
|
65
|
+
update?(context: ClientContext<P>): void;
|
|
66
|
+
reactive?: string[];
|
|
67
|
+
css?: string;
|
|
68
|
+
}
|
|
69
|
+
export interface RuntimeLibrary {
|
|
70
|
+
components: Record<string, WidgetComponent>;
|
|
71
|
+
functions?: EvaluationContext['functions'];
|
|
72
|
+
actions?: EvaluationContext['actions'];
|
|
73
|
+
}
|
|
74
|
+
export interface RuntimeOptions {
|
|
75
|
+
instanceId: string;
|
|
76
|
+
version?: number;
|
|
77
|
+
initialState?: SavedState;
|
|
78
|
+
onOperation?(operation: Operation): unknown | Promise<unknown>;
|
|
79
|
+
onAction?(event: ActionEvent): void | Promise<void>;
|
|
80
|
+
onDiagnostic?(diagnostic: Diagnostic): void;
|
|
81
|
+
validators?: Record<string, ValidatorFn>;
|
|
82
|
+
openUrl?(url: string): void;
|
|
83
|
+
/** Compiler uses defaults without starting tools. Normal instances execute queries. */
|
|
84
|
+
executeQueries?: boolean;
|
|
85
|
+
}
|
|
86
|
+
export declare function text(value: unknown): string;
|
|
87
|
+
export declare function runtimeLibrary(library: Library<WidgetComponent>): RuntimeLibrary;
|
|
88
|
+
/** Framework-free execution. Parser results come from the official OpenUI parser. */
|
|
89
|
+
export declare class WidgetRuntime {
|
|
90
|
+
private library;
|
|
91
|
+
private options;
|
|
92
|
+
readonly store: import("@openuidev/lang-core").Store;
|
|
93
|
+
queries: import("@openuidev/lang-core").QueryManager;
|
|
94
|
+
readonly diagnostics: Diagnostic[];
|
|
95
|
+
readonly instanceId: string;
|
|
96
|
+
version: number;
|
|
97
|
+
private sequence;
|
|
98
|
+
private readonly incarnation;
|
|
99
|
+
private disposed;
|
|
100
|
+
private program;
|
|
101
|
+
private forms;
|
|
102
|
+
private restoredResults;
|
|
103
|
+
private fields;
|
|
104
|
+
private fieldSignature;
|
|
105
|
+
private listeners;
|
|
106
|
+
private pending;
|
|
107
|
+
private clicks;
|
|
108
|
+
private context;
|
|
109
|
+
private evalLibrary;
|
|
110
|
+
private unsubscribers;
|
|
111
|
+
private records;
|
|
112
|
+
private fieldDescriptors;
|
|
113
|
+
private fieldHandles;
|
|
114
|
+
constructor(library: RuntimeLibrary, program: ParseResult, options: RuntimeOptions);
|
|
115
|
+
private firingMutation;
|
|
116
|
+
private firingMutationId?;
|
|
117
|
+
private renderGeneration;
|
|
118
|
+
private queryChanged;
|
|
119
|
+
private createQueries;
|
|
120
|
+
report(diagnostic: Diagnostic): void;
|
|
121
|
+
private check;
|
|
122
|
+
private notify;
|
|
123
|
+
subscribe(listener: () => void): () => void;
|
|
124
|
+
private sync;
|
|
125
|
+
private operation;
|
|
126
|
+
reply(reply: OperationReply): boolean;
|
|
127
|
+
snapshot(): SavedState;
|
|
128
|
+
restore(saved: SavedState): void;
|
|
129
|
+
setState(name: string, value: unknown): void;
|
|
130
|
+
getState(name: string): unknown;
|
|
131
|
+
get querySnapshot(): import("@openuidev/lang-core").QuerySnapshot;
|
|
132
|
+
get parsed(): ParseResult;
|
|
133
|
+
get hasActions(): boolean;
|
|
134
|
+
clientRecords(): {
|
|
135
|
+
[k: string]: {
|
|
136
|
+
type: string;
|
|
137
|
+
props: any;
|
|
138
|
+
statementId: string | undefined;
|
|
139
|
+
scope: string | undefined;
|
|
140
|
+
fields: FieldDescriptor[] | undefined;
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
requiredStyles(): string[];
|
|
144
|
+
fieldValues(): {
|
|
145
|
+
[k: string]: unknown;
|
|
146
|
+
};
|
|
147
|
+
formErrors(): {
|
|
148
|
+
[k: string]: Record<string, string>;
|
|
149
|
+
};
|
|
150
|
+
setFieldHandle(handle: string, value: unknown): void;
|
|
151
|
+
nativeLink(id: string): string | undefined;
|
|
152
|
+
get nativeLinks(): {
|
|
153
|
+
[k: string]: string;
|
|
154
|
+
};
|
|
155
|
+
replace(program: ParseResult, version?: number): void;
|
|
156
|
+
form(scope?: string): {
|
|
157
|
+
values: {
|
|
158
|
+
[x: string]: unknown;
|
|
159
|
+
};
|
|
160
|
+
errors: Record<string, string>;
|
|
161
|
+
};
|
|
162
|
+
private formFields;
|
|
163
|
+
field<T = unknown>(name: string, binding?: unknown, options?: {
|
|
164
|
+
scope?: string;
|
|
165
|
+
rules?: unknown;
|
|
166
|
+
}): StateField<T>;
|
|
167
|
+
private registerField;
|
|
168
|
+
trigger(value: unknown, label?: string, scope?: string): Promise<boolean>;
|
|
169
|
+
click(id: string): Promise<boolean>;
|
|
170
|
+
render(): Promise<string>;
|
|
171
|
+
/** Attach to an existing DOM root. All listeners and component resources are owned here. */
|
|
172
|
+
mount(element: HTMLElement): () => void;
|
|
173
|
+
dispose(): void;
|
|
174
|
+
}
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { createStore, createQueryManager, evaluate, evaluateRoot, isElementNode, markReactive, resolveStateField, parseRules, parseStructuredRules, validate, } from '@openuidev/lang-core';
|
|
2
|
+
import { mountWidgetClient } from './client.js';
|
|
3
|
+
function clone(value) {
|
|
4
|
+
if (Array.isArray(value))
|
|
5
|
+
return value.map(clone);
|
|
6
|
+
if (value && typeof value === 'object')
|
|
7
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, clone(v)]));
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
function own(record, key) {
|
|
11
|
+
return Object.hasOwn(record, key) ? record[key] : undefined;
|
|
12
|
+
}
|
|
13
|
+
function put(record, key, value) {
|
|
14
|
+
Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
|
|
15
|
+
}
|
|
16
|
+
export function text(value) {
|
|
17
|
+
return String(value ?? '').replace(/[&<>"']/g, c => ({
|
|
18
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
19
|
+
})[c]);
|
|
20
|
+
}
|
|
21
|
+
export function runtimeLibrary(library) {
|
|
22
|
+
const schema = library.toJSONSchema();
|
|
23
|
+
return {
|
|
24
|
+
components: Object.fromEntries(Object.entries(library.components).map(([name, c]) => [name, c.component])),
|
|
25
|
+
functions: Object.fromEntries(Object.entries(library.functions ?? {}).map(([name, f]) => [name, { ...schema.functions[name], fn: f.fn }])),
|
|
26
|
+
actions: schema.actions,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Framework-free execution. Parser results come from the official OpenUI parser. */
|
|
30
|
+
export class WidgetRuntime {
|
|
31
|
+
library;
|
|
32
|
+
options;
|
|
33
|
+
store = createStore();
|
|
34
|
+
queries;
|
|
35
|
+
diagnostics = [];
|
|
36
|
+
instanceId;
|
|
37
|
+
version;
|
|
38
|
+
sequence = 0;
|
|
39
|
+
incarnation = Math.random().toString(36).slice(2);
|
|
40
|
+
disposed = false;
|
|
41
|
+
program;
|
|
42
|
+
forms;
|
|
43
|
+
restoredResults;
|
|
44
|
+
fields = new Map();
|
|
45
|
+
fieldSignature = '';
|
|
46
|
+
listeners = new Set();
|
|
47
|
+
pending = new Map();
|
|
48
|
+
clicks = new Map();
|
|
49
|
+
context;
|
|
50
|
+
evalLibrary;
|
|
51
|
+
unsubscribers;
|
|
52
|
+
records = new Map();
|
|
53
|
+
fieldDescriptors = new Map();
|
|
54
|
+
fieldHandles = new Map();
|
|
55
|
+
constructor(library, program, options) {
|
|
56
|
+
this.library = library;
|
|
57
|
+
this.options = options;
|
|
58
|
+
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(options.instanceId))
|
|
59
|
+
throw new TypeError('instanceId must be 1–32 ASCII letters, digits, _ or -');
|
|
60
|
+
if (options.version !== undefined && (!Number.isSafeInteger(options.version) || options.version < 1))
|
|
61
|
+
throw new TypeError('version must be a positive integer');
|
|
62
|
+
this.instanceId = options.instanceId;
|
|
63
|
+
this.version = options.version ?? 1;
|
|
64
|
+
this.program = program;
|
|
65
|
+
this.forms = clone(options.initialState?.forms ?? {});
|
|
66
|
+
this.restoredResults = clone(options.initialState?.results ?? {});
|
|
67
|
+
this.evalLibrary = { components: Object.fromEntries(Object.entries(library.components).map(([name, c]) => {
|
|
68
|
+
const shape = Object.fromEntries((c.reactive ?? []).map(key => {
|
|
69
|
+
const schema = {};
|
|
70
|
+
markReactive(schema);
|
|
71
|
+
return [key, schema];
|
|
72
|
+
}));
|
|
73
|
+
return [name, { props: { shape } }];
|
|
74
|
+
})) };
|
|
75
|
+
this.context = {
|
|
76
|
+
getState: name => this.store.get(name),
|
|
77
|
+
resolveRef: name => {
|
|
78
|
+
return Object.hasOwn(this.restoredResults, name) ? this.restoredResults[name]
|
|
79
|
+
: this.queries.getMutationResult(name) ?? this.queries.getResult(name);
|
|
80
|
+
},
|
|
81
|
+
functions: library.functions, actions: library.actions,
|
|
82
|
+
reportError: message => this.report({ source: 'runtime', code: 'runtime-error', message }),
|
|
83
|
+
};
|
|
84
|
+
this.queries = this.createQueries();
|
|
85
|
+
this.store.initialize(program.stateDeclarations, options.initialState?.state ?? {});
|
|
86
|
+
this.unsubscribers = [this.store.subscribe(() => { this.sync(); this.notify(); }),
|
|
87
|
+
this.queries.subscribe(() => this.queryChanged())];
|
|
88
|
+
this.sync();
|
|
89
|
+
}
|
|
90
|
+
firingMutation = false;
|
|
91
|
+
firingMutationId;
|
|
92
|
+
renderGeneration = 0;
|
|
93
|
+
queryChanged() {
|
|
94
|
+
for (const statement of this.program.queryStatements)
|
|
95
|
+
if (this.options.executeQueries !== false && !this.queries.isLoading(statement.statementId) &&
|
|
96
|
+
Object.hasOwn(this.queries.getSnapshot(), statement.statementId))
|
|
97
|
+
delete this.restoredResults[statement.statementId];
|
|
98
|
+
this.notify();
|
|
99
|
+
}
|
|
100
|
+
createQueries() {
|
|
101
|
+
return createQueryManager(this.options.executeQueries === false ? null : { callTool: (tool, args) => this.operation(this.firingMutation ? 'mutation' : 'query', tool, args) });
|
|
102
|
+
}
|
|
103
|
+
report(diagnostic) {
|
|
104
|
+
this.diagnostics.push(diagnostic);
|
|
105
|
+
this.options.onDiagnostic?.(diagnostic);
|
|
106
|
+
}
|
|
107
|
+
check() { if (this.disposed)
|
|
108
|
+
throw new Error('Interface is disposed'); }
|
|
109
|
+
notify() { if (!this.disposed)
|
|
110
|
+
for (const listener of this.listeners)
|
|
111
|
+
listener(); }
|
|
112
|
+
subscribe(listener) { this.check(); this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
|
|
113
|
+
sync() {
|
|
114
|
+
this.queries.registerMutations(this.program.mutationStatements.map(m => ({
|
|
115
|
+
statementId: m.statementId, toolName: String(m.toolAST ? evaluate(m.toolAST, this.context) : ''),
|
|
116
|
+
})));
|
|
117
|
+
this.queries.evaluateStatements(this.program.queryStatements, this.context);
|
|
118
|
+
}
|
|
119
|
+
operation(type, tool, args) {
|
|
120
|
+
this.check();
|
|
121
|
+
const op = { id: `${this.instanceId}:${this.incarnation}:${++this.sequence}`, instanceId: this.instanceId,
|
|
122
|
+
version: this.version, type, tool, args };
|
|
123
|
+
const promise = new Promise((resolve, reject) => this.pending.set(op.id, { resolve, reject, version: op.version, liveVersion: op.version, type, tool, args, statementId: this.firingMutationId }));
|
|
124
|
+
if (this.options.onOperation) {
|
|
125
|
+
try {
|
|
126
|
+
// Preserve Telegram's tap when the provider invokes request() synchronously.
|
|
127
|
+
const result = this.options.onOperation(op);
|
|
128
|
+
Promise.resolve(result).then(result => this.reply({ ...op, result }), error => this.reply({ ...op, error: error instanceof Error ? error.message : String(error) }));
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
this.reply({ ...op, error: String(error) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
this.reply({ ...op, error: `No operation provider for ${tool}` });
|
|
136
|
+
}
|
|
137
|
+
return promise;
|
|
138
|
+
}
|
|
139
|
+
reply(reply) {
|
|
140
|
+
if (this.disposed || reply.instanceId !== this.instanceId)
|
|
141
|
+
return false;
|
|
142
|
+
const pending = this.pending.get(reply.id);
|
|
143
|
+
if (!pending || reply.version !== pending.version || pending.liveVersion !== this.version)
|
|
144
|
+
return false;
|
|
145
|
+
this.pending.delete(reply.id);
|
|
146
|
+
if (reply.error !== undefined) {
|
|
147
|
+
this.report({ source: 'transport', code: 'operation-error', message: reply.error, toolName: reply.id });
|
|
148
|
+
pending.reject(new Error(reply.error));
|
|
149
|
+
}
|
|
150
|
+
else
|
|
151
|
+
pending.resolve(reply.result);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
snapshot() {
|
|
155
|
+
this.check();
|
|
156
|
+
const results = Object.fromEntries([...this.program.queryStatements, ...this.program.mutationStatements]
|
|
157
|
+
.map(statement => [statement.statementId, this.context.resolveRef(statement.statementId)]));
|
|
158
|
+
return clone({ state: this.store.getSnapshot(), forms: this.forms, results });
|
|
159
|
+
}
|
|
160
|
+
restore(saved) {
|
|
161
|
+
this.check();
|
|
162
|
+
this.forms = clone(saved.forms);
|
|
163
|
+
this.restoredResults = clone(saved.results ?? {});
|
|
164
|
+
this.store.initialize(this.program.stateDeclarations, saved.state);
|
|
165
|
+
this.notify();
|
|
166
|
+
}
|
|
167
|
+
setState(name, value) { this.check(); this.store.set(name, value); }
|
|
168
|
+
getState(name) { return this.store.get(name); }
|
|
169
|
+
get querySnapshot() { return this.queries.getSnapshot(); }
|
|
170
|
+
get parsed() { return this.program; }
|
|
171
|
+
get hasActions() { return this.clicks.size !== 0; }
|
|
172
|
+
clientRecords() {
|
|
173
|
+
return Object.fromEntries([...this.records].filter(([, r]) => r.component.mount || r.component.update).map(([id, r]) => [id, { type: Object.keys(this.library.components).find(name => this.library.components[name] === r.component),
|
|
174
|
+
props: r.context.props, statementId: r.context.statementId, scope: r.context.scope,
|
|
175
|
+
fields: this.fieldDescriptors.get(id) }]));
|
|
176
|
+
}
|
|
177
|
+
requiredStyles() {
|
|
178
|
+
return [...new Set([...this.records.values()].filter(record => record.component.css).map(record => Object.keys(this.library.components).find(name => this.library.components[name] === record.component)))].sort();
|
|
179
|
+
}
|
|
180
|
+
fieldValues() {
|
|
181
|
+
return Object.fromEntries([...this.fields].map(([key, field]) => [key, field.get()]));
|
|
182
|
+
}
|
|
183
|
+
formErrors() {
|
|
184
|
+
const scopes = new Set([...this.fields.values()].map(field => field.scope));
|
|
185
|
+
return Object.fromEntries([...scopes].flatMap(scope => {
|
|
186
|
+
const errors = this.form(scope).errors;
|
|
187
|
+
return Object.keys(errors).length ? [[scope, errors]] : [];
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
setFieldHandle(handle, value) {
|
|
191
|
+
const setter = this.fieldHandles.get(handle);
|
|
192
|
+
if (!setter)
|
|
193
|
+
throw new Error('Field handle is no longer present');
|
|
194
|
+
setter(value);
|
|
195
|
+
}
|
|
196
|
+
nativeLink(id) {
|
|
197
|
+
const click = this.clicks.get(id);
|
|
198
|
+
if (click?.form !== undefined && Object.keys(this.form(click.form).errors).length)
|
|
199
|
+
return undefined;
|
|
200
|
+
const value = click?.value;
|
|
201
|
+
return value?.steps?.length === 1 && value.steps[0].type === 'open_url' ? value.steps[0].url : undefined;
|
|
202
|
+
}
|
|
203
|
+
get nativeLinks() {
|
|
204
|
+
return Object.fromEntries([...this.clicks.keys()].flatMap(id => {
|
|
205
|
+
const url = this.nativeLink(id);
|
|
206
|
+
return url ? [[id, url]] : [];
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
replace(program, version = this.version + 1) {
|
|
210
|
+
this.check();
|
|
211
|
+
if (!Number.isSafeInteger(version) || version <= this.version)
|
|
212
|
+
throw new Error('Program version must increase');
|
|
213
|
+
const old = this.program;
|
|
214
|
+
const operations = [...program.queryStatements, ...program.mutationStatements];
|
|
215
|
+
for (const statement of [...old.queryStatements, ...old.mutationStatements])
|
|
216
|
+
if (JSON.stringify(statement) !== JSON.stringify(operations.find(next => next.statementId === statement.statementId)))
|
|
217
|
+
delete this.restoredResults[statement.statementId];
|
|
218
|
+
const changedMutations = new Set(old.mutationStatements.filter(m => JSON.stringify(m) !== JSON.stringify(program.mutationStatements.find(n => n.statementId === m.statementId))).map(m => m.statementId));
|
|
219
|
+
if (changedMutations.size)
|
|
220
|
+
this.queries.registerMutations(old.mutationStatements.filter(m => !changedMutations.has(m.statementId)).map(m => ({
|
|
221
|
+
statementId: m.statementId, toolName: String(m.toolAST ? evaluate(m.toolAST, this.context) : ''),
|
|
222
|
+
})));
|
|
223
|
+
this.version = version;
|
|
224
|
+
this.program = program;
|
|
225
|
+
this.store.initialize(program.stateDeclarations, {});
|
|
226
|
+
this.sync();
|
|
227
|
+
this.notify();
|
|
228
|
+
for (const [id, pending] of this.pending) {
|
|
229
|
+
const candidates = pending.type === 'query' ? program.queryStatements : pending.type === 'mutation' ?
|
|
230
|
+
program.mutationStatements.filter(m => !changedMutations.has(m.statementId)) : [];
|
|
231
|
+
const relevant = candidates.some(statement => {
|
|
232
|
+
try {
|
|
233
|
+
return (statement.toolAST ? evaluate(statement.toolAST, this.context) : '') === pending.tool &&
|
|
234
|
+
(pending.type === 'mutation' ? statement.statementId === pending.statementId :
|
|
235
|
+
JSON.stringify(statement.argsAST ? evaluate(statement.argsAST, this.context) ?? {} : {}) === JSON.stringify(pending.args));
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
if (relevant)
|
|
242
|
+
pending.liveVersion = version;
|
|
243
|
+
else {
|
|
244
|
+
this.pending.delete(id);
|
|
245
|
+
pending.resolve(undefined);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
form(scope = '') {
|
|
250
|
+
return this.formFields(scope, this.fields);
|
|
251
|
+
}
|
|
252
|
+
formFields(scope, fields) {
|
|
253
|
+
const values = { ...own(this.forms, scope) };
|
|
254
|
+
const errors = {};
|
|
255
|
+
for (const field of fields.values())
|
|
256
|
+
if (field.scope === scope) {
|
|
257
|
+
put(values, field.name, field.get());
|
|
258
|
+
const rules = typeof field.rules === 'string' || Array.isArray(field.rules)
|
|
259
|
+
? parseRules(field.rules) : parseStructuredRules(field.rules);
|
|
260
|
+
const error = validate(values[field.name], rules, this.options.validators);
|
|
261
|
+
if (error)
|
|
262
|
+
put(errors, field.name, error);
|
|
263
|
+
}
|
|
264
|
+
return { values, errors };
|
|
265
|
+
}
|
|
266
|
+
field(name, binding, options = {}) {
|
|
267
|
+
this.check();
|
|
268
|
+
return this.registerField(name, binding, options, this.fields);
|
|
269
|
+
}
|
|
270
|
+
registerField(name, binding, options, fields) {
|
|
271
|
+
const scope = options.scope ?? '';
|
|
272
|
+
const getter = (key) => own(own(this.forms, scope) ?? {}, key);
|
|
273
|
+
const setter = (key, value) => {
|
|
274
|
+
let form = own(this.forms, scope);
|
|
275
|
+
if (!form) {
|
|
276
|
+
form = {};
|
|
277
|
+
put(this.forms, scope, form);
|
|
278
|
+
}
|
|
279
|
+
put(form, key, value);
|
|
280
|
+
this.notify();
|
|
281
|
+
};
|
|
282
|
+
const field = resolveStateField(name, binding, this.store, this.context, getter, setter);
|
|
283
|
+
// Resolve again when validating: bindings and store values may have changed since render.
|
|
284
|
+
fields.set(`${scope}\0${name}`, { scope, name, rules: options.rules, binding,
|
|
285
|
+
get: () => resolveStateField(name, binding, this.store, this.context, getter, setter).value });
|
|
286
|
+
return field;
|
|
287
|
+
}
|
|
288
|
+
async trigger(value, label = '', scope) {
|
|
289
|
+
this.check();
|
|
290
|
+
const version = this.version;
|
|
291
|
+
if (scope !== undefined && Object.keys(this.form(scope).errors).length) {
|
|
292
|
+
this.notify();
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
const plan = value;
|
|
296
|
+
const steps = plan?.steps;
|
|
297
|
+
if (!steps)
|
|
298
|
+
return false;
|
|
299
|
+
// A URL after an await has lost the gesture. Reject before running any side effects.
|
|
300
|
+
let asynchronous = false;
|
|
301
|
+
for (const step of steps) {
|
|
302
|
+
if (step.type === 'open_url' && (asynchronous || !this.options.openUrl)) {
|
|
303
|
+
this.report({ source: 'platform', code: 'gesture-required',
|
|
304
|
+
message: '@OpenUrl requires a synchronous tap; use a separate link button.' });
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
if (['run', 'continue_conversation', 'custom_action', 'open_url'].includes(step.type))
|
|
308
|
+
asynchronous = true;
|
|
309
|
+
}
|
|
310
|
+
for (const step of steps) {
|
|
311
|
+
if (this.disposed || version !== this.version)
|
|
312
|
+
return false;
|
|
313
|
+
switch (step.type) {
|
|
314
|
+
case 'set':
|
|
315
|
+
this.store.set(step.target, evaluate(step.valueAST, this.context));
|
|
316
|
+
break;
|
|
317
|
+
case 'reset':
|
|
318
|
+
for (const target of step.targets)
|
|
319
|
+
this.store.set(target, this.program.stateDeclarations[target], { pristine: true });
|
|
320
|
+
break;
|
|
321
|
+
case 'run': {
|
|
322
|
+
if (step.refType === 'query')
|
|
323
|
+
this.queries.invalidate([step.statementId]);
|
|
324
|
+
else {
|
|
325
|
+
const mutation = this.program.mutationStatements.find(m => m.statementId === step.statementId);
|
|
326
|
+
if (!mutation)
|
|
327
|
+
return false;
|
|
328
|
+
delete this.restoredResults[step.statementId];
|
|
329
|
+
this.firingMutation = true;
|
|
330
|
+
this.firingMutationId = step.statementId;
|
|
331
|
+
let execution;
|
|
332
|
+
try {
|
|
333
|
+
execution = this.queries.fireMutation(step.statementId, mutation.argsAST ? evaluate(mutation.argsAST, this.context) : {});
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
this.firingMutation = false;
|
|
337
|
+
this.firingMutationId = undefined;
|
|
338
|
+
}
|
|
339
|
+
if (!await execution)
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
case 'open_url':
|
|
345
|
+
this.options.openUrl(step.url);
|
|
346
|
+
break;
|
|
347
|
+
case 'continue_conversation':
|
|
348
|
+
case 'custom_action': {
|
|
349
|
+
const event = {
|
|
350
|
+
type: step.type === 'custom_action' ? step.name : 'continue_conversation',
|
|
351
|
+
params: step.type === 'custom_action' ? step.params : step.context == null ? {} : { context: step.context },
|
|
352
|
+
humanFriendlyMessage: step.type === 'custom_action' ? label : step.message,
|
|
353
|
+
formState: this.form(scope).values, formName: scope,
|
|
354
|
+
};
|
|
355
|
+
if (this.options.onAction)
|
|
356
|
+
await this.options.onAction(event);
|
|
357
|
+
else
|
|
358
|
+
await this.operation('action', event.type, event);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
async click(id) {
|
|
366
|
+
const click = this.clicks.get(id);
|
|
367
|
+
return click ? this.trigger(click.value, click.label, click.form) : false;
|
|
368
|
+
}
|
|
369
|
+
async render() {
|
|
370
|
+
this.check();
|
|
371
|
+
const generation = ++this.renderGeneration;
|
|
372
|
+
const clicks = new Map();
|
|
373
|
+
const records = new Map();
|
|
374
|
+
const fieldDescriptors = new Map();
|
|
375
|
+
const fieldHandles = new Map();
|
|
376
|
+
const fields = new Map();
|
|
377
|
+
const occurrences = new Map();
|
|
378
|
+
const errors = [];
|
|
379
|
+
const root = evaluateRoot(this.program, { ctx: this.context, library: this.evalLibrary, store: this.store, errors });
|
|
380
|
+
errors.forEach(e => this.report(e));
|
|
381
|
+
const renderNode = async (value, options = {}, path = '0') => {
|
|
382
|
+
if (value == null)
|
|
383
|
+
return '';
|
|
384
|
+
if (Array.isArray(value))
|
|
385
|
+
return (await Promise.all(value.map((child, index) => renderNode(child, options, `${path}-${index}`)))).join('');
|
|
386
|
+
if (!isElementNode(value)) {
|
|
387
|
+
if (typeof value === 'object')
|
|
388
|
+
throw new TypeError('renderNode requires a component or text');
|
|
389
|
+
return text(value);
|
|
390
|
+
}
|
|
391
|
+
const component = this.library.components[value.typeName];
|
|
392
|
+
if (!component)
|
|
393
|
+
return '';
|
|
394
|
+
const base = `oui-${this.instanceId}-${value.statementId ? 's-' + value.statementId : 'p-' + path}`;
|
|
395
|
+
const occurrence = occurrences.get(base) ?? 0;
|
|
396
|
+
occurrences.set(base, occurrence + 1);
|
|
397
|
+
const id = base + (occurrence ? `-${occurrence}` : '');
|
|
398
|
+
let childCall = 0;
|
|
399
|
+
const context = {
|
|
400
|
+
props: value.props, statementId: value.statementId, id, scope: options.scope,
|
|
401
|
+
renderNode: (child, childOptions) => renderNode(child, { ...options, ...childOptions }, `${path}-${childCall++}`), text, runtime: this,
|
|
402
|
+
action: (action, options = {}) => {
|
|
403
|
+
const key = String(clicks.size);
|
|
404
|
+
clicks.set(key, { value: action, label: options.label ?? '', form: options.form ?? context.scope });
|
|
405
|
+
return `data-oui-action="${key}"`;
|
|
406
|
+
},
|
|
407
|
+
field: (name, binding, options) => {
|
|
408
|
+
const scope = options?.scope ?? context.scope;
|
|
409
|
+
const field = this.registerField(name, binding, { ...options, scope }, fields);
|
|
410
|
+
const handle = fieldHandles.size.toString(36);
|
|
411
|
+
fieldHandles.set(handle, value => field.setValue(value));
|
|
412
|
+
const descriptors = fieldDescriptors.get(id) ?? [];
|
|
413
|
+
descriptors.push({ name, scope: scope ?? '', handle, reactive: field.isReactive, rules: options?.rules });
|
|
414
|
+
fieldDescriptors.set(id, descriptors);
|
|
415
|
+
return field;
|
|
416
|
+
},
|
|
417
|
+
form: scope => this.formFields(scope ?? options.scope ?? '', fields),
|
|
418
|
+
};
|
|
419
|
+
try {
|
|
420
|
+
const html = await component.render(context);
|
|
421
|
+
records.set(id, { component, context });
|
|
422
|
+
if (!component.mount && !component.update) {
|
|
423
|
+
if (value.statementId && /^\s*<[a-zA-Z][\w:-]*\b/.test(html) &&
|
|
424
|
+
!/^\s*<[^>]*\bdata-oui-(?:component|node)=/.test(html))
|
|
425
|
+
return html.replace(/^(\s*<[a-zA-Z][\w:-]*)\b/, `$1 data-oui-node="${id}"`);
|
|
426
|
+
return html;
|
|
427
|
+
}
|
|
428
|
+
if (!/^\s*<[a-zA-Z][\w:-]*\b/.test(html))
|
|
429
|
+
throw new Error('Lifecycle components must render a root element');
|
|
430
|
+
return html.replace(/^(\s*<[a-zA-Z][\w:-]*)\b/, `$1 data-oui-component="${id}"`);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
this.report({ source: 'runtime', code: 'render-error', component: value.typeName,
|
|
434
|
+
statementId: value.statementId, message: String(error) });
|
|
435
|
+
return '';
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
const html = await renderNode(root);
|
|
439
|
+
if (!this.disposed && generation === this.renderGeneration) {
|
|
440
|
+
this.clicks = clicks;
|
|
441
|
+
this.records = records;
|
|
442
|
+
this.fieldDescriptors = fieldDescriptors;
|
|
443
|
+
this.fieldHandles = fieldHandles;
|
|
444
|
+
this.fields = fields;
|
|
445
|
+
const signature = JSON.stringify([...fields].sort(([a], [b]) => a.localeCompare(b))
|
|
446
|
+
.map(([key, field]) => [key, field.binding, field.rules]));
|
|
447
|
+
if (signature !== this.fieldSignature) {
|
|
448
|
+
this.fieldSignature = signature;
|
|
449
|
+
this.notify();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return html;
|
|
453
|
+
}
|
|
454
|
+
/** Attach to an existing DOM root. All listeners and component resources are owned here. */
|
|
455
|
+
mount(element) {
|
|
456
|
+
this.check();
|
|
457
|
+
let stopped = false, scheduled = false, generation = 0;
|
|
458
|
+
let revision = 0;
|
|
459
|
+
let client;
|
|
460
|
+
const frame = async () => ({ i: this.instanceId, v: this.version, r: ++revision,
|
|
461
|
+
h: await this.render(), c: this.clientRecords(), f: this.fieldValues(), e: this.formErrors(), l: {} });
|
|
462
|
+
const draw = async () => {
|
|
463
|
+
const current = ++generation;
|
|
464
|
+
const next = await frame();
|
|
465
|
+
if (stopped || current !== generation)
|
|
466
|
+
return;
|
|
467
|
+
if (client)
|
|
468
|
+
client.apply(next);
|
|
469
|
+
else
|
|
470
|
+
client = mountWidgetClient(next, this.library.components, { root: element,
|
|
471
|
+
openUrl: this.options.openUrl,
|
|
472
|
+
onDiagnostic: diagnostic => this.report(diagnostic),
|
|
473
|
+
request: async (input) => {
|
|
474
|
+
if (input[1] !== this.version)
|
|
475
|
+
throw new Error('Obsolete local action');
|
|
476
|
+
if (input[3] === 'a') {
|
|
477
|
+
if (!await this.click(input[4]))
|
|
478
|
+
throw new Error('Action did not complete');
|
|
479
|
+
}
|
|
480
|
+
else
|
|
481
|
+
this.setFieldHandle(input[4], input[5]);
|
|
482
|
+
return frame();
|
|
483
|
+
}, });
|
|
484
|
+
};
|
|
485
|
+
const schedule = () => {
|
|
486
|
+
if (scheduled || stopped)
|
|
487
|
+
return;
|
|
488
|
+
scheduled = true;
|
|
489
|
+
Promise.resolve().then(() => { scheduled = false; return draw(); }).catch(error => this.report({ source: 'runtime', code: 'render-error', message: String(error) }));
|
|
490
|
+
};
|
|
491
|
+
const unsubscribe = this.subscribe(schedule);
|
|
492
|
+
const stop = () => { stopped = true; generation++; unsubscribe(); client?.dispose(); };
|
|
493
|
+
this.unsubscribers.push(stop);
|
|
494
|
+
schedule();
|
|
495
|
+
return stop;
|
|
496
|
+
}
|
|
497
|
+
dispose() {
|
|
498
|
+
if (this.disposed)
|
|
499
|
+
return;
|
|
500
|
+
this.disposed = true;
|
|
501
|
+
this.unsubscribers.splice(0).forEach(f => f());
|
|
502
|
+
this.queries.dispose();
|
|
503
|
+
this.store.dispose();
|
|
504
|
+
this.listeners.clear();
|
|
505
|
+
for (const p of this.pending.values())
|
|
506
|
+
p.resolve(undefined);
|
|
507
|
+
this.pending.clear();
|
|
508
|
+
this.clicks.clear();
|
|
509
|
+
this.records.clear();
|
|
510
|
+
this.fields.clear();
|
|
511
|
+
this.fieldDescriptors.clear();
|
|
512
|
+
this.fieldHandles.clear();
|
|
513
|
+
}
|
|
514
|
+
}
|