WorkflowEngine.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. import { nanoid } from 'nanoid';
  2. import browser from 'webextension-polyfill';
  3. import cloneDeep from 'lodash.clonedeep';
  4. import { getBlocks } from '@/utils/getSharedData';
  5. import { clearCache, sleep, parseJSON, isObject } from '@/utils/helper';
  6. import dbStorage from '@/db/storage';
  7. import WorkflowWorker from './WorkflowWorker';
  8. let blocks = getBlocks();
  9. class WorkflowEngine {
  10. constructor(workflow, { states, logger, blocksHandler, options }) {
  11. this.id = nanoid();
  12. this.states = states;
  13. this.logger = logger;
  14. this.workflow = workflow;
  15. this.blocksHandler = blocksHandler;
  16. this.parentWorkflow = options?.parentWorkflow;
  17. this.saveLog = workflow.settings?.saveLog ?? true;
  18. this.isMV2 = browser.runtime.getManifest().manifest_version === 2;
  19. this.workerId = 0;
  20. this.workers = new Map();
  21. this.packagesCache = {};
  22. this.extractedGroup = {};
  23. this.connectionsMap = {};
  24. this.waitConnections = {};
  25. this.isDestroyed = false;
  26. this.isUsingProxy = false;
  27. this.triggerBlockId = null;
  28. this.blocks = {};
  29. this.history = [];
  30. this.columnsId = {};
  31. this.historyCtxData = {};
  32. this.eventListeners = {};
  33. this.preloadScripts = [];
  34. this.columns = {
  35. column: {
  36. index: 0,
  37. type: 'any',
  38. name: this.workflow.settings?.defaultColumnName || 'column',
  39. },
  40. };
  41. this.rowData = {};
  42. this.logsLimit = 1001;
  43. this.logHistoryId = 0;
  44. let variables = {};
  45. let { globalData } = workflow;
  46. if (options && options?.data) {
  47. globalData = options.data.globalData || globalData;
  48. variables = isObject(options.data.variables)
  49. ? options?.data.variables
  50. : {};
  51. options.data = { globalData, variables };
  52. }
  53. this.options = options;
  54. this.referenceData = {
  55. variables,
  56. table: [],
  57. secrets: {},
  58. loopData: {},
  59. workflow: {},
  60. googleSheets: {},
  61. globalData: parseJSON(globalData, globalData),
  62. };
  63. this.onDebugEvent = ({ tabId }, method, params) => {
  64. let isActiveTabEvent = false;
  65. this.workers.forEach((worker) => {
  66. if (isActiveTabEvent) return;
  67. isActiveTabEvent = worker.activeTab.id === tabId;
  68. });
  69. if (!isActiveTabEvent) return;
  70. (this.eventListeners[method] || []).forEach((listener) => {
  71. listener(params);
  72. });
  73. };
  74. this.onWorkflowStopped = (id) => {
  75. if (this.id !== id || this.isDestroyed) return;
  76. this.stop();
  77. };
  78. }
  79. async init() {
  80. try {
  81. if (this.workflow.isDisabled) return;
  82. if (!this.states) {
  83. console.error(`"${this.workflow.name}" workflow doesn't have states`);
  84. this.destroy('error');
  85. return;
  86. }
  87. const { nodes, edges } = this.workflow.drawflow;
  88. if (!nodes || nodes.length === 0) {
  89. console.error(`${this.workflow.name} doesn't have blocks`);
  90. return;
  91. }
  92. const triggerBlock = nodes.find((node) => {
  93. if (this.options?.blockId) return node.id === this.options.blockId;
  94. return node.label === 'trigger';
  95. });
  96. if (!triggerBlock) {
  97. console.error(`${this.workflow.name} doesn't have a trigger block`);
  98. return;
  99. }
  100. blocks = getBlocks();
  101. const checkParams = this.options?.checkParams ?? true;
  102. const hasParams =
  103. checkParams && triggerBlock.data?.parameters?.length > 0;
  104. if (hasParams) {
  105. this.eventListeners = {};
  106. const paramUrl = browser.runtime.getURL('params.html');
  107. const tabs = await browser.tabs.query({});
  108. const paramTab = tabs.find((tab) => tab.url?.includes(paramUrl));
  109. if (paramTab) {
  110. browser.tabs.sendMessage(paramTab.id, {
  111. name: 'workflow:params',
  112. data: this.workflow,
  113. });
  114. browser.windows.update(paramTab.windowId, { focused: true });
  115. } else {
  116. browser.windows.create({
  117. type: 'popup',
  118. width: 480,
  119. height: window.screen.availHeight,
  120. url: browser.runtime.getURL(
  121. `/params.html?workflowId=${this.workflow.id}`
  122. ),
  123. });
  124. }
  125. return;
  126. }
  127. this.triggerBlockId = triggerBlock.id;
  128. this.blocks = nodes.reduce((acc, node) => {
  129. acc[node.id] = node;
  130. return acc;
  131. }, {});
  132. this.connectionsMap = edges.reduce(
  133. (acc, { sourceHandle, target, targetHandle }) => {
  134. if (!acc[sourceHandle]) acc[sourceHandle] = new Map();
  135. acc[sourceHandle].set(target, {
  136. id: target,
  137. targetHandle,
  138. sourceHandle,
  139. });
  140. return acc;
  141. },
  142. {}
  143. );
  144. const workflowTable = this.workflow.table || this.workflow.dataColumns;
  145. let columns = Array.isArray(workflowTable)
  146. ? workflowTable
  147. : Object.values(workflowTable);
  148. if (this.workflow.connectedTable) {
  149. const connectedTable = await dbStorage.tablesItems
  150. .where('id')
  151. .equals(this.workflow.connectedTable)
  152. .first();
  153. const connectedTableData = await dbStorage.tablesData
  154. .where('tableId')
  155. .equals(connectedTable?.id)
  156. .first();
  157. if (connectedTable && connectedTableData) {
  158. columns = Object.values(connectedTable.columns);
  159. Object.assign(this.columns, connectedTableData.columnsIndex);
  160. this.referenceData.table = connectedTableData.items || [];
  161. } else {
  162. this.workflow.connectedTable = null;
  163. }
  164. }
  165. columns.forEach(({ name, type, id }) => {
  166. const columnId = id || name;
  167. this.rowData[name] = null;
  168. this.columnsId[name] = columnId;
  169. if (!this.columns[columnId])
  170. this.columns[columnId] = { index: 0, name, type };
  171. });
  172. if (BROWSER_TYPE !== 'chrome') {
  173. this.workflow.settings.debugMode = false;
  174. } else if (this.workflow.settings.debugMode) {
  175. chrome.debugger.onEvent.addListener(this.onDebugEvent);
  176. }
  177. if (
  178. this.workflow.settings.reuseLastState &&
  179. !this.workflow.connectedTable
  180. ) {
  181. const lastStateKey = `state:${this.workflow.id}`;
  182. const value = await browser.storage.local.get(lastStateKey);
  183. const lastState = value[lastStateKey];
  184. if (lastState) {
  185. Object.assign(this.columns, lastState.columns);
  186. Object.assign(this.referenceData, lastState.referenceData);
  187. }
  188. }
  189. const { settings: userSettings } = await browser.storage.local.get(
  190. 'settings'
  191. );
  192. this.logsLimit = userSettings?.logsLimit || 1001;
  193. this.workflow.table = columns;
  194. this.startedTimestamp = Date.now();
  195. this.states.on('stop', this.onWorkflowStopped);
  196. const credentials = await dbStorage.credentials.toArray();
  197. credentials.forEach(({ name, value }) => {
  198. this.referenceData.secrets[name] = value;
  199. });
  200. const variables = await dbStorage.variables.toArray();
  201. variables.forEach(({ name, value }) => {
  202. this.referenceData.variables[`$$${name}`] = value;
  203. });
  204. await this.states.add(this.id, {
  205. id: this.id,
  206. state: this.state,
  207. workflowId: this.workflow.id,
  208. parentState: this.parentWorkflow,
  209. teamId: this.workflow.teamId || null,
  210. });
  211. this.addWorker({ blockId: triggerBlock.id });
  212. } catch (error) {
  213. console.error(error);
  214. }
  215. }
  216. addWorker(detail) {
  217. this.workerId += 1;
  218. const workerId = `worker-${this.workerId}`;
  219. const worker = new WorkflowWorker(workerId, this, { blocksDetail: blocks });
  220. worker.init(detail);
  221. this.workers.set(worker.id, worker);
  222. }
  223. addLogHistory(detail) {
  224. if (detail.name === 'blocks-group') return;
  225. const isLimit = this.history.length >= this.logsLimit;
  226. const notErrorLog = detail.type !== 'error';
  227. if ((isLimit || !this.saveLog) && notErrorLog) return;
  228. this.logHistoryId += 1;
  229. detail.id = this.logHistoryId;
  230. if (
  231. detail.name !== 'delay' ||
  232. detail.replacedValue ||
  233. detail.name === 'javascript-code' ||
  234. (blocks[detail.name]?.refDataKeys && this.saveLog)
  235. ) {
  236. const { activeTabUrl, variables, loopData } = JSON.parse(
  237. JSON.stringify(this.referenceData)
  238. );
  239. this.historyCtxData[this.logHistoryId] = {
  240. referenceData: {
  241. loopData,
  242. variables,
  243. activeTabUrl,
  244. prevBlockData: detail.prevBlockData || '',
  245. },
  246. replacedValue: cloneDeep(detail.replacedValue),
  247. };
  248. delete detail.replacedValue;
  249. }
  250. this.history.push(detail);
  251. }
  252. async stop() {
  253. try {
  254. if (this.childWorkflowId) {
  255. await this.states.stop(this.childWorkflowId);
  256. }
  257. await this.destroy('stopped');
  258. } catch (error) {
  259. console.error(error);
  260. }
  261. }
  262. async executeQueue() {
  263. const { workflowQueue } = await browser.storage.local.get('workflowQueue');
  264. const queueIndex = (workflowQueue || []).indexOf(this.workflow?.id);
  265. if (!workflowQueue || queueIndex === -1) return;
  266. const engine = new WorkflowEngine(this.workflow, {
  267. logger: this.logger,
  268. states: this.states,
  269. blocksHandler: this.blocksHandler,
  270. });
  271. engine.init();
  272. workflowQueue.splice(queueIndex, 1);
  273. await browser.storage.local.set({ workflowQueue });
  274. }
  275. destroyWorker(workerId) {
  276. this.workers.delete(workerId);
  277. if (this.workers.size === 0) {
  278. this.addLogHistory({
  279. type: 'finish',
  280. name: 'finish',
  281. });
  282. this.dispatchEvent('finish');
  283. this.destroy('success');
  284. }
  285. }
  286. async destroy(status, message, blockDetail) {
  287. const cleanUp = () => {
  288. this.id = null;
  289. this.states = null;
  290. this.logger = null;
  291. this.saveLog = null;
  292. this.workflow = null;
  293. this.blocksHandler = null;
  294. this.parentWorkflow = null;
  295. this.isDestroyed = true;
  296. this.referenceData = null;
  297. this.eventListeners = null;
  298. this.packagesCache = null;
  299. this.extractedGroup = null;
  300. this.connectionsMap = null;
  301. this.waitConnections = null;
  302. this.blocks = null;
  303. this.history = null;
  304. this.columnsId = null;
  305. this.historyCtxData = null;
  306. this.preloadScripts = null;
  307. };
  308. try {
  309. if (this.isDestroyed) return;
  310. if (this.isUsingProxy) browser.proxy.settings.clear({});
  311. if (this.workflow.settings.debugMode && BROWSER_TYPE === 'chrome') {
  312. chrome.debugger.onEvent.removeListener(this.onDebugEvent);
  313. await sleep(1000);
  314. this.workers.forEach((worker) => {
  315. if (!worker.debugAttached) return;
  316. chrome.debugger.detach({ tabId: worker.activeTab.id });
  317. });
  318. }
  319. const endedTimestamp = Date.now();
  320. this.workers.clear();
  321. this.executeQueue();
  322. this.states.off('stop', this.onWorkflowStopped);
  323. await this.states.delete(this.id);
  324. this.dispatchEvent('destroyed', {
  325. status,
  326. message,
  327. blockDetail,
  328. id: this.id,
  329. endedTimestamp,
  330. history: this.history,
  331. startedTimestamp: this.startedTimestamp,
  332. });
  333. if (this.workflow.settings.reuseLastState) {
  334. const workflowState = {
  335. [`state:${this.workflow.id}`]: {
  336. columns: this.columns,
  337. referenceData: {
  338. table: this.referenceData.table,
  339. variables: this.referenceData.variables,
  340. },
  341. },
  342. };
  343. browser.storage.local.set(workflowState);
  344. } else if (status === 'success') {
  345. clearCache(this.workflow);
  346. }
  347. const { table, variables } = this.referenceData;
  348. const tableId = this.workflow.connectedTable;
  349. await dbStorage.transaction(
  350. 'rw',
  351. dbStorage.tablesItems,
  352. dbStorage.tablesData,
  353. dbStorage.variables,
  354. async () => {
  355. if (tableId) {
  356. await dbStorage.tablesItems.update(tableId, {
  357. modifiedAt: Date.now(),
  358. rowsCount: table.length,
  359. });
  360. await dbStorage.tablesData.where('tableId').equals(tableId).modify({
  361. items: table,
  362. columnsIndex: this.columns,
  363. });
  364. }
  365. for (const key in variables) {
  366. if (key.startsWith('$$')) {
  367. const varName = key.slice(2);
  368. const varValue = variables[key];
  369. const variable =
  370. (await dbStorage.variables
  371. .where('name')
  372. .equals(varName)
  373. .first()) || {};
  374. variable.name = varName;
  375. variable.value = varValue;
  376. await dbStorage.variables.put(variable);
  377. }
  378. }
  379. }
  380. );
  381. if (!this.workflow?.isTesting) {
  382. const { name, id, teamId } = this.workflow;
  383. await this.logger.add({
  384. detail: {
  385. name,
  386. status,
  387. teamId,
  388. message,
  389. id: this.id,
  390. workflowId: id,
  391. saveLog: this.saveLog,
  392. endedAt: endedTimestamp,
  393. parentLog: this.parentWorkflow,
  394. startedAt: this.startedTimestamp,
  395. },
  396. history: {
  397. logId: this.id,
  398. data: this.saveLog ? this.history : [],
  399. },
  400. ctxData: {
  401. logId: this.id,
  402. data: this.historyCtxData,
  403. },
  404. data: {
  405. logId: this.id,
  406. data: {
  407. table: [...this.referenceData.table],
  408. variables: { ...this.referenceData.variables },
  409. },
  410. },
  411. });
  412. }
  413. cleanUp();
  414. } catch (error) {
  415. console.error(error);
  416. cleanUp();
  417. }
  418. }
  419. async updateState(data) {
  420. const state = {
  421. ...data,
  422. tabIds: [],
  423. currentBlock: [],
  424. name: this.workflow.name,
  425. logs: this.history.slice(-5),
  426. startedTimestamp: this.startedTimestamp,
  427. };
  428. this.workers.forEach((worker) => {
  429. const { id, label, startedAt } = worker.currentBlock;
  430. state.currentBlock.push({ id, name: label, startedAt });
  431. state.tabIds.push(worker.activeTab.id);
  432. });
  433. await this.states.update(this.id, { state });
  434. this.dispatchEvent('update', { state });
  435. }
  436. dispatchEvent(name, params) {
  437. const listeners = this.eventListeners[name];
  438. if (!listeners) return;
  439. listeners.forEach((callback) => {
  440. callback(params);
  441. });
  442. }
  443. on(name, listener) {
  444. (this.eventListeners[name] = this.eventListeners[name] || []).push(
  445. listener
  446. );
  447. }
  448. }
  449. export default WorkflowEngine;