@enegalan/request-manager 1.0.3 → 1.1.0

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