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