@jaseeey/request-manager 2.2.2 → 2.3.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/README.md CHANGED
@@ -23,6 +23,7 @@ It is intentionally focused: not a full HTTP client, cache layer, or request que
23
23
  - [FAQ](#faq)
24
24
  - [Known limitations](#known-limitations)
25
25
  - [Background and scope](#background-and-scope)
26
+ - [Features summary](#features-summary)
26
27
  - [Contributing](#contributing)
27
28
  - [License](#license)
28
29
 
@@ -65,13 +66,15 @@ You can use both: a query library for cache and lifecycle, and RequestManager un
65
66
  npm install @jaseeey/request-manager axios
66
67
  ```
67
68
 
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:
69
+ **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:
69
70
 
70
71
  ```bash
71
72
  npm install axios
72
73
  ```
73
74
 
74
- Requires **Node.js 18+** for development tooling; the published package is plain ESM/CJS JavaScript for bundlers and Node.
75
+ Requires **Node.js 22.12+** for development tooling (running the test suite); the
76
+ published package remains plain ESM/CJS JavaScript that runs on Node.js 18+ in your
77
+ applications.
75
78
 
76
79
  ---
77
80
 
@@ -147,7 +150,7 @@ Each in-flight request is stored under a key built from:
147
150
 
148
151
  1. **Axios client instance** (identity, not config equality)
149
152
  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)
153
+ 3. **URL string** (exact string match of the `url` argument—or its `baseURL`-resolved form when `resolveBaseURL` is enabled)
151
154
  4. **`config.params`** (deterministic serialisation; missing/`undefined`/empty params are equivalent)
152
155
 
153
156
  ```text
@@ -165,13 +168,14 @@ for (const [hash, entry] of requestManager.activeRequests) {
165
168
  entry.identity.method; // e.g. 'get'
166
169
  entry.identity.url;
167
170
  entry.identity.params; // as supplied (or null)
168
- entry.identity.paramsSerialised;
171
+ entry.identity.paramsSerialized;
172
+ entry.identity.paramsSerialised; // deprecated alias of `paramsSerialized`
169
173
  entry.original; // underlying Axios promise
170
174
  entry.processed; // shared caller promise
171
175
  }
172
176
  ```
173
177
 
174
- See also [`baseURL` is not part of the key](#baseurl-is-not-part-of-the-key).
178
+ By default the raw `url` argument is the key segment; enable [`resolveBaseURL`](#constructor) to key on the `baseURL`-resolved URL instead.
175
179
 
176
180
  ### Lifecycle
177
181
 
@@ -194,6 +198,7 @@ There is **no cache** of completed responses. De-duplication applies only to con
194
198
  | Different `data` bodies | Yes (same key) | Body is **not** in the key (mutations are usually user actions) |
195
199
  | Different headers, timeout, signal, etc. | Yes (same key) | Other config is **not** in the key |
196
200
  | Same path, different full URL strings | No | `'/users?id=1'` and `'/users?id=2'` are different keys |
201
+ | Relative URL and its `baseURL`-resolved absolute form | No (Yes with `resolveBaseURL`) | Opt-in `baseURL`-aware keying |
197
202
  | Different Axios instances | No | Separate clients never share de-duplication |
198
203
  | Different methods | No | `GET` and `POST` to the same URL are independent |
199
204
 
@@ -330,6 +335,9 @@ await RequestManager.call(client, 'GET', 'https://example.com/health');
330
335
  - `onSuccess` may return a `Promise`; it is awaited.
331
336
  - `onError` is not awaited; keep it synchronous or fire-and-forget async work carefully.
332
337
  - Method matching for de-duplication is case-insensitive.
338
+ - `call()` rejects with a `TypeError` when the client is not an Axios-like instance, the
339
+ `method` or `url` is invalid, or `config.params` is not serialisable (e.g. a circular
340
+ reference).
333
341
 
334
342
  ### Instance fields
335
343
 
@@ -345,10 +353,17 @@ Clearing the map mid-flight is not recommended except in tests. If you clear an
345
353
 
346
354
  ```typescript
347
355
  const manager = new RequestManager();
356
+ const resolvedManager = new RequestManager({ resolveBaseURL: true });
348
357
  ```
349
358
 
350
359
  Creates an isolated manager. The generic type parameter on the class is historical; prefer generics on `call()` for response typing.
351
360
 
361
+ | Option | Type | Default | Description |
362
+ |--------|------|---------|-------------|
363
+ | `resolveBaseURL` | `boolean` | `false` | Resolve relative URLs against the client's `baseURL` before keying, so a relative URL and its absolute form share one in-flight request |
364
+
365
+ With `resolveBaseURL` enabled, `'/users/me'` on a client created with `baseURL: 'https://api.example.com'` de-duplicates against `'https://api.example.com/users/me'`. Resolution mirrors Axios's own `buildFullPath` rules: absolute and protocol-relative URLs are left untouched, and joining follows the same trailing-slash rules Axios uses.
366
+
352
367
  ---
353
368
 
354
369
  ## Effective usage patterns
@@ -592,26 +607,7 @@ De-duplication includes `config.params` but not request bodies, headers, timeout
592
607
 
593
608
  ### Exact URL string matching
594
609
 
595
- `/users` and `/users/` are different keys. Relative URLs are not normalised against `baseURL` for keying—the string you pass is the key segment.
596
-
597
- ### `baseURL` is not part of the key
598
-
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.
610
+ `/users` and `/users/` are different keys. Relative URLs are not normalised against `baseURL` for keying by default—the string you pass is the key segment (enable `resolveBaseURL` for `baseURL`-aware keying).
615
611
 
616
612
  ### Client identity, not configuration equality
617
613
 
@@ -645,6 +641,7 @@ Accepted trade-offs (key design, first-callback wins, in-flight-only) match that
645
641
  - Shared promise for all joiners until the request settles
646
642
  - Optional `onSuccess` / `onError` hooks (with documented join semantics)
647
643
  - Default shared instance **and** constructible isolated managers
644
+ - Opt-in **`baseURL`-aware keying** (`resolveBaseURL`) so relative and absolute URLs for the same resource share one in-flight request
648
645
  - TypeScript types, ESM + CJS builds
649
646
  - Legacy `RequestManager.call` static helper (deprecated)
650
647
 
@@ -1,3 +1,3 @@
1
- export type { ActiveRequest, RequestIdentity } from './request-manager/index.js';
1
+ export type { ActiveRequest, RequestIdentity, RequestManagerOptions } from './request-manager/index.js';
2
2
  export { RequestManager } from './request-manager/index.js';
3
3
  export { default } from './request-manager/index.js';
@@ -1,4 +1,4 @@
1
1
  import requestManager, { RequestManager } from './request-manager.js';
2
- export type { ActiveRequest, RequestIdentity } from './request-manager.js';
2
+ export type { ActiveRequest, RequestIdentity, RequestManagerOptions } from './request-manager.js';
3
3
  export { RequestManager };
4
4
  export default requestManager;
@@ -7,10 +7,28 @@ type MaybePromise<T> = T | Promise<T>;
7
7
  export interface RequestIdentity {
8
8
  clientId: number;
9
9
  method: string;
10
+ /** URL string as passed to `call`, or resolved against the client's `baseURL` when `resolveBaseURL` is enabled. */
10
11
  url: string;
11
12
  params: unknown;
13
+ paramsSerialized: string;
14
+ /**
15
+ * Backward-compatible British spelling of `paramsSerialized`.
16
+ *
17
+ * @deprecated Use `paramsSerialized` instead; to be removed in a future major release.
18
+ */
12
19
  paramsSerialised: string;
13
20
  }
21
+ /**
22
+ * Configuration options for a {@link RequestManager} instance.
23
+ */
24
+ export interface RequestManagerOptions {
25
+ /**
26
+ * When enabled, relative URLs are resolved against the client's `baseURL` before keying,
27
+ * so the same logical resource de-duplicates whether callers address it relatively or
28
+ * absolutely. Disabled by default to preserve historical exact-string keying.
29
+ */
30
+ resolveBaseURL?: boolean;
31
+ }
14
32
  /**
15
33
  * Active in-flight request entry stored in {@link RequestManager.activeRequests}.
16
34
  *
@@ -38,6 +56,13 @@ export declare class RequestManager<T = any> {
38
56
  activeRequests: Map<RequestKey, ActiveRequest>;
39
57
  private clientIds;
40
58
  private nextClientId;
59
+ private readonly resolveBaseURL;
60
+ /**
61
+ * Creates an isolated manager.
62
+ *
63
+ * @param {RequestManagerOptions} [options] - Optional instance configuration.
64
+ */
65
+ constructor(options?: RequestManagerOptions);
41
66
  /**
42
67
  * Backward-compatible static API that delegates to the shared singleton instance.
43
68
  *
@@ -48,47 +73,70 @@ export declare class RequestManager<T = any> {
48
73
  * Executes or retrieves an ongoing HTTP request based on the provided URL, method, payload, and configuration.
49
74
  *
50
75
  * Requests are de-duplicated when they target the same client instance, method, URL, and `config.params`.
76
+ * When constructed with `resolveBaseURL`, relative URLs are resolved against the client's `baseURL` for keying.
51
77
  * Request bodies and other config fields (headers, timeout, signal, etc.) are not part of the key.
52
78
  * If `onSuccess` returns a non-`undefined` value, that value becomes the resolved result.
53
79
  *
54
- * @param client Axios client used to execute the request.
55
- * @param method HTTP method.
56
- * @param url Target request URL.
57
- * @param data Optional request payload.
58
- * @param config Optional Axios request configuration.
59
- * @param onSuccess Optional callback invoked on success. Returning a value overrides the resolved response.
60
- * @param onError Optional callback for request failures only. If it throws, the original request error is rethrown.
80
+ * @param {AxiosInstance} client - Axios client used to execute the request.
81
+ * @param {Method} method - HTTP method.
82
+ * @param {string} url - Target request URL.
83
+ * @param {any} [data] - Optional request payload.
84
+ * @param {AxiosRequestConfig|null} [config] - Optional Axios request configuration.
85
+ * @param {((result: AxiosResponse<TResponse>) => MaybePromise<TSuccess | undefined>) | null} [onSuccess] -
86
+ * Optional callback invoked on success. Returning a value overrides the resolved response.
87
+ * @param {((error: unknown) => void) | null} [onError] - Optional callback for request failures only.
88
+ * If it throws, the original request error is rethrown.
89
+ * @returns {Promise<AxiosResponse<TResponse> | TSuccess | void>} - The shared promise for
90
+ * the in-flight request.
91
+ * @throws {TypeError} - If the client is not an Axios-like instance, the method or URL is
92
+ * invalid, or config.params is not serializable.
61
93
  */
62
94
  call<TResponse = T, TSuccess = never>(client: AxiosInstance, method: Method, url: string, data?: any, config?: AxiosRequestConfig | null, onSuccess?: ((result: AxiosResponse<TResponse>) => MaybePromise<TSuccess | undefined>) | null, onError?: ((error: unknown) => void) | null): Promise<AxiosResponse<TResponse> | TSuccess | void>;
63
95
  /**
64
96
  * Builds the stable human-readable identity for an in-flight request.
65
97
  *
66
- * @param client Axios client instance used for the request.
67
- * @param method HTTP method.
68
- * @param url Request URL string as passed to `call`.
69
- * @param params Axios `config.params` value, if any.
70
- * @returns Identity fields used for hashing and inspection.
98
+ * @param {AxiosInstance} client - Axios client instance used for the request.
99
+ * @param {Method} method - HTTP method.
100
+ * @param {string} url - Request URL string as passed to `call`.
101
+ * @param {AxiosRequestConfig['params']} params - Axios `config.params` value, if any.
102
+ * @returns {RequestIdentity} - Identity fields used for hashing and inspection.
71
103
  */
72
104
  private buildRequestIdentity;
105
+ /**
106
+ * Resolves a relative URL against the client's `baseURL` for keying, mirroring Axios's
107
+ * own `buildFullPath` and `combineURLs` semantics.
108
+ *
109
+ * @param {string} url - Request URL string as passed to `call`.
110
+ * @param {AxiosInstance} client - Axios client whose `defaults.baseURL` is the resolution base.
111
+ * @returns {string} - The URL string used as the key segment.
112
+ */
113
+ private resolveUrlAgainstBase;
114
+ /**
115
+ * Determines whether a URL is absolute, matching Axios's `isAbsoluteURL` check.
116
+ *
117
+ * @param {string} url - URL string to test.
118
+ * @returns {boolean} - True when `url` has a scheme or is protocol-relative.
119
+ */
120
+ private isAbsoluteUrl;
73
121
  /**
74
122
  * Hashes a request identity into a fixed-length map key.
75
123
  *
76
- * @param identity Stable request identity.
77
- * @returns Lowercase hex SHA-256 digest of the identity string.
124
+ * @param {RequestIdentity} identity - Stable request identity.
125
+ * @returns {string} - Lowercase hex SHA-256 digest of the identity string.
78
126
  */
79
127
  private hashIdentity;
80
128
  /**
81
- * Serialises query params into a deterministic string for request keys.
129
+ * Serializes query params into a deterministic string for request keys.
82
130
  *
83
- * @param params Axios params value (`undefined`, plain object, array, or `URLSearchParams`).
84
- * @returns A stable serialisation, or an empty string when params are absent.
131
+ * @param {AxiosRequestConfig['params']} params - Axios params value (`undefined`, plain object, array, or `URLSearchParams`).
132
+ * @returns {string} - A stable serialization, or an empty string when params are absent.
85
133
  */
86
134
  private serializeParams;
87
135
  /**
88
- * JSON-like serialisation with sorted object keys so param order does not affect identity.
136
+ * JSON-like serialization with sorted object keys so param order does not affect identity.
89
137
  *
90
- * @param value Value to serialise.
91
- * @returns Deterministic string form of `value`.
138
+ * @param {unknown} value - Value to serialize.
139
+ * @returns {string} - Deterministic string form of `value`.
92
140
  */
93
141
  private stableStringify;
94
142
  }
@@ -17,6 +17,15 @@ class RequestManager {
17
17
  activeRequests = new Map();
18
18
  clientIds = new WeakMap();
19
19
  nextClientId = 1;
20
+ resolveBaseURL;
21
+ /**
22
+ * Creates an isolated manager.
23
+ *
24
+ * @param {RequestManagerOptions} [options] - Optional instance configuration.
25
+ */
26
+ constructor(options = {}) {
27
+ this.resolveBaseURL = options.resolveBaseURL === true;
28
+ }
20
29
  /**
21
30
  * Backward-compatible static API that delegates to the shared singleton instance.
22
31
  *
@@ -29,18 +38,28 @@ class RequestManager {
29
38
  * Executes or retrieves an ongoing HTTP request based on the provided URL, method, payload, and configuration.
30
39
  *
31
40
  * Requests are de-duplicated when they target the same client instance, method, URL, and `config.params`.
41
+ * When constructed with `resolveBaseURL`, relative URLs are resolved against the client's `baseURL` for keying.
32
42
  * Request bodies and other config fields (headers, timeout, signal, etc.) are not part of the key.
33
43
  * If `onSuccess` returns a non-`undefined` value, that value becomes the resolved result.
34
44
  *
35
- * @param client Axios client used to execute the request.
36
- * @param method HTTP method.
37
- * @param url Target request URL.
38
- * @param data Optional request payload.
39
- * @param config Optional Axios request configuration.
40
- * @param onSuccess Optional callback invoked on success. Returning a value overrides the resolved response.
41
- * @param onError Optional callback for request failures only. If it throws, the original request error is rethrown.
45
+ * @param {AxiosInstance} client - Axios client used to execute the request.
46
+ * @param {Method} method - HTTP method.
47
+ * @param {string} url - Target request URL.
48
+ * @param {any} [data] - Optional request payload.
49
+ * @param {AxiosRequestConfig|null} [config] - Optional Axios request configuration.
50
+ * @param {((result: AxiosResponse<TResponse>) => MaybePromise<TSuccess | undefined>) | null} [onSuccess] -
51
+ * Optional callback invoked on success. Returning a value overrides the resolved response.
52
+ * @param {((error: unknown) => void) | null} [onError] - Optional callback for request failures only.
53
+ * If it throws, the original request error is rethrown.
54
+ * @returns {Promise<AxiosResponse<TResponse> | TSuccess | void>} - The shared promise for
55
+ * the in-flight request.
56
+ * @throws {TypeError} - If the client is not an Axios-like instance, the method or URL is
57
+ * invalid, or config.params is not serializable.
42
58
  */
43
59
  call(client, method, url, data = {}, config = {}, onSuccess, onError) {
60
+ if (client == null || typeof client.request !== 'function') {
61
+ return Promise.reject(new TypeError('RequestManager.call requires an Axios client instance'));
62
+ }
44
63
  if (typeof method !== 'string' || method.length === 0) {
45
64
  return Promise.reject(new TypeError('RequestManager.call requires a non-empty HTTP method string'));
46
65
  }
@@ -49,7 +68,13 @@ class RequestManager {
49
68
  }
50
69
  data ??= {};
51
70
  config ??= {};
52
- const identity = this.buildRequestIdentity(client, method, url, config.params);
71
+ let identity;
72
+ try {
73
+ identity = this.buildRequestIdentity(client, method, url, config.params);
74
+ }
75
+ catch {
76
+ return Promise.reject(new TypeError('RequestManager.call requires serializable config.params'));
77
+ }
53
78
  const hash = this.hashIdentity(identity);
54
79
  const existing = this.activeRequests.get(hash);
55
80
  if (existing) {
@@ -95,11 +120,11 @@ class RequestManager {
95
120
  /**
96
121
  * Builds the stable human-readable identity for an in-flight request.
97
122
  *
98
- * @param client Axios client instance used for the request.
99
- * @param method HTTP method.
100
- * @param url Request URL string as passed to `call`.
101
- * @param params Axios `config.params` value, if any.
102
- * @returns Identity fields used for hashing and inspection.
123
+ * @param {AxiosInstance} client - Axios client instance used for the request.
124
+ * @param {Method} method - HTTP method.
125
+ * @param {string} url - Request URL string as passed to `call`.
126
+ * @param {AxiosRequestConfig['params']} params - Axios `config.params` value, if any.
127
+ * @returns {RequestIdentity} - Identity fields used for hashing and inspection.
103
128
  */
104
129
  buildRequestIdentity(client, method, url, params) {
105
130
  let clientId = this.clientIds.get(client);
@@ -107,31 +132,57 @@ class RequestManager {
107
132
  clientId = this.nextClientId++;
108
133
  this.clientIds.set(client, clientId);
109
134
  }
110
- const normalisedMethod = method.toLowerCase();
111
- const paramsSerialised = this.serializeParams(params);
135
+ const normalizedMethod = method.toLowerCase();
136
+ const urlKey = this.resolveBaseURL ? this.resolveUrlAgainstBase(url, client) : url;
137
+ const paramsSerialized = this.serializeParams(params);
112
138
  return {
113
139
  clientId,
114
- method: normalisedMethod,
115
- url,
140
+ method: normalizedMethod,
141
+ url: urlKey,
116
142
  params: params ?? null,
117
- paramsSerialised
143
+ paramsSerialized,
144
+ paramsSerialised: paramsSerialized
118
145
  };
119
146
  }
147
+ /**
148
+ * Resolves a relative URL against the client's `baseURL` for keying, mirroring Axios's
149
+ * own `buildFullPath` and `combineURLs` semantics.
150
+ *
151
+ * @param {string} url - Request URL string as passed to `call`.
152
+ * @param {AxiosInstance} client - Axios client whose `defaults.baseURL` is the resolution base.
153
+ * @returns {string} - The URL string used as the key segment.
154
+ */
155
+ resolveUrlAgainstBase(url, client) {
156
+ const baseURL = client.defaults?.baseURL;
157
+ if (typeof baseURL !== 'string' || baseURL.length === 0 || this.isAbsoluteUrl(url)) {
158
+ return url;
159
+ }
160
+ return baseURL.replace(/\/+$/, '') + '/' + url.replace(/^\/+/, '');
161
+ }
162
+ /**
163
+ * Determines whether a URL is absolute, matching Axios's `isAbsoluteURL` check.
164
+ *
165
+ * @param {string} url - URL string to test.
166
+ * @returns {boolean} - True when `url` has a scheme or is protocol-relative.
167
+ */
168
+ isAbsoluteUrl(url) {
169
+ return /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url);
170
+ }
120
171
  /**
121
172
  * Hashes a request identity into a fixed-length map key.
122
173
  *
123
- * @param identity Stable request identity.
124
- * @returns Lowercase hex SHA-256 digest of the identity string.
174
+ * @param {RequestIdentity} identity - Stable request identity.
175
+ * @returns {string} - Lowercase hex SHA-256 digest of the identity string.
125
176
  */
126
177
  hashIdentity(identity) {
127
- const material = `${identity.clientId}:${identity.method}:${identity.url}:${identity.paramsSerialised}`;
178
+ const material = `${identity.clientId}:${identity.method}:${identity.url}:${identity.paramsSerialized}`;
128
179
  return (0, sha256_js_1.sha256Hex)(material);
129
180
  }
130
181
  /**
131
- * Serialises query params into a deterministic string for request keys.
182
+ * Serializes query params into a deterministic string for request keys.
132
183
  *
133
- * @param params Axios params value (`undefined`, plain object, array, or `URLSearchParams`).
134
- * @returns A stable serialisation, or an empty string when params are absent.
184
+ * @param {AxiosRequestConfig['params']} params - Axios params value (`undefined`, plain object, array, or `URLSearchParams`).
185
+ * @returns {string} - A stable serialization, or an empty string when params are absent.
135
186
  */
136
187
  serializeParams(params) {
137
188
  if (params == null) {
@@ -153,10 +204,10 @@ class RequestManager {
153
204
  return this.stableStringify(params);
154
205
  }
155
206
  /**
156
- * JSON-like serialisation with sorted object keys so param order does not affect identity.
207
+ * JSON-like serialization with sorted object keys so param order does not affect identity.
157
208
  *
158
- * @param value Value to serialise.
159
- * @returns Deterministic string form of `value`.
209
+ * @param {unknown} value - Value to serialize.
210
+ * @returns {string} - Deterministic string form of `value`.
160
211
  */
161
212
  stableStringify(value) {
162
213
  if (value === null || typeof value !== 'object') {
@@ -1,3 +1,3 @@
1
- export type { ActiveRequest, RequestIdentity } from './request-manager/index.js';
1
+ export type { ActiveRequest, RequestIdentity, RequestManagerOptions } from './request-manager/index.js';
2
2
  export { RequestManager } from './request-manager/index.js';
3
3
  export { default } from './request-manager/index.js';
@@ -1,4 +1,4 @@
1
1
  import requestManager, { RequestManager } from './request-manager.js';
2
- export type { ActiveRequest, RequestIdentity } from './request-manager.js';
2
+ export type { ActiveRequest, RequestIdentity, RequestManagerOptions } from './request-manager.js';
3
3
  export { RequestManager };
4
4
  export default requestManager;
@@ -7,10 +7,28 @@ type MaybePromise<T> = T | Promise<T>;
7
7
  export interface RequestIdentity {
8
8
  clientId: number;
9
9
  method: string;
10
+ /** URL string as passed to `call`, or resolved against the client's `baseURL` when `resolveBaseURL` is enabled. */
10
11
  url: string;
11
12
  params: unknown;
13
+ paramsSerialized: string;
14
+ /**
15
+ * Backward-compatible British spelling of `paramsSerialized`.
16
+ *
17
+ * @deprecated Use `paramsSerialized` instead; to be removed in a future major release.
18
+ */
12
19
  paramsSerialised: string;
13
20
  }
21
+ /**
22
+ * Configuration options for a {@link RequestManager} instance.
23
+ */
24
+ export interface RequestManagerOptions {
25
+ /**
26
+ * When enabled, relative URLs are resolved against the client's `baseURL` before keying,
27
+ * so the same logical resource de-duplicates whether callers address it relatively or
28
+ * absolutely. Disabled by default to preserve historical exact-string keying.
29
+ */
30
+ resolveBaseURL?: boolean;
31
+ }
14
32
  /**
15
33
  * Active in-flight request entry stored in {@link RequestManager.activeRequests}.
16
34
  *
@@ -38,6 +56,13 @@ export declare class RequestManager<T = any> {
38
56
  activeRequests: Map<RequestKey, ActiveRequest>;
39
57
  private clientIds;
40
58
  private nextClientId;
59
+ private readonly resolveBaseURL;
60
+ /**
61
+ * Creates an isolated manager.
62
+ *
63
+ * @param {RequestManagerOptions} [options] - Optional instance configuration.
64
+ */
65
+ constructor(options?: RequestManagerOptions);
41
66
  /**
42
67
  * Backward-compatible static API that delegates to the shared singleton instance.
43
68
  *
@@ -48,47 +73,70 @@ export declare class RequestManager<T = any> {
48
73
  * Executes or retrieves an ongoing HTTP request based on the provided URL, method, payload, and configuration.
49
74
  *
50
75
  * Requests are de-duplicated when they target the same client instance, method, URL, and `config.params`.
76
+ * When constructed with `resolveBaseURL`, relative URLs are resolved against the client's `baseURL` for keying.
51
77
  * Request bodies and other config fields (headers, timeout, signal, etc.) are not part of the key.
52
78
  * If `onSuccess` returns a non-`undefined` value, that value becomes the resolved result.
53
79
  *
54
- * @param client Axios client used to execute the request.
55
- * @param method HTTP method.
56
- * @param url Target request URL.
57
- * @param data Optional request payload.
58
- * @param config Optional Axios request configuration.
59
- * @param onSuccess Optional callback invoked on success. Returning a value overrides the resolved response.
60
- * @param onError Optional callback for request failures only. If it throws, the original request error is rethrown.
80
+ * @param {AxiosInstance} client - Axios client used to execute the request.
81
+ * @param {Method} method - HTTP method.
82
+ * @param {string} url - Target request URL.
83
+ * @param {any} [data] - Optional request payload.
84
+ * @param {AxiosRequestConfig|null} [config] - Optional Axios request configuration.
85
+ * @param {((result: AxiosResponse<TResponse>) => MaybePromise<TSuccess | undefined>) | null} [onSuccess] -
86
+ * Optional callback invoked on success. Returning a value overrides the resolved response.
87
+ * @param {((error: unknown) => void) | null} [onError] - Optional callback for request failures only.
88
+ * If it throws, the original request error is rethrown.
89
+ * @returns {Promise<AxiosResponse<TResponse> | TSuccess | void>} - The shared promise for
90
+ * the in-flight request.
91
+ * @throws {TypeError} - If the client is not an Axios-like instance, the method or URL is
92
+ * invalid, or config.params is not serializable.
61
93
  */
62
94
  call<TResponse = T, TSuccess = never>(client: AxiosInstance, method: Method, url: string, data?: any, config?: AxiosRequestConfig | null, onSuccess?: ((result: AxiosResponse<TResponse>) => MaybePromise<TSuccess | undefined>) | null, onError?: ((error: unknown) => void) | null): Promise<AxiosResponse<TResponse> | TSuccess | void>;
63
95
  /**
64
96
  * Builds the stable human-readable identity for an in-flight request.
65
97
  *
66
- * @param client Axios client instance used for the request.
67
- * @param method HTTP method.
68
- * @param url Request URL string as passed to `call`.
69
- * @param params Axios `config.params` value, if any.
70
- * @returns Identity fields used for hashing and inspection.
98
+ * @param {AxiosInstance} client - Axios client instance used for the request.
99
+ * @param {Method} method - HTTP method.
100
+ * @param {string} url - Request URL string as passed to `call`.
101
+ * @param {AxiosRequestConfig['params']} params - Axios `config.params` value, if any.
102
+ * @returns {RequestIdentity} - Identity fields used for hashing and inspection.
71
103
  */
72
104
  private buildRequestIdentity;
105
+ /**
106
+ * Resolves a relative URL against the client's `baseURL` for keying, mirroring Axios's
107
+ * own `buildFullPath` and `combineURLs` semantics.
108
+ *
109
+ * @param {string} url - Request URL string as passed to `call`.
110
+ * @param {AxiosInstance} client - Axios client whose `defaults.baseURL` is the resolution base.
111
+ * @returns {string} - The URL string used as the key segment.
112
+ */
113
+ private resolveUrlAgainstBase;
114
+ /**
115
+ * Determines whether a URL is absolute, matching Axios's `isAbsoluteURL` check.
116
+ *
117
+ * @param {string} url - URL string to test.
118
+ * @returns {boolean} - True when `url` has a scheme or is protocol-relative.
119
+ */
120
+ private isAbsoluteUrl;
73
121
  /**
74
122
  * Hashes a request identity into a fixed-length map key.
75
123
  *
76
- * @param identity Stable request identity.
77
- * @returns Lowercase hex SHA-256 digest of the identity string.
124
+ * @param {RequestIdentity} identity - Stable request identity.
125
+ * @returns {string} - Lowercase hex SHA-256 digest of the identity string.
78
126
  */
79
127
  private hashIdentity;
80
128
  /**
81
- * Serialises query params into a deterministic string for request keys.
129
+ * Serializes query params into a deterministic string for request keys.
82
130
  *
83
- * @param params Axios params value (`undefined`, plain object, array, or `URLSearchParams`).
84
- * @returns A stable serialisation, or an empty string when params are absent.
131
+ * @param {AxiosRequestConfig['params']} params - Axios params value (`undefined`, plain object, array, or `URLSearchParams`).
132
+ * @returns {string} - A stable serialization, or an empty string when params are absent.
85
133
  */
86
134
  private serializeParams;
87
135
  /**
88
- * JSON-like serialisation with sorted object keys so param order does not affect identity.
136
+ * JSON-like serialization with sorted object keys so param order does not affect identity.
89
137
  *
90
- * @param value Value to serialise.
91
- * @returns Deterministic string form of `value`.
138
+ * @param {unknown} value - Value to serialize.
139
+ * @returns {string} - Deterministic string form of `value`.
92
140
  */
93
141
  private stableStringify;
94
142
  }
@@ -14,6 +14,15 @@ export class RequestManager {
14
14
  activeRequests = new Map();
15
15
  clientIds = new WeakMap();
16
16
  nextClientId = 1;
17
+ resolveBaseURL;
18
+ /**
19
+ * Creates an isolated manager.
20
+ *
21
+ * @param {RequestManagerOptions} [options] - Optional instance configuration.
22
+ */
23
+ constructor(options = {}) {
24
+ this.resolveBaseURL = options.resolveBaseURL === true;
25
+ }
17
26
  /**
18
27
  * Backward-compatible static API that delegates to the shared singleton instance.
19
28
  *
@@ -26,18 +35,28 @@ export class RequestManager {
26
35
  * Executes or retrieves an ongoing HTTP request based on the provided URL, method, payload, and configuration.
27
36
  *
28
37
  * Requests are de-duplicated when they target the same client instance, method, URL, and `config.params`.
38
+ * When constructed with `resolveBaseURL`, relative URLs are resolved against the client's `baseURL` for keying.
29
39
  * Request bodies and other config fields (headers, timeout, signal, etc.) are not part of the key.
30
40
  * If `onSuccess` returns a non-`undefined` value, that value becomes the resolved result.
31
41
  *
32
- * @param client Axios client used to execute the request.
33
- * @param method HTTP method.
34
- * @param url Target request URL.
35
- * @param data Optional request payload.
36
- * @param config Optional Axios request configuration.
37
- * @param onSuccess Optional callback invoked on success. Returning a value overrides the resolved response.
38
- * @param onError Optional callback for request failures only. If it throws, the original request error is rethrown.
42
+ * @param {AxiosInstance} client - Axios client used to execute the request.
43
+ * @param {Method} method - HTTP method.
44
+ * @param {string} url - Target request URL.
45
+ * @param {any} [data] - Optional request payload.
46
+ * @param {AxiosRequestConfig|null} [config] - Optional Axios request configuration.
47
+ * @param {((result: AxiosResponse<TResponse>) => MaybePromise<TSuccess | undefined>) | null} [onSuccess] -
48
+ * Optional callback invoked on success. Returning a value overrides the resolved response.
49
+ * @param {((error: unknown) => void) | null} [onError] - Optional callback for request failures only.
50
+ * If it throws, the original request error is rethrown.
51
+ * @returns {Promise<AxiosResponse<TResponse> | TSuccess | void>} - The shared promise for
52
+ * the in-flight request.
53
+ * @throws {TypeError} - If the client is not an Axios-like instance, the method or URL is
54
+ * invalid, or config.params is not serializable.
39
55
  */
40
56
  call(client, method, url, data = {}, config = {}, onSuccess, onError) {
57
+ if (client == null || typeof client.request !== 'function') {
58
+ return Promise.reject(new TypeError('RequestManager.call requires an Axios client instance'));
59
+ }
41
60
  if (typeof method !== 'string' || method.length === 0) {
42
61
  return Promise.reject(new TypeError('RequestManager.call requires a non-empty HTTP method string'));
43
62
  }
@@ -46,7 +65,13 @@ export class RequestManager {
46
65
  }
47
66
  data ??= {};
48
67
  config ??= {};
49
- const identity = this.buildRequestIdentity(client, method, url, config.params);
68
+ let identity;
69
+ try {
70
+ identity = this.buildRequestIdentity(client, method, url, config.params);
71
+ }
72
+ catch {
73
+ return Promise.reject(new TypeError('RequestManager.call requires serializable config.params'));
74
+ }
50
75
  const hash = this.hashIdentity(identity);
51
76
  const existing = this.activeRequests.get(hash);
52
77
  if (existing) {
@@ -92,11 +117,11 @@ export class RequestManager {
92
117
  /**
93
118
  * Builds the stable human-readable identity for an in-flight request.
94
119
  *
95
- * @param client Axios client instance used for the request.
96
- * @param method HTTP method.
97
- * @param url Request URL string as passed to `call`.
98
- * @param params Axios `config.params` value, if any.
99
- * @returns Identity fields used for hashing and inspection.
120
+ * @param {AxiosInstance} client - Axios client instance used for the request.
121
+ * @param {Method} method - HTTP method.
122
+ * @param {string} url - Request URL string as passed to `call`.
123
+ * @param {AxiosRequestConfig['params']} params - Axios `config.params` value, if any.
124
+ * @returns {RequestIdentity} - Identity fields used for hashing and inspection.
100
125
  */
101
126
  buildRequestIdentity(client, method, url, params) {
102
127
  let clientId = this.clientIds.get(client);
@@ -104,31 +129,57 @@ export class RequestManager {
104
129
  clientId = this.nextClientId++;
105
130
  this.clientIds.set(client, clientId);
106
131
  }
107
- const normalisedMethod = method.toLowerCase();
108
- const paramsSerialised = this.serializeParams(params);
132
+ const normalizedMethod = method.toLowerCase();
133
+ const urlKey = this.resolveBaseURL ? this.resolveUrlAgainstBase(url, client) : url;
134
+ const paramsSerialized = this.serializeParams(params);
109
135
  return {
110
136
  clientId,
111
- method: normalisedMethod,
112
- url,
137
+ method: normalizedMethod,
138
+ url: urlKey,
113
139
  params: params ?? null,
114
- paramsSerialised
140
+ paramsSerialized,
141
+ paramsSerialised: paramsSerialized
115
142
  };
116
143
  }
144
+ /**
145
+ * Resolves a relative URL against the client's `baseURL` for keying, mirroring Axios's
146
+ * own `buildFullPath` and `combineURLs` semantics.
147
+ *
148
+ * @param {string} url - Request URL string as passed to `call`.
149
+ * @param {AxiosInstance} client - Axios client whose `defaults.baseURL` is the resolution base.
150
+ * @returns {string} - The URL string used as the key segment.
151
+ */
152
+ resolveUrlAgainstBase(url, client) {
153
+ const baseURL = client.defaults?.baseURL;
154
+ if (typeof baseURL !== 'string' || baseURL.length === 0 || this.isAbsoluteUrl(url)) {
155
+ return url;
156
+ }
157
+ return baseURL.replace(/\/+$/, '') + '/' + url.replace(/^\/+/, '');
158
+ }
159
+ /**
160
+ * Determines whether a URL is absolute, matching Axios's `isAbsoluteURL` check.
161
+ *
162
+ * @param {string} url - URL string to test.
163
+ * @returns {boolean} - True when `url` has a scheme or is protocol-relative.
164
+ */
165
+ isAbsoluteUrl(url) {
166
+ return /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url);
167
+ }
117
168
  /**
118
169
  * Hashes a request identity into a fixed-length map key.
119
170
  *
120
- * @param identity Stable request identity.
121
- * @returns Lowercase hex SHA-256 digest of the identity string.
171
+ * @param {RequestIdentity} identity - Stable request identity.
172
+ * @returns {string} - Lowercase hex SHA-256 digest of the identity string.
122
173
  */
123
174
  hashIdentity(identity) {
124
- const material = `${identity.clientId}:${identity.method}:${identity.url}:${identity.paramsSerialised}`;
175
+ const material = `${identity.clientId}:${identity.method}:${identity.url}:${identity.paramsSerialized}`;
125
176
  return sha256Hex(material);
126
177
  }
127
178
  /**
128
- * Serialises query params into a deterministic string for request keys.
179
+ * Serializes query params into a deterministic string for request keys.
129
180
  *
130
- * @param params Axios params value (`undefined`, plain object, array, or `URLSearchParams`).
131
- * @returns A stable serialisation, or an empty string when params are absent.
181
+ * @param {AxiosRequestConfig['params']} params - Axios params value (`undefined`, plain object, array, or `URLSearchParams`).
182
+ * @returns {string} - A stable serialization, or an empty string when params are absent.
132
183
  */
133
184
  serializeParams(params) {
134
185
  if (params == null) {
@@ -150,10 +201,10 @@ export class RequestManager {
150
201
  return this.stableStringify(params);
151
202
  }
152
203
  /**
153
- * JSON-like serialisation with sorted object keys so param order does not affect identity.
204
+ * JSON-like serialization with sorted object keys so param order does not affect identity.
154
205
  *
155
- * @param value Value to serialise.
156
- * @returns Deterministic string form of `value`.
206
+ * @param {unknown} value - Value to serialize.
207
+ * @returns {string} - Deterministic string form of `value`.
157
208
  */
158
209
  stableStringify(value) {
159
210
  if (value === null || typeof value !== 'object') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaseeey/request-manager",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "De-duplicate concurrent Axios HTTP requests so independent callers share one in-flight promise and response",
5
5
  "keywords": [
6
6
  "axios",
@@ -53,12 +53,12 @@
53
53
  "test": "vitest run --coverage"
54
54
  },
55
55
  "peerDependencies": {
56
- "axios": ">=1"
56
+ "axios": "^1"
57
57
  },
58
58
  "devDependencies": {
59
- "@vitest/coverage-v8": "^4.1.11",
59
+ "@vitest/coverage-v8": "^5.0.3",
60
60
  "axios": "^1.20.0",
61
- "typescript": "^5.9.3",
62
- "vitest": "^4.1.11"
61
+ "typescript": "^7.0.2",
62
+ "vitest": "^5.0.3"
63
63
  }
64
64
  }