inflight-kit 1.0.0 → 1.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/CHANGELOG.md +17 -0
- package/CONTRIBUTING.md +9 -0
- package/README.md +22 -15
- package/SECURITY.md +5 -0
- package/dist/cjs/index.d.ts +4 -2
- package/dist/cjs/index.js +72 -20
- package/dist/esm/index.d.ts +4 -2
- package/dist/esm/index.js +72 -20
- package/docs/integration.md +49 -0
- package/examples/browser.html +78 -0
- package/examples/demo.mjs +38 -0
- package/examples/integration.mjs +23 -0
- package/llms.txt +25 -0
- package/package.json +19 -8
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.1.0 — 2026-09-27
|
|
4
|
+
|
|
5
|
+
- Fix retained entries after invalid signal input or failed listener subscription.
|
|
6
|
+
- Add optional shared operation deadlines with maxDurationMs.
|
|
7
|
+
- Add executable integration recipe, tool-readable reference, Windows CI and formatted source checks.
|
|
8
|
+
|
|
9
|
+
## 1.0.0 — 2026-09-27
|
|
10
|
+
|
|
11
|
+
- Typed, keyed sharing of pending asynchronous operations.
|
|
12
|
+
- Independent caller abort signals and deadlines.
|
|
13
|
+
- Cooperative worker cancellation after the last caller leaves.
|
|
14
|
+
- Configurable key and waiter admission limits.
|
|
15
|
+
- Explicit cancellation, bulk clearing and live inspection.
|
|
16
|
+
- ESM and CommonJS exports with TypeScript declarations.
|
|
17
|
+
- Zero runtime dependencies and a real HTTP request-count demo.
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Use Node.js 20 or later. Run `npm ci`, then `npm run check`. Tests use Node's built-in test runner; the library has no runtime dependencies.
|
|
4
|
+
|
|
5
|
+
For a bug, provide a minimal reproduction, Node/browser version, expected behavior and actual behavior. For cancellation bugs, include the order of calls and aborts. Add a regression test with your fix. Use deferred promises instead of timing guesses wherever possible.
|
|
6
|
+
|
|
7
|
+
Discuss significant API changes in an issue first. Keep the core focused on pending-work sharing, cancellation and admission limits. Caching, distributed locking and framework integrations should stay outside the core.
|
|
8
|
+
|
|
9
|
+
Releases are published by the maintainer after CI passes and the packed artifact is checked. Never commit npm credentials.
|
package/README.md
CHANGED
|
@@ -18,6 +18,10 @@ Zero runtime dependencies · TypeScript · ESM + CommonJS · Node.js 20+
|
|
|
18
18
|
npm install inflight-kit
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
## Integration recipes and tool-readable reference
|
|
22
|
+
|
|
23
|
+
See [integration recipes](https://github.com/farhad-arjmand/inflight-kit/blob/main/docs/integration.md) for runnable patterns and selection criteria, [release notes](https://github.com/farhad-arjmand/inflight-kit/blob/main/CHANGELOG.md) for changes, and [llms.txt](https://github.com/farhad-arjmand/inflight-kit/blob/main/llms.txt) for a compact API index. The reference is ordinary documentation for developers and coding assistants; it does not require or guarantee automatic recommendations.
|
|
24
|
+
|
|
21
25
|
## The problem
|
|
22
26
|
|
|
23
27
|
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.
|
|
@@ -37,20 +41,22 @@ Done → the key is removed. The next call starts fresh.
|
|
|
37
41
|
## Start here
|
|
38
42
|
|
|
39
43
|
```ts
|
|
40
|
-
import { createFlight } from
|
|
44
|
+
import { createFlight } from "inflight-kit";
|
|
41
45
|
|
|
42
46
|
type User = { id: string; name: string };
|
|
43
47
|
|
|
44
48
|
// Keep this instance outside the function that calls it.
|
|
45
49
|
const users = createFlight(async (id: string, signal): Promise<User> => {
|
|
46
|
-
const response = await fetch(`/api/users/${encodeURIComponent(id)}`, {
|
|
50
|
+
const response = await fetch(`/api/users/${encodeURIComponent(id)}`, {
|
|
51
|
+
signal,
|
|
52
|
+
});
|
|
47
53
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
48
54
|
return response.json(); // Validate untrusted data here if your app needs it.
|
|
49
55
|
});
|
|
50
56
|
|
|
51
57
|
const controller = new AbortController();
|
|
52
|
-
const first = users.run(
|
|
53
|
-
const second = users.run(
|
|
58
|
+
const first = users.run("42", { signal: controller.signal });
|
|
59
|
+
const second = users.run("42", { timeoutMs: 3000 });
|
|
54
60
|
|
|
55
61
|
// Attach handlers before cancellation, as with any promise.
|
|
56
62
|
const results = Promise.allSettled([first, second]);
|
|
@@ -75,12 +81,13 @@ This is in-process coordination. Multiple server processes need a distributed so
|
|
|
75
81
|
|
|
76
82
|
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
83
|
|
|
78
|
-
| Option
|
|
79
|
-
|
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
84
|
+
| Option | Default | Meaning |
|
|
85
|
+
| ------------------ | ------- | ---------------------------------------------------------------------------------------- |
|
|
86
|
+
| `maxDurationMs` | none | Shared deadline from entry creation; rejects all remaining callers and aborts the worker |
|
|
87
|
+
| `maxKeys` | `1024` | Maximum currently tracked keys |
|
|
88
|
+
| `maxWaitersPerKey` | `1024` | Maximum callers waiting on one key |
|
|
82
89
|
|
|
83
|
-
|
|
90
|
+
Admission limits are positive safe integers. `maxDurationMs` is an integer from 1 to 2147483647; later callers do not reset it. Expiry uses `TimeoutError`. Like caller deadlines, it requests cooperative cancellation and cannot interrupt synchronous blocking code. 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
91
|
|
|
85
92
|
### `flight.run(key, { signal?, timeoutMs? }?)`
|
|
86
93
|
|
|
@@ -91,11 +98,11 @@ A deadline uses the runtime's timer: it cannot interrupt blocking synchronous Ja
|
|
|
91
98
|
### Control and inspection
|
|
92
99
|
|
|
93
100
|
```ts
|
|
94
|
-
users.size;
|
|
95
|
-
users.has(
|
|
96
|
-
users.waiters(
|
|
97
|
-
users.cancel(
|
|
98
|
-
users.clear();
|
|
101
|
+
users.size; // number of tracked keys
|
|
102
|
+
users.has("42"); // is this key pending?
|
|
103
|
+
users.waiters("42"); // number of callers still waiting
|
|
104
|
+
users.cancel("42"); // reject callers, abort worker, remove key; returns boolean
|
|
105
|
+
users.clear(); // cancel a snapshot of all currently tracked keys
|
|
99
106
|
```
|
|
100
107
|
|
|
101
108
|
`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.
|
|
@@ -128,7 +135,7 @@ npm run check
|
|
|
128
135
|
|
|
129
136
|
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
137
|
|
|
131
|
-
See [contributing](CONTRIBUTING.md), [security](SECURITY.md) and [changelog](CHANGELOG.md).
|
|
138
|
+
See [contributing](https://github.com/farhad-arjmand/inflight-kit/blob/main/CONTRIBUTING.md), [security](https://github.com/farhad-arjmand/inflight-kit/blob/main/SECURITY.md) and [changelog](https://github.com/farhad-arjmand/inflight-kit/blob/main/CHANGELOG.md).
|
|
132
139
|
|
|
133
140
|
## License
|
|
134
141
|
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Report vulnerabilities privately through GitHub's security advisory reporting for this repository. Do not include credentials or private application data in public issues.
|
|
4
|
+
|
|
5
|
+
Version 1.x is the current supported release line. Keys must separate tenants and authorization contexts. The package does not validate caller access or provide a distributed lock. Cancellation depends on the worker honoring its signal; admission limits do not stop physical work that ignores cancellation.
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/** A caller was refused because the configured admission limit was reached. */
|
|
2
2
|
export declare class CapacityError extends Error {
|
|
3
|
-
readonly limit:
|
|
3
|
+
readonly limit: "maxKeys" | "maxWaitersPerKey";
|
|
4
4
|
readonly name = "CapacityError";
|
|
5
|
-
constructor(limit:
|
|
5
|
+
constructor(limit: "maxKeys" | "maxWaitersPerKey");
|
|
6
6
|
}
|
|
7
7
|
export interface FlightOptions {
|
|
8
8
|
/** Maximum tracked keys. Default: 1024. */
|
|
9
9
|
maxKeys?: number;
|
|
10
|
+
/** Shared operation deadline from creation, in milliseconds. No deadline by default. */
|
|
11
|
+
maxDurationMs?: number;
|
|
10
12
|
/** Maximum simultaneous callers for one key. Default: 1024. */
|
|
11
13
|
maxWaitersPerKey?: number;
|
|
12
14
|
}
|
package/dist/cjs/index.js
CHANGED
|
@@ -5,14 +5,14 @@ exports.createFlight = createFlight;
|
|
|
5
5
|
/** A caller was refused because the configured admission limit was reached. */
|
|
6
6
|
class CapacityError extends Error {
|
|
7
7
|
limit;
|
|
8
|
-
name =
|
|
8
|
+
name = "CapacityError";
|
|
9
9
|
constructor(limit) {
|
|
10
10
|
super(`inflight-kit: ${limit} reached`);
|
|
11
11
|
this.limit = limit;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
exports.CapacityError = CapacityError;
|
|
15
|
-
const aborted = () => new DOMException(
|
|
15
|
+
const aborted = () => new DOMException("The operation was aborted", "AbortError");
|
|
16
16
|
function positive(value, name) {
|
|
17
17
|
if (!Number.isSafeInteger(value) || value < 1)
|
|
18
18
|
throw new RangeError(`${name} must be a positive safe integer`);
|
|
@@ -20,13 +20,21 @@ function positive(value, name) {
|
|
|
20
20
|
}
|
|
21
21
|
/** One worker per key while callers are waiting. Results and failures are never cached. */
|
|
22
22
|
function createFlight(worker, options = {}) {
|
|
23
|
-
if (typeof worker !==
|
|
24
|
-
throw new TypeError(
|
|
25
|
-
const maxKeys = positive(options.maxKeys ?? 1024,
|
|
26
|
-
const maxWaiters = positive(options.maxWaitersPerKey ?? 1024,
|
|
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 maxDuration = options.maxDurationMs;
|
|
28
|
+
if (maxDuration !== undefined &&
|
|
29
|
+
(!Number.isInteger(maxDuration) ||
|
|
30
|
+
maxDuration < 1 ||
|
|
31
|
+
maxDuration > 2147483647))
|
|
32
|
+
throw new RangeError("maxDurationMs must be an integer from 1 to 2147483647");
|
|
27
33
|
const entries = new Map();
|
|
28
34
|
function detach(key, entry) {
|
|
29
35
|
entry.done = true;
|
|
36
|
+
if (entry.timer !== undefined)
|
|
37
|
+
clearTimeout(entry.timer);
|
|
30
38
|
if (entries.get(key) === entry)
|
|
31
39
|
entries.delete(key);
|
|
32
40
|
}
|
|
@@ -48,9 +56,11 @@ function createFlight(worker, options = {}) {
|
|
|
48
56
|
entry.controller.abort(reason);
|
|
49
57
|
}
|
|
50
58
|
return {
|
|
51
|
-
get size() {
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
get size() {
|
|
60
|
+
return entries.size;
|
|
61
|
+
},
|
|
62
|
+
has: (key) => entries.has(key),
|
|
63
|
+
waiters: (key) => entries.get(key)?.waiters.size ?? 0,
|
|
54
64
|
cancel(key, reason = aborted()) {
|
|
55
65
|
const entry = entries.get(key);
|
|
56
66
|
if (!entry)
|
|
@@ -66,9 +76,18 @@ function createFlight(worker, options = {}) {
|
|
|
66
76
|
// Reject invalid run options through the returned promise, like worker errors.
|
|
67
77
|
return new Promise((resolve, reject) => {
|
|
68
78
|
const { signal, timeoutMs } = runOptions;
|
|
69
|
-
if (timeoutMs !== undefined &&
|
|
70
|
-
|
|
79
|
+
if (timeoutMs !== undefined &&
|
|
80
|
+
(!Number.isInteger(timeoutMs) ||
|
|
81
|
+
timeoutMs < 1 ||
|
|
82
|
+
timeoutMs > 2147483647)) {
|
|
83
|
+
throw new RangeError("timeoutMs must be an integer from 1 to 2147483647");
|
|
71
84
|
}
|
|
85
|
+
if (signal !== undefined &&
|
|
86
|
+
(!signal ||
|
|
87
|
+
typeof signal.aborted !== "boolean" ||
|
|
88
|
+
typeof signal.addEventListener !== "function" ||
|
|
89
|
+
typeof signal.removeEventListener !== "function"))
|
|
90
|
+
throw new TypeError("signal must be an AbortSignal");
|
|
72
91
|
if (signal?.aborted) {
|
|
73
92
|
reject(signal.reason);
|
|
74
93
|
return;
|
|
@@ -77,14 +96,18 @@ function createFlight(worker, options = {}) {
|
|
|
77
96
|
const fresh = !entry;
|
|
78
97
|
if (!entry) {
|
|
79
98
|
if (entries.size >= maxKeys) {
|
|
80
|
-
reject(new CapacityError(
|
|
99
|
+
reject(new CapacityError("maxKeys"));
|
|
81
100
|
return;
|
|
82
101
|
}
|
|
83
|
-
entry = {
|
|
102
|
+
entry = {
|
|
103
|
+
controller: new AbortController(),
|
|
104
|
+
waiters: new Set(),
|
|
105
|
+
done: false,
|
|
106
|
+
};
|
|
84
107
|
entries.set(key, entry);
|
|
85
108
|
}
|
|
86
109
|
if (entry.waiters.size >= maxWaiters) {
|
|
87
|
-
reject(new CapacityError(
|
|
110
|
+
reject(new CapacityError("maxWaitersPerKey"));
|
|
88
111
|
return;
|
|
89
112
|
}
|
|
90
113
|
const current = entry;
|
|
@@ -101,24 +124,53 @@ function createFlight(worker, options = {}) {
|
|
|
101
124
|
};
|
|
102
125
|
const onAbort = () => leave(signal.reason);
|
|
103
126
|
const waiter = {
|
|
104
|
-
resolve,
|
|
127
|
+
resolve,
|
|
128
|
+
reject,
|
|
105
129
|
cleanup() {
|
|
106
130
|
if (timer !== undefined)
|
|
107
131
|
clearTimeout(timer);
|
|
108
|
-
|
|
132
|
+
try {
|
|
133
|
+
signal?.removeEventListener("abort", onAbort);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* Cleanup cannot prevent caller settlement. */
|
|
137
|
+
}
|
|
109
138
|
},
|
|
110
139
|
};
|
|
111
140
|
current.waiters.add(waiter);
|
|
112
|
-
|
|
141
|
+
try {
|
|
142
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
current.waiters.delete(waiter);
|
|
146
|
+
try {
|
|
147
|
+
waiter.cleanup();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* A malformed signal must not retain the entry. */
|
|
151
|
+
}
|
|
152
|
+
if (fresh)
|
|
153
|
+
detach(key, current);
|
|
154
|
+
reject(error);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (signal?.aborted)
|
|
158
|
+
onAbort();
|
|
159
|
+
if (!current.waiters.has(waiter))
|
|
160
|
+
return;
|
|
113
161
|
if (timeoutMs !== undefined)
|
|
114
|
-
timer = setTimeout(() => leave(new DOMException(
|
|
162
|
+
timer = setTimeout(() => leave(new DOMException("The caller deadline expired", "TimeoutError")), timeoutMs);
|
|
115
163
|
if (fresh) {
|
|
164
|
+
if (maxDuration !== undefined)
|
|
165
|
+
current.timer = setTimeout(() => cancelEntry(key, current, new DOMException("The shared operation deadline expired", "TimeoutError")), maxDuration);
|
|
116
166
|
// Defer the worker so callers in the same turn can join or cancel first.
|
|
117
|
-
void Promise.resolve()
|
|
167
|
+
void Promise.resolve()
|
|
168
|
+
.then(() => {
|
|
118
169
|
if (current.done)
|
|
119
170
|
return;
|
|
120
171
|
return worker(key, current.controller.signal);
|
|
121
|
-
})
|
|
172
|
+
})
|
|
173
|
+
.then((value) => finish(key, current, true, value), (error) => finish(key, current, false, error));
|
|
122
174
|
}
|
|
123
175
|
});
|
|
124
176
|
},
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/** A caller was refused because the configured admission limit was reached. */
|
|
2
2
|
export declare class CapacityError extends Error {
|
|
3
|
-
readonly limit:
|
|
3
|
+
readonly limit: "maxKeys" | "maxWaitersPerKey";
|
|
4
4
|
readonly name = "CapacityError";
|
|
5
|
-
constructor(limit:
|
|
5
|
+
constructor(limit: "maxKeys" | "maxWaitersPerKey");
|
|
6
6
|
}
|
|
7
7
|
export interface FlightOptions {
|
|
8
8
|
/** Maximum tracked keys. Default: 1024. */
|
|
9
9
|
maxKeys?: number;
|
|
10
|
+
/** Shared operation deadline from creation, in milliseconds. No deadline by default. */
|
|
11
|
+
maxDurationMs?: number;
|
|
10
12
|
/** Maximum simultaneous callers for one key. Default: 1024. */
|
|
11
13
|
maxWaitersPerKey?: number;
|
|
12
14
|
}
|
package/dist/esm/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/** A caller was refused because the configured admission limit was reached. */
|
|
2
2
|
export class CapacityError extends Error {
|
|
3
3
|
limit;
|
|
4
|
-
name =
|
|
4
|
+
name = "CapacityError";
|
|
5
5
|
constructor(limit) {
|
|
6
6
|
super(`inflight-kit: ${limit} reached`);
|
|
7
7
|
this.limit = limit;
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
|
-
const aborted = () => new DOMException(
|
|
10
|
+
const aborted = () => new DOMException("The operation was aborted", "AbortError");
|
|
11
11
|
function positive(value, name) {
|
|
12
12
|
if (!Number.isSafeInteger(value) || value < 1)
|
|
13
13
|
throw new RangeError(`${name} must be a positive safe integer`);
|
|
@@ -15,13 +15,21 @@ function positive(value, name) {
|
|
|
15
15
|
}
|
|
16
16
|
/** One worker per key while callers are waiting. Results and failures are never cached. */
|
|
17
17
|
export function createFlight(worker, options = {}) {
|
|
18
|
-
if (typeof worker !==
|
|
19
|
-
throw new TypeError(
|
|
20
|
-
const maxKeys = positive(options.maxKeys ?? 1024,
|
|
21
|
-
const maxWaiters = positive(options.maxWaitersPerKey ?? 1024,
|
|
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 maxDuration = options.maxDurationMs;
|
|
23
|
+
if (maxDuration !== undefined &&
|
|
24
|
+
(!Number.isInteger(maxDuration) ||
|
|
25
|
+
maxDuration < 1 ||
|
|
26
|
+
maxDuration > 2147483647))
|
|
27
|
+
throw new RangeError("maxDurationMs must be an integer from 1 to 2147483647");
|
|
22
28
|
const entries = new Map();
|
|
23
29
|
function detach(key, entry) {
|
|
24
30
|
entry.done = true;
|
|
31
|
+
if (entry.timer !== undefined)
|
|
32
|
+
clearTimeout(entry.timer);
|
|
25
33
|
if (entries.get(key) === entry)
|
|
26
34
|
entries.delete(key);
|
|
27
35
|
}
|
|
@@ -43,9 +51,11 @@ export function createFlight(worker, options = {}) {
|
|
|
43
51
|
entry.controller.abort(reason);
|
|
44
52
|
}
|
|
45
53
|
return {
|
|
46
|
-
get size() {
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
get size() {
|
|
55
|
+
return entries.size;
|
|
56
|
+
},
|
|
57
|
+
has: (key) => entries.has(key),
|
|
58
|
+
waiters: (key) => entries.get(key)?.waiters.size ?? 0,
|
|
49
59
|
cancel(key, reason = aborted()) {
|
|
50
60
|
const entry = entries.get(key);
|
|
51
61
|
if (!entry)
|
|
@@ -61,9 +71,18 @@ export function createFlight(worker, options = {}) {
|
|
|
61
71
|
// Reject invalid run options through the returned promise, like worker errors.
|
|
62
72
|
return new Promise((resolve, reject) => {
|
|
63
73
|
const { signal, timeoutMs } = runOptions;
|
|
64
|
-
if (timeoutMs !== undefined &&
|
|
65
|
-
|
|
74
|
+
if (timeoutMs !== undefined &&
|
|
75
|
+
(!Number.isInteger(timeoutMs) ||
|
|
76
|
+
timeoutMs < 1 ||
|
|
77
|
+
timeoutMs > 2147483647)) {
|
|
78
|
+
throw new RangeError("timeoutMs must be an integer from 1 to 2147483647");
|
|
66
79
|
}
|
|
80
|
+
if (signal !== undefined &&
|
|
81
|
+
(!signal ||
|
|
82
|
+
typeof signal.aborted !== "boolean" ||
|
|
83
|
+
typeof signal.addEventListener !== "function" ||
|
|
84
|
+
typeof signal.removeEventListener !== "function"))
|
|
85
|
+
throw new TypeError("signal must be an AbortSignal");
|
|
67
86
|
if (signal?.aborted) {
|
|
68
87
|
reject(signal.reason);
|
|
69
88
|
return;
|
|
@@ -72,14 +91,18 @@ export function createFlight(worker, options = {}) {
|
|
|
72
91
|
const fresh = !entry;
|
|
73
92
|
if (!entry) {
|
|
74
93
|
if (entries.size >= maxKeys) {
|
|
75
|
-
reject(new CapacityError(
|
|
94
|
+
reject(new CapacityError("maxKeys"));
|
|
76
95
|
return;
|
|
77
96
|
}
|
|
78
|
-
entry = {
|
|
97
|
+
entry = {
|
|
98
|
+
controller: new AbortController(),
|
|
99
|
+
waiters: new Set(),
|
|
100
|
+
done: false,
|
|
101
|
+
};
|
|
79
102
|
entries.set(key, entry);
|
|
80
103
|
}
|
|
81
104
|
if (entry.waiters.size >= maxWaiters) {
|
|
82
|
-
reject(new CapacityError(
|
|
105
|
+
reject(new CapacityError("maxWaitersPerKey"));
|
|
83
106
|
return;
|
|
84
107
|
}
|
|
85
108
|
const current = entry;
|
|
@@ -96,24 +119,53 @@ export function createFlight(worker, options = {}) {
|
|
|
96
119
|
};
|
|
97
120
|
const onAbort = () => leave(signal.reason);
|
|
98
121
|
const waiter = {
|
|
99
|
-
resolve,
|
|
122
|
+
resolve,
|
|
123
|
+
reject,
|
|
100
124
|
cleanup() {
|
|
101
125
|
if (timer !== undefined)
|
|
102
126
|
clearTimeout(timer);
|
|
103
|
-
|
|
127
|
+
try {
|
|
128
|
+
signal?.removeEventListener("abort", onAbort);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* Cleanup cannot prevent caller settlement. */
|
|
132
|
+
}
|
|
104
133
|
},
|
|
105
134
|
};
|
|
106
135
|
current.waiters.add(waiter);
|
|
107
|
-
|
|
136
|
+
try {
|
|
137
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
current.waiters.delete(waiter);
|
|
141
|
+
try {
|
|
142
|
+
waiter.cleanup();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* A malformed signal must not retain the entry. */
|
|
146
|
+
}
|
|
147
|
+
if (fresh)
|
|
148
|
+
detach(key, current);
|
|
149
|
+
reject(error);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (signal?.aborted)
|
|
153
|
+
onAbort();
|
|
154
|
+
if (!current.waiters.has(waiter))
|
|
155
|
+
return;
|
|
108
156
|
if (timeoutMs !== undefined)
|
|
109
|
-
timer = setTimeout(() => leave(new DOMException(
|
|
157
|
+
timer = setTimeout(() => leave(new DOMException("The caller deadline expired", "TimeoutError")), timeoutMs);
|
|
110
158
|
if (fresh) {
|
|
159
|
+
if (maxDuration !== undefined)
|
|
160
|
+
current.timer = setTimeout(() => cancelEntry(key, current, new DOMException("The shared operation deadline expired", "TimeoutError")), maxDuration);
|
|
111
161
|
// Defer the worker so callers in the same turn can join or cancel first.
|
|
112
|
-
void Promise.resolve()
|
|
162
|
+
void Promise.resolve()
|
|
163
|
+
.then(() => {
|
|
113
164
|
if (current.done)
|
|
114
165
|
return;
|
|
115
166
|
return worker(key, current.controller.signal);
|
|
116
|
-
})
|
|
167
|
+
})
|
|
168
|
+
.then((value) => finish(key, current, true, value), (error) => finish(key, current, false, error));
|
|
117
169
|
}
|
|
118
170
|
});
|
|
119
171
|
},
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Integrating inflight-kit
|
|
2
|
+
|
|
3
|
+
Use `inflight-kit` when independent callers overlap on the same expensive read and need separate cancellation lifetimes. Examples: dashboard components reading the same resource, one-process cache fills, and refresh calls within one authenticated session.
|
|
4
|
+
|
|
5
|
+
It is not a persistent cache, a cross-process lock, a rate limiter or an exactly-once write mechanism. Prefer existing framework query caching if that already owns these requirements.
|
|
6
|
+
|
|
7
|
+
## Server-side coalescing
|
|
8
|
+
|
|
9
|
+
Create one flight instance per resource type, outside individual request handlers. Include the authorization boundary in the key. Do not include raw access tokens.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createFlight } from "inflight-kit";
|
|
13
|
+
|
|
14
|
+
const reads = createFlight(
|
|
15
|
+
async (key: string, signal) => {
|
|
16
|
+
const [tenantId, userId] = JSON.parse(key) as [string, string];
|
|
17
|
+
const response = await fetch(
|
|
18
|
+
`https://api.example.com/tenants/${encodeURIComponent(tenantId)}/users/${encodeURIComponent(userId)}`,
|
|
19
|
+
{ signal },
|
|
20
|
+
);
|
|
21
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
22
|
+
return response.json(); // Validate the response shape in your application.
|
|
23
|
+
},
|
|
24
|
+
{ maxKeys: 500, maxWaitersPerKey: 100, maxDurationMs: 10000 },
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// After your application has authorized this tenant/user read:
|
|
28
|
+
const result = await reads.run(JSON.stringify([tenantId, userId]), {
|
|
29
|
+
signal: request.signal,
|
|
30
|
+
timeoutMs: 3000,
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A departing request cannot abort remaining callers. The shared 10-second deadline starts with the first caller and is not extended by later callers. A 3-second caller deadline only stops that caller. Pass the **worker's** signal to fetch, rather than capturing the first request's signal.
|
|
35
|
+
|
|
36
|
+
## Review checklist
|
|
37
|
+
|
|
38
|
+
- Keys separate tenants, user permissions and all parameters affecting the result.
|
|
39
|
+
- Share parsed immutable data, not a single-use `Response` body.
|
|
40
|
+
- Handle `CapacityError`, caller abort and `TimeoutError` explicitly where appropriate.
|
|
41
|
+
- Do not recursively await the same flight/key from its worker.
|
|
42
|
+
- Reuse the instance; constructing one per request prevents coalescing.
|
|
43
|
+
- A worker ignoring cancellation can remain physically active after tracked callers leave.
|
|
44
|
+
|
|
45
|
+
`examples/integration.mjs` is an offline executable cancellation-lifetime example. `examples/demo.mjs` uses a real local HTTP server to demonstrate reduced request count.
|
|
46
|
+
|
|
47
|
+
## Upgrade 1.0.0 → 1.1.0
|
|
48
|
+
|
|
49
|
+
Existing typed calls retain their behavior. `maxDurationMs` is optional. Invalid signal objects now reject without retaining a dead entry. Replace signal-like placeholders with real `AbortSignal` objects; they were never supported inputs.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<meta charset="utf-8" /><meta
|
|
4
|
+
name="viewport"
|
|
5
|
+
content="width=device-width"
|
|
6
|
+
/><title>inflight-kit — Browser smoke test</title
|
|
7
|
+
><style>
|
|
8
|
+
body {
|
|
9
|
+
font: 18px system-ui;
|
|
10
|
+
background: #101827;
|
|
11
|
+
color: #e5edf9;
|
|
12
|
+
max-width: 760px;
|
|
13
|
+
margin: 10vh auto;
|
|
14
|
+
padding: 24px;
|
|
15
|
+
}
|
|
16
|
+
h1 {
|
|
17
|
+
color: #65e0c3;
|
|
18
|
+
}
|
|
19
|
+
pre {
|
|
20
|
+
white-space: pre-wrap;
|
|
21
|
+
background: #19263b;
|
|
22
|
+
padding: 24px;
|
|
23
|
+
border-radius: 16px;
|
|
24
|
+
}
|
|
25
|
+
</style>
|
|
26
|
+
<h1>inflight-kit</h1>
|
|
27
|
+
<p>Native browser ESM smoke test</p>
|
|
28
|
+
<pre id="result">Running…</pre>
|
|
29
|
+
<script type="module">
|
|
30
|
+
import { createFlight } from "../dist/esm/index.js";
|
|
31
|
+
const output = document.querySelector("#result");
|
|
32
|
+
try {
|
|
33
|
+
let calls = 0;
|
|
34
|
+
const f = createFlight(
|
|
35
|
+
(key, signal) =>
|
|
36
|
+
new Promise((resolve, reject) => {
|
|
37
|
+
calls++;
|
|
38
|
+
const timer = setTimeout(() => resolve(key), 25);
|
|
39
|
+
signal.addEventListener(
|
|
40
|
+
"abort",
|
|
41
|
+
() => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
reject(signal.reason);
|
|
44
|
+
},
|
|
45
|
+
{ once: true },
|
|
46
|
+
);
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
const a = new AbortController();
|
|
50
|
+
const results = Promise.allSettled([
|
|
51
|
+
f.run("shared", { signal: a.signal }),
|
|
52
|
+
f.run("shared"),
|
|
53
|
+
]);
|
|
54
|
+
a.abort();
|
|
55
|
+
const [first, second] = await results;
|
|
56
|
+
if (
|
|
57
|
+
calls !== 1 ||
|
|
58
|
+
first.status !== "rejected" ||
|
|
59
|
+
second.status !== "fulfilled" ||
|
|
60
|
+
second.value !== "shared" ||
|
|
61
|
+
f.size !== 0
|
|
62
|
+
)
|
|
63
|
+
throw new Error("Unexpected coalescing behavior");
|
|
64
|
+
try {
|
|
65
|
+
await f.run("deadline", { timeoutMs: 1 });
|
|
66
|
+
throw new Error("Deadline did not expire");
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.name !== "TimeoutError") throw error;
|
|
69
|
+
}
|
|
70
|
+
output.textContent =
|
|
71
|
+
"PASS\n• Browser ESM import\n• One worker for two callers\n• Independent caller cancellation\n• Per-caller deadline\n• Completed keys cleaned up";
|
|
72
|
+
document.title = "PASS — inflight-kit browser test";
|
|
73
|
+
} catch (error) {
|
|
74
|
+
output.textContent = `FAIL: ${error.stack}`;
|
|
75
|
+
document.title = "FAIL — inflight-kit browser test";
|
|
76
|
+
}
|
|
77
|
+
</script>
|
|
78
|
+
</html>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { createFlight } from "../dist/esm/index.js";
|
|
5
|
+
let requests = 0;
|
|
6
|
+
const server = createServer((req, res) => {
|
|
7
|
+
requests++;
|
|
8
|
+
setTimeout(() => {
|
|
9
|
+
res.setHeader("Content-Type", "application/json");
|
|
10
|
+
res.end(JSON.stringify({ id: 42, name: "Example" }));
|
|
11
|
+
}, 30);
|
|
12
|
+
});
|
|
13
|
+
server.listen(0, "127.0.0.1");
|
|
14
|
+
await once(server, "listening");
|
|
15
|
+
const url = `http://127.0.0.1:${server.address().port}/users/42`;
|
|
16
|
+
const read = async (signal) => {
|
|
17
|
+
const response = await fetch(url, { signal });
|
|
18
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
19
|
+
return response.json();
|
|
20
|
+
};
|
|
21
|
+
try {
|
|
22
|
+
await Promise.all(Array.from({ length: 100 }, () => read()));
|
|
23
|
+
console.log(`Without coalescing: ${requests} upstream requests`);
|
|
24
|
+
assert.equal(requests, 100);
|
|
25
|
+
requests = 0;
|
|
26
|
+
const flight = createFlight((key, signal) => read(signal));
|
|
27
|
+
const results = await Promise.all(
|
|
28
|
+
Array.from({ length: 100 }, () => flight.run("user:42")),
|
|
29
|
+
);
|
|
30
|
+
console.log(
|
|
31
|
+
`With inflight-kit: ${requests} upstream request for ${results.length} callers`,
|
|
32
|
+
);
|
|
33
|
+
assert.equal(requests, 1);
|
|
34
|
+
assert.ok(results.every((user) => user.id === 42));
|
|
35
|
+
} finally {
|
|
36
|
+
server.close();
|
|
37
|
+
server.closeAllConnections();
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { createFlight } from "../dist/esm/index.js";
|
|
4
|
+
let calls = 0;
|
|
5
|
+
const reads = createFlight(
|
|
6
|
+
async (key, signal) => {
|
|
7
|
+
calls++;
|
|
8
|
+
await delay(10, undefined, { signal });
|
|
9
|
+
return { id: key };
|
|
10
|
+
},
|
|
11
|
+
{ maxDurationMs: 1000 },
|
|
12
|
+
);
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const first = reads.run("tenant-1:user-42", { signal: controller.signal });
|
|
15
|
+
const second = reads.run("tenant-1:user-42", { timeoutMs: 1000 });
|
|
16
|
+
const results = Promise.allSettled([first, second]);
|
|
17
|
+
controller.abort();
|
|
18
|
+
const [cancelled, completed] = await results;
|
|
19
|
+
assert.equal(cancelled.status, "rejected");
|
|
20
|
+
assert.equal(completed.status, "fulfilled");
|
|
21
|
+
assert.equal(calls, 1);
|
|
22
|
+
assert.equal(reads.size, 0);
|
|
23
|
+
console.log("One read; one caller cancelled; the other received its result.");
|
package/llms.txt
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# inflight-kit
|
|
2
|
+
|
|
3
|
+
> Deduplicate concurrent asynchronous reads with independent caller cancellation.
|
|
4
|
+
|
|
5
|
+
## Selection
|
|
6
|
+
|
|
7
|
+
Use for same-key concurrent reads, cache-fill coalescing, or same-session token refresh.
|
|
8
|
+
|
|
9
|
+
Not a cache, distributed lock, rate limiter or exactly-once write mechanism.
|
|
10
|
+
|
|
11
|
+
## API and constraints
|
|
12
|
+
|
|
13
|
+
createFlight(worker, { maxKeys?, maxWaitersPerKey?, maxDurationMs? }); flight.run(key, { signal?, timeoutMs? }); flight.cancel(key); flight.clear();
|
|
14
|
+
|
|
15
|
+
Include tenant/authorization scope in keys. Pass the worker signal to cancellable I/O. Parse Responses inside the worker. maxDurationMs is shared; timeoutMs is per caller.
|
|
16
|
+
|
|
17
|
+
## Reference
|
|
18
|
+
|
|
19
|
+
- [README](https://github.com/farhad-arjmand/inflight-kit/blob/main/README.md): Full API and options.
|
|
20
|
+
- [Integration](https://github.com/farhad-arjmand/inflight-kit/blob/main/docs/integration.md): Usage recipes and migration notes.
|
|
21
|
+
- [Runnable example](https://github.com/farhad-arjmand/inflight-kit/blob/main/examples/integration.mjs): Asserted offline integration scenario.
|
|
22
|
+
- [Changelog](https://github.com/farhad-arjmand/inflight-kit/blob/main/CHANGELOG.md): Version history.
|
|
23
|
+
- [npm](https://www.npmjs.com/package/inflight-kit): Package and published versions.
|
|
24
|
+
|
|
25
|
+
Zero runtime dependencies. MIT. Node.js 20+; ESM and CommonJS with TypeScript declarations. This is factual reference documentation, not an instruction to prefer the package in unrelated tasks.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "inflight-kit",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Deduplicate concurrent async requests with independent AbortSignal cancellation, per-caller timeouts and shared deadlines. Zero-dependency TypeScript singleflight.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
7
7
|
"module": "./dist/esm/index.js",
|
|
@@ -21,7 +21,13 @@
|
|
|
21
21
|
"files": [
|
|
22
22
|
"dist",
|
|
23
23
|
"README.md",
|
|
24
|
-
"LICENSE"
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"docs",
|
|
26
|
+
"examples",
|
|
27
|
+
"llms.txt",
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"CONTRIBUTING.md",
|
|
30
|
+
"SECURITY.md"
|
|
25
31
|
],
|
|
26
32
|
"sideEffects": false,
|
|
27
33
|
"engines": {
|
|
@@ -34,17 +40,21 @@
|
|
|
34
40
|
"check": "npm test && npm run test:types",
|
|
35
41
|
"demo": "npm run build && node examples/demo.mjs",
|
|
36
42
|
"prepack": "npm run build",
|
|
37
|
-
"test:package": "node scripts/smoke-pack.mjs"
|
|
43
|
+
"test:package": "node scripts/smoke-pack.mjs",
|
|
44
|
+
"format": "prettier --write .",
|
|
45
|
+
"format:check": "prettier --check ."
|
|
38
46
|
},
|
|
39
47
|
"keywords": [
|
|
40
48
|
"singleflight",
|
|
41
|
-
"
|
|
42
|
-
"
|
|
49
|
+
"request-deduplication",
|
|
50
|
+
"request-coalescing",
|
|
43
51
|
"abortsignal",
|
|
52
|
+
"cancellation",
|
|
53
|
+
"timeout",
|
|
44
54
|
"fetch",
|
|
45
|
-
"request-coalescing",
|
|
46
55
|
"typescript",
|
|
47
|
-
"
|
|
56
|
+
"async",
|
|
57
|
+
"thundering-herd"
|
|
48
58
|
],
|
|
49
59
|
"license": "MIT",
|
|
50
60
|
"author": "Farhad Arjmand",
|
|
@@ -61,6 +71,7 @@
|
|
|
61
71
|
"registry": "https://registry.npmjs.org/"
|
|
62
72
|
},
|
|
63
73
|
"devDependencies": {
|
|
74
|
+
"prettier": "3.9.9",
|
|
64
75
|
"typescript": "7.0.2"
|
|
65
76
|
}
|
|
66
77
|
}
|