UNPKG

@uppy/utils

Version:

Shared utility functions for Uppy Core and plugins maintained by the Uppy team.

201 lines (200 loc) 6.39 kB
/** * A concurrent task queue with FIFO ordering. * * Tasks are functions that receive an AbortSignal and return a Promise. * The queue manages concurrency and processes tasks in insertion order. * * @example * ```ts * const queue = new TaskQueue({ concurrency: 3 }) * * const promise = queue.add(async (signal) => { * const response = await fetch(url, { signal }) * return response.json() * }) * * // To abort: * promise.abort() * ``` */ export class TaskQueue { #queue = []; #running = 0; #concurrency; #paused = false; constructor(options) { const limit = options?.concurrency; this.#concurrency = typeof limit !== 'number' || limit === 0 ? Infinity : limit; } /** * Add a task to the queue. * * @param task - Function receiving AbortSignal, returns Promise * @returns AbortablePromise that resolves with task result */ add(task) { const controller = new AbortController(); let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); const queuedTask = { run: () => task(controller.signal), resolve, reject, controller, }; // Handle abort while queued controller.signal.addEventListener('abort', () => { const index = this.#queue.indexOf(queuedTask); if (index !== -1) { this.#queue.splice(index, 1); reject(controller.signal.reason ?? new DOMException('Aborted', 'AbortError')); } }, { once: true }); promise.abort = (reason) => { controller.abort(reason ?? new DOMException('Aborted', 'AbortError')); }; // Legacy compatibility: abortOn method promise.abortOn = (signal) => { if (signal) { const onAbort = () => promise.abort(signal.reason); signal.addEventListener('abort', onAbort, { once: true }); promise.then(() => signal.removeEventListener('abort', onAbort), () => signal.removeEventListener('abort', onAbort)); } return promise; }; // Run immediately or queue if (!this.#paused && this.#running < this.#concurrency) { this.#execute(queuedTask); } else { this.#queue.push(queuedTask); } return promise; } #execute(task) { this.#running++; // Check if already aborted before starting if (task.controller.signal.aborted) { this.#running--; task.reject(task.controller.signal.reason ?? new DOMException('Aborted', 'AbortError')); this.#advance(); return; } let runPromise; try { runPromise = task.run(); } catch (error) { runPromise = Promise.reject(error); } runPromise .then((result) => { if (task.controller.signal.aborted) { task.reject(task.controller.signal.reason ?? new DOMException('Aborted', 'AbortError')); } else { task.resolve(result); } }, (error) => { task.reject(error); }) .finally(() => { this.#running--; this.#advance(); }); } #advance() { // Use microtask to allow batch aborts without starting new tasks queueMicrotask(() => { if (this.#paused || this.#running >= this.#concurrency) return; while (this.#queue.length > 0) { const next = this.#queue.shift(); if (next.controller.signal.aborted) continue; this.#execute(next); return; } }); } /** * Pause the queue. Running tasks continue, but no new tasks start. */ pause() { this.#paused = true; } /** * Resume the queue and start processing pending tasks. */ resume() { this.#paused = false; // Kick off tasks up to concurrency limit const available = this.#concurrency - this.#running; for (let i = 0; i < available; i++) { this.#advance(); } } /** * Clear all pending tasks from the queue. * Running tasks are not affected. * * @param reason - Optional reason for rejection (defaults to AbortError) */ clear(reason) { const tasks = this.#queue.splice(0); const error = reason ?? new DOMException('Cleared', 'AbortError'); for (const task of tasks) { task.controller.abort(error); task.reject(error); } } get concurrency() { return this.#concurrency; } set concurrency(value) { this.#concurrency = typeof value !== 'number' || value === 0 ? Infinity : value; // If concurrency increased, try to start more tasks if (!this.#paused) { const available = this.#concurrency - this.#running; for (let i = 0; i < available; i++) { this.#advance(); } } } get pending() { return this.#queue.length; } get running() { return this.#running; } get isPaused() { return this.#paused; } /** * @deprecated Legacy compatibility wrapper for RateLimitedQueue API. * Wraps a function so that when called, it's queued and returns an AbortablePromise. * Note: for legacy compatibility with RateLimitedQueue, the wrapped function * does not receive this queue's AbortSignal. Aborting the returned promise * will reject it, but it will not automatically cancel work inside the wrapped * function unless that function is wired to an external AbortSignal. */ wrapPromiseFunction(fn) { return (...args) => { return this.add((signal) => { // The wrapped function doesn't receive signal directly, // caller is responsible for using signal if needed void signal; return fn(...args); }); }; } }