UNPKG

@prostojs/wf

Version:

Generic workflow framework

455 lines (450 loc) 14.4 kB
class StepRetriableError extends Error { constructor(originalError, errorList, inputRequired, expires) { super(originalError.message); this.originalError = originalError; this.errorList = errorList; this.inputRequired = inputRequired; this.expires = expires; this.name = 'StepRetriableError'; } } const GLOBALS = { // Node.js Globals global: null, process: null, Buffer: null, require: null, __filename: null, __dirname: null, exports: null, module: null, setImmediate: null, clearImmediate: null, setTimeout: null, clearTimeout: null, setInterval: null, clearInterval: null, queueMicrotask: null, queueGlobalMicrotask: null, globalThis: null, // GlobalThis (Introduced in ECMAScript 2020) // Browser Globals window: null, self: null, document: null, localStorage: null, sessionStorage: null, indexedDB: null, caches: null, console: null, performance: null, fetch: null, XMLHttpRequest: null, Image: null, Audio: null, navigator: null, navigation: null, location: null, history: null, screen: null, requestAnimationFrame: null, cancelAnimationFrame: null, cancelIdleCallback: null, captureEvents: null, chrome: null, clientInformation: null, addEventListener: null, removeEventListener: null, blur: null, close: null, closed: null, confirm: null, alert: null, customElements: null, dispatchEvent: null, debug: null, focus: null, find: null, frames: null, getSelection: null, getScreenDetails: null, getEventListeners: null, keys: null, launchQueue: null, parent: null, postMessage: null, print: null, profile: null, profileEnd: null, prompt: null, queryLocalFonts: null, queryObjects: null, releaseEvents: null, reportError: null, resizeBy: null, resizeTo: null, scheduler: null, stop: null, scroll: null, scrollBy: null, scrollTo: null, scrollY: null, scrollX: null, top: null, // other eval: null, __ctx__: null }; class FtringsPool { cache = /* @__PURE__ */ new Map(); call(code, ctx) { return this.getFn(code)(ctx); } getFn(code) { let cached = this.cache.get(code); if (!cached) { cached = ftring(code); this.cache.set(code, cached); } return cached; } } function ftring(code) { const fnCode = `with(__ctx__){ return ${code} }`; const fn = new Function("__ctx__", fnCode); return (ctx) => { const newCtx = Object.freeze(Object.assign({}, GLOBALS, ctx)); return fn(newCtx); }; } class Workflow { constructor(steps) { this.steps = steps; this.mappedSteps = {}; this.schemas = {}; this.schemaPrefix = {}; this.spies = []; this.fnPool = new FtringsPool(); for (const step of steps) { if (this.mappedSteps[step.id]) { throw new Error(`Duplicate step id "${step.id}"`); } this.mappedSteps[step.id] = step; } } resolveStep(stepId) { return this.mappedSteps[stepId]; } attachSpy(fn) { this.spies.push(fn); return () => this.detachSpy(fn); } detachSpy(fn) { this.spies = this.spies.filter((spy) => spy !== fn); } addStep(step) { if (this.mappedSteps[step.id]) { throw new Error(`Duplicate step id "${step.id}"`); } this.steps.push(step); this.mappedSteps[step.id] = step; } emit(spy, event, eventOutput, flowOutput, ms) { runSpy(spy); for (const spy of this.spies) { runSpy(spy); } function runSpy(spy) { if (spy) { try { spy(event, eventOutput, flowOutput, ms); } catch (e) { console.error(e.message || 'Workflow spy uncought exception.', e.stack); } } } } validateSchema(schemaId, item, prefix) { const { stepId, steps } = this.normalizeWorkflowItem(item, prefix); if (typeof stepId === 'string') { if (!this.resolveStep(stepId)) error(stepId); } else if (steps) { for (const step of steps) { this.validateSchema(schemaId, step, prefix); } } function error(id) { throw new Error(`Workflow schema "${schemaId}" refers to an unknown step id "${id}".`); } } register(id, schema, prefix) { if (!this.schemas[id]) { for (const step of schema) { this.validateSchema(id, step, prefix); } this.schemas[id] = schema; if (prefix) { this.schemaPrefix[id] = prefix; } } else { throw new Error(`Workflow schema with id "${id}" already registered.`); } } start(schemaId, initialContext, input, spy) { const schema = this.schemas[schemaId]; if (!schema) { throw new Error(`Workflow schema id "${schemaId}" does not exist.`); } return this.loopInto('workflow', { schemaId, context: initialContext, schema, input, spy, }); } async callConditionFn(spy, event, fn, result) { let conditionResult = false; const now = new Date().getTime(); if (typeof fn === 'string') { conditionResult = await this.fnPool.call(fn, result.state.context); } else { conditionResult = await fn(result.state.context); } this.emit(spy, event, { fn, result: conditionResult }, result, new Date().getTime() - now); return conditionResult; } async loopInto(event, opts) { const prefix = this.schemaPrefix[opts.schemaId]; const schema = opts.schema; const level = opts.level || 0; const indexes = (opts.indexes = opts.indexes || []); const startIndex = indexes[level] || 0; let skipCondition = indexes.length > level + 1; indexes[level] = startIndex; let input = opts.input; let result = { state: { schemaId: opts.schemaId, context: opts.context, indexes }, finished: false, stepId: '', }; this.emit(opts.spy, event + '-start', event === 'subflow' ? '' : opts.schemaId, result); try { for (let i = startIndex; i < schema.length; i++) { indexes[level] = i; const item = this.normalizeWorkflowItem(schema[i], prefix); if (item.continueFn) { if (await this.callConditionFn(opts.spy, 'eval-continue-fn', item.continueFn, result)) { result.break = false; break; } } if (item.breakFn) { if (await this.callConditionFn(opts.spy, 'eval-break-fn', item.breakFn, result)) { result.break = true; break; } } if (!skipCondition && item.conditionFn) { if (!(await this.callConditionFn(opts.spy, 'eval-condition-fn', item.conditionFn, result))) continue; } skipCondition = false; if (typeof item.stepId === 'string') { result.stepId = item.stepId; const step = this.resolveStep(item.stepId); if (!step) { throw new Error(`Step "${item.stepId}" not found.`); } let mergedInput = input; if (typeof item.input !== 'undefined') { if (typeof input === 'undefined') { mergedInput = item.input; } else if (typeof item.input === 'object' && typeof input === 'object') { mergedInput = { ...item.input, ...input }; } } const now = new Date().getTime(); const stepResult = await step.handle(result.state.context, mergedInput); const ms = new Date().getTime() - now; if (stepResult && stepResult.inputRequired) { result.interrupt = true; result.inputRequired = stepResult.inputRequired; result.expires = stepResult.expires; result.errorList = stepResult.errorList; this.emit(opts.spy, 'step', item.stepId, result, ms); break; } if (stepResult && stepResult instanceof StepRetriableError) { retriableError(stepResult); this.emit(opts.spy, 'step', item.stepId, result, ms); break; } this.emit(opts.spy, 'step', item.stepId, result, ms); } else if (item.steps) { while (true) { if (item.whileFn) { if (!(await this.callConditionFn(opts.spy, 'eval-while-cond', item.whileFn, result))) break; } result = await this.loopInto('subflow', { schemaId: opts.schemaId, schema: item.steps, context: result.state.context, indexes, input, level: level + 1, spy: opts.spy, }); if (!item.whileFn || result.break || result.interrupt) break; } result.break = false; if (result.interrupt) break; } input = undefined; } } catch (e) { if (e instanceof StepRetriableError) { retriableError(e); } else { this.emit(opts.spy, 'error:', e.message || '', result); throw e; } } function retriableError(e) { result.interrupt = true; result.error = e.originalError; result.inputRequired = e.inputRequired; result.errorList = e.errorList; result.expires = e.expires; } if (result.interrupt) { if (level === 0) { const resume = (input) => this.resume(result.state, input, opts.spy); if (result.error) { result.retry = resume; } else { result.resume = resume; } } this.emit(opts.spy, event + '-interrupt', event === 'subflow' ? '' : opts.schemaId, result); return result; } if (level === 0) { result.finished = true; } this.emit(opts.spy, event + '-end', event === 'subflow' ? '' : opts.schemaId, result); indexes.pop(); return result; } prefixStepId(id, prefix) { if (!id) return id; return prefix && id[0] !== '/' ? [prefix, id].join('/') : id; } getItemStepId(item, prefix) { return this.prefixStepId(typeof item === 'string' ? item : item.id, prefix); } normalizeWorkflowItem(item, prefix) { const stepId = this.getItemStepId(item, prefix); const input = typeof item === 'object' ? item.input : undefined; const conditionFn = (typeof item === 'object' && item.condition); const continueFn = (typeof item === 'object' && item.continue); const breakFn = (typeof item === 'object' && item.break); const whileFn = (typeof item === 'object' && item.while); const steps = (typeof item === 'object' && item.steps); return { stepId, conditionFn, steps, continueFn, breakFn, input, whileFn, }; } resume(state, input, spy) { const schema = this.schemas[state.schemaId]; if (!schema) { throw new Error(`Workflow schema id "${state.schemaId}" does not exist.`); } return this.loopInto('resume', { schemaId: state.schemaId, context: state.context, indexes: state.indexes, schema, input, spy, }); } } const fnPool = new FtringsPool(); class Step { constructor(id, handler, globals = {}) { this.id = id; this.handler = handler; this.globals = globals; } getGlobals(ctx, input) { const globals = Object.assign({ StepRetriableError }, this.globals); globals.ctx = ctx; globals.input = input; return globals; } handle(ctx, input) { if (!this._handler) { if (typeof this.handler === 'string') { const code = this.handler; this._handler = (ctx, input) => fnPool.call(code, this.getGlobals(ctx, input)); } else { this._handler = this.handler; } } return this._handler(ctx, input); } } function createStep(id, opts) { let _handler; const step = new Step(id, (async (ctx, input) => { if (opts.input && typeof input === 'undefined') { return { inputRequired: opts.input }; } return await _handler(ctx, input); })); if (typeof opts.handler === 'string') { const code = opts.handler; _handler = (ctx, input) => fnPool.call(code, step.getGlobals(ctx, input)); } else { _handler = opts.handler; } return step; } export { Step, StepRetriableError, Workflow, createStep };