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