UNPKG

bpmn-engine

Version:

BPMN 2.0 execution engine. Open source javascript workflow engine.

634 lines (526 loc) 17.9 kB
import { createRequire } from 'node:module'; import { EventEmitter } from 'node:events'; import { fileURLToPath } from 'node:url'; import BpmnModdle from 'bpmn-moddle'; import * as Elements from 'bpmn-elements'; import { Broker } from 'smqp'; import serializer, { deserialize, TypeResolver } from 'moddle-context-serializer'; import DebugLogger from './Logger.js'; import JavaScripts from './JavaScripts.js'; import getOptionsAndCallback from './getOptionsAndCallback.js'; import ProcessOutputDataObject from './extensions/ProcessOutputDataObject.js'; const nodeRequire = createRequire(fileURLToPath(import.meta.url)); const { version: engineVersion } = nodeRequire('../package.json'); const kEngine = Symbol.for('engine'); const kEnvironment = Symbol.for('environment'); const kExecuting = Symbol.for('executing'); const kExecution = Symbol.for('execution'); const kLoadedDefinitions = Symbol.for('loaded definitions'); const kOnBrokerReturn = Symbol.for('onBrokerReturn'); const kPendingSources = Symbol.for('pending sources'); const kState = Symbol.for('state'); const kStopped = Symbol.for('stopped'); const kTypeResolver = Symbol.for('type resolver'); export default Engine; export { JavaScripts }; export function Engine(options) { if (!(this instanceof Engine)) return new Engine(options); EventEmitter.call(this); const opts = (this.options = { Logger: DebugLogger, scripts: new JavaScripts(options?.disableDummyScript), ...options, }); this.logger = opts.Logger('engine'); this[kTypeResolver] = TypeResolver( { ...Elements, ...opts.elements, }, opts.typeResolver || defaultTypeResolver ); this[kEnvironment] = new Elements.Environment(opts); const broker = (this.broker = new Broker(this)); broker.assertExchange('event', 'topic', { autoDelete: false }); this[kExecution] = null; this[kLoadedDefinitions] = null; const pendingSources = (this[kPendingSources] = new Set()); if (opts.source) pendingSources.add(this._serializeSource(opts.source)); if (opts.moddleContext) pendingSources.add(this._serializeModdleContext(opts.moddleContext)); if (opts.sourceContext) pendingSources.add(opts.sourceContext); } function defaultTypeResolver(elementTypes) { elementTypes['bpmn:DataObject'] = ProcessOutputDataObject; elementTypes['bpmn:DataStoreReference'] = ProcessOutputDataObject; } Engine.prototype = Object.create(EventEmitter.prototype); Object.defineProperties(Engine.prototype, { name: { get() { return this.options.name; }, set(value) { this.options.name = value; }, }, environment: { get() { return this[kEnvironment]; }, }, state: { get() { const execution = this.execution; if (execution) return execution.state; return 'idle'; }, }, stopped: { get() { const execution = this.execution; if (execution) return execution.stopped; return false; }, }, execution: { enumerable: true, get() { return this[kExecution]; }, }, activityStatus: { get() { const execution = this.execution; if (execution) return execution.activityStatus; return 'idle'; }, }, }); Engine.prototype.execute = async function execute(...args) { const [executeOptions, callback] = getOptionsAndCallback(...args); try { var definitions = await this._loadDefinitions(executeOptions); // eslint-disable-line no-var } catch (err) { if (callback) return callback(err); throw err; } const execution = (this[kExecution] = new Execution(this, definitions, this.options)); return execution._execute(executeOptions, callback); }; Engine.prototype.stop = function stop() { const execution = this.execution; if (!execution) return; return execution.stop(); }; Engine.prototype.recover = function recover(savedState, recoverOptions) { if (this[kExecution]?.isRunning) { throw new Error('cannot recover a running engine'); } if (!savedState) return this; let name = this.name; if (!name) name = this.name = savedState.name; this.logger.debug(`<${name}> recover`); if (recoverOptions) { this[kEnvironment] = this[kEnvironment].clone(recoverOptions); } if (savedState.environment) this[kEnvironment] = this[kEnvironment].recover(savedState.environment); if (!savedState.definitions) return this; const pendingSources = this[kPendingSources]; const preSources = [...pendingSources]; pendingSources.clear(); const typeResolver = this[kTypeResolver]; const loadedDefinitions = (this[kLoadedDefinitions] = savedState.definitions.map((dState) => { let source; if (dState.source) source = deserialize(JSON.parse(dState.source), typeResolver); else source = preSources.find((s) => s.id === dState.id); pendingSources.add(source); this.logger.debug(`<${name}> recover ${dState.type} <${dState.id}>`); const definition = this._loadDefinition(source); definition.recover(dState); return definition; })); this[kExecution] = new Execution(this, loadedDefinitions, {}, true); return this; }; Engine.prototype.resume = async function resume(...args) { const [resumeOptions, callback] = getOptionsAndCallback(...args); let execution = this.execution; if (!execution) { const definitions = await this.getDefinitions(); if (!definitions.length) { const err = new Error('nothing to resume'); if (callback) return callback(err); throw err; } execution = this[kExecution] = new Execution(this, definitions, this.options); } else if (execution.isRunning) { const err = new Error('cannot resume a running engine'); if (callback) return callback(err); throw err; } return execution._resume(resumeOptions, callback); }; Engine.prototype.addSource = function addSource(options) { if (!options?.sourceContext) return; const loadedDefinitions = this[kLoadedDefinitions]; if (loadedDefinitions) loadedDefinitions.splice(0); this[kPendingSources].add(options.sourceContext); }; Engine.prototype.getDefinitions = function getDefinitions(executeOptions) { const loadedDefinitions = this[kLoadedDefinitions]; if (loadedDefinitions?.length) return Promise.resolve(loadedDefinitions); return this._loadDefinitions(executeOptions); }; Engine.prototype.getDefinitionById = async function getDefinitionById(id) { return (await this.getDefinitions()).find((d) => d.id === id); }; Engine.prototype.getState = async function getState() { const execution = this.execution; if (execution) return execution.getState(); const definitions = await this.getDefinitions(); return new Execution(this, definitions, this.options).getState(); }; Engine.prototype.waitFor = function waitFor(eventName) { const self = this; return new Promise((resolve, reject) => { self.once(eventName, onEvent); self.once('error', onError); function onEvent(api) { self.removeListener('error', onError); resolve(api); } function onError(err) { self.removeListener(eventName, onEvent); reject(err); } }); }; Engine.prototype._loadDefinitions = async function loadDefinitions(executeOptions) { const runSources = await Promise.all(this[kPendingSources]); const loadedDefinitions = (this[kLoadedDefinitions] = runSources.map((source) => this._loadDefinition(source, executeOptions))); return loadedDefinitions; }; Engine.prototype._loadDefinition = function loadDefinition(serializedContext, executeOptions) { const environment = this.environment; const context = new Elements.Context( serializedContext, environment.clone({ listener: environment.options.listener, ...executeOptions, settings: { ...environment.settings, ...executeOptions?.settings, }, variables: { ...environment.variables, ...executeOptions?.variables, }, source: serializedContext, }) ); return new Elements.Definition(context); }; Engine.prototype._serializeSource = async function serializeSource(source) { const moddleContext = await this._getModdleContext(source); return this._serializeModdleContext(moddleContext); }; Engine.prototype._serializeModdleContext = function serializeModdleContext(moddleContext) { return serializer(moddleContext, this[kTypeResolver], this.options.extendFn); }; Engine.prototype._getModdleContext = function getModdleContext(source) { const bpmnModdle = new BpmnModdle(this.options.moddleOptions); return bpmnModdle.fromXML(Buffer.isBuffer(source) ? source.toString() : source.trim()); }; export function Execution(engine, definitions, options, isRecovered = false) { this.name = engine.name; this.options = options; this.definitions = definitions; this[kState] = 'idle'; this[kStopped] = isRecovered; this[kEnvironment] = engine.environment; this[kEngine] = engine; this[kExecuting] = new Set(); const onBrokerReturn = (this[kOnBrokerReturn] = this._onBrokerReturn.bind(this)); engine.broker.on('return', onBrokerReturn); } Object.defineProperties(Execution.prototype, { state: { get() { return this[kState]; }, }, stopped: { get() { return this[kStopped]; }, }, broker: { get() { return this[kEngine].broker; }, }, environment: { get() { return this[kEnvironment]; }, }, activityStatus: { get() { return this._getActivityStatus(); }, }, isRunning: { get() { for (const definition of this[kExecuting]) { if (definition.isRunning) return true; } return false; }, }, }); Execution.prototype._execute = function execute(executeOptions, callback) { this._setup(executeOptions); this[kStopped] = false; this._debug('execute'); this._addConsumerCallbacks(callback); const definitionExecutions = this.definitions.reduce((result, definition) => { if (!definition.getExecutableProcesses().length) return result; result.push(definition.run()); return result; }, []); if (!definitionExecutions.length) { const error = new Error('No executable processes'); if (!callback) return this[kEngine].emit('error', error); return callback(error); } return this; }; Execution.prototype._resume = function resume(resumeOptions, callback) { this._setup(resumeOptions); this[kStopped] = false; this._debug('resume'); this._addConsumerCallbacks(callback); this[kExecuting].clear(); this.definitions.forEach((definition) => definition.resume()); return this; }; Execution.prototype._addConsumerCallbacks = function addConsumerCallbacks(callback) { if (!callback) return; const broker = this.broker; const onBrokerReturn = this[kOnBrokerReturn]; broker.off('return', onBrokerReturn); clearConsumers(); broker.subscribeOnce( 'event', 'engine.stop', () => { clearConsumers(); return callback(null, this); }, { consumerTag: 'ctag-cb-stop' } ); broker.subscribeOnce( 'event', 'engine.end', () => { clearConsumers(); return callback(null, this); }, { consumerTag: 'ctag-cb-end' } ); broker.subscribeOnce( 'event', 'engine.error', (_, message) => { clearConsumers(); return callback(message.content); }, { consumerTag: 'ctag-cb-error' } ); return callback; function clearConsumers() { broker.cancel('ctag-cb-stop'); broker.cancel('ctag-cb-end'); broker.cancel('ctag-cb-error'); broker.on('return', onBrokerReturn); } }; Execution.prototype.stop = async function stop() { const engine = this[kEngine]; const prom = engine.waitFor('stop'); this[kStopped] = true; const timers = engine.environment.timers; timers.executing.forEach((ref) => timers.clearTimeout(ref)); const executing = new Set(this[kExecuting]); this[kExecuting].clear(); for (const definition of executing) definition.stop(); const result = await prom; this[kState] = 'stopped'; return result; }; Execution.prototype._setup = function setup(setupOptions) { const listener = setupOptions?.listener || this.options.listener; if (listener && typeof listener.emit !== 'function') throw new Error('listener.emit is not a function'); const onChildMessage = this._onChildMessage.bind(this); for (const definition of this.definitions) { if (listener) definition.environment.options.listener = listener; const { queueName } = definition.broker.subscribeTmp('event', 'definition.#', onChildMessage, { noAck: true, consumerTag: '_engine_definition', }); definition.broker.bindQueue(queueName, 'event', 'process.#'); definition.broker.bindQueue(queueName, 'event', 'activity.#'); definition.broker.bindQueue(queueName, 'event', 'flow.#'); } }; Execution.prototype._onChildMessage = function onChildMessage(routingKey, message, owner) { const { environment: ownerEnvironment } = owner; const listener = ownerEnvironment.options?.listener; this[kState] = 'running'; let newState; const elementApi = owner.getApi && owner.getApi(message); switch (routingKey) { case 'definition.resume': case 'definition.enter': { this[kExecuting].add(owner); break; } case 'definition.stop': { this._teardownDefinition(owner); if (this.isRunning) break; newState = 'stopped'; this[kStopped] = true; break; } case 'definition.leave': this._teardownDefinition(owner); if (this.isRunning) break; newState = 'idle'; break; case 'definition.error': { this._saveOutput(owner.environment.output); this._teardownDefinition(owner); newState = 'error'; break; } case 'activity.wait': { if (listener) listener.emit('wait', owner.getApi(message), this); break; } case 'process.end': { if (message.content.inbound) break; this._saveOutput(message.content.output); break; } } if (listener) listener.emit(routingKey, elementApi, this); const broker = this.broker; broker.publish('event', routingKey, { ...message.content }, { ...message.properties, mandatory: false }); if (!newState) return; this[kState] = newState; switch (newState) { case 'stopped': this._debug('stopped'); return this._complete('stop', {}, { type: 'stop' }); case 'idle': this._debug('completed'); return this._complete('end', {}, { type: 'end' }); case 'error': this._debug('error'); return this._complete('error', message.content.error, { type: 'error', mandatory: true }); } }; Execution.prototype._complete = function complete(eventType, content, messageProperties) { const timers = this.environment.timers; timers.executing.slice().forEach((ref) => timers.clearTimeout(ref)); this.broker.publish('event', 'engine.' + eventType, content, messageProperties); return eventType !== 'error' && this[kEngine].emit(eventType, this); }; Execution.prototype._teardownDefinition = function teardownDefinition(definition) { this[kExecuting].delete(definition); definition.broker.cancel('_engine_definition'); }; Execution.prototype._saveOutput = function saveOutput(output) { if (!output || typeof output !== 'object') return; const environmentOutput = this.environment.output; for (const key in output) { if (key === 'data') { const data = (environmentOutput.data = environmentOutput.data || {}); environmentOutput.data = { ...data, ...output.data }; } else { environmentOutput[key] = output[key]; } } }; Execution.prototype.getState = function getState() { const definitions = []; for (const definition of this.definitions) { definitions.push({ ...definition.getState(), source: definition.environment.options.source.serialize(), }); } return { name: this[kEngine].name, state: this.state, stopped: this.stopped, engineVersion, environment: this.environment.getState(), definitions, }; }; Execution.prototype.getActivityById = function getActivityById(activityId) { for (const definition of this.definitions) { const activity = definition.getActivityById(activityId); if (activity) return activity; } }; Execution.prototype.getPostponed = function getPostponed() { const definitions = this.stopped ? this.definitions : this[kExecuting]; let result = []; for (const definition of definitions) { result = result.concat(definition.getPostponed()); } return result; }; Execution.prototype.signal = function signal(payload, signalOptions) { for (const definition of this[kExecuting]) { if (signalOptions?.ignoreSameDefinition && payload?.parent?.id === definition.id) continue; definition.signal(payload); } }; Execution.prototype.cancelActivity = function cancelActivity(payload) { for (const definition of this[kExecuting]) { definition.cancelActivity(payload); } }; Execution.prototype.waitFor = function waitFor(...args) { return this[kEngine].waitFor(...args); }; Execution.prototype._onBrokerReturn = function onBrokerReturn(message) { if (message.properties.type === 'error') { this[kEngine].emit('error', message.content); } }; Execution.prototype._debug = function debug(msg) { this[kEngine].logger.debug(`<${this.name}> ${msg}`); }; Execution.prototype._getActivityStatus = function getActivityStatus() { let status = 'idle'; const executing = this[kExecuting]; if (!executing.size) return status; for (const definition of executing) { const bpStatus = definition.activityStatus; switch (definition.activityStatus) { case 'executing': return bpStatus; case 'timer': status = bpStatus; break; case 'wait': if (status === 'idle') status = bpStatus; break; } } return status; };