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