@prostojs/wf
Version:
Generic workflow framework
455 lines (450 loc) • 14.4 kB
JavaScript
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 };