UNPKG

@xylabs/threads

Version:

Web workers & worker threads as simple as a function call

449 lines (443 loc) 15.3 kB
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) { if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); // src/master/pool-node.ts import DebugLogger from "debug"; import { multicast, Observable, Subject } from "observable-fns"; // src/master/implementation.node.ts import { EventEmitter } from "node:events"; import { cpus } from "node:os"; import path from "node:path"; import { cwd } from "node:process"; import { Worker as NativeWorker } from "node:worker_threads"; var defaultPoolSize = cpus().length; function resolveScriptPath(scriptPath, baseURL) { const makeAbsolute = (filePath) => { return path.isAbsolute(filePath) ? filePath : path.join(baseURL ?? cwd(), filePath); }; const absolutePath = makeAbsolute(scriptPath); return absolutePath; } function initWorkerThreadsWorker() { let allWorkers = []; class Worker extends NativeWorker { mappedEventListeners; constructor(scriptPath, options) { const resolvedScriptPath = options && options.fromSource ? null : resolveScriptPath(scriptPath, (options ?? {})._baseURL); if (resolvedScriptPath) { super(resolvedScriptPath, options); } else { const sourceCode = scriptPath; super(sourceCode, { ...options, eval: true }); } this.mappedEventListeners = /* @__PURE__ */ new WeakMap(); allWorkers.push(this); } addEventListener(eventName, rawListener) { const listener = (message) => { rawListener({ data: message }); }; this.mappedEventListeners.set(rawListener, listener); this.on(eventName, listener); } removeEventListener(eventName, rawListener) { const listener = this.mappedEventListeners.get(rawListener) || rawListener; this.off(eventName, listener); } } const terminateWorkersAndMaster = () => { Promise.all(allWorkers.map((worker) => worker.terminate())).then( () => process.exit(0), () => process.exit(1) ); allWorkers = []; }; process.on("SIGINT", () => terminateWorkersAndMaster()); process.on("SIGTERM", () => terminateWorkersAndMaster()); class BlobWorker extends Worker { constructor(blob, options) { super(Buffer.from(blob).toString("utf-8"), { ...options, fromSource: true }); } static fromText(source, options) { return new Worker(source, { ...options, fromSource: true }); } } return { blob: BlobWorker, default: Worker }; } function initTinyWorker() { const TinyWorker = __require("tiny-worker"); let allWorkers = []; class Worker extends TinyWorker { emitter; constructor(scriptPath, options) { const resolvedScriptPath = options && options.fromSource ? null : process.platform === "win32" ? `file:///${resolveScriptPath(scriptPath).replaceAll("\\", "/")}` : resolveScriptPath(scriptPath); if (resolvedScriptPath) { super(resolvedScriptPath, [], { esm: true }); } else { const sourceCode = scriptPath; super(new Function(sourceCode), [], { esm: true }); } allWorkers.push(this); this.emitter = new EventEmitter(); this.onerror = (error) => this.emitter.emit("error", error); this.onmessage = (message) => this.emitter.emit("message", message); } addEventListener(eventName, listener) { this.emitter.addListener(eventName, listener); } removeEventListener(eventName, listener) { this.emitter.removeListener(eventName, listener); } terminate() { allWorkers = allWorkers.filter((worker) => worker !== this); return super.terminate(); } } const terminateWorkersAndMaster = () => { Promise.all(allWorkers.map((worker) => worker.terminate())).then( () => process.exit(0), () => process.exit(1) ); allWorkers = []; }; process.on("SIGINT", () => terminateWorkersAndMaster()); process.on("SIGTERM", () => terminateWorkersAndMaster()); class BlobWorker extends Worker { constructor(blob, options) { super(Buffer.from(blob).toString("utf-8"), { ...options, fromSource: true }); } static fromText(source, options) { return new Worker(source, { ...options, fromSource: true }); } } return { blob: BlobWorker, default: Worker }; } var implementation; var isTinyWorker; function selectWorkerImplementation() { try { isTinyWorker = false; return initWorkerThreadsWorker(); } catch (ex) { console.error(ex); console.debug("Node worker_threads not available. Trying to fall back to tiny-worker polyfill..."); isTinyWorker = true; return initTinyWorker(); } } function getWorkerImplementation() { if (!implementation) { implementation = selectWorkerImplementation(); } return implementation; } function isWorkerRuntime() { if (isTinyWorker) { return globalThis !== void 0 && self["postMessage"] ? true : false; } else { const isMainThread = typeof __non_webpack_require__ === "function" ? __non_webpack_require__("worker_threads").isMainThread : eval("require")("worker_threads").isMainThread; return !isMainThread; } } // src/master/pool-types.ts var PoolEventType = /* @__PURE__ */ ((PoolEventType2) => { PoolEventType2["initialized"] = "initialized"; PoolEventType2["taskCanceled"] = "taskCanceled"; PoolEventType2["taskCompleted"] = "taskCompleted"; PoolEventType2["taskFailed"] = "taskFailed"; PoolEventType2["taskQueued"] = "taskQueued"; PoolEventType2["taskQueueDrained"] = "taskQueueDrained"; PoolEventType2["taskStart"] = "taskStart"; PoolEventType2["terminated"] = "terminated"; return PoolEventType2; })(PoolEventType || {}); // src/symbols.ts var $errors = Symbol("thread.errors"); var $events = Symbol("thread.events"); var $terminate = Symbol("thread.terminate"); var $transferable = Symbol("thread.transferable"); var $worker = Symbol("thread.worker"); // src/master/thread.ts function fail(message) { throw new Error(message); } var Thread = { /** Return an observable that can be used to subscribe to all errors happening in the thread. */ errors(thread) { return thread[$errors] || fail("Error observable not found. Make sure to pass a thread instance as returned by the spawn() promise."); }, /** Return an observable that can be used to subscribe to internal events happening in the thread. Useful for debugging. */ events(thread) { return thread[$events] || fail("Events observable not found. Make sure to pass a thread instance as returned by the spawn() promise."); }, /** Terminate a thread. Remember to terminate every thread when you are done using it. */ terminate(thread) { return thread[$terminate](); } }; // src/master/pool-node.ts var nextPoolID = 1; function createArray(size) { const array = []; for (let index = 0; index < size; index++) { array.push(index); } return array; } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function flatMap(array, mapper) { return array.reduce((flattened, element) => [...flattened, ...mapper(element)], []); } function slugify(text) { return text.replaceAll(/\W/g, " ").trim().replaceAll(/\s+/g, "-"); } function spawnWorkers(spawnWorker, count) { return createArray(count).map( () => ({ init: spawnWorker(), runningTasks: [] }) ); } var WorkerPool = class { static EventType = PoolEventType; debug; eventObservable; options; workers; eventSubject = new Subject(); initErrors = []; isClosing = false; nextTaskID = 1; taskQueue = []; constructor(spawnWorker, optionsOrSize) { const options = typeof optionsOrSize === "number" ? { size: optionsOrSize } : optionsOrSize || {}; const { size = defaultPoolSize } = options; this.debug = DebugLogger(`threads:pool:${slugify(options.name || String(nextPoolID++))}`); this.options = options; this.workers = spawnWorkers(spawnWorker, size); this.eventObservable = multicast(Observable.from(this.eventSubject)); Promise.all(this.workers.map((worker) => worker.init)).then( () => this.eventSubject.next({ size: this.workers.length, type: "initialized" /* initialized */ }), (error) => { this.debug("Error while initializing pool worker:", error); this.eventSubject.error(error); this.initErrors.push(error); } ); } findIdlingWorker() { const { concurrency = 1 } = this.options; return this.workers.find((worker) => worker.runningTasks.length < concurrency); } async runPoolTask(worker, task) { const workerID = this.workers.indexOf(worker) + 1; this.debug(`Running task #${task.id} on worker #${workerID}...`); this.eventSubject.next({ taskID: task.id, type: "taskStart" /* taskStart */, workerID }); try { const returnValue = await task.run(await worker.init); this.debug(`Task #${task.id} completed successfully`); this.eventSubject.next({ returnValue, taskID: task.id, type: "taskCompleted" /* taskCompleted */, workerID }); } catch (ex) { const error = ex; this.debug(`Task #${task.id} failed`); this.eventSubject.next({ error, taskID: task.id, type: "taskFailed" /* taskFailed */, workerID }); } } run(worker, task) { const runPromise = (async () => { const removeTaskFromWorkersRunningTasks = () => { worker.runningTasks = worker.runningTasks.filter((someRunPromise) => someRunPromise !== runPromise); }; await delay(0); try { await this.runPoolTask(worker, task); } finally { removeTaskFromWorkersRunningTasks(); if (!this.isClosing) { this.scheduleWork(); } } })(); worker.runningTasks.push(runPromise); } scheduleWork() { this.debug("Attempt de-queueing a task in order to run it..."); const availableWorker = this.findIdlingWorker(); if (!availableWorker) return; const nextTask = this.taskQueue.shift(); if (!nextTask) { this.debug("Task queue is empty"); this.eventSubject.next({ type: "taskQueueDrained" /* taskQueueDrained */ }); return; } this.run(availableWorker, nextTask); } taskCompletion(taskID) { return new Promise((resolve, reject) => { const eventSubscription = this.events().subscribe((event) => { if (event.type === "taskCompleted" /* taskCompleted */ && event.taskID === taskID) { eventSubscription.unsubscribe(); resolve(event.returnValue); } else if (event.type === "taskFailed" /* taskFailed */ && event.taskID === taskID) { eventSubscription.unsubscribe(); reject(event.error); } else if (event.type === "terminated" /* terminated */) { eventSubscription.unsubscribe(); reject(new Error("Pool has been terminated before task was run.")); } }); }); } async settled(allowResolvingImmediately = false) { const getCurrentlyRunningTasks = () => flatMap(this.workers, (worker) => worker.runningTasks); const taskFailures = []; const failureSubscription = this.eventObservable.subscribe((event) => { if (event.type === "taskFailed" /* taskFailed */) { taskFailures.push(event.error); } }); if (this.initErrors.length > 0) { throw this.initErrors[0]; } if (allowResolvingImmediately && this.taskQueue.length === 0) { await Promise.allSettled(getCurrentlyRunningTasks()); return taskFailures; } await new Promise((resolve, reject) => { const subscription = this.eventObservable.subscribe({ error: reject, next(event) { if (event.type === "taskQueueDrained" /* taskQueueDrained */) { subscription.unsubscribe(); resolve(void 0); } } // make a pool-wide error reject the completed() result promise }); }); await Promise.allSettled(getCurrentlyRunningTasks()); failureSubscription.unsubscribe(); return taskFailures; } async completed(allowResolvingImmediately = false) { const settlementPromise = this.settled(allowResolvingImmediately); const earlyExitPromise = new Promise((resolve, reject) => { const subscription = this.eventObservable.subscribe({ error: reject, next(event) { if (event.type === "taskQueueDrained" /* taskQueueDrained */) { subscription.unsubscribe(); resolve(settlementPromise); } else if (event.type === "taskFailed" /* taskFailed */) { subscription.unsubscribe(); reject(event.error); } } // make a pool-wide error reject the completed() result promise }); }); const errors = await Promise.race([settlementPromise, earlyExitPromise]); if (errors.length > 0) { throw errors[0]; } } events() { return this.eventObservable; } queue(taskFunction) { const { maxQueuedJobs = Number.POSITIVE_INFINITY } = this.options; if (this.isClosing) { throw new Error("Cannot schedule pool tasks after terminate() has been called."); } if (this.initErrors.length > 0) { throw this.initErrors[0]; } const taskID = this.nextTaskID++; const taskCompletion = this.taskCompletion(taskID); taskCompletion.catch((error) => { this.debug(`Task #${taskID} errored:`, error); }); const task = { cancel: () => { if (!this.taskQueue.includes(task)) return; this.taskQueue = this.taskQueue.filter((someTask) => someTask !== task); this.eventSubject.next({ taskID: task.id, type: "taskCanceled" /* taskCanceled */ }); }, id: taskID, run: taskFunction, then: taskCompletion.then.bind(taskCompletion) }; if (this.taskQueue.length >= maxQueuedJobs) { throw new Error( "Maximum number of pool tasks queued. Refusing to queue another one.\nThis usually happens for one of two reasons: We are either at peak workload right now or some tasks just won't finish, thus blocking the pool." ); } this.debug(`Queueing task #${task.id}...`); this.taskQueue.push(task); this.eventSubject.next({ taskID: task.id, type: "taskQueued" /* taskQueued */ }); this.scheduleWork(); return task; } async terminate(force) { this.isClosing = true; if (!force) { await this.completed(true); } this.eventSubject.next({ remainingQueue: [...this.taskQueue], type: "terminated" /* terminated */ }); this.eventSubject.complete(); await Promise.all(this.workers.map(async (worker) => Thread.terminate(await worker.init))); } }; function PoolConstructor(spawnWorker, optionsOrSize) { return new WorkerPool(spawnWorker, optionsOrSize); } PoolConstructor.EventType = PoolEventType; var Pool = PoolConstructor; export { Pool, PoolEventType, Thread }; //# sourceMappingURL=pool-node.mjs.map