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