UNPKG

workerpool

Version:

Offload tasks to a pool of workers on node.js and in the browser

381 lines (330 loc) 10 kB
/** * worker must be started as a child process or a web worker. * It listens for RPC messages from the parent process. */ var Transfer = require('./transfer'); /** * worker must handle async cleanup handlers. Use custom Promise implementation. */ var Promise = require('./Promise').Promise; /** * Special message sent by parent which causes the worker to terminate itself. * Not a "message object"; this string is the entire message. */ var TERMINATE_METHOD_ID = '__workerpool-terminate__'; /** * Special message by parent which causes a child process worker to perform cleaup * steps before determining if the child process worker should be terminated. */ var CLEANUP_METHOD_ID = '__workerpool-cleanup__'; // var nodeOSPlatform = require('./environment').nodeOSPlatform; var TIMEOUT_DEFAULT = 1_000; // create a worker API for sending and receiving messages which works both on // node.js and in the browser var worker = { exit: function() {} }; // api for in worker communication with parent process // works in both node.js and the browser var publicWorker = { /** * * @param {() => Promise<void>} listener */ addAbortListener: function(listener) { worker.abortListeners.push(listener); }, emit: worker.emit }; if (typeof self !== 'undefined' && typeof postMessage === 'function' && typeof addEventListener === 'function') { // worker in the browser worker.on = function (event, callback) { addEventListener(event, function (message) { callback(message.data); }) }; worker.send = function (message, transfer) { transfer ? postMessage(message, transfer) : postMessage (message); }; } else if (typeof process !== 'undefined') { // node.js var WorkerThreads; try { WorkerThreads = require('worker_threads'); } catch(error) { if (typeof error === 'object' && error !== null && error.code === 'MODULE_NOT_FOUND') { // no worker_threads, fallback to sub-process based workers } else { throw error; } } if (WorkerThreads && /* if there is a parentPort, we are in a WorkerThread */ WorkerThreads.parentPort !== null) { var parentPort = WorkerThreads.parentPort; worker.send = parentPort.postMessage.bind(parentPort); worker.on = parentPort.on.bind(parentPort); worker.exit = process.exit.bind(process); } else { worker.on = process.on.bind(process); // ignore transfer argument since it is not supported by process worker.send = function (message) { process.send(message); }; // register disconnect handler only for subprocess worker to exit when parent is killed unexpectedly worker.on('disconnect', function () { process.exit(1); }); worker.exit = process.exit.bind(process); } } else { throw new Error('Script must be executed as a worker'); } function convertError(error) { return Object.getOwnPropertyNames(error).reduce(function(product, name) { return Object.defineProperty(product, name, { value: error[name], enumerable: true }); }, {}); } /** * Test whether a value is a Promise via duck typing. * @param {*} value * @returns {boolean} Returns true when given value is an object * having functions `then` and `catch`. */ function isPromise(value) { return value && (typeof value.then === 'function') && (typeof value.catch === 'function'); } // functions available externally worker.methods = {}; /** * Execute a function with provided arguments * @param {String} fn Stringified function * @param {Array} [args] Function arguments * @returns {*} */ worker.methods.run = function run(fn, args) { var f = new Function('return (' + fn + ').apply(this, arguments);'); f.worker = publicWorker; return f.apply(f, args); }; /** * Get a list with methods available on this worker * @return {String[]} methods */ worker.methods.methods = function methods() { return Object.keys(worker.methods); }; /** * Custom handler for when the worker is terminated. */ worker.terminationHandler = undefined; worker.abortListenerTimeout = TIMEOUT_DEFAULT; /** * Abort handlers for resolving errors which may cause a timeout or cancellation * to occur from a worker context */ worker.abortListeners = []; /** * Cleanup and exit the worker. * @param {Number} code * @returns {Promise<void>} */ worker.terminateAndExit = function(code) { var _exit = function() { worker.exit(code); } if(!worker.terminationHandler) { return _exit(); } var result = worker.terminationHandler(code); if (isPromise(result)) { result.then(_exit, _exit); return result; } else { _exit(); return new Promise(function (_resolve, reject) { reject(new Error("Worker terminating")); }); } } /** * Called within the worker message handler to run abort handlers if registered to perform cleanup operations. * @param {Integer} [requestId] id of task which is currently executing in the worker * @return {Promise<void>} */ worker.cleanup = function(requestId) { if (!worker.abortListeners.length) { worker.send({ id: requestId, method: CLEANUP_METHOD_ID, error: convertError(new Error('Worker terminating')), }); // If there are no handlers registered, reject the promise with an error as we want the handler to be notified // that cleanup should begin and the handler should be GCed. return new Promise(function(resolve) { resolve(); }); } var _exit = function() { worker.exit(); } var _abort = function() { if (!worker.abortListeners.length) { worker.abortListeners = []; } } const promises = worker.abortListeners.map(listener => listener()); let timerId; const timeoutPromise = new Promise((_resolve, reject) => { timerId = setTimeout(function () { reject(new Error('Timeout occured waiting for abort handler, killing worker')); }, worker.abortListenerTimeout); }); // Once a promise settles we need to clear the timeout to prevet fulfulling the promise twice const settlePromise = Promise.all(promises).then(function() { clearTimeout(timerId); _abort(); }, function() { clearTimeout(timerId); _exit(); }); // Returns a promise which will result in one of the following cases // - Resolve once all handlers resolve // - Reject if one or more handlers exceed the 'abortListenerTimeout' interval // - Reject if one or more handlers reject // Upon one of the above cases a message will be sent to the handler with the result of the handler execution // which will either kill the worker if the result contains an error, or return Promise.all([ settlePromise, timeoutPromise ]).then(function() { worker.send({ id: requestId, method: CLEANUP_METHOD_ID, error: null, }); }, function(err) { worker.send({ id: requestId, method: CLEANUP_METHOD_ID, error: err ? convertError(err) : null, }); }); } var currentRequestId = null; worker.on('message', function (request) { if (request === TERMINATE_METHOD_ID) { return worker.terminateAndExit(0); } if (request.method === CLEANUP_METHOD_ID) { return worker.cleanup(request.id); } try { var method = worker.methods[request.method]; if (method) { currentRequestId = request.id; // execute the function var result = method.apply(method, request.params); if (isPromise(result)) { // promise returned, resolve this and then return result .then(function (result) { if (result instanceof Transfer) { worker.send({ id: request.id, result: result.message, error: null }, result.transfer); } else { worker.send({ id: request.id, result: result, error: null }); } currentRequestId = null; }) .catch(function (err) { worker.send({ id: request.id, result: null, error: convertError(err), }); currentRequestId = null; }); } else { // immediate result if (result instanceof Transfer) { worker.send({ id: request.id, result: result.message, error: null }, result.transfer); } else { worker.send({ id: request.id, result: result, error: null }); } currentRequestId = null; } } else { throw new Error('Unknown method "' + request.method + '"'); } } catch (err) { worker.send({ id: request.id, result: null, error: convertError(err) }); } }); /** * Register methods to the worker * @param {Object} [methods] * @param {import('./types.js').WorkerRegisterOptions} [options] */ worker.register = function (methods, options) { if (methods) { for (var name in methods) { if (methods.hasOwnProperty(name)) { worker.methods[name] = methods[name]; worker.methods[name].worker = publicWorker; } } } if (options) { worker.terminationHandler = options.onTerminate; // register listener timeout or default to 1 second worker.abortListenerTimeout = options.abortListenerTimeout || TIMEOUT_DEFAULT; } worker.send('ready'); }; worker.emit = function (payload) { if (currentRequestId) { if (payload instanceof Transfer) { worker.send({ id: currentRequestId, isEvent: true, payload: payload.message }, payload.transfer); return; } worker.send({ id: currentRequestId, isEvent: true, payload }); } }; if (typeof exports !== 'undefined') { exports.add = worker.register; exports.emit = worker.emit; }