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 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.
@@ -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 'inflight-kit';
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)}`, { signal });
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('42', { signal: controller.signal });
53
- const second = users.run('42', { timeoutMs: 3000 });
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 | Default | Meaning |
79
- | --- | --- | --- |
80
- | `maxKeys` | `1024` | Maximum currently tracked keys |
81
- | `maxWaitersPerKey` | `1024` | Maximum callers waiting on one key |
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
- 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.
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; // 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
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.
@@ -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: 'maxKeys' | 'maxWaitersPerKey';
3
+ readonly limit: "maxKeys" | "maxWaitersPerKey";
4
4
  readonly name = "CapacityError";
5
- constructor(limit: 'maxKeys' | 'maxWaitersPerKey');
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 = 'CapacityError';
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('The operation was aborted', 'AbortError');
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 !== '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');
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() { return entries.size; },
52
- has: key => entries.has(key),
53
- waiters: key => entries.get(key)?.waiters.size ?? 0,
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 && (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647)) {
70
- throw new RangeError('timeoutMs must be an integer from 1 to 2147483647');
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('maxKeys'));
99
+ reject(new CapacityError("maxKeys"));
81
100
  return;
82
101
  }
83
- entry = { controller: new AbortController(), waiters: new Set(), done: false };
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('maxWaitersPerKey'));
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, reject,
127
+ resolve,
128
+ reject,
105
129
  cleanup() {
106
130
  if (timer !== undefined)
107
131
  clearTimeout(timer);
108
- signal?.removeEventListener('abort', onAbort);
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
- signal?.addEventListener('abort', onAbort, { once: true });
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('The caller deadline expired', 'TimeoutError')), timeoutMs);
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().then(() => {
167
+ void Promise.resolve()
168
+ .then(() => {
118
169
  if (current.done)
119
170
  return;
120
171
  return worker(key, current.controller.signal);
121
- }).then(value => finish(key, current, true, value), error => finish(key, current, false, error));
172
+ })
173
+ .then((value) => finish(key, current, true, value), (error) => finish(key, current, false, error));
122
174
  }
123
175
  });
124
176
  },
@@ -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: 'maxKeys' | 'maxWaitersPerKey';
3
+ readonly limit: "maxKeys" | "maxWaitersPerKey";
4
4
  readonly name = "CapacityError";
5
- constructor(limit: 'maxKeys' | 'maxWaitersPerKey');
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 = 'CapacityError';
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('The operation was aborted', 'AbortError');
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 !== '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');
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() { return entries.size; },
47
- has: key => entries.has(key),
48
- waiters: key => entries.get(key)?.waiters.size ?? 0,
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 && (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647)) {
65
- throw new RangeError('timeoutMs must be an integer from 1 to 2147483647');
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('maxKeys'));
94
+ reject(new CapacityError("maxKeys"));
76
95
  return;
77
96
  }
78
- entry = { controller: new AbortController(), waiters: new Set(), done: false };
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('maxWaitersPerKey'));
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, reject,
122
+ resolve,
123
+ reject,
100
124
  cleanup() {
101
125
  if (timer !== undefined)
102
126
  clearTimeout(timer);
103
- signal?.removeEventListener('abort', onAbort);
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
- signal?.addEventListener('abort', onAbort, { once: true });
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('The caller deadline expired', 'TimeoutError')), timeoutMs);
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().then(() => {
162
+ void Promise.resolve()
163
+ .then(() => {
113
164
  if (current.done)
114
165
  return;
115
166
  return worker(key, current.controller.signal);
116
- }).then(value => finish(key, current, true, value), error => finish(key, current, false, error));
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.0.0",
4
- "description": "Share pending async work without sharing cancellation. Typed singleflight with per-caller deadlines and bounded admission.",
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
- "deduplicate",
42
- "async",
49
+ "request-deduplication",
50
+ "request-coalescing",
43
51
  "abortsignal",
52
+ "cancellation",
53
+ "timeout",
44
54
  "fetch",
45
- "request-coalescing",
46
55
  "typescript",
47
- "cancellation"
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
  }