@azghr/singlet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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,412 @@
1
+ # singlet
2
+
3
+ Deduplicate concurrent async calls. Same key → one in-flight promise. **Not a cache.**
4
+
5
+ Zero dependencies · Zero runtime overhead · ESM + CJS · TypeScript · Node / browsers / Deno / Bun / edge
6
+
7
+ ## The problem
8
+
9
+ The same async operation often fires many times at once:
10
+ - Three components mount and each fetch `/users/42` → 3 HTTP requests
11
+ - A webhook burst triggers five identical DB refreshes → 5 database queries
12
+ - Two clicks race the same mutation → conflicting writes
13
+
14
+ You get **N network calls, N database hits, and sometimes N conflicting writes** — for one answer.
15
+
16
+ ## The solution
17
+
18
+ singlet solves **concurrent call deduplication** — not caching:
19
+
20
+ ```ts
21
+ import singlet from "@azghr/singlet";
22
+
23
+ const flight = singlet();
24
+
25
+ // These 3 concurrent calls → 1 HTTP request:
26
+ const [user1, user2, user3] = await Promise.all([
27
+ flight.run(`user:42`, () => fetch('/api/users/42').then(r => r.json())),
28
+ flight.run(`user:42`, () => fetch('/api/users/42').then(r => r.json())),
29
+ flight.run(`user:42`, () => fetch('/api/users/42').then(r => r.json()))
30
+ ]);
31
+
32
+ // After settlement → key is forgotten (not a cache):
33
+ const later = await flight.run(`user:42`, () => fetch('/api/users/42').then(r => r.json()));
34
+ // This makes a fresh request
35
+ ```
36
+
37
+ ## How it works
38
+
39
+ 1. **First call** with key → starts the operation, stores promise in Map
40
+ 2. **Concurrent calls** with same key → receive the same promise, **don't start new operation**
41
+ 3. **After promise settles** → key is **deleted from Map** (no caching)
42
+ 4. **Next call** with same key → starts fresh operation (sequential calls are not deduplicated)
43
+
44
+ ## Install
45
+
46
+ ```sh
47
+ npm install @azghr/singlet
48
+ ```
49
+
50
+ ## Quick start
51
+
52
+ ### 1. Core API
53
+
54
+ ```ts
55
+ import singlet from "@azghr/singlet";
56
+
57
+ const flight = singlet();
58
+
59
+ async function getUser(id: string) {
60
+ return flight.run(`user:${id}`, () =>
61
+ fetch(`/api/users/${id}`).then(r => r.json())
62
+ );
63
+ }
64
+
65
+ // Concurrent calls → ONE fetch
66
+ const [user1, user2, user3] = await Promise.all([
67
+ getUser("42"),
68
+ getUser("42"),
69
+ getUser("42")
70
+ ]);
71
+ ```
72
+
73
+ ### 2. Ergonomic API
74
+
75
+ ```ts
76
+ import { wrap } from "@azghr/singlet";
77
+
78
+ const fetchUser = wrap(
79
+ async (id: string) => fetch(`/api/users/${id}`).then(r => r.json()),
80
+ { keyFn: ([id]) => `user:${id}` }
81
+ );
82
+
83
+ // Automatic deduplication based on arguments
84
+ const [user1, user2] = await Promise.all([
85
+ fetchUser("42"),
86
+ fetchUser("42") // Only ONE HTTP request
87
+ ]);
88
+ ```
89
+
90
+ ### 3. Shared instance
91
+
92
+ ```ts
93
+ import { shared } from "@azghr/singlet";
94
+
95
+ // Simple app-wide deduplication
96
+ const config = await shared.run("app:config", loadConfig);
97
+ ```
98
+
99
+ ## Use
100
+
101
+ ### Core API: `singlet()`
102
+
103
+ ```ts
104
+ import singlet from "@azghr/singlet";
105
+
106
+ const flight = singlet();
107
+
108
+ async function getUser(id: string) {
109
+ return flight.run(`user:${id}`, () =>
110
+ fetch(`/api/users/${id}`).then((r) => r.json())
111
+ );
112
+ }
113
+
114
+ // Fired concurrently → ONE fetch, three identical results:
115
+ const [a, b, c] = await Promise.all([getUser("42"), getUser("42"), getUser("42")]);
116
+
117
+ // Fired afterwards → a fresh fetch (singlet is not a cache):
118
+ const later = await getUser("42");
119
+ ```
120
+
121
+ ### Ergonomic API: `wrap()`
122
+
123
+ The `wrap()` function automatically deduplicates based on function arguments:
124
+
125
+ ```ts
126
+ import { wrap } from "@azghr/singlet";
127
+
128
+ const fetchUser = wrap(
129
+ async (id: string) => fetch(`/api/users/${id}`).then(r => r.json()),
130
+ { keyFn: ([id]) => `user:${id}` }
131
+ );
132
+
133
+ // These concurrent calls share ONE fetch:
134
+ const [user1, user2] = await Promise.all([
135
+ fetchUser('42'),
136
+ fetchUser('42')
137
+ ]);
138
+ ```
139
+
140
+ ### Fixed-key deduplication: `wrapWithKey()`
141
+
142
+ For single operations that shouldn't run concurrently:
143
+
144
+ ```ts
145
+ import { wrapWithKey } from "@azghr/singlet";
146
+
147
+ const loadConfig = wrapWithKey(
148
+ async () => fetch('/api/config').then(r => r.json()),
149
+ 'app:config'
150
+ );
151
+
152
+ // Multiple components loading config concurrently → ONE fetch:
153
+ const [config1, config2] = await Promise.all([
154
+ loadConfig(),
155
+ loadConfig()
156
+ ]);
157
+ ```
158
+
159
+ ### Shared instance
160
+
161
+ A shared app-wide instance is available for simple use cases:
162
+
163
+ ```ts
164
+ import { shared } from "@azghr/singlet";
165
+ await shared.run("config", loadConfig);
166
+ ```
167
+
168
+ ## API
169
+
170
+ ### Core functions
171
+
172
+ #### `singlet(): Singlet`
173
+
174
+ Creates an isolated singlet instance with its own key namespace.
175
+
176
+ ```ts
177
+ import singlet from "@azghr/singlet";
178
+
179
+ const flight = singlet();
180
+ const adminFlight = singlet(); // Separate namespace
181
+ ```
182
+
183
+ #### `Singlet.run<T>(key: string, fn: () => T | Promise<T>): Promise<T>`
184
+
185
+ If `key` is in flight, returns the existing promise without calling `fn`.
186
+ Otherwise calls `fn`, shares its promise with concurrent callers, and forgets
187
+ the key once it settles.
188
+
189
+ - **Rejections are shared** — all concurrent callers get the same error
190
+ - **Rejections are forgotten** — next call retries fresh
191
+ - **Synchronous throws become rejections** — never escape to callers
192
+ - **Type inference** — `T` is inferred from `fn`, no generics needed
193
+
194
+ Keys are plain strings; you decide how to serialize:
195
+ ```ts
196
+ flight.run(`user:${id}`, () => fetchUser(id));
197
+ flight.run(`cache:${JSON.stringify(params)}`, () => query(params));
198
+ ```
199
+
200
+ #### `Singlet.forget(key: string): boolean`
201
+
202
+ Drops an in-flight key so the *next* `run` starts fresh. Callers already
203
+ awaiting the old promise still receive its result. Returns `false` if the key
204
+ wasn't in flight.
205
+
206
+ ```ts
207
+ // Invalidate mid-flight, next call retries:
208
+ flight.forget(`user:${userId}`);
209
+ ```
210
+
211
+ #### `Singlet.isInFlight(key: string): boolean` · `Singlet.size: number`
212
+
213
+ Introspection for tests, metrics, and debugging.
214
+
215
+ ```ts
216
+ if (flight.isInFlight(`user:${id}`)) {
217
+ console.log(`Already fetching user ${id}`);
218
+ }
219
+ console.log(`${flight.size} operations in flight`);
220
+ ```
221
+
222
+ ### Ergonomic functions
223
+
224
+ #### `wrap<TArgs, TResult>(fn: (...args: TArgs) => TResult | Promise<TResult>, options: WrapOptions<TArgs>): (...args: TArgs) => Promise<TResult>`
225
+
226
+ Wraps a function to automatically deduplicate concurrent calls based on its arguments.
227
+
228
+ ```ts
229
+ import { wrap } from "@azghr/singlet";
230
+
231
+ const fetchUser = wrap(
232
+ async (id: string) => fetch(`/api/users/${id}`).then(r => r.json()),
233
+ {
234
+ keyFn: ([id]) => `user:${id}` // Extract key from arguments
235
+ }
236
+ );
237
+ ```
238
+
239
+ **WrapOptions:**
240
+ - `keyFn: (args: TArgs) => string` — Extract deduplication key from function arguments
241
+ - `singlet?: Singlet` — Optional shared singlet instance
242
+
243
+ #### `wrapWithKey<TResult>(fn: () => TResult | Promise<TResult>, key: string, singlet?: Singlet): () => Promise<TResult>`
244
+
245
+ Wraps a no-argument function with a fixed deduplication key.
246
+
247
+ ```ts
248
+ import { wrapWithKey } from "@azghr/singlet";
249
+
250
+ const loadConfig = wrapWithKey(
251
+ async () => fetch('/api/config').then(r => r.json()),
252
+ 'app:config'
253
+ );
254
+ ```
255
+
256
+ ### Shared instance
257
+
258
+ #### `shared: Singlet`
259
+
260
+ A shared app-wide instance for simple use cases.
261
+
262
+ ```ts
263
+ import { shared } from "@azghr/singlet";
264
+ await shared.run("config", loadConfig);
265
+ ```
266
+
267
+ ## Patterns & composition
268
+
269
+ ### Deduplication + caching
270
+
271
+ Singlet prevents concurrent duplicate work; cache libraries store results.
272
+ Compose them for complete deduplication:
273
+
274
+ ```ts
275
+ import { wrap } from "@azghr/singlet";
276
+
277
+ const cache = new Map<string, User>();
278
+
279
+ const getUser = wrap(
280
+ async (id: string) => {
281
+ const hit = cache.get(id);
282
+ if (hit) return hit;
283
+
284
+ const user = await fetchUser(id);
285
+ cache.set(id, user);
286
+ return user;
287
+ },
288
+ { keyFn: ([id]) => `user:${id}` }
289
+ );
290
+ ```
291
+
292
+ ### Deduplication + retries
293
+
294
+ Singlet shares concurrent calls; retry libraries handle transient failures:
295
+
296
+ ```ts
297
+ import { wrap } from "@azghr/singlet";
298
+ import pRetry from "p-retry";
299
+
300
+ const fetchWithRetry = wrap(
301
+ (url: string) => pRetry(() => fetch(url), { retries: 3 }),
302
+ { keyFn: ([url]) => url }
303
+ );
304
+ ```
305
+
306
+ ### Framework integration
307
+
308
+ Singlet is framework-agnostic. Integrate with any framework:
309
+
310
+ ```ts
311
+ // React hook example
312
+ import { wrap } from "@azghr/singlet";
313
+
314
+ const useUser = (id: string) => {
315
+ const fetchUser = wrap(
316
+ async (userId: string) => fetch(`/api/users/${userId}`).then(r => r.json()),
317
+ { keyFn: ([userId]) => `user:${userId}` }
318
+ );
319
+
320
+ // Use in React, Vue, Svelte, etc.
321
+ const { data, loading } = useFetch(() => fetchUser(id));
322
+ return { user: data, loading };
323
+ };
324
+ ```
325
+
326
+ ### Custom key strategies
327
+
328
+ You control how keys are generated:
329
+
330
+ ```ts
331
+ // Single argument
332
+ wrap(fetchUser, { keyFn: ([id]) => `user:${id}` });
333
+
334
+ // Multiple arguments
335
+ wrap(fetchResource, {
336
+ keyFn: ([method, url, params]) => `${method}:${url}:${JSON.stringify(params)}`
337
+ });
338
+
339
+ // Complex objects
340
+ wrap(searchUsers, {
341
+ keyFn: ([query]) => `search:${JSON.stringify(query)}`
342
+ });
343
+ ```
344
+
345
+ ## TypeScript
346
+
347
+ Written in strict TypeScript; ships bundled `.d.ts`. All functions infer types from your code:
348
+
349
+ ```ts
350
+ // Type inference works automatically
351
+ const result = await flight.run("key", () => 42); // inferred as Promise<number>
352
+ const result = await flight.run("key", () => "hello"); // inferred as Promise<string>
353
+
354
+ // Generic functions are fully inferred
355
+ const fetchUser = async (id: string): Promise<User> => ({ id, name: "User" });
356
+ const wrapped = wrap(fetchUser, { keyFn: ([id]) => id });
357
+ // wrapped is inferred as (id: string) => Promise<User>
358
+ ```
359
+
360
+ ## Use cases
361
+
362
+ - **API deduplication**: Prevent duplicate HTTP requests for the same data
363
+ - **Database query coalescing**: Share query results across concurrent operations
364
+ - **Cache loading**: Single fetch when multiple components need the same data
365
+ - **Mutation deduplication**: Prevent duplicate POST/PUT/DELETE operations
366
+ - **Configuration loading**: Ensure config/auth loads once at startup
367
+ - **Webhook debouncing**: Handle duplicate webhook events gracefully
368
+ - **Resource initialization**: Share expensive setup across concurrent requests
369
+
370
+ ## Non-goals
371
+
372
+ By design, `singlet` focuses on one thing: deduplicating concurrent in-flight calls. These features are **explicitly out of scope**:
373
+
374
+ - **Result caching/TTLs** — Use cache libraries (lru-cache, node-cache)
375
+ - **Retries/backoff** — Use retry libraries (p-retry, retry)
376
+ - **Timeouts** — Use timeout libraries (p-timeout, Promise.race)
377
+ - **Batching/DataLoader** — Use batching libraries (dataloader, batch-promises)
378
+ - **Framework adapters** — Compose with React/Vue/Angular hooks
379
+ - **Non-string keys** — Serialize your own keys with `JSON.stringify()`
380
+
381
+ If you need these features, compose singlet with specialized libraries.
382
+
383
+ ## Performance
384
+
385
+ singlet has **zero runtime overhead**:
386
+
387
+ - No dependencies
388
+ - No binary size cost (~1.2 KB minified)
389
+ - O(1) Map operations for key lookup/insert/delete
390
+ - No shared rejections create unhandled promise noise
391
+ - Automatic cleanup after promise settlement
392
+
393
+ ## Examples
394
+
395
+ Run the interactive demo:
396
+
397
+ ```sh
398
+ npm run demo
399
+ ```
400
+
401
+ Or explore examples in `examples/demo.ts` covering:
402
+ - Basic deduplication
403
+ - No caching behavior
404
+ - Different key independence
405
+ - Ergonomic wrap() API
406
+ - Fixed-key operations
407
+ - Error handling
408
+ - Shared instance usage
409
+
410
+ ## License
411
+
412
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ default: () => singlet_default,
24
+ shared: () => shared,
25
+ singlet: () => singlet,
26
+ wrap: () => wrap,
27
+ wrapWithKey: () => wrapWithKey
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/singlet.ts
32
+ function singlet() {
33
+ const inflight = /* @__PURE__ */ new Map();
34
+ return {
35
+ run(key, fn) {
36
+ const existing = inflight.get(key);
37
+ if (existing !== void 0) {
38
+ return existing;
39
+ }
40
+ const promise = Promise.resolve().then(fn).finally(() => {
41
+ if (inflight.get(key) === promise) {
42
+ inflight.delete(key);
43
+ }
44
+ });
45
+ inflight.set(key, promise);
46
+ return promise;
47
+ },
48
+ forget(key) {
49
+ return inflight.delete(key);
50
+ },
51
+ isInFlight(key) {
52
+ return inflight.has(key);
53
+ },
54
+ get size() {
55
+ return inflight.size;
56
+ }
57
+ };
58
+ }
59
+ var shared = singlet();
60
+ var singlet_default = singlet;
61
+
62
+ // src/wrap.ts
63
+ function wrap(fn, options) {
64
+ const instance = options.singlet ?? singlet();
65
+ return function(...args) {
66
+ const key = options.keyFn(args);
67
+ return instance.run(key, () => fn(...args));
68
+ };
69
+ }
70
+ function wrapWithKey(fn, key, singlet2) {
71
+ const options = { keyFn: () => key };
72
+ if (singlet2 !== void 0) {
73
+ options.singlet = singlet2;
74
+ }
75
+ return wrap(fn, options);
76
+ }
77
+ // Annotate the CommonJS export names for ESM import in node:
78
+ 0 && (module.exports = {
79
+ shared,
80
+ singlet,
81
+ wrap,
82
+ wrapWithKey
83
+ });
84
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/singlet.ts","../src/wrap.ts"],"sourcesContent":["/**\n * singlet — deduplicate concurrent async calls.\n *\n * Barrel export: re-exports all public API.\n * No logic here — see src/singlet.ts and src/wrap.ts for implementation.\n */\n\nexport type { Singlet } from \"./singlet.js\";\nexport { singlet, shared } from \"./singlet.js\";\nexport { default } from \"./singlet.js\";\n\nexport type { WrapOptions } from \"./wrap.js\";\nexport { wrap, wrapWithKey } from \"./wrap.js\";\n","/**\n * singlet — deduplicate concurrent async calls.\n *\n * Concurrent calls with the same key share ONE in-flight promise.\n * Once the promise settles, the key is forgotten. singlet is NOT a cache:\n * it stores nothing after completion. One job, done well.\n */\n\nexport interface Singlet {\n /**\n * Run `fn` under `key`. If a call with the same key is already in flight,\n * the existing promise is returned and `fn` is NOT invoked.\n */\n run<T>(key: string, fn: () => T | Promise<T>): Promise<T>;\n\n /**\n * Drop an in-flight key so the NEXT call starts fresh.\n * Callers already awaiting the old promise still receive its result.\n * Returns true if a key was dropped.\n */\n forget(key: string): boolean;\n\n /** Whether a call for `key` is currently in flight. */\n isInFlight(key: string): boolean;\n\n /** Number of distinct keys currently in flight. */\n readonly size: number;\n}\n\n/**\n * Create an isolated singlet instance (its own key namespace).\n */\nexport function singlet(): Singlet {\n const inflight = new Map<string, Promise<unknown>>();\n\n return {\n run<T>(key: string, fn: () => T | Promise<T>): Promise<T> {\n const existing = inflight.get(key);\n if (existing !== undefined) {\n return existing as Promise<T>;\n }\n\n // `fn` is invoked in a microtask via .then(), AFTER the map entry is\n // set, so synchronous throws inside `fn` reject the shared promise\n // instead of escaping to only the first caller. The key is forgotten\n // in `finally`, which runs BEFORE the promise settles for consumers —\n // so by the time any caller's `await` resolves, the key is already\n // gone and a sequential call starts fresh (no caching, not even for\n // one microtask). The identity check guards against `forget()` having\n // replaced the entry with a newer promise for the same key.\n const promise: Promise<T> = Promise.resolve()\n .then(fn)\n .finally(() => {\n if (inflight.get(key) === promise) {\n inflight.delete(key);\n }\n });\n\n inflight.set(key, promise);\n return promise;\n },\n\n forget(key: string): boolean {\n return inflight.delete(key);\n },\n\n isInFlight(key: string): boolean {\n return inflight.has(key);\n },\n\n get size(): number {\n return inflight.size;\n },\n };\n}\n\n/**\n * Shared default instance — convenient for app-wide deduplication.\n * Prefer `singlet()` instances in libraries to avoid key collisions.\n */\nexport const shared: Singlet = singlet();\n\n// Default export for ESM compatibility\nexport default singlet;\n","/**\n * Ergonomic layer over singlet core.\n *\n * This module COMPOSES the core singlet functionality — it never duplicates logic.\n * See src/singlet.ts for the core implementation.\n */\n\nimport type { Singlet } from \"./singlet.js\";\nimport { singlet as createSinglet } from \"./singlet.js\";\n\n/**\n * Options for configuring a wrapped function.\n */\nexport interface WrapOptions<TArgs> {\n /**\n * Extract a deduplication key from function arguments.\n * Return the same key for concurrent calls that should share one promise.\n *\n * @example\n * ```ts\n * // Dedupe by user ID:\n * keyFn: ([id]) => `user:${id}`\n *\n * // Dedupe by multiple args:\n * keyFn: ([method, url]) => `${method}:${url}`\n * ```\n */\n keyFn: (args: TArgs) => string;\n\n /**\n * Optional singlet instance for this wrapped function.\n * Defaults to a new isolated instance.\n * Use this to share a singlet across multiple wrapped functions.\n */\n singlet?: Singlet;\n}\n\n/**\n * Wrap an async function to automatically deduplicate concurrent calls.\n *\n * @example\n * ```ts\n * import { wrap } from 'singlet';\n *\n * const fetchUser = wrap(\n * async (id: string) => fetch(`/api/users/${id}`).then(r => r.json()),\n * { keyFn: ([id]) => `user:${id}` }\n * );\n *\n * // These two concurrent calls share ONE fetch:\n * const [user1, user2] = await Promise.all([\n * fetchUser('42'),\n * fetchUser('42')\n * ]);\n * ```\n *\n * @param fn - The async function to wrap\n * @param options - Configuration for key extraction and singlet instance\n * @returns A wrapped function with automatic deduplication\n */\nexport function wrap<TArgs extends unknown[], TResult>(\n fn: (...args: TArgs) => TResult | Promise<TResult>,\n options: WrapOptions<TArgs>\n): (...args: TArgs) => Promise<TResult> {\n const instance = options.singlet ?? createSinglet();\n\n return function (...args: TArgs): Promise<TResult> {\n const key = options.keyFn(args);\n return instance.run(key, () => fn(...args));\n };\n}\n\n/**\n * Wrap an async function with a simple key string (for single-operation functions).\n *\n * @example\n * ```ts\n * import { wrapWithKey } from 'singlet';\n *\n * const loadConfig = wrapWithKey(\n * async () => fetch('/api/config').then(r => r.json()),\n * 'config'\n * );\n * ```\n *\n * @param fn - The async function to wrap\n * @param key - Fixed key for all calls to this function\n * @param singlet - Optional singlet instance\n * @returns A wrapped function with fixed-key deduplication\n */\nexport function wrapWithKey<TResult>(\n fn: () => TResult | Promise<TResult>,\n key: string,\n singlet?: Singlet\n): () => Promise<TResult> {\n const options: WrapOptions<[]> = { keyFn: () => key };\n if (singlet !== undefined) {\n options.singlet = singlet;\n }\n return wrap(fn, options);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACgCO,SAAS,UAAmB;AACjC,QAAM,WAAW,oBAAI,IAA8B;AAEnD,SAAO;AAAA,IACL,IAAO,KAAa,IAAsC;AACxD,YAAM,WAAW,SAAS,IAAI,GAAG;AACjC,UAAI,aAAa,QAAW;AAC1B,eAAO;AAAA,MACT;AAUA,YAAM,UAAsB,QAAQ,QAAQ,EACzC,KAAK,EAAE,EACP,QAAQ,MAAM;AACb,YAAI,SAAS,IAAI,GAAG,MAAM,SAAS;AACjC,mBAAS,OAAO,GAAG;AAAA,QACrB;AAAA,MACF,CAAC;AAEH,eAAS,IAAI,KAAK,OAAO;AACzB,aAAO;AAAA,IACT;AAAA,IAEA,OAAO,KAAsB;AAC3B,aAAO,SAAS,OAAO,GAAG;AAAA,IAC5B;AAAA,IAEA,WAAW,KAAsB;AAC/B,aAAO,SAAS,IAAI,GAAG;AAAA,IACzB;AAAA,IAEA,IAAI,OAAe;AACjB,aAAO,SAAS;AAAA,IAClB;AAAA,EACF;AACF;AAMO,IAAM,SAAkB,QAAQ;AAGvC,IAAO,kBAAQ;;;ACvBR,SAAS,KACd,IACA,SACsC;AACtC,QAAM,WAAW,QAAQ,WAAW,QAAc;AAElD,SAAO,YAAa,MAA+B;AACjD,UAAM,MAAM,QAAQ,MAAM,IAAI;AAC9B,WAAO,SAAS,IAAI,KAAK,MAAM,GAAG,GAAG,IAAI,CAAC;AAAA,EAC5C;AACF;AAoBO,SAAS,YACd,IACA,KACAA,UACwB;AACxB,QAAM,UAA2B,EAAE,OAAO,MAAM,IAAI;AACpD,MAAIA,aAAY,QAAW;AACzB,YAAQ,UAAUA;AAAA,EACpB;AACA,SAAO,KAAK,IAAI,OAAO;AACzB;","names":["singlet"]}
@@ -0,0 +1,111 @@
1
+ /**
2
+ * singlet — deduplicate concurrent async calls.
3
+ *
4
+ * Concurrent calls with the same key share ONE in-flight promise.
5
+ * Once the promise settles, the key is forgotten. singlet is NOT a cache:
6
+ * it stores nothing after completion. One job, done well.
7
+ */
8
+ interface Singlet {
9
+ /**
10
+ * Run `fn` under `key`. If a call with the same key is already in flight,
11
+ * the existing promise is returned and `fn` is NOT invoked.
12
+ */
13
+ run<T>(key: string, fn: () => T | Promise<T>): Promise<T>;
14
+ /**
15
+ * Drop an in-flight key so the NEXT call starts fresh.
16
+ * Callers already awaiting the old promise still receive its result.
17
+ * Returns true if a key was dropped.
18
+ */
19
+ forget(key: string): boolean;
20
+ /** Whether a call for `key` is currently in flight. */
21
+ isInFlight(key: string): boolean;
22
+ /** Number of distinct keys currently in flight. */
23
+ readonly size: number;
24
+ }
25
+ /**
26
+ * Create an isolated singlet instance (its own key namespace).
27
+ */
28
+ declare function singlet(): Singlet;
29
+ /**
30
+ * Shared default instance — convenient for app-wide deduplication.
31
+ * Prefer `singlet()` instances in libraries to avoid key collisions.
32
+ */
33
+ declare const shared: Singlet;
34
+
35
+ /**
36
+ * Ergonomic layer over singlet core.
37
+ *
38
+ * This module COMPOSES the core singlet functionality — it never duplicates logic.
39
+ * See src/singlet.ts for the core implementation.
40
+ */
41
+
42
+ /**
43
+ * Options for configuring a wrapped function.
44
+ */
45
+ interface WrapOptions<TArgs> {
46
+ /**
47
+ * Extract a deduplication key from function arguments.
48
+ * Return the same key for concurrent calls that should share one promise.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * // Dedupe by user ID:
53
+ * keyFn: ([id]) => `user:${id}`
54
+ *
55
+ * // Dedupe by multiple args:
56
+ * keyFn: ([method, url]) => `${method}:${url}`
57
+ * ```
58
+ */
59
+ keyFn: (args: TArgs) => string;
60
+ /**
61
+ * Optional singlet instance for this wrapped function.
62
+ * Defaults to a new isolated instance.
63
+ * Use this to share a singlet across multiple wrapped functions.
64
+ */
65
+ singlet?: Singlet;
66
+ }
67
+ /**
68
+ * Wrap an async function to automatically deduplicate concurrent calls.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * import { wrap } from 'singlet';
73
+ *
74
+ * const fetchUser = wrap(
75
+ * async (id: string) => fetch(`/api/users/${id}`).then(r => r.json()),
76
+ * { keyFn: ([id]) => `user:${id}` }
77
+ * );
78
+ *
79
+ * // These two concurrent calls share ONE fetch:
80
+ * const [user1, user2] = await Promise.all([
81
+ * fetchUser('42'),
82
+ * fetchUser('42')
83
+ * ]);
84
+ * ```
85
+ *
86
+ * @param fn - The async function to wrap
87
+ * @param options - Configuration for key extraction and singlet instance
88
+ * @returns A wrapped function with automatic deduplication
89
+ */
90
+ declare function wrap<TArgs extends unknown[], TResult>(fn: (...args: TArgs) => TResult | Promise<TResult>, options: WrapOptions<TArgs>): (...args: TArgs) => Promise<TResult>;
91
+ /**
92
+ * Wrap an async function with a simple key string (for single-operation functions).
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * import { wrapWithKey } from 'singlet';
97
+ *
98
+ * const loadConfig = wrapWithKey(
99
+ * async () => fetch('/api/config').then(r => r.json()),
100
+ * 'config'
101
+ * );
102
+ * ```
103
+ *
104
+ * @param fn - The async function to wrap
105
+ * @param key - Fixed key for all calls to this function
106
+ * @param singlet - Optional singlet instance
107
+ * @returns A wrapped function with fixed-key deduplication
108
+ */
109
+ declare function wrapWithKey<TResult>(fn: () => TResult | Promise<TResult>, key: string, singlet?: Singlet): () => Promise<TResult>;
110
+
111
+ export { type Singlet, type WrapOptions, singlet as default, shared, singlet, wrap, wrapWithKey };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * singlet — deduplicate concurrent async calls.
3
+ *
4
+ * Concurrent calls with the same key share ONE in-flight promise.
5
+ * Once the promise settles, the key is forgotten. singlet is NOT a cache:
6
+ * it stores nothing after completion. One job, done well.
7
+ */
8
+ interface Singlet {
9
+ /**
10
+ * Run `fn` under `key`. If a call with the same key is already in flight,
11
+ * the existing promise is returned and `fn` is NOT invoked.
12
+ */
13
+ run<T>(key: string, fn: () => T | Promise<T>): Promise<T>;
14
+ /**
15
+ * Drop an in-flight key so the NEXT call starts fresh.
16
+ * Callers already awaiting the old promise still receive its result.
17
+ * Returns true if a key was dropped.
18
+ */
19
+ forget(key: string): boolean;
20
+ /** Whether a call for `key` is currently in flight. */
21
+ isInFlight(key: string): boolean;
22
+ /** Number of distinct keys currently in flight. */
23
+ readonly size: number;
24
+ }
25
+ /**
26
+ * Create an isolated singlet instance (its own key namespace).
27
+ */
28
+ declare function singlet(): Singlet;
29
+ /**
30
+ * Shared default instance — convenient for app-wide deduplication.
31
+ * Prefer `singlet()` instances in libraries to avoid key collisions.
32
+ */
33
+ declare const shared: Singlet;
34
+
35
+ /**
36
+ * Ergonomic layer over singlet core.
37
+ *
38
+ * This module COMPOSES the core singlet functionality — it never duplicates logic.
39
+ * See src/singlet.ts for the core implementation.
40
+ */
41
+
42
+ /**
43
+ * Options for configuring a wrapped function.
44
+ */
45
+ interface WrapOptions<TArgs> {
46
+ /**
47
+ * Extract a deduplication key from function arguments.
48
+ * Return the same key for concurrent calls that should share one promise.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * // Dedupe by user ID:
53
+ * keyFn: ([id]) => `user:${id}`
54
+ *
55
+ * // Dedupe by multiple args:
56
+ * keyFn: ([method, url]) => `${method}:${url}`
57
+ * ```
58
+ */
59
+ keyFn: (args: TArgs) => string;
60
+ /**
61
+ * Optional singlet instance for this wrapped function.
62
+ * Defaults to a new isolated instance.
63
+ * Use this to share a singlet across multiple wrapped functions.
64
+ */
65
+ singlet?: Singlet;
66
+ }
67
+ /**
68
+ * Wrap an async function to automatically deduplicate concurrent calls.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * import { wrap } from 'singlet';
73
+ *
74
+ * const fetchUser = wrap(
75
+ * async (id: string) => fetch(`/api/users/${id}`).then(r => r.json()),
76
+ * { keyFn: ([id]) => `user:${id}` }
77
+ * );
78
+ *
79
+ * // These two concurrent calls share ONE fetch:
80
+ * const [user1, user2] = await Promise.all([
81
+ * fetchUser('42'),
82
+ * fetchUser('42')
83
+ * ]);
84
+ * ```
85
+ *
86
+ * @param fn - The async function to wrap
87
+ * @param options - Configuration for key extraction and singlet instance
88
+ * @returns A wrapped function with automatic deduplication
89
+ */
90
+ declare function wrap<TArgs extends unknown[], TResult>(fn: (...args: TArgs) => TResult | Promise<TResult>, options: WrapOptions<TArgs>): (...args: TArgs) => Promise<TResult>;
91
+ /**
92
+ * Wrap an async function with a simple key string (for single-operation functions).
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * import { wrapWithKey } from 'singlet';
97
+ *
98
+ * const loadConfig = wrapWithKey(
99
+ * async () => fetch('/api/config').then(r => r.json()),
100
+ * 'config'
101
+ * );
102
+ * ```
103
+ *
104
+ * @param fn - The async function to wrap
105
+ * @param key - Fixed key for all calls to this function
106
+ * @param singlet - Optional singlet instance
107
+ * @returns A wrapped function with fixed-key deduplication
108
+ */
109
+ declare function wrapWithKey<TResult>(fn: () => TResult | Promise<TResult>, key: string, singlet?: Singlet): () => Promise<TResult>;
110
+
111
+ export { type Singlet, type WrapOptions, singlet as default, shared, singlet, wrap, wrapWithKey };
package/dist/index.js ADDED
@@ -0,0 +1,54 @@
1
+ // src/singlet.ts
2
+ function singlet() {
3
+ const inflight = /* @__PURE__ */ new Map();
4
+ return {
5
+ run(key, fn) {
6
+ const existing = inflight.get(key);
7
+ if (existing !== void 0) {
8
+ return existing;
9
+ }
10
+ const promise = Promise.resolve().then(fn).finally(() => {
11
+ if (inflight.get(key) === promise) {
12
+ inflight.delete(key);
13
+ }
14
+ });
15
+ inflight.set(key, promise);
16
+ return promise;
17
+ },
18
+ forget(key) {
19
+ return inflight.delete(key);
20
+ },
21
+ isInFlight(key) {
22
+ return inflight.has(key);
23
+ },
24
+ get size() {
25
+ return inflight.size;
26
+ }
27
+ };
28
+ }
29
+ var shared = singlet();
30
+ var singlet_default = singlet;
31
+
32
+ // src/wrap.ts
33
+ function wrap(fn, options) {
34
+ const instance = options.singlet ?? singlet();
35
+ return function(...args) {
36
+ const key = options.keyFn(args);
37
+ return instance.run(key, () => fn(...args));
38
+ };
39
+ }
40
+ function wrapWithKey(fn, key, singlet2) {
41
+ const options = { keyFn: () => key };
42
+ if (singlet2 !== void 0) {
43
+ options.singlet = singlet2;
44
+ }
45
+ return wrap(fn, options);
46
+ }
47
+ export {
48
+ singlet_default as default,
49
+ shared,
50
+ singlet,
51
+ wrap,
52
+ wrapWithKey
53
+ };
54
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/singlet.ts","../src/wrap.ts"],"sourcesContent":["/**\n * singlet — deduplicate concurrent async calls.\n *\n * Concurrent calls with the same key share ONE in-flight promise.\n * Once the promise settles, the key is forgotten. singlet is NOT a cache:\n * it stores nothing after completion. One job, done well.\n */\n\nexport interface Singlet {\n /**\n * Run `fn` under `key`. If a call with the same key is already in flight,\n * the existing promise is returned and `fn` is NOT invoked.\n */\n run<T>(key: string, fn: () => T | Promise<T>): Promise<T>;\n\n /**\n * Drop an in-flight key so the NEXT call starts fresh.\n * Callers already awaiting the old promise still receive its result.\n * Returns true if a key was dropped.\n */\n forget(key: string): boolean;\n\n /** Whether a call for `key` is currently in flight. */\n isInFlight(key: string): boolean;\n\n /** Number of distinct keys currently in flight. */\n readonly size: number;\n}\n\n/**\n * Create an isolated singlet instance (its own key namespace).\n */\nexport function singlet(): Singlet {\n const inflight = new Map<string, Promise<unknown>>();\n\n return {\n run<T>(key: string, fn: () => T | Promise<T>): Promise<T> {\n const existing = inflight.get(key);\n if (existing !== undefined) {\n return existing as Promise<T>;\n }\n\n // `fn` is invoked in a microtask via .then(), AFTER the map entry is\n // set, so synchronous throws inside `fn` reject the shared promise\n // instead of escaping to only the first caller. The key is forgotten\n // in `finally`, which runs BEFORE the promise settles for consumers —\n // so by the time any caller's `await` resolves, the key is already\n // gone and a sequential call starts fresh (no caching, not even for\n // one microtask). The identity check guards against `forget()` having\n // replaced the entry with a newer promise for the same key.\n const promise: Promise<T> = Promise.resolve()\n .then(fn)\n .finally(() => {\n if (inflight.get(key) === promise) {\n inflight.delete(key);\n }\n });\n\n inflight.set(key, promise);\n return promise;\n },\n\n forget(key: string): boolean {\n return inflight.delete(key);\n },\n\n isInFlight(key: string): boolean {\n return inflight.has(key);\n },\n\n get size(): number {\n return inflight.size;\n },\n };\n}\n\n/**\n * Shared default instance — convenient for app-wide deduplication.\n * Prefer `singlet()` instances in libraries to avoid key collisions.\n */\nexport const shared: Singlet = singlet();\n\n// Default export for ESM compatibility\nexport default singlet;\n","/**\n * Ergonomic layer over singlet core.\n *\n * This module COMPOSES the core singlet functionality — it never duplicates logic.\n * See src/singlet.ts for the core implementation.\n */\n\nimport type { Singlet } from \"./singlet.js\";\nimport { singlet as createSinglet } from \"./singlet.js\";\n\n/**\n * Options for configuring a wrapped function.\n */\nexport interface WrapOptions<TArgs> {\n /**\n * Extract a deduplication key from function arguments.\n * Return the same key for concurrent calls that should share one promise.\n *\n * @example\n * ```ts\n * // Dedupe by user ID:\n * keyFn: ([id]) => `user:${id}`\n *\n * // Dedupe by multiple args:\n * keyFn: ([method, url]) => `${method}:${url}`\n * ```\n */\n keyFn: (args: TArgs) => string;\n\n /**\n * Optional singlet instance for this wrapped function.\n * Defaults to a new isolated instance.\n * Use this to share a singlet across multiple wrapped functions.\n */\n singlet?: Singlet;\n}\n\n/**\n * Wrap an async function to automatically deduplicate concurrent calls.\n *\n * @example\n * ```ts\n * import { wrap } from 'singlet';\n *\n * const fetchUser = wrap(\n * async (id: string) => fetch(`/api/users/${id}`).then(r => r.json()),\n * { keyFn: ([id]) => `user:${id}` }\n * );\n *\n * // These two concurrent calls share ONE fetch:\n * const [user1, user2] = await Promise.all([\n * fetchUser('42'),\n * fetchUser('42')\n * ]);\n * ```\n *\n * @param fn - The async function to wrap\n * @param options - Configuration for key extraction and singlet instance\n * @returns A wrapped function with automatic deduplication\n */\nexport function wrap<TArgs extends unknown[], TResult>(\n fn: (...args: TArgs) => TResult | Promise<TResult>,\n options: WrapOptions<TArgs>\n): (...args: TArgs) => Promise<TResult> {\n const instance = options.singlet ?? createSinglet();\n\n return function (...args: TArgs): Promise<TResult> {\n const key = options.keyFn(args);\n return instance.run(key, () => fn(...args));\n };\n}\n\n/**\n * Wrap an async function with a simple key string (for single-operation functions).\n *\n * @example\n * ```ts\n * import { wrapWithKey } from 'singlet';\n *\n * const loadConfig = wrapWithKey(\n * async () => fetch('/api/config').then(r => r.json()),\n * 'config'\n * );\n * ```\n *\n * @param fn - The async function to wrap\n * @param key - Fixed key for all calls to this function\n * @param singlet - Optional singlet instance\n * @returns A wrapped function with fixed-key deduplication\n */\nexport function wrapWithKey<TResult>(\n fn: () => TResult | Promise<TResult>,\n key: string,\n singlet?: Singlet\n): () => Promise<TResult> {\n const options: WrapOptions<[]> = { keyFn: () => key };\n if (singlet !== undefined) {\n options.singlet = singlet;\n }\n return wrap(fn, options);\n}\n"],"mappings":";AAgCO,SAAS,UAAmB;AACjC,QAAM,WAAW,oBAAI,IAA8B;AAEnD,SAAO;AAAA,IACL,IAAO,KAAa,IAAsC;AACxD,YAAM,WAAW,SAAS,IAAI,GAAG;AACjC,UAAI,aAAa,QAAW;AAC1B,eAAO;AAAA,MACT;AAUA,YAAM,UAAsB,QAAQ,QAAQ,EACzC,KAAK,EAAE,EACP,QAAQ,MAAM;AACb,YAAI,SAAS,IAAI,GAAG,MAAM,SAAS;AACjC,mBAAS,OAAO,GAAG;AAAA,QACrB;AAAA,MACF,CAAC;AAEH,eAAS,IAAI,KAAK,OAAO;AACzB,aAAO;AAAA,IACT;AAAA,IAEA,OAAO,KAAsB;AAC3B,aAAO,SAAS,OAAO,GAAG;AAAA,IAC5B;AAAA,IAEA,WAAW,KAAsB;AAC/B,aAAO,SAAS,IAAI,GAAG;AAAA,IACzB;AAAA,IAEA,IAAI,OAAe;AACjB,aAAO,SAAS;AAAA,IAClB;AAAA,EACF;AACF;AAMO,IAAM,SAAkB,QAAQ;AAGvC,IAAO,kBAAQ;;;ACvBR,SAAS,KACd,IACA,SACsC;AACtC,QAAM,WAAW,QAAQ,WAAW,QAAc;AAElD,SAAO,YAAa,MAA+B;AACjD,UAAM,MAAM,QAAQ,MAAM,IAAI;AAC9B,WAAO,SAAS,IAAI,KAAK,MAAM,GAAG,GAAG,IAAI,CAAC;AAAA,EAC5C;AACF;AAoBO,SAAS,YACd,IACA,KACAA,UACwB;AACxB,QAAM,UAA2B,EAAE,OAAO,MAAM,IAAI;AACpD,MAAIA,aAAY,QAAW;AACzB,YAAQ,UAAUA;AAAA,EACpB;AACA,SAAO,KAAK,IAAI,OAAO;AACzB;","names":["singlet"]}
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@azghr/singlet",
3
+ "version": "0.1.0",
4
+ "description": "Deduplicate concurrent async calls: same key, one in-flight promise. Not a cache.",
5
+ "keywords": [
6
+ "singleflight",
7
+ "dedupe",
8
+ "deduplication",
9
+ "promise",
10
+ "async",
11
+ "concurrency",
12
+ "thundering-herd",
13
+ "request-coalescing",
14
+ "typescript",
15
+ "type-safe",
16
+ "isomorphic",
17
+ "in-flight"
18
+ ],
19
+ "license": "MIT",
20
+ "author": "",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/azghr/singlet.git"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "main": "./dist/index.cjs",
39
+ "module": "./dist/index.js",
40
+ "types": "./dist/index.d.ts",
41
+ "exports": {
42
+ ".": {
43
+ "import": {
44
+ "types": "./dist/index.d.ts",
45
+ "default": "./dist/index.js"
46
+ },
47
+ "require": {
48
+ "types": "./dist/index.d.cts",
49
+ "default": "./dist/index.cjs"
50
+ }
51
+ }
52
+ },
53
+ "scripts": {
54
+ "build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean",
55
+ "demo": "tsx examples/demo.ts",
56
+ "test": "vitest run",
57
+ "test:watch": "vitest",
58
+ "typecheck": "tsc --noEmit",
59
+ "lint": "eslint src test",
60
+ "check": "npm run typecheck && npm run lint && npm run test && npm run build",
61
+ "prepublishOnly": "npm run check"
62
+ },
63
+ "devDependencies": {
64
+ "@eslint/js": "^9.0.0",
65
+ "@types/node": "^22.20.1",
66
+ "eslint": "^9.0.0",
67
+ "tsup": "^8.0.0",
68
+ "tsx": "^4.23.0",
69
+ "typescript": "^5.5.0",
70
+ "typescript-eslint": "^8.0.0",
71
+ "vitest": "^3.0.0"
72
+ }
73
+ }