@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.
- package/lib/JSRunner.js +23 -1
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +3 -3
- package/lib/data-source/index.d.ts +7 -27
- package/lib/data-source/index.js +67 -46
- package/lib/flowContext.d.ts +62 -0
- package/lib/flowContext.js +92 -3
- package/lib/flowEngine.js +18 -8
- package/lib/index.d.ts +4 -1
- package/lib/index.js +5 -0
- package/lib/resources/sqlResource.d.ts +3 -3
- package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +12 -2
- package/lib/runjs-context/contexts/JSBlockRunJSContext.js +2 -2
- package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.d.ts +16 -0
- package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +125 -0
- package/lib/runjs-context/contexts/JSItemRunJSContext.js +12 -2
- package/lib/runjs-context/contexts/base.js +691 -23
- package/lib/runjs-context/contributions.d.ts +33 -0
- package/lib/runjs-context/contributions.js +88 -0
- package/lib/runjs-context/setup.js +6 -0
- package/lib/runjs-context/snippets/index.d.ts +11 -1
- package/lib/runjs-context/snippets/index.js +61 -40
- package/lib/utils/safeGlobals.js +2 -0
- package/package.json +4 -4
- package/src/JSRunner.ts +29 -1
- package/src/__tests__/JSRunner.test.ts +64 -0
- package/src/__tests__/flowContext.test.ts +90 -0
- package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
- package/src/__tests__/runjsContext.test.ts +4 -1
- package/src/__tests__/runjsLocales.test.ts +4 -1
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +3 -3
- package/src/data-source/index.ts +71 -105
- package/src/flowContext.ts +160 -2
- package/src/flowEngine.ts +18 -8
- package/src/index.ts +4 -1
- package/src/resources/sqlResource.ts +3 -3
- package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +10 -0
- package/src/runjs-context/contexts/JSBlockRunJSContext.ts +6 -2
- package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +106 -0
- package/src/runjs-context/contexts/JSItemRunJSContext.ts +10 -0
- package/src/runjs-context/contexts/base.ts +698 -30
- package/src/runjs-context/contributions.ts +88 -0
- package/src/runjs-context/setup.ts +6 -0
- package/src/runjs-context/snippets/index.ts +75 -41
- package/src/utils/__tests__/safeGlobals.test.ts +8 -0
- 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,
|
|
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 =
|
|
131
|
-
if (
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
});
|
package/src/utils/safeGlobals.ts
CHANGED
|
@@ -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 代理
|