@arronqzy/blueprint-dsl 1.0.6 → 1.0.7
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/package.json +3 -3
- package/src/behaviors/default.ts +20 -1
- package/src/event-config.ts +211 -0
- package/src/fetch-config.ts +20 -4
- package/src/incoming-node-scope.ts +122 -6
- package/src/index.ts +2 -0
- package/src/nodes/definitions.ts +10 -0
- package/src/runtime/graph-runner.ts +50 -3
- package/src/scope-autocomplete.ts +114 -0
- package/src/scope-template.ts +58 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arronqzy/blueprint-dsl",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Abuilder blueprint DSL runtime",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"devDependencies": {
|
|
17
17
|
"eslint": "^8.57.0",
|
|
18
18
|
"typescript": "^5.5.4",
|
|
19
|
-
"@arronqzy/
|
|
20
|
-
"@arronqzy/
|
|
19
|
+
"@arronqzy/typescript-config": "1.0.0",
|
|
20
|
+
"@arronqzy/eslint-config": "0.0.0"
|
|
21
21
|
},
|
|
22
22
|
"publishConfig": {
|
|
23
23
|
"access": "public"
|
package/src/behaviors/default.ts
CHANGED
|
@@ -5,11 +5,13 @@ import {
|
|
|
5
5
|
isTrueSignal,
|
|
6
6
|
} from "../node-signal.js";
|
|
7
7
|
import type { LifecycleSignal } from "../lifecycle.js";
|
|
8
|
+
import type { ViewEventSignal } from "../event-config.js";
|
|
8
9
|
import { BehaviorRegistry } from "../core/behavior-registry.js";
|
|
9
10
|
|
|
10
11
|
const LIFECYCLE_SIGNAL_KEY = "__lifecycleSignal";
|
|
11
12
|
const BLUEPRINT_ACTIVATION_INPUT_KEY = "__blueprintActivationInput";
|
|
12
13
|
const UI_EVENT_PAYLOAD_KEY = "__uiEventPayload";
|
|
14
|
+
const VIEW_EVENT_SIGNAL_KEY = "__viewEventSignal";
|
|
13
15
|
|
|
14
16
|
export function registerDefaultBehaviors(registry: BehaviorRegistry) {
|
|
15
17
|
registry.registerJS("logic-noop", async ({ io }) => {
|
|
@@ -40,6 +42,23 @@ export function registerDefaultBehaviors(registry: BehaviorRegistry) {
|
|
|
40
42
|
}
|
|
41
43
|
io.emitFlow("out");
|
|
42
44
|
});
|
|
45
|
+
|
|
46
|
+
registry.registerJS("event-emit", async ({ token, io }) => {
|
|
47
|
+
const payload = token.scope.vars.get(VIEW_EVENT_SIGNAL_KEY) as
|
|
48
|
+
| ViewEventSignal
|
|
49
|
+
| undefined;
|
|
50
|
+
if (!payload) {
|
|
51
|
+
// 生命周期输入只负责注册,不向下游发出信号
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
io.setOutput("out", createTrueSignal(payload));
|
|
55
|
+
io.emitFlow("out");
|
|
56
|
+
});
|
|
43
57
|
}
|
|
44
58
|
|
|
45
|
-
export {
|
|
59
|
+
export {
|
|
60
|
+
LIFECYCLE_SIGNAL_KEY,
|
|
61
|
+
BLUEPRINT_ACTIVATION_INPUT_KEY,
|
|
62
|
+
UI_EVENT_PAYLOAD_KEY,
|
|
63
|
+
VIEW_EVENT_SIGNAL_KEY,
|
|
64
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/** 视图事件节点可监听的常用 DOM 事件 */
|
|
2
|
+
export const VIEW_EVENT_TYPES = [
|
|
3
|
+
"click",
|
|
4
|
+
"dblclick",
|
|
5
|
+
"contextmenu",
|
|
6
|
+
"mouseenter",
|
|
7
|
+
"mouseleave",
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export type ViewEventType = (typeof VIEW_EVENT_TYPES)[number];
|
|
11
|
+
|
|
12
|
+
export type ViewEventNodeConfig = {
|
|
13
|
+
/** 监听的事件类型,至少一项;默认 click */
|
|
14
|
+
eventTypes: ViewEventType[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_VIEW_EVENT_NODE_CONFIG: ViewEventNodeConfig = {
|
|
18
|
+
eventTypes: ["click"],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const VIEW_EVENT_TYPE_SET = new Set<string>(VIEW_EVENT_TYPES);
|
|
22
|
+
|
|
23
|
+
export function isViewEventType(value: unknown): value is ViewEventType {
|
|
24
|
+
return typeof value === "string" && VIEW_EVENT_TYPE_SET.has(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeViewEventConfig(
|
|
28
|
+
config?: Partial<ViewEventNodeConfig> | null
|
|
29
|
+
): ViewEventNodeConfig {
|
|
30
|
+
const raw = Array.isArray(config?.eventTypes) ? config.eventTypes : [];
|
|
31
|
+
const eventTypes = [
|
|
32
|
+
...new Set(raw.filter(isViewEventType)),
|
|
33
|
+
];
|
|
34
|
+
return {
|
|
35
|
+
eventTypes:
|
|
36
|
+
eventTypes.length > 0
|
|
37
|
+
? eventTypes
|
|
38
|
+
: [...DEFAULT_VIEW_EVENT_NODE_CONFIG.eventTypes],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 可序列化的 DOM 事件快照(原生 Event 无法作为蓝图信号传递) */
|
|
43
|
+
export type ViewEventSnapshot = {
|
|
44
|
+
type: string;
|
|
45
|
+
timeStamp: number;
|
|
46
|
+
clientX?: number;
|
|
47
|
+
clientY?: number;
|
|
48
|
+
pageX?: number;
|
|
49
|
+
pageY?: number;
|
|
50
|
+
button?: number;
|
|
51
|
+
altKey?: boolean;
|
|
52
|
+
ctrlKey?: boolean;
|
|
53
|
+
metaKey?: boolean;
|
|
54
|
+
shiftKey?: boolean;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** 触发该事件的视图节点摘要 */
|
|
58
|
+
export type ViewEventNodeSnapshot = {
|
|
59
|
+
id: string;
|
|
60
|
+
name?: string;
|
|
61
|
+
materialType?: string;
|
|
62
|
+
x?: number;
|
|
63
|
+
y?: number;
|
|
64
|
+
width?: number;
|
|
65
|
+
height?: number;
|
|
66
|
+
layerId?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** 事件节点向下游发出的真信号 value */
|
|
70
|
+
export type ViewEventSignal = {
|
|
71
|
+
/** 触发事件(可序列化快照) */
|
|
72
|
+
event: ViewEventSnapshot;
|
|
73
|
+
/** 触发节点 */
|
|
74
|
+
node: ViewEventNodeSnapshot;
|
|
75
|
+
/** 触发的事件类型 */
|
|
76
|
+
eventType: ViewEventType;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function snapshotDomEvent(event: {
|
|
80
|
+
type: string;
|
|
81
|
+
timeStamp?: number;
|
|
82
|
+
clientX?: number;
|
|
83
|
+
clientY?: number;
|
|
84
|
+
pageX?: number;
|
|
85
|
+
pageY?: number;
|
|
86
|
+
button?: number;
|
|
87
|
+
altKey?: boolean;
|
|
88
|
+
ctrlKey?: boolean;
|
|
89
|
+
metaKey?: boolean;
|
|
90
|
+
shiftKey?: boolean;
|
|
91
|
+
}): ViewEventSnapshot {
|
|
92
|
+
return {
|
|
93
|
+
type: event.type,
|
|
94
|
+
timeStamp: Number(event.timeStamp) || Date.now(),
|
|
95
|
+
clientX: event.clientX,
|
|
96
|
+
clientY: event.clientY,
|
|
97
|
+
pageX: event.pageX,
|
|
98
|
+
pageY: event.pageY,
|
|
99
|
+
button: event.button,
|
|
100
|
+
altKey: event.altKey,
|
|
101
|
+
ctrlKey: event.ctrlKey,
|
|
102
|
+
metaKey: event.metaKey,
|
|
103
|
+
shiftKey: event.shiftKey,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function snapshotViewEventNode(
|
|
108
|
+
node: Partial<ViewEventNodeSnapshot> & { id: string }
|
|
109
|
+
): ViewEventNodeSnapshot {
|
|
110
|
+
return {
|
|
111
|
+
id: node.id,
|
|
112
|
+
name: node.name,
|
|
113
|
+
materialType: node.materialType,
|
|
114
|
+
x: node.x,
|
|
115
|
+
y: node.y,
|
|
116
|
+
width: node.width,
|
|
117
|
+
height: node.height,
|
|
118
|
+
layerId: node.layerId,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createViewEventSignal(input: {
|
|
123
|
+
event: ViewEventSnapshot;
|
|
124
|
+
node: ViewEventNodeSnapshot;
|
|
125
|
+
eventType: ViewEventType;
|
|
126
|
+
}): ViewEventSignal {
|
|
127
|
+
return {
|
|
128
|
+
event: input.event,
|
|
129
|
+
node: input.node,
|
|
130
|
+
eventType: input.eventType,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function collectArmedViewEventNodeIds(
|
|
135
|
+
nodes: Array<{
|
|
136
|
+
id: string;
|
|
137
|
+
nodeType?: string;
|
|
138
|
+
lifecyclePhase?: string;
|
|
139
|
+
}>,
|
|
140
|
+
edges: Array<{ source: string; target: string }>,
|
|
141
|
+
eventNodeType: string,
|
|
142
|
+
lifecycleNodeType: string,
|
|
143
|
+
firedPhases: ReadonlySet<string>
|
|
144
|
+
): Set<string> {
|
|
145
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
146
|
+
const armed = new Set<string>();
|
|
147
|
+
for (const node of nodes) {
|
|
148
|
+
if (node.nodeType !== eventNodeType) continue;
|
|
149
|
+
const registered = edges.some((edge) => {
|
|
150
|
+
if (edge.target !== node.id) return false;
|
|
151
|
+
const source = byId.get(edge.source);
|
|
152
|
+
if (!source || source.nodeType !== lifecycleNodeType) return false;
|
|
153
|
+
const phase = source.lifecyclePhase || "mounted";
|
|
154
|
+
return firedPhases.has(phase);
|
|
155
|
+
});
|
|
156
|
+
if (registered) armed.add(node.id);
|
|
157
|
+
}
|
|
158
|
+
return armed;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 仅收集已由生命周期注册的事件节点绑定 */
|
|
162
|
+
export function collectArmedViewEventBindings(
|
|
163
|
+
nodes: Array<{
|
|
164
|
+
id: string;
|
|
165
|
+
nodeType?: string;
|
|
166
|
+
lifecyclePhase?: string;
|
|
167
|
+
viewElementIds?: string[];
|
|
168
|
+
eventConfig?: Partial<ViewEventNodeConfig> | null;
|
|
169
|
+
}>,
|
|
170
|
+
edges: Array<{ source: string; target: string }>,
|
|
171
|
+
eventNodeType: string,
|
|
172
|
+
lifecycleNodeType: string,
|
|
173
|
+
firedPhases: ReadonlySet<string>
|
|
174
|
+
): Map<string, Set<ViewEventType>> {
|
|
175
|
+
const armed = collectArmedViewEventNodeIds(
|
|
176
|
+
nodes,
|
|
177
|
+
edges,
|
|
178
|
+
eventNodeType,
|
|
179
|
+
lifecycleNodeType,
|
|
180
|
+
firedPhases
|
|
181
|
+
);
|
|
182
|
+
return collectViewEventBindings(nodes, eventNodeType, armed);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function collectViewEventBindings(
|
|
186
|
+
nodes: Array<{
|
|
187
|
+
id?: string;
|
|
188
|
+
nodeType?: string;
|
|
189
|
+
viewElementIds?: string[];
|
|
190
|
+
eventConfig?: Partial<ViewEventNodeConfig> | null;
|
|
191
|
+
}>,
|
|
192
|
+
eventNodeType: string,
|
|
193
|
+
armedNodeIds?: ReadonlySet<string>
|
|
194
|
+
): Map<string, Set<ViewEventType>> {
|
|
195
|
+
const map = new Map<string, Set<ViewEventType>>();
|
|
196
|
+
for (const node of nodes) {
|
|
197
|
+
if (node.nodeType !== eventNodeType) continue;
|
|
198
|
+
if (armedNodeIds && (!node.id || !armedNodeIds.has(node.id))) continue;
|
|
199
|
+
const types = normalizeViewEventConfig(node.eventConfig).eventTypes;
|
|
200
|
+
for (const id of node.viewElementIds ?? []) {
|
|
201
|
+
if (!id) continue;
|
|
202
|
+
let set = map.get(id);
|
|
203
|
+
if (!set) {
|
|
204
|
+
set = new Set();
|
|
205
|
+
map.set(id, set);
|
|
206
|
+
}
|
|
207
|
+
for (const type of types) set.add(type);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return map;
|
|
211
|
+
}
|
package/src/fetch-config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SwaggerApiEndpoint } from "./swagger.js";
|
|
2
|
+
import { looksLikeJsonText } from "./scope-template.js";
|
|
2
3
|
|
|
3
4
|
export const FETCH_HTTP_METHODS = [
|
|
4
5
|
"GET",
|
|
@@ -189,6 +190,24 @@ export async function executeFetch(
|
|
|
189
190
|
const body =
|
|
190
191
|
hasBody && config.body?.trim() ? config.body : undefined;
|
|
191
192
|
|
|
193
|
+
if (body && looksLikeJsonText(body)) {
|
|
194
|
+
try {
|
|
195
|
+
JSON.parse(body);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`请求体不是有效 JSON:${error instanceof Error ? error.message : "格式无效"}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const headers: Record<string, string> = { ...(config.headers ?? {}) };
|
|
204
|
+
const hasContentType = Object.keys(headers).some(
|
|
205
|
+
(key) => key.toLowerCase() === "content-type"
|
|
206
|
+
);
|
|
207
|
+
if (body && looksLikeJsonText(body) && !hasContentType) {
|
|
208
|
+
headers["Content-Type"] = "application/json";
|
|
209
|
+
}
|
|
210
|
+
|
|
192
211
|
const timeoutMs = config.timeoutMs ?? 30000;
|
|
193
212
|
const controller = new AbortController();
|
|
194
213
|
const unlinkExternal = linkAbortSignal(controller, options?.signal);
|
|
@@ -200,10 +219,7 @@ export async function executeFetch(
|
|
|
200
219
|
try {
|
|
201
220
|
const response = await fetch(url, {
|
|
202
221
|
method,
|
|
203
|
-
headers:
|
|
204
|
-
config.headers && Object.keys(config.headers).length > 0
|
|
205
|
-
? config.headers
|
|
206
|
-
: undefined,
|
|
222
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
207
223
|
body,
|
|
208
224
|
credentials: config.credentials,
|
|
209
225
|
mode: config.mode,
|
|
@@ -2,7 +2,9 @@ import type { FetchRequestConfig } from "./fetch-config.js";
|
|
|
2
2
|
import {
|
|
3
3
|
evaluateScopeExpression,
|
|
4
4
|
evaluateScopeTemplate,
|
|
5
|
+
evaluateScopeTemplateInJson,
|
|
5
6
|
hasScopeTemplate,
|
|
7
|
+
looksLikeJsonText,
|
|
6
8
|
stringifyScopeValue,
|
|
7
9
|
} from "./scope-template.js";
|
|
8
10
|
import { isFalseSignal, isTrueSignal } from "./node-signal.js";
|
|
@@ -40,6 +42,24 @@ export type IncomingNodeScope =
|
|
|
40
42
|
| IncomingNodeScopeEntry
|
|
41
43
|
| Record<string, IncomingNodeScopeEntry>;
|
|
42
44
|
|
|
45
|
+
/** 尚无上游数据时,仍可联想 scope 的固定字段 */
|
|
46
|
+
export const FETCH_SCOPE_AUTOCOMPLETE_FALLBACK: IncomingNodeScopeEntry = {
|
|
47
|
+
id: "",
|
|
48
|
+
name: "",
|
|
49
|
+
type: "",
|
|
50
|
+
role: "",
|
|
51
|
+
port: "out",
|
|
52
|
+
kind: "pending",
|
|
53
|
+
value: {},
|
|
54
|
+
error: "",
|
|
55
|
+
timestamp: 0,
|
|
56
|
+
isoTime: "",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function resolveFetchScopeAutocompleteRoot(scope: unknown): unknown {
|
|
60
|
+
return scope === undefined ? FETCH_SCOPE_AUTOCOMPLETE_FALLBACK : scope;
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
function readOutputSignal(output: unknown): Pick<
|
|
44
64
|
IncomingNodeScopeEntry,
|
|
45
65
|
"kind" | "value" | "error" | "timestamp" | "isoTime"
|
|
@@ -191,6 +211,94 @@ function resolveTemplateString(
|
|
|
191
211
|
return evaluateScopeTemplate(value, scope);
|
|
192
212
|
}
|
|
193
213
|
|
|
214
|
+
export function parseJsonText(
|
|
215
|
+
text: string
|
|
216
|
+
): { ok: true; value: unknown } | { ok: false; error: string } {
|
|
217
|
+
try {
|
|
218
|
+
return { ok: true, value: JSON.parse(text) as unknown };
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
error: error instanceof Error ? error.message : "JSON 格式无效",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function validateFetchJsonText(
|
|
228
|
+
text: string,
|
|
229
|
+
options?: { allowEmpty?: boolean }
|
|
230
|
+
): { ok: true } | { ok: false; error: string } {
|
|
231
|
+
const trimmed = text.trim();
|
|
232
|
+
if (!trimmed) {
|
|
233
|
+
if (options?.allowEmpty === false) {
|
|
234
|
+
return { ok: false, error: "内容不能为空" };
|
|
235
|
+
}
|
|
236
|
+
return { ok: true };
|
|
237
|
+
}
|
|
238
|
+
if (isWholeScopeExpression(trimmed)) {
|
|
239
|
+
return { ok: true };
|
|
240
|
+
}
|
|
241
|
+
if (!looksLikeJsonText(trimmed)) {
|
|
242
|
+
return { ok: true };
|
|
243
|
+
}
|
|
244
|
+
const parsed = parseJsonText(trimmed);
|
|
245
|
+
return parsed.ok ? { ok: true } : parsed;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function resolveFetchBody(
|
|
249
|
+
body: string | undefined,
|
|
250
|
+
scope?: unknown
|
|
251
|
+
): string | undefined {
|
|
252
|
+
if (body === undefined) return undefined;
|
|
253
|
+
const trimmed = body.trim();
|
|
254
|
+
if (!trimmed) return body;
|
|
255
|
+
if (scope !== undefined && isWholeScopeExpression(trimmed)) {
|
|
256
|
+
const value = evaluateScopeExpression(trimmed.slice(1, -1), scope);
|
|
257
|
+
if (value === undefined) return "";
|
|
258
|
+
if (typeof value === "string") return value;
|
|
259
|
+
return stringifyScopeValue(value);
|
|
260
|
+
}
|
|
261
|
+
if (scope === undefined || !hasScopeTemplate(body)) return body;
|
|
262
|
+
if (looksLikeJsonText(trimmed)) {
|
|
263
|
+
return evaluateScopeTemplateInJson(body, scope);
|
|
264
|
+
}
|
|
265
|
+
return evaluateScopeTemplate(body, scope);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function getFetchBodyValidationError(
|
|
269
|
+
body: string | undefined,
|
|
270
|
+
resolvedBody?: string
|
|
271
|
+
): string | null {
|
|
272
|
+
const source = (resolvedBody ?? body ?? "").trim();
|
|
273
|
+
if (!source) return null;
|
|
274
|
+
if (isWholeScopeExpression(source)) return null;
|
|
275
|
+
if (!looksLikeJsonText(source)) return null;
|
|
276
|
+
const parsed = parseJsonText(source);
|
|
277
|
+
if (parsed.ok) return null;
|
|
278
|
+
// 仍含未求值的 {scope?...} 时,等上游数据再校验
|
|
279
|
+
if (hasScopeTemplate(source)) return null;
|
|
280
|
+
return parsed.error;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function getFetchHeadersValidationError(
|
|
284
|
+
config: FetchRequestConfig,
|
|
285
|
+
scope?: unknown
|
|
286
|
+
): string | null {
|
|
287
|
+
const raw = (config.headersJson ?? "").trim();
|
|
288
|
+
if (!raw) return null;
|
|
289
|
+
if (isWholeScopeExpression(raw) || hasScopeTemplate(raw)) {
|
|
290
|
+
if (scope === undefined) return null;
|
|
291
|
+
const parsed = parseFetchHeadersJson(raw, scope);
|
|
292
|
+
if (!parsed) return "请求头模板解析后不是有效的 JSON 对象";
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const result = validateFetchJsonText(raw);
|
|
296
|
+
if (!result.ok) return result.error;
|
|
297
|
+
const parsed = parseFetchHeadersJson(raw, scope);
|
|
298
|
+
if (!parsed) return "请求头不是有效的 JSON 对象";
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
194
302
|
/** `{scope?...}` 整段表达式,区别于 JSON 对象 `{ "Authorization": "..." }` */
|
|
195
303
|
export function isWholeScopeExpression(text: string): boolean {
|
|
196
304
|
const trimmed = text.trim();
|
|
@@ -233,10 +341,17 @@ export function parseFetchHeadersJson(
|
|
|
233
341
|
const inner = trimmed.slice(1, -1);
|
|
234
342
|
return asHeaderRecord(evaluateScopeExpression(inner, scope));
|
|
235
343
|
}
|
|
344
|
+
let jsonText = trimmed;
|
|
345
|
+
if (scope !== undefined && hasScopeTemplate(trimmed) && looksLikeJsonText(trimmed)) {
|
|
346
|
+
jsonText = evaluateScopeTemplateInJson(trimmed, scope);
|
|
347
|
+
}
|
|
236
348
|
try {
|
|
237
|
-
const parsed = asHeaderRecord(JSON.parse(
|
|
349
|
+
const parsed = asHeaderRecord(JSON.parse(jsonText));
|
|
238
350
|
if (!parsed) return undefined;
|
|
239
|
-
|
|
351
|
+
if (jsonText === trimmed) {
|
|
352
|
+
return applyScopeToHeaderRecord(parsed, scope);
|
|
353
|
+
}
|
|
354
|
+
return parsed;
|
|
240
355
|
} catch {
|
|
241
356
|
return undefined;
|
|
242
357
|
}
|
|
@@ -269,16 +384,17 @@ export function applyFetchConfigScope(
|
|
|
269
384
|
config: FetchRequestConfig,
|
|
270
385
|
scope: unknown
|
|
271
386
|
): FetchRequestConfig {
|
|
387
|
+
const headers = resolveFetchHeaders(config, scope);
|
|
388
|
+
const body = resolveFetchBody(config.body, scope);
|
|
272
389
|
if (scope === undefined) {
|
|
273
|
-
|
|
274
|
-
return
|
|
390
|
+
if (headers === config.headers && body === config.body) return config;
|
|
391
|
+
return { ...config, headers, body };
|
|
275
392
|
}
|
|
276
|
-
const headers = resolveFetchHeaders(config, scope);
|
|
277
393
|
return {
|
|
278
394
|
...config,
|
|
279
395
|
url: resolveTemplateString(config.url, scope) ?? "",
|
|
280
396
|
apiBaseUrl: resolveTemplateString(config.apiBaseUrl, scope),
|
|
281
|
-
body
|
|
397
|
+
body,
|
|
282
398
|
headers,
|
|
283
399
|
};
|
|
284
400
|
}
|
package/src/index.ts
CHANGED
|
@@ -8,10 +8,12 @@ export * from "./core/executor.js";
|
|
|
8
8
|
export * from "./behaviors/default.js";
|
|
9
9
|
export * from "./fetch-config.js";
|
|
10
10
|
export * from "./scope-template.js";
|
|
11
|
+
export * from "./scope-autocomplete.js";
|
|
11
12
|
export * from "./incoming-node-scope.js";
|
|
12
13
|
export * from "./json-config.js";
|
|
13
14
|
export * from "./logic-config.js";
|
|
14
15
|
export * from "./clock-config.js";
|
|
16
|
+
export * from "./event-config.js";
|
|
15
17
|
export * from "./swagger.js";
|
|
16
18
|
export * from "./runtime/execution-trace.js";
|
|
17
19
|
export * from "./runtime/blueprint-cycle.js";
|
package/src/nodes/definitions.ts
CHANGED
|
@@ -8,6 +8,7 @@ export const JSON_NODE_TYPE = "JsonNode";
|
|
|
8
8
|
export const CLOCK_NODE_TYPE = "Clock";
|
|
9
9
|
export const AND_NODE_TYPE = "AndLogic";
|
|
10
10
|
export const VIEW_NODE_TYPE = "ViewBind";
|
|
11
|
+
export const EVENT_NODE_TYPE = "ViewEvent";
|
|
11
12
|
|
|
12
13
|
/** 蓝图引用节点:单入单出,收到真信号后执行嵌套蓝图 */
|
|
13
14
|
export const blueprintNodeDefinition: NodeDefinition = {
|
|
@@ -75,6 +76,14 @@ export const viewBindNodeDefinition: NodeDefinition = {
|
|
|
75
76
|
behavior: { kind: "js", ref: "view-bind-run" },
|
|
76
77
|
};
|
|
77
78
|
|
|
79
|
+
/** 视图事件节点:输入必须来自生命周期节点,对应阶段触发后注册事件,视图事件发生时输出真信号 */
|
|
80
|
+
export const viewEventNodeDefinition: NodeDefinition = {
|
|
81
|
+
type: EVENT_NODE_TYPE,
|
|
82
|
+
inputs: [{ name: "in", kind: "data" }],
|
|
83
|
+
outputs: [{ name: "out", kind: "data" }],
|
|
84
|
+
behavior: { kind: "js", ref: "event-emit" },
|
|
85
|
+
};
|
|
86
|
+
|
|
78
87
|
export const nodeDefinitionRegistry: Record<string, NodeDefinition> = {
|
|
79
88
|
[BLUEPRINT_NODE_TYPE]: blueprintNodeDefinition,
|
|
80
89
|
[DEFAULT_LOGIC_NODE_TYPE]: defaultLogicNodeDefinition,
|
|
@@ -84,6 +93,7 @@ export const nodeDefinitionRegistry: Record<string, NodeDefinition> = {
|
|
|
84
93
|
[CLOCK_NODE_TYPE]: clockNodeDefinition,
|
|
85
94
|
[AND_NODE_TYPE]: andLogicNodeDefinition,
|
|
86
95
|
[VIEW_NODE_TYPE]: viewBindNodeDefinition,
|
|
96
|
+
[EVENT_NODE_TYPE]: viewEventNodeDefinition,
|
|
87
97
|
};
|
|
88
98
|
|
|
89
99
|
export function getNodeDefinition(type: string): NodeDefinition {
|
|
@@ -4,7 +4,12 @@ import {
|
|
|
4
4
|
normalizeFetchRequestConfig,
|
|
5
5
|
resolveFetchRequestUrl,
|
|
6
6
|
} from "../fetch-config.js";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
applyFetchConfigScope,
|
|
9
|
+
getFetchBodyValidationError,
|
|
10
|
+
getFetchHeadersValidationError,
|
|
11
|
+
resolveFetchIncomingScope,
|
|
12
|
+
} from "../incoming-node-scope.js";
|
|
8
13
|
import type { JsonNodeConfig } from "../json-config.js";
|
|
9
14
|
import {
|
|
10
15
|
DEFAULT_JSON_NODE_CONFIG,
|
|
@@ -23,6 +28,8 @@ import {
|
|
|
23
28
|
normalizeClockConfig,
|
|
24
29
|
validateClockScheduleConfig,
|
|
25
30
|
} from "../clock-config.js";
|
|
31
|
+
import type { ViewEventNodeConfig, ViewEventSignal } from "../event-config.js";
|
|
32
|
+
import { normalizeViewEventConfig } from "../event-config.js";
|
|
26
33
|
import {
|
|
27
34
|
buildClockSessionKey,
|
|
28
35
|
scheduleClockOutputs,
|
|
@@ -40,6 +47,7 @@ import {
|
|
|
40
47
|
BLUEPRINT_ACTIVATION_INPUT_KEY,
|
|
41
48
|
LIFECYCLE_SIGNAL_KEY,
|
|
42
49
|
UI_EVENT_PAYLOAD_KEY,
|
|
50
|
+
VIEW_EVENT_SIGNAL_KEY,
|
|
43
51
|
} from "../behaviors/default.js";
|
|
44
52
|
import { BehaviorRegistry } from "../core/behavior-registry.js";
|
|
45
53
|
import { Executor } from "../core/executor.js";
|
|
@@ -58,6 +66,7 @@ import {
|
|
|
58
66
|
import {
|
|
59
67
|
BLUEPRINT_NODE_TYPE,
|
|
60
68
|
CLOCK_NODE_TYPE,
|
|
69
|
+
EVENT_NODE_TYPE,
|
|
61
70
|
getNodeDefinition,
|
|
62
71
|
LIFECYCLE_NODE_TYPE,
|
|
63
72
|
VIEW_NODE_TYPE,
|
|
@@ -91,6 +100,7 @@ export type RunnableGraphNode = {
|
|
|
91
100
|
jsonConfig?: JsonNodeConfig;
|
|
92
101
|
logicConfig?: LogicNodeConfig;
|
|
93
102
|
clockConfig?: ClockNodeConfig;
|
|
103
|
+
eventConfig?: ViewEventNodeConfig;
|
|
94
104
|
};
|
|
95
105
|
|
|
96
106
|
export type RunnableGraphEdge = {
|
|
@@ -307,6 +317,28 @@ export class BlueprintGraphRunner {
|
|
|
307
317
|
}
|
|
308
318
|
}
|
|
309
319
|
|
|
320
|
+
/**
|
|
321
|
+
* 视图节点触发事件时,匹配已注册(上游生命周期已触发)且绑定了该元素、事件类型的事件节点,发出真信号。
|
|
322
|
+
*/
|
|
323
|
+
async emitViewEvent(
|
|
324
|
+
payload: ViewEventSignal,
|
|
325
|
+
armedNodeIds?: ReadonlySet<string>
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
await this.ensureNoBlueprintCycle();
|
|
328
|
+
const targets = this.graph.nodes.filter((node) => {
|
|
329
|
+
if (node.nodeType !== EVENT_NODE_TYPE) return false;
|
|
330
|
+
if (armedNodeIds && !armedNodeIds.has(node.id)) return false;
|
|
331
|
+
if (!(node.viewElementIds ?? []).includes(payload.node.id)) return false;
|
|
332
|
+
return normalizeViewEventConfig(node.eventConfig).eventTypes.includes(
|
|
333
|
+
payload.eventType
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
for (const node of targets) {
|
|
338
|
+
await this.runFromNode(node.id, undefined, undefined, undefined, payload);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
310
342
|
/** 时钟节点单次输出:向下游传播含当前时间的真信号 */
|
|
311
343
|
async propagateClockOutput(
|
|
312
344
|
nodeId: string,
|
|
@@ -1088,6 +1120,14 @@ export class BlueprintGraphRunner {
|
|
|
1088
1120
|
normalizeFetchRequestConfig(node.fetchConfig),
|
|
1089
1121
|
incomingScope
|
|
1090
1122
|
);
|
|
1123
|
+
const headersError = getFetchHeadersValidationError(config, incomingScope);
|
|
1124
|
+
if (headersError) {
|
|
1125
|
+
throw new Error(headersError);
|
|
1126
|
+
}
|
|
1127
|
+
const bodyError = getFetchBodyValidationError(config.body, config.body);
|
|
1128
|
+
if (bodyError) {
|
|
1129
|
+
throw new Error(`请求体不是有效 JSON:${bodyError}`);
|
|
1130
|
+
}
|
|
1091
1131
|
resolveFetchRequestUrl(config);
|
|
1092
1132
|
|
|
1093
1133
|
const result = await executeFetch(config, {
|
|
@@ -1266,7 +1306,8 @@ export class BlueprintGraphRunner {
|
|
|
1266
1306
|
nodeId: string,
|
|
1267
1307
|
lifecycleSignal?: LifecycleSignal,
|
|
1268
1308
|
parentScope?: Scope,
|
|
1269
|
-
activationInput?: unknown
|
|
1309
|
+
activationInput?: unknown,
|
|
1310
|
+
viewEvent?: ViewEventSignal
|
|
1270
1311
|
) {
|
|
1271
1312
|
const queue: ExecutionToken[] = [
|
|
1272
1313
|
{
|
|
@@ -1287,6 +1328,10 @@ export class BlueprintGraphRunner {
|
|
|
1287
1328
|
activationInput
|
|
1288
1329
|
);
|
|
1289
1330
|
}
|
|
1331
|
+
if (viewEvent) {
|
|
1332
|
+
queue[0]!.scope.vars.set(VIEW_EVENT_SIGNAL_KEY, viewEvent);
|
|
1333
|
+
queue[0]!.scope.vars.set(UI_EVENT_PAYLOAD_KEY, viewEvent);
|
|
1334
|
+
}
|
|
1290
1335
|
|
|
1291
1336
|
const executor = this.createExecutor(queue);
|
|
1292
1337
|
|
|
@@ -1362,7 +1407,9 @@ export class BlueprintGraphRunner {
|
|
|
1362
1407
|
}
|
|
1363
1408
|
return this.graph.nodes.filter(
|
|
1364
1409
|
(node) =>
|
|
1365
|
-
node.nodeType !== LIFECYCLE_NODE_TYPE &&
|
|
1410
|
+
node.nodeType !== LIFECYCLE_NODE_TYPE &&
|
|
1411
|
+
node.nodeType !== EVENT_NODE_TYPE &&
|
|
1412
|
+
!hasIncoming.has(node.id)
|
|
1366
1413
|
);
|
|
1367
1414
|
}
|
|
1368
1415
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export type ScopeAutocompleteState = {
|
|
2
|
+
templateStart: number;
|
|
3
|
+
cursor: number;
|
|
4
|
+
path: string[];
|
|
5
|
+
partialKey: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function getValueAtPath(scope: unknown, path: string[]): unknown {
|
|
9
|
+
let current: unknown = scope;
|
|
10
|
+
for (const part of path) {
|
|
11
|
+
if (current === null || typeof current !== "object") return undefined;
|
|
12
|
+
current = (current as Record<string, unknown>)[part];
|
|
13
|
+
}
|
|
14
|
+
return current;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function listObjectKeys(value: unknown): string[] {
|
|
18
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
return Object.keys(value as Record<string, unknown>);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 解析光标处未闭合的 scope 模版表达式 */
|
|
25
|
+
export function parseScopeAutocomplete(
|
|
26
|
+
text: string,
|
|
27
|
+
cursor: number,
|
|
28
|
+
scope: unknown
|
|
29
|
+
): ScopeAutocompleteState | null {
|
|
30
|
+
const before = text.slice(0, cursor);
|
|
31
|
+
const templateStart = before.lastIndexOf("{");
|
|
32
|
+
if (templateStart < 0) return null;
|
|
33
|
+
|
|
34
|
+
const inner = before.slice(templateStart + 1);
|
|
35
|
+
if (inner.includes("}")) return null;
|
|
36
|
+
if (!inner.startsWith("scope")) return null;
|
|
37
|
+
|
|
38
|
+
let body = inner.slice("scope".length);
|
|
39
|
+
if (body === "" || body === "?") {
|
|
40
|
+
return { templateStart, cursor, path: [], partialKey: "" };
|
|
41
|
+
}
|
|
42
|
+
if (!body.startsWith("?.")) return null;
|
|
43
|
+
|
|
44
|
+
body = body.slice(2);
|
|
45
|
+
if (body === "") {
|
|
46
|
+
return { templateStart, cursor, path: [], partialKey: "" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const segments = body.split("?.");
|
|
50
|
+
const endsWithSeparator = inner.endsWith("?.");
|
|
51
|
+
|
|
52
|
+
if (endsWithSeparator) {
|
|
53
|
+
const path = segments.filter((segment) => segment.length > 0);
|
|
54
|
+
return { templateStart, cursor, path, partialKey: "" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const lastSegment = segments[segments.length - 1] ?? "";
|
|
58
|
+
const prefixSegments = segments.slice(0, -1).filter((segment) => segment.length > 0);
|
|
59
|
+
const parent = getValueAtPath(scope, prefixSegments);
|
|
60
|
+
const keys = listObjectKeys(parent);
|
|
61
|
+
|
|
62
|
+
if (lastSegment.length === 0) {
|
|
63
|
+
return { templateStart, cursor, path: prefixSegments, partialKey: "" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (keys.includes(lastSegment)) {
|
|
67
|
+
return {
|
|
68
|
+
templateStart,
|
|
69
|
+
cursor,
|
|
70
|
+
path: [...prefixSegments, lastSegment],
|
|
71
|
+
partialKey: "",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
templateStart,
|
|
77
|
+
cursor,
|
|
78
|
+
path: prefixSegments,
|
|
79
|
+
partialKey: lastSegment,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getScopeAutocompleteSuggestions(
|
|
84
|
+
scope: unknown,
|
|
85
|
+
state: ScopeAutocompleteState
|
|
86
|
+
): string[] {
|
|
87
|
+
const parent =
|
|
88
|
+
state.partialKey.length > 0
|
|
89
|
+
? getValueAtPath(scope, state.path)
|
|
90
|
+
: state.path.length > 0
|
|
91
|
+
? getValueAtPath(scope, state.path)
|
|
92
|
+
: scope;
|
|
93
|
+
|
|
94
|
+
const keys = listObjectKeys(parent);
|
|
95
|
+
if (state.partialKey.length === 0) return keys;
|
|
96
|
+
return keys.filter((key) => key.startsWith(state.partialKey));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildScopeExpression(path: string[], selectedKey: string): string {
|
|
100
|
+
return `scope?.${[...path, selectedKey].join("?.")}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function applyScopeAutocompleteSelection(
|
|
104
|
+
text: string,
|
|
105
|
+
state: ScopeAutocompleteState,
|
|
106
|
+
selectedKey: string
|
|
107
|
+
): { value: string; cursor: number } {
|
|
108
|
+
const expr = buildScopeExpression(state.path, selectedKey);
|
|
109
|
+
const before = text.slice(0, state.templateStart + 1);
|
|
110
|
+
const after = text.slice(state.cursor);
|
|
111
|
+
const value = `${before}${expr}${after}`;
|
|
112
|
+
const cursor = before.length + expr.length;
|
|
113
|
+
return { value, cursor };
|
|
114
|
+
}
|
package/src/scope-template.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
/** 仅匹配 `{scope...}`,避免把 JSON 对象 `{ "a": 1 }` 误当成模板 */
|
|
2
|
+
const SCOPE_TOKEN_RE = /\{(scope[^}]*)\}/g;
|
|
2
3
|
|
|
3
4
|
export function hasScopeTemplate(value: string): boolean {
|
|
4
|
-
|
|
5
|
+
SCOPE_TOKEN_RE.lastIndex = 0;
|
|
6
|
+
return SCOPE_TOKEN_RE.test(value);
|
|
5
7
|
}
|
|
6
8
|
|
|
7
9
|
export function evaluateScopeExpression(
|
|
@@ -31,10 +33,61 @@ export function stringifyScopeValue(value: unknown): string {
|
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
function isInsideJsonString(before: string): boolean {
|
|
37
|
+
let inString = false;
|
|
38
|
+
let escape = false;
|
|
39
|
+
for (const ch of before) {
|
|
40
|
+
if (inString) {
|
|
41
|
+
if (escape) {
|
|
42
|
+
escape = false;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (ch === "\\") {
|
|
46
|
+
escape = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (ch === '"') inString = false;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (ch === '"') inString = true;
|
|
53
|
+
}
|
|
54
|
+
return inString;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function replaceScopeTokens(
|
|
58
|
+
template: string,
|
|
59
|
+
scope: unknown,
|
|
60
|
+
replacer: (value: unknown, offset: number) => string
|
|
61
|
+
): string {
|
|
62
|
+
SCOPE_TOKEN_RE.lastIndex = 0;
|
|
63
|
+
return template.replace(SCOPE_TOKEN_RE, (_match, rawExpr: string, offset: number) =>
|
|
64
|
+
replacer(evaluateScopeExpression(rawExpr, scope), offset)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
34
68
|
/** 将 `{scope?.a}` 替换为求值结果;无模板时原样返回 */
|
|
35
69
|
export function evaluateScopeTemplate(template: string, scope: unknown): string {
|
|
36
70
|
if (!hasScopeTemplate(template)) return template;
|
|
37
|
-
return template
|
|
38
|
-
|
|
39
|
-
|
|
71
|
+
return replaceScopeTokens(template, scope, (value) => stringifyScopeValue(value));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 在 JSON 文本中替换 `{scope?...}`:
|
|
76
|
+
* - 位于字符串内时写入转义后的内容
|
|
77
|
+
* - 位于值位置时写入 JSON 字面量(对象/数组/字符串/数字)
|
|
78
|
+
*/
|
|
79
|
+
export function evaluateScopeTemplateInJson(template: string, scope: unknown): string {
|
|
80
|
+
if (!hasScopeTemplate(template)) return template;
|
|
81
|
+
return replaceScopeTokens(template, scope, (value, offset) => {
|
|
82
|
+
if (isInsideJsonString(template.slice(0, offset))) {
|
|
83
|
+
return JSON.stringify(stringifyScopeValue(value)).slice(1, -1);
|
|
84
|
+
}
|
|
85
|
+
if (value === undefined) return "null";
|
|
86
|
+
return JSON.stringify(value);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function looksLikeJsonText(text: string): boolean {
|
|
91
|
+
const trimmed = text.trim();
|
|
92
|
+
return trimmed.startsWith("{") || trimmed.startsWith("[");
|
|
40
93
|
}
|