@arronqzy/blueprint-dsl 1.0.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arronqzy/blueprint-dsl",
3
- "version": "1.0.5",
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/eslint-config": "0.0.0",
20
- "@arronqzy/typescript-config": "1.0.0"
19
+ "@arronqzy/typescript-config": "1.0.0",
20
+ "@arronqzy/eslint-config": "0.0.0"
21
21
  },
22
22
  "publishConfig": {
23
23
  "access": "public"
@@ -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 { LIFECYCLE_SIGNAL_KEY, BLUEPRINT_ACTIVATION_INPUT_KEY, UI_EVENT_PAYLOAD_KEY };
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
+ }
@@ -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",
@@ -37,6 +38,11 @@ export type FetchRequestConfig = {
37
38
  url: string;
38
39
  method?: FetchHttpMethod;
39
40
  headers?: Record<string, string>;
41
+ /**
42
+ * 请求头编辑器原文。可写 JSON,也可整段写成 `{scope?...}`。
43
+ * 运行时优先按该字段解析,避免未加引号的模板在 JSON.parse 时被丢掉。
44
+ */
45
+ headersJson?: string;
40
46
  body?: string;
41
47
  credentials?: RequestCredentials;
42
48
  mode?: RequestMode;
@@ -78,6 +84,7 @@ export function normalizeFetchRequestConfig(
78
84
  ...DEFAULT_FETCH_REQUEST_CONFIG.headers,
79
85
  ...config?.headers,
80
86
  },
87
+ headersJson: config?.headersJson,
81
88
  swaggerEndpoints: config?.swaggerEndpoints,
82
89
  };
83
90
  }
@@ -183,6 +190,24 @@ export async function executeFetch(
183
190
  const body =
184
191
  hasBody && config.body?.trim() ? config.body : undefined;
185
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
+
186
211
  const timeoutMs = config.timeoutMs ?? 30000;
187
212
  const controller = new AbortController();
188
213
  const unlinkExternal = linkAbortSignal(controller, options?.signal);
@@ -194,10 +219,7 @@ export async function executeFetch(
194
219
  try {
195
220
  const response = await fetch(url, {
196
221
  method,
197
- headers:
198
- config.headers && Object.keys(config.headers).length > 0
199
- ? config.headers
200
- : undefined,
222
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
201
223
  body,
202
224
  credentials: config.credentials,
203
225
  mode: config.mode,
@@ -0,0 +1,411 @@
1
+ import type { FetchRequestConfig } from "./fetch-config.js";
2
+ import {
3
+ evaluateScopeExpression,
4
+ evaluateScopeTemplate,
5
+ evaluateScopeTemplateInJson,
6
+ hasScopeTemplate,
7
+ looksLikeJsonText,
8
+ stringifyScopeValue,
9
+ } from "./scope-template.js";
10
+ import { isFalseSignal, isTrueSignal } from "./node-signal.js";
11
+
12
+ export type IncomingNodeSource = {
13
+ id: string;
14
+ label?: string;
15
+ name?: string;
16
+ nodeType?: string;
17
+ type?: string;
18
+ role?: string;
19
+ };
20
+
21
+ export type IncomingGraphEdge = {
22
+ source: string;
23
+ target: string;
24
+ sourceHandle?: string;
25
+ targetHandle?: string;
26
+ };
27
+
28
+ export type IncomingNodeScopeEntry = {
29
+ id: string;
30
+ name: string;
31
+ type: string;
32
+ role?: string;
33
+ port: string;
34
+ kind: "true" | "false" | "pending";
35
+ value?: unknown;
36
+ error?: string;
37
+ timestamp?: number;
38
+ isoTime?: string;
39
+ };
40
+
41
+ export type IncomingNodeScope =
42
+ | IncomingNodeScopeEntry
43
+ | Record<string, IncomingNodeScopeEntry>;
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
+
63
+ function readOutputSignal(output: unknown): Pick<
64
+ IncomingNodeScopeEntry,
65
+ "kind" | "value" | "error" | "timestamp" | "isoTime"
66
+ > {
67
+ if (output === undefined) return { kind: "pending" };
68
+ if (isTrueSignal(output)) {
69
+ return {
70
+ kind: "true",
71
+ value: output.value,
72
+ timestamp: output.timestamp,
73
+ isoTime: output.isoTime,
74
+ };
75
+ }
76
+ if (isFalseSignal(output)) {
77
+ return {
78
+ kind: "false",
79
+ error: output.error,
80
+ timestamp: output.timestamp,
81
+ isoTime: output.isoTime,
82
+ };
83
+ }
84
+ if (output && typeof output === "object" && "kind" in output) {
85
+ const rec = output as {
86
+ kind?: string;
87
+ value?: unknown;
88
+ error?: string;
89
+ timestamp?: number;
90
+ isoTime?: string;
91
+ };
92
+ if (rec.kind === "true") {
93
+ return {
94
+ kind: "true",
95
+ value: rec.value,
96
+ timestamp: rec.timestamp,
97
+ isoTime: rec.isoTime,
98
+ };
99
+ }
100
+ if (rec.kind === "false") {
101
+ return {
102
+ kind: "false",
103
+ error: rec.error,
104
+ timestamp: rec.timestamp,
105
+ isoTime: rec.isoTime,
106
+ };
107
+ }
108
+ }
109
+ return { kind: "true", value: output };
110
+ }
111
+
112
+ export function createIncomingNodeScopeEntry(
113
+ node: IncomingNodeSource | undefined,
114
+ port: string,
115
+ output: unknown
116
+ ): IncomingNodeScopeEntry {
117
+ const id = node?.id ?? "in";
118
+ const signal = readOutputSignal(output);
119
+ return {
120
+ id,
121
+ name: node?.label?.trim() || node?.name?.trim() || id,
122
+ type: node?.nodeType || node?.type || "unknown",
123
+ role: node?.role,
124
+ port,
125
+ ...signal,
126
+ };
127
+ }
128
+
129
+ export function buildIncomingNodeScope(
130
+ entries: IncomingNodeScopeEntry[]
131
+ ): IncomingNodeScope | undefined {
132
+ if (entries.length === 0) return undefined;
133
+ if (entries.length === 1) return entries[0];
134
+ const scoped: Record<string, IncomingNodeScopeEntry> = {};
135
+ for (const entry of entries) {
136
+ scoped[entry.id] = entry;
137
+ }
138
+ return scoped;
139
+ }
140
+
141
+ export function collectIncomingNodeScopeEntries(options: {
142
+ targetNodeId: string;
143
+ targetPort?: string;
144
+ nodes: IncomingNodeSource[];
145
+ edges: IncomingGraphEdge[];
146
+ getOutput?: (nodeId: string, port: string) => unknown;
147
+ }): IncomingNodeScopeEntry[] {
148
+ const targetPort = options.targetPort ?? "in";
149
+ const nodeById = new Map(options.nodes.map((node) => [node.id, node]));
150
+ const inbound = options.edges.filter(
151
+ (edge) =>
152
+ edge.target === options.targetNodeId &&
153
+ (edge.targetHandle ?? "in") === targetPort
154
+ );
155
+
156
+ const seen = new Set<string>();
157
+ const entries: IncomingNodeScopeEntry[] = [];
158
+ for (const edge of inbound) {
159
+ const port = edge.sourceHandle ?? "out";
160
+ const key = `${edge.source}:${port}`;
161
+ if (seen.has(key)) continue;
162
+ seen.add(key);
163
+ const output = options.getOutput?.(edge.source, port);
164
+ entries.push(
165
+ createIncomingNodeScopeEntry(nodeById.get(edge.source), port, output)
166
+ );
167
+ }
168
+ return entries;
169
+ }
170
+
171
+ export function resolveFetchIncomingScope(options: {
172
+ fetchNodeId: string;
173
+ inputPort?: string;
174
+ nodes: IncomingNodeSource[];
175
+ edges: IncomingGraphEdge[];
176
+ getOutput?: (nodeId: string, port: string) => unknown;
177
+ fallbackInput?: unknown;
178
+ }): IncomingNodeScope | undefined {
179
+ const entries = collectIncomingNodeScopeEntries({
180
+ targetNodeId: options.fetchNodeId,
181
+ targetPort: options.inputPort,
182
+ nodes: options.nodes,
183
+ edges: options.edges,
184
+ getOutput: options.getOutput,
185
+ });
186
+ if (entries.length > 0) return buildIncomingNodeScope(entries);
187
+ if (options.fallbackInput === undefined) return undefined;
188
+ return createIncomingNodeScopeEntry(
189
+ { id: "in", label: "in", nodeType: "input" },
190
+ options.inputPort ?? "in",
191
+ options.fallbackInput
192
+ );
193
+ }
194
+
195
+ export function latestTraceOutputsByNode(
196
+ entries: Array<{ nodeId: string; outputs?: Record<string, unknown> }>
197
+ ): Record<string, Record<string, unknown>> {
198
+ const map: Record<string, Record<string, unknown>> = {};
199
+ for (const entry of entries) {
200
+ map[entry.nodeId] = entry.outputs ?? {};
201
+ }
202
+ return map;
203
+ }
204
+
205
+ function resolveTemplateString(
206
+ value: string | undefined,
207
+ scope: unknown
208
+ ): string | undefined {
209
+ if (value === undefined) return undefined;
210
+ if (!hasScopeTemplate(value)) return value;
211
+ return evaluateScopeTemplate(value, scope);
212
+ }
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
+
302
+ /** `{scope?...}` 整段表达式,区别于 JSON 对象 `{ "Authorization": "..." }` */
303
+ export function isWholeScopeExpression(text: string): boolean {
304
+ const trimmed = text.trim();
305
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false;
306
+ return trimmed.slice(1, -1).trim().startsWith("scope");
307
+ }
308
+
309
+ function asHeaderRecord(value: unknown): Record<string, string> | undefined {
310
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
311
+ const headers: Record<string, string> = {};
312
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
313
+ if (raw === undefined || raw === null) continue;
314
+ headers[key] = typeof raw === "string" ? raw : stringifyScopeValue(raw);
315
+ }
316
+ return headers;
317
+ }
318
+
319
+ function applyScopeToHeaderRecord(
320
+ headers: Record<string, string>,
321
+ scope: unknown
322
+ ): Record<string, string> {
323
+ if (scope === undefined) return headers;
324
+ return Object.fromEntries(
325
+ Object.entries(headers).map(([key, value]) => [
326
+ evaluateScopeTemplate(String(key), scope),
327
+ evaluateScopeTemplate(value == null ? "" : String(value), scope),
328
+ ])
329
+ );
330
+ }
331
+
332
+ /** 将请求头编辑器原文解析为对象;支持 JSON 内模板,也支持整段 `{scope?...}` */
333
+ export function parseFetchHeadersJson(
334
+ text: string,
335
+ scope?: unknown
336
+ ): Record<string, string> | undefined {
337
+ const trimmed = text.trim();
338
+ if (!trimmed) return {};
339
+ if (isWholeScopeExpression(trimmed)) {
340
+ if (scope === undefined) return undefined;
341
+ const inner = trimmed.slice(1, -1);
342
+ return asHeaderRecord(evaluateScopeExpression(inner, scope));
343
+ }
344
+ let jsonText = trimmed;
345
+ if (scope !== undefined && hasScopeTemplate(trimmed) && looksLikeJsonText(trimmed)) {
346
+ jsonText = evaluateScopeTemplateInJson(trimmed, scope);
347
+ }
348
+ try {
349
+ const parsed = asHeaderRecord(JSON.parse(jsonText));
350
+ if (!parsed) return undefined;
351
+ if (jsonText === trimmed) {
352
+ return applyScopeToHeaderRecord(parsed, scope);
353
+ }
354
+ return parsed;
355
+ } catch {
356
+ return undefined;
357
+ }
358
+ }
359
+
360
+ export function draftFetchHeadersText(config: FetchRequestConfig): string {
361
+ if (config.headersJson !== undefined) return config.headersJson;
362
+ try {
363
+ return JSON.stringify(config.headers ?? {}, null, 2);
364
+ } catch {
365
+ return "{}";
366
+ }
367
+ }
368
+
369
+ export function resolveFetchHeaders(
370
+ config: FetchRequestConfig,
371
+ scope?: unknown
372
+ ): Record<string, string> | undefined {
373
+ const rawJson = config.headersJson;
374
+ if (rawJson !== undefined && rawJson.trim() !== "") {
375
+ const parsed = parseFetchHeadersJson(rawJson, scope);
376
+ if (parsed) return parsed;
377
+ }
378
+ if (!config.headers) return config.headers;
379
+ return applyScopeToHeaderRecord(config.headers, scope);
380
+ }
381
+
382
+ /** 用上游 scope 解析 fetch 表单中的 URL / Base URL / headers / body */
383
+ export function applyFetchConfigScope(
384
+ config: FetchRequestConfig,
385
+ scope: unknown
386
+ ): FetchRequestConfig {
387
+ const headers = resolveFetchHeaders(config, scope);
388
+ const body = resolveFetchBody(config.body, scope);
389
+ if (scope === undefined) {
390
+ if (headers === config.headers && body === config.body) return config;
391
+ return { ...config, headers, body };
392
+ }
393
+ return {
394
+ ...config,
395
+ url: resolveTemplateString(config.url, scope) ?? "",
396
+ apiBaseUrl: resolveTemplateString(config.apiBaseUrl, scope),
397
+ body,
398
+ headers,
399
+ };
400
+ }
401
+
402
+ export function fetchConfigHasScopeTemplate(config: FetchRequestConfig): boolean {
403
+ if (hasScopeTemplate(config.url ?? "")) return true;
404
+ if (hasScopeTemplate(config.apiBaseUrl ?? "")) return true;
405
+ if (hasScopeTemplate(config.body ?? "")) return true;
406
+ if (hasScopeTemplate(config.headersJson ?? "")) return true;
407
+ const headers = config.headers ?? {};
408
+ return Object.entries(headers).some(
409
+ ([key, value]) => hasScopeTemplate(key) || hasScopeTemplate(String(value ?? ""))
410
+ );
411
+ }
package/src/index.ts CHANGED
@@ -7,9 +7,13 @@ export * from "./core/behavior-registry.js";
7
7
  export * from "./core/executor.js";
8
8
  export * from "./behaviors/default.js";
9
9
  export * from "./fetch-config.js";
10
+ export * from "./scope-template.js";
11
+ export * from "./scope-autocomplete.js";
12
+ export * from "./incoming-node-scope.js";
10
13
  export * from "./json-config.js";
11
14
  export * from "./logic-config.js";
12
15
  export * from "./clock-config.js";
16
+ export * from "./event-config.js";
13
17
  export * from "./swagger.js";
14
18
  export * from "./runtime/execution-trace.js";
15
19
  export * from "./runtime/blueprint-cycle.js";
@@ -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,6 +4,12 @@ import {
4
4
  normalizeFetchRequestConfig,
5
5
  resolveFetchRequestUrl,
6
6
  } from "../fetch-config.js";
7
+ import {
8
+ applyFetchConfigScope,
9
+ getFetchBodyValidationError,
10
+ getFetchHeadersValidationError,
11
+ resolveFetchIncomingScope,
12
+ } from "../incoming-node-scope.js";
7
13
  import type { JsonNodeConfig } from "../json-config.js";
8
14
  import {
9
15
  DEFAULT_JSON_NODE_CONFIG,
@@ -22,6 +28,8 @@ import {
22
28
  normalizeClockConfig,
23
29
  validateClockScheduleConfig,
24
30
  } from "../clock-config.js";
31
+ import type { ViewEventNodeConfig, ViewEventSignal } from "../event-config.js";
32
+ import { normalizeViewEventConfig } from "../event-config.js";
25
33
  import {
26
34
  buildClockSessionKey,
27
35
  scheduleClockOutputs,
@@ -39,6 +47,7 @@ import {
39
47
  BLUEPRINT_ACTIVATION_INPUT_KEY,
40
48
  LIFECYCLE_SIGNAL_KEY,
41
49
  UI_EVENT_PAYLOAD_KEY,
50
+ VIEW_EVENT_SIGNAL_KEY,
42
51
  } from "../behaviors/default.js";
43
52
  import { BehaviorRegistry } from "../core/behavior-registry.js";
44
53
  import { Executor } from "../core/executor.js";
@@ -57,6 +66,7 @@ import {
57
66
  import {
58
67
  BLUEPRINT_NODE_TYPE,
59
68
  CLOCK_NODE_TYPE,
69
+ EVENT_NODE_TYPE,
60
70
  getNodeDefinition,
61
71
  LIFECYCLE_NODE_TYPE,
62
72
  VIEW_NODE_TYPE,
@@ -78,6 +88,8 @@ import {
78
88
  export type RunnableGraphNode = {
79
89
  id: string;
80
90
  nodeType: string;
91
+ label?: string;
92
+ role?: string;
81
93
  lifecyclePhase?: PageLifecyclePhase;
82
94
  /** 蓝图库记录 id,Blueprint 节点引用嵌套蓝图时使用 */
83
95
  libraryBlueprintId?: string;
@@ -88,6 +100,7 @@ export type RunnableGraphNode = {
88
100
  jsonConfig?: JsonNodeConfig;
89
101
  logicConfig?: LogicNodeConfig;
90
102
  clockConfig?: ClockNodeConfig;
103
+ eventConfig?: ViewEventNodeConfig;
91
104
  };
92
105
 
93
106
  export type RunnableGraphEdge = {
@@ -304,6 +317,28 @@ export class BlueprintGraphRunner {
304
317
  }
305
318
  }
306
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
+
307
342
  /** 时钟节点单次输出:向下游传播含当前时间的真信号 */
308
343
  async propagateClockOutput(
309
344
  nodeId: string,
@@ -1076,7 +1111,23 @@ export class BlueprintGraphRunner {
1076
1111
  return;
1077
1112
  }
1078
1113
 
1079
- const config = normalizeFetchRequestConfig(node.fetchConfig);
1114
+ const incomingScope = runner.buildFetchIncomingScope(
1115
+ token.nodeId,
1116
+ inputPort,
1117
+ input
1118
+ );
1119
+ const config = applyFetchConfigScope(
1120
+ normalizeFetchRequestConfig(node.fetchConfig),
1121
+ incomingScope
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
+ }
1080
1131
  resolveFetchRequestUrl(config);
1081
1132
 
1082
1133
  const result = await executeFetch(config, {
@@ -1255,7 +1306,8 @@ export class BlueprintGraphRunner {
1255
1306
  nodeId: string,
1256
1307
  lifecycleSignal?: LifecycleSignal,
1257
1308
  parentScope?: Scope,
1258
- activationInput?: unknown
1309
+ activationInput?: unknown,
1310
+ viewEvent?: ViewEventSignal
1259
1311
  ) {
1260
1312
  const queue: ExecutionToken[] = [
1261
1313
  {
@@ -1276,6 +1328,10 @@ export class BlueprintGraphRunner {
1276
1328
  activationInput
1277
1329
  );
1278
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
+ }
1279
1335
 
1280
1336
  const executor = this.createExecutor(queue);
1281
1337
 
@@ -1351,7 +1407,9 @@ export class BlueprintGraphRunner {
1351
1407
  }
1352
1408
  return this.graph.nodes.filter(
1353
1409
  (node) =>
1354
- node.nodeType !== LIFECYCLE_NODE_TYPE && !hasIncoming.has(node.id)
1410
+ node.nodeType !== LIFECYCLE_NODE_TYPE &&
1411
+ node.nodeType !== EVENT_NODE_TYPE &&
1412
+ !hasIncoming.has(node.id)
1355
1413
  );
1356
1414
  }
1357
1415
 
@@ -1409,6 +1467,27 @@ export class BlueprintGraphRunner {
1409
1467
  );
1410
1468
  }
1411
1469
 
1470
+ private buildFetchIncomingScope(
1471
+ nodeId: string,
1472
+ port: string,
1473
+ fallbackInput: Value | undefined
1474
+ ) {
1475
+ return resolveFetchIncomingScope({
1476
+ fetchNodeId: nodeId,
1477
+ inputPort: port,
1478
+ nodes: this.graph.nodes.map((node) => ({
1479
+ id: node.id,
1480
+ label: node.label,
1481
+ nodeType: node.nodeType,
1482
+ role: node.role,
1483
+ })),
1484
+ edges: this.graph.edges,
1485
+ getOutput: (sourceId, sourcePort) =>
1486
+ this.getNodeOutput(sourceId, sourcePort),
1487
+ fallbackInput,
1488
+ });
1489
+ }
1490
+
1412
1491
  /** 单端口多连线:任一为真则视为真(或语义);支持 triggerNode 注入的入口信号 */
1413
1492
  private resolvePortInputValue(nodeId: string, port: string): Value | undefined {
1414
1493
  const injected = this.injectedInputs.get(`${nodeId}:${port}`);
@@ -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
+ }
@@ -0,0 +1,93 @@
1
+ /** 仅匹配 `{scope...}`,避免把 JSON 对象 `{ "a": 1 }` 误当成模板 */
2
+ const SCOPE_TOKEN_RE = /\{(scope[^}]*)\}/g;
3
+
4
+ export function hasScopeTemplate(value: string): boolean {
5
+ SCOPE_TOKEN_RE.lastIndex = 0;
6
+ return SCOPE_TOKEN_RE.test(value);
7
+ }
8
+
9
+ export function evaluateScopeExpression(
10
+ expression: string,
11
+ scope: unknown
12
+ ): unknown {
13
+ const trimmed = expression.trim();
14
+ if (!trimmed) return undefined;
15
+ try {
16
+ const fn = new Function("scope", `"use strict"; return (${trimmed});`);
17
+ return fn(scope);
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
22
+
23
+ export function stringifyScopeValue(value: unknown): string {
24
+ if (value === null || value === undefined) return "";
25
+ if (typeof value === "string") return value;
26
+ if (typeof value === "number" || typeof value === "boolean") {
27
+ return String(value);
28
+ }
29
+ try {
30
+ return JSON.stringify(value);
31
+ } catch {
32
+ return String(value);
33
+ }
34
+ }
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
+
68
+ /** 将 `{scope?.a}` 替换为求值结果;无模板时原样返回 */
69
+ export function evaluateScopeTemplate(template: string, scope: unknown): string {
70
+ if (!hasScopeTemplate(template)) return template;
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("[");
93
+ }