@jaseeey/request-manager 2.1.4 → 2.2.1
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/README.md +599 -61
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/request-manager/index.d.ts +1 -0
- package/dist/cjs/request-manager/request-manager.d.ts +54 -8
- package/dist/cjs/request-manager/request-manager.js +109 -21
- package/dist/cjs/request-manager/sha256.d.ts +14 -0
- package/dist/cjs/request-manager/sha256.js +106 -0
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/request-manager/index.d.ts +1 -0
- package/dist/esm/request-manager/request-manager.d.ts +54 -8
- package/dist/esm/request-manager/request-manager.js +109 -21
- package/dist/esm/request-manager/sha256.d.ts +14 -0
- package/dist/esm/request-manager/sha256.js +102 -0
- package/package.json +37 -23
package/README.md
CHANGED
|
@@ -1,128 +1,666 @@
|
|
|
1
1
|
# RequestManager
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@jaseeey/request-manager` is a small TypeScript library that **de-duplicates concurrent HTTP requests** made through [Axios](https://axios-http.com/).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
If several parts of your application call the same endpoint at the same time (same Axios client instance, method, and URL), only **one** network request is made. Every caller receives the **same shared promise** and resolves or rejects together.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
It is intentionally focused: not a full HTTP client, cache layer, or request queue. Use it when concurrent duplicate in-flight calls are the problem you need to solve.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
---
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
## Table of contents
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
- [When to use it](#when-to-use-it)
|
|
14
|
+
- [Related tools](#related-tools)
|
|
15
|
+
- [Installation](#installation)
|
|
16
|
+
- [Quick start](#quick-start)
|
|
17
|
+
- [How de-duplication works](#how-de-duplication-works)
|
|
18
|
+
- [Choosing a manager instance](#choosing-a-manager-instance)
|
|
19
|
+
- [API reference](#api-reference)
|
|
20
|
+
- [Effective usage patterns](#effective-usage-patterns)
|
|
21
|
+
- [TypeScript tips](#typescript-tips)
|
|
22
|
+
- [CommonJS](#commonjs)
|
|
23
|
+
- [FAQ](#faq)
|
|
24
|
+
- [Known limitations](#known-limitations)
|
|
25
|
+
- [Background and scope](#background-and-scope)
|
|
26
|
+
- [Contributing](#contributing)
|
|
27
|
+
- [License](#license)
|
|
14
28
|
|
|
15
|
-
|
|
29
|
+
---
|
|
16
30
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
31
|
+
## When to use it
|
|
32
|
+
|
|
33
|
+
**Good fit**
|
|
34
|
+
|
|
35
|
+
- Multiple components mount at once and each load the same resource (`GET /users/me`, `GET /config`, …).
|
|
36
|
+
- A user double-clicks a button and you want a single in-flight `POST`/`PUT` until it finishes.
|
|
37
|
+
- You already use Axios (or an Axios-compatible `request()` surface) and want de-duplication without rewriting your client.
|
|
38
|
+
|
|
39
|
+
**Usually not the right tool**
|
|
40
|
+
|
|
41
|
+
- You need response **caching** after the request has finished (this library only de-duplicates **in-flight** requests).
|
|
42
|
+
- You need keys based on **body or headers** (params *are* part of the key; bodies are not — see [Known limitations](#known-limitations)).
|
|
43
|
+
- You need automatic retries, offline queues, or GraphQL batching.
|
|
44
|
+
|
|
45
|
+
## Related tools
|
|
46
|
+
|
|
47
|
+
Libraries such as **TanStack Query**, **SWR**, and **Vue Query** solve a broader problem: server-state caching, background revalidation, stale-while-revalidate UX, and often retries.
|
|
48
|
+
|
|
49
|
+
RequestManager is narrower:
|
|
50
|
+
|
|
51
|
+
| Concern | RequestManager | Query libraries (typical) |
|
|
52
|
+
|---------|----------------|---------------------------|
|
|
53
|
+
| Collapse concurrent identical in-flight HTTP calls | Yes | Sometimes, as part of a larger cache model |
|
|
54
|
+
| Keep a cache after the request finishes | No | Yes |
|
|
55
|
+
| Revalidate on focus / interval | No | Yes |
|
|
56
|
+
| Requires Axios | Yes (client with `request`) | Usually `fetch` or pluggable clients |
|
|
57
|
+
|
|
58
|
+
You can use both: a query library for cache and lifecycle, and RequestManager underneath an Axios-based API module when multiple non-query call sites still risk duplicate in-flight requests. Do not expect RequestManager alone to replace a query library.
|
|
59
|
+
|
|
60
|
+
---
|
|
21
61
|
|
|
22
62
|
## Installation
|
|
23
63
|
|
|
24
|
-
|
|
64
|
+
```bash
|
|
65
|
+
npm install @jaseeey/request-manager axios
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Axios is a peer dependency** (`axios >= 1`). The package does not bundle a client for you; you pass your own `AxiosInstance` into `call()`. Install Axios alongside this library if it is not already in your project:
|
|
25
69
|
|
|
26
70
|
```bash
|
|
27
|
-
npm install
|
|
71
|
+
npm install axios
|
|
28
72
|
```
|
|
29
73
|
|
|
30
|
-
|
|
74
|
+
Requires **Node.js 18+** for development tooling; the published package is plain ESM/CJS JavaScript for bundlers and Node.
|
|
31
75
|
|
|
32
|
-
|
|
76
|
+
---
|
|
33
77
|
|
|
34
|
-
|
|
78
|
+
## Quick start
|
|
35
79
|
|
|
36
|
-
|
|
80
|
+
### ESM (recommended)
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
import axios from 'axios';
|
|
37
84
|
import requestManager from '@jaseeey/request-manager';
|
|
38
85
|
|
|
39
|
-
const client = axios.create(
|
|
40
|
-
|
|
41
|
-
|
|
86
|
+
const client = axios.create({
|
|
87
|
+
baseURL: 'https://api.example.com',
|
|
88
|
+
timeout: 10_000
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Only one HTTP request is sent; both callers share the result.
|
|
92
|
+
const [a, b] = await Promise.all([
|
|
93
|
+
requestManager.call(client, 'GET', '/users/me'),
|
|
94
|
+
requestManager.call(client, 'GET', '/users/me')
|
|
95
|
+
]);
|
|
42
96
|
|
|
43
|
-
|
|
97
|
+
console.log(a === b); // true (same resolved value / same shared completion)
|
|
44
98
|
```
|
|
45
99
|
|
|
46
|
-
###
|
|
100
|
+
### Return response data only
|
|
47
101
|
|
|
48
|
-
```
|
|
102
|
+
```typescript
|
|
103
|
+
import axios from 'axios';
|
|
49
104
|
import requestManager from '@jaseeey/request-manager';
|
|
50
105
|
|
|
51
|
-
const client = axios.create();
|
|
52
|
-
|
|
106
|
+
const client = axios.create({ baseURL: 'https://api.example.com' });
|
|
107
|
+
|
|
108
|
+
const user = await requestManager.call(
|
|
109
|
+
client,
|
|
110
|
+
'GET',
|
|
111
|
+
'/users/me',
|
|
112
|
+
undefined,
|
|
113
|
+
undefined,
|
|
114
|
+
(response) => response.data // non-undefined return becomes the promise result
|
|
115
|
+
);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Handle errors without throwing
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
import axios from 'axios';
|
|
122
|
+
import requestManager from '@jaseeey/request-manager';
|
|
123
|
+
|
|
124
|
+
const client = axios.create({ baseURL: 'https://api.example.com' });
|
|
53
125
|
|
|
54
126
|
const result = await requestManager.call(
|
|
55
127
|
client,
|
|
56
128
|
'GET',
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
129
|
+
'/users/me',
|
|
130
|
+
undefined,
|
|
131
|
+
undefined,
|
|
132
|
+
undefined,
|
|
133
|
+
(error) => {
|
|
134
|
+
console.error('Load failed', error);
|
|
135
|
+
// Promise resolves to undefined instead of rejecting
|
|
63
136
|
}
|
|
64
137
|
);
|
|
65
138
|
```
|
|
66
139
|
|
|
67
|
-
|
|
140
|
+
---
|
|
68
141
|
|
|
69
|
-
|
|
142
|
+
## How de-duplication works
|
|
143
|
+
|
|
144
|
+
### Request key
|
|
145
|
+
|
|
146
|
+
Each in-flight request is stored under a key built from:
|
|
147
|
+
|
|
148
|
+
1. **Axios client instance** (identity, not config equality)
|
|
149
|
+
2. **HTTP method** (compared case-insensitively, e.g. `GET` and `get` match)
|
|
150
|
+
3. **URL string** (exact string match of the `url` argument—not Axios's fully resolved URL)
|
|
151
|
+
4. **`config.params`** (deterministic serialisation; missing/`undefined`/empty params are equivalent)
|
|
152
|
+
|
|
153
|
+
```text
|
|
154
|
+
identity = `${clientId}:${method.toLowerCase()}:${url}:${stableParams}`
|
|
155
|
+
mapKey = sha256Hex(identity) // fixed-length hex; used as Map key only
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Object key order in `params` does not matter (`{ a: 1, b: 2 }` matches `{ b: 2, a: 1 }`). Nested plain objects and arrays are included. `URLSearchParams` is supported.
|
|
159
|
+
|
|
160
|
+
The Map key is a **SHA-256 hash** so long URLs/params do not bloat the map. Full details stay on each entry:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
for (const [hash, entry] of requestManager.activeRequests) {
|
|
164
|
+
entry.hash; // same as Map key
|
|
165
|
+
entry.identity.method; // e.g. 'get'
|
|
166
|
+
entry.identity.url;
|
|
167
|
+
entry.identity.params; // as supplied (or null)
|
|
168
|
+
entry.identity.paramsSerialised;
|
|
169
|
+
entry.original; // underlying Axios promise
|
|
170
|
+
entry.processed; // shared caller promise
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
See also [`baseURL` is not part of the key](#baseurl-is-not-part-of-the-key).
|
|
175
|
+
|
|
176
|
+
### Lifecycle
|
|
177
|
+
|
|
178
|
+
1. First `call()` for a key creates the Axios request and stores its promise.
|
|
179
|
+
2. Further `call()`s with the same key **while the HTTP request is still in flight** return the **existing processed promise**.
|
|
180
|
+
3. When the **HTTP request** settles (success or failure), the key is removed from the active map. This happens before `onSuccess` finishes if that callback is async or slow.
|
|
181
|
+
4. A later `call()` with the same key—including one made from inside `onSuccess` after the HTTP response arrived—starts a **new** network request.
|
|
182
|
+
|
|
183
|
+
Keeping the key only for the HTTP phase avoids deadlocking when `onSuccess` itself issues another same-key `call()` (joining the still-running processed promise would wait forever).
|
|
184
|
+
|
|
185
|
+
There is **no cache** of completed responses. De-duplication applies only to concurrent in-flight HTTP work, not to success-callback execution time.
|
|
186
|
+
|
|
187
|
+
### What is and is not part of the key
|
|
188
|
+
|
|
189
|
+
| Input | Same key? | Notes |
|
|
190
|
+
|--------|-----------|--------|
|
|
191
|
+
| Same client, method, URL, and `params` | Yes | Concurrent callers share one Promise |
|
|
192
|
+
| Different `config.params` (different subsets/filters) | No | Separate HTTP requests — correct for different data slices |
|
|
193
|
+
| Missing / `undefined` / `{}` params | Yes (equivalent) | Treated as “no params” |
|
|
194
|
+
| Different `data` bodies | Yes (same key) | Body is **not** in the key (mutations are usually user actions) |
|
|
195
|
+
| Different headers, timeout, signal, etc. | Yes (same key) | Other config is **not** in the key |
|
|
196
|
+
| Same path, different full URL strings | No | `'/users?id=1'` and `'/users?id=2'` are different keys |
|
|
197
|
+
| Different Axios instances | No | Separate clients never share de-duplication |
|
|
198
|
+
| Different methods | No | `GET` and `POST` to the same URL are independent |
|
|
199
|
+
|
|
200
|
+
### Shared promise and callbacks
|
|
201
|
+
|
|
202
|
+
When a second caller joins an in-flight request:
|
|
203
|
+
|
|
204
|
+
- It receives the **same** processed promise as the first caller.
|
|
205
|
+
- Only the **first** caller’s `onSuccess` / `onError` run for that network attempt.
|
|
206
|
+
- Callbacks passed by later callers are **ignored** for that in-flight request.
|
|
207
|
+
|
|
208
|
+
If you need per-caller side effects, prefer:
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
const response = await requestManager.call(client, 'GET', url);
|
|
212
|
+
// each caller runs its own logic after await
|
|
213
|
+
updateUi(response.data);
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Joiners also share **promise identity**: concurrent callers for the same key receive the same `Promise` instance, not merely the same eventual value.
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
const p1 = requestManager.call(client, 'GET', '/users/me');
|
|
220
|
+
const p2 = requestManager.call(client, 'GET', '/users/me');
|
|
221
|
+
console.log(p1 === p2); // true while the request is in flight
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Axios interceptors run once
|
|
225
|
+
|
|
226
|
+
Request and response interceptors on the Axios client run for the **single** underlying `client.request(...)`. Joined callers do not re-enter interceptors.
|
|
227
|
+
|
|
228
|
+
That is usually what you want for auth headers, logging, and token refresh: one network attempt, one interceptor chain, many awaiters.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Choosing a manager instance
|
|
233
|
+
|
|
234
|
+
### Default shared instance (most apps)
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
import requestManager from '@jaseeey/request-manager';
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Use this when you want app-wide de-duplication for a given Axios client and endpoint. This is the recommended default in browser single-page apps.
|
|
241
|
+
|
|
242
|
+
The default export is a **module-level singleton**: one shared `RequestManager` for the entire JavaScript realm that loaded the module (typically one per browser tab, or one per Node process).
|
|
243
|
+
|
|
244
|
+
### Isolated managers
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
import { RequestManager } from '@jaseeey/request-manager';
|
|
248
|
+
|
|
249
|
+
const billingRequests = new RequestManager();
|
|
250
|
+
const adminRequests = new RequestManager();
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Each instance has its **own** active-request map. The same client/method/URL can run in parallel across different manager instances.
|
|
254
|
+
|
|
255
|
+
Use isolated managers when:
|
|
256
|
+
|
|
257
|
+
- You need separate de-duplication domains (e.g. multi-tenant tabs, micro-frontends).
|
|
258
|
+
- Tests should not share state with the default singleton (or clear `activeRequests` carefully).
|
|
259
|
+
- You run **SSR or multi-request Node** code and must not share in-flight maps across concurrent HTTP requests or users.
|
|
260
|
+
|
|
261
|
+
#### Server-side rendering and multi-request Node
|
|
262
|
+
|
|
263
|
+
On the server, the default export can be shared across concurrent incoming requests in the same process. That may incorrectly join unrelated users' in-flight calls if they hit the same client/method/URL key.
|
|
264
|
+
|
|
265
|
+
Prefer creating a manager (and often an Axios client) **per request** or per app context:
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
import axios from 'axios';
|
|
70
269
|
import { RequestManager } from '@jaseeey/request-manager';
|
|
71
270
|
|
|
271
|
+
export function createRequestContext() {
|
|
272
|
+
const api = axios.create({ baseURL: process.env.API_URL });
|
|
273
|
+
const requests = new RequestManager();
|
|
274
|
+
return { api, requests };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// inside a single incoming request handler / RSC context
|
|
278
|
+
const { api, requests } = createRequestContext();
|
|
279
|
+
await requests.call(api, 'GET', '/users/me');
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
In the browser, the default singleton is usually correct because one tab is one user session.
|
|
283
|
+
|
|
284
|
+
### Legacy static API
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
import axios from 'axios';
|
|
288
|
+
import { RequestManager } from '@jaseeey/request-manager';
|
|
289
|
+
|
|
290
|
+
const client = axios.create();
|
|
291
|
+
await RequestManager.call(client, 'GET', 'https://example.com/health');
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`RequestManager.call(...)` is a **deprecated** compatibility helper. It always delegates to the **default shared instance**, not to `new RequestManager()`. Prefer the default export.
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## API reference
|
|
299
|
+
|
|
300
|
+
### Imports
|
|
301
|
+
|
|
302
|
+
| Import | Description |
|
|
303
|
+
|--------|-------------|
|
|
304
|
+
| `import requestManager from '@jaseeey/request-manager'` | Default shared instance |
|
|
305
|
+
| `import { RequestManager } from '@jaseeey/request-manager'` | Class for new instances / static legacy API |
|
|
306
|
+
| `import requestManager, { RequestManager } from '@jaseeey/request-manager'` | Both |
|
|
307
|
+
| `import … from '@jaseeey/request-manager/request-manager'` | Subpath export of the same surface |
|
|
308
|
+
|
|
309
|
+
### `requestManager.call(client, method, url, data?, config?, onSuccess?, onError?)`
|
|
310
|
+
|
|
311
|
+
| Parameter | Type | Default | Description |
|
|
312
|
+
|-----------|------|---------|-------------|
|
|
313
|
+
| `client` | `AxiosInstance` | required | Axios instance used to perform `client.request(...)` |
|
|
314
|
+
| `method` | `Method` | required | HTTP method (`'GET'`, `'POST'`, …) |
|
|
315
|
+
| `url` | `string` | required | Request URL (relative or absolute, as Axios expects) |
|
|
316
|
+
| `data` | `any` | `{}` | Request body (also used when `null`/`undefined` is passed → coerced to `{}`) |
|
|
317
|
+
| `config` | `AxiosRequestConfig \| null` | `{}` | Extra Axios config merged into the request (`null`/`undefined` → `{}`) |
|
|
318
|
+
| `onSuccess` | `((response) => T \| Promise<T \| undefined>) \| null` | — | Optional success hook; see below |
|
|
319
|
+
| `onError` | `((error) => void) \| null` | — | Optional error hook; see below |
|
|
320
|
+
|
|
321
|
+
**Return value**
|
|
322
|
+
|
|
323
|
+
- With no `onSuccess` (or `onSuccess` returns `undefined`): resolves to the full `AxiosResponse`.
|
|
324
|
+
- If `onSuccess` returns a value other than `undefined`: resolves to that value (may be async).
|
|
325
|
+
- If the request fails and `onError` is provided: `onError` is invoked and the promise **resolves to `undefined`** (does not rethrow).
|
|
326
|
+
- If the request fails and `onError` is omitted: the promise **rejects** with the error.
|
|
327
|
+
|
|
328
|
+
**Notes**
|
|
329
|
+
|
|
330
|
+
- `onSuccess` may return a `Promise`; it is awaited.
|
|
331
|
+
- `onError` is not awaited; keep it synchronous or fire-and-forget async work carefully.
|
|
332
|
+
- Method matching for de-duplication is case-insensitive.
|
|
333
|
+
|
|
334
|
+
### Instance fields
|
|
335
|
+
|
|
336
|
+
| Member | Description |
|
|
337
|
+
|--------|-------------|
|
|
338
|
+
| `activeRequests` | `Map<hash, ActiveRequest>` of in-flight entries. Keys are SHA-256 digests; values include `identity`, `original`, and `processed`. Prefer not to depend on hash format in production code. |
|
|
339
|
+
|
|
340
|
+
`ActiveRequest` keeps **backward-compatible** promise fields (`original`, `processed`) and adds `hash` + `identity` for inspection.
|
|
341
|
+
|
|
342
|
+
Clearing the map mid-flight is not recommended except in tests. If you clear an in-flight key, a later `call()` with the same client/method/URL/params will start a **new** HTTP request while the original promise may still settle independently.
|
|
343
|
+
|
|
344
|
+
### Constructor
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
const manager = new RequestManager();
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Creates an isolated manager. The generic type parameter on the class is historical; prefer generics on `call()` for response typing.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Effective usage patterns
|
|
355
|
+
|
|
356
|
+
### 1. Central Axios client + default manager
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
// api/client.ts
|
|
360
|
+
import axios from 'axios';
|
|
361
|
+
|
|
362
|
+
export const api = axios.create({
|
|
363
|
+
baseURL: import.meta.env.VITE_API_URL,
|
|
364
|
+
withCredentials: true
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
api.interceptors.request.use((config) => {
|
|
368
|
+
// attach auth headers, etc.
|
|
369
|
+
return config;
|
|
370
|
+
});
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
// api/users.ts
|
|
375
|
+
import requestManager from '@jaseeey/request-manager';
|
|
376
|
+
import { api } from './client';
|
|
377
|
+
|
|
378
|
+
export function fetchCurrentUser() {
|
|
379
|
+
return requestManager.call(
|
|
380
|
+
api,
|
|
381
|
+
'GET',
|
|
382
|
+
'/users/me',
|
|
383
|
+
undefined,
|
|
384
|
+
undefined,
|
|
385
|
+
(res) => res.data
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Several UI components can call `fetchCurrentUser()` on mount safely; only one HTTP request runs at a time for that key.
|
|
391
|
+
|
|
392
|
+
### 2. Distinguish list/filter loads with URL query or `config.params`
|
|
393
|
+
|
|
394
|
+
Either style works; both affect identity:
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
// Different keys via URL string
|
|
398
|
+
await requestManager.call(api, 'GET', `/items?id=${id}`);
|
|
399
|
+
|
|
400
|
+
// Different keys via config.params (included in the de-dupe key)
|
|
401
|
+
await requestManager.call(api, 'GET', '/items', undefined, { params: { id } });
|
|
402
|
+
|
|
403
|
+
// Same params → same key → one in-flight request for concurrent callers
|
|
404
|
+
await Promise.all([
|
|
405
|
+
requestManager.call(api, 'GET', '/jobs', {}, { params: { includeArchived: false } }),
|
|
406
|
+
requestManager.call(api, 'GET', '/jobs', {}, { params: { includeArchived: false } })
|
|
407
|
+
]);
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Prefer one convention in a given app (query in the URL **or** `params`) so keys stay predictable. Mixing both for the same logical filter can create two keys that look “the same” to humans but differ in the key string.
|
|
411
|
+
|
|
412
|
+
### 3. Avoid accidental de-duplication of different POST bodies
|
|
413
|
+
|
|
414
|
+
Concurrent `POST`s to the same URL with different bodies currently share one request (first body wins for the network call; all callers share that outcome). If that is wrong for your API:
|
|
415
|
+
|
|
416
|
+
- Use distinct URLs, or
|
|
417
|
+
- Use separate manager instances, or
|
|
418
|
+
- Call Axios directly for those operations.
|
|
419
|
+
|
|
420
|
+
### 4. Prefer post-await logic over dual callbacks for multi-caller UI
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
// Preferred when many callers may join one request
|
|
424
|
+
try {
|
|
425
|
+
const res = await requestManager.call(api, 'GET', '/settings');
|
|
426
|
+
applySettings(res.data);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
showError(err);
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### 5. Double-submit protection
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
async function saveProfile(payload: Profile) {
|
|
437
|
+
return requestManager.call(
|
|
438
|
+
api,
|
|
439
|
+
'PUT',
|
|
440
|
+
'/profile',
|
|
441
|
+
payload,
|
|
442
|
+
undefined,
|
|
443
|
+
(res) => res.data
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Rapid repeated calls while the first is in flight share one PUT.
|
|
448
|
+
await Promise.all([saveProfile(data), saveProfile(data)]);
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Remember: body is not in the key—only use this when concurrent calls are intentionally identical or the first body is acceptable for all joiners.
|
|
452
|
+
|
|
453
|
+
### 6. Testing
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
import requestManager from '@jaseeey/request-manager';
|
|
457
|
+
|
|
458
|
+
afterEach(() => {
|
|
459
|
+
requestManager.activeRequests.clear();
|
|
460
|
+
});
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Or construct `new RequestManager()` per test file to avoid shared state.
|
|
464
|
+
|
|
465
|
+
### 7. Cancellation
|
|
466
|
+
|
|
467
|
+
This library does not cancel Axios requests. To cancel, pass an `AbortSignal` in `config` as you would with Axios. Joined callers still share the same promise; aborting affects the single underlying request.
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
const controller = new AbortController();
|
|
471
|
+
|
|
472
|
+
const promise = requestManager.call(
|
|
473
|
+
api,
|
|
474
|
+
'GET',
|
|
475
|
+
'/slow',
|
|
476
|
+
undefined,
|
|
477
|
+
{ signal: controller.signal }
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
controller.abort();
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
## TypeScript tips
|
|
486
|
+
|
|
487
|
+
```typescript
|
|
488
|
+
import type { AxiosResponse } from 'axios';
|
|
489
|
+
import axios from 'axios';
|
|
490
|
+
import requestManager from '@jaseeey/request-manager';
|
|
491
|
+
|
|
492
|
+
interface User {
|
|
493
|
+
id: string;
|
|
494
|
+
name: string;
|
|
495
|
+
}
|
|
496
|
+
|
|
72
497
|
const client = axios.create();
|
|
73
|
-
const scopedRequestManager = new RequestManager();
|
|
74
498
|
|
|
75
|
-
|
|
499
|
+
// Full AxiosResponse
|
|
500
|
+
const response = await requestManager.call<User>(client, 'GET', '/users/me');
|
|
501
|
+
// response is AxiosResponse<User> when onSuccess is omitted
|
|
502
|
+
|
|
503
|
+
// Mapped result type via onSuccess return value
|
|
504
|
+
const user = await requestManager.call<User, User>(
|
|
505
|
+
client,
|
|
506
|
+
'GET',
|
|
507
|
+
'/users/me',
|
|
508
|
+
undefined,
|
|
509
|
+
undefined,
|
|
510
|
+
(res: AxiosResponse<User>) => res.data
|
|
511
|
+
);
|
|
76
512
|
```
|
|
77
513
|
|
|
78
|
-
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
## CommonJS
|
|
79
517
|
|
|
80
518
|
```javascript
|
|
81
|
-
|
|
519
|
+
const axios = require('axios');
|
|
520
|
+
const requestManager = require('@jaseeey/request-manager').default;
|
|
521
|
+
// or: const { RequestManager } = require('@jaseeey/request-manager');
|
|
522
|
+
|
|
523
|
+
const client = axios.create();
|
|
82
524
|
|
|
83
|
-
|
|
525
|
+
requestManager.call(client, 'GET', 'https://example.com').then((res) => {
|
|
526
|
+
console.log(res.status);
|
|
527
|
+
});
|
|
84
528
|
```
|
|
85
529
|
|
|
86
|
-
|
|
530
|
+
Package exports:
|
|
531
|
+
|
|
532
|
+
- `require('@jaseeey/request-manager')` → CJS build
|
|
533
|
+
- `import … from '@jaseeey/request-manager'` → ESM build
|
|
534
|
+
- Types resolve from the ESM declaration entry
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
## FAQ
|
|
539
|
+
|
|
540
|
+
### Does RequestManager cache responses?
|
|
541
|
+
|
|
542
|
+
No. It only de-duplicates **in-flight** requests. After a call settles, the next `call()` with the same key performs a new network request. For caching, revalidation, and background refresh, use a data library (see [Related tools](#related-tools)) or your own cache.
|
|
543
|
+
|
|
544
|
+
### Why did two different POST bodies share one request?
|
|
87
545
|
|
|
88
|
-
|
|
546
|
+
The de-duplication key ignores `data`. Concurrent `POST`s to the same client, method, and URL string join the first request; later bodies are not sent separately. Use distinct URLs, separate manager instances, or call Axios directly when bodies must not merge. See [Avoid accidental de-duplication of different POST bodies](#3-avoid-accidental-de-duplication-of-different-post-bodies).
|
|
89
547
|
|
|
90
|
-
### `
|
|
548
|
+
### Do different `config.params` create separate keys?
|
|
549
|
+
|
|
550
|
+
Yes. `config.params` is part of the de-duplication key (stable serialisation). Different filters/subsets produce different keys and separate HTTP requests; identical params still share one in-flight Promise. See [Request key](#request-key).
|
|
551
|
+
|
|
552
|
+
### Can I use `fetch` instead of Axios?
|
|
553
|
+
|
|
554
|
+
Not with this package as-is. `call()` expects an Axios-like client with `request(config)`. You could wrap `fetch` behind a minimal `request()` adapter, but that is outside the supported surface.
|
|
555
|
+
|
|
556
|
+
### What if `onSuccess` returns `undefined`?
|
|
557
|
+
|
|
558
|
+
The promise still resolves to the full `AxiosResponse`. Only a **non-`undefined`** return value from `onSuccess` replaces the result.
|
|
559
|
+
|
|
560
|
+
### How can I see what is in flight?
|
|
561
|
+
|
|
562
|
+
Inspect `requestManager.activeRequests.size` or iterate values and read `entry.identity` for debugging and tests. Treat hash keys as opaque; use `identity` for human-readable details.
|
|
563
|
+
|
|
564
|
+
### Should I migrate off `RequestManager.call`?
|
|
565
|
+
|
|
566
|
+
Yes, when convenient. The static helper is deprecated and always uses the default shared instance:
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
// Before
|
|
570
|
+
await RequestManager.call(client, 'GET', '/users/me');
|
|
571
|
+
|
|
572
|
+
// After
|
|
573
|
+
import requestManager from '@jaseeey/request-manager';
|
|
574
|
+
await requestManager.call(client, 'GET', '/users/me');
|
|
575
|
+
```
|
|
91
576
|
|
|
92
|
-
|
|
93
|
-
- `method`: The HTTP method (e.g., 'GET', 'POST').
|
|
94
|
-
- `url`: The URL to which the request is sent.
|
|
95
|
-
- `data` (optional): The data to be sent as the request body.
|
|
96
|
-
- `config` (optional): The configuration options for the request.
|
|
97
|
-
- `onSuccess` (optional): A callback function that is called when the request is successful. Returning a non-`undefined` value from this callback changes the resolved value of the `call()` promise.
|
|
98
|
-
- `onError` (optional): A callback function that is called when the request fails.
|
|
577
|
+
---
|
|
99
578
|
|
|
100
|
-
|
|
579
|
+
## Known limitations
|
|
101
580
|
|
|
102
|
-
|
|
581
|
+
### In-flight only (no response cache)
|
|
103
582
|
|
|
104
|
-
|
|
583
|
+
After a request completes, a new `call()` hits the network again. Add your own cache if you need longer-lived memoisation.
|
|
105
584
|
|
|
106
|
-
|
|
585
|
+
### First caller owns callbacks
|
|
107
586
|
|
|
108
|
-
|
|
587
|
+
Only the first in-flight caller’s `onSuccess` / `onError` execute. Joiners share the processed promise only.
|
|
109
588
|
|
|
110
|
-
|
|
589
|
+
### Key ignores body and most config
|
|
111
590
|
|
|
112
|
-
|
|
591
|
+
De-duplication includes `config.params` but not request bodies, headers, timeout, or abort signals. Concurrent mutations with different bodies on the same URL can still share one request unless you isolate them.
|
|
113
592
|
|
|
114
|
-
|
|
593
|
+
### Exact URL string matching
|
|
115
594
|
|
|
116
|
-
|
|
595
|
+
`/users` and `/users/` are different keys. Relative URLs are not normalised against `baseURL` for keying—the string you pass is the key segment.
|
|
117
596
|
|
|
118
|
-
|
|
597
|
+
### `baseURL` is not part of the key
|
|
119
598
|
|
|
120
|
-
|
|
121
|
-
|
|
599
|
+
Axios may resolve a relative `url` against the client's `baseURL` when sending the request, but RequestManager keys only on the **`url` argument string**.
|
|
600
|
+
|
|
601
|
+
```typescript
|
|
602
|
+
const client = axios.create({ baseURL: 'https://api.example.com' });
|
|
603
|
+
|
|
604
|
+
// These share one in-flight request (same url string: '/users/me')
|
|
605
|
+
await Promise.all([
|
|
606
|
+
requestManager.call(client, 'GET', '/users/me'),
|
|
607
|
+
requestManager.call(client, 'GET', '/users/me')
|
|
608
|
+
]);
|
|
609
|
+
|
|
610
|
+
// This is a different key (different url string), even if it hits the same origin
|
|
611
|
+
await requestManager.call(client, 'GET', 'https://api.example.com/users/me');
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Keep the `url` argument consistent across call sites (usually the same relative path) so de-duplication works as intended.
|
|
615
|
+
|
|
616
|
+
### Client identity, not configuration equality
|
|
617
|
+
|
|
618
|
+
Two `axios.create({ baseURL: 'https://api.example.com' })` instances are different clients and will not de-duplicate against each other.
|
|
619
|
+
|
|
620
|
+
### Lifecycle / unmount
|
|
621
|
+
|
|
622
|
+
If multiple components share one in-flight request, unmounting one should not cancel for all unless you coordinate abort signals carefully.
|
|
623
|
+
|
|
624
|
+
### `onError` is not awaited
|
|
625
|
+
|
|
626
|
+
Async work inside `onError` is not tracked by the returned promise.
|
|
627
|
+
|
|
628
|
+
### Default export is shared process-wide
|
|
629
|
+
|
|
630
|
+
The default `requestManager` is a single module-level instance for the whole realm. Use `new RequestManager()` when you need isolation—especially under SSR or multi-tenant Node servers (see [Server-side rendering and multi-request Node](#server-side-rendering-and-multi-request-node)).
|
|
631
|
+
|
|
632
|
+
---
|
|
633
|
+
|
|
634
|
+
## Background and scope
|
|
635
|
+
|
|
636
|
+
This library was written to solve a concrete problem: concurrent duplicate HTTP calls in application UIs. It is not intended as a general-purpose request framework.
|
|
637
|
+
|
|
638
|
+
Accepted trade-offs (key design, first-callback wins, in-flight-only) match that goal. If you need different behaviour, fork or wrap the class—`RequestManager` is small and MIT-licensed.
|
|
639
|
+
|
|
640
|
+
---
|
|
641
|
+
|
|
642
|
+
## Features summary
|
|
643
|
+
|
|
644
|
+
- De-duplicates concurrent requests per **Axios instance + method + URL + `config.params`**
|
|
645
|
+
- Shared promise for all joiners until the request settles
|
|
646
|
+
- Optional `onSuccess` / `onError` hooks (with documented join semantics)
|
|
647
|
+
- Default shared instance **and** constructible isolated managers
|
|
648
|
+
- TypeScript types, ESM + CJS builds
|
|
649
|
+
- Legacy `RequestManager.call` static helper (deprecated)
|
|
650
|
+
|
|
651
|
+
---
|
|
122
652
|
|
|
123
653
|
## Contributing
|
|
124
654
|
|
|
125
|
-
Contributions
|
|
655
|
+
Contributions are welcome. Fork the repository, open a pull request, and keep changes focused. Run tests with:
|
|
656
|
+
|
|
657
|
+
```bash
|
|
658
|
+
npm ci
|
|
659
|
+
npm test
|
|
660
|
+
npm run build
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
---
|
|
126
664
|
|
|
127
665
|
## License
|
|
128
666
|
|