@nocobase/flow-engine 2.0.0-beta.2 → 2.0.0-beta.20
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/BlockScopedFlowEngine.js +0 -1
- package/lib/JSRunner.d.ts +6 -0
- package/lib/JSRunner.js +2 -1
- package/lib/ViewScopedFlowEngine.js +3 -0
- package/lib/acl/Acl.js +13 -3
- package/lib/components/dnd/gridDragPlanner.d.ts +1 -0
- package/lib/components/dnd/gridDragPlanner.js +53 -1
- package/lib/components/settings/wrappers/component/SwitchWithTitle.js +2 -1
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +11 -3
- package/lib/components/variables/VariableInput.js +8 -2
- package/lib/data-source/index.js +6 -0
- package/lib/executor/FlowExecutor.d.ts +2 -1
- package/lib/executor/FlowExecutor.js +156 -22
- package/lib/flowContext.d.ts +4 -1
- package/lib/flowContext.js +176 -107
- package/lib/flowEngine.d.ts +21 -0
- package/lib/flowEngine.js +38 -0
- package/lib/flowSettings.js +12 -10
- package/lib/index.d.ts +3 -0
- package/lib/index.js +16 -0
- package/lib/models/CollectionFieldModel.d.ts +1 -0
- package/lib/models/CollectionFieldModel.js +3 -2
- package/lib/models/flowModel.d.ts +7 -0
- package/lib/models/flowModel.js +66 -1
- package/lib/provider.js +7 -6
- package/lib/resources/baseRecordResource.d.ts +5 -0
- package/lib/resources/baseRecordResource.js +24 -0
- package/lib/resources/multiRecordResource.d.ts +1 -0
- package/lib/resources/multiRecordResource.js +11 -4
- package/lib/resources/singleRecordResource.js +2 -0
- package/lib/resources/sqlResource.d.ts +1 -0
- package/lib/resources/sqlResource.js +8 -3
- package/lib/runjs-context/contexts/base.js +10 -4
- package/lib/runjsLibs.d.ts +28 -0
- package/lib/runjsLibs.js +532 -0
- package/lib/scheduler/ModelOperationScheduler.d.ts +2 -0
- package/lib/scheduler/ModelOperationScheduler.js +21 -21
- package/lib/types.d.ts +15 -0
- package/lib/utils/createCollectionContextMeta.js +1 -0
- package/lib/utils/index.d.ts +2 -0
- package/lib/utils/index.js +10 -0
- package/lib/utils/params-resolvers.js +16 -9
- package/lib/utils/resolveModuleUrl.d.ts +58 -0
- package/lib/utils/resolveModuleUrl.js +65 -0
- package/lib/utils/runjsModuleLoader.d.ts +58 -0
- package/lib/utils/runjsModuleLoader.js +422 -0
- package/lib/utils/runjsTemplateCompat.d.ts +35 -0
- package/lib/utils/runjsTemplateCompat.js +743 -0
- package/lib/utils/safeGlobals.d.ts +5 -9
- package/lib/utils/safeGlobals.js +129 -17
- package/lib/views/createViewMeta.d.ts +0 -7
- package/lib/views/createViewMeta.js +19 -70
- package/lib/views/index.d.ts +1 -2
- package/lib/views/index.js +4 -3
- package/lib/views/useDialog.js +8 -3
- package/lib/views/useDrawer.js +7 -2
- package/lib/views/usePage.d.ts +4 -0
- package/lib/views/usePage.js +43 -6
- package/lib/views/usePopover.js +4 -1
- package/lib/views/viewEvents.d.ts +17 -0
- package/lib/views/viewEvents.js +90 -0
- package/package.json +4 -4
- package/src/BlockScopedFlowEngine.ts +2 -5
- package/src/JSRunner.ts +8 -1
- package/src/ViewScopedFlowEngine.ts +4 -0
- package/src/__tests__/createViewMeta.popup.test.ts +62 -1
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +63 -0
- package/src/__tests__/flowSettings.open.test.tsx +69 -15
- package/src/__tests__/provider.test.tsx +0 -5
- package/src/__tests__/runjsExternalLibs.test.ts +242 -0
- package/src/__tests__/runjsLibsLazyLoading.test.ts +44 -0
- package/src/__tests__/runjsPreprocessDefault.test.ts +49 -0
- package/src/acl/Acl.tsx +3 -3
- package/src/components/__tests__/gridDragPlanner.test.ts +141 -1
- package/src/components/dnd/gridDragPlanner.ts +60 -0
- package/src/components/settings/wrappers/component/SwitchWithTitle.tsx +2 -1
- package/src/components/settings/wrappers/component/__tests__/InlineControls.test.tsx +74 -0
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +11 -3
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +63 -4
- package/src/components/variables/VariableInput.tsx +8 -2
- package/src/data-source/index.ts +6 -0
- package/src/executor/FlowExecutor.ts +193 -23
- package/src/executor/__tests__/flowExecutor.test.ts +66 -0
- package/src/flowContext.ts +234 -118
- package/src/flowEngine.ts +41 -0
- package/src/flowSettings.ts +12 -11
- package/src/index.ts +10 -0
- package/src/models/CollectionFieldModel.tsx +3 -1
- package/src/models/__tests__/dispatchEvent.when.test.ts +356 -0
- package/src/models/__tests__/flowModel.clone.test.ts +416 -0
- package/src/models/__tests__/flowModel.test.ts +16 -0
- package/src/models/flowModel.tsx +94 -1
- package/src/provider.tsx +9 -7
- package/src/resources/__tests__/multiRecordResource.test.ts +44 -0
- package/src/resources/__tests__/sqlResource.test.ts +60 -0
- package/src/resources/baseRecordResource.ts +31 -0
- package/src/resources/multiRecordResource.ts +11 -4
- package/src/resources/singleRecordResource.ts +3 -0
- package/src/resources/sqlResource.ts +8 -3
- package/src/runjs-context/contexts/base.ts +9 -2
- package/src/runjsLibs.ts +622 -0
- package/src/scheduler/ModelOperationScheduler.ts +23 -21
- package/src/types.ts +26 -1
- package/src/utils/__tests__/params-resolvers.test.ts +40 -0
- package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +38 -0
- package/src/utils/__tests__/runjsTemplateCompat.test.ts +159 -0
- package/src/utils/__tests__/safeGlobals.test.ts +49 -2
- package/src/utils/createCollectionContextMeta.ts +1 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/params-resolvers.ts +23 -9
- package/src/utils/resolveModuleUrl.ts +91 -0
- package/src/utils/runjsModuleLoader.ts +553 -0
- package/src/utils/runjsTemplateCompat.ts +828 -0
- package/src/utils/safeGlobals.ts +133 -16
- package/src/views/__tests__/FlowView.usePage.test.tsx +54 -1
- package/src/views/__tests__/useDialog.closeDestroy.test.tsx +35 -8
- package/src/views/__tests__/viewEvents.resolveOpenerEngine.test.ts +28 -0
- package/src/views/createViewMeta.ts +22 -75
- package/src/views/index.tsx +1 -2
- package/src/views/useDialog.tsx +9 -2
- package/src/views/useDrawer.tsx +8 -1
- package/src/views/usePage.tsx +51 -5
- package/src/views/usePopover.tsx +4 -1
- package/src/views/viewEvents.ts +55 -0
|
@@ -36,6 +36,8 @@ export interface LifecycleEvent {
|
|
|
36
36
|
error?: any;
|
|
37
37
|
inputArgs?: Record<string, any>;
|
|
38
38
|
result?: any;
|
|
39
|
+
flowKey?: string;
|
|
40
|
+
stepKey?: string;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
type ScheduledItem = {
|
|
@@ -162,37 +164,37 @@ export class ModelOperationScheduler {
|
|
|
162
164
|
const emitter = this.engine.emitter;
|
|
163
165
|
if (!emitter || typeof emitter.on !== 'function') return;
|
|
164
166
|
|
|
165
|
-
const onCreated = (e: LifecycleEvent) => {
|
|
166
|
-
this.processLifecycleEvent(e.uid, { ...e, type: 'created' });
|
|
167
|
+
const onCreated = async (e: LifecycleEvent) => {
|
|
168
|
+
await this.processLifecycleEvent(e.uid, { ...e, type: 'created' });
|
|
167
169
|
};
|
|
168
170
|
emitter.on('model:created', onCreated);
|
|
169
171
|
this.unbindHandlers.push(() => emitter.off('model:created', onCreated));
|
|
170
172
|
|
|
171
|
-
const onMounted = (e: LifecycleEvent) => {
|
|
172
|
-
this.processLifecycleEvent(e.uid, { ...e, type: 'mounted' });
|
|
173
|
+
const onMounted = async (e: LifecycleEvent) => {
|
|
174
|
+
await this.processLifecycleEvent(e.uid, { ...e, type: 'mounted' });
|
|
173
175
|
};
|
|
174
176
|
emitter.on('model:mounted', onMounted);
|
|
175
177
|
this.unbindHandlers.push(() => emitter.off('model:mounted', onMounted));
|
|
176
178
|
|
|
177
|
-
const onGenericBeforeStart = (e: LifecycleEvent) => {
|
|
178
|
-
this.processLifecycleEvent(e.uid, { ...e, type: 'event:beforeRender:start' });
|
|
179
|
+
const onGenericBeforeStart = async (e: LifecycleEvent) => {
|
|
180
|
+
await this.processLifecycleEvent(e.uid, { ...e, type: 'event:beforeRender:start' });
|
|
179
181
|
};
|
|
180
182
|
emitter.on('model:event:beforeRender:start', onGenericBeforeStart);
|
|
181
183
|
this.unbindHandlers.push(() => emitter.off('model:event:beforeRender:start', onGenericBeforeStart));
|
|
182
184
|
|
|
183
|
-
const onGenericBeforeEnd = (e: LifecycleEvent) => {
|
|
184
|
-
this.processLifecycleEvent(e.uid, { ...e, type: 'event:beforeRender:end' });
|
|
185
|
+
const onGenericBeforeEnd = async (e: LifecycleEvent) => {
|
|
186
|
+
await this.processLifecycleEvent(e.uid, { ...e, type: 'event:beforeRender:end' });
|
|
185
187
|
};
|
|
186
188
|
emitter.on('model:event:beforeRender:end', onGenericBeforeEnd);
|
|
187
189
|
this.unbindHandlers.push(() => emitter.off('model:event:beforeRender:end', onGenericBeforeEnd));
|
|
188
190
|
|
|
189
|
-
const onUnmounted = (e: LifecycleEvent) => {
|
|
190
|
-
this.processLifecycleEvent(e.uid, { ...e, type: 'unmounted' });
|
|
191
|
+
const onUnmounted = async (e: LifecycleEvent) => {
|
|
192
|
+
await this.processLifecycleEvent(e.uid, { ...e, type: 'unmounted' });
|
|
191
193
|
};
|
|
192
194
|
emitter.on('model:unmounted', onUnmounted);
|
|
193
195
|
this.unbindHandlers.push(() => emitter.off('model:unmounted', onUnmounted));
|
|
194
196
|
|
|
195
|
-
const onDestroyed = (e: LifecycleEvent) => {
|
|
197
|
+
const onDestroyed = async (e: LifecycleEvent) => {
|
|
196
198
|
const targetBucket = this.itemsByTargetUid.get(e.uid);
|
|
197
199
|
const event = { ...e, type: 'destroyed' as const };
|
|
198
200
|
if (targetBucket && targetBucket.size) {
|
|
@@ -201,7 +203,7 @@ export class ModelOperationScheduler {
|
|
|
201
203
|
const it = this.itemsById.get(id);
|
|
202
204
|
if (!it) continue;
|
|
203
205
|
if (this.shouldTrigger(it.options.when, event)) {
|
|
204
|
-
|
|
206
|
+
await this.tryExecuteOnce(id, event);
|
|
205
207
|
} else {
|
|
206
208
|
this.internalCancel(id);
|
|
207
209
|
}
|
|
@@ -220,14 +222,14 @@ export class ModelOperationScheduler {
|
|
|
220
222
|
if (this.subscribedEventNames.has(name)) return;
|
|
221
223
|
this.subscribedEventNames.add(name);
|
|
222
224
|
const emitter = this.engine.emitter;
|
|
223
|
-
const onStart = (e: LifecycleEvent) => {
|
|
224
|
-
this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:start` as
|
|
225
|
+
const onStart = async (e: LifecycleEvent) => {
|
|
226
|
+
await this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:start` as LifecycleType });
|
|
225
227
|
};
|
|
226
|
-
const onEnd = (e: LifecycleEvent) => {
|
|
227
|
-
this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:end` as
|
|
228
|
+
const onEnd = async (e: LifecycleEvent) => {
|
|
229
|
+
await this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:end` as LifecycleType });
|
|
228
230
|
};
|
|
229
|
-
const onError = (e: LifecycleEvent) => {
|
|
230
|
-
this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:error` as
|
|
231
|
+
const onError = async (e: LifecycleEvent) => {
|
|
232
|
+
await this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:error` as LifecycleType });
|
|
231
233
|
};
|
|
232
234
|
emitter.on(`model:event:${name}:start`, onStart);
|
|
233
235
|
emitter.on(`model:event:${name}:end`, onEnd);
|
|
@@ -239,12 +241,12 @@ export class ModelOperationScheduler {
|
|
|
239
241
|
|
|
240
242
|
private parseEventWhen(when?: ScheduleWhen): { name: string; phase: 'start' | 'end' | 'error' } | null {
|
|
241
243
|
if (!when || typeof when !== 'string') return null;
|
|
242
|
-
const m = /^event:(
|
|
244
|
+
const m = /^event:(.+):(start|end|error)$/.exec(when);
|
|
243
245
|
if (!m) return null;
|
|
244
246
|
return { name: m[1], phase: m[2] as 'start' | 'end' | 'error' };
|
|
245
247
|
}
|
|
246
248
|
|
|
247
|
-
private processLifecycleEvent(targetUid: string, event: LifecycleEvent) {
|
|
249
|
+
private async processLifecycleEvent(targetUid: string, event: LifecycleEvent) {
|
|
248
250
|
const targetBucket = this.itemsByTargetUid.get(targetUid);
|
|
249
251
|
if (!targetBucket || targetBucket.size === 0) return;
|
|
250
252
|
const ids = Array.from(targetBucket.keys());
|
|
@@ -253,7 +255,7 @@ export class ModelOperationScheduler {
|
|
|
253
255
|
if (!item) continue;
|
|
254
256
|
const should = this.shouldTrigger(item.options.when, event);
|
|
255
257
|
if (!should) continue;
|
|
256
|
-
|
|
258
|
+
await this.tryExecuteOnce(id, event);
|
|
257
259
|
}
|
|
258
260
|
}
|
|
259
261
|
|
package/src/types.ts
CHANGED
|
@@ -213,12 +213,37 @@ export type FlowEventName =
|
|
|
213
213
|
// fallback to any string for extensibility
|
|
214
214
|
| (string & {});
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* 事件流的执行时机(phase)。
|
|
218
|
+
*
|
|
219
|
+
* 说明:
|
|
220
|
+
* - 缺省(phase 未配置)表示保持现有行为;
|
|
221
|
+
* - 当配置了 phase 时,运行时会将其映射为 `scheduleModelOperation` 的 `when` 锚点;
|
|
222
|
+
* - phase 同时适用于动态事件流(实例级)与静态流(内置)。
|
|
223
|
+
*/
|
|
224
|
+
export type FlowEventPhase =
|
|
225
|
+
| 'beforeAllFlows'
|
|
226
|
+
| 'afterAllFlows'
|
|
227
|
+
| 'beforeFlow'
|
|
228
|
+
| 'afterFlow'
|
|
229
|
+
| 'beforeStep'
|
|
230
|
+
| 'afterStep';
|
|
231
|
+
|
|
216
232
|
/**
|
|
217
233
|
* Flow 事件类型(供 FlowDefinitionOptions.on 使用)。
|
|
218
234
|
*/
|
|
219
235
|
export type FlowEvent<TModel extends FlowModel = FlowModel> =
|
|
220
236
|
| FlowEventName
|
|
221
|
-
| {
|
|
237
|
+
| {
|
|
238
|
+
eventName: FlowEventName;
|
|
239
|
+
defaultParams?: Record<string, any>;
|
|
240
|
+
/** 动态事件流的执行时机(默认 beforeAllFlows) */
|
|
241
|
+
phase?: FlowEventPhase;
|
|
242
|
+
/** phase 为 beforeFlow/afterFlow/beforeStep/afterStep 时使用 */
|
|
243
|
+
flowKey?: string;
|
|
244
|
+
/** phase 为 beforeStep/afterStep 时使用 */
|
|
245
|
+
stepKey?: string;
|
|
246
|
+
};
|
|
222
247
|
|
|
223
248
|
/**
|
|
224
249
|
* 事件分发选项。
|
|
@@ -491,6 +491,46 @@ describe('resolveExpressions', () => {
|
|
|
491
491
|
],
|
|
492
492
|
});
|
|
493
493
|
});
|
|
494
|
+
|
|
495
|
+
test('should resolve dot-only path with dashed keys', async () => {
|
|
496
|
+
ctx.defineProperty('formValues', {
|
|
497
|
+
value: {
|
|
498
|
+
'oho-test': {
|
|
499
|
+
'o2m-users': [1, 2],
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const params = '{{ctx.formValues.oho-test.o2m-users}}';
|
|
505
|
+
|
|
506
|
+
const result = await resolveExpressions(params, ctx);
|
|
507
|
+
|
|
508
|
+
expect(result).toEqual([1, 2]);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test('should resolve dashed keys inside template strings', async () => {
|
|
512
|
+
ctx.defineProperty('formValues', {
|
|
513
|
+
value: {
|
|
514
|
+
'oho-test': {
|
|
515
|
+
'o2m-users': 'X',
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const params = 'prefix {{ctx.formValues.oho-test.o2m-users}} suffix';
|
|
521
|
+
|
|
522
|
+
const result = await resolveExpressions(params, ctx);
|
|
523
|
+
|
|
524
|
+
expect(result).toEqual('prefix X suffix');
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test('should not treat subtraction as dashed key path', async () => {
|
|
528
|
+
const params = '{{ctx.aa.bb-ctx.cc}}';
|
|
529
|
+
|
|
530
|
+
const result = await resolveExpressions(params, ctx);
|
|
531
|
+
|
|
532
|
+
expect(result).toEqual(5);
|
|
533
|
+
});
|
|
494
534
|
});
|
|
495
535
|
|
|
496
536
|
// 测试高级功能:多表达式模板字符串
|
|
@@ -0,0 +1,38 @@
|
|
|
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 { beforeEach, describe, expect, it } from 'vitest';
|
|
11
|
+
import { runjsRequireAsync } from '../runjsModuleLoader';
|
|
12
|
+
import { __resetRunJSSafeGlobalsRegistryForTests, createSafeWindow } from '../safeGlobals';
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
__resetRunJSSafeGlobalsRegistryForTests();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('runjsRequireAsync auto whitelist', () => {
|
|
19
|
+
it('should allow safeWindow to access globals introduced during requireAsync', async () => {
|
|
20
|
+
const key = '__nb_require_async_added_global__';
|
|
21
|
+
delete (window as any)[key];
|
|
22
|
+
|
|
23
|
+
const safeWin: any = createSafeWindow();
|
|
24
|
+
expect(() => safeWin[key]).toThrow(/not allowed/);
|
|
25
|
+
|
|
26
|
+
const requirejs: any = (deps: string[], onLoad: (...args: any[]) => void) => {
|
|
27
|
+
// Simulate a remote library attaching itself to the real window.
|
|
28
|
+
(window as any)[key] = { ok: true };
|
|
29
|
+
onLoad(undefined);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
await runjsRequireAsync(requirejs, 'https://example.com/fake-lib.js');
|
|
33
|
+
|
|
34
|
+
expect(safeWin[key]).toEqual({ ok: true });
|
|
35
|
+
|
|
36
|
+
delete (window as any)[key];
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
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 { describe, it, expect, vi } from 'vitest';
|
|
11
|
+
import * as jsxTransform from '../../utils/jsxTransform';
|
|
12
|
+
import { prepareRunJsCode, preprocessRunJsTemplates } from '../runjsTemplateCompat';
|
|
13
|
+
|
|
14
|
+
describe('runjsTemplateCompat', () => {
|
|
15
|
+
describe('preprocessRunJsTemplates', () => {
|
|
16
|
+
it('hoists bare {{ }} placeholders into top-level resolved vars', () => {
|
|
17
|
+
const src = `const a = {{ctx.user.id}};`;
|
|
18
|
+
const out = preprocessRunJsTemplates(src);
|
|
19
|
+
expect(out).toContain(`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.id}}");`);
|
|
20
|
+
expect(out).toContain(`const a = __runjs_ctx_tpl_0;`);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('replaces string literals containing {{ }} via split/join, without injecting await into nested functions', () => {
|
|
24
|
+
const src = `const s = '{{ctx.user.id}}';`;
|
|
25
|
+
const out = preprocessRunJsTemplates(src);
|
|
26
|
+
expect(out).toContain(`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.id}}");`);
|
|
27
|
+
expect(out).toContain(`__runjs_templateValueToString(__runjs_ctx_tpl_0, "{{ctx.user.id}}")`);
|
|
28
|
+
expect(out).toContain(`.split("{{ctx.user.id}}").join(`);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('does not transform arguments inside explicit ctx.resolveJsonTemplate(...) call', () => {
|
|
32
|
+
const src = `const v = await ctx.resolveJsonTemplate('{{ctx.user.id}}');\nconst s = '{{ctx.user.id}}';`;
|
|
33
|
+
const out = preprocessRunJsTemplates(src);
|
|
34
|
+
// inside call: keep raw
|
|
35
|
+
expect(out).toContain(`await ctx.resolveJsonTemplate('{{ctx.user.id}}')`);
|
|
36
|
+
// outside call: transform
|
|
37
|
+
expect(out).toContain(`__runjs_templateValueToString(__runjs_ctx_tpl_0, "{{ctx.user.id}}")`);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps template markers in comments unchanged', () => {
|
|
41
|
+
const src = `// {{ctx.user.id}}\nconst a = 1;`;
|
|
42
|
+
const out = preprocessRunJsTemplates(src);
|
|
43
|
+
expect(out).toBe(src);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('supports template literals containing {{ }}', () => {
|
|
47
|
+
const src = 'const s = `hi {{ctx.user.name}}`;';
|
|
48
|
+
const out = preprocessRunJsTemplates(src);
|
|
49
|
+
expect(out).toContain(`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.name}}");`);
|
|
50
|
+
expect(out).toContain(`\`hi {{ctx.user.name}}\``);
|
|
51
|
+
expect(out).toContain(`.split("{{ctx.user.name}}").join(`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not rewrite non-ctx {{ }} patterns (e.g. JSX style object) while still rewriting ctx placeholders', () => {
|
|
55
|
+
const src = `const id = {{ctx.user.id}};\nctx.render(<div style={{ width: '100%' }} />);`;
|
|
56
|
+
const out = preprocessRunJsTemplates(src);
|
|
57
|
+
expect(out).toContain(`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.id}}");`);
|
|
58
|
+
expect(out).toContain(`style={{ width: '100%' }}`);
|
|
59
|
+
expect(out).not.toContain(`resolveJsonTemplate("{{ width: '100%' }}")`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('avoids injecting await into non-async nested function bodies', () => {
|
|
63
|
+
const src = `
|
|
64
|
+
function f() {
|
|
65
|
+
return {{ctx.user.id}};
|
|
66
|
+
}
|
|
67
|
+
return f();
|
|
68
|
+
`.trim();
|
|
69
|
+
const out = preprocessRunJsTemplates(src);
|
|
70
|
+
// The only await should be in the top-level preamble
|
|
71
|
+
expect(out).toContain(`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.id}}");`);
|
|
72
|
+
expect(out).toContain(`return __runjs_ctx_tpl_0;`);
|
|
73
|
+
expect(out).not.toContain(`return (await ctx.resolveJsonTemplate`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('is tolerant to already-preprocessed code (idempotent heuristic)', () => {
|
|
77
|
+
const src =
|
|
78
|
+
`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.id}}");\n` +
|
|
79
|
+
`const s = ("{{ctx.user.id}}").split("{{ctx.user.id}}").join(__runjs_templateValueToString(__runjs_ctx_tpl_0, "{{ctx.user.id}}"));`;
|
|
80
|
+
const out = preprocessRunJsTemplates(src);
|
|
81
|
+
expect(out).toBe(src);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rewrites object literal string keys via computed property to keep syntax valid', () => {
|
|
85
|
+
const src = `const o = { '{{ctx.user.id}}': 1 };`;
|
|
86
|
+
const out = preprocessRunJsTemplates(src);
|
|
87
|
+
expect(out).toContain(`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.id}}");`);
|
|
88
|
+
expect(out).toContain(`{ [`);
|
|
89
|
+
expect(out).toContain(`]: 1 }`);
|
|
90
|
+
expect(out).toContain(`.split("{{ctx.user.id}}").join(`);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('prepareRunJsCode', () => {
|
|
95
|
+
it('preprocesses templates and compiles JSX', async () => {
|
|
96
|
+
const src = `
|
|
97
|
+
const name = '{{ctx.user.name}}';
|
|
98
|
+
ctx.render(<div className="x">{name}</div>);
|
|
99
|
+
`.trim();
|
|
100
|
+
const out = await prepareRunJsCode(src, { preprocessTemplates: true });
|
|
101
|
+
expect(out).toMatch(/ctx\.React\.createElement/);
|
|
102
|
+
expect(out).toMatch(/ctx\.resolveJsonTemplate/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('injects ctx.libs ensure preamble for member access', async () => {
|
|
106
|
+
const src = `return ctx.libs.lodash;`;
|
|
107
|
+
const out = await prepareRunJsCode(src, { preprocessTemplates: false });
|
|
108
|
+
expect(out).toContain(`/* __runjs_ensure_libs */`);
|
|
109
|
+
expect(out).toContain(`await ctx.__ensureLibs(["lodash"]);`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('injects ctx.libs ensure preamble for bracket access with string literal', async () => {
|
|
113
|
+
const src = `return ctx.libs['lodash'];`;
|
|
114
|
+
const out = await prepareRunJsCode(src, { preprocessTemplates: false });
|
|
115
|
+
expect(out).toContain(`await ctx.__ensureLibs(["lodash"]);`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('injects ctx.libs ensure preamble for object destructuring', async () => {
|
|
119
|
+
const src = `const { lodash } = ctx.libs;\nreturn lodash;`;
|
|
120
|
+
const out = await prepareRunJsCode(src, { preprocessTemplates: false });
|
|
121
|
+
expect(out).toContain(`await ctx.__ensureLibs(["lodash"]);`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('does not inject ctx.libs preamble when ctx.libs only appears in string/comment', async () => {
|
|
125
|
+
const src = `// ctx.libs.lodash\nconst s = "ctx.libs.lodash";\nreturn s;`;
|
|
126
|
+
const out = await prepareRunJsCode(src, { preprocessTemplates: false });
|
|
127
|
+
expect(out).not.toContain(`__runjs_ensure_libs`);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('is idempotent for already-prepared code', async () => {
|
|
131
|
+
const src = `return ctx.libs['lodash'];`;
|
|
132
|
+
const out1 = await prepareRunJsCode(src, { preprocessTemplates: false });
|
|
133
|
+
const out2 = await prepareRunJsCode(out1, { preprocessTemplates: false });
|
|
134
|
+
expect(out2).toBe(out1);
|
|
135
|
+
expect(out2.match(/__runjs_ensure_libs/g)?.length ?? 0).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does not break JSX attribute string values when preprocessing templates', async () => {
|
|
139
|
+
const src = `ctx.render(<Input title="{{ctx.user.name}}" />);`;
|
|
140
|
+
const out = await prepareRunJsCode(src, { preprocessTemplates: true });
|
|
141
|
+
expect(out).toMatch(/ctx\.React\.createElement/);
|
|
142
|
+
expect(out).toMatch(/title:\s*\(/);
|
|
143
|
+
expect(out).toContain(`.split("{{ctx.user.name}}").join(`);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('caches prepared code by source and preprocessTemplates option', async () => {
|
|
147
|
+
const spy = vi.spyOn(jsxTransform, 'compileRunJs');
|
|
148
|
+
const src = `/* cache-test */\nconst a = 1;\nreturn a;`;
|
|
149
|
+
|
|
150
|
+
await prepareRunJsCode(src, { preprocessTemplates: false });
|
|
151
|
+
await prepareRunJsCode(src, { preprocessTemplates: false });
|
|
152
|
+
await prepareRunJsCode(src, { preprocessTemplates: true });
|
|
153
|
+
await prepareRunJsCode(src, { preprocessTemplates: true });
|
|
154
|
+
|
|
155
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
156
|
+
spy.mockRestore();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -7,8 +7,19 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { describe, expect, it } from 'vitest';
|
|
11
|
-
import {
|
|
10
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
11
|
+
import {
|
|
12
|
+
__resetRunJSSafeGlobalsRegistryForTests,
|
|
13
|
+
createSafeDocument,
|
|
14
|
+
createSafeNavigator,
|
|
15
|
+
createSafeWindow,
|
|
16
|
+
registerRunJSSafeDocumentGlobals,
|
|
17
|
+
registerRunJSSafeWindowGlobals,
|
|
18
|
+
} from '../safeGlobals';
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
__resetRunJSSafeGlobalsRegistryForTests();
|
|
22
|
+
});
|
|
12
23
|
|
|
13
24
|
describe('safeGlobals', () => {
|
|
14
25
|
it('createSafeWindow exposes only allowed globals and extras', () => {
|
|
@@ -21,6 +32,24 @@ describe('safeGlobals', () => {
|
|
|
21
32
|
expect(() => win.location.href).toThrow(/not allowed/);
|
|
22
33
|
});
|
|
23
34
|
|
|
35
|
+
it('createSafeWindow allows writing new props and reading them back', () => {
|
|
36
|
+
const win: any = createSafeWindow();
|
|
37
|
+
win.someLib = { ok: true };
|
|
38
|
+
expect(win.someLib).toEqual({ ok: true });
|
|
39
|
+
expect('someLib' in win).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('createSafeWindow can access dynamically registered globals from real window', () => {
|
|
43
|
+
const key = '__nb_safe_window_global__';
|
|
44
|
+
(window as any)[key] = { v: 1 };
|
|
45
|
+
registerRunJSSafeWindowGlobals([key]);
|
|
46
|
+
|
|
47
|
+
const win: any = createSafeWindow();
|
|
48
|
+
expect(win[key]).toEqual({ v: 1 });
|
|
49
|
+
|
|
50
|
+
delete (window as any)[key];
|
|
51
|
+
});
|
|
52
|
+
|
|
24
53
|
it('createSafeDocument exposes whitelisted methods and extras', () => {
|
|
25
54
|
const doc: any = createSafeDocument({ bar: true });
|
|
26
55
|
expect(typeof doc.createElement).toBe('function');
|
|
@@ -28,6 +57,24 @@ describe('safeGlobals', () => {
|
|
|
28
57
|
expect(() => doc.cookie).toThrow(/not allowed/);
|
|
29
58
|
});
|
|
30
59
|
|
|
60
|
+
it('createSafeDocument allows writing new props and reading them back', () => {
|
|
61
|
+
const doc: any = createSafeDocument();
|
|
62
|
+
doc.someLib = { ok: true };
|
|
63
|
+
expect(doc.someLib).toEqual({ ok: true });
|
|
64
|
+
expect('someLib' in doc).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('createSafeDocument can access dynamically registered globals from real document', () => {
|
|
68
|
+
const key = '__nb_safe_document_global__';
|
|
69
|
+
(document as any)[key] = 123;
|
|
70
|
+
registerRunJSSafeDocumentGlobals([key]);
|
|
71
|
+
|
|
72
|
+
const doc: any = createSafeDocument();
|
|
73
|
+
expect(doc[key]).toBe(123);
|
|
74
|
+
|
|
75
|
+
delete (document as any)[key];
|
|
76
|
+
});
|
|
77
|
+
|
|
31
78
|
it('createSafeNavigator exposes limited props and guards others', () => {
|
|
32
79
|
const nav: any = createSafeNavigator();
|
|
33
80
|
// clipboard object should always exist
|
package/src/utils/index.ts
CHANGED
|
@@ -63,9 +63,15 @@ export { parsePathnameToViewParams, type ViewParam } from './parsePathnameToView
|
|
|
63
63
|
// 安全全局对象(window/document)
|
|
64
64
|
export { createSafeDocument, createSafeWindow, createSafeNavigator } from './safeGlobals';
|
|
65
65
|
|
|
66
|
+
// RunJS 代码兼容预处理({{ }})与 JSX 编译
|
|
67
|
+
export { prepareRunJsCode, preprocessRunJsTemplates } from './runjsTemplateCompat';
|
|
68
|
+
|
|
66
69
|
// Ephemeral context helper(用于临时注入属性/方法,避免污染父级 ctx)
|
|
67
70
|
export { createEphemeralContext } from './createEphemeralContext';
|
|
68
71
|
|
|
69
72
|
// Filter helpers
|
|
70
73
|
export { pruneFilter } from './pruneFilter';
|
|
71
74
|
export { isBeforeRenderFlow } from './flows';
|
|
75
|
+
|
|
76
|
+
// Module URL resolver
|
|
77
|
+
export { resolveModuleUrl, isCssFile } from './resolveModuleUrl';
|
|
@@ -409,8 +409,13 @@ export async function preprocessExpression(expression: string, ctx: FlowContext)
|
|
|
409
409
|
async function compileExpression<TModel extends FlowModel = FlowModel>(expression: string, ctx: FlowContext) {
|
|
410
410
|
// 仅点号路径匹配:ctx.a.b.c(不支持括号/函数/索引),用于数组聚合取值
|
|
411
411
|
const matchDotOnly = (expr: string): string | null => {
|
|
412
|
-
|
|
413
|
-
|
|
412
|
+
// 顶层变量名仍使用 JS 标识符规则(与 ctx.defineProperty 保持一致);
|
|
413
|
+
// 子路径允许包含 '-'(例如 formValues.oho-test.o2m-users)。
|
|
414
|
+
const m = expr
|
|
415
|
+
.trim()
|
|
416
|
+
.match(/^ctx\.([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\.([a-zA-Z_$][a-zA-Z0-9_$-]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$-]*)*))?$/);
|
|
417
|
+
if (!m) return null;
|
|
418
|
+
return m[2] ? `${m[1]}.${m[2]}` : m[1];
|
|
414
419
|
};
|
|
415
420
|
|
|
416
421
|
// 基于 getValuesByPath 的聚合取值:支持数组扁平化,仅支持 '.' 访问
|
|
@@ -423,6 +428,20 @@ async function compileExpression<TModel extends FlowModel = FlowModel>(expressio
|
|
|
423
428
|
return getValuesByPath(base as object, segs.join('.'));
|
|
424
429
|
};
|
|
425
430
|
|
|
431
|
+
const resolveInnerExpression = async (innerExpr: string): Promise<any> => {
|
|
432
|
+
const dotPath = matchDotOnly(innerExpr);
|
|
433
|
+
if (dotPath) {
|
|
434
|
+
const resolved = await resolveDotOnlyPath(dotPath);
|
|
435
|
+
// 当 dotPath 含 '-' 时可能与减号运算符存在歧义,例如:ctx.aa.bb-ctx.cc。
|
|
436
|
+
// 若按 path 解析未取到值,则回退到 JS 表达式解析,尽量保持兼容。
|
|
437
|
+
if (resolved === undefined && dotPath.includes('-')) {
|
|
438
|
+
return await processExpression(innerExpr, ctx);
|
|
439
|
+
}
|
|
440
|
+
return resolved;
|
|
441
|
+
}
|
|
442
|
+
return await processExpression(innerExpr, ctx);
|
|
443
|
+
};
|
|
444
|
+
|
|
426
445
|
/**
|
|
427
446
|
* 单个表达式模式匹配
|
|
428
447
|
*
|
|
@@ -443,11 +462,7 @@ async function compileExpression<TModel extends FlowModel = FlowModel>(expressio
|
|
|
443
462
|
const singleMatch = expression.match(/^\s*\{\{\s*([^{}]+?)\s*\}\}\s*$/);
|
|
444
463
|
if (singleMatch) {
|
|
445
464
|
const inner = singleMatch[1];
|
|
446
|
-
|
|
447
|
-
if (dotPath) {
|
|
448
|
-
return await resolveDotOnlyPath(dotPath);
|
|
449
|
-
}
|
|
450
|
-
return await processExpression(inner, ctx);
|
|
465
|
+
return await resolveInnerExpression(inner);
|
|
451
466
|
}
|
|
452
467
|
|
|
453
468
|
/**
|
|
@@ -470,8 +485,7 @@ async function compileExpression<TModel extends FlowModel = FlowModel>(expressio
|
|
|
470
485
|
let result = expression;
|
|
471
486
|
|
|
472
487
|
for (const [fullMatch, innerExpr] of matches) {
|
|
473
|
-
const
|
|
474
|
-
const value = dotPath ? await resolveDotOnlyPath(dotPath) : await processExpression(innerExpr, ctx);
|
|
488
|
+
const value = await resolveInnerExpression(innerExpr);
|
|
475
489
|
if (value !== undefined) {
|
|
476
490
|
const replacement = typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
|
|
477
491
|
result = result.replace(fullMatch, replacement);
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* 解析模块 URL,将相对路径转换为完整的 CDN URL
|
|
12
|
+
*
|
|
13
|
+
* @param url - 模块地址(支持相对路径或完整 URL)
|
|
14
|
+
* @param options - 可选配置
|
|
15
|
+
* @param options.addSuffix - 是否添加 ESM_CDN_SUFFIX 后缀(如 `+esm`),默认为 `true`
|
|
16
|
+
* @returns 解析后的完整 URL
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* // 相对路径会被拼接上 CDN 前缀和后缀(默认添加)
|
|
21
|
+
* // 如果使用 esm.sh(默认),不需要后缀
|
|
22
|
+
* resolveModuleUrl('vue@3.4.0')
|
|
23
|
+
* // => 'https://esm.sh/vue@3.4.0'
|
|
24
|
+
*
|
|
25
|
+
* // 如果使用 jsdelivr,需要配置 ESM_CDN_SUFFIX='/+esm'
|
|
26
|
+
* // resolveModuleUrl('vue@3.4.0') => 'https://cdn.jsdelivr.net/npm/vue@3.4.0/+esm'
|
|
27
|
+
*
|
|
28
|
+
* // 不添加后缀(适用于 UMD 库或 CSS 文件)
|
|
29
|
+
* resolveModuleUrl('vue@3.4.0', { addSuffix: false })
|
|
30
|
+
* // => 'https://esm.sh/vue@3.4.0' (即使配置了 suffix 也不会添加)
|
|
31
|
+
*
|
|
32
|
+
* // 原始 URL(适用于 UMD 库)
|
|
33
|
+
* resolveModuleUrl('lodash@4.17.21/lodash.js', { raw: true })
|
|
34
|
+
* // => 'https://esm.sh/lodash@4.17.21/lodash.js?raw' (即使配置了 suffix 也不会添加)
|
|
35
|
+
*
|
|
36
|
+
* // 完整 URL 保持不变
|
|
37
|
+
* resolveModuleUrl('https://cdn.jsdelivr.net/npm/vue@3.4.0')
|
|
38
|
+
* // => 'https://cdn.jsdelivr.net/npm/vue@3.4.0'
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function resolveModuleUrl(url: string, options?: { addSuffix?: boolean; raw?: boolean }): string {
|
|
42
|
+
if (!url || typeof url !== 'string') {
|
|
43
|
+
throw new Error('invalid url');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const u = url.trim();
|
|
47
|
+
|
|
48
|
+
// 如果是完整 URL(http:// 或 https://),直接返回
|
|
49
|
+
if (u.startsWith('http://') || u.startsWith('https://')) {
|
|
50
|
+
return u;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 相对路径:拼接 CDN 前缀和后缀
|
|
54
|
+
const ESM_CDN_BASE_URL = (window as any)['__esm_cdn_base_url__'] || 'https://esm.sh';
|
|
55
|
+
const ESM_CDN_SUFFIX = options?.addSuffix ? (window as any)['__esm_cdn_suffix__'] || '' : '';
|
|
56
|
+
|
|
57
|
+
// 移除 base URL 末尾的斜杠,移除相对路径开头的斜杠
|
|
58
|
+
const base = ESM_CDN_BASE_URL.replace(/\/$/, '');
|
|
59
|
+
const path = u.replace(/^\//, '');
|
|
60
|
+
|
|
61
|
+
if (options?.raw) {
|
|
62
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
63
|
+
return `${base}/${path}${sep}raw`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return `${base}/${path}${ESM_CDN_SUFFIX}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 判断 URL 是否为 CSS 文件
|
|
71
|
+
*
|
|
72
|
+
* @param url - 文件 URL(支持带 query 和 hash,如 `example.css?v=123`)
|
|
73
|
+
* @returns 如果是 CSS 文件返回 `true`,否则返回 `false`
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* isCssFile('style.css') // => true
|
|
78
|
+
* isCssFile('style.css?v=123') // => true
|
|
79
|
+
* isCssFile('style.css#section') // => true
|
|
80
|
+
* isCssFile('script.js') // => false
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export function isCssFile(url: string): boolean {
|
|
84
|
+
if (!url || typeof url !== 'string') {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 去掉 query 和 hash 后判断文件扩展名
|
|
89
|
+
const pathPart = url.split('?')[0].split('#')[0];
|
|
90
|
+
return pathPart.endsWith('.css');
|
|
91
|
+
}
|