@enegalan/request-manager 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,597 +0,0 @@
1
- 'use strict';
2
-
3
- Object.defineProperty(exports, '__esModule', { value: true });
4
-
5
- /**
6
- * RequestManager - A library for managing and regulating HTTP requests efficiently.
7
- * @license MIT
8
- * @author Eneko Galan <enekogalanelorza@gmail.com>
9
- * This library allows you to manage HTTP requests from any library (ajax, Ext.Ajax, axios, fetch, etc.)
10
- * by accepting Promises as parameters. When a request is repeated with the same identifier,
11
- * the previous request is automatically cancelled and the new one is executed, giving priority to the most recent requests.
12
- */
13
- class RequestManager {
14
- constructor(options = {}) {
15
- /**
16
- * @type {Map<string, import('./index.d.ts').ActiveRequest>}
17
- */
18
- this.activeRequests = new Map();
19
- /**
20
- * @type {import('./index.d.ts').Options}
21
- */
22
- this.options = options;
23
- /**
24
- * One-shot AbortController for getSignal()/getAbortController() handoff.
25
- * Consumed by the next request that does not pass options.abortController.
26
- * @type {AbortController|null}
27
- */
28
- this.abortController = null;
29
- }
30
-
31
- /**
32
- * Gets the manager options
33
- * @returns {import('./index.d.ts').Options} Manager options
34
- */
35
- getOptions() {
36
- return this.options;
37
- }
38
-
39
- /**
40
- * Sets the manager options
41
- * @param {import('./index.d.ts').Options} options - The options to set
42
- */
43
- setOptions(options) {
44
- this.options = options;
45
- }
46
-
47
- /**
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.
51
- * @returns {AbortSignal} The signal from a new AbortController
52
- * @example
53
- * const signal = requestManager.getSignal();
54
- * requestManager.request('/api/users', fetch('/api/users', { signal }));
55
- */
56
- getSignal() {
57
- return this.getAbortController().signal;
58
- }
59
-
60
- /**
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
64
- */
65
- getAbortController() {
66
- this.abortController = new AbortController();
67
- return this.abortController;
68
- }
69
-
70
- /**
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
74
- */
75
- isActive(requestId) {
76
- return this.activeRequests.has(requestId);
77
- }
78
-
79
- /**
80
- * Gets the number of active requests.
81
- * @returns {number} The number of currently active requests
82
- */
83
- getActiveCount() {
84
- return this.activeRequests.size;
85
- }
86
-
87
- /**
88
- * Clears all active requests without cancelling them.
89
- * Use with caution - this will not cancel the underlying HTTP requests.
90
- */
91
- clear() {
92
- this.activeRequests.clear();
93
- }
94
-
95
- /**
96
- * Executes an HTTP request, cancelling any previous request with the same identifier.
97
- * @param {string} url - The URL to request
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
100
- * @returns {Promise} A Promise that resolves/rejects based on the most recent request
101
- * @example
102
- * // Request with Promise
103
- * requestManager.request('/api/users', axios.get('/api/users'));
104
- * @example
105
- * // Request with Function
106
- * requestManager.request('/api/users', ({ options }) => fetch('/api/users', { signal: options.signal, ...options }));
107
- * @example
108
- * // Request with Promise and custom cancellation grouping with requestKey
109
- * const options = {
110
- * requestKey: 'get-users'
111
- * }
112
- * requestManager.request('/api/users', axios.get('/api/users', options), options);
113
- */
114
- request(url, requestPromise, options = {}) {
115
- return this.#_request(this.getRequestId(url, options), requestPromise, options);
116
- }
117
-
118
- /**
119
- * Executes an HTTP request using fetch, cancelling any previous request with the same identifier.
120
- * @param {string} url - The URL to fetch
121
- * @param {import('./index.d.ts').FetchOptions} options - Optional configuration
122
- * @returns {Promise} A Promise that resolves/rejects based on the most recent request
123
- * @example
124
- * // Simple GET request
125
- * requestManager.fetch('/api/users');
126
- * @example
127
- * // POST request with options
128
- * requestManager.fetch('/api/users', {
129
- * method: 'POST',
130
- * headers: { 'Content-Type': 'application/json' },
131
- * body: JSON.stringify({ name: 'John' })
132
- * });
133
- * @example
134
- * // Request with requestKey for custom cancellation grouping with requestKey
135
- * requestManager.fetch('/api/users', {
136
- * requestKey: 'get-users'
137
- * });
138
- */
139
- fetch(url, options = {}) {
140
- return this.#_request(this.getRequestId(url, options), url, options);
141
- }
142
-
143
- /**
144
- * Executes an HTTP request using axios, cancelling any previous request with the same identifier.
145
- * @param {string} url - The URL to request
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.
148
- * @returns {Promise} A Promise that resolves/rejects based on the most recent request
149
- * @example
150
- * // Simple GET request (uses global axios)
151
- * requestManager.axios('/api/users');
152
- * @example
153
- * // With custom axios instance
154
- * const myAxios = axios.create({ baseURL: 'https://api.example.com' });
155
- * requestManager.axios('/users', {}, myAxios);
156
- * @example
157
- * // POST request with options
158
- * requestManager.axios('/api/users', {
159
- * method: 'POST',
160
- * headers: { 'Content-Type': 'application/json' },
161
- * body: JSON.stringify({ name: 'John' })
162
- * });
163
- * @example
164
- * // Request with requestKey for custom cancellation grouping with requestKey
165
- * requestManager.axios('/api/users', {
166
- * requestKey: 'get-users'
167
- * });
168
- */
169
- axios(url, options = {}, axiosInstance = null) {
170
- const axiosLib = axiosInstance || axios;
171
- return this.#_request(
172
- this.getRequestId(url, options),
173
- ({ options: requestOptions }) => axiosLib({ url, ...requestOptions }),
174
- options
175
- );
176
- }
177
-
178
- /**
179
- * Executes an HTTP request using jQuery.ajax, cancelling any previous request with the same identifier.
180
- * @param {import('./index.d.ts').AjaxFunction} ajaxFunction - A function that receives { url, ...options } and returns a Promise
181
- * @param {string} url - The URL to request
182
- * @param {import('./index.d.ts').BaseRequestOptions & Record<string, any>} options - Optional configuration
183
- * @returns {Promise} A Promise that resolves/rejects based on the most recent request
184
- * @example
185
- * // Simple GET request
186
- * requestManager.ajax(ajaxFunction, '/api/users');
187
- * @example
188
- * // POST request with options
189
- * requestManager.ajax(ajaxFunction, '/api/users', {
190
- * method: 'POST',
191
- * headers: { 'Content-Type': 'application/json' },
192
- * body: JSON.stringify({ name: 'John' })
193
- * });
194
- * @example
195
- * // Request with requestKey for custom cancellation grouping with requestKey
196
- * requestManager.ajax(ajaxFunction, '/api/users', {
197
- * requestKey: 'get-users'
198
- * });
199
- */
200
- ajax(ajaxFunction, url, options = {}) {
201
- if (typeof ajaxFunction !== 'function') throw new Error('ajaxFunction parameter must be a function');
202
- return this.#_request(
203
- this.getRequestId(url, options),
204
- ({ options: requestOptions }) => ajaxFunction({ url, ...requestOptions }),
205
- options
206
- );
207
- }
208
-
209
- /**
210
- * Executes an HTTP request using XMLHttpRequest, cancelling any previous request with the same identifier.
211
- * @param {string} url - The URL to request
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
214
- * @example
215
- * // Simple GET request
216
- * requestManager.xhr('/api/users');
217
- * @example
218
- * // POST request with options
219
- * requestManager.xhr('/api/users', {
220
- * method: 'POST',
221
- * headers: { 'Content-Type': 'application/json' },
222
- * body: JSON.stringify({ name: 'John' })
223
- * });
224
- * @example
225
- * // Request with requestKey for custom cancellation grouping
226
- * requestManager.xhr('/api/users', {
227
- * requestKey: 'get-users'
228
- * });
229
- */
230
- xhr(url, options = {}) {
231
- const requestId = this.getRequestId(url, options);
232
- /** @type {import('./index.d.ts').RequestFunction<import('./index.d.ts').XhrResponse>} */
233
- const xhrFunction = ({ options: fetchOptions }) => {
234
- // Create XMLHttpRequest
235
- const xhr = new XMLHttpRequest();
236
- const method = (options.method || 'GET').toUpperCase();
237
- // Create a promise that wraps the XHR request
238
- const xhrPromise = new Promise((resolve, reject) => {
239
- xhr.onload = function () {
240
- if (xhr.status >= 200 && xhr.status < 300) {
241
- let response = xhr.response;
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
- ) {
248
- try {
249
- response = JSON.parse(response);
250
- } catch {}
251
- }
252
- resolve({
253
- data: response,
254
- status: xhr.status,
255
- statusText: xhr.statusText,
256
- headers: xhr.getAllResponseHeaders(),
257
- xhr: xhr,
258
- });
259
- } else {
260
- reject({
261
- message: `Request failed with status ${xhr.status}`,
262
- status: xhr.status,
263
- statusText: xhr.statusText,
264
- xhr: xhr,
265
- });
266
- }
267
- };
268
- xhr.onerror = function () {
269
- reject({
270
- message: 'Network error',
271
- xhr: xhr,
272
- });
273
- };
274
- xhr.ontimeout = function () {
275
- reject({
276
- message: 'Request timeout',
277
- xhr: xhr,
278
- });
279
- };
280
-
281
- // Open the request
282
- xhr.open(method, url, true);
283
-
284
- // Set response type
285
- if (options.responseType) xhr.responseType = options.responseType;
286
- // Set withCredentials
287
- if (options.withCredentials !== undefined) xhr.withCredentials = options.withCredentials;
288
- // Set timeout
289
- if (options.timeout !== undefined) xhr.timeout = options.timeout;
290
- // Set headers
291
- if (options.headers)
292
- Object.keys(options.headers).forEach((key) => {
293
- xhr.setRequestHeader(key, options.headers[key]);
294
- });
295
-
296
- // Connect abort signal to xhr.abort()
297
- if (fetchOptions.signal) fetchOptions.signal.addEventListener('abort', () => xhr.abort());
298
-
299
- // Send the request
300
- xhr.send(options.body || null);
301
- });
302
- return xhrPromise;
303
- };
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}`;
343
- }
344
-
345
- /**
346
- * Cancels a specific request by its identifier.
347
- * @param {string} requestId - The unique identifier of the request to cancel
348
- * @returns {boolean} True if the request was found and cancelled, false otherwise
349
- */
350
- cancel(requestId) {
351
- /** @type {import('./index.d.ts').ActiveRequest|undefined} */
352
- const requestInfo = this.activeRequests.get(requestId);
353
- if (!requestInfo) return false;
354
-
355
- requestInfo.isCancelled = true; // Mark as cancelled
356
-
357
- // Try to abort using AbortController (for fetch)
358
- if (requestInfo.abortController && !requestInfo.abortController.signal.aborted) {
359
- try {
360
- requestInfo.abortController.abort('Request was cancelled');
361
- } catch (error) {}
362
- }
363
-
364
- // Try to cancel using cancel token/function (for axios and others)
365
- if (requestInfo.cancelToken) {
366
- try {
367
- if (typeof requestInfo.cancelToken === 'function') requestInfo.cancelToken();
368
- else if (requestInfo.cancelToken.cancel) requestInfo.cancelToken.cancel();
369
- } catch (error) {}
370
- }
371
-
372
- // Reject the wrapper promise
373
- this.#_deleteRequest(
374
- requestId,
375
- requestInfo.rejectWrapper,
376
- this.getOptions().verbose ? new Error(`Request ${requestId} was cancelled`) : null
377
- );
378
- return true;
379
- }
380
-
381
- /**
382
- * Link abort signal with HTTP client abort method.
383
- * Useful for custom HTTP clients that only support the abort method to cancel requests.
384
- * @param {Function} abortMethod - The abort method to call when the signal is aborted
385
- * @param {AbortSignal} signal - The signal to listen to
386
- */
387
- addAbortListener(abortMethod, signal) {
388
- if (!abortMethod || !signal) return;
389
- signal.addEventListener('abort', () => {
390
- if (typeof abortMethod === 'function') {
391
- try {
392
- abortMethod();
393
- } catch (error) {}
394
- }
395
- });
396
- }
397
-
398
- /**
399
- * Cancels all active requests.
400
- * @returns {number} The number of requests that were cancelled
401
- */
402
- cancelAll() {
403
- const requestIds = Array.from(this.activeRequests.keys());
404
- let cancelledCount = 0;
405
- requestIds.forEach((requestId) => {
406
- if (this.cancel(requestId)) cancelledCount++;
407
- });
408
- return cancelledCount;
409
- }
410
-
411
- /**
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
417
- */
418
- #_resolveAbortController(provided) {
419
- const abortController = provided || this.abortController || new AbortController();
420
- this.abortController = null;
421
- return abortController;
422
- }
423
-
424
- /**
425
- * Picks the best abort callback for a client request object.
426
- * @param {Object} req - The request object
427
- * @returns {Function|null}
428
- * @private
429
- */
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;
440
- }
441
-
442
- /**
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
448
- */
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;
592
- }
593
- }
594
-
595
- exports.RequestManager = RequestManager;
596
- exports.default = RequestManager;
597
- //# sourceMappingURL=request-manager.cjs.js.map