inflight-kit 1.0.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 +135 -0
- package/dist/cjs/index.d.ts +31 -0
- package/dist/cjs/index.js +126 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/index.d.ts +31 -0
- package/dist/esm/index.js +121 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Farhad Arjmand
|
|
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,135 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# inflight-kit
|
|
4
|
+
|
|
5
|
+
**Share the work. Keep your own cancel button.**
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/inflight-kit)
|
|
8
|
+
[](https://github.com/farhad-arjmand/inflight-kit/actions/workflows/ci.yml)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
|
|
11
|
+
One pending operation per key. Independent cancellation and deadlines for every caller.
|
|
12
|
+
|
|
13
|
+
Zero runtime dependencies · TypeScript · ESM + CommonJS · Node.js 20+
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install inflight-kit
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## The problem
|
|
22
|
+
|
|
23
|
+
Three components ask for the same user. Three requests hit your API. You share a promise to fix it—then one component unmounts and aborts the request for everyone.
|
|
24
|
+
|
|
25
|
+
`inflight-kit` gives each caller its own promise, signal and deadline, while sharing one underlying operation. The worker receives a separate signal that aborts only when the last caller leaves (or you explicitly cancel the key).
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
Caller A ── run("user:42") ─┐
|
|
29
|
+
Caller B ── run("user:42") ─┼── ONE worker ── API
|
|
30
|
+
Caller C ── run("user:42") ─┘
|
|
31
|
+
|
|
32
|
+
A cancels → A rejects; B and C still receive the result.
|
|
33
|
+
All cancel → the worker's signal aborts.
|
|
34
|
+
Done → the key is removed. The next call starts fresh.
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Start here
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createFlight } from 'inflight-kit';
|
|
41
|
+
|
|
42
|
+
type User = { id: string; name: string };
|
|
43
|
+
|
|
44
|
+
// Keep this instance outside the function that calls it.
|
|
45
|
+
const users = createFlight(async (id: string, signal): Promise<User> => {
|
|
46
|
+
const response = await fetch(`/api/users/${encodeURIComponent(id)}`, { signal });
|
|
47
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
48
|
+
return response.json(); // Validate untrusted data here if your app needs it.
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const first = users.run('42', { signal: controller.signal });
|
|
53
|
+
const second = users.run('42', { timeoutMs: 3000 });
|
|
54
|
+
|
|
55
|
+
// Attach handlers before cancellation, as with any promise.
|
|
56
|
+
const results = Promise.allSettled([first, second]);
|
|
57
|
+
controller.abort();
|
|
58
|
+
console.log(await results); // first rejects; second can still succeed
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Parse responses inside the worker.** A shared `Response` has a single-use body. Share the parsed value instead, and treat shared objects as immutable.
|
|
62
|
+
|
|
63
|
+
## Useful places to use it
|
|
64
|
+
|
|
65
|
+
- Coalesce repeated profile, settings or metadata reads across components.
|
|
66
|
+
- Collapse a burst of cache misses for one key into a single database lookup.
|
|
67
|
+
- Share an expensive computation while each caller keeps its own deadline.
|
|
68
|
+
- Deduplicate token refresh calls **within the same authenticated session**.
|
|
69
|
+
|
|
70
|
+
This is in-process coordination. Multiple server processes need a distributed solution if they must coordinate with one another.
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
|
|
74
|
+
### `createFlight(worker, options?)`
|
|
75
|
+
|
|
76
|
+
The worker is `(key, sharedSignal) => value | PromiseLike<value>`. Key and result types are inferred from it. Synchronous throws and asynchronous rejections reach every remaining caller. Work begins in a microtask so same-turn calls can join before execution.
|
|
77
|
+
|
|
78
|
+
| Option | Default | Meaning |
|
|
79
|
+
| --- | --- | --- |
|
|
80
|
+
| `maxKeys` | `1024` | Maximum currently tracked keys |
|
|
81
|
+
| `maxWaitersPerKey` | `1024` | Maximum callers waiting on one key |
|
|
82
|
+
|
|
83
|
+
Limits are positive safe integers. Excess callers reject with `CapacityError` and its `limit` field. There is no hidden queue. Existing keys can still accept callers when `maxKeys` is reached, up to their own waiter limit.
|
|
84
|
+
|
|
85
|
+
### `flight.run(key, { signal?, timeoutMs? }?)`
|
|
86
|
+
|
|
87
|
+
Returns a separate `Promise<V>` for each caller. Same-key calls share the worker while it is pending. `timeoutMs` is a per-caller deadline, an integer between `1` and `2147483647`. Deadline expiry rejects with a `DOMException` named `TimeoutError`; abort preserves `signal.reason`. An already-aborted caller never starts or joins work.
|
|
88
|
+
|
|
89
|
+
A deadline uses the runtime's timer: it cannot interrupt blocking synchronous JavaScript. Handle returned promises to avoid unhandled rejections.
|
|
90
|
+
|
|
91
|
+
### Control and inspection
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
users.size; // number of tracked keys
|
|
95
|
+
users.has('42'); // is this key pending?
|
|
96
|
+
users.waiters('42'); // number of callers still waiting
|
|
97
|
+
users.cancel('42'); // reject callers, abort worker, remove key; returns boolean
|
|
98
|
+
users.clear(); // cancel a snapshot of all currently tracked keys
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`cancel(key, reason?)` and `clear(reason?)` accept a custom rejection reason. They default to an `AbortError`. A new call may immediately start a replacement operation. Late results from old work cannot delete or settle that replacement.
|
|
102
|
+
|
|
103
|
+
## Boundaries that matter
|
|
104
|
+
|
|
105
|
+
- **Keys are your isolation boundary.** Include tenant, user, permission scope and relevant parameters. Do not share a key between different authorization contexts. Avoid putting secrets directly in keys.
|
|
106
|
+
- **Map semantics apply.** Strings compare by value; object keys compare by identity. There is no implicit JSON serialization or hashing.
|
|
107
|
+
- **No settled cache, retries or rate limiting.** Once work settles, its key is removed. Use your existing cache around the worker if you need cached values.
|
|
108
|
+
- **Cancellation is cooperative.** Pass the worker's signal to `fetch` or another cancellation-aware operation. Ignoring it can leave physical work running after its key is removed. Admission limits bound tracked work, not uncancellable operations.
|
|
109
|
+
- **Reads and safe computations are the intended use.** Deduplicating writes can suppress actions that were meant to happen separately.
|
|
110
|
+
- **Do not recursively await the same flight/key inside its worker.** That would wait on itself.
|
|
111
|
+
- Runtime uses standard `Map`, `Promise`, `AbortController`, `DOMException` and timers. Node.js 20/22/24 are tested in CI; modern browser ESM is smoke-tested separately during development. No Node-specific runtime imports.
|
|
112
|
+
|
|
113
|
+
## Why another async utility?
|
|
114
|
+
|
|
115
|
+
The focus is a small combination: keyed pending-work sharing, **independent caller lifetimes**, cooperative last-caller cancellation, and bounded admission. If you need a query cache, use a query cache. If you need a concurrency queue or distributed lock, use one. This package deliberately keeps a single typed worker per instance so callers cannot accidentally associate incompatible result types with the same key.
|
|
116
|
+
|
|
117
|
+
The singleflight pattern predates this project; see [Go's singleflight](https://pkg.go.dev/golang.org/x/sync/singleflight) and the cancellation-aware [janos/singleflight](https://github.com/janos/singleflight). This JavaScript implementation is independent.
|
|
118
|
+
|
|
119
|
+
## Run the demo and tests
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
git clone https://github.com/farhad-arjmand/inflight-kit.git
|
|
123
|
+
cd inflight-kit
|
|
124
|
+
npm ci
|
|
125
|
+
npm run demo
|
|
126
|
+
npm run check
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The local demo starts a real HTTP server and compares 100 direct requests with 100 coalesced callers. It measures upstream request count, not a performance speedup claim.
|
|
130
|
+
|
|
131
|
+
See [contributing](CONTRIBUTING.md), [security](SECURITY.md) and [changelog](CHANGELOG.md).
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT © Farhad Arjmand
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** A caller was refused because the configured admission limit was reached. */
|
|
2
|
+
export declare class CapacityError extends Error {
|
|
3
|
+
readonly limit: 'maxKeys' | 'maxWaitersPerKey';
|
|
4
|
+
readonly name = "CapacityError";
|
|
5
|
+
constructor(limit: 'maxKeys' | 'maxWaitersPerKey');
|
|
6
|
+
}
|
|
7
|
+
export interface FlightOptions {
|
|
8
|
+
/** Maximum tracked keys. Default: 1024. */
|
|
9
|
+
maxKeys?: number;
|
|
10
|
+
/** Maximum simultaneous callers for one key. Default: 1024. */
|
|
11
|
+
maxWaitersPerKey?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface RunOptions {
|
|
14
|
+
/** Cancels only this caller, unless it is the last caller. */
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
/** Per-caller deadline, in milliseconds (1 to 2147483647). */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface Flight<K, V> {
|
|
20
|
+
run(key: K, options?: RunOptions): Promise<V>;
|
|
21
|
+
/** Tracked keys, not physical operations that ignore cancellation. */
|
|
22
|
+
readonly size: number;
|
|
23
|
+
has(key: K): boolean;
|
|
24
|
+
waiters(key: K): number;
|
|
25
|
+
/** Reject current callers and request cooperative cancellation. */
|
|
26
|
+
cancel(key: K, reason?: unknown): boolean;
|
|
27
|
+
/** Cancel a snapshot of all currently tracked keys. */
|
|
28
|
+
clear(reason?: unknown): void;
|
|
29
|
+
}
|
|
30
|
+
/** One worker per key while callers are waiting. Results and failures are never cached. */
|
|
31
|
+
export declare function createFlight<K, V>(worker: (key: K, signal: AbortSignal) => V | PromiseLike<V>, options?: FlightOptions): Flight<K, V>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CapacityError = void 0;
|
|
4
|
+
exports.createFlight = createFlight;
|
|
5
|
+
/** A caller was refused because the configured admission limit was reached. */
|
|
6
|
+
class CapacityError extends Error {
|
|
7
|
+
limit;
|
|
8
|
+
name = 'CapacityError';
|
|
9
|
+
constructor(limit) {
|
|
10
|
+
super(`inflight-kit: ${limit} reached`);
|
|
11
|
+
this.limit = limit;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
exports.CapacityError = CapacityError;
|
|
15
|
+
const aborted = () => new DOMException('The operation was aborted', 'AbortError');
|
|
16
|
+
function positive(value, name) {
|
|
17
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
18
|
+
throw new RangeError(`${name} must be a positive safe integer`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
/** One worker per key while callers are waiting. Results and failures are never cached. */
|
|
22
|
+
function createFlight(worker, options = {}) {
|
|
23
|
+
if (typeof worker !== 'function')
|
|
24
|
+
throw new TypeError('worker must be a function');
|
|
25
|
+
const maxKeys = positive(options.maxKeys ?? 1024, 'maxKeys');
|
|
26
|
+
const maxWaiters = positive(options.maxWaitersPerKey ?? 1024, 'maxWaitersPerKey');
|
|
27
|
+
const entries = new Map();
|
|
28
|
+
function detach(key, entry) {
|
|
29
|
+
entry.done = true;
|
|
30
|
+
if (entries.get(key) === entry)
|
|
31
|
+
entries.delete(key);
|
|
32
|
+
}
|
|
33
|
+
function finish(key, entry, ok, value) {
|
|
34
|
+
if (entry.done)
|
|
35
|
+
return;
|
|
36
|
+
detach(key, entry);
|
|
37
|
+
for (const waiter of entry.waiters) {
|
|
38
|
+
waiter.cleanup();
|
|
39
|
+
if (ok)
|
|
40
|
+
waiter.resolve(value);
|
|
41
|
+
else
|
|
42
|
+
waiter.reject(value);
|
|
43
|
+
}
|
|
44
|
+
entry.waiters.clear();
|
|
45
|
+
}
|
|
46
|
+
function cancelEntry(key, entry, reason) {
|
|
47
|
+
finish(key, entry, false, reason);
|
|
48
|
+
entry.controller.abort(reason);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
get size() { return entries.size; },
|
|
52
|
+
has: key => entries.has(key),
|
|
53
|
+
waiters: key => entries.get(key)?.waiters.size ?? 0,
|
|
54
|
+
cancel(key, reason = aborted()) {
|
|
55
|
+
const entry = entries.get(key);
|
|
56
|
+
if (!entry)
|
|
57
|
+
return false;
|
|
58
|
+
cancelEntry(key, entry, reason);
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
clear(reason = aborted()) {
|
|
62
|
+
for (const [key, entry] of [...entries])
|
|
63
|
+
cancelEntry(key, entry, reason);
|
|
64
|
+
},
|
|
65
|
+
run(key, runOptions = {}) {
|
|
66
|
+
// Reject invalid run options through the returned promise, like worker errors.
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const { signal, timeoutMs } = runOptions;
|
|
69
|
+
if (timeoutMs !== undefined && (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647)) {
|
|
70
|
+
throw new RangeError('timeoutMs must be an integer from 1 to 2147483647');
|
|
71
|
+
}
|
|
72
|
+
if (signal?.aborted) {
|
|
73
|
+
reject(signal.reason);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let entry = entries.get(key);
|
|
77
|
+
const fresh = !entry;
|
|
78
|
+
if (!entry) {
|
|
79
|
+
if (entries.size >= maxKeys) {
|
|
80
|
+
reject(new CapacityError('maxKeys'));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
entry = { controller: new AbortController(), waiters: new Set(), done: false };
|
|
84
|
+
entries.set(key, entry);
|
|
85
|
+
}
|
|
86
|
+
if (entry.waiters.size >= maxWaiters) {
|
|
87
|
+
reject(new CapacityError('maxWaitersPerKey'));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const current = entry;
|
|
91
|
+
let timer;
|
|
92
|
+
const leave = (reason) => {
|
|
93
|
+
if (!current.waiters.delete(waiter))
|
|
94
|
+
return;
|
|
95
|
+
waiter.cleanup();
|
|
96
|
+
reject(reason);
|
|
97
|
+
if (!current.done && current.waiters.size === 0) {
|
|
98
|
+
detach(key, current);
|
|
99
|
+
current.controller.abort(reason);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const onAbort = () => leave(signal.reason);
|
|
103
|
+
const waiter = {
|
|
104
|
+
resolve, reject,
|
|
105
|
+
cleanup() {
|
|
106
|
+
if (timer !== undefined)
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
signal?.removeEventListener('abort', onAbort);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
current.waiters.add(waiter);
|
|
112
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
113
|
+
if (timeoutMs !== undefined)
|
|
114
|
+
timer = setTimeout(() => leave(new DOMException('The caller deadline expired', 'TimeoutError')), timeoutMs);
|
|
115
|
+
if (fresh) {
|
|
116
|
+
// Defer the worker so callers in the same turn can join or cancel first.
|
|
117
|
+
void Promise.resolve().then(() => {
|
|
118
|
+
if (current.done)
|
|
119
|
+
return;
|
|
120
|
+
return worker(key, current.controller.signal);
|
|
121
|
+
}).then(value => finish(key, current, true, value), error => finish(key, current, false, error));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** A caller was refused because the configured admission limit was reached. */
|
|
2
|
+
export declare class CapacityError extends Error {
|
|
3
|
+
readonly limit: 'maxKeys' | 'maxWaitersPerKey';
|
|
4
|
+
readonly name = "CapacityError";
|
|
5
|
+
constructor(limit: 'maxKeys' | 'maxWaitersPerKey');
|
|
6
|
+
}
|
|
7
|
+
export interface FlightOptions {
|
|
8
|
+
/** Maximum tracked keys. Default: 1024. */
|
|
9
|
+
maxKeys?: number;
|
|
10
|
+
/** Maximum simultaneous callers for one key. Default: 1024. */
|
|
11
|
+
maxWaitersPerKey?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface RunOptions {
|
|
14
|
+
/** Cancels only this caller, unless it is the last caller. */
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
/** Per-caller deadline, in milliseconds (1 to 2147483647). */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface Flight<K, V> {
|
|
20
|
+
run(key: K, options?: RunOptions): Promise<V>;
|
|
21
|
+
/** Tracked keys, not physical operations that ignore cancellation. */
|
|
22
|
+
readonly size: number;
|
|
23
|
+
has(key: K): boolean;
|
|
24
|
+
waiters(key: K): number;
|
|
25
|
+
/** Reject current callers and request cooperative cancellation. */
|
|
26
|
+
cancel(key: K, reason?: unknown): boolean;
|
|
27
|
+
/** Cancel a snapshot of all currently tracked keys. */
|
|
28
|
+
clear(reason?: unknown): void;
|
|
29
|
+
}
|
|
30
|
+
/** One worker per key while callers are waiting. Results and failures are never cached. */
|
|
31
|
+
export declare function createFlight<K, V>(worker: (key: K, signal: AbortSignal) => V | PromiseLike<V>, options?: FlightOptions): Flight<K, V>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** A caller was refused because the configured admission limit was reached. */
|
|
2
|
+
export class CapacityError extends Error {
|
|
3
|
+
limit;
|
|
4
|
+
name = 'CapacityError';
|
|
5
|
+
constructor(limit) {
|
|
6
|
+
super(`inflight-kit: ${limit} reached`);
|
|
7
|
+
this.limit = limit;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const aborted = () => new DOMException('The operation was aborted', 'AbortError');
|
|
11
|
+
function positive(value, name) {
|
|
12
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
13
|
+
throw new RangeError(`${name} must be a positive safe integer`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
/** One worker per key while callers are waiting. Results and failures are never cached. */
|
|
17
|
+
export function createFlight(worker, options = {}) {
|
|
18
|
+
if (typeof worker !== 'function')
|
|
19
|
+
throw new TypeError('worker must be a function');
|
|
20
|
+
const maxKeys = positive(options.maxKeys ?? 1024, 'maxKeys');
|
|
21
|
+
const maxWaiters = positive(options.maxWaitersPerKey ?? 1024, 'maxWaitersPerKey');
|
|
22
|
+
const entries = new Map();
|
|
23
|
+
function detach(key, entry) {
|
|
24
|
+
entry.done = true;
|
|
25
|
+
if (entries.get(key) === entry)
|
|
26
|
+
entries.delete(key);
|
|
27
|
+
}
|
|
28
|
+
function finish(key, entry, ok, value) {
|
|
29
|
+
if (entry.done)
|
|
30
|
+
return;
|
|
31
|
+
detach(key, entry);
|
|
32
|
+
for (const waiter of entry.waiters) {
|
|
33
|
+
waiter.cleanup();
|
|
34
|
+
if (ok)
|
|
35
|
+
waiter.resolve(value);
|
|
36
|
+
else
|
|
37
|
+
waiter.reject(value);
|
|
38
|
+
}
|
|
39
|
+
entry.waiters.clear();
|
|
40
|
+
}
|
|
41
|
+
function cancelEntry(key, entry, reason) {
|
|
42
|
+
finish(key, entry, false, reason);
|
|
43
|
+
entry.controller.abort(reason);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
get size() { return entries.size; },
|
|
47
|
+
has: key => entries.has(key),
|
|
48
|
+
waiters: key => entries.get(key)?.waiters.size ?? 0,
|
|
49
|
+
cancel(key, reason = aborted()) {
|
|
50
|
+
const entry = entries.get(key);
|
|
51
|
+
if (!entry)
|
|
52
|
+
return false;
|
|
53
|
+
cancelEntry(key, entry, reason);
|
|
54
|
+
return true;
|
|
55
|
+
},
|
|
56
|
+
clear(reason = aborted()) {
|
|
57
|
+
for (const [key, entry] of [...entries])
|
|
58
|
+
cancelEntry(key, entry, reason);
|
|
59
|
+
},
|
|
60
|
+
run(key, runOptions = {}) {
|
|
61
|
+
// Reject invalid run options through the returned promise, like worker errors.
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const { signal, timeoutMs } = runOptions;
|
|
64
|
+
if (timeoutMs !== undefined && (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647)) {
|
|
65
|
+
throw new RangeError('timeoutMs must be an integer from 1 to 2147483647');
|
|
66
|
+
}
|
|
67
|
+
if (signal?.aborted) {
|
|
68
|
+
reject(signal.reason);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let entry = entries.get(key);
|
|
72
|
+
const fresh = !entry;
|
|
73
|
+
if (!entry) {
|
|
74
|
+
if (entries.size >= maxKeys) {
|
|
75
|
+
reject(new CapacityError('maxKeys'));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
entry = { controller: new AbortController(), waiters: new Set(), done: false };
|
|
79
|
+
entries.set(key, entry);
|
|
80
|
+
}
|
|
81
|
+
if (entry.waiters.size >= maxWaiters) {
|
|
82
|
+
reject(new CapacityError('maxWaitersPerKey'));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const current = entry;
|
|
86
|
+
let timer;
|
|
87
|
+
const leave = (reason) => {
|
|
88
|
+
if (!current.waiters.delete(waiter))
|
|
89
|
+
return;
|
|
90
|
+
waiter.cleanup();
|
|
91
|
+
reject(reason);
|
|
92
|
+
if (!current.done && current.waiters.size === 0) {
|
|
93
|
+
detach(key, current);
|
|
94
|
+
current.controller.abort(reason);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const onAbort = () => leave(signal.reason);
|
|
98
|
+
const waiter = {
|
|
99
|
+
resolve, reject,
|
|
100
|
+
cleanup() {
|
|
101
|
+
if (timer !== undefined)
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
signal?.removeEventListener('abort', onAbort);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
current.waiters.add(waiter);
|
|
107
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
108
|
+
if (timeoutMs !== undefined)
|
|
109
|
+
timer = setTimeout(() => leave(new DOMException('The caller deadline expired', 'TimeoutError')), timeoutMs);
|
|
110
|
+
if (fresh) {
|
|
111
|
+
// Defer the worker so callers in the same turn can join or cancel first.
|
|
112
|
+
void Promise.resolve().then(() => {
|
|
113
|
+
if (current.done)
|
|
114
|
+
return;
|
|
115
|
+
return worker(key, current.controller.signal);
|
|
116
|
+
}).then(value => finish(key, current, true, value), error => finish(key, current, false, error));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "inflight-kit",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Share pending async work without sharing cancellation. Typed singleflight with per-caller deadlines and bounded admission.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/cjs/index.js",
|
|
7
|
+
"module": "./dist/esm/index.js",
|
|
8
|
+
"types": "./dist/esm/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/esm/index.d.ts",
|
|
13
|
+
"default": "./dist/esm/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/cjs/index.d.ts",
|
|
17
|
+
"default": "./dist/cjs/index.js"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "node scripts/build.mjs",
|
|
32
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
33
|
+
"test:types": "tsc --noEmit --strict --module NodeNext --moduleResolution NodeNext --target ES2022 test/types.mts test/types.cts",
|
|
34
|
+
"check": "npm test && npm run test:types",
|
|
35
|
+
"demo": "npm run build && node examples/demo.mjs",
|
|
36
|
+
"prepack": "npm run build",
|
|
37
|
+
"test:package": "node scripts/smoke-pack.mjs"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"singleflight",
|
|
41
|
+
"deduplicate",
|
|
42
|
+
"async",
|
|
43
|
+
"abortsignal",
|
|
44
|
+
"fetch",
|
|
45
|
+
"request-coalescing",
|
|
46
|
+
"typescript",
|
|
47
|
+
"cancellation"
|
|
48
|
+
],
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"author": "Farhad Arjmand",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/farhad-arjmand/inflight-kit.git"
|
|
54
|
+
},
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/farhad-arjmand/inflight-kit/issues"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://github.com/farhad-arjmand/inflight-kit#readme",
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public",
|
|
61
|
+
"registry": "https://registry.npmjs.org/"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"typescript": "7.0.2"
|
|
65
|
+
}
|
|
66
|
+
}
|