1
0

engine.js 13 KB

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