better-race 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +226 -0
- package/dist/index.d.mts +70 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +75 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ShimG
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# better-race
|
|
2
|
+
|
|
3
|
+
> A better `Promise.race()` for TypeScript: keyed results, precise narrowing, and optional cooperative cancellation.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/lumberjacque/better-race/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/better-race)
|
|
7
|
+
[](https://www.npmjs.com/package/better-race)
|
|
8
|
+
[](#quality-checks)
|
|
9
|
+
[](#public-types)
|
|
10
|
+
[](#installation)
|
|
11
|
+
[](https://bundlephobia.com/package/better-race)
|
|
12
|
+
[](LICENSE)
|
|
13
|
+
|
|
14
|
+
`ESM-only` · `zero runtime dependencies` · `tree-shakeable` · `AbortSignal`-native
|
|
15
|
+
|
|
16
|
+
`Promise.race()` tells you the first value that settled. `better-race` also tells you **which task produced it**, while keeping that key and value connected in TypeScript.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { race } from "better-race";
|
|
20
|
+
|
|
21
|
+
const winner = await race({
|
|
22
|
+
cache: () => readCache(),
|
|
23
|
+
api: ({ signal }) => fetchUser({ signal }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (winner.key === "api") {
|
|
27
|
+
winner.value; // exactly the return type of fetchUser
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Why?
|
|
32
|
+
|
|
33
|
+
Native `Promise.race()` returns a value union but drops its source:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const value = await Promise.race([readCache(), fetchUser()]);
|
|
37
|
+
// CachedUser | ApiUser
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
You can restore the source by manually wrapping every promise. `better-race` makes that wrapper the default, preserves the relation as a discriminated union, and can abort cooperative losers when that saves work.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npm install better-race
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`better-race` is ESM-only, targets ES2022, and supports Node.js 22 or newer. It is framework-agnostic: it uses only promises and the standard `AbortSignal` API.
|
|
49
|
+
|
|
50
|
+
## `race(tasks, options?)`
|
|
51
|
+
|
|
52
|
+
Pass an object whose string keys name tasks. A task can return a value, a promise, or any `PromiseLike` value. It receives a context containing an `AbortSignal`, which it may ignore.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { race } from "better-race";
|
|
56
|
+
|
|
57
|
+
const winner = await race({
|
|
58
|
+
memory: () => memoryCache.get("user-42"),
|
|
59
|
+
replica: ({ signal }) => replicaClient.get("user-42", { signal }),
|
|
60
|
+
primary: ({ signal }) => primaryClient.get("user-42", { signal }),
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The result is inferred as:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
{ key: "memory"; value: MemoryUser }
|
|
68
|
+
| { key: "replica"; value: ReplicaUser }
|
|
69
|
+
| { key: "primary"; value: PrimaryUser }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Narrow the key and TypeScript narrows the value:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
if (winner.key === "memory") {
|
|
76
|
+
winner.value; // MemoryUser
|
|
77
|
+
} else if (winner.key === "replica") {
|
|
78
|
+
winner.value; // ReplicaUser
|
|
79
|
+
} else {
|
|
80
|
+
winner.value; // PrimaryUser
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Semantics
|
|
85
|
+
|
|
86
|
+
`race()` intentionally follows the mental model of `Promise.race()`.
|
|
87
|
+
|
|
88
|
+
- Every task starts in object property order before any settlement is processed.
|
|
89
|
+
- The first task to settle wins, whether it fulfils or rejects.
|
|
90
|
+
- A first rejection rejects with the **original reason**; it is never wrapped.
|
|
91
|
+
- A synchronous task result works. A synchronous throw becomes a rejected race.
|
|
92
|
+
- `race({})` is rejected at the call site with a clear `TypeError`; TypeScript also rejects it.
|
|
93
|
+
- Only own, enumerable string keys are considered.
|
|
94
|
+
|
|
95
|
+
There is no priority system. Property order defines predictable startup order only—not a tie-breaker.
|
|
96
|
+
|
|
97
|
+
### Optional loser cancellation
|
|
98
|
+
|
|
99
|
+
By default, losers keep running, exactly like `Promise.race()`:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const winner = await race({
|
|
103
|
+
cache: readCache,
|
|
104
|
+
network: ({ signal }) => fetch("/user", { signal }),
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Set `abortLosers: true` to abort every still-running loser after the first settlement, including when the winning task rejects:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const winner = await race(
|
|
112
|
+
{
|
|
113
|
+
primary: ({ signal }) => fetch("/primary", { signal }),
|
|
114
|
+
backup: ({ signal }) => fetch("/backup", { signal }),
|
|
115
|
+
},
|
|
116
|
+
{ abortLosers: true },
|
|
117
|
+
);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Cancellation is cooperative. Passing a signal does not magically stop synchronous CPU work or code that ignores it. `fetch`, many database clients, and your own signal-aware functions can react to it.
|
|
121
|
+
|
|
122
|
+
The winning task's signal is never aborted by `abortLosers`.
|
|
123
|
+
|
|
124
|
+
### External cancellation
|
|
125
|
+
|
|
126
|
+
Pass a caller-owned signal to stop a pending race:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
|
|
131
|
+
const winner = await race(
|
|
132
|
+
{
|
|
133
|
+
primary: ({ signal }) => fetch("/primary", { signal }),
|
|
134
|
+
backup: ({ signal }) => fetch("/backup", { signal }),
|
|
135
|
+
},
|
|
136
|
+
{ signal: controller.signal, abortLosers: true },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
controller.abort(new Error("User navigated away"));
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
If the external signal is already aborted, no task starts. If it aborts while the race is pending, every task signal is aborted and the returned promise rejects with the exact `signal.reason`.
|
|
143
|
+
|
|
144
|
+
Once a task wins, the external listener is removed. With the default `abortLosers: false`, an external abort later does **not** retroactively cancel a loser that was deliberately allowed to continue.
|
|
145
|
+
|
|
146
|
+
### Options
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
interface RaceOptions {
|
|
150
|
+
signal?: AbortSignal;
|
|
151
|
+
abortLosers?: boolean; // false by default
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Public types
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import type { RaceContext, RaceOptions, RaceResult, RaceTask, RaceTasks } from "better-race";
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`RaceContext` deliberately contains only `signal`. There are no framework adapters, schedulers, retries, timeouts, hooks, or hidden global state.
|
|
162
|
+
|
|
163
|
+
## Use cases
|
|
164
|
+
|
|
165
|
+
- Read from a memory cache, distributed cache, and primary store simultaneously.
|
|
166
|
+
- Query independent replicas and return the fastest response.
|
|
167
|
+
- Race a preferred endpoint against a fallback endpoint, then abort the fallback.
|
|
168
|
+
- Preserve source information for metrics, tracing, or structured logging.
|
|
169
|
+
- Write compact TypeScript that narrows the result without hand-written wrapper objects.
|
|
170
|
+
|
|
171
|
+
## Examples
|
|
172
|
+
|
|
173
|
+
The executable examples are in [`examples/`](./examples):
|
|
174
|
+
|
|
175
|
+
- [`basic-keyed-result.ts`](./examples/basic-keyed-result.ts)
|
|
176
|
+
- [`abort-losers.ts`](./examples/abort-losers.ts)
|
|
177
|
+
- [`external-abort.ts`](./examples/external-abort.ts)
|
|
178
|
+
- [`rejection-semantics.ts`](./examples/rejection-semantics.ts)
|
|
179
|
+
|
|
180
|
+
CI compiles and executes these examples against the packed package, not the source tree.
|
|
181
|
+
|
|
182
|
+
## Visual semantics
|
|
183
|
+
|
|
184
|
+
A race is not a dependency graph: every task starts immediately. The important moment is the first settlement.
|
|
185
|
+
|
|
186
|
+
```text
|
|
187
|
+
Race Timeline — cache wins with abortLosers: true
|
|
188
|
+
|
|
189
|
+
Task │ 0ms 72ms 400ms
|
|
190
|
+
───────────┼─────────────────────┼──────────────────────────────────────────
|
|
191
|
+
cache │ ███████████████████ ● fulfilled winner
|
|
192
|
+
api │ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ × abort signal received
|
|
193
|
+
replica │ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ × abort signal received
|
|
194
|
+
↑
|
|
195
|
+
first settlement wins
|
|
196
|
+
|
|
197
|
+
Legend: █ active fulfilled work · ▓ active rejected work · ▒ active work aborted by the race
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Open [`playground/race-lab.html`](./playground/race-lab.html) directly in a browser to explore an animated version. It contains four concrete scenarios: fulfilment, first rejection, continuing losers, and external abort. The page is a visual prototype, not a shipped package artifact; Vitest and the packed-consumer test verify the runtime contract.
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
npm install
|
|
206
|
+
npm run verify
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`verify` runs formatting, linting, typechecking, 100% runtime coverage, type tests, a production build, and a packed-consumer test.
|
|
210
|
+
|
|
211
|
+
Useful commands:
|
|
212
|
+
|
|
213
|
+
```sh
|
|
214
|
+
npm run format # Format source with oxfmt
|
|
215
|
+
npm run lint # Lint with oxlint
|
|
216
|
+
npm run test:coverage # Runtime tests with strict 100% coverage
|
|
217
|
+
npm run test:types # Compile-time inference tests
|
|
218
|
+
npm run build # ESM package and declaration output
|
|
219
|
+
npm run test:pack # Test the tarball from a clean consumer
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Husky runs formatting for staged files plus typechecking and tests before commits. Commit messages use the Conventional Commits specification.
|
|
223
|
+
|
|
224
|
+
## Contributing and security
|
|
225
|
+
|
|
226
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) and [SECURITY.md](./SECURITY.md). This project is released under the [MIT License](./LICENSE).
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
/** Context supplied to every task in a race. */
|
|
3
|
+
interface RaceContext {
|
|
4
|
+
/**
|
|
5
|
+
* A signal that is aborted when the caller cancels a pending race or, when
|
|
6
|
+
* enabled, when another task settles first.
|
|
7
|
+
*/
|
|
8
|
+
readonly signal: AbortSignal;
|
|
9
|
+
}
|
|
10
|
+
/** A synchronous or asynchronous operation that participates in a race. */
|
|
11
|
+
type RaceTask<T> = (context: RaceContext) => T | PromiseLike<T>;
|
|
12
|
+
/** A named collection of tasks accepted by {@link race}. */
|
|
13
|
+
type RaceTasks = Readonly<Record<string, RaceTask<unknown>>>;
|
|
14
|
+
/** Options that control cancellation for {@link race}. */
|
|
15
|
+
interface RaceOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Cancels every still-running task while the race is pending. The race
|
|
18
|
+
* rejects with this signal's reason. Once a task wins, this signal is
|
|
19
|
+
* detached and can no longer affect continuing losers.
|
|
20
|
+
*/
|
|
21
|
+
readonly signal?: AbortSignal;
|
|
22
|
+
/**
|
|
23
|
+
* Abort still-running losers when the first task settles. Defaults to
|
|
24
|
+
* `false`, matching the non-cancelling behaviour of `Promise.race()`.
|
|
25
|
+
*/
|
|
26
|
+
readonly abortLosers?: boolean;
|
|
27
|
+
}
|
|
28
|
+
type StringKeyOf<T> = Extract<keyof T, string>;
|
|
29
|
+
/**
|
|
30
|
+
* The discriminated union returned by {@link race}. Narrowing `key` narrows
|
|
31
|
+
* `value` to the exact value type returned by that task.
|
|
32
|
+
*/
|
|
33
|
+
type RaceResult<T extends RaceTasks> = { [K in StringKeyOf<T>]: {
|
|
34
|
+
readonly key: K;
|
|
35
|
+
readonly value: Awaited<ReturnType<T[K]>>;
|
|
36
|
+
}; }[StringKeyOf<T>];
|
|
37
|
+
/** @internal Rejects an empty object at compile time. */
|
|
38
|
+
type NonEmptyTasks<T extends RaceTasks> = keyof T extends never ? never : T;
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/race.d.ts
|
|
41
|
+
/**
|
|
42
|
+
* Races named tasks and returns both the first settled task's key and value.
|
|
43
|
+
*
|
|
44
|
+
* All tasks are started in object property order before any settlement is
|
|
45
|
+
* processed. The first task to fulfil resolves with its keyed result; the
|
|
46
|
+
* first task to reject rejects with its original reason, just like
|
|
47
|
+
* `Promise.race()`.
|
|
48
|
+
*
|
|
49
|
+
* Set `abortLosers` to abort cooperative losers after the race settles. Pass
|
|
50
|
+
* `signal` to cancel a still-pending race externally. A task must use the
|
|
51
|
+
* supplied `AbortSignal` for cancellation to stop its underlying work.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* const winner = await race({
|
|
56
|
+
* cache: () => readCache(),
|
|
57
|
+
* api: ({ signal }) => fetchUser({ signal }),
|
|
58
|
+
* });
|
|
59
|
+
*
|
|
60
|
+
* if (winner.key === "api") {
|
|
61
|
+
* winner.value; // inferred from fetchUser
|
|
62
|
+
* }
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* @throws {TypeError} When `tasks` is empty or contains a non-function value.
|
|
66
|
+
*/
|
|
67
|
+
export declare function race<const T extends RaceTasks>(tasks: NonEmptyTasks<T>, options?: RaceOptions): Promise<RaceResult<T>>;
|
|
68
|
+
//#endregion
|
|
69
|
+
export type { RaceContext, RaceOptions, RaceResult, RaceTask, RaceTasks };
|
|
70
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/race.ts"],"mappings":";;UACiB;;;;;WAKN,QAAQ;;;KAIP,SAAS,MAAM,SAAS,gBAAgB,IAAI,YAAY;;KAGxD,YAAY,SAAS,eAAe;;UAG/B;;;;;;WAMN,SAAS;;;;;WAMT;;KAGN,YAAY,KAAK,cAAc;;;;;KAMxB,WAAW,UAAU,gBAC9B,KAAK,YAAY;WACP,KAAK;WACL,OAAO,QAAQ,WAAW,EAAE;KAEvC,YAAY;;KAGF,cAAc,UAAU,mBAAmB,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBCRjE,WAAW,UAAU,WACnC,OAAO,cAAc,IACrB,UAAS,cACR,QAAQ,WAAW"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
//#region src/race.ts
|
|
2
|
+
/**
|
|
3
|
+
* Races named tasks and returns both the first settled task's key and value.
|
|
4
|
+
*
|
|
5
|
+
* All tasks are started in object property order before any settlement is
|
|
6
|
+
* processed. The first task to fulfil resolves with its keyed result; the
|
|
7
|
+
* first task to reject rejects with its original reason, just like
|
|
8
|
+
* `Promise.race()`.
|
|
9
|
+
*
|
|
10
|
+
* Set `abortLosers` to abort cooperative losers after the race settles. Pass
|
|
11
|
+
* `signal` to cancel a still-pending race externally. A task must use the
|
|
12
|
+
* supplied `AbortSignal` for cancellation to stop its underlying work.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const winner = await race({
|
|
17
|
+
* cache: () => readCache(),
|
|
18
|
+
* api: ({ signal }) => fetchUser({ signal }),
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* if (winner.key === "api") {
|
|
22
|
+
* winner.value; // inferred from fetchUser
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @throws {TypeError} When `tasks` is empty or contains a non-function value.
|
|
27
|
+
*/
|
|
28
|
+
function race(tasks, options = {}) {
|
|
29
|
+
const entries = Object.entries(tasks);
|
|
30
|
+
if (entries.length === 0) throw new TypeError("race() requires at least one task.");
|
|
31
|
+
for (const [key, task] of entries) if (typeof task !== "function") throw new TypeError(`race() task "${key}" must be a function.`);
|
|
32
|
+
if (options.signal?.aborted) return Promise.reject(options.signal.reason);
|
|
33
|
+
const controllers = new Map(entries.map(([key]) => [key, new AbortController()]));
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
let settled = false;
|
|
36
|
+
const cleanup = () => {
|
|
37
|
+
options.signal?.removeEventListener("abort", onExternalAbort);
|
|
38
|
+
};
|
|
39
|
+
const abortTasks = (winnerKey) => {
|
|
40
|
+
for (const [key, controller] of controllers) if (key !== winnerKey) controller.abort();
|
|
41
|
+
};
|
|
42
|
+
const settleFulfilled = (key, value) => {
|
|
43
|
+
if (settled) return;
|
|
44
|
+
settled = true;
|
|
45
|
+
cleanup();
|
|
46
|
+
if (options.abortLosers) abortTasks(key);
|
|
47
|
+
resolve({
|
|
48
|
+
key,
|
|
49
|
+
value
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
const settleRejected = (key, reason) => {
|
|
53
|
+
if (settled) return;
|
|
54
|
+
settled = true;
|
|
55
|
+
cleanup();
|
|
56
|
+
if (options.abortLosers) abortTasks(key);
|
|
57
|
+
reject(reason);
|
|
58
|
+
};
|
|
59
|
+
const onExternalAbort = () => {
|
|
60
|
+
settled = true;
|
|
61
|
+
cleanup();
|
|
62
|
+
abortTasks();
|
|
63
|
+
reject(options.signal?.reason);
|
|
64
|
+
};
|
|
65
|
+
options.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
66
|
+
for (const [key, task] of entries) {
|
|
67
|
+
const context = { signal: controllers.get(key).signal };
|
|
68
|
+
Promise.resolve().then(() => task(context)).then((value) => settleFulfilled(key, value), (reason) => settleRejected(key, reason));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
export { race };
|
|
74
|
+
|
|
75
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/race.ts"],"sourcesContent":["import type {\n NonEmptyTasks,\n RaceContext,\n RaceOptions,\n RaceResult,\n RaceTask,\n RaceTasks,\n} from \"./types.js\";\n\ntype TaskEntry<T extends RaceTasks> = readonly [Extract<keyof T, string>, RaceTask<unknown>];\n\n/**\n * Races named tasks and returns both the first settled task's key and value.\n *\n * All tasks are started in object property order before any settlement is\n * processed. The first task to fulfil resolves with its keyed result; the\n * first task to reject rejects with its original reason, just like\n * `Promise.race()`.\n *\n * Set `abortLosers` to abort cooperative losers after the race settles. Pass\n * `signal` to cancel a still-pending race externally. A task must use the\n * supplied `AbortSignal` for cancellation to stop its underlying work.\n *\n * @example\n * ```ts\n * const winner = await race({\n * cache: () => readCache(),\n * api: ({ signal }) => fetchUser({ signal }),\n * });\n *\n * if (winner.key === \"api\") {\n * winner.value; // inferred from fetchUser\n * }\n * ```\n *\n * @throws {TypeError} When `tasks` is empty or contains a non-function value.\n */\nexport function race<const T extends RaceTasks>(\n tasks: NonEmptyTasks<T>,\n options: RaceOptions = {},\n): Promise<RaceResult<T>> {\n const entries = Object.entries(tasks) as unknown as TaskEntry<T>[];\n\n if (entries.length === 0) {\n throw new TypeError(\"race() requires at least one task.\");\n }\n\n for (const [key, task] of entries) {\n if (typeof task !== \"function\") {\n throw new TypeError(`race() task \"${key}\" must be a function.`);\n }\n }\n\n if (options.signal?.aborted) {\n return Promise.reject(options.signal.reason);\n }\n\n const controllers = new Map<string, AbortController>(\n entries.map(([key]) => [key, new AbortController()]),\n );\n\n return new Promise<RaceResult<T>>((resolve, reject) => {\n let settled = false;\n\n const cleanup = (): void => {\n options.signal?.removeEventListener(\"abort\", onExternalAbort);\n };\n\n const abortTasks = (winnerKey?: string): void => {\n for (const [key, controller] of controllers) {\n if (key !== winnerKey) {\n controller.abort();\n }\n }\n };\n\n const settleFulfilled = (key: string, value: unknown): void => {\n if (settled) return;\n\n settled = true;\n cleanup();\n if (options.abortLosers) abortTasks(key);\n resolve({ key, value } as RaceResult<T>);\n };\n\n const settleRejected = (key: string, reason: unknown): void => {\n if (settled) return;\n\n settled = true;\n cleanup();\n if (options.abortLosers) abortTasks(key);\n reject(reason);\n };\n\n const onExternalAbort = (): void => {\n settled = true;\n cleanup();\n abortTasks();\n reject(options.signal?.reason);\n };\n\n options.signal?.addEventListener(\"abort\", onExternalAbort, { once: true });\n\n for (const [key, task] of entries) {\n const controller = controllers.get(key)!;\n\n const context: RaceContext = { signal: controller.signal };\n Promise.resolve()\n .then(() => task(context))\n .then(\n (value) => settleFulfilled(key, value),\n (reason: unknown) => settleRejected(key, reason),\n );\n }\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,SAAgB,KACd,OACA,UAAuB,CAAC,GACA;CACxB,MAAM,UAAU,OAAO,QAAQ,KAAK;CAEpC,IAAI,QAAQ,WAAW,GACrB,MAAM,IAAI,UAAU,oCAAoC;CAG1D,KAAK,MAAM,CAAC,KAAK,SAAS,SACxB,IAAI,OAAO,SAAS,YAClB,MAAM,IAAI,UAAU,gBAAgB,IAAI,sBAAsB;CAIlE,IAAI,QAAQ,QAAQ,SAClB,OAAO,QAAQ,OAAO,QAAQ,OAAO,MAAM;CAG7C,MAAM,cAAc,IAAI,IACtB,QAAQ,KAAK,CAAC,SAAS,CAAC,KAAK,IAAI,gBAAgB,CAAC,CAAC,CACrD;CAEA,OAAO,IAAI,SAAwB,SAAS,WAAW;EACrD,IAAI,UAAU;EAEd,MAAM,gBAAsB;GAC1B,QAAQ,QAAQ,oBAAoB,SAAS,eAAe;EAC9D;EAEA,MAAM,cAAc,cAA6B;GAC/C,KAAK,MAAM,CAAC,KAAK,eAAe,aAC9B,IAAI,QAAQ,WACV,WAAW,MAAM;EAGvB;EAEA,MAAM,mBAAmB,KAAa,UAAyB;GAC7D,IAAI,SAAS;GAEb,UAAU;GACV,QAAQ;GACR,IAAI,QAAQ,aAAa,WAAW,GAAG;GACvC,QAAQ;IAAE;IAAK;GAAM,CAAkB;EACzC;EAEA,MAAM,kBAAkB,KAAa,WAA0B;GAC7D,IAAI,SAAS;GAEb,UAAU;GACV,QAAQ;GACR,IAAI,QAAQ,aAAa,WAAW,GAAG;GACvC,OAAO,MAAM;EACf;EAEA,MAAM,wBAA8B;GAClC,UAAU;GACV,QAAQ;GACR,WAAW;GACX,OAAO,QAAQ,QAAQ,MAAM;EAC/B;EAEA,QAAQ,QAAQ,iBAAiB,SAAS,iBAAiB,EAAE,MAAM,KAAK,CAAC;EAEzE,KAAK,MAAM,CAAC,KAAK,SAAS,SAAS;GAGjC,MAAM,UAAuB,EAAE,QAFZ,YAAY,IAAI,GAEa,CAAC,CAAC,OAAO;GACzD,QAAQ,QAAQ,CAAC,CACd,WAAW,KAAK,OAAO,CAAC,CAAC,CACzB,MACE,UAAU,gBAAgB,KAAK,KAAK,IACpC,WAAoB,eAAe,KAAK,MAAM,CACjD;EACJ;CACF,CAAC;AACH"}
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "better-race",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A better Promise.race() for TypeScript: keyed results and optional cancellation.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"abortsignal",
|
|
7
|
+
"async",
|
|
8
|
+
"cancellation",
|
|
9
|
+
"promise",
|
|
10
|
+
"race",
|
|
11
|
+
"typescript"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/lumberjacque/better-race#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/lumberjacque/better-race/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/lumberjacque/better-race.git"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"types": "./dist/index.d.mts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.mts",
|
|
33
|
+
"import": "./dist/index.mjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"format": "oxfmt --write .",
|
|
39
|
+
"format:check": "oxfmt --check .",
|
|
40
|
+
"lint": "oxlint .",
|
|
41
|
+
"lint:ci": "oxlint --deny-warnings .",
|
|
42
|
+
"typecheck": "tsc --project tsconfig.json --noEmit",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"test:coverage": "vitest run --coverage",
|
|
45
|
+
"test:types": "vitest --typecheck",
|
|
46
|
+
"test:pack": "node scripts/test-pack.mjs",
|
|
47
|
+
"verify": "node scripts/verify.mjs",
|
|
48
|
+
"version": "changeset version",
|
|
49
|
+
"publish": "node scripts/publish.mjs",
|
|
50
|
+
"prepare": "husky"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@changesets/cli": "^3.0.2",
|
|
54
|
+
"@commitlint/cli": "^21.2.2",
|
|
55
|
+
"@commitlint/config-conventional": "^21.2.2",
|
|
56
|
+
"@types/node": "^26.4.1",
|
|
57
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
58
|
+
"husky": "^9.1.7",
|
|
59
|
+
"lint-staged": "^17.4.1",
|
|
60
|
+
"oxfmt": "^0.66.0",
|
|
61
|
+
"oxlint": "^1.81.0",
|
|
62
|
+
"tsdown": "^0.23.0",
|
|
63
|
+
"typescript": "^5.9.3",
|
|
64
|
+
"vitest": "^5.0.0"
|
|
65
|
+
},
|
|
66
|
+
"lint-staged": {
|
|
67
|
+
"*.{js,cjs,mjs,ts,tsx,json,jsonc,md,yml,yaml,html,css}": "oxfmt --write"
|
|
68
|
+
},
|
|
69
|
+
"engines": {
|
|
70
|
+
"node": ">=22"
|
|
71
|
+
}
|
|
72
|
+
}
|