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 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
+ [![npm version](https://img.shields.io/npm/v/inflight-kit.svg)](https://www.npmjs.com/package/inflight-kit)
8
+ [![CI](https://github.com/farhad-arjmand/inflight-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/farhad-arjmand/inflight-kit/actions/workflows/ci.yml)
9
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
+ }