@nocobase/flow-engine 2.0.0-beta.21 → 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 CHANGED
@@ -35,6 +35,7 @@ const _JSRunner = class _JSRunner {
35
35
  globals;
36
36
  timeoutMs;
37
37
  constructor(options = {}) {
38
+ var _a, _b;
38
39
  const bindWindowFn = /* @__PURE__ */ __name((key) => {
39
40
  if (typeof window !== "undefined" && typeof window[key] === "function") {
40
41
  return window[key].bind(window);
@@ -42,13 +43,34 @@ const _JSRunner = class _JSRunner {
42
43
  const fn = globalThis[key];
43
44
  return typeof fn === "function" ? fn.bind(globalThis) : fn;
44
45
  }, "bindWindowFn");
46
+ const providedGlobals = options.globals || {};
47
+ const liftedGlobals = {};
48
+ if (!Object.prototype.hasOwnProperty.call(providedGlobals, "Blob")) {
49
+ try {
50
+ const blobCtor = (_a = providedGlobals.window) == null ? void 0 : _a.Blob;
51
+ if (typeof blobCtor !== "undefined") {
52
+ liftedGlobals.Blob = blobCtor;
53
+ }
54
+ } catch {
55
+ }
56
+ }
57
+ if (!Object.prototype.hasOwnProperty.call(providedGlobals, "URL")) {
58
+ try {
59
+ const urlCtor = (_b = providedGlobals.window) == null ? void 0 : _b.URL;
60
+ if (typeof urlCtor !== "undefined") {
61
+ liftedGlobals.URL = urlCtor;
62
+ }
63
+ } catch {
64
+ }
65
+ }
45
66
  this.globals = {
46
67
  console,
47
68
  setTimeout: bindWindowFn("setTimeout"),
48
69
  clearTimeout: bindWindowFn("clearTimeout"),
49
70
  setInterval: bindWindowFn("setInterval"),
50
71
  clearInterval: bindWindowFn("clearInterval"),
51
- ...options.globals || {}
72
+ ...liftedGlobals,
73
+ ...providedGlobals
52
74
  };
53
75
  this.timeoutMs = options.timeoutMs ?? 5e3;
54
76
  }
@@ -521,7 +521,7 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
521
521
  };
522
522
  items.push({
523
523
  key: uniqueKey,
524
- label: /* @__PURE__ */ import_react.default.createElement(MenuLabelItem, { title: t(stepInfo.title), uiMode, itemProps })
524
+ label: /* @__PURE__ */ import_react.default.createElement(MenuLabelItem, { title: stepInfo.title, uiMode, itemProps })
525
525
  });
526
526
  });
527
527
  if (flow.options.divider === "bottom") {
@@ -555,7 +555,7 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
555
555
  const uniqueKey = generateUniqueKey(`${flow.key}:${stepInfo.stepKey}`);
556
556
  items.push({
557
557
  key: uniqueKey,
558
- label: t(stepInfo.title)
558
+ label: stepInfo.title
559
559
  });
560
560
  });
561
561
  });
@@ -567,7 +567,7 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
567
567
  const uniqueKey = generateUniqueKey(`${modelKey}:${flow.key}:${stepInfo.stepKey}`);
568
568
  subMenuChildren.push({
569
569
  key: uniqueKey,
570
- label: t(stepInfo.title)
570
+ label: stepInfo.title
571
571
  });
572
572
  });
573
573
  });
@@ -830,7 +830,8 @@ const _FlowEngineContext = class _FlowEngineContext extends BaseFlowEngineContex
830
830
  value: this.engine
831
831
  });
832
832
  this.defineProperty("sql", {
833
- get: /* @__PURE__ */ __name(() => new import_resources.FlowSQLRepository(this), "get")
833
+ get: /* @__PURE__ */ __name((ctx) => new import_resources.FlowSQLRepository(ctx), "get"),
834
+ cache: false
834
835
  });
835
836
  this.defineProperty("dataSourceManager", {
836
837
  value: dataSourceManager
@@ -6,7 +6,7 @@
6
6
  * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
- import { FlowEngineContext } from '../flowContext';
9
+ import { FlowContext, FlowEngineContext } from '../flowContext';
10
10
  import { BaseRecordResource } from './baseRecordResource';
11
11
  type SQLRunOptions = {
12
12
  bind?: Record<string, any>;
@@ -20,8 +20,8 @@ type SQLSaveOptions = {
20
20
  dataSourceKey?: string;
21
21
  };
22
22
  export declare class FlowSQLRepository {
23
- protected ctx: FlowEngineContext;
24
- constructor(ctx: FlowEngineContext);
23
+ protected ctx: FlowContext;
24
+ constructor(ctx: FlowContext);
25
25
  run(sql: string, options?: SQLRunOptions): Promise<any>;
26
26
  save(data: SQLSaveOptions): Promise<void>;
27
27
  runById(uid: string, options?: SQLRunOptions): Promise<any>;
@@ -196,6 +196,8 @@ function createSafeWindow(extra) {
196
196
  Math,
197
197
  Date,
198
198
  FormData,
199
+ ...typeof Blob !== "undefined" ? { Blob } : {},
200
+ ...typeof URL !== "undefined" ? { URL } : {},
199
201
  // 事件侦听仅绑定到真实 window,便于少量需要的全局监听
200
202
  addEventListener: addEventListener.bind(window),
201
203
  // 安全的 window.open 代理
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.0.0-beta.21",
3
+ "version": "2.0.0-beta.22",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.0.0-beta.21",
12
- "@nocobase/shared": "2.0.0-beta.21",
11
+ "@nocobase/sdk": "2.0.0-beta.22",
12
+ "@nocobase/shared": "2.0.0-beta.22",
13
13
  "ahooks": "^3.7.2",
14
14
  "dayjs": "^1.11.9",
15
15
  "dompurify": "^3.0.2",
@@ -36,5 +36,5 @@
36
36
  ],
37
37
  "author": "NocoBase Team",
38
38
  "license": "AGPL-3.0",
39
- "gitHead": "3ea30685d9592934ec578c0b5e8def60a7fcc3c2"
39
+ "gitHead": "a49874a59e48eda08c1a1570466e8baff00b8a24"
40
40
  }
package/src/JSRunner.ts CHANGED
@@ -34,13 +34,41 @@ export class JSRunner {
34
34
  return typeof fn === 'function' ? fn.bind(globalThis) : fn;
35
35
  };
36
36
 
37
+ const providedGlobals = options.globals || {};
38
+ const liftedGlobals: Record<string, any> = {};
39
+
40
+ // Auto-lift selected globals from safe window into top-level sandbox globals
41
+ // so user code can access them directly (e.g. `new Blob(...)`).
42
+ if (!Object.prototype.hasOwnProperty.call(providedGlobals, 'Blob')) {
43
+ try {
44
+ const blobCtor = (providedGlobals as any).window?.Blob;
45
+ if (typeof blobCtor !== 'undefined') {
46
+ liftedGlobals.Blob = blobCtor;
47
+ }
48
+ } catch {
49
+ // ignore when window proxy blocks property access
50
+ }
51
+ }
52
+
53
+ if (!Object.prototype.hasOwnProperty.call(providedGlobals, 'URL')) {
54
+ try {
55
+ const urlCtor = (providedGlobals as any).window?.URL;
56
+ if (typeof urlCtor !== 'undefined') {
57
+ liftedGlobals.URL = urlCtor;
58
+ }
59
+ } catch {
60
+ // ignore when window proxy blocks property access
61
+ }
62
+ }
63
+
37
64
  this.globals = {
38
65
  console,
39
66
  setTimeout: bindWindowFn('setTimeout'),
40
67
  clearTimeout: bindWindowFn('clearTimeout'),
41
68
  setInterval: bindWindowFn('setInterval'),
42
69
  clearInterval: bindWindowFn('clearInterval'),
43
- ...(options.globals || {}),
70
+ ...liftedGlobals,
71
+ ...providedGlobals,
44
72
  };
45
73
  this.timeoutMs = options.timeoutMs ?? 5000; // 默认 5 秒超时
46
74
  }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
11
  import { JSRunner } from '../JSRunner';
12
+ import { createSafeWindow } from '../utils';
12
13
 
13
14
  describe('JSRunner', () => {
14
15
  let originalSearch: string;
@@ -48,6 +49,69 @@ describe('JSRunner', () => {
48
49
  expect(res2.value).toBe('baz');
49
50
  });
50
51
 
52
+ it('auto-lifts Blob from injected window to top-level globals', async () => {
53
+ if (typeof Blob === 'undefined') {
54
+ return;
55
+ }
56
+
57
+ const runner = new JSRunner({
58
+ globals: {
59
+ window: createSafeWindow(),
60
+ },
61
+ });
62
+
63
+ const result = await runner.run('return new Blob(["x"]).size');
64
+ expect(result.success).toBe(true);
65
+ expect(result.value).toBe(1);
66
+ });
67
+
68
+ it('keeps explicit globals.Blob higher priority than auto-lifted Blob', async () => {
69
+ const explicitBlob = function ExplicitBlob(this: any, chunks: any[]) {
70
+ this.size = Array.isArray(chunks) ? chunks.length : 0;
71
+ } as any;
72
+
73
+ const runner = new JSRunner({
74
+ globals: {
75
+ window: createSafeWindow(),
76
+ Blob: explicitBlob,
77
+ },
78
+ });
79
+
80
+ const result = await runner.run('const b = new Blob([1,2,3]); return b.size;');
81
+ expect(result.success).toBe(true);
82
+ expect(result.value).toBe(3);
83
+ });
84
+
85
+ it('auto-lifts URL from injected window to top-level globals', async () => {
86
+ const runner = new JSRunner({
87
+ globals: {
88
+ window: createSafeWindow(),
89
+ },
90
+ });
91
+
92
+ const result = await runner.run('return typeof URL.createObjectURL === "function"');
93
+ expect(result.success).toBe(true);
94
+ expect(result.value).toBe(true);
95
+ });
96
+
97
+ it('keeps explicit globals.URL higher priority than auto-lifted URL', async () => {
98
+ const explicitURL = {
99
+ createObjectURL: () => 'explicit://url',
100
+ revokeObjectURL: (_url: string) => undefined,
101
+ };
102
+
103
+ const runner = new JSRunner({
104
+ globals: {
105
+ window: createSafeWindow(),
106
+ URL: explicitURL,
107
+ },
108
+ });
109
+
110
+ const result = await runner.run('return URL.createObjectURL(new Blob(["x"]))');
111
+ expect(result.success).toBe(true);
112
+ expect(result.value).toBe('explicit://url');
113
+ });
114
+
51
115
  it('exposes console in sandbox by default', async () => {
52
116
  const runner = new JSRunner();
53
117
  const result = await runner.run('return typeof console !== "undefined"');
@@ -759,6 +759,84 @@ describe('FlowEngine context', () => {
759
759
  expect(engine.context.appName).toBe('NocoBase');
760
760
  });
761
761
 
762
+ it('ctx.sql should resolve template variables from caller context in delegate chain', async () => {
763
+ const engine = new FlowEngine();
764
+ const request = vi.fn(async () => ({ data: { data: [] } }));
765
+ engine.context.defineProperty('api', {
766
+ value: { request },
767
+ });
768
+ engine.context.defineProperty('minId', {
769
+ get: () => 999,
770
+ cache: false,
771
+ });
772
+
773
+ const callerCtx = new FlowContext();
774
+ callerCtx.addDelegate(engine.context);
775
+ callerCtx.defineProperty('minId', {
776
+ get: () => 1,
777
+ cache: false,
778
+ });
779
+
780
+ await callerCtx.sql.run('SELECT * FROM users WHERE id > {{ctx.minId}}', { type: 'selectRows' });
781
+
782
+ expect(request).toHaveBeenCalledTimes(1);
783
+ const config = request.mock.calls[0]?.[0];
784
+ expect(config?.url).toBe('flowSql:run');
785
+ expect(config?.data.bind.__var1).toBe(1);
786
+ });
787
+
788
+ it('ctx.sql should not share repository instance between different caller contexts', async () => {
789
+ const engine = new FlowEngine();
790
+ const request = vi.fn(async () => ({ data: { data: [] } }));
791
+ engine.context.defineProperty('api', {
792
+ value: { request },
793
+ });
794
+
795
+ const caller1 = new FlowContext();
796
+ caller1.addDelegate(engine.context);
797
+ caller1.defineProperty('minId', {
798
+ get: () => 1,
799
+ cache: false,
800
+ });
801
+
802
+ const caller2 = new FlowContext();
803
+ caller2.addDelegate(engine.context);
804
+ caller2.defineProperty('minId', {
805
+ get: () => 2,
806
+ cache: false,
807
+ });
808
+
809
+ expect(caller1.sql).not.toBe(caller2.sql);
810
+
811
+ await caller1.sql.run('SELECT * FROM users WHERE id > {{ctx.minId}}', { type: 'selectRows' });
812
+ await caller2.sql.run('SELECT * FROM users WHERE id > {{ctx.minId}}', { type: 'selectRows' });
813
+
814
+ expect(request).toHaveBeenCalledTimes(2);
815
+ const config1 = request.mock.calls[0]?.[0];
816
+ const config2 = request.mock.calls[1]?.[0];
817
+ expect(config1?.data.bind.__var1).toBe(1);
818
+ expect(config2?.data.bind.__var1).toBe(2);
819
+ });
820
+
821
+ it('engine.context.sql should keep working when accessed directly', async () => {
822
+ const engine = new FlowEngine();
823
+ const request = vi.fn(async () => ({ data: { data: [] } }));
824
+ engine.context.defineProperty('api', {
825
+ value: { request },
826
+ });
827
+ engine.context.defineProperty('minId', {
828
+ get: () => 3,
829
+ cache: false,
830
+ });
831
+
832
+ await engine.context.sql.run('SELECT * FROM users WHERE id > {{ctx.minId}}', { type: 'selectRows' });
833
+
834
+ expect(request).toHaveBeenCalledTimes(1);
835
+ const config = request.mock.calls[0]?.[0];
836
+ expect(config?.url).toBe('flowSql:run');
837
+ expect(config?.data.bind.__var1).toBe(3);
838
+ });
839
+
762
840
  it('engine.context.getVar should resolve variable by path', async () => {
763
841
  const engine = new FlowEngine();
764
842
  engine.context.defineProperty('foo', { value: { bar: 1 } });
@@ -614,7 +614,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
614
614
  };
615
615
  items.push({
616
616
  key: uniqueKey,
617
- label: <MenuLabelItem title={t(stepInfo.title)} uiMode={uiMode} itemProps={itemProps} />,
617
+ label: <MenuLabelItem title={stepInfo.title} uiMode={uiMode} itemProps={itemProps} />,
618
618
  });
619
619
  });
620
620
  if (flow.options.divider === 'bottom') {
@@ -656,7 +656,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
656
656
 
657
657
  items.push({
658
658
  key: uniqueKey,
659
- label: t(stepInfo.title),
659
+ label: stepInfo.title,
660
660
  });
661
661
  });
662
662
  });
@@ -671,7 +671,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
671
671
 
672
672
  subMenuChildren.push({
673
673
  key: uniqueKey,
674
- label: t(stepInfo.title),
674
+ label: stepInfo.title,
675
675
  });
676
676
  });
677
677
  });
@@ -1144,7 +1144,8 @@ export class FlowEngineContext extends BaseFlowEngineContext {
1144
1144
  value: this.engine,
1145
1145
  });
1146
1146
  this.defineProperty('sql', {
1147
- get: () => new FlowSQLRepository(this),
1147
+ get: (ctx) => new FlowSQLRepository(ctx),
1148
+ cache: false,
1148
1149
  });
1149
1150
  this.defineProperty('dataSourceManager', {
1150
1151
  value: dataSourceManager,
@@ -27,10 +27,10 @@ type SQLSaveOptions = {
27
27
  };
28
28
 
29
29
  export class FlowSQLRepository {
30
- protected ctx: FlowEngineContext;
30
+ protected ctx: FlowContext;
31
31
 
32
- constructor(ctx: FlowEngineContext) {
33
- this.ctx = new FlowContext() as FlowEngineContext;
32
+ constructor(ctx: FlowContext) {
33
+ this.ctx = new FlowContext();
34
34
  this.ctx.addDelegate(ctx);
35
35
  this.ctx.defineProperty('offset', {
36
36
  get: () => 0,
@@ -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 代理