@nocobase/flow-engine 2.0.0-beta.20 → 2.0.0-beta.22

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.
Files changed (45) hide show
  1. package/lib/JSRunner.js +23 -1
  2. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +3 -3
  3. package/lib/data-source/index.d.ts +7 -27
  4. package/lib/data-source/index.js +67 -46
  5. package/lib/flowContext.d.ts +62 -0
  6. package/lib/flowContext.js +92 -3
  7. package/lib/flowEngine.js +18 -8
  8. package/lib/index.d.ts +4 -1
  9. package/lib/index.js +5 -0
  10. package/lib/resources/sqlResource.d.ts +3 -3
  11. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +12 -2
  12. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +2 -2
  13. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.d.ts +16 -0
  14. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +125 -0
  15. package/lib/runjs-context/contexts/JSItemRunJSContext.js +12 -2
  16. package/lib/runjs-context/contexts/base.js +691 -23
  17. package/lib/runjs-context/contributions.d.ts +33 -0
  18. package/lib/runjs-context/contributions.js +88 -0
  19. package/lib/runjs-context/setup.js +6 -0
  20. package/lib/runjs-context/snippets/index.d.ts +11 -1
  21. package/lib/runjs-context/snippets/index.js +61 -40
  22. package/lib/utils/safeGlobals.js +2 -0
  23. package/package.json +4 -4
  24. package/src/JSRunner.ts +29 -1
  25. package/src/__tests__/JSRunner.test.ts +64 -0
  26. package/src/__tests__/flowContext.test.ts +90 -0
  27. package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
  28. package/src/__tests__/runjsContext.test.ts +4 -1
  29. package/src/__tests__/runjsLocales.test.ts +4 -1
  30. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +3 -3
  31. package/src/data-source/index.ts +71 -105
  32. package/src/flowContext.ts +160 -2
  33. package/src/flowEngine.ts +18 -8
  34. package/src/index.ts +4 -1
  35. package/src/resources/sqlResource.ts +3 -3
  36. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +10 -0
  37. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +6 -2
  38. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +106 -0
  39. package/src/runjs-context/contexts/JSItemRunJSContext.ts +10 -0
  40. package/src/runjs-context/contexts/base.ts +698 -30
  41. package/src/runjs-context/contributions.ts +88 -0
  42. package/src/runjs-context/setup.ts +6 -0
  43. package/src/runjs-context/snippets/index.ts +75 -41
  44. package/src/utils/__tests__/safeGlobals.test.ts +8 -0
  45. package/src/utils/safeGlobals.ts +3 -1
@@ -0,0 +1,88 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { FlowRunJSContext } from '../flowContext';
11
+ import { RunJSContextRegistry, type RunJSVersion } from './registry';
12
+
13
+ export type RunJSContextContributionApi = {
14
+ version: RunJSVersion;
15
+ RunJSContextRegistry: typeof RunJSContextRegistry;
16
+ FlowRunJSContext: typeof FlowRunJSContext;
17
+ };
18
+
19
+ export type RunJSContextContribution = (api: RunJSContextContributionApi) => void | Promise<void>;
20
+
21
+ const contributions = new Set<RunJSContextContribution>();
22
+ const appliedByVersion = new Map<RunJSVersion, Set<RunJSContextContribution>>();
23
+ const setupDoneVersions = new Set<RunJSVersion>();
24
+
25
+ async function applyContributionOnce(version: RunJSVersion, contribution: RunJSContextContribution) {
26
+ const applied = appliedByVersion.get(version) || new Set<RunJSContextContribution>();
27
+ appliedByVersion.set(version, applied);
28
+ if (applied.has(contribution)) return;
29
+
30
+ // Mark as applied before awaiting to avoid duplicate runs on concurrency.
31
+ // If it fails, remove the marker so a later setup retry can re-apply.
32
+ applied.add(contribution);
33
+ try {
34
+ await contribution({
35
+ version,
36
+ RunJSContextRegistry,
37
+ FlowRunJSContext,
38
+ });
39
+ } catch (error) {
40
+ applied.delete(contribution);
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Register a RunJS context/doc contribution.
47
+ *
48
+ * - If RunJS contexts have already been set up for a version, the contribution is applied immediately once.
49
+ * - Each contribution is executed at most once per version.
50
+ */
51
+ export function registerRunJSContextContribution(contribution: RunJSContextContribution) {
52
+ if (typeof contribution !== 'function') {
53
+ throw new Error('[flow-engine] registerRunJSContextContribution: contribution must be a function');
54
+ }
55
+ if (contributions.has(contribution)) return;
56
+ contributions.add(contribution);
57
+
58
+ // Apply immediately for already-setup versions (late registration).
59
+ for (const version of setupDoneVersions) {
60
+ void applyContributionOnce(version, contribution).catch((error) => {
61
+ // Avoid unhandled rejections in late registrations
62
+ try {
63
+ // eslint-disable-next-line no-console
64
+ console.error('[flow-engine] RunJS context contribution failed:', error);
65
+ } catch (_) {
66
+ void 0;
67
+ }
68
+ });
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Apply all registered contributions for a given version.
74
+ * Intended to be called by setupRunJSContexts().
75
+ */
76
+ export async function applyRunJSContextContributions(version: RunJSVersion) {
77
+ for (const contribution of contributions) {
78
+ await applyContributionOnce(version, contribution);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Mark setupRunJSContexts() as completed for a given version.
84
+ * Used to support late contributions that should take effect without re-running setup.
85
+ */
86
+ export function markRunJSContextsSetupDone(version: RunJSVersion) {
87
+ setupDoneVersions.add(version);
88
+ }
@@ -13,6 +13,7 @@
13
13
  import { RunJSContextRegistry } from './registry';
14
14
  import { FlowRunJSContext } from '../flowContext';
15
15
  import { defineBaseContextMeta } from './contexts/base';
16
+ import { applyRunJSContextContributions, markRunJSContextsSetupDone } from './contributions';
16
17
 
17
18
  let done = false;
18
19
  export async function setupRunJSContexts() {
@@ -23,6 +24,7 @@ export async function setupRunJSContexts() {
23
24
  const [
24
25
  { JSBlockRunJSContext },
25
26
  { JSFieldRunJSContext },
27
+ { JSEditableFieldRunJSContext },
26
28
  { JSItemRunJSContext },
27
29
  { JSColumnRunJSContext },
28
30
  { FormJSFieldItemRunJSContext },
@@ -31,6 +33,7 @@ export async function setupRunJSContexts() {
31
33
  ] = await Promise.all([
32
34
  import('./contexts/JSBlockRunJSContext'),
33
35
  import('./contexts/JSFieldRunJSContext'),
36
+ import('./contexts/JSEditableFieldRunJSContext'),
34
37
  import('./contexts/JSItemRunJSContext'),
35
38
  import('./contexts/JSColumnRunJSContext'),
36
39
  import('./contexts/FormJSFieldItemRunJSContext'),
@@ -42,10 +45,13 @@ export async function setupRunJSContexts() {
42
45
  RunJSContextRegistry.register(v1, '*', FlowRunJSContext);
43
46
  RunJSContextRegistry.register(v1, 'JSBlockModel', JSBlockRunJSContext, { scenes: ['block'] });
44
47
  RunJSContextRegistry.register(v1, 'JSFieldModel', JSFieldRunJSContext, { scenes: ['detail'] });
48
+ RunJSContextRegistry.register(v1, 'JSEditableFieldModel', JSEditableFieldRunJSContext, { scenes: ['form'] });
45
49
  RunJSContextRegistry.register(v1, 'JSItemModel', JSItemRunJSContext, { scenes: ['form'] });
46
50
  RunJSContextRegistry.register(v1, 'JSColumnModel', JSColumnRunJSContext, { scenes: ['table'] });
47
51
  RunJSContextRegistry.register(v1, 'FormJSFieldItemModel', FormJSFieldItemRunJSContext, { scenes: ['form'] });
48
52
  RunJSContextRegistry.register(v1, 'JSRecordActionModel', JSRecordActionRunJSContext, { scenes: ['table'] });
49
53
  RunJSContextRegistry.register(v1, 'JSCollectionActionModel', JSCollectionActionRunJSContext, { scenes: ['table'] });
54
+ await applyRunJSContextContributions(v1);
50
55
  done = true;
56
+ markRunJSContextsSetupDone(v1);
51
57
  }
@@ -9,8 +9,10 @@
9
9
 
10
10
  import { RunJSContextRegistry } from '../registry';
11
11
 
12
+ export type RunJSSnippetLoader = () => Promise<any>;
13
+
12
14
  // Simple manual exports - no build-time magic needed
13
- const snippets: Record<string, () => Promise<any>> = {
15
+ const snippets: Record<string, RunJSSnippetLoader | undefined> = {
14
16
  // global
15
17
  'global/message-success': () => import('./global/message-success.snippet'),
16
18
  'global/message-error': () => import('./global/message-error.snippet'),
@@ -69,6 +71,32 @@ const snippets: Record<string, () => Promise<any>> = {
69
71
 
70
72
  export default snippets;
71
73
 
74
+ /**
75
+ * Register a RunJS snippet loader for editors/AI coding.
76
+ *
77
+ * - By default, an existing ref will NOT be overwritten (returns false).
78
+ * - Use { override: true } to overwrite an existing ref (returns true).
79
+ */
80
+ export function registerRunJSSnippet(
81
+ ref: string,
82
+ loader: RunJSSnippetLoader,
83
+ options?: {
84
+ override?: boolean;
85
+ },
86
+ ): boolean {
87
+ if (typeof ref !== 'string' || !ref.trim()) {
88
+ throw new Error('[flow-engine] registerRunJSSnippet: ref must be a non-empty string');
89
+ }
90
+ if (typeof loader !== 'function') {
91
+ throw new Error('[flow-engine] registerRunJSSnippet: loader must be a function returning a Promise');
92
+ }
93
+ const key = ref.trim();
94
+ const existed = typeof snippets[key] === 'function';
95
+ if (existed && !options?.override) return false;
96
+ snippets[key] = loader;
97
+ return true;
98
+ }
99
+
72
100
  // Cohesive snippet helpers for clients (editor, etc.)
73
101
  type EngineSnippetEntry = {
74
102
  name: string;
@@ -127,8 +155,8 @@ function resolveLocaleMeta(def: any, locale?: string) {
127
155
  }
128
156
 
129
157
  export async function getSnippetBody(ref: string): Promise<string> {
130
- const loader = (snippets as any)[ref];
131
- if (!loader) throw new Error(`[flow-engine] snippet not found: ${ref}`);
158
+ const loader = snippets[ref];
159
+ if (typeof loader !== 'function') throw new Error(`[flow-engine] snippet not found: ${ref}`);
132
160
  const mod = await loader();
133
161
  const def = mod?.default;
134
162
  // engine snippet modules export a SnippetModule as default
@@ -152,46 +180,52 @@ export async function listSnippetsForContext(
152
180
  }
153
181
  await Promise.all(
154
182
  Object.entries(snippets).map(async ([key, loader]) => {
155
- const mod = await (loader as any)();
156
- const def = mod?.default || {};
157
- const body: any = def?.content ?? mod?.content;
158
- if (typeof body !== 'string') return;
159
- let ok = true;
160
- if (Array.isArray(def?.contexts) && def.contexts.length) {
161
- const ctxNames = def.contexts.map((item: any) => {
162
- if (item === '*') return '*';
163
- if (typeof item === 'string') return item;
164
- if (typeof item === 'function') return item.name || '*';
165
- if (item && typeof item === 'object' && typeof item.name === 'string') return item.name;
166
- return String(item ?? '');
167
- });
168
- if (ctxClassName === '*') {
169
- // '*' means return all snippets without filtering by context
170
- ok = true;
171
- } else {
172
- ok = ctxNames.includes('*') || ctxNames.some((name: string) => allowedContextNames.has(name));
183
+ if (typeof loader !== 'function') return;
184
+ try {
185
+ const mod = await loader();
186
+ const def = mod?.default || {};
187
+ const body: any = def?.content ?? mod?.content;
188
+ if (typeof body !== 'string') return;
189
+ let ok = true;
190
+ if (Array.isArray(def?.contexts) && def.contexts.length) {
191
+ const ctxNames = def.contexts.map((item: any) => {
192
+ if (item === '*') return '*';
193
+ if (typeof item === 'string') return item;
194
+ if (typeof item === 'function') return item.name || '*';
195
+ if (item && typeof item === 'object' && typeof item.name === 'string') return item.name;
196
+ return String(item ?? '');
197
+ });
198
+ if (ctxClassName === '*') {
199
+ // '*' means return all snippets without filtering by context
200
+ ok = true;
201
+ } else {
202
+ ok = ctxNames.includes('*') || ctxNames.some((name: string) => allowedContextNames.has(name));
203
+ }
173
204
  }
205
+ if (ok && Array.isArray(def?.versions) && def.versions.length) {
206
+ ok = def.versions.includes('*') || def.versions.includes(version);
207
+ }
208
+ if (!ok) return;
209
+ const localeMeta = resolveLocaleMeta(def, locale);
210
+ const name = localeMeta.label || def?.label || deriveNameFromKey(key);
211
+ const description = localeMeta.description ?? def?.description;
212
+ const prefix = def?.prefix || name;
213
+ const groups = computeGroups(def, key);
214
+ const scenes = normalizeScenes(def, key);
215
+ entries.push({
216
+ name,
217
+ prefix,
218
+ description,
219
+ body,
220
+ ref: key,
221
+ group: groups[0],
222
+ groups,
223
+ scenes,
224
+ });
225
+ } catch (_) {
226
+ // fail-open: ignore broken snippet loader
227
+ return;
174
228
  }
175
- if (ok && Array.isArray(def?.versions) && def.versions.length) {
176
- ok = def.versions.includes('*') || def.versions.includes(version);
177
- }
178
- if (!ok) return;
179
- const localeMeta = resolveLocaleMeta(def, locale);
180
- const name = localeMeta.label || def?.label || deriveNameFromKey(key);
181
- const description = localeMeta.description ?? def?.description;
182
- const prefix = def?.prefix || name;
183
- const groups = computeGroups(def, key);
184
- const scenes = normalizeScenes(def, key);
185
- entries.push({
186
- name,
187
- prefix,
188
- description,
189
- body,
190
- ref: key,
191
- group: groups[0],
192
- groups,
193
- scenes,
194
- });
195
229
  }),
196
230
  );
197
231
  return entries;
@@ -28,6 +28,14 @@ describe('safeGlobals', () => {
28
28
  expect(win.console).toBeDefined();
29
29
  expect(win.foo).toBe(123);
30
30
  expect(new win.FormData()).toBeInstanceOf(window.FormData);
31
+ if (typeof window.Blob !== 'undefined') {
32
+ expect(typeof win.Blob).toBe('function');
33
+ expect(new win.Blob(['x'])).toBeInstanceOf(window.Blob);
34
+ }
35
+ if (typeof window.URL !== 'undefined') {
36
+ expect(win.URL).toBe(window.URL);
37
+ expect(typeof win.URL.createObjectURL).toBe('function');
38
+ }
31
39
  // access to location proxy is allowed, but sensitive props throw
32
40
  expect(() => win.location.href).toThrow(/not allowed/);
33
41
  });
@@ -9,7 +9,7 @@
9
9
 
10
10
  /**
11
11
  * 统一的安全全局对象代理:window/document/navigator
12
- * - window:仅允许常用的定时器、console、Math、Date、FormData、addEventListener、open(安全包装)、location(安全代理)
12
+ * - window:仅允许常用的定时器、console、Math、Date、FormData、Blob、URL、addEventListener、open(安全包装)、location(安全代理)
13
13
  * - document:仅允许 createElement/querySelector/querySelectorAll
14
14
  * - navigator:仅提供极少量低风险能力(clipboard.writeText、onLine、language、languages)
15
15
  * - 不允许随意访问未声明的属性,最小权限原则
@@ -211,6 +211,8 @@ export function createSafeWindow(extra?: Record<string, any>) {
211
211
  Math,
212
212
  Date,
213
213
  FormData,
214
+ ...(typeof Blob !== 'undefined' ? { Blob } : {}),
215
+ ...(typeof URL !== 'undefined' ? { URL } : {}),
214
216
  // 事件侦听仅绑定到真实 window,便于少量需要的全局监听
215
217
  addEventListener: addEventListener.bind(window),
216
218
  // 安全的 window.open 代理