@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 +21 -0
- package/README.md +412 -0
- package/dist/index.cjs +84 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +111 -0
- package/dist/index.d.ts +111 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|