WorkflowWorker.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  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. });
  142. this.engine.addWorker({
  143. state,
  144. execParam,
  145. blockId: id,
  146. });
  147. }
  148. });
  149. }
  150. resume(nextBlock) {
  151. if (!this.breakpointState) return;
  152. const { block, execParam, isRetry } = this.breakpointState;
  153. const payload = { ...execParam, resume: true };
  154. payload.nextBlockBreakpointCount = nextBlock ? 1 : null;
  155. this.executeBlock(block, payload, isRetry);
  156. this.breakpointState = null;
  157. }
  158. async executeBlock(block, execParam = {}, isRetry = false) {
  159. const currentState = await this.engine.states.get(this.engine.id);
  160. if (!currentState || currentState.isDestroyed) {
  161. if (this.engine.isDestroyed) return;
  162. await this.engine.destroy('stopped');
  163. return;
  164. }
  165. const startExecuteTime = Date.now();
  166. const prevBlock = this.currentBlock;
  167. this.currentBlock = { ...block, startedAt: startExecuteTime };
  168. const isInBreakpoint =
  169. this.engine.isTestingMode &&
  170. ((block.data?.$breakpoint && !execParam.resume) ||
  171. execParam.nextBlockBreakpointCount === 0);
  172. if (!isRetry) {
  173. const payload = {
  174. activeTabUrl: this.activeTab.url,
  175. childWorkflowId: this.childWorkflowId,
  176. nextBlockBreakpoint: Boolean(execParam.nextBlockBreakpointCount),
  177. };
  178. if (isInBreakpoint && currentState.status !== 'breakpoint')
  179. payload.status = 'breakpoint';
  180. await this.engine.updateState(payload);
  181. }
  182. if (execParam.nextBlockBreakpointCount) {
  183. execParam.nextBlockBreakpointCount -= 1;
  184. }
  185. if (isInBreakpoint || currentState.status === 'breakpoint') {
  186. this.engine.isInBreakpoint = true;
  187. this.breakpointState = { block, execParam, isRetry };
  188. return;
  189. }
  190. const blockHandler = this.engine.blocksHandler[toCamelCase(block.label)];
  191. const handler =
  192. !blockHandler && this.blocksDetail[block.label].category === 'interaction'
  193. ? this.engine.blocksHandler.interactionBlock
  194. : blockHandler;
  195. if (!handler) {
  196. console.error(`${block.label} doesn't have handler`);
  197. this.engine.destroy('stopped');
  198. return;
  199. }
  200. const { prevBlockData } = execParam;
  201. const refData = {
  202. prevBlockData,
  203. ...this.engine.referenceData,
  204. activeTabUrl: this.activeTab.url,
  205. };
  206. const replacedBlock = await templating({
  207. block,
  208. data: refData,
  209. isPopup: this.engine.isPopup,
  210. refKeys:
  211. isRetry || block.data.disableBlock
  212. ? null
  213. : this.blocksDetail[block.label].refDataKeys,
  214. });
  215. const blockDelay = this.settings?.blockDelay || 0;
  216. const addBlockLog = (status, obj = {}) => {
  217. let { description } = block.data;
  218. if (block.label === 'loop-breakpoint') description = block.data.loopId;
  219. else if (block.label === 'block-package') description = block.data.name;
  220. this.engine.addLogHistory({
  221. description,
  222. prevBlockData,
  223. type: status,
  224. name: block.label,
  225. blockId: block.id,
  226. workerId: this.id,
  227. timestamp: startExecuteTime,
  228. activeTabUrl: this.activeTab?.url,
  229. replacedValue: replacedBlock.replacedValue,
  230. duration: Math.round(Date.now() - startExecuteTime),
  231. ...obj,
  232. });
  233. };
  234. const executeBlocks = (blocks, data) => {
  235. return this.executeNextBlocks(
  236. blocks,
  237. data,
  238. execParam.nextBlockBreakpointCount
  239. );
  240. };
  241. try {
  242. let result;
  243. if (block.data.disableBlock) {
  244. result = {
  245. data: '',
  246. nextBlockId: this.getBlockConnections(block.id),
  247. };
  248. } else {
  249. const bindedHandler = handler.bind(this, replacedBlock, {
  250. refData,
  251. prevBlock,
  252. ...(execParam || {}),
  253. });
  254. result = await blockExecutionWrapper(bindedHandler, block.data);
  255. if (this.engine.isDestroyed) return;
  256. if (result.replacedValue) {
  257. replacedBlock.replacedValue = result.replacedValue;
  258. }
  259. addBlockLog(result.status || 'success', {
  260. logId: result.logId,
  261. ctxData: result?.ctxData,
  262. });
  263. }
  264. if (result.nextBlockId && !result.destroyWorker) {
  265. if (blockDelay > 0) {
  266. setTimeout(() => {
  267. executeBlocks(result.nextBlockId, result.data);
  268. }, blockDelay);
  269. } else {
  270. executeBlocks(result.nextBlockId, result.data);
  271. }
  272. } else {
  273. this.engine.destroyWorker(this.id);
  274. }
  275. } catch (error) {
  276. console.error(error);
  277. const errorLogData = {
  278. message: error.message,
  279. ...(error.data || {}),
  280. ...(error.ctxData || {}),
  281. };
  282. const { onError: blockOnError } = replacedBlock.data;
  283. if (blockOnError && blockOnError.enable) {
  284. if (blockOnError.retry && blockOnError.retryTimes) {
  285. await sleep(blockOnError.retryInterval * 1000);
  286. blockOnError.retryTimes -= 1;
  287. await this.executeBlock(replacedBlock, execParam, true);
  288. return;
  289. }
  290. if (blockOnError.insertData) {
  291. for (const item of blockOnError.dataToInsert) {
  292. let value = (
  293. await renderString(item.value, refData, this.engine.isPopup)
  294. )?.value;
  295. value = parseJSON(value, value);
  296. if (item.type === 'variable') {
  297. this.setVariable(item.name, value);
  298. } else {
  299. this.addDataToColumn(item.name, value);
  300. }
  301. }
  302. }
  303. const nextBlocks = this.getBlockConnections(
  304. block.id,
  305. blockOnError.toDo === 'continue' ? 1 : 'fallback'
  306. );
  307. if (blockOnError.toDo !== 'error' && nextBlocks) {
  308. addBlockLog('error', errorLogData);
  309. executeBlocks(nextBlocks, prevBlockData);
  310. return;
  311. }
  312. }
  313. const errorLogItem = errorLogData;
  314. addBlockLog('error', errorLogItem);
  315. errorLogItem.blockId = block.id;
  316. const { onError } = this.settings;
  317. const nodeConnections = this.getBlockConnections(block.id);
  318. if (onError === 'keep-running' && nodeConnections) {
  319. setTimeout(() => {
  320. executeBlocks(nodeConnections, error.data || '');
  321. }, blockDelay);
  322. } else if (onError === 'restart-workflow' && !this.parentWorkflow) {
  323. const restartCount = this.engine.restartWorkersCount[this.id] || 0;
  324. const maxRestart = this.settings.restartTimes ?? 3;
  325. if (restartCount >= maxRestart) {
  326. delete this.engine.restartWorkersCount[this.id];
  327. this.engine.destroy('error', error.message, errorLogItem);
  328. return;
  329. }
  330. this.reset();
  331. const triggerBlock = this.engine.blocks[this.engine.triggerBlockId];
  332. if (triggerBlock) this.executeBlock(triggerBlock, execParam);
  333. this.engine.restartWorkersCount[this.id] = restartCount + 1;
  334. } else {
  335. this.engine.destroy('error', error.message, errorLogItem);
  336. }
  337. }
  338. }
  339. reset() {
  340. this.loopList = {};
  341. this.repeatedTasks = {};
  342. this.windowId = null;
  343. this.currentBlock = null;
  344. this.childWorkflowId = null;
  345. this.engine.history = [];
  346. this.engine.preloadScripts = [];
  347. this.engine.columns = {
  348. column: {
  349. index: 0,
  350. type: 'any',
  351. name: this.settings?.defaultColumnName || 'column',
  352. },
  353. };
  354. this.activeTab = {
  355. url: '',
  356. frameId: 0,
  357. frames: {},
  358. groupId: null,
  359. id: this.options?.tabId,
  360. };
  361. this.engine.referenceData = {
  362. table: [],
  363. loopData: {},
  364. workflow: {},
  365. googleSheets: {},
  366. variables: this.engine.options?.variables || {},
  367. globalData: this.engine.referenceData.globalData,
  368. };
  369. }
  370. async _sendMessageToTab(payload, options = {}, runBeforeLoad = false) {
  371. try {
  372. if (!this.activeTab.id) {
  373. const error = new Error('no-tab');
  374. error.workflowId = this.id;
  375. throw error;
  376. }
  377. if (!runBeforeLoad) {
  378. await waitTabLoaded({
  379. tabId: this.activeTab.id,
  380. ms: this.settings?.tabLoadTimeout ?? 30000,
  381. });
  382. }
  383. const { executedBlockOnWeb, debugMode } = this.settings;
  384. const messagePayload = {
  385. isBlock: true,
  386. debugMode,
  387. executedBlockOnWeb,
  388. loopEls: this.loopEls,
  389. activeTabId: this.activeTab.id,
  390. frameSelector: this.frameSelector,
  391. ...payload,
  392. };
  393. const data = await browser.tabs.sendMessage(
  394. this.activeTab.id,
  395. messagePayload,
  396. { frameId: this.activeTab.frameId, ...options }
  397. );
  398. return data;
  399. } catch (error) {
  400. console.error(error);
  401. const noConnection = error.message?.includes(
  402. 'Could not establish connection'
  403. );
  404. const channelClosed = error.message?.includes('message channel closed');
  405. if (noConnection || channelClosed) {
  406. const isScriptInjected = await injectContentScript(
  407. this.activeTab.id,
  408. this.activeTab.frameId
  409. );
  410. if (isScriptInjected) {
  411. const result = await this._sendMessageToTab(
  412. payload,
  413. options,
  414. runBeforeLoad
  415. );
  416. return result;
  417. }
  418. error.message = 'Could not establish connection to the active tab';
  419. } else if (error.message?.startsWith('No tab')) {
  420. error.message = 'active-tab-removed';
  421. }
  422. throw error;
  423. }
  424. }
  425. }
  426. export default WorkflowWorker;