It can make asynchronous tasks that promise cannot complete, ignore the order of sending and receiving, allow binding of asynchronous tasks in pending status, allow triggering a task in multiple functions, and support producer consumer mode and order mode.
English | 简体中文
- Node.js >= 22.18 (or any modern runtime that supports ES2023).
- ESM only.
importworks everywhere, andrequire()works on Node.js 22.18+. - Zero runtime dependencies and no restriction on your tech stack. Type declarations are included.
Upgrading from 1.x? See the migration guide.
npm i async-task-mapping --saveimport { createTaskList } from "async-task-mapping";
// In TypeScript, declare the data type: createTaskList<string>()
const asyncTask = createTaskList({
ordered: true,
taskCount: 2,
resolveCount: 2,
});
setTimeout(async () => {
// The instance is a thenable, so you can use either async/await or .then.
const { list, dataMap } = await asyncTask;
console.log("data-1", list[0]);
}, 100);
setTimeout(async () => {
const { list, dataMap } = await asyncTask;
console.log("data-2", dataMap.res2);
}, 200);
setTimeout(() => {
asyncTask.pushResolve("response1");
}, 300);
setTimeout(() => {
asyncTask.pushResolve("response2", "res2");
}, 400);You can use these functions in different methods. All requests of a round settle together once resolveCount pieces of data have been pushed. The result of the above code is:
// time 400: data-1 response1
// time 400: data-2 response2| name | description | default |
|---|---|---|
| ordered | Ordered list: every request must be bound before the first pushResolve, otherwise pushResolve throws |
true |
| taskCount | Number of times request (including then / catch / finally / await) can be bound, positive integer |
1 |
| resolveCount | Number of times pushResolve can be called, positive integer |
1 |
| timeout | Timeout in milliseconds (> 0 and ≤ 2147483647). Counted from the first request of a round. Rejects with TaskTimeoutError |
undefined |
| signal | An AbortSignal. See abort |
undefined |
Invalid values throw immediately: a non-number throws TypeError, a non-positive-integer taskCount / resolveCount or a non-positive timeout throws RangeError, and a signal that is not an AbortSignal throws TypeError.
| name | description | notes |
|---|---|---|
| request | Bind one request and return a Promise | Over the limit, it returns a rejected Promise (RangeError) |
| then* | then / catch / finally of the instance, each one calls request() once |
task.then(({ list, dataMap }) => {}) |
| pushResolve | Add data: pushResolve(data, name?). With a name (an empty string is a valid key), the data can be read from dataMap[name] |
Throws on over the limit, on wrong order (ordered list) and after pushReject |
| pushReject | Fail the current round with reason. Every pending and later request of the round rejects (fail-fast) |
Throws if the round has already failed, and (ordered list) if requests are not all bound |
| clear | Reset the current round. Pending requests reject with TaskClearedError |
See clear |
| paused | Pause dispatching. pushResolve / pushReject are still recorded but requests do not settle |
clear, timeout and abort are never deferred |
| running | Resume dispatching and settle immediately if possible | -- |
| getStatus | Get current status | See below |
asyncTask.getStatus();
// {
// requestDone: true, // whether all `taskCount` requests are bound
// responseDone: false, // whether all `resolveCount` pushResolve are done
// requestCount: 2, // number of requests bound
// responseCount: 0 // number of pushResolve done
// }Result shape. await task always resolves { list, dataMap }, for both ordered and unordered lists. list keeps the order of pushResolve, dataMap is a plain object. Every round gets its own snapshot, and a later round never mutates the result of an earlier one.
Unordered list. With ordered: false, data can be pushed before the request is bound, and a request that is bound after the round has been fulfilled resolves immediately:
const task = createTaskList({ ordered: false, resolveCount: 2 });
task.pushResolve("x", "k1");
task.pushResolve("y");
console.log(await task.request());
// { list: [ 'x', 'y' ], dataMap: { k1: 'x' } }Once all taskCount requests are used up and the round has settled, the next request starts a new round. For an unordered list (ordered: false), calling pushResolve in that situation also starts a new round. An ordered list must request first, and an extra pushResolve still throws RangeError.
Requests and data are paired strictly in first-in-first-out order, regardless of which side arrives first.
import { createTaskOrder } from "async-task-mapping";
const taskOrder = createTaskOrder();
setTimeout(async () => {
const data = await taskOrder;
console.log("data1", data);
}, 230);
setTimeout(async () => {
const data = await taskOrder;
console.log("data2", data);
}, 500);
setTimeout(() => {
taskOrder.pushResolve("resolve1");
}, 200);
setTimeout(() => {
taskOrder.pushResolve("resolve2");
}, 300);
setTimeout(() => {
taskOrder.pushResolve("resolve3");
}, 400);The result of the above code is:
// time 230: data1 resolve1
// time 500: data2 resolve2
// resolve3 has no request yet:
// taskOrder.getStatus() -> { pendingRequests: 0, pendingResolve: 1 }| name | description | default |
|---|---|---|
| timeout | Timeout in milliseconds (> 0 and ≤ 2147483647) of each request. A timed-out request leaves the queue | undefined |
| signal | An AbortSignal. See abort |
undefined |
| name | description | notes |
|---|---|---|
| request | Bind one request | -- |
| then* | then / catch / finally of the instance, each one calls request() once |
-- |
| pushResolve | Add data | -- |
| pushReject | Pair with the first unmatched request and reject it with reason |
Later requests are not affected |
| clear | Clear the queues. Pending requests reject with TaskClearedError, unmatched data is dropped |
Does not reset getLastCompletedTask() or paused(). See clear |
| paused | Pause matching | running() matches every piled-up pair at once |
| running | Resume matching | -- |
| getStatus | { pendingRequests, pendingResolve }: unmatched requests and unmatched data |
-- |
| getLastCompletedTask | The data of the last successful match, null if there is none |
pushReject does not update it |
const order = createTaskOrder();
const first = order.request().catch((e) => `rejected: ${e.message}`);
const second = order.request();
order.pushReject(new Error("boom"));
order.pushResolve("ok");
console.log(await first, "/", await second);
// rejected: boom / okA concurrency pool: it starts running when called, and never runs more than limit tasks at the same time.
import { createStepTask } from "async-task-mapping";
const controller = new AbortController();
const settled = await createStepTask(
[
// each task receives the signal: pass it to fetch so abort really cancels the request
(signal) => fetch("/a", { signal }),
(signal) => fetch("/b", { signal }),
(signal) => fetch("/c", { signal }),
],
{
limit: 2, // required, positive integer
signal: controller.signal, // optional
onComplete: (settled) => {}, // optional
},
);
// settled is ordered by task index, like Promise.allSettled:
// [{ status: "fulfilled", value }, { status: "rejected", reason }, ...]- A task that throws synchronously, or is not a function, is recorded as
rejected. The returned Promise itself only rejects foronCompleteerrors orabort. onCompleteis called once after all tasks settle and before the returned Promise resolves. If it throws, the returned Promise rejects with that error.- After
abort, no new task is started and the returned Promise rejects withsignal.reason. Tasks that are already running are not forcibly interrupted, but every task function receives the samesignal(a signal that never aborts when nosignaloption is given). Pass it tofetchand similar APIs to really cancel them. onCompleteis not called afterabort.- Invalid arguments throw synchronously instead of returning a rejected Promise:
tasksthat is not an array throwsTypeError, alimitthat is not a positive integer throwsTypeError/RangeError, and asignalthat is not anAbortSignalthrowsTypeError. Wrap the call intryif the arguments come from user input.
pushReject, timeout and signal make .catch / .finally actually useful. Always catch the rejection, see clear.
pushReject: fail the request from the producer side.
import { createTaskList } from "async-task-mapping";
const task = createTaskList();
const pending = task.request();
task.pushReject(new Error("boom"));
await pending.catch((error) => console.log(error.message));
// boom
// The round has failed: pushResolve / pushReject now throw.
// Call task.clear() to recover immediately, see the pushReject section below.For createTaskOrder, pushReject only rejects the first unmatched request (see the example in the createTaskOrder section).
timeout: reject with TaskTimeoutError when nothing arrives in time.
import {
createTaskList,
createTaskOrder,
TaskTimeoutError,
} from "async-task-mapping";
// createTaskList: counted once per round, from the first request
const task = createTaskList({ timeout: 1000 });
try {
await task; // nobody calls task.pushResolve within 1 second
} catch (error) {
console.log(error instanceof TaskTimeoutError); // true
}
// createTaskOrder: counted for each request separately,
// a timed-out request leaves the queue and does not affect the others
const order = createTaskOrder({ timeout: 1000 });
await order.request().catch((error) => error instanceof TaskTimeoutError); // truesignal: cancel from outside with an AbortSignal.
import { createTaskOrder } from "async-task-mapping";
const controller = new AbortController();
const order = createTaskOrder({ signal: controller.signal });
const pending = order.request();
controller.abort(new Error("page closed"));
await pending.catch((error) => console.log(error.message));
// page closed
// After abort the instance is no longer usable:
await order.request().catch((error) => console.log(error.message)); // page closed
order.pushResolve("x"); // silently ignored, does not throwcreateTaskList accepts timeout and signal in the same way. Details (such as how timeout interacts with paused()) are in abort and timeout and paused.
Type declarations are built in and no @types package is needed. Both factories take the data type as a generic parameter (default unknown):
import {
createStepTask,
createTaskList,
createTaskOrder,
type StepTaskFunction,
type TaskList,
type TaskListResult,
type TaskOrder,
} from "async-task-mapping";
const list: TaskList<string> = createTaskList<string>();
const pending = list.request(); // an ordered list must bind the request first
list.pushResolve("a", "k"); // list.pushResolve(1) is a compile error, data must be a string
const result: TaskListResult<string> = await pending;
console.log(result.list[0], result.dataMap.k);
// result.list: readonly string[]
// result.dataMap: Readonly<Record<string, string>>
const order: TaskOrder<number> = createTaskOrder<number>();
const next = order.request();
order.pushResolve(1);
const value = await next; // number
const last = order.getLastCompletedTask(); // number | null
// createStepTask<T>: every task returns T (or a PromiseLike<T>)
const tasks: StepTaskFunction<number>[] = [() => 1, async () => 2];
const settled = await createStepTask(tasks, { limit: 2 });
// settled: PromiseSettledResult<number>[]Exported types:
| type | description |
|---|---|
TaskList<T>, TaskOrder<T> |
Instance types returned by createTaskList<T>() / createTaskOrder<T>(). Both are PromiseLike |
TaskListOptions, TaskOrderOptions |
Options of the factories, all optional |
TaskListResult<T> |
{ readonly list: readonly T[]; readonly dataMap: Readonly<Record<string, T>> } |
TaskListStatus, TaskOrderStatus |
Return types of getStatus() |
StepTaskFunction<T>, StepTaskOptions<T> |
Task function (signal: AbortSignal) => PromiseLike<T> | T and options of createStepTask |
TaskClearedError, TaskTimeoutError |
Error classes (values, not only types), usable with instanceof |
The reason of a rejection is typed as unknown, so narrow it before use: if (error instanceof TaskTimeoutError) { ... }.
import { TaskClearedError, TaskTimeoutError } from "async-task-mapping";Errors that reject the returned Promise:
| error | when |
|---|---|
TaskClearedError |
clear() was called while the request was pending |
TaskTimeoutError |
timeout was configured and the request did not settle in time |
RangeError |
request over the limit (createTaskList) |
signal.reason |
The signal was aborted: pending and later requests reject with it |
the reason of pushReject |
Whatever you passed to pushReject(reason) |
Errors that are thrown synchronously:
| error | when |
|---|---|
RangeError |
pushResolve over the limit, or taskCount / resolveCount / timeout / limit is out of range |
TypeError |
A numeric option is not a number, signal is not an AbortSignal, or tasks of createStepTask is not an array |
Error |
createTaskList: pushResolve / pushReject in the wrong order (ordered list, requests not all bound yet), or pushResolve / pushReject after the round has failed |
then, catch, finally and await on an instance each call request() once, and each consumes one request slot. For taskCount: 1, the second await task in the same round gets a rejected RangeError promise.
An async function that returns the instance unwraps it, which counts as one more await. Return task.request() explicitly when you want to control it.
Risk: anything that unwraps a thenable silently consumes a slot, including Promise.resolve(task), Promise.all([task]), Promise.race([...]), and handing the instance to a third-party library or test framework that awaits it. Once a slot is consumed by accident, the real consumer gets a RangeError (and createTaskOrder pairs the data with the wrong request). Always call request() explicitly in application code, and do not pass the instance around as if it were a plain Promise.
clear() of createTaskOrder only empties the two queues (pending requests and unmatched data):
getLastCompletedTask()still returns the last matched data from beforeclear().- If
paused()was called earlier, the instance stays paused afterclear(): new requests / data keep piling up untilrunning().
If you need these to be reset, do it yourself (for example call running() after clear(), or create a new instance).
clear() (and abort) rejects every pending request. If the caller has no catch, the rejection is an unhandledRejection, and Node.js terminates the process by default.
task.then(render); // not safe: rejects with TaskClearedError after task.clear()
task.then(render).catch(() => {}); // safesignal: after it is aborted, pending requests reject withsignal.reason, laterrequest()calls reject with it as well, andpushResolve/pushRejectare silently ignored (a producer is not always aware that the consumer has given up). A signal that is already aborted when the instance is created takes effect immediately. The instance only holds a weak reference to itssignal, so a long-lived signal does not keep the instance alive, and the listener it left on the signal is removed automatically once the instance is garbage collected.timeoutofcreateTaskListstarts at the first request of a round and applies to the whole round, ordered or not. Nobody waiting means no timer: in an unordered list that is already filled bypushResolvethere is no timer, and a late request resolves immediately.timeoutofcreateTaskOrderis counted for each request separately.
createTaskList: fail-fast. The whole round fails (whether bypushRejector bytimeout), later requests of the same round reject too, andpushResolve/pushRejectthrow. Callclear()to recover immediately; otherwise the nextrequest()only starts a new round after alltaskCountrequest slots are used up. This also holds whenpushRejectis called whilepaused(): the requests reject afterrunning(), and a laterpushResolvealready throws.createTaskOrder: paired with requests in FIFO order, only one request is rejected.
paused() only defers the dispatching of pushResolve / pushReject. clear, timeout and abort always take effect immediately. Note that timeout is not frozen by pausing: the timer keeps running while paused and rejects with TaskTimeoutError when it fires. If a pause may last long, do not combine it with a short timeout.
| 1.x | 2.0 |
|---|---|
| Published as a bundle with no type declarations | ESM only with built-in types, engines.node >= 22.18. Use import (or require() on Node.js 22.18+) |
requestCount / responseCount options and pushResponse (compatibility layer) |
Removed. Use taskCount / resolveCount and pushResolve |
request over the limit logged console.warn and returned undefined |
Returns a rejected Promise (RangeError). Nothing is logged |
pushResolve over the limit or in the wrong order returned an error string |
Throws RangeError / Error |
clear() left pending requests hanging forever |
They reject with TaskClearedError. Add a catch, see clear |
taskCount: 0 and similar values silently became 1 |
taskCount / resolveCount / timeout / signal are validated and throw on invalid values |
| An unordered list returned a bare array for a request after fulfilled | Always { list, dataMap } |
dataMap was shared by every round (clear() did not reset it) |
Each round has its own list / dataMap snapshot |
createTaskOrder().getStatus() always returned 0, and running() matched only one pair |
Real numbers, and running() matches every piled-up pair |
Debug console.log in createTaskOrder |
Removed |
| -- | New: pushReject, timeout, signal, createStepTask, TaskClearedError, TaskTimeoutError |
Notes:
ordereddefaults totrue, as it did in the 1.x code (the 1.x README saidfalseby mistake). If you rely on pushing data before binding requests, setordered: falseexplicitly.getStatus()ofcreateTaskOrderusespendingResolve(the 1.x README wrotependingResponsesby mistake).
Issues and pull requests are welcome, please read CONTRIBUTING.md first (written in Chinese).
MIT.