@enegalan/request-manager 1.0.2 → 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,94 +158,61 @@ 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} ajaxMethod - 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
- * requestManager.ajax(ajaxMethod, '/api/users');
424
- *
185
+ * requestManager.ajax(ajaxFunction, '/api/users');
425
186
  * @example
426
187
  * // POST request with options
427
- * requestManager.ajax(ajaxMethod, '/api/users', {
188
+ * requestManager.ajax(ajaxFunction, '/api/users', {
428
189
  * method: 'POST',
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
- * requestManager.ajax(ajaxMethod, '/api/users', {
195
+ * requestManager.ajax(ajaxFunction, '/api/users', {
436
196
  * requestKey: 'get-users'
437
197
  * });
438
198
  */
439
- ajax(ajaxMethod, url, options = {}) {
440
- if (typeof ajaxMethod !== 'function') throw new Error('ajaxMethod must be a function');
441
- const requestOptions = options || {};
442
- const requestId = this.#_generateRequestId(url, requestOptions.requestKey, requestOptions.noCancel);
443
- try {
444
- const req = ajaxMethod({ url, ...requestOptions });
445
- this.addAbortListener(req.abort, this.getSignal());
446
- return this.#_request(requestId, req, requestOptions);
447
- } catch (error) {
448
- return Promise.reject(error);
449
- }
199
+ ajax(ajaxFunction, url, options = {}) {
200
+ if (typeof ajaxFunction !== 'function') throw new Error('ajaxFunction parameter must be a function');
201
+ return this.#_request(
202
+ this.getRequestId(url, options),
203
+ ({ options: requestOptions }) => ajaxFunction({ url, ...requestOptions }),
204
+ options
205
+ );
450
206
  }
451
207
 
452
208
  /**
453
209
  * Executes an HTTP request using XMLHttpRequest, cancelling any previous request with the same identifier.
454
- *
455
210
  * @param {string} url - The URL to request
456
- * @param {Object} options - Optional configuration
457
- * @param {string} options.method - HTTP method (GET, POST, PUT, DELETE, etc.). Defaults to 'GET'.
458
- * @param {Object} options.headers - Headers object to set on the request
459
- * @param {string|FormData|Blob|ArrayBuffer} options.body - Request body
460
- * @param {string} options.responseType - Response type ('text', 'json', 'blob', 'arraybuffer', 'document'). Defaults to 'text'.
461
- * @param {boolean} options.withCredentials - Whether to send credentials with the request
462
- * @param {number} options.timeout - Request timeout in milliseconds
463
- * @param {string|number|Function} options.requestKey - Key to identify duplicate requests.
464
- * If provided, requests with the same key will cancel previous ones.
465
- * Can be a string, number, or function that returns a key.
466
- * @param {boolean} options.noCancel - If true, this request will not cancel previous requests with the same ID, allowing concurrent requests
467
- * @returns {Promise} A Promise that resolves/rejects based on the most recent request
468
- *
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
469
213
  * @example
470
214
  * // Simple GET request
471
215
  * requestManager.xhr('/api/users');
472
- *
473
216
  * @example
474
217
  * // POST request with options
475
218
  * requestManager.xhr('/api/users', {
@@ -477,35 +220,32 @@ class RequestManager {
477
220
  * headers: { 'Content-Type': 'application/json' },
478
221
  * body: JSON.stringify({ name: 'John' })
479
222
  * });
480
- *
481
223
  * @example
482
224
  * // Request with requestKey for custom cancellation grouping
483
225
  * requestManager.xhr('/api/users', {
484
226
  * requestKey: 'get-users'
485
227
  * });
486
- *
487
- * @example
488
- * // Request with noCancel to allow concurrent requests
489
- * requestManager.xhr('/api/lazy?load=1', { noCancel: true });
490
- * requestManager.xhr('/api/lazy?load=2', { noCancel: true });
491
228
  */
492
229
  xhr(url, options = {}) {
493
- const requestOptions = options || {};
494
- 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>} */
495
232
  const xhrFunction = ({ options: fetchOptions }) => {
496
233
  // Create XMLHttpRequest
497
234
  const xhr = new XMLHttpRequest();
498
- const method = (requestOptions.method || 'GET').toUpperCase();
235
+ const method = (options.method || 'GET').toUpperCase();
499
236
  // Create a promise that wraps the XHR request
500
237
  const xhrPromise = new Promise((resolve, reject) => {
501
- xhr.onload = function() {
238
+ xhr.onload = function () {
502
239
  if (xhr.status >= 200 && xhr.status < 300) {
503
240
  let response = xhr.response;
504
- if (requestOptions.responseType === 'json' ||
505
- (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
+ ) {
506
246
  try {
507
247
  response = JSON.parse(xhr.responseText);
508
- } catch (e) {
248
+ } catch {
509
249
  response = xhr.responseText;
510
250
  }
511
251
  }
@@ -514,27 +254,27 @@ class RequestManager {
514
254
  status: xhr.status,
515
255
  statusText: xhr.statusText,
516
256
  headers: xhr.getAllResponseHeaders(),
517
- xhr: xhr
257
+ xhr: xhr,
518
258
  });
519
259
  } else {
520
260
  reject({
521
261
  message: `Request failed with status ${xhr.status}`,
522
262
  status: xhr.status,
523
263
  statusText: xhr.statusText,
524
- xhr: xhr
264
+ xhr: xhr,
525
265
  });
526
266
  }
527
267
  };
528
- xhr.onerror = function() {
268
+ xhr.onerror = function () {
529
269
  reject({
530
270
  message: 'Network error',
531
- xhr: xhr
271
+ xhr: xhr,
532
272
  });
533
273
  };
534
- xhr.ontimeout = function() {
274
+ xhr.ontimeout = function () {
535
275
  reject({
536
276
  message: 'Request timeout',
537
- xhr: xhr
277
+ xhr: xhr,
538
278
  });
539
279
  };
540
280
 
@@ -542,34 +282,73 @@ class RequestManager {
542
282
  xhr.open(method, url, true);
543
283
 
544
284
  // Set response type
545
- if (requestOptions.responseType) xhr.responseType = requestOptions.responseType;
285
+ if (options.responseType) xhr.responseType = options.responseType;
546
286
  // Set withCredentials
547
- if (requestOptions.withCredentials !== undefined) xhr.withCredentials = requestOptions.withCredentials;
287
+ if (options.withCredentials !== undefined) xhr.withCredentials = options.withCredentials;
548
288
  // Set timeout
549
- if (requestOptions.timeout !== undefined) xhr.timeout = requestOptions.timeout;
289
+ if (options.timeout !== undefined) xhr.timeout = options.timeout;
550
290
  // Set headers
551
- if (requestOptions.headers) Object.keys(requestOptions.headers).forEach(key => {
552
- xhr.setRequestHeader(key, requestOptions.headers[key]);
553
- });
291
+ if (options.headers)
292
+ Object.keys(options.headers).forEach((key) => {
293
+ xhr.setRequestHeader(key, options.headers[key]);
294
+ });
554
295
 
555
296
  // Connect abort signal to xhr.abort()
556
297
  if (fetchOptions.signal) fetchOptions.signal.addEventListener('abort', () => xhr.abort());
557
298
 
558
299
  // Send the request
559
- xhr.send(requestOptions.body || null);
300
+ xhr.send(options.body || null);
560
301
  });
561
302
  return xhrPromise;
562
303
  };
563
- 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}`;
564
343
  }
565
344
 
566
345
  /**
567
346
  * Cancels a specific request by its identifier.
568
- *
569
347
  * @param {string} requestId - The unique identifier of the request to cancel
570
348
  * @returns {boolean} True if the request was found and cancelled, false otherwise
571
349
  */
572
350
  cancel(requestId) {
351
+ /** @type {import('./index.d.ts').ActiveRequest|undefined} */
573
352
  const requestInfo = this.activeRequests.get(requestId);
574
353
  if (!requestInfo) return false;
575
354
 
@@ -591,10 +370,11 @@ class RequestManager {
591
370
  }
592
371
 
593
372
  // Reject the wrapper promise
594
- if (requestInfo.rejectWrapper && this.verbose) {
595
- requestInfo.rejectWrapper(new Error('Request was cancelled'));
596
- }
597
- 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
+ );
598
378
  return true;
599
379
  }
600
380
 
@@ -605,8 +385,8 @@ class RequestManager {
605
385
  * @param {AbortSignal} signal - The signal to listen to
606
386
  */
607
387
  addAbortListener(abortMethod, signal) {
608
- if (!signal) return;
609
- signal.addEventListener("abort", () => {
388
+ if (!abortMethod || !signal) return;
389
+ signal.addEventListener('abort', () => {
610
390
  if (typeof abortMethod === 'function') {
611
391
  try {
612
392
  abortMethod();
@@ -617,7 +397,6 @@ class RequestManager {
617
397
 
618
398
  /**
619
399
  * Cancels all active requests.
620
- *
621
400
  * @returns {number} The number of requests that were cancelled
622
401
  */
623
402
  cancelAll() {
@@ -630,30 +409,186 @@ class RequestManager {
630
409
  }
631
410
 
632
411
  /**
633
- * Checks if a request with the given identifier is currently active.
634
- *
635
- * @param {string} requestId - The unique identifier to check
636
- * @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
637
417
  */
638
- isActive(requestId) {
639
- return this.activeRequests.has(requestId);
418
+ #_resolveAbortController(provided) {
419
+ const abortController = provided || this.abortController || new AbortController();
420
+ this.abortController = null;
421
+ return abortController;
640
422
  }
641
423
 
642
424
  /**
643
- * Gets the number of active requests.
644
- *
645
- * @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
646
429
  */
647
- getActiveCount() {
648
- 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;
649
440
  }
650
441
 
651
442
  /**
652
- * Clears all active requests without cancelling them.
653
- * 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
654
448
  */
655
- clear() {
656
- 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;
657
592
  }
658
593
  }
659
594