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