@d1g1tal/transportr 1.2.2 → 1.3.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.
package/src/transportr.js CHANGED
@@ -1,6 +1,4 @@
1
- import { _objectIsEmpty, _objectMerge, _type } from '@d1g1tal/chrysalis';
2
1
  import SetMultiMap from '@d1g1tal/collections/set-multi-map.js';
3
- import { MediaType } from '@d1g1tal/media-type';
4
2
  import Subscribr from '@d1g1tal/subscribr';
5
3
  import HttpError from './http-error.js';
6
4
  import HttpMediaType from './http-media-type.js';
@@ -8,25 +6,17 @@ import HttpRequestHeader from './http-request-headers.js';
8
6
  import HttpRequestMethod from './http-request-methods.js';
9
7
  import HttpResponseHeader from './http-response-headers.js';
10
8
  import ResponseStatus from './response-status.js';
11
- import SignalController from './signal-controller.js';
9
+ import ParameterMap from './parameter-map.js';
10
+ import AbortSignal from './abort-signal.js';
11
+ import { MediaType } from '@d1g1tal/media-type';
12
+ import { _objectMerge, _objectIsEmpty, _type } from '@d1g1tal/chrysalis';
13
+ import { mediaTypes, endsWithSlashRegEx, RequestEvents, SignalEvents, abortEvent, requestBodyMethods } from './constants.js';
12
14
 
13
15
  /**
14
16
  * @template T extends ResponseBody
15
17
  * @typedef {function(Response): Promise<T>} ResponseHandler<T>
16
18
  */
17
19
 
18
- /**
19
- * @template T
20
- * @typedef {function(T): Object<string, *>} TypeConverter<T>
21
- */
22
-
23
- /**
24
- * @typedef {Object} PropertyTypeConverter
25
- * @property {string} property The name of the property on the {@link RequestOptions} object.
26
- * @property {Type} type The type of the property on the {@link RequestOptions} object.
27
- * @property {TypeConverter<FormData|URLSearchParams|Headers>} converter A function that converts the property on the {@link RequestOptions} object to the type T.
28
- */
29
-
30
20
  /**
31
21
  * @typedef {Object} ContextEventHandler
32
22
  * @property {*} context The context object.
@@ -76,22 +66,6 @@ import SignalController from './signal-controller.js';
76
66
  * @property {null} window Can only be null. Used to disassociate request from any Window.
77
67
  */
78
68
 
79
- /** @type {RegExp} */
80
- const endsWithSlashRegEx = /\/$/;
81
- /** @type {string} */
82
- const charset = 'utf-8';
83
-
84
- const _mediaTypes = new Map([
85
- [HttpMediaType.PNG, new MediaType(HttpMediaType.PNG)],
86
- [HttpMediaType.TEXT, new MediaType(HttpMediaType.TEXT, { charset })],
87
- [HttpMediaType.JSON, new MediaType(HttpMediaType.JSON, { charset })],
88
- [HttpMediaType.HTML, new MediaType(HttpMediaType.HTML, { charset })],
89
- [HttpMediaType.JAVA_SCRIPT, new MediaType(HttpMediaType.JAVA_SCRIPT, { charset })],
90
- [HttpMediaType.CSS, new MediaType(HttpMediaType.CSS, { charset })],
91
- [HttpMediaType.XML, new MediaType(HttpMediaType.XML, { charset })],
92
- [HttpMediaType.BIN, new MediaType(HttpMediaType.BIN)]
93
- ]);
94
-
95
69
  /** @type {ResponseHandler<string>} */
96
70
  const _handleText = async (response) => await response.text();
97
71
 
@@ -141,9 +115,6 @@ const _handleHtml = async (response) => new DOMParser().parseFromString(await re
141
115
  /** @type {ResponseHandler<DocumentFragment>} */
142
116
  const _handleHtmlFragment = async (response) => document.createRange().createContextualFragment(await response.text());
143
117
 
144
- /** @type {TypeConverter<FormData|URLSearchParams>} */
145
- const _typeConverter = (data) => Object.fromEntries(Array.from(data.keys()).map((key, index, keys, value = data.getAll(key)) => [key, value.length > 1 ? value : value[0]]));
146
-
147
118
  /**
148
119
  * A wrapper around the fetch API that makes it easier to make HTTP requests.
149
120
  *
@@ -159,32 +130,18 @@ export default class Transportr {
159
130
  #subscribr;
160
131
  /** @type {Subscribr} */
161
132
  static #globalSubscribr = new Subscribr();
162
- /** @type {Array<SignalController>} */
163
- static #activeRequests = [];
164
- /**
165
- * @private
166
- * @static
167
- * @type {SetMultiMap<ResponseHandler<ResponseBody>, string>}
168
- */
133
+ /** @type {Set<AbortSignal>} */
134
+ static #activeRequests = new Set();
135
+ /** @type {SetMultiMap<ResponseHandler<ResponseBody>, string>} */
169
136
  static #contentTypeHandlers = new SetMultiMap([
170
- [_handleImage, _mediaTypes.get(HttpMediaType.PNG).type],
171
- [_handleText, _mediaTypes.get(HttpMediaType.TEXT).type],
172
- [_handleJson, _mediaTypes.get(HttpMediaType.JSON).subtype],
173
- [_handleHtml, _mediaTypes.get(HttpMediaType.HTML).subtype],
174
- [_handleScript, _mediaTypes.get(HttpMediaType.JAVA_SCRIPT).subtype],
175
- [_handleCss, _mediaTypes.get(HttpMediaType.CSS).subtype],
176
- [_handleXml, _mediaTypes.get(HttpMediaType.XML).subtype],
177
- [_handleReadableStream, _mediaTypes.get(HttpMediaType.BIN).subtype]
178
- ]);
179
- /**
180
- * @private
181
- * @static
182
- * @type {Set<PropertyTypeConverter>}
183
- */
184
- static #propertyTypeConverters = new Set([
185
- [{ property: 'body', type: FormData, converter: _typeConverter }],
186
- [{ property: 'searchParams', type: URLSearchParams, converter: _typeConverter }],
187
- [{ property: 'headers', type: Headers, converter: Object.fromEntries }]
137
+ [_handleImage, mediaTypes.get(HttpMediaType.PNG).type],
138
+ [_handleText, mediaTypes.get(HttpMediaType.TEXT).type],
139
+ [_handleJson, mediaTypes.get(HttpMediaType.JSON).subtype],
140
+ [_handleHtml, mediaTypes.get(HttpMediaType.HTML).subtype],
141
+ [_handleScript, mediaTypes.get(HttpMediaType.JAVA_SCRIPT).subtype],
142
+ [_handleCss, mediaTypes.get(HttpMediaType.CSS).subtype],
143
+ [_handleXml, mediaTypes.get(HttpMediaType.XML).subtype],
144
+ [_handleReadableStream, mediaTypes.get(HttpMediaType.BIN).subtype]
188
145
  ]);
189
146
 
190
147
  /**
@@ -193,74 +150,14 @@ export default class Transportr {
193
150
  * @param {URL|string|RequestOptions} [url=location.origin] The URL for {@link fetch} requests.
194
151
  * @param {RequestOptions} [options={}] The default {@link RequestOptions} for this instance.
195
152
  */
196
- constructor(url = location.origin, options = {}) {
197
- const type = _type(url);
198
- if (type == Object) {
199
- options = url;
200
- url = location.origin;
201
- } else if (type != URL) {
202
- url = url.startsWith('/') ? new URL(url, location.origin) : new URL(url);
203
- }
153
+ constructor(url = globalThis.location.origin, options = {}) {
154
+ if (_type(url) == Object) { [ url, options ] = [ globalThis.location.origin, url ] }
204
155
 
205
- this.#baseUrl = url;
206
- // Merge the default options with the provided options.
207
- this.#options = _objectMerge(Transportr.#defaultRequestOptions, Transportr.#convertRequestOptions(options));
156
+ this.#baseUrl = Transportr.#getBaseUrl(url);
157
+ this.#options = Transportr.#createOptions(options, Transportr.#defaultRequestOptions);
208
158
  this.#subscribr = new Subscribr();
209
159
  }
210
160
 
211
- /**
212
- * Returns a {@link SignalController} used for aborting requests.
213
- *
214
- * @static
215
- * @param {AbortSignal} [signal] The optional {@link AbortSignal} to used for chaining.
216
- * @returns {SignalController} A new {@link SignalController} instance.
217
- */
218
- static signalController(signal) {
219
- return new SignalController(signal);
220
- }
221
-
222
- /**
223
- * Returns a {@link EventRegistration} used for subscribing to global events.
224
- *
225
- * @static
226
- * @param {TransportrEvent} event The event to subscribe to.
227
- * @param {function(Event, *): void} handler The event handler.
228
- * @param {*} context The context to bind the handler to.
229
- * @returns {EventRegistration} A new {@link EventRegistration} instance.
230
- */
231
- static register(event, handler, context) {
232
- return Transportr.#globalSubscribr.subscribe(event, handler, context);
233
- }
234
-
235
- /**
236
- * Removes a {@link EventRegistration} from the global event handler.
237
- *
238
- * @static
239
- * @param {EventRegistration} eventRegistration The {@link EventRegistration} to remove.
240
- * @returns {boolean} True if the {@link EventRegistration} was removed, false otherwise.
241
- */
242
- static unregister(eventRegistration) {
243
- return Transportr.#globalSubscribr.unsubscribe(eventRegistration);
244
- }
245
-
246
- /**
247
- * Aborts all active requests.
248
- * This is useful for when the user navigates away from the current page.
249
- * This will also clear the {@link Transportr#activeRequests} array.
250
- * This is called automatically when the {@link Transportr#abort} method is called.
251
- *
252
- * @static
253
- * @returns {void}
254
- */
255
- static abortAll() {
256
- for (const signalController of this.#activeRequests) {
257
- signalController.abort();
258
- }
259
-
260
- // Clear the array after aborting all requests
261
- this.#activeRequests = [];
262
- }
263
-
264
161
  /**
265
162
  * @static
266
163
  * @constant {Object<string, HttpRequestMethod>}
@@ -349,15 +246,7 @@ export default class Transportr {
349
246
  * @static
350
247
  * @constant {Object<string, TransportrEvent>}
351
248
  */
352
- static Events = Object.freeze({
353
- CONFIGURED: 'configured',
354
- SUCCESS: 'success',
355
- ERROR: 'error',
356
- ABORTED: 'aborted',
357
- TIMEOUT: 'timeout',
358
- COMPLETE: 'complete',
359
- ALL_COMPLETE: 'all-complete'
360
- });
249
+ static RequestEvents = RequestEvents;
361
250
 
362
251
  /**
363
252
  * @private
@@ -368,7 +257,7 @@ export default class Transportr {
368
257
  body: null,
369
258
  cache: Transportr.CachingPolicy.NO_STORE,
370
259
  credentials: Transportr.CredentialsPolicy.SAME_ORIGIN,
371
- headers: { [HttpRequestHeader.CONTENT_TYPE]: _mediaTypes.get(HttpMediaType.JSON).toString(), [HttpRequestHeader.ACCEPT]: _mediaTypes.get(HttpMediaType.JSON).toString() },
260
+ headers: { [HttpRequestHeader.CONTENT_TYPE]: mediaTypes.get(HttpMediaType.JSON).toString(), [HttpRequestHeader.ACCEPT]: mediaTypes.get(HttpMediaType.JSON).toString() },
372
261
  searchParams: {},
373
262
  integrity: undefined,
374
263
  keepalive: undefined,
@@ -389,10 +278,61 @@ export default class Transportr {
389
278
  * @type {Map<TransportrEvent, ResponseStatus>}
390
279
  */
391
280
  static #eventResponseStatuses = new Map([
392
- [Transportr.Events.ABORTED, new ResponseStatus(499, 'Aborted')],
393
- [Transportr.Events.TIMEOUT, new ResponseStatus(504, 'Gateway Timeout')]
281
+ [RequestEvents.ABORTED, new ResponseStatus(499, 'Aborted')],
282
+ [RequestEvents.TIMEOUT, new ResponseStatus(504, 'Gateway Timeout')]
394
283
  ]);
395
284
 
285
+ /**
286
+ * Returns a {@link AbortSignal} used for aborting requests.
287
+ *
288
+ * @static
289
+ * @returns {AbortSignal} A new {@link AbortSignal} instance.
290
+ */
291
+ static abortSignal() {
292
+ return new AbortSignal();
293
+ }
294
+
295
+ /**
296
+ * Returns a {@link EventRegistration} used for subscribing to global events.
297
+ *
298
+ * @static
299
+ * @param {TransportrEvent} event The event to subscribe to.
300
+ * @param {function(Event, *): void} handler The event handler.
301
+ * @param {*} context The context to bind the handler to.
302
+ * @returns {EventRegistration} A new {@link EventRegistration} instance.
303
+ */
304
+ static register(event, handler, context) {
305
+ return Transportr.#globalSubscribr.subscribe(event, handler, context);
306
+ }
307
+
308
+ /**
309
+ * Removes a {@link EventRegistration} from the global event handler.
310
+ *
311
+ * @static
312
+ * @param {EventRegistration} eventRegistration The {@link EventRegistration} to remove.
313
+ * @returns {boolean} True if the {@link EventRegistration} was removed, false otherwise.
314
+ */
315
+ static unregister(eventRegistration) {
316
+ return Transportr.#globalSubscribr.unsubscribe(eventRegistration);
317
+ }
318
+
319
+ /**
320
+ * Aborts all active requests.
321
+ * This is useful for when the user navigates away from the current page.
322
+ * This will also clear the {@link Transportr#activeRequests} set.
323
+ *
324
+ * @static
325
+ * @returns {void}
326
+ */
327
+ static abortAll() {
328
+ for (const abortSignal of this.#activeRequests) {
329
+ abortSignal.abort(abortEvent);
330
+ }
331
+
332
+ // Clear the array after aborting all requests
333
+ this.#activeRequests.clear();
334
+ }
335
+
396
336
  /**
397
337
  * It returns the base {@link URL} for the API.
398
338
  *
@@ -500,15 +440,17 @@ export default class Transportr {
500
440
  }
501
441
 
502
442
  /**
503
- * It takes a path and options, and returns a request with the method set to OPTIONS.
443
+ * It returns a promise that resolves to the allowed request methods for the given resource path.
504
444
  *
505
445
  * @async
506
446
  * @param {string} [path] The path to the resource.
507
447
  * @param {RequestOptions} [options] The options for the request.
508
- * @returns {Promise<ResponseBody>} The return value of the #request method.
448
+ * @returns {Promise<string[]>} A promise that resolves to an array of allowed request methods for this resource.
509
449
  */
510
450
  async options(path, options) {
511
- return this.#request(path, options, { method: HttpRequestMethod.OPTIONS });
451
+ const response = await this.#request(path, options, { method: HttpRequestMethod.OPTIONS });
452
+
453
+ return response.headers.get('allow').split(',').map((method) => method.trim());
512
454
  }
513
455
 
514
456
  /**
@@ -516,11 +458,11 @@ export default class Transportr {
516
458
  *
517
459
  * @async
518
460
  * @param {string} [path] The path to the endpoint you want to hit.
519
- * @param {RequestOptions} [options] The options for the request.
461
+ * @param {RequestOptions} [userOptions] The options for the request.
520
462
  * @returns {Promise<ResponseBody>} The return value of the function is the return value of the function that is passed to the `then` method of the promise returned by the `fetch` method.
521
463
  */
522
- async request(path, options) {
523
- return this.#request(path, options);
464
+ async request(path, userOptions) {
465
+ return this.#request(path, userOptions, {}, (response) => response);
524
466
  }
525
467
 
526
468
  /**
@@ -532,7 +474,7 @@ export default class Transportr {
532
474
  * @returns {Promise<JsonObject>} A promise that resolves to the response body as a JSON object.
533
475
  */
534
476
  async getJson(path, options) {
535
- return this.#get(path, options, { headers: { [HttpRequestHeader.ACCEPT]: _mediaTypes.get(HttpMediaType.JSON).toString() } }, _handleJson);
477
+ return this.#get(path, options, { headers: { [HttpRequestHeader.ACCEPT]: mediaTypes.get(HttpMediaType.JSON).toString() } }, _handleJson);
536
478
  }
537
479
 
538
480
  /**
@@ -544,7 +486,7 @@ export default class Transportr {
544
486
  * @returns {Promise<Document>} The result of the function call to #get.
545
487
  */
546
488
  async getXml(path, options) {
547
- return this.#get(path, options, { headers: { [HttpRequestHeader.ACCEPT]: _mediaTypes.get(HttpMediaType.XML).toString() } }, _handleXml);
489
+ return this.#get(path, options, { headers: { [HttpRequestHeader.ACCEPT]: mediaTypes.get(HttpMediaType.XML).toString() } }, _handleXml);
548
490
  }
549
491
 
550
492
  /**
@@ -558,7 +500,7 @@ export default class Transportr {
558
500
  * method of the promise returned by the `#get` method.
559
501
  */
560
502
  async getHtml(path, options) {
561
- return this.#get(path, options, { headers: { [HttpRequestHeader.ACCEPT]: _mediaTypes.get(HttpMediaType.HTML).toString() } }, _handleHtml);
503
+ return this.#get(path, options, { headers: { [HttpRequestHeader.ACCEPT]: mediaTypes.get(HttpMediaType.HTML).toString() } }, _handleHtml);
562
504
  }
563
505
 
564
506
  /**
@@ -571,7 +513,7 @@ export default class Transportr {
571
513
  * @returns {Promise<DocumentFragment>} A promise that resolves to an HTML fragment.
572
514
  */
573
515
  async getHtmlFragment(path, options) {
574
- return this.#get(path, options, { headers: { [HttpRequestHeader.ACCEPT]: _mediaTypes.get(HttpMediaType.HTML).toString() } }, _handleHtmlFragment);
516
+ return this.#get(path, options, { headers: { [HttpRequestHeader.ACCEPT]: mediaTypes.get(HttpMediaType.HTML).toString() } }, _handleHtmlFragment);
575
517
  }
576
518
 
577
519
  /**
@@ -584,7 +526,7 @@ export default class Transportr {
584
526
  * @returns {Promise<void>} A promise that has been resolved.
585
527
  */
586
528
  async getScript(path, options) {
587
- return this.#get(path, options, { headers: { [HttpRequestHeader.ACCEPT]: _mediaTypes.get(HttpMediaType.JAVA_SCRIPT).toString() } }, _handleScript);
529
+ return this.#get(path, options, { headers: { [HttpRequestHeader.ACCEPT]: mediaTypes.get(HttpMediaType.JAVA_SCRIPT).toString() } }, _handleScript);
588
530
  }
589
531
 
590
532
  /**
@@ -596,7 +538,7 @@ export default class Transportr {
596
538
  * @returns {Promise<void>} A promise that has been resolved.
597
539
  */
598
540
  async getStylesheet(path, options) {
599
- return this.#get(path, options, { headers: { [HttpRequestHeader.ACCEPT]: _mediaTypes.get(HttpMediaType.CSS).toString() } }, _handleCss);
541
+ return this.#get(path, options, { headers: { [HttpRequestHeader.ACCEPT]: mediaTypes.get(HttpMediaType.CSS).toString() } }, _handleCss);
600
542
  }
601
543
 
602
544
  /**
@@ -660,9 +602,7 @@ export default class Transportr {
660
602
  * @returns {Promise<ResponseBody>} The result of the #request method.
661
603
  */
662
604
  async #get(path, userOptions, options, responseHandler) {
663
- delete userOptions?.method;
664
-
665
- return this.#request(path, userOptions, options, responseHandler);
605
+ return this.#request(path, userOptions, { ...options, method: HttpRequestMethod.GET }, responseHandler);
666
606
  }
667
607
 
668
608
  /**
@@ -679,67 +619,49 @@ export default class Transportr {
679
619
  * @returns {Promise<ResponseBody>} The response from the API call.
680
620
  */
681
621
  async #request(path, userOptions = {}, options = {}, responseHandler) {
682
- if (_type(path) == Object) {
683
- userOptions = path;
684
- path = undefined;
685
- }
686
-
687
- const requestOptions = _objectMerge(this.#options, Transportr.#convertRequestOptions(userOptions), options);
688
- const url = Transportr.#createUrl(this.#baseUrl, path, requestOptions.searchParams);
689
- const signalController = new SignalController(requestOptions.signal);
690
-
691
- Transportr.#activeRequests.push(signalController);
692
- requestOptions.signal = signalController.signal;
622
+ if (_type(path) == Object) { [ path, userOptions ] = [ undefined, path ] }
693
623
 
694
- if (Transportr.#needsSerialization(requestOptions.method, requestOptions.headers[HttpRequestHeader.CONTENT_TYPE])) {
695
- try {
696
- requestOptions.body = JSON.stringify(requestOptions.body);
697
- } catch (error) {
698
- return Promise.reject(new HttpError(url, { cause: error })); // reject a promise instead of throwing error
699
- }
700
- } else if (requestOptions.method == HttpRequestMethod.GET && requestOptions.headers[HttpRequestHeader.CONTENT_TYPE] != '') {
701
- delete requestOptions.headers[HttpRequestHeader.CONTENT_TYPE];
702
- delete requestOptions.body;
624
+ try {
625
+ options = this.#processRequestOptions(userOptions, options);
626
+ } catch (cause) {
627
+ return Promise.reject(new HttpError('Unable to process request options', { cause }));
703
628
  }
704
629
 
705
- requestOptions.signal.addEventListener('abort', (event) => this.#publish(Transportr.Events.ABORTED, requestOptions.global, event));
706
- requestOptions.signal.addEventListener('timeout', (event) => this.#publish(Transportr.Events.TIMEOUT, requestOptions.global, event));
630
+ this.#publish({ name: RequestEvents.CONFIGURED, data: options, global: options.global });
707
631
 
708
- this.#publish(Transportr.Events.CONFIGURED, requestOptions.global, requestOptions);
632
+ const url = Transportr.#createUrl(this.#baseUrl, path);
633
+ if (_type(options.signal) != AbortSignal) { options.signal = new AbortSignal(options.signal) }
634
+ options.signal.addEventListener(SignalEvents.ABORT, (event) => this.#publish({ name: RequestEvents.ABORTED, event, global: options.global }));
635
+ options.signal.addEventListener(SignalEvents.TIMEOUT, (event) => this.#publish({ name: RequestEvents.TIMEOUT, event, global: options.global }));
709
636
 
710
- let result, timeoutId, response;
637
+ Transportr.#activeRequests.add(options.signal);
711
638
 
639
+ let response, result;
712
640
  try {
713
- timeoutId = setTimeout(() => {
714
- const cause = new DOMException(`The call to '${url}' timed-out after ${requestOptions.timeout / 1000} seconds`, 'TimeoutError');
715
- signalController.abort(cause);
716
- requestOptions.signal.dispatchEvent(new CustomEvent(Transportr.Events.TIMEOUT, { detail: { url, options: requestOptions, cause } }));
717
- }, requestOptions.timeout);
641
+ // Proxy the options and trap for the signal to be accessed to start the timeout timer
642
+ response = await fetch(url, new Proxy(options, { get: Transportr.#requestOptionsProxyHandler(options.timeout) }));
643
+
644
+ if (!responseHandler && response.status != 204 && response.headers.has(HttpResponseHeader.CONTENT_TYPE)) {
645
+ responseHandler = this.#getResponseHandler(response.headers.get(HttpResponseHeader.CONTENT_TYPE));
646
+ }
718
647
 
719
- response = await fetch(url, requestOptions);
648
+ result = await responseHandler?.(response) ?? response;
720
649
 
721
650
  if (!response.ok) {
722
- // reject a promise instead of throwing error
723
- return Promise.reject(this.#handleError(url, { status: Transportr.#generateResponseStatusFromError('ResponseError', response), entity: await this.#processResponse(response, url) }));
651
+ return Promise.reject(this.#handleError(url, { status: Transportr.#generateResponseStatusFromError('ResponseError', response), entity: result }));
724
652
  }
725
-
726
- result = await this.#processResponse(response, url, responseHandler);
727
- this.#publish(Transportr.Events.SUCCESS, requestOptions.global, result);
653
+ this.#publish({ name: RequestEvents.SUCCESS, data: result, global: options.global });
728
654
  } catch (error) {
729
655
  return Promise.reject(this.#handleError(url, { cause: error, status: Transportr.#generateResponseStatusFromError(error.name, response) }));
730
656
  } finally {
731
- clearTimeout(timeoutId);
732
- if (!requestOptions.signal.aborted) {
733
- this.#publish(Transportr.Events.COMPLETE, requestOptions.global, response);
734
-
735
- // Remove the completed request's signalController from the array
736
- const index = Transportr.#activeRequests.indexOf(signalController);
737
- if (index > -1) {
738
- Transportr.#activeRequests.splice(index, 1);
739
- }
657
+ options.signal.clearTimeout();
658
+ if (!options.signal.aborted) {
659
+ this.#publish({ name: RequestEvents.COMPLETE, data: response, global: options.global });
740
660
 
741
- if (Transportr.#activeRequests.length === 0) {
742
- this.#publish(Transportr.Events.ALL_COMPLETE, requestOptions.global, response);
661
+ Transportr.#activeRequests.delete(options.signal);
662
+
663
+ if (Transportr.#activeRequests.size === 0) {
664
+ this.#publish({ name: RequestEvents.ALL_COMPLETE, global: options.global });
743
665
  }
744
666
  }
745
667
  }
@@ -748,86 +670,112 @@ export default class Transportr {
748
670
  }
749
671
 
750
672
  /**
751
- * Handles an error by logging it and throwing it.
673
+ * Creates the options for a {@link Transportr} instance.
752
674
  *
753
675
  * @private
754
- * @param {URL} url The path to the resource you want to access.
755
- * @param {import('./http-error.js').HttpErrorOptions} options The options for the HttpError.
756
- * @returns {HttpError} The HttpError.
676
+ * @static
677
+ * @param {RequestOptions} userOptions The {@link RequestOptions} to convert.
678
+ * @param {RequestOptions} options The default {@link RequestOptions}.
679
+ * @returns {RequestOptions} The converted {@link RequestOptions}.
757
680
  */
758
- #handleError(url, options) {
759
- const error = new HttpError(`An error has occurred with your request to: '${url}'`, options);
760
- this.#publish(Transportr.Events.ERROR, true, error);
761
-
762
- return error;
681
+ static #createOptions({ body, headers: userHeaders, searchParams: userSearchParams, ...userOptions }, { headers, searchParams, ...options }) {
682
+ return _objectMerge(options, userOptions, {
683
+ body: [FormData, URLSearchParams, Object].includes(_type(body)) ? new ParameterMap(body) : body,
684
+ headers: Transportr.#mergeOptions(new Headers(), userHeaders, headers),
685
+ searchParams: Transportr.#mergeOptions(new URLSearchParams(), userSearchParams, searchParams)
686
+ });
763
687
  }
764
688
 
765
689
  /**
766
- * Publishes an event to the global and instance subscribers.
690
+ * Merge the user options and request options into the target.
767
691
  *
768
692
  * @private
769
- * @param {string} eventName The name of the event.
770
- * @param {boolean} global Whether or not to publish the event to the global subscribers.
771
- * @param {Event} [event] The event object.
772
- * @param {*} [data] The data to pass to the subscribers.
773
- * @returns {void}
774
- */
775
- #publish(eventName, global, event, data) {
776
- if (global) {
777
- Transportr.#globalSubscribr.publish(eventName, event, data);
693
+ * @static
694
+ * @param {Headers|URLSearchParams|FormData} target The target to merge the options into.
695
+ * @param {Headers|URLSearchParams|FormData|Object} userOption The user options to merge into the target.
696
+ * @param {Headers|URLSearchParams|FormData|Object} requestOption The request options to merge into the target.
697
+ * @returns {Headers|URLSearchParams} The target.
698
+ */
699
+ static #mergeOptions(target, userOption = {}, requestOption = {}) {
700
+ for (const option of [userOption, requestOption]) {
701
+ for (const [name, value] of option.entries?.() ?? Object.entries(option)) { target.set(name, value) }
778
702
  }
779
- this.#subscribr.publish(eventName, event, data);
703
+
704
+ return target;
780
705
  }
781
706
 
782
707
  /**
783
- * Generates a ResponseStatus object based on the error name and the response.
708
+ * Merges the user options and request options with the instance options into a new object that is used for the request.
784
709
  *
785
710
  * @private
786
- * @static
787
- * @param {string} errorName The name of the error.
788
- * @param {Response} response The response object returned by the fetch API.
789
- * @returns {ResponseStatus} The response status object.
790
- */
791
- static #generateResponseStatusFromError(errorName, response) {
792
- switch (errorName) {
793
- case 'AbortError': return Transportr.#eventResponseStatuses.get(Transportr.Events.ABORTED);
794
- case 'TimeoutError': return Transportr.#eventResponseStatuses.get(Transportr.Events.TIMEOUT);
795
- default: return response ? new ResponseStatus(response.status, response.statusText) : new ResponseStatus(500, 'Internal Server Error');
711
+ * @param {RequestOptions} userOptions The user options to merge into the request options.
712
+ * @param {RequestOptions} options The request options to merge into the user options.
713
+ * @returns {RequestOptions} The merged options.
714
+ */
715
+ #processRequestOptions({ body: userBody, headers: userHeaders, searchParams: userSearchParams, ...userOptions }, { headers, searchParams, ...options }) {
716
+ const requestOptions = _objectMerge(this.#options, userOptions, options, {
717
+ headers: Transportr.#mergeOptions(new Headers(this.#options.headers), userHeaders, headers),
718
+ searchParams: Transportr.#mergeOptions(new URLSearchParams(this.#options.searchParams), userSearchParams, searchParams)
719
+ });
720
+
721
+ if (requestBodyMethods.includes(requestOptions.method)) {
722
+ if ([ParameterMap, FormData, URLSearchParams, Object].includes(_type(userBody))) {
723
+ const contentType = requestOptions.headers.get(HttpRequestHeader.CONTENT_TYPE);
724
+ const mediaType = (mediaTypes.get(contentType) ?? MediaType.parse(contentType))?.subtype;
725
+ if (mediaType == HttpMediaType.MULTIPART_FORM_DATA) {
726
+ requestOptions.body = Transportr.#mergeOptions(new FormData(requestOptions.body), userBody);
727
+ } else if (mediaType == HttpMediaType.FORM) {
728
+ requestOptions.body = Transportr.#mergeOptions(new URLSearchParams(requestOptions.body), userBody);
729
+ } else if (mediaType.includes('json')) {
730
+ requestOptions.body = JSON.stringify(Transportr.#mergeOptions(new ParameterMap(requestOptions.body), userBody));
731
+ } else {
732
+ requestOptions.body = Transportr.#mergeOptions(new ParameterMap(requestOptions.body), userBody);
733
+ }
734
+ } else {
735
+ requestOptions.body = userBody;
736
+ }
737
+ } else {
738
+ requestOptions.headers.delete(HttpRequestHeader.CONTENT_TYPE);
739
+ if (!requestOptions.body?.isEmpty()) {
740
+ Transportr.#mergeOptions(requestOptions.searchParams, requestOptions.body);
741
+ }
742
+ requestOptions.body = undefined;
796
743
  }
744
+
745
+ return requestOptions;
797
746
  }
798
747
 
799
748
  /**
800
- * It takes a response and a handler, and if the handler is not defined, it tries to find a handler
801
- * based on the response's content type
749
+ * Returns a Proxy of the options object that traps for 'signal' access.
750
+ * If the signal has not been aborted, then it will return a new signal with a timeout.
802
751
  *
803
752
  * @private
804
753
  * @static
805
- * @async
806
- * @param {Response} response The response object returned by the fetch API.
807
- * @param {URL} url The path to the resource you want to access. Used for error handling.
808
- * @param {ResponseHandler<ResponseBody>} [handler] The handler to use for processing the response.
809
- * @returns {Promise<ResponseBody>} The response is being returned.
754
+ * @param {number} timeout The timeout in milliseconds before the signal is aborted.
755
+ * @returns {Proxy<RequestOptions>} A proxy for the options object.
810
756
  */
811
- async #processResponse(response, url, handler) {
812
- try {
813
- let mediaType;
814
- if (!handler) {
815
- mediaType = MediaType.parse(response.headers.get(HttpResponseHeader.CONTENT_TYPE));
816
-
817
- if (mediaType) {
818
- for (const [responseHandler, contentTypes] of Transportr.#contentTypeHandlers) {
819
- if (contentTypes.has(mediaType.type) || contentTypes.has(mediaType.subtype)) {
820
- handler = responseHandler;
821
- break;
822
- }
823
- }
824
- }
825
- }
757
+ static #requestOptionsProxyHandler(timeout) {
758
+ return (target, property) => {
759
+ const value = Reflect.get(target, property);
760
+ return property == 'signal' && !value.aborted ? value.withTimeout(timeout) : value;
761
+ };
762
+ }
826
763
 
827
- return (handler ?? _handleText)(response);
828
- } catch (error) {
829
- console.error('Unable to process response.', error, response);
830
- return Promise.reject(this.#handleError(url, { cause: error }));
764
+ /**
765
+ * It takes a url or a string, and returns a {@link URL} instance.
766
+ * If the url is a string and starts with a slash, then the origin of the current page is used as the base url.
767
+ *
768
+ * @private
769
+ * @static
770
+ * @param {URL|string} url The URL to convert to a {@link URL} instance.
771
+ * @returns {URL} A {@link URL} instance.
772
+ * @throws {TypeError} If the url is not a string or {@link URL} instance.
773
+ */
774
+ static #getBaseUrl(url) {
775
+ switch (_type(url)) {
776
+ case URL: return url;
777
+ case String: return new URL(url, url.startsWith('/') ? globalThis.location.origin : undefined);
778
+ default: throw new TypeError('Invalid URL');
831
779
  }
832
780
  }
833
781
 
@@ -838,55 +786,83 @@ export default class Transportr {
838
786
  * @private
839
787
  * @static
840
788
  * @param {URL} url The URL to use as a base.
841
- * @param {string} [path] The path to the resource. This can be a relative path or a full URL.
842
- * @param {Object<string, string>} [searchParams={}] An object containing the query parameters to be added to the URL.
789
+ * @param {string} [path] The optional, relative path to the resource. This MUST be a relative path, otherwise, you should create a new {@link Transportr} instance.
843
790
  * @returns {URL} A new URL object with the pathname and origin of the url parameter, and the path parameter
844
791
  * appended to the end of the pathname.
845
792
  */
846
- static #createUrl(url, path, searchParams = {}) {
847
- let _url;
848
- if (path) {
849
- // Create the object URL with a relative or absolute path
850
- _url = path.startsWith('/') ? new URL(`${url.pathname.replace(endsWithSlashRegEx, '')}${path}`, url.origin) : new URL(path);
851
- } else {
852
- // Create a new URL object from the existing URL
853
- _url = new URL(url);
793
+ static #createUrl(url, path) {
794
+ return path ? new URL(`${url.pathname.replace(endsWithSlashRegEx, '')}${path}`, url.origin) : new URL(url);
795
+ }
796
+
797
+ /**
798
+ * Generates a ResponseStatus object based on the error name and the response.
799
+ *
800
+ * @private
801
+ * @static
802
+ * @param {string} errorName The name of the error.
803
+ * @param {Response} response The response object returned by the fetch API.
804
+ * @returns {ResponseStatus} The response status object.
805
+ */
806
+ static #generateResponseStatusFromError(errorName, response) {
807
+ switch (errorName) {
808
+ case 'AbortError': return Transportr.#eventResponseStatuses.get(RequestEvents.ABORTED);
809
+ case 'TimeoutError': return Transportr.#eventResponseStatuses.get(RequestEvents.TIMEOUT);
810
+ default: return response ? new ResponseStatus(response.status, response.statusText) : new ResponseStatus(500, 'Internal Server Error');
854
811
  }
812
+ }
855
813
 
856
- Object.entries(searchParams).forEach(([key, value]) => _url.searchParams.append(key, value));
814
+ /**
815
+ * Handles an error by logging it and throwing it.
816
+ *
817
+ * @private
818
+ * @param {URL} url The path to the resource you want to access.
819
+ * @param {import('./http-error.js').HttpErrorOptions} options The options for the HttpError.
820
+ * @returns {HttpError} The HttpError.
821
+ */
822
+ #handleError(url, options) {
823
+ const error = new HttpError(`An error has occurred with your request to: '${url}'`, options);
824
+ this.#publish({ name: RequestEvents.ERROR, data: error });
857
825
 
858
- return _url;
826
+ return error;
859
827
  }
860
828
 
861
829
  /**
862
- * If the request method is POST, PUT, or PATCH, and the content type is JSON, then the request body
863
- * needs to be serialized.
830
+ * Publishes an event to the global and instance subscribers.
864
831
  *
865
832
  * @private
866
- * @static
867
- * @param {RequestMethod} method The HTTP request method.
868
- * @param {HttpMediaType} contentType The headers of the request.
869
- * @returns {boolean} `true` if the request body needs to be serialized, `false` otherwise.
833
+ * @param {Object} options The options for the event.
834
+ * @param {string} options.name The name of the event.
835
+ * @param {Event} [options.event] The event object.
836
+ * @param {*} [options.data] The data to pass to the subscribers.
837
+ * @param {boolean} [options.global=true] Whether or not to publish the event to the global subscribers.
838
+ * @returns {void}
870
839
  */
871
- static #needsSerialization(method, contentType) {
872
- return (_mediaTypes.get(contentType) ?? new MediaType(contentType)).essence == HttpMediaType.JSON && [HttpRequestMethod.POST, HttpRequestMethod.PUT, HttpRequestMethod.PATCH].includes(method);
840
+ #publish({ name, event = new CustomEvent(name), data, global = true } = {}) {
841
+ if (global) { Transportr.#globalSubscribr.publish(name, event, data) }
842
+ this.#subscribr.publish(name, event, data);
873
843
  }
874
844
 
875
845
  /**
846
+ * Returns a response handler for the given content type.
876
847
  *
877
- * @param {RequestOptions} options The options passed to the public function to use for the request.
878
- * @returns {RequestOptions} The options to use for the request.
879
- */
880
- static #convertRequestOptions(options) {
881
- if (!_objectIsEmpty(options)) {
882
- for (const [{ property, type, converter }, option = options[property]] of Transportr.#propertyTypeConverters) {
883
- if (option instanceof type) {
884
- options[property] = converter(option);
848
+ * @private
849
+ * @param {string} contentType The content type of the response.
850
+ * @returns {ResponseHandler<ResponseBody>} The response handler.
851
+ */
852
+ #getResponseHandler(contentType) {
853
+ let handler;
854
+ const mediaType = MediaType.parse(contentType);
855
+
856
+ if (mediaType) {
857
+ for (const [responseHandler, contentTypes] of Transportr.#contentTypeHandlers) {
858
+ if (contentTypes.has(mediaType.type) || contentTypes.has(mediaType.subtype)) {
859
+ handler = responseHandler;
860
+ break;
885
861
  }
886
862
  }
887
863
  }
888
864
 
889
- return options;
865
+ return handler;
890
866
  }
891
867
 
892
868
  /**