@uppy/utils
Version:
Shared utility functions for Uppy Core and plugins maintained by the Uppy team.
261 lines (232 loc) • 6.75 kB
text/typescript
/**
* A promise that can be aborted.
*/
export interface AbortablePromise<T> extends Promise<T> {
abort(reason?: unknown): void
/**
* @deprecated Legacy compatibility - abort when signal fires
*/
abortOn(signal?: AbortSignal): AbortablePromise<T>
}
interface QueuedTask<T> {
run: () => Promise<T>
resolve: (value: T) => void
reject: (reason: unknown) => void
controller: AbortController
}
export interface TaskQueueOptions {
concurrency?: number
}
/**
* 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: QueuedTask<unknown>[] = []
#running = 0
#concurrency: number
#paused = false
constructor(options?: TaskQueueOptions) {
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<T>(task: (signal: AbortSignal) => Promise<T>): AbortablePromise<T> {
const controller = new AbortController()
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
}) as AbortablePromise<T>
const queuedTask: QueuedTask<T> = {
run: () => task(controller.signal),
resolve,
reject,
controller,
}
// Handle abort while queued
controller.signal.addEventListener(
'abort',
() => {
const index = this.#queue.indexOf(queuedTask as QueuedTask<unknown>)
if (index !== -1) {
this.#queue.splice(index, 1)
reject(
controller.signal.reason ??
new DOMException('Aborted', 'AbortError'),
)
}
},
{ once: true },
)
promise.abort = (reason?: unknown) => {
controller.abort(reason ?? new DOMException('Aborted', 'AbortError'))
}
// Legacy compatibility: abortOn method
promise.abortOn = (signal?: AbortSignal) => {
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 as QueuedTask<unknown>)
}
return promise
}
#execute<T>(task: QueuedTask<T>): void {
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: Promise<T>
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(): void {
// 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(): void {
this.#paused = true
}
/**
* Resume the queue and start processing pending tasks.
*/
resume(): void {
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?: unknown): void {
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(): number {
return this.#concurrency
}
set concurrency(value: number) {
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(): number {
return this.#queue.length
}
get running(): number {
return this.#running
}
get isPaused(): boolean {
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<T extends (...args: any[]) => Promise<any>>(
fn: T,
): (...args: Parameters<T>) => AbortablePromise<Awaited<ReturnType<T>>> {
return (...args: Parameters<T>) => {
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)
})
}
}
}