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