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