@enegalan/request-manager 1.0.3 → 1.0.10

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.
@@ -1,94 +1,51 @@
1
1
  /**
2
2
  * RequestManager - A library for managing and regulating HTTP requests efficiently.
3
3
  * @license MIT
4
+ * @author Eneko Galan <enekogalanelorza@gmail.com>
4
5
  * This library allows you to manage HTTP requests from any library (ajax, Ext.Ajax, axios, fetch, etc.)
5
6
  * by accepting Promises as parameters. When a request is repeated with the same identifier,
6
7
  * the previous request is automatically cancelled and the new one is executed, giving priority to the most recent requests.
7
-
8
- * @param {Object} managerOptions - The options for the manager
9
- * @param {boolean} managerOptions.verbose - If true, cancellation errors will include messages
10
- * @returns {RequestManager} A new RequestManager instance
11
- */
8
+ */
12
9
  class RequestManager {
13
- constructor(managerOptions = {}) {
10
+ constructor(options = {}) {
14
11
  /**
15
12
  * Map to store active requests by their unique identifier.
16
- * Each entry contains:
17
- * - promise: The original promise
18
- * - abortController: AbortController instance (if available)
19
- * - cancelToken: Cancel token (for axios compatibility)
20
- * - wrapperPromise: The promise that wraps the request
21
- * - resolveWrapper: The function to resolve the wrapper promise
22
- * - rejectWrapper: The function to reject the wrapper promise
23
- * - isCancelled: Whether the request has been cancelled
24
- * - verbose: Whether to include verbose messages in the cancellation error
13
+ * @type {Map<string, import('./index.d.ts').ActiveRequest>}
25
14
  */
26
15
  this.activeRequests = new Map();
27
16
  /**
28
- * Verbose mode: if true, cancellation errors will include messages
29
- * @type {boolean}
30
- */
31
- this.verbose = managerOptions.verbose || false;
32
- /**
33
- * Manager options: the options that were passed to the constructor
34
- * @type {Object}
17
+ * @type {import('./index.d.ts').Options}
35
18
  */
36
- this.managerOptions = managerOptions;
37
- /**
38
- * Options for the current request: will be flushed after the request is completed
39
- * @type {Object}
40
- */
41
- this.options = {};
19
+ this.options = options;
42
20
  /**
43
- * AbortController instance for the current request: will be flushed after the request is completed
44
- * @type {AbortController}
21
+ * One-shot AbortController for getSignal()/getAbortController() handoff.
22
+ * Consumed by the next request that does not pass options.abortController.
23
+ * @type {AbortController|null}
45
24
  */
46
25
  this.abortController = null;
47
26
  }
48
27
 
49
- /**
50
- * Flushes the options for the current request
51
- * @private
52
- */
53
- #_flushRequestOptions() {
54
- this.options = {};
55
- }
56
-
57
28
  /**
58
29
  * Gets the manager options
59
- * @returns {Object} Manager options
30
+ * @returns {import('./index.d.ts').Options} Manager options
60
31
  */
61
32
  getOptions() {
62
- return this.managerOptions;
33
+ return this.options;
63
34
  }
64
35
 
65
36
  /**
66
37
  * Sets the manager options
67
- * @param {Object} options - The options to set
38
+ * @param {import('./index.d.ts').Options} options - The options to set
68
39
  */
69
40
  setOptions(options) {
70
- this.managerOptions = options;
71
- if (options.verbose !== undefined) {
72
- this.verbose = options.verbose;
73
- }
74
- }
75
-
76
- /**
77
- * Sets the options for the current request
78
- * @param {Object} options - The options to set
79
- * @private
80
- */
81
- #_setRequestOptions(options) {
82
41
  this.options = options;
83
42
  }
84
43
 
85
44
  /**
86
- * Creates an AbortController and returns its signal.
87
- * The AbortController is stored internally and will be used by the next request() call.
88
- * This allows users to get the signal before creating the request.
89
- *
45
+ * Creates a new AbortController and returns its signal for the next request()
46
+ * (one getSignal one request). Do not use for parallel requests; use fetch(),
47
+ * axios(), or request(url, ({ options }) => ...) instead they create their own signal.
90
48
  * @returns {AbortSignal} The signal from a new AbortController
91
- *
92
49
  * @example
93
50
  * const signal = requestManager.getSignal();
94
51
  * requestManager.request('/api/users', fetch('/api/users', { signal }));
@@ -98,197 +55,52 @@ class RequestManager {
98
55
  }
99
56
 
100
57
  /**
101
- * Gets the current AbortController instance
102
- * Creates a new AbortController if none exists or if the current one is aborted
103
- * @returns {AbortController} The current AbortController instance
58
+ * Creates a new AbortController for the next request handoff.
59
+ * Always returns a fresh controller (never reuses one from another in-flight request).
60
+ * @returns {AbortController} A new AbortController instance
104
61
  */
105
62
  getAbortController() {
106
- // Create a new AbortController if none exists or if the current one is aborted
107
- if (!this.abortController || this.abortController.signal.aborted) {
108
- this.abortController = new AbortController();
109
- }
63
+ this.abortController = new AbortController();
110
64
  return this.abortController;
111
65
  }
112
66
 
113
67
  /**
114
- * Clears the current AbortController
115
- * @private
116
- */
117
- #_clearAbortController() {
118
- this.abortController = null;
119
- }
120
-
121
- /**
122
- * Generates a request identifier based on the requestKey or URL
123
- * @param {string} url - The URL of the request
124
- * @param {string|number|Function} requestKey - Optional key to generate a deterministic ID.
125
- * If provided, requests with the same key will share the same ID.
126
- * If null or undefined, the cleaned URL will be used as the key.
127
- * @param {boolean} noCancel - If true, generates a unique ID to prevent cancellation
128
- * @returns {string} A unique request identifier
129
- * @private
68
+ * Checks if a request with the given identifier is currently active.
69
+ * @param {string} requestId - The unique identifier to check
70
+ * @returns {boolean} True if the request is active, false otherwise
130
71
  */
131
- #_generateRequestId(url, requestKey = null, noCancel = false) {
132
- if (noCancel) { // Generate a unique ID to prevent cancellation
133
- return `request_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
134
- }
135
- if (requestKey !== null && requestKey !== undefined) {
136
- if (typeof requestKey === 'function') {
137
- try {
138
- requestKey = requestKey();
139
- } catch (error) {
140
- requestKey = null;
141
- }
142
- }
143
- if (requestKey !== null && requestKey !== undefined) return `request_${String(requestKey)}`;
144
- }
145
- // Use cleaned URL as key when requestKey is null/undefined
146
- let cleanedUrl = url || '';
147
- let hasProtocol = cleanedUrl.includes('://');
148
- if (hasProtocol) cleanedUrl = cleanedUrl.split('://')[1];
149
- let hasParams = cleanedUrl.includes('?');
150
- if (hasParams) cleanedUrl = cleanedUrl.split('?')[0];
151
- let hasHash = cleanedUrl.includes('#');
152
- if (hasHash) cleanedUrl = cleanedUrl.split('#')[0];
153
- return `request_${cleanedUrl}`;
72
+ isActive(requestId) {
73
+ return this.activeRequests.has(requestId);
154
74
  }
155
75
 
156
76
  /**
157
- * Prepares fetch options by merging options and removing custom properties
158
- * @param {Object} options - Configuration options
159
- * @param {AbortSignal} signal - Abort signal to add to fetch options
160
- * @param {Object} additionalOptions - Additional options to merge
161
- * @returns {Object} Prepared fetch options
162
- * @private
77
+ * Gets the number of active requests.
78
+ * @returns {number} The number of currently active requests
163
79
  */
164
- #_prepareFetchOptions(options, signal, additionalOptions = {}) {
165
- const fetchOptions = Object.assign({}, additionalOptions);
166
- const customOptions = ['abortController', 'cancelToken', 'requestKey', 'noCancel'];
167
- Object.keys(options).forEach(key => {
168
- if (customOptions.includes(key)) return;
169
- fetchOptions[key] = options[key];
170
- });
171
- fetchOptions.signal = signal;
172
- return fetchOptions;
80
+ getActiveCount() {
81
+ return this.activeRequests.size;
173
82
  }
174
83
 
175
84
  /**
176
- * Internal method that handles the core request logic.
177
- *
178
- * @param {string} requestId - Unique identifier for the request
179
- * @param {Promise|Function|string} requestPromise - The request promise, function, or URL string
180
- * @param {Object} options - Configuration options
181
- * @param {AbortController} options.abortController - AbortController instance
182
- * @param {Function} options.cancelToken - Cancel token or cancel function
183
- * @param {boolean} options.noCancel - If true, this request will not cancel previous requests with the same ID
184
- * @returns {Promise} A Promise that resolves/rejects based on the most recent request
185
- * @private
85
+ * Clears all active requests without cancelling them.
86
+ * Use with caution - this will not cancel the underlying HTTP requests.
186
87
  */
187
- #_request(requestId, requestPromise, options = {}) {
188
- this.#_setRequestOptions(options);
189
- if (!options.abortController) this.#_clearAbortController(); // Clear any existing abortController to ensure each request gets a fresh one
190
- this.#_flushRequestOptions();
191
- const abortController = options.abortController || this.getAbortController();
192
-
193
- // Handle different types of requestPromise inputs
194
- // Priority: Function (Custom logic) > String (URL) > Promise (axios, fetch, etc.)
195
- if (typeof requestPromise === 'function') {
196
- // Function: custom logic for any library (axios, ajax, etc.)
197
- const fetchOptions = this.#_prepareFetchOptions(options, abortController.signal);
198
- requestPromise = requestPromise({ options: fetchOptions });
199
- } else if (typeof requestPromise === 'string') {
200
- // String (URL): make fetch internally
201
- const fetchOptions = this.#_prepareFetchOptions(options, abortController.signal);
202
- requestPromise = fetch(requestPromise, fetchOptions);
203
- }
204
-
205
- // Cancel previous request with the same ID if it exists
206
- if (!options.noCancel) this.cancel(requestId);
207
-
208
- // Create a wrapper promise that will be resolved/rejected based on the request
209
- let resolveWrapper, rejectWrapper;
210
- const wrapperPromise = new Promise((resolve, reject) => {
211
- resolveWrapper = resolve;
212
- rejectWrapper = reject;
213
- });
214
-
215
- // Store the request information
216
- const requestInfo = {
217
- promise: requestPromise,
218
- abortController: abortController,
219
- cancelToken: options.cancelToken || null,
220
- wrapperPromise: wrapperPromise,
221
- resolveWrapper: resolveWrapper,
222
- rejectWrapper: rejectWrapper,
223
- isCancelled: false,
224
- verbose: this.verbose
225
- };
226
-
227
- this.activeRequests.set(requestId, requestInfo);
228
-
229
- // Handle request promise completion
230
- if (requestPromise && typeof requestPromise.then === 'function') {
231
- try {
232
- let req = requestPromise.then((result) => {
233
- if (this.activeRequests.get(requestId) === requestInfo && !requestInfo.isCancelled) {
234
- this.activeRequests.delete(requestId);
235
- resolveWrapper(result);
236
- }
237
- });
238
- if (req.catch) req.catch((error) => {
239
- onError(this, error);
240
- });
241
- } catch (error) {
242
- onError(this, error);
243
- }
244
- function onError(scope, error) {
245
- // Check if this requestInfo is still the active one, or if it was cancelled
246
- const currentRequestInfo = scope.activeRequests.get(requestId);
247
- if (currentRequestInfo !== requestInfo) return;
248
- // Only delete if this is still the active request
249
- scope.activeRequests.delete(requestId);
250
- if (!requestInfo.isCancelled) rejectWrapper(error);
251
- else if (requestInfo.isCancelled && requestInfo.verbose) rejectWrapper(new Error('Request was cancelled'));
252
- }
253
- } else {
254
- // If requestPromise is not a promise, we can't track its completion automatically
255
- // This can happen with libraries like ExtJS that return request objects instead of promises
256
- // In this case, the user should handle the request object themselves
257
- // We'll resolve the wrapper promise immediately to prevent it from hanging
258
- // The user can still use the request object returned by the library
259
- setTimeout(() => {
260
- if (this.activeRequests.get(requestId) === requestInfo && !requestInfo.isCancelled) {
261
- this.activeRequests.delete(requestId);
262
- resolveWrapper(requestPromise); // Resolve with the request object so the user can use it
263
- }
264
- }, 0);
265
- }
266
- return wrapperPromise;
88
+ clear() {
89
+ this.activeRequests.clear();
267
90
  }
268
91
 
269
92
  /**
270
93
  * Executes an HTTP request, cancelling any previous request with the same identifier.
271
- *
272
94
  * @param {string} url - The URL to request
273
- * @param {Promise|Function} requestPromise - The request promise or function that returns a promise
274
- * @param {Object} options - Optional configuration
275
- * @param {string|number|Function} options.requestKey - Key to identify duplicate requests.
276
- * If provided, requests with the same key will cancel previous ones.
277
- * Can be a string, number, or function that returns a key.
278
- * @param {AbortController} options.abortController - AbortController instance (created automatically if not provided)
279
- * @param {Function} options.cancelToken - Cancel token or cancel function for other libraries
280
- * @param {boolean} options.noCancel - If true, this request will not cancel previous requests with the same ID, allowing concurrent requests
281
- * Any other properties are passed as fetch options (method, headers, body, etc.)
95
+ * @param {Promise|import('./index.d.ts').RequestFunction|string} requestPromise - The request promise or function that returns a promise
96
+ * @param {import('./index.d.ts').RequestOptions} options - Optional configuration
282
97
  * @returns {Promise} A Promise that resolves/rejects based on the most recent request
283
- *
284
98
  * @example
285
99
  * // Request with Promise
286
100
  * requestManager.request('/api/users', axios.get('/api/users', { cancelToken: axios.CancelToken.source().token }));
287
- *
288
101
  * @example
289
102
  * // Request with Function
290
103
  * requestManager.request('/api/users', ({ options }) => fetch('/api/users', { signal: options.signal, ...options }));
291
- *
292
104
  * @example
293
105
  * // Request with Promise and custom cancellation grouping with requestKey
294
106
  * const options = {
@@ -296,37 +108,19 @@ class RequestManager {
296
108
  * cancelToken: axios.CancelToken.source().cancel
297
109
  * }
298
110
  * requestManager.request('/api/users', axios.get('/api/users', options), options);
299
- *
300
- * @example
301
- * // Request with noCancel to allow concurrent requests (e.g., lazy loading)
302
- * requestManager.request('/api/lazy?load=1', fetch('/api/lazy?load=1'), { noCancel: true });
303
- * requestManager.request('/api/lazy?load=2', fetch('/api/lazy?load=2'), { noCancel: true });
304
- * // Both requests will execute concurrently without canceling each other
305
111
  */
306
112
  request(url, requestPromise, options = {}) {
307
- const requestOptions = options || {};
308
- const requestId = this.#_generateRequestId(url, requestOptions.requestKey, requestOptions.noCancel);
309
- return this.#_request(requestId, requestPromise, requestOptions);
113
+ return this.#_request(this.getRequestId(url, options), requestPromise, options);
310
114
  }
311
115
 
312
116
  /**
313
117
  * Executes an HTTP request using fetch, cancelling any previous request with the same identifier.
314
- *
315
118
  * @param {string} url - The URL to fetch
316
- * @param {Object} options - Optional configuration
317
- * @param {string|number|Function} options.requestKey - Key to identify duplicate requests.
318
- * If provided, requests with the same key will cancel previous ones.
319
- * Can be a string, number, or function that returns a key.
320
- * @param {AbortController} options.abortController - AbortController instance (created automatically if not provided)
321
- * @param {Function} options.cancelToken - Cancel token or cancel function for other libraries
322
- * @param {boolean} options.noCancel - If true, this request will not cancel previous requests with the same ID, allowing concurrent requests
323
- * Any other properties are passed as fetch options (method, headers, body, etc.)
119
+ * @param {import('./index.d.ts').FetchOptions} options - Optional configuration
324
120
  * @returns {Promise} A Promise that resolves/rejects based on the most recent request
325
- *
326
121
  * @example
327
122
  * // Simple GET request
328
123
  * requestManager.fetch('/api/users');
329
- *
330
124
  * @example
331
125
  * // POST request with options
332
126
  * requestManager.fetch('/api/users', {
@@ -334,47 +128,29 @@ class RequestManager {
334
128
  * headers: { 'Content-Type': 'application/json' },
335
129
  * body: JSON.stringify({ name: 'John' })
336
130
  * });
337
- *
338
131
  * @example
339
132
  * // Request with requestKey for custom cancellation grouping with requestKey
340
133
  * requestManager.fetch('/api/users', {
341
134
  * requestKey: 'get-users'
342
135
  * });
343
- *
344
- * @example
345
- * // Request with noCancel to allow concurrent requests (e.g., lazy loading)
346
- * requestManager.fetch('/api/lazy?load=1', { noCancel: true });
347
- * requestManager.fetch('/api/lazy?load=2', { noCancel: true });
348
- * // Both requests will execute concurrently without canceling each other
349
136
  */
350
137
  fetch(url, options = {}) {
351
- const requestOptions = options || {};
352
- const requestId = this.#_generateRequestId(url, requestOptions.requestKey, requestOptions.noCancel);
353
- return this.#_request(requestId, url, requestOptions);
138
+ return this.#_request(this.getRequestId(url, options), url, options);
354
139
  }
355
140
 
356
141
  /**
357
142
  * Executes an HTTP request using axios, cancelling any previous request with the same identifier.
358
- *
359
143
  * @param {string} url - The URL to request
360
- * @param {Object} options - Optional configuration
361
- * @param {string|number|Function} options.requestKey - Key to identify duplicate requests.
362
- * If provided, requests with the same key will cancel previous ones.
363
- * Can be a string, number, or function that returns a key.
364
- * @param {boolean} options.noCancel - If true, this request will not cancel previous requests with the same ID, allowing concurrent requests
365
- * Any other properties are passed as axios options (method, headers, params, data, etc.)
366
- * @param {Object} axiosInstance - Optional axios instance to use. If not provided, uses global axios.
144
+ * @param {import('./index.d.ts').AxiosRequestOptions} options - Optional configuration
145
+ * @param {import('./index.d.ts').AxiosInstance|import('./index.d.ts').AxiosStatic|null} axiosInstance - Optional axios instance to use. If not provided, uses global axios.
367
146
  * @returns {Promise} A Promise that resolves/rejects based on the most recent request
368
- *
369
147
  * @example
370
148
  * // Simple GET request (uses global axios)
371
149
  * requestManager.axios('/api/users');
372
- *
373
150
  * @example
374
151
  * // With custom axios instance
375
152
  * const myAxios = axios.create({ baseURL: 'https://api.example.com' });
376
153
  * requestManager.axios('/users', {}, myAxios);
377
- *
378
154
  * @example
379
155
  * // POST request with options
380
156
  * requestManager.axios('/api/users', {
@@ -382,46 +158,31 @@ class RequestManager {
382
158
  * headers: { 'Content-Type': 'application/json' },
383
159
  * body: JSON.stringify({ name: 'John' })
384
160
  * });
385
- *
386
161
  * @example
387
162
  * // Request with requestKey for custom cancellation grouping with requestKey
388
163
  * requestManager.axios('/api/users', {
389
164
  * requestKey: 'get-users'
390
165
  * });
391
- *
392
- * @example
393
- * // Request with noCancel to allow concurrent requests
394
- * requestManager.axios('/api/lazy?load=1', { noCancel: true });
395
- * requestManager.axios('/api/lazy?load=2', { noCancel: true });
396
166
  */
397
167
  axios(url, options = {}, axiosInstance = null) {
398
- const requestOptions = options || {};
399
168
  const axiosLib = axiosInstance || axios;
400
169
  const cancelToken = axiosLib.CancelToken.source();
401
- const requestId = this.#_generateRequestId(url, requestOptions.requestKey, requestOptions.noCancel);
402
- return this.#_request(requestId, axiosLib.get(url, { cancelToken: cancelToken.token, ...requestOptions }), {
170
+ const requestId = this.getRequestId(url, options);
171
+ return this.#_request(requestId, axiosLib({ url, cancelToken: cancelToken.token, ...options }), {
403
172
  cancelToken: cancelToken,
404
- ...requestOptions
173
+ ...options,
405
174
  });
406
175
  }
407
176
 
408
177
  /**
409
178
  * Executes an HTTP request using jQuery.ajax, cancelling any previous request with the same identifier.
410
- *
411
- * @param {Function} ajaxFunction - A function that receives { url, ...options } and returns a Promise
179
+ * @param {import('./index.d.ts').AjaxFunction} ajaxFunction - A function that receives { url, ...options } and returns a Promise
412
180
  * @param {string} url - The URL to request
413
- * @param {Object} options - Optional configuration
414
- * @param {string|number|Function} options.requestKey - Key to identify duplicate requests.
415
- * If provided, requests with the same key will cancel previous ones.
416
- * Can be a string, number, or function that returns a key.
417
- * @param {boolean} options.noCancel - If true, this request will not cancel previous requests with the same ID, allowing concurrent requests
418
- * Any other properties are passed to the ajax method function
181
+ * @param {import('./index.d.ts').BaseRequestOptions & Record<string, any>} options - Optional configuration
419
182
  * @returns {Promise} A Promise that resolves/rejects based on the most recent request
420
- *
421
183
  * @example
422
184
  * // Simple GET request
423
185
  * requestManager.ajax(ajaxFunction, '/api/users');
424
- *
425
186
  * @example
426
187
  * // POST request with options
427
188
  * requestManager.ajax(ajaxFunction, '/api/users', {
@@ -429,7 +190,6 @@ class RequestManager {
429
190
  * headers: { 'Content-Type': 'application/json' },
430
191
  * body: JSON.stringify({ name: 'John' })
431
192
  * });
432
- *
433
193
  * @example
434
194
  * // Request with requestKey for custom cancellation grouping with requestKey
435
195
  * requestManager.ajax(ajaxFunction, '/api/users', {
@@ -438,52 +198,21 @@ class RequestManager {
438
198
  */
439
199
  ajax(ajaxFunction, url, options = {}) {
440
200
  if (typeof ajaxFunction !== 'function') throw new Error('ajaxFunction parameter must be a function');
441
- const requestOptions = options || {};
442
- const requestId = this.#_generateRequestId(url, requestOptions.requestKey, requestOptions.noCancel);
443
- try {
444
- // AbortController is needed at this point, so we clear here any existing one.
445
- // This is the same behavior as in the request method.
446
- this.#_clearAbortController();
447
- const abortController = options.abortController || this.getAbortController();
448
- const req = ajaxFunction({ url, ...requestOptions });
449
- // Determine abort method
450
- let abortMethod = null;
451
- if (req) {
452
- if (typeof req.abort === 'function') {
453
- abortMethod = req.abort.bind(req);
454
- } else if (req.xhr && typeof req.xhr.abort === 'function') {
455
- abortMethod = req.xhr.abort.bind(req.xhr);
456
- }
457
- }
458
- if (abortMethod) this.addAbortListener(abortMethod, abortController.signal);
459
- // Pass the AbortController to avoid creating another one for this request
460
- return this.#_request(requestId, req, { ...requestOptions, abortController: abortController });
461
- } catch (error) {
462
- return Promise.reject(error);
463
- }
201
+ return this.#_request(
202
+ this.getRequestId(url, options),
203
+ ({ options: requestOptions }) => ajaxFunction({ url, ...requestOptions }),
204
+ options
205
+ );
464
206
  }
465
207
 
466
208
  /**
467
209
  * Executes an HTTP request using XMLHttpRequest, cancelling any previous request with the same identifier.
468
- *
469
210
  * @param {string} url - The URL to request
470
- * @param {Object} options - Optional configuration
471
- * @param {string} options.method - HTTP method (GET, POST, PUT, DELETE, etc.). Defaults to 'GET'.
472
- * @param {Object} options.headers - Headers object to set on the request
473
- * @param {string|FormData|Blob|ArrayBuffer} options.body - Request body
474
- * @param {string} options.responseType - Response type ('text', 'json', 'blob', 'arraybuffer', 'document'). Defaults to 'text'.
475
- * @param {boolean} options.withCredentials - Whether to send credentials with the request
476
- * @param {number} options.timeout - Request timeout in milliseconds
477
- * @param {string|number|Function} options.requestKey - Key to identify duplicate requests.
478
- * If provided, requests with the same key will cancel previous ones.
479
- * Can be a string, number, or function that returns a key.
480
- * @param {boolean} options.noCancel - If true, this request will not cancel previous requests with the same ID, allowing concurrent requests
481
- * @returns {Promise} A Promise that resolves/rejects based on the most recent request
482
- *
211
+ * @param {import('./index.d.ts').XhrOptions} options - Optional configuration
212
+ * @returns {Promise<import('./index.d.ts').XhrResponse>} A Promise that resolves/rejects based on the most recent request
483
213
  * @example
484
214
  * // Simple GET request
485
215
  * requestManager.xhr('/api/users');
486
- *
487
216
  * @example
488
217
  * // POST request with options
489
218
  * requestManager.xhr('/api/users', {
@@ -491,35 +220,32 @@ class RequestManager {
491
220
  * headers: { 'Content-Type': 'application/json' },
492
221
  * body: JSON.stringify({ name: 'John' })
493
222
  * });
494
- *
495
223
  * @example
496
224
  * // Request with requestKey for custom cancellation grouping
497
225
  * requestManager.xhr('/api/users', {
498
226
  * requestKey: 'get-users'
499
227
  * });
500
- *
501
- * @example
502
- * // Request with noCancel to allow concurrent requests
503
- * requestManager.xhr('/api/lazy?load=1', { noCancel: true });
504
- * requestManager.xhr('/api/lazy?load=2', { noCancel: true });
505
228
  */
506
229
  xhr(url, options = {}) {
507
- const requestOptions = options || {};
508
- const requestId = this.#_generateRequestId(url, requestOptions.requestKey, requestOptions.noCancel);
230
+ const requestId = this.getRequestId(url, options);
231
+ /** @type {import('./index.d.ts').RequestFunction<import('./index.d.ts').XhrResponse>} */
509
232
  const xhrFunction = ({ options: fetchOptions }) => {
510
233
  // Create XMLHttpRequest
511
234
  const xhr = new XMLHttpRequest();
512
- const method = (requestOptions.method || 'GET').toUpperCase();
235
+ const method = (options.method || 'GET').toUpperCase();
513
236
  // Create a promise that wraps the XHR request
514
237
  const xhrPromise = new Promise((resolve, reject) => {
515
- xhr.onload = function() {
238
+ xhr.onload = function () {
516
239
  if (xhr.status >= 200 && xhr.status < 300) {
517
240
  let response = xhr.response;
518
- if (requestOptions.responseType === 'json' ||
519
- (xhr.getResponseHeader('Content-Type') && xhr.getResponseHeader('Content-Type').includes('application/json'))) {
241
+ if (
242
+ options.responseType === 'json' ||
243
+ (xhr.getResponseHeader('Content-Type') &&
244
+ xhr.getResponseHeader('Content-Type').includes('application/json'))
245
+ ) {
520
246
  try {
521
247
  response = JSON.parse(xhr.responseText);
522
- } catch (e) {
248
+ } catch {
523
249
  response = xhr.responseText;
524
250
  }
525
251
  }
@@ -528,27 +254,27 @@ class RequestManager {
528
254
  status: xhr.status,
529
255
  statusText: xhr.statusText,
530
256
  headers: xhr.getAllResponseHeaders(),
531
- xhr: xhr
257
+ xhr: xhr,
532
258
  });
533
259
  } else {
534
260
  reject({
535
261
  message: `Request failed with status ${xhr.status}`,
536
262
  status: xhr.status,
537
263
  statusText: xhr.statusText,
538
- xhr: xhr
264
+ xhr: xhr,
539
265
  });
540
266
  }
541
267
  };
542
- xhr.onerror = function() {
268
+ xhr.onerror = function () {
543
269
  reject({
544
270
  message: 'Network error',
545
- xhr: xhr
271
+ xhr: xhr,
546
272
  });
547
273
  };
548
- xhr.ontimeout = function() {
274
+ xhr.ontimeout = function () {
549
275
  reject({
550
276
  message: 'Request timeout',
551
- xhr: xhr
277
+ xhr: xhr,
552
278
  });
553
279
  };
554
280
 
@@ -556,34 +282,73 @@ class RequestManager {
556
282
  xhr.open(method, url, true);
557
283
 
558
284
  // Set response type
559
- if (requestOptions.responseType) xhr.responseType = requestOptions.responseType;
285
+ if (options.responseType) xhr.responseType = options.responseType;
560
286
  // Set withCredentials
561
- if (requestOptions.withCredentials !== undefined) xhr.withCredentials = requestOptions.withCredentials;
287
+ if (options.withCredentials !== undefined) xhr.withCredentials = options.withCredentials;
562
288
  // Set timeout
563
- if (requestOptions.timeout !== undefined) xhr.timeout = requestOptions.timeout;
289
+ if (options.timeout !== undefined) xhr.timeout = options.timeout;
564
290
  // Set headers
565
- if (requestOptions.headers) Object.keys(requestOptions.headers).forEach(key => {
566
- xhr.setRequestHeader(key, requestOptions.headers[key]);
567
- });
291
+ if (options.headers)
292
+ Object.keys(options.headers).forEach((key) => {
293
+ xhr.setRequestHeader(key, options.headers[key]);
294
+ });
568
295
 
569
296
  // Connect abort signal to xhr.abort()
570
297
  if (fetchOptions.signal) fetchOptions.signal.addEventListener('abort', () => xhr.abort());
571
298
 
572
299
  // Send the request
573
- xhr.send(requestOptions.body || null);
300
+ xhr.send(options.body || null);
574
301
  });
575
302
  return xhrPromise;
576
303
  };
577
- return this.#_request(requestId, xhrFunction, requestOptions);
304
+ return this.#_request(requestId, xhrFunction, options);
305
+ }
306
+
307
+ /**
308
+ * Returns the request identifier for a URL and options.
309
+ * @param {string} url - The URL used when starting the request
310
+ * @param {import('./index.d.ts').BaseRequestOptions} options - Same options used for the request
311
+ * @returns {string} The request identifier
312
+ * @example
313
+ * requestManager.fetch('/api/users');
314
+ * const id = requestManager.getRequestId('/api/users');
315
+ * requestManager.cancel(id);
316
+ */
317
+ getRequestId(url, options = {}) {
318
+ let requestKey = options.requestKey;
319
+
320
+ const prefix = 'request_';
321
+
322
+ // Generate a unique identifier to prevent cancellation for non cancelable requests
323
+ if (options.noCancel) {
324
+ return `${prefix}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
325
+ }
326
+
327
+ // Handle function requestKey
328
+ if (typeof requestKey === 'function') {
329
+ try {
330
+ requestKey = requestKey();
331
+ } catch {
332
+ requestKey = null;
333
+ }
334
+ }
335
+ if (requestKey !== null && requestKey !== undefined) return `${prefix}${String(requestKey)}`;
336
+
337
+ // Use cleaned URL as key as fallback
338
+ let cleanedUrl = url || '';
339
+ if (cleanedUrl.includes('://')) cleanedUrl = cleanedUrl.split('://')[1];
340
+ if (cleanedUrl.includes('#')) cleanedUrl = cleanedUrl.split('#')[0];
341
+ if (!options.includeQuery && cleanedUrl.includes('?')) cleanedUrl = cleanedUrl.split('?')[0];
342
+ return `${prefix}${cleanedUrl}`;
578
343
  }
579
344
 
580
345
  /**
581
346
  * Cancels a specific request by its identifier.
582
- *
583
347
  * @param {string} requestId - The unique identifier of the request to cancel
584
348
  * @returns {boolean} True if the request was found and cancelled, false otherwise
585
349
  */
586
350
  cancel(requestId) {
351
+ /** @type {import('./index.d.ts').ActiveRequest|undefined} */
587
352
  const requestInfo = this.activeRequests.get(requestId);
588
353
  if (!requestInfo) return false;
589
354
 
@@ -605,10 +370,11 @@ class RequestManager {
605
370
  }
606
371
 
607
372
  // Reject the wrapper promise
608
- if (requestInfo.rejectWrapper && this.verbose) {
609
- requestInfo.rejectWrapper(new Error(`Request ${requestId} was cancelled`));
610
- }
611
- this.activeRequests.delete(requestId); // Remove from active requests
373
+ this.#_deleteRequest(
374
+ requestId,
375
+ requestInfo.rejectWrapper,
376
+ this.getOptions().verbose ? new Error(`Request ${requestId} was cancelled`) : null
377
+ );
612
378
  return true;
613
379
  }
614
380
 
@@ -619,8 +385,8 @@ class RequestManager {
619
385
  * @param {AbortSignal} signal - The signal to listen to
620
386
  */
621
387
  addAbortListener(abortMethod, signal) {
622
- if (!signal) return;
623
- signal.addEventListener("abort", () => {
388
+ if (!abortMethod || !signal) return;
389
+ signal.addEventListener('abort', () => {
624
390
  if (typeof abortMethod === 'function') {
625
391
  try {
626
392
  abortMethod();
@@ -631,7 +397,6 @@ class RequestManager {
631
397
 
632
398
  /**
633
399
  * Cancels all active requests.
634
- *
635
400
  * @returns {number} The number of requests that were cancelled
636
401
  */
637
402
  cancelAll() {
@@ -644,30 +409,186 @@ class RequestManager {
644
409
  }
645
410
 
646
411
  /**
647
- * Checks if a request with the given identifier is currently active.
648
- *
649
- * @param {string} requestId - The unique identifier to check
650
- * @returns {boolean} True if the request is active, false otherwise
412
+ * Resolves the AbortController for a request: explicit option, pending handoff, or new.
413
+ * Clears the pending handoff so concurrent requests do not share it.
414
+ * @param {AbortController|undefined} provided - Optional AbortController from options
415
+ * @returns {AbortController}
416
+ * @private
651
417
  */
652
- isActive(requestId) {
653
- return this.activeRequests.has(requestId);
418
+ #_resolveAbortController(provided) {
419
+ const abortController = provided || this.abortController || new AbortController();
420
+ this.abortController = null;
421
+ return abortController;
654
422
  }
655
423
 
656
424
  /**
657
- * Gets the number of active requests.
658
- *
659
- * @returns {number} The number of currently active requests
425
+ * Picks the best abort callback for a client request object.
426
+ * @param {Object} req - The request object
427
+ * @returns {Function|null}
428
+ * @private
660
429
  */
661
- getActiveCount() {
662
- return this.activeRequests.size;
430
+ #_resolveAbortMethod(req) {
431
+ if (!req) return null;
432
+ if (typeof req.abort === 'function') return () => req.abort();
433
+ const ExtAjax =
434
+ typeof globalThis !== 'undefined' && globalThis.Ext && globalThis.Ext.Ajax ? globalThis.Ext.Ajax : null;
435
+ if (req.xhr && ExtAjax && typeof ExtAjax.abort === 'function') {
436
+ return () => ExtAjax.abort(req);
437
+ }
438
+ if (req.xhr && typeof req.xhr.abort === 'function') return () => req.xhr.abort();
439
+ return null;
663
440
  }
664
441
 
665
442
  /**
666
- * Clears all active requests without cancelling them.
667
- * Use with caution - this will not cancel the underlying HTTP requests.
443
+ * Prepares request options by merging options and removing custom properties
444
+ * @param {import('./index.d.ts').RequestOptions} options - Configuration options
445
+ * @param {AbortSignal} signal - Abort signal to add to request options
446
+ * @returns {Object} Prepared request options
447
+ * @private
668
448
  */
669
- clear() {
670
- this.activeRequests.clear();
449
+ #_prepareRequestOptions(options, signal) {
450
+ const requestOptions = {};
451
+ const customOptions = ['abortController', 'cancelToken', 'requestKey', 'noCancel', 'includeQuery'];
452
+ Object.keys(options).forEach((key) => {
453
+ if (customOptions.includes(key)) return;
454
+ requestOptions[key] = options[key];
455
+ });
456
+ requestOptions.signal = signal;
457
+ return requestOptions;
458
+ }
459
+
460
+ /**
461
+ * Deletes a request from the active requests map and rejects the wrapper promise
462
+ * @param {string} requestId - The unique identifier of the request
463
+ * @param {Function} rejectWrapper - The function to reject the wrapper promise
464
+ * @param {*} error - The error to reject the wrapper promise with; the wrapper is not rejected if null/undefined
465
+ * @private
466
+ */
467
+ #_deleteRequest(requestId, rejectWrapper, error) {
468
+ this.activeRequests.delete(requestId);
469
+ if (error !== null && error !== undefined) {
470
+ rejectWrapper(error);
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Completes a request by deleting it from the active requests map and resolving the wrapper promise
476
+ * @param {string} requestId - The unique identifier of the request
477
+ * @param {Function} resolveWrapper - The function to resolve the wrapper promise
478
+ * @param {Promise} requestPromise - The request promise
479
+ * @param {boolean} isCancelled - Whether the request was cancelled
480
+ * @private
481
+ */
482
+ #_completeRequest(requestId, resolveWrapper, requestPromise, isCancelled) {
483
+ this.activeRequests.delete(requestId);
484
+ if (!isCancelled) {
485
+ resolveWrapper(requestPromise);
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Internal method that handles the core request logic.
491
+ * @param {string} requestId - Unique identifier for the request
492
+ * @param {Promise|import('./index.d.ts').RequestFunction|string} requestPromise - The request promise, function, or URL string
493
+ * @param {import('./index.d.ts').BaseRequestOptions} options - Configuration options
494
+ * @returns {Promise} A Promise that resolves/rejects based on the most recent request
495
+ * @private
496
+ */
497
+ #_request(requestId, requestPromise, options = {}) {
498
+ const abortController = this.#_resolveAbortController(options.abortController);
499
+
500
+ // Handle different types of requestPromise inputs
501
+ // Priority: Function (Custom logic) > String (URL) > Promise (axios, fetch, etc.)
502
+ if (typeof requestPromise === 'function') {
503
+ // Function: custom logic for any library (axios, ajax, etc.)
504
+ try {
505
+ requestPromise = requestPromise({
506
+ options: this.#_prepareRequestOptions(options, abortController.signal),
507
+ });
508
+ } catch (error) {
509
+ return Promise.reject(error);
510
+ }
511
+ } else if (typeof requestPromise === 'string') {
512
+ // String (URL): make fetch internally
513
+ try {
514
+ requestPromise = fetch(requestPromise, this.#_prepareRequestOptions(options, abortController.signal));
515
+ } catch (error) {
516
+ return Promise.reject(error);
517
+ }
518
+ }
519
+
520
+ // Link the request object's abort method (Ext.Ajax, XHR, etc.) to the cancellation signal
521
+ this.addAbortListener(this.#_resolveAbortMethod(requestPromise), abortController.signal);
522
+
523
+ // Cancel previous request with the same identifier if it exists
524
+ if (!options.noCancel) this.cancel(requestId);
525
+
526
+ // Create a wrapper promise that will be resolved/rejected based on the request
527
+ let resolveWrapper, rejectWrapper;
528
+ const wrapperPromise = new Promise((resolve, reject) => {
529
+ resolveWrapper = resolve;
530
+ rejectWrapper = reject;
531
+ });
532
+
533
+ /**
534
+ * @type {import('./index.d.ts').ActiveRequest}
535
+ */
536
+ const requestInfo = {
537
+ promise: requestPromise,
538
+ abortController: abortController,
539
+ cancelToken: options.cancelToken || null,
540
+ resolveWrapper: resolveWrapper,
541
+ rejectWrapper: rejectWrapper,
542
+ isCancelled: false,
543
+ };
544
+
545
+ this.activeRequests.set(requestId, requestInfo);
546
+
547
+ // Handle request promise completion
548
+ if (requestPromise && typeof requestPromise.then === 'function') {
549
+ try {
550
+ let req = requestPromise.then((result) => {
551
+ if (this.activeRequests.get(requestId) !== requestInfo) return;
552
+ this.#_completeRequest(requestId, resolveWrapper, result, requestInfo.isCancelled);
553
+ });
554
+ if (req.catch)
555
+ req.catch((error) => {
556
+ onError(this, error);
557
+ });
558
+ } catch (error) {
559
+ onError(this, error);
560
+ }
561
+ function onError(scope, error) {
562
+ // Check if this requestInfo is still the active one, or if it was cancelled
563
+ if (scope.activeRequests.get(requestId) !== requestInfo) return;
564
+ if (requestInfo.isCancelled) {
565
+ // Already cancelled: let cancel() handle cleanup and reject the wrapper promise
566
+ scope.cancel(requestId);
567
+ return;
568
+ }
569
+ // Only delete if this is still the active request
570
+ scope.#_deleteRequest(requestId, rejectWrapper, error);
571
+ }
572
+ } else {
573
+ // Non-promise (Ext.Ajax request object, raw XHR, etc.). Keep tracked until the
574
+ // underlying XHR finishes so a later duplicate can still cancel it.
575
+ const xhr =
576
+ requestPromise &&
577
+ (requestPromise.xhr ||
578
+ (typeof XMLHttpRequest !== 'undefined' && requestPromise instanceof XMLHttpRequest
579
+ ? requestPromise
580
+ : null));
581
+ const finish = () => {
582
+ if (this.activeRequests.get(requestId) !== requestInfo) return;
583
+ this.#_completeRequest(requestId, resolveWrapper, requestPromise, requestInfo.isCancelled);
584
+ };
585
+ if (xhr && typeof xhr.addEventListener === 'function') {
586
+ xhr.addEventListener('loadend', finish);
587
+ } else {
588
+ setTimeout(finish, 0);
589
+ }
590
+ }
591
+ return wrapperPromise;
671
592
  }
672
593
  }
673
594