WorkflowWorker.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. import browser from 'webextension-polyfill';
  2. import cloneDeep from 'lodash.clonedeep';
  3. import {
  4. toCamelCase,
  5. sleep,
  6. objectHasKey,
  7. parseJSON,
  8. isObject,
  9. } from '@/utils/helper';
  10. import templating from './templating';
  11. import renderString from './templating/renderString';
  12. import { convertData, waitTabLoaded } from './helper';
  13. import injectContentScript from './injectContentScript';
  14. function blockExecutionWrapper(blockHandler, blockData) {
  15. return new Promise((resolve, reject) => {
  16. let timeout = null;
  17. const timeoutMs = blockData?.settings?.blockTimeout;
  18. if (timeoutMs && timeoutMs > 0) {
  19. timeout = setTimeout(() => {
  20. reject(new Error('Timeout'));
  21. }, timeoutMs);
  22. }
  23. blockHandler()
  24. .then((result) => {
  25. resolve(result);
  26. })
  27. .catch((error) => {
  28. reject(error);
  29. })
  30. .finally(() => {
  31. if (timeout) clearTimeout(timeout);
  32. });
  33. });
  34. }
  35. class WorkflowWorker {
  36. constructor(id, engine, options = {}) {
  37. this.id = id;
  38. this.engine = engine;
  39. this.settings = engine.workflow.settings;
  40. this.blocksDetail = options.blocksDetail || {};
  41. this.loopEls = [];
  42. this.loopList = {};
  43. this.repeatedTasks = {};
  44. this.preloadScripts = [];
  45. this.breakpointState = null;
  46. this.windowId = null;
  47. this.currentBlock = null;
  48. this.childWorkflowId = null;
  49. this.debugAttached = false;
  50. this.activeTab = {
  51. url: '',
  52. frameId: 0,
  53. frames: {},
  54. groupId: null,
  55. id: engine.options?.tabId,
  56. };
  57. }
  58. init({ blockId, execParam, state }) {
  59. if (state) {
  60. Object.keys(state).forEach((key) => {
  61. this[key] = state[key];
  62. });
  63. }
  64. const block = this.engine.blocks[blockId];
  65. this.executeBlock(block, execParam);
  66. }
  67. addDataToColumn(key, value) {
  68. if (Array.isArray(key)) {
  69. key.forEach((item) => {
  70. if (!isObject(item)) return;
  71. Object.entries(item).forEach(([itemKey, itemValue]) => {
  72. this.addDataToColumn(itemKey, itemValue);
  73. });
  74. });
  75. return;
  76. }
  77. const insertDefault = this.settings.insertDefaultColumn ?? true;
  78. const columnId =
  79. (this.engine.columns[key] ? key : this.engine.columnsId[key]) || 'column';
  80. if (columnId === 'column' && !insertDefault) return;
  81. const currentColumn = this.engine.columns[columnId];
  82. const columnName = currentColumn.name || 'column';
  83. const convertedValue = convertData(value, currentColumn.type);
  84. if (objectHasKey(this.engine.referenceData.table, currentColumn.index)) {
  85. this.engine.referenceData.table[currentColumn.index][columnName] =
  86. convertedValue;
  87. } else {
  88. this.engine.referenceData.table.push({
  89. [columnName]: convertedValue,
  90. });
  91. }
  92. currentColumn.index += 1;
  93. }
  94. setVariable(name, value) {
  95. const vars = this.engine.referenceData.variables;
  96. if (name.startsWith('$push:')) {
  97. const { 1: varName } = name.split('$push:');
  98. if (!objectHasKey(vars, varName)) vars[varName] = [];
  99. else if (!Array.isArray(vars[varName])) vars[varName] = [vars[varName]];
  100. vars[varName].push(value);
  101. }
  102. vars[name] = value;
  103. this.engine.addRefDataSnapshot('variables');
  104. }
  105. getBlockConnections(blockId, outputIndex = 1) {
  106. if (this.engine.isDestroyed) return null;
  107. const outputId = `${blockId}-output-${outputIndex}`;
  108. const connections = this.engine.connectionsMap[outputId];
  109. if (!connections) return null;
  110. return [...connections.values()];
  111. }
  112. executeNextBlocks(
  113. connections,
  114. prevBlockData,
  115. nextBlockBreakpointCount = null
  116. ) {
  117. connections.forEach((connection, index) => {
  118. const { id, targetHandle, sourceHandle } =
  119. typeof connection === 'string'
  120. ? { id: connection, targetHandle: '', sourceHandle: '' }
  121. : connection;
  122. const execParam = {
  123. prevBlockData,
  124. targetHandle,
  125. sourceHandle,
  126. nextBlockBreakpointCount,
  127. };
  128. if (index === 0) {
  129. this.executeBlock(this.engine.blocks[id], {
  130. prevBlockData,
  131. ...execParam,
  132. });
  133. } else {
  134. const state = cloneDeep({
  135. windowId: this.windowId,
  136. loopList: this.loopList,
  137. activeTab: this.activeTab,
  138. currentBlock: this.currentBlock,
  139. repeatedTasks: this.repeatedTasks,
  140. preloadScripts: this.preloadScripts,
  141. debugAttached: this.debugAttached,
  142. });
  143. this.engine.addWorker({
  144. state,
  145. execParam,
  146. blockId: id,
  147. });
  148. }
  149. });
  150. }
  151. resume(nextBlock) {
  152. if (!this.breakpointState) return;
  153. const { block, execParam, isRetry } = this.breakpointState;
  154. const payload = { ...execParam, resume: true };
  155. payload.nextBlockBreakpointCount = nextBlock ? 1 : null;
  156. this.executeBlock(block, payload, isRetry);
  157. this.breakpointState = null;
  158. }
  159. async executeBlock(block, execParam = {}, isRetry = false) {
  160. const currentState = await this.engine.states.get(this.engine.id);
  161. if (!currentState || currentState.isDestroyed) {
  162. if (this.engine.isDestroyed) return;
  163. await this.engine.destroy('stopped');
  164. return;
  165. }
  166. const startExecuteTime = Date.now();
  167. const prevBlock = this.currentBlock;
  168. this.currentBlock = { ...block, startedAt: startExecuteTime };
  169. const isInBreakpoint =
  170. this.engine.isTestingMode &&
  171. ((block.data?.$breakpoint && !execParam.resume) ||
  172. execParam.nextBlockBreakpointCount === 0);
  173. if (!isRetry) {
  174. const payload = {
  175. activeTabUrl: this.activeTab.url,
  176. childWorkflowId: this.childWorkflowId,
  177. nextBlockBreakpoint: Boolean(execParam.nextBlockBreakpointCount),
  178. };
  179. if (isInBreakpoint && currentState.status !== 'breakpoint')
  180. payload.status = 'breakpoint';
  181. await this.engine.updateState(payload);
  182. }
  183. if (execParam.nextBlockBreakpointCount) {
  184. execParam.nextBlockBreakpointCount -= 1;
  185. }
  186. if (isInBreakpoint || currentState.status === 'breakpoint') {
  187. this.engine.isInBreakpoint = true;
  188. this.breakpointState = { block, execParam, isRetry };
  189. return;
  190. }
  191. const blockHandler = this.engine.blocksHandler[toCamelCase(block.label)];
  192. const handler =
  193. !blockHandler && this.blocksDetail[block.label].category === 'interaction'
  194. ? this.engine.blocksHandler.interactionBlock
  195. : blockHandler;
  196. if (!handler) {
  197. console.error(`${block.label} doesn't have handler`);
  198. this.engine.destroy('stopped');
  199. return;
  200. }
  201. const { prevBlockData } = execParam;
  202. const refData = {
  203. prevBlockData,
  204. ...this.engine.referenceData,
  205. activeTabUrl: this.activeTab.url,
  206. };
  207. const replacedBlock = await templating({
  208. block,
  209. data: refData,
  210. isPopup: this.engine.isPopup,
  211. refKeys:
  212. isRetry || block.data.disableBlock
  213. ? null
  214. : this.blocksDetail[block.label].refDataKeys,
  215. });
  216. const blockDelay = this.settings?.blockDelay || 0;
  217. const addBlockLog = (status, obj = {}) => {
  218. let { description } = block.data;
  219. if (block.label === 'loop-breakpoint') description = block.data.loopId;
  220. else if (block.label === 'block-package') description = block.data.name;
  221. this.engine.addLogHistory({
  222. description,
  223. prevBlockData,
  224. type: status,
  225. name: block.label,
  226. blockId: block.id,
  227. workerId: this.id,
  228. timestamp: startExecuteTime,
  229. activeTabUrl: this.activeTab?.url,
  230. replacedValue: replacedBlock.replacedValue,
  231. duration: Math.round(Date.now() - startExecuteTime),
  232. ...obj,
  233. });
  234. };
  235. const executeBlocks = (blocks, data) => {
  236. return this.executeNextBlocks(
  237. blocks,
  238. data,
  239. execParam.nextBlockBreakpointCount
  240. );
  241. };
  242. try {
  243. let result;
  244. if (block.data.disableBlock) {
  245. result = {
  246. data: '',
  247. nextBlockId: this.getBlockConnections(block.id),
  248. };
  249. } else {
  250. const bindedHandler = handler.bind(this, replacedBlock, {
  251. refData,
  252. prevBlock,
  253. ...(execParam || {}),
  254. });
  255. result = await blockExecutionWrapper(bindedHandler, block.data);
  256. if (this.engine.isDestroyed) return;
  257. if (result.replacedValue) {
  258. replacedBlock.replacedValue = result.replacedValue;
  259. }
  260. addBlockLog(result.status || 'success', {
  261. logId: result.logId,
  262. ctxData: result?.ctxData,
  263. });
  264. }
  265. if (result.nextBlockId && !result.destroyWorker) {
  266. if (blockDelay > 0) {
  267. setTimeout(() => {
  268. executeBlocks(result.nextBlockId, result.data);
  269. }, blockDelay);
  270. } else {
  271. executeBlocks(result.nextBlockId, result.data);
  272. }
  273. } else {
  274. this.engine.destroyWorker(this.id);
  275. }
  276. } catch (error) {
  277. console.error(error);
  278. const errorLogData = {
  279. message: error.message,
  280. ...(error.data || {}),
  281. ...(error.ctxData || {}),
  282. };
  283. const { onError: blockOnError } = replacedBlock.data;
  284. if (blockOnError && blockOnError.enable) {
  285. if (blockOnError.retry && blockOnError.retryTimes) {
  286. await sleep(blockOnError.retryInterval * 1000);
  287. blockOnError.retryTimes -= 1;
  288. await this.executeBlock(replacedBlock, execParam, true);
  289. return;
  290. }
  291. if (blockOnError.insertData) {
  292. for (const item of blockOnError.dataToInsert) {
  293. let value = (
  294. await renderString(item.value, refData, this.engine.isPopup)
  295. )?.value;
  296. value = parseJSON(value, value);
  297. if (item.type === 'variable') {
  298. this.setVariable(item.name, value);
  299. } else {
  300. this.addDataToColumn(item.name, value);
  301. }
  302. }
  303. }
  304. const nextBlocks = this.getBlockConnections(
  305. block.id,
  306. blockOnError.toDo === 'continue' ? 1 : 'fallback'
  307. );
  308. if (blockOnError.toDo !== 'error' && nextBlocks) {
  309. addBlockLog('error', errorLogData);
  310. executeBlocks(nextBlocks, prevBlockData);
  311. return;
  312. }
  313. }
  314. const errorLogItem = errorLogData;
  315. addBlockLog('error', errorLogItem);
  316. errorLogItem.blockId = block.id;
  317. const { onError } = this.settings;
  318. const nodeConnections = this.getBlockConnections(block.id);
  319. if (onError === 'keep-running' && nodeConnections) {
  320. setTimeout(() => {
  321. executeBlocks(nodeConnections, error.data || '');
  322. }, blockDelay);
  323. } else if (onError === 'restart-workflow' && !this.parentWorkflow) {
  324. const restartCount = this.engine.restartWorkersCount[this.id] || 0;
  325. const maxRestart = this.settings.restartTimes ?? 3;
  326. if (restartCount >= maxRestart) {
  327. delete this.engine.restartWorkersCount[this.id];
  328. this.engine.destroy('error', error.message, errorLogItem);
  329. return;
  330. }
  331. this.reset();
  332. const triggerBlock = this.engine.blocks[this.engine.triggerBlockId];
  333. if (triggerBlock) this.executeBlock(triggerBlock, execParam);
  334. this.engine.restartWorkersCount[this.id] = restartCount + 1;
  335. } else {
  336. this.engine.destroy('error', error.message, errorLogItem);
  337. }
  338. }
  339. }
  340. reset() {
  341. this.loopList = {};
  342. this.repeatedTasks = {};
  343. this.windowId = null;
  344. this.currentBlock = null;
  345. this.childWorkflowId = null;
  346. this.engine.history = [];
  347. this.engine.preloadScripts = [];
  348. this.engine.columns = {
  349. column: {
  350. index: 0,
  351. type: 'any',
  352. name: this.settings?.defaultColumnName || 'column',
  353. },
  354. };
  355. this.activeTab = {
  356. url: '',
  357. frameId: 0,
  358. frames: {},
  359. groupId: null,
  360. id: this.options?.tabId,
  361. };
  362. this.engine.referenceData = {
  363. table: [],
  364. loopData: {},
  365. workflow: {},
  366. googleSheets: {},
  367. variables: this.engine.options?.variables || {},
  368. globalData: this.engine.referenceData.globalData,
  369. };
  370. }
  371. async _sendMessageToTab(payload, options = {}, runBeforeLoad = false) {
  372. try {
  373. if (!this.activeTab.id) {
  374. const error = new Error('no-tab');
  375. error.workflowId = this.id;
  376. throw error;
  377. }
  378. if (!runBeforeLoad) {
  379. await waitTabLoaded({
  380. tabId: this.activeTab.id,
  381. ms: this.settings?.tabLoadTimeout ?? 30000,
  382. });
  383. }
  384. const { executedBlockOnWeb, debugMode } = this.settings;
  385. const messagePayload = {
  386. isBlock: true,
  387. debugMode,
  388. executedBlockOnWeb,
  389. loopEls: this.loopEls,
  390. activeTabId: this.activeTab.id,
  391. frameSelector: this.frameSelector,
  392. ...payload,
  393. };
  394. const data = await browser.tabs.sendMessage(
  395. this.activeTab.id,
  396. messagePayload,
  397. { frameId: this.activeTab.frameId, ...options }
  398. );
  399. return data;
  400. } catch (error) {
  401. console.error(error);
  402. const noConnection = error.message?.includes(
  403. 'Could not establish connection'
  404. );
  405. const channelClosed = error.message?.includes('message channel closed');
  406. if (noConnection || channelClosed) {
  407. const isScriptInjected = await injectContentScript(
  408. this.activeTab.id,
  409. this.activeTab.frameId
  410. );
  411. if (isScriptInjected) {
  412. const result = await this._sendMessageToTab(
  413. payload,
  414. options,
  415. runBeforeLoad
  416. );
  417. return result;
  418. }
  419. error.message = 'Could not establish connection to the active tab';
  420. } else if (error.message?.startsWith('No tab')) {
  421. error.message = 'active-tab-removed';
  422. }
  423. throw error;
  424. }
  425. }
  426. }
  427. export default WorkflowWorker;