UNPKG

@knighttower/utility

Version:

UtilityJs is a utility library that provides a collection of utility functions for various tasks. The library is designed to be easy to use and covers the most common use cases.

1,004 lines (905 loc) 34.5 kB
'use strict'; class EventBus { constructor() { // cleanup this.listeners = {}; } // creates an event that can be triggered any number of times /** * creates an event that can be triggered any number of times * @method on * @param {string} eventName - The name of the event * @param {function} callback - The callback to execute * @return {void} * @example EventBus.on('event.name', function() { console.log('event.name was triggered') }) */ on(eventName, callback) { this.registerListener(eventName, callback); } /** * creates an event that can be triggered only once. If it is emitted twice, the callback will only be executed once! * @method once * @param {string} eventName - The name of the event * @param {function} callback - The callback to execute * @return {void} * @example EventBus.once('event.name', function() { console.log('event.name was triggered only once') }) */ once(eventName, callback) { this.registerListener(eventName, callback, 1); } /** * reates an event that can be triggered only a number of times. If it is emitted more than that, the callback will not be be executed anymore! * @method exactly * @param {string} eventName - The name of the event * @return {void} * @example EventBus.exactly('event.name', function() { console.log('event.name was triggered 3 times') }, 3) */ exactly(eventName, callback, capacity) { this.registerListener(eventName, callback, capacity); } /** * kill an event with all it's callbacks * @method off * @param {string} eventName - The name of the event * @return {void} * @example EventBus.off('event.name') */ off(eventName) { delete this.listeners[eventName]; } /** * removes the given callback for the given event * @method detach * @param {string} eventName - The name of the event * @param {function} callback - The callback to remove * @return {void|boolean} - Returns true if the event was found and removed, void otherwise * @example EventBus.detach('event.name', callback) */ detach(eventName, callback) { const listenersRecords = this.listeners[eventName] || []; const filteredListeners = listenersRecords.filter(function (value) { return value.callback !== callback; }); if (eventName in this.listeners) { this.listeners[eventName] = filteredListeners; return true; // Event was found and removed } return false; // Event was not found } /** * emits an event with the given name and arguments * @param {string} eventName - The name of the event * @param {any} args - The arguments to pass to the callback * @return {void} * @use {__context: this|Instance} to pass the context to the callback * @example EventBus.emit('event.name', arg1, arg2, arg3) * @example EventBus.emit('event.name', arg1, arg2, arg3, {__context: YourInstance}) */ emit(eventName, ...args) { let queueListeners = []; let matches = null; const allArgs = this.extractContextFromArgs(args); const context = allArgs[0]; args = allArgs[1]; // name exact match if (this.hasListener(eventName)) { queueListeners = this.listeners[eventName]; } else { // ----------------------------------------- // Wildcard support if (eventName.includes('*')) { // case 1, if the incoming string has * or ** in it // Matches the emit 'eventName' to the registered 'on' this.listeners matches = this.patternSearch(eventName, Object.keys(this.listeners)); if (matches.length > 0) { matches.forEach((match) => { queueListeners = queueListeners.concat(this.listeners[match]); }); } } else { // case 2, if the incoming string does not have * or ** in it // get the patterns from the this.listeners (on method) and match them to the emit name for (const key in this.listeners) { if (key.includes('*')) { matches = this.patternSearch(key, [eventName]); if (matches) { queueListeners = queueListeners.concat(this.listeners[key]); } } } } } queueListeners.forEach((listener, k) => { let callback = listener.callback; if (context) { callback = callback.bind(context); } if (listener.triggerCapacity !== undefined) { listener.triggerCapacity--; queueListeners[k].triggerCapacity = listener.triggerCapacity; } if (this.checkToRemoveListener(listener)) { this.listeners[eventName].splice(k, 1); } callback(...args); }); } /** * Search for a pattern in a list of strings * @method patternSearch * @private * @param {string} pattern - The pattern to search for * @param {string[]} list - The list of strings to search in * @return {string[]|null} - Returns a list of strings that match the pattern, or null if no match is found * @example patternSearch('name.*', ['name.a', 'name.b', 'name.c']) // returns ['name.a', 'name.b', 'name.c'] */ patternSearch(pattern, list) { let filteredList = []; // console.log('__testLogHere__', pattern, this.setWildCardString(pattern)); const regex = new RegExp(this.setWildCardString(pattern), 'g'); filteredList = list.filter((item) => regex.test(item)); return filteredList.length === 0 ? null : filteredList; } setWildCardString(string) { let regexStr = string.replace(/([.+?^${}()|\[\]\/\\])/g, '\\$&'); // escape all regex special chars regexStr = regexStr.replace(/\*/g, '(.*?)'); return `^${regexStr}`; } /** * Extract the context from the arguments * @method extractContextFromArgs * @private * @param {any[]} args - The arguments to extract the context from * @return {any[]} - Returns an array with the context as the first element and the arguments as the second element */ extractContextFromArgs(args) { let context = null; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg && typeof arg === 'object' && arg.hasOwnProperty('__context')) { context = arg.__context; args.splice(i, 1); break; } } return [context, args]; } registerListener(eventName, callback, triggerCapacity) { if (!this.hasListener(eventName)) { this.listeners[eventName] = []; } this.listeners[eventName].push({ callback, triggerCapacity }); } checkToRemoveListener(eventInformation) { if (eventInformation.triggerCapacity !== undefined) { return eventInformation.triggerCapacity <= 0; } return false; } hasListener(eventName) { return eventName in this.listeners; } } // // ----------------------------------------- // /** // * @knighttower // * @url knighttower.io // * @git https://github.com/knighttower/ // */ // // ----------------------------------------- /** * Make sure the the item is an array or convert it to an array * @function makeArray * @param {String|Array} item * @return array * @example makeArray('test') // ['test'] */ const makeArray = (item) => (Array.isArray(item) ? item : [item]); const uuid = (max = 20) => { const rnd = () => Math.random().toString(36).substring(2, 15); max = max || 40; var str = ''; for (var i = 0; i < max / 3 + 1; i++) { str += rnd(); } return str.substring(0, max); }; /** * Generate unique ids * @function getDynamicId * @memberof utility * @return string Format kn__000000__000 */ function getDynamicId() { return 'id__' + uuid(8) + '__' + new Date().getTime(); } /** * Check the type of a variable, and get the correct type for it. It also accepts simple comparisons * For more advance type checking see https://github.com/knighttower/JsTypeCheck * @param {any} input - The variable to check * @param {string} test - The types to check against, piped string * @return {string|boolean} - The type of the variable * @example typeOf('hello', 'string') // returns true * @example typeOf('hello', 'number') // returns false * @example typeOf('hello', 'string') // returns true * @example typeOf('hello') // returns 'string' * @example typeOf(123, 'number|int') // returns 'number' * @example typeOf({}) // returns 'object' */ function typeOf(input, test) { // Special case for null since it can be treated as an object if (input === null) { if (test) { return test === null || test === 'null' ? true : false; } return 'null'; } let inputType; switch (typeof input) { case 'number': case 'string': case 'boolean': case 'undefined': case 'bigint': case 'symbol': case 'function': inputType = typeof input; break; case 'object': inputType = Array.isArray(input) ? 'array' : 'object'; break; default: inputType = 'unknown'; } if (test) { if (test.includes('|')) { for (let type of test.split('|')) { if (inputType === type) { return type; } } return false; } return test === inputType; } return inputType; } // // utility; { // convertToBool, // currencyToDecimal, // convertToNumber, // dateFormat, // decimalToCurrency, // emptyOrValue, // extend, // formatPhoneNumber, // getDynamicId, // getGoogleMapsAddress, // getRandomId, // includes, // isEmpty, // from https://moderndash.io/ // isNumber, // instanceOf, // openGoogleMapsAddress, // toCurrency, // toDollarString, // typeOf, // validateEmail, // validatePhone, // makeArray, // uuid, // uniqueId, // }; // @resources: look at the workerpool library for more advanced promise/worker handling // https://github.com/josdejong/workerpool?tab=readme-ov-file // ========================================= // --> promiseQueue // -------------------------- /** * @class promiseQueue * Class to manage a queue of promises, executing them sequentially with status tracking for each promise. * @extends EventBus * @methods * add: Adds a promise to the queue and starts the queue processing if not already started. * clear: Clears the promise queue. * status: Returns the current status of all promises in the queue. * @returns {Object} An instance of the promiseQueue class. * @example * const queue = promiseQueue(); * queue.add(fetch('https://jsonplaceholder.typicode.com/todos/1')); * queue.add(fetch('https://jsonplaceholder.typicode.com/todos/2')); * queue.status(); // 'in-progress' * queue.on('completed', () => {}); */ const promiseQueue = () => { const stats = { completed: 0, rejected: 0, pending: 0, total: 0, errors: '', promises: [], }; return new (class extends EventBus { constructor() { super(); this.queue = []; this.inProgress = false; this._timer = null; this._stats = { ...stats }; } /** * Adds a promise to the queue and starts the queue processing if not already started. * @param {Promise} promiseFunction A function that returns a promise. */ add(promise) { const isValidPromise = (() => { if (promise instanceof Promise) { return true; } if (Array.isArray(promise)) { return promise.length > 0 && promise.every((item) => item instanceof Promise); } return false; })(); if (!isValidPromise) { if (typeOf(promise, 'function')) { promise = new Promise(async (resolve) => { const fx = await promise(); return resolve(fx); }); } else { console.error('---> Invalid promise added to the Queue:', promise.toString()); return this.emit('fail', promise.toString()); } } makeArray(promise).forEach((promiseFunction) => { this._stats.total++; this._stats.pending++; this.queue.push({ promiseFunction, response: null, status: 'pending', // 'pending', 'fulfilled', or 'rejected' error: null, }); }); if (!this.inProgress) { this._next(); } this._setTimer(); } /** * Clears the promise queue. */ clear() { this._timer && clearInterval(this._timer); this._timer = null; this.queue = []; this.inProgress = false; this._stats = { ...stats }; return this; } _setTimer() { if (this._timer) { clearInterval(this._timer); } this._timer = setInterval(() => { if (this.status() === 'done') { clearInterval(this._timer); this._timer = null; this.emit('completed', this._stats); this.emit('done', this._stats); } }, 10); } /** * Processes the next promise in the queue, if any. * @private */ _next() { if (this.queue.length === 0) { this.inProgress = false; return; } this.inProgress = true; // this always grabs the first promise in the queue and then removes it after processing const { promiseFunction } = this.queue[0]; promiseFunction .then((response) => { this.queue[0].status = 'fulfilled'; this.queue[0].response = response; this._stats.completed++; }) .catch((error) => { this._stats.errors += error + '\n'; this.queue[0].status = 'rejected'; this._stats.rejected++; }) .finally(() => { this._stats.promises.push(this.queue[0]); this._stats.pending--; this.queue.shift(); // Remove the processed promise from the queue this._next(); // Process the next promise }); } stats() { return this._stats; } /** * Returns the current status of all promises in the queue. * @returns {Array<Object>} An array of objects with the status of each promise. */ status() { return this.queue.length === 0 ? 'done' : 'in-progress'; } })(); }; // ========================================= // --> doPoll // -------------------------- /** * Creates a poll function that continuously calls a given function until it returns true or a promise resolves. * @param {Function} fn - The function to be polled. It can return a promise or a boolean. * @param {Object} options - Configuration options for polling. * @param {number} [options.interval=200] - The interval in milliseconds between each poll. * @param {number} [options.timeout=1000] - The maximum time in milliseconds to continue polling. * @returns {Object} { promise, stop } - An object containing the polling promise and a cancel function. * @fails returns 'failed' if the polling times out or is cancelled. * @options: {} * - interval: The interval in milliseconds between each poll. * - timeout: The maximum time in milliseconds to continue polling. * @example * const { promise, stop } = doPoll(() => { * // Polling logic here * return true; // or return a promise * } */ const doPoll = (fn, options = {}) => { const isThenable = (v) => v != null && (typeof v === 'object' || typeof v === 'function') && typeof v.then === 'function'; if (!(typeof fn === 'function' || isThenable(fn))) { throw new Error('doPoll: The first argument must be a function or Promise.'); } const { msg, interval = 200, timeout = 10000, timeoutMsg = msg ?? null, signal } = options; const tickMs = Number.isFinite(interval) && interval > 0 ? interval : 200; const maxMs = Number.isFinite(timeout) && timeout > 0 ? timeout : 10000; const fnIsThenable = isThenable(fn); let timeoutId, intervalId; let resolvePromise, rejectPromise; let stopped = false; let running = false; let settled = false; const clearTimers = () => { stopped = true; if (timeoutId) clearTimeout(timeoutId); if (intervalId) clearInterval(intervalId); if (signal) signal.removeEventListener?.('abort', onAbort); }; const done = (val) => { if (settled) return; settled = true; clearTimers(); resolvePromise(val); }; const settleReject = (reason) => { if (settled) return; settled = true; clearTimers(); rejectPromise(reason); }; const stop = (reason) => { if (reason === '__TIMEOUT__' && typeOf(timeoutMsg, 'string')) { console.info(timeoutMsg); return settleReject(timeoutMsg); } return settleReject(reason); }; const handleValue = (val) => { // resolve on any boolean (including false) or any truthy value if (typeOf(val, 'boolean') || val) done(val); }; const onAbort = () => stop(signal?.reason ?? 'aborted'); const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; const poll = () => { if (stopped || running || settled) return; try { const fx = fnIsThenable ? fn : fn(done, stop); if (isThenable(fx)) { running = true; fx.then((val) => { running = false; handleValue(val); }).catch((err) => { running = false; if (!stopped) stop(err); }); } else { handleValue(fx); } } catch (err) { if (!stopped) stop(err); } }; if (fnIsThenable) { poll(); // single-shot for passed thenable } else { intervalId = setInterval(poll, tickMs); poll(); // initial tick } timeoutId = setTimeout(() => { if (!stopped) stop('__TIMEOUT__'); }, maxMs); if (signal?.aborted) onAbort(); else if (signal) signal.addEventListener?.('abort', onAbort, { once: true }); }); return { promise, stop }; }; // ========================================= // --> promisePool // -------------------------- /** * @class PromisePool * @description Class to manage a pool of promises with status tracking and concurrency control. * @methods * add: Adds a promise or array of promises to the pool and sets up handling for resolution. * status: Returns the overall status of the promise pool. * isDone: Returns true if the pool is done processing all promises. * results: Gets the results of the promise pool. * stats: Gets the results of the promise pool. * clear: Clears all promises from the pool. * isEmpty: Returns true if the pool has no promises. * @param {Number} maxConcurrency Maximum number of concurrent promises (default: 10) * @returns {Object} An instance of the PromisePool class. * @example * const pool = promisePool(5); * pool.add(fetch('https://jsonplaceholder.typicode.com/todos/1')); * pool.add(fetch('https://jsonplaceholder.typicode.com/todos/2')); * pool.status(); // 'in-progress' * pool.isDone(); // use this to check if the pool has completed its cycle * pool.on('completed', (stats) => {}); // 'completed' is emitted only if there are any promises * pool.on('done', (stats) => {}); // 'done' is emitted whether or not there are any promises * pool.on('rejected', (rejectedPromises, stats) => {}); * pool.on('stats', (stats) => {}); */ const promisePool = (maxConcurrency = 10) => { let _status = 'not-started'; // 'not-started', 'in-progress', or 'done' const promises = {}; const rejectedPromises = []; const queue = []; let activeCount = 0; let totalAdded = 0; let completedCount = 0; let rejectedCount = 0; return new (class extends EventBus { constructor() { super(); } /** * Clears all promises from the pool and resets state. */ clear() { _status = 'done'; totalAdded = 0; completedCount = 0; rejectedCount = 0; activeCount = 0; rejectedPromises.length = 0; queue.length = 0; Object.keys(promises).forEach((key) => { if (promises[key].rejecter) { promises[key].rejecter('Promise pool cleared.'); } delete promises[key]; }); } /** * Adds a promise or array of promises to the pool and sets up handling for resolution. * @param {Promise|Array<Promise>|Function|Array<Function>} _promises The promise(s) or function(s) to add to the pool. */ add(_promises) { const $this = this; if (!_promises) { $this._updateStatus(); return; } if (_status === 'not-started') { _status = 'in-progress'; } const promiseCollection = makeArray(_promises); promiseCollection.forEach((promise) => { let originalPromise = promise; let promiseFunction; if (!(promise instanceof Promise)) { if (typeof promise === 'function') { promiseFunction = promise; promise = null; // Will be created when dequeued } else { console.info('---> Invalid promise added to the pool.'); rejectedPromises.push(promise.toString()); rejectedCount++; return; } } else { promiseFunction = () => promise; } totalAdded++; const promiseId = getDynamicId(); promises[promiseId] = { status: 'in-progress', promise: originalPromise, promiseFunction: promiseFunction, response: null, error: null, resolver: null, rejecter: null, }; queue.push(promiseId); }); this._processQueue(); } /** * Processes queued promises up to the concurrency limit. * @private */ _processQueue() { const $this = this; while (activeCount < maxConcurrency && queue.length > 0) { const promiseId = queue.shift(); const promiseData = promises[promiseId]; if (!promiseData) continue; activeCount++; $this._updateStatus(); new Promise((resolve, reject) => { promiseData.resolver = (response) => { if (promiseData.status === 'rejected') { return; } promiseData.status = 'completed'; promiseData.response = response; completedCount++; resolve(response); }; promiseData.rejecter = (error) => { promiseData.status = 'rejected'; promiseData.error = error; rejectedPromises.push(error); rejectedCount++; reject(error); }; // Execute the promise function let promise; try { promise = promiseData.promiseFunction(); if (!(promise instanceof Promise)) { promise = Promise.resolve(promise); } } catch (error) { promise = Promise.reject(error); } promise .then((response) => { promises[promiseId]?.resolver(response); }) .catch((error) => { promises[promiseId]?.rejecter(error); }); }) .catch(() => { // Error already handled by rejecter }) .finally(() => { activeCount--; $this._updateStatus(); // Continue processing queue if (queue.length > 0) { $this._processQueue(); } else if (activeCount === 0) { $this._checkCompletion(); } }); } } /** * Checks if all promises are complete and emits appropriate events. * @private */ _checkCompletion() { if (_status === 'done') { return; } const allDone = activeCount === 0 && queue.length === 0; if (allDone) { const instances = Object.values(promises); const allCompletedOrRejected = instances.every( (promise) => promise.status === 'completed' || promise.status === 'rejected' ); if (allCompletedOrRejected || totalAdded === 0) { _status = 'done'; const stats = this._getStats(); // Emits 'done' whether or not there are any promises this.emit('done', stats); // If there are any promises, emit 'completed' and 'rejected' if (totalAdded > 0) { this.emit('completed', stats); if (rejectedPromises.length > 0) { this.emit('rejected', rejectedPromises, stats); } } } } } /** * Returns the overall status of the promise pool. * @returns {String} The current status of the pool ('not-started', 'in-progress', or 'done'). */ status() { return _status; } /** * Checks if the pool has finished processing all promises. * @returns {Boolean} True if all promises are resolved or rejected. */ isDone() { return _status === 'done'; } /** * Checks if the pool is empty (no promises added). * @returns {Boolean} True if the pool has no promises. */ isEmpty() { return totalAdded === 0; } /** * Gets the results of the promise pool. * @returns {Object} The results of the promise pool. */ results() { return this._getStats(); } /** * Gets the statistics of the promise pool. * @returns {Object} The statistics of the promise pool. */ stats() { return this.results(); } /** * Generates current statistics for the promise pool. * @private * @returns {Object} Statistics object. */ _getStats() { Object.values(promises); return { completed: completedCount, rejected: rejectedCount, pending: queue.length + activeCount, total: totalAdded, errors: rejectedPromises.join('\n'), promises, }; } /** * Updates the status and emits stats event. * @private */ _updateStatus() { const stats = this._getStats(); this.emit('stats', stats); } })(); }; // ========================================= // --> doTimeout // -------------------------- const doTimeoutStore = {}; /** * Initialize, cancel, or force execution of a callback after a delay using a unique ID. * If delay and callback are specified, a timeout is initialized. The callback will execute * asynchronously after the delay. If an ID is specified, this timeout will override and * cancel any existing timeout with the same ID. Any additional arguments will be passed * into the callback when it is executed. * If the callback returns true, the timeout loop will execute again, after the delay, * creating a polling loop until the callback returns a non-true value. * * @param {string|number} idOrDelay - A unique identifier for this timeout or the delay if no ID is given. * @param {number|Function} delayOrCallback - A zero-or-greater delay in milliseconds or the callback function if no ID is given. * @param {Function} [callback] - A function to be executed after delay milliseconds. * @param {...any} args - Additional arguments to pass to the callback. * @returns {boolean | undefined} - If the callback is yet to be executed, true is returned, otherwise undefined. * @usage * // Initialize a timeout with an ID * doTimeout('myTimeout', 1000, () => console.log('Hello, world!')); * // Initialize a timeout without an ID * doTimeout(1000, () => console.log('Hello, world!')); * // Cancel a timeout with an ID * doTimeout('myTimeout'); * // Force execution of a timeout with an ID * doTimeout('myTimeout', 0); * // Initialize a polling loop * doTimeout(100, function() { * if (someCondition()) { * console.log('Condition met, stopping the polling.'); * return false; // Stop polling when some condition is true * } * console.log('Condition not met, continue polling.'); * return true; // Continue polling by returning true * }); */ function doTimeout(idOrDelay, delayOrCallback, callback, ...args) { let id, delay; if (typeof idOrDelay === 'string' && typeof delayOrCallback === 'number') { id = idOrDelay; delay = delayOrCallback; } else if (typeof idOrDelay === 'number' && typeof delayOrCallback === 'function') { delay = idOrDelay; callback = delayOrCallback; } else if (!delayOrCallback && typeof idOrDelay === 'string') { id = idOrDelay; } else { throw new Error('Invalid parameters'); } // Namespace for timeout IDs to prevent conflicts const namespace = '_doTimeout_'; const fullId = id ? namespace + id : null; // Clear any existing timeout with this ID if (fullId && fullId in doTimeoutStore) { clearTimeout(doTimeoutStore[fullId]); delete doTimeoutStore[fullId]; } // Clean up function to remove the timeout ID function cleanup() { if (fullId && fullId in doTimeoutStore) { delete doTimeoutStore[fullId]; } } // Setup the actual timeout function function setupTimeout() { doTimeoutStore[fullId] = setTimeout(() => { if (callback(...args) === true) { setupTimeout(); } else { cleanup(); } }, delay); } // If callback is a function and delay is defined, set up the timeout if (typeof callback === 'function' && typeof delay === 'number') { setupTimeout(); return true; } // Cancel the timeout without executing the callback if (id && delay === undefined) { cleanup(); } } // ========================================= // --> doAsync // -------------------------- /** * Wraps a function that might be synchronous or asynchronous into a standardized asynchronous workflow. * Helps to mitigate the need to know if a function is synchronous or asynchronous. * @param {Function} fn - A function that may be synchronous or return a Promise. * @param {...any} args - Arguments to be passed to the function. * @returns {Promise<any>} - A Promise resolving with the function's return value or resolving with `true` if the function returns void or rejecting with any thrown error. */ function doAsync(fn, ...args) { return new Promise((resolve, reject) => { try { Promise.resolve(fn(...args)) .then((result) => resolve(result)) .catch((error) => reject(error)); } catch (error) { reject(error); } }); } exports.doAsync = doAsync; exports.doPoll = doPoll; exports.doTimeout = doTimeout; exports.promisePool = promisePool; exports.promiseQueue = promiseQueue;