@d1g1tal/transportr 0.0.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.
@@ -0,0 +1,462 @@
1
+ import { SetMultiMap } from '@d1g1tal/collections';
2
+ import { _objectMerge } from '@d1g1tal/chrysalis';
3
+ import { MediaType } from '@d1g1tal/media-type';
4
+ import HttpMediaType from './http-media-type.js';
5
+ import HttpRequestHeader from './http-request-headers.js';
6
+ import HttpRequestMethod from './http-request-methods.js';
7
+ import HttpResponseHeader from './http-response-headers.js';
8
+
9
+ /**
10
+ * @template T extends ResponseBody
11
+ * @typedef {function(Response): Promise<T>} ResponseHandler<T>
12
+ */
13
+
14
+ /** @typedef {Object<string, (boolean|string|number|Array)>} JsonObject */
15
+ /** @typedef {Blob|ArrayBuffer|TypedArray|DataView|FormData|URLSearchParams|string|ReadableStream} RequestBody */
16
+ /** @typedef {Blob|ArrayBuffer|FormData|string|ReadableStream} ResponseBody */
17
+ /** @typedef {'default'|'force-cache'|'no-cache'|'no-store'|'only-if-cached'|'reload'} RequestCache */
18
+ /** @typedef {'include'|'omit'|'same-origin'} RequestCredentials */
19
+ /** @typedef {Headers|Object<string, string>} RequestHeaders */
20
+ /** @typedef {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'HEAD'|'OPTIONS'} RequestMethod */
21
+ /** @typedef {'cors'|'navigate'|'no-cors'|'same-origin'} RequestMode */
22
+ /** @typedef {'error'|'follow'|'manual'} RequestRedirect */
23
+ /** @typedef {''|'no-referrer'|'no-referrer-when-downgrade'|'origin'|'origin-when-cross-origin'|'same-origin'|'strict-origin'|'strict-origin-when-cross-origin'|'unsafe-url'} ReferrerPolicy */
24
+ /** @typedef {Int8Array|Uint8Array|Uint8ClampedArray|Int16Array|Uint16Array|Int32Array|Uint32Array|Float32Array|Float64Array|BigInt64Array|BigUint64Array} TypedArray */
25
+
26
+ /**
27
+ * The options for a {@link Request} object or the second parameter of a {@link fetch} request
28
+ *
29
+ * @typedef {Object} RequestOptions
30
+ * @property {RequestBody} body A RequestInit object or null to set request's body.
31
+ * @property {RequestCache} cache A string indicating how the request will interact with the browser's cache to set request's cache.
32
+ * @property {RequestCredentials} credentials A string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. Sets request's credentials.
33
+ * @property {RequestHeaders} headers A Headers object, an object literal, or an array of two-item arrays to set request's headers.
34
+ * @property {string} integrity A cryptographic hash of the resource to be fetched by request. Sets request's integrity.
35
+ * @property {boolean} keepalive A boolean to set request's keepalive.
36
+ * @property {RequestMethod} method A string to set request's method.
37
+ * @property {RequestMode} mode A string to indicate whether the request will use CORS, or will be restricted to same-origin URLs. Sets request's mode.
38
+ * @property {RequestRedirect} redirect A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect.
39
+ * @property {string} referrer A string whose value is a same-origin URL, "about:client", or the empty string, to set request's referrer.
40
+ * @property {ReferrerPolicy} referrerPolicy A referrer policy to set request's referrerPolicy.
41
+ * @property {AbortSignal} signal An AbortSignal to set request's signal.
42
+ * @property {null} window Can only be null. Used to disassociate request from any Window.
43
+ */
44
+
45
+ /** @extends Error */
46
+ class HttpError extends Error {}
47
+
48
+ /** @type {RegExp} */
49
+ const endsWithSlashRegEx = /\/$/;
50
+
51
+ /** @type {ResponseHandler<string>} */
52
+ const _handleText = async (response) => await response.text();
53
+
54
+ /** @type {ResponseHandler<JsonObject>} */
55
+ const _handleJson = async (response) => await response.json();
56
+
57
+ /** @type {ResponseHandler<Blob>} */
58
+ const _handleBlob = async (response) => await response.blob();
59
+
60
+ /** @type {ResponseHandler<ArrayBuffer>} */
61
+ const _handleBuffer = async (response) => await response.arrayBuffer();
62
+
63
+ /** @type {ResponseHandler<ReadableStream<Uint8Array>} */
64
+ const _handleReadableStream = async (response) => response.body;
65
+
66
+ /** @type {ResponseHandler<Document>} */
67
+ const _handleXml = async (response) => new DOMParser().parseFromString(await response.text(), Transportr.MediaType.XML.essence);
68
+
69
+ export default class Transportr {
70
+ #baseUrl;
71
+ /**
72
+ * @static
73
+ * @constant {Object<string, MediaType>}
74
+ */
75
+ static #MediaType = {
76
+ JSON: new MediaType(HttpMediaType.JSON),
77
+ XML: new MediaType(HttpMediaType.XML),
78
+ HTML: new MediaType(HttpMediaType.HTML),
79
+ SCRIPT: new MediaType(HttpMediaType.JAVA_SCRIPT),
80
+ TEXT: new MediaType(HttpMediaType.TEXT),
81
+ CSS: new MediaType(HttpMediaType.CSS),
82
+ WEBP: new MediaType(HttpMediaType.WEBP),
83
+ PNG: new MediaType(HttpMediaType.PNG),
84
+ GIF: new MediaType(HttpMediaType.GIF),
85
+ JPG: new MediaType(HttpMediaType.JPEG),
86
+ OTF: new MediaType(HttpMediaType.OTF),
87
+ WOFF: new MediaType(HttpMediaType.WOFF),
88
+ WOFF2: new MediaType(HttpMediaType.WOFF2),
89
+ TTF: new MediaType(HttpMediaType.TTF),
90
+ PDF: new MediaType(HttpMediaType.PDF)
91
+ };
92
+ /**
93
+ * @static
94
+ * @type {SetMultiMap<ResponseHandler<ResponseBody>, string>}
95
+ */
96
+ static #contentTypeHandlers = new SetMultiMap([
97
+ [_handleJson, Transportr.#MediaType.JSON.subtype],
98
+ [_handleText, Transportr.#MediaType.HTML.subtype],
99
+ [_handleText, Transportr.#MediaType.SCRIPT.subtype],
100
+ [_handleText, Transportr.#MediaType.CSS.subtype],
101
+ [_handleText, Transportr.#MediaType.TEXT.subtype],
102
+ [_handleXml, Transportr.#MediaType.XML.subtype],
103
+ [_handleBlob, Transportr.#MediaType.GIF.subtype],
104
+ [_handleBlob, Transportr.#MediaType.JPG.subtype],
105
+ [_handleBlob, Transportr.#MediaType.PNG.subtype]
106
+ ]);
107
+
108
+ /**
109
+ * Create a new Transportr instance with the provided location or origin and context path.
110
+ *
111
+ * @param {URL | string} [url = location.origin] The URL for {@link fetch} requests.
112
+ */
113
+ constructor(url = location.origin) {
114
+ /** @type {URL} */
115
+ this.#baseUrl = url instanceof URL ? url : url.startsWith('/') ? new URL(url, location.origin) : new URL(url);
116
+ }
117
+
118
+ /**
119
+ * @static
120
+ * @constant {Object<string, 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'HEAD'|'OPTIONS'|'TRACE'|'CONNECT'>}
121
+ */
122
+ static Method = Object.freeze(HttpRequestMethod);
123
+
124
+ /**
125
+ * @static
126
+ * @constant {Object<string, string>}
127
+ */
128
+ static MediaType = HttpMediaType;
129
+
130
+ /**
131
+ * @static
132
+ * @constant {Object<string, string>}
133
+ */
134
+ static RequestHeader = HttpRequestHeader;
135
+
136
+ /**
137
+ * @static
138
+ * @constant {Object<string, string>}
139
+ */
140
+ static ResponseHeader = Object.freeze(HttpResponseHeader);
141
+
142
+ static CachingPolicy = {
143
+ DEFAULT: 'default',
144
+ FORCE_CACHE: 'force-cache',
145
+ NO_CACHE: 'no-cache',
146
+ NO_STORE: 'no-store',
147
+ ONLY_IF_CACHED: 'only-if-cached',
148
+ RELOAD: 'reload'
149
+ };
150
+
151
+ /**
152
+ * @static
153
+ * @type {Object<string, string>}
154
+ */
155
+ static CredentialsPolicy = {
156
+ INCLUDE: 'include',
157
+ OMIT: 'omit',
158
+ SAME_ORIGIN: 'same-origin'
159
+ };
160
+
161
+ /** @type {RequestOptions} */
162
+ #defaultRequestOptions = {
163
+ body: null,
164
+ cache: Transportr.CachingPolicy.NO_STORE,
165
+ credentials: 'same-origin',
166
+ headers: {},
167
+ integrity: undefined,
168
+ keepalive: undefined,
169
+ method: undefined,
170
+ mode: 'cors',
171
+ redirect: 'follow',
172
+ referrer: 'about:client',
173
+ referrerPolicy: 'strict-origin-when-cross-origin',
174
+ signal: null,
175
+ window: null
176
+ };
177
+
178
+ /**
179
+ *
180
+ * @returns {URL} The base URL used for requests
181
+ */
182
+ get baseUrl() {
183
+ return this.#baseUrl;
184
+ }
185
+
186
+ /**
187
+ *
188
+ * @async
189
+ * @param {string} path
190
+ * @param {RequestOptions} [options = {}]
191
+ * @returns {Promise<*>}
192
+ */
193
+ async get(path, options = {}) {
194
+ return this.#request(path, _objectMerge(options, { method: HttpRequestMethod.GET }));
195
+ }
196
+
197
+ /**
198
+ *
199
+ * @async
200
+ * @param {string} path
201
+ * @param {Object} body
202
+ * @param {RequestOptions} [options = {}]
203
+ * @returns {Promise<*>}
204
+ */
205
+ async post(path, body, options = {}) {
206
+ return this.#request(path, _objectMerge(options, { body, method: HttpRequestMethod.POST }));
207
+ }
208
+
209
+ /**
210
+ *
211
+ * @async
212
+ * @param {string} path
213
+ * @param {RequestOptions} [options = {}]
214
+ * @returns {Promise<*>}
215
+ */
216
+ async put(path, options = {}) {
217
+ return this.#request(path, _objectMerge(options, { method: HttpRequestMethod.PUT }));
218
+ }
219
+
220
+ /**
221
+ *
222
+ * @async
223
+ * @param {string} path
224
+ * @param {RequestOptions} [options = {}]
225
+ * @returns {Promise<*>}
226
+ */
227
+ async patch(path, options = {}) {
228
+ return this.#request(path, _objectMerge(options, { method: HttpRequestMethod.PATCH }));
229
+ }
230
+
231
+ /**
232
+ *
233
+ * @async
234
+ * @param {string} path
235
+ * @param {RequestOptions} [options = {}]
236
+ * @returns {Promise<*>}
237
+ */
238
+ async delete(path, options = {}) {
239
+ return this.#request(path, _objectMerge(options, { method: HttpRequestMethod.DELETE }));
240
+ }
241
+
242
+ /**
243
+ *
244
+ * @async
245
+ * @param {string} path
246
+ * @param {RequestOptions} [options = {}]
247
+ * @returns {Promise<*>}
248
+ */
249
+ async head(path, options = {}) {
250
+ return this.#request(path, _objectMerge(options, { method: HttpRequestMethod.HEAD }));
251
+ }
252
+
253
+ /**
254
+ *
255
+ * @async
256
+ * @param {string} path
257
+ * @param {RequestOptions} [options = {}]
258
+ * @returns {Promise<*>}
259
+ */
260
+ async options(path, options = {}) {
261
+ return this.#request(path, _objectMerge(options, { method: HttpRequestMethod.OPTIONS }));
262
+ }
263
+
264
+ /**
265
+ *
266
+ * @async
267
+ * @param {string} path
268
+ * @param {RequestOptions} [options = {}]
269
+ * @returns {Promise<*>}
270
+ */
271
+ async request(path, options = {}) {
272
+ return this.#request(path, options);
273
+ }
274
+
275
+ /**
276
+ *
277
+ * @async
278
+ * @param {string} path
279
+ * @param {RequestOptions} [options = {}]
280
+ * @returns {Promise<JsonObject>}
281
+ */
282
+ async getJson(path, options = {}) {
283
+ return this.#get(path, _objectMerge(options, { headers: { [HttpRequestHeader.ACCEPT]: Transportr.MediaType.JSON } }), _handleJson);
284
+ }
285
+
286
+ /**
287
+ *
288
+ * @async
289
+ * @param {string} path
290
+ * @param {RequestOptions} [options = {}]
291
+ * @returns {Promise<Document>}
292
+ */
293
+ async getXml(path, options = {}) {
294
+ return new DOMParser().parseFromString(await this.#get(path, _objectMerge(options, { headers: { [HttpRequestHeader.ACCEPT]: Transportr.MediaType.XML } }), _handleBlob), HttpMediaType.XML);
295
+ }
296
+
297
+ /**
298
+ * TODO - Add way to return portion of the retrieved HTML using a selector. Like jQuery.
299
+ *
300
+ * @async
301
+ * @param {string} path
302
+ * @param {RequestOptions} [options = {}]
303
+ * @returns {Promise<string>}
304
+ */
305
+ async getHtml(path, options = {}) {
306
+ return this.#get(path, _objectMerge(options, { headers: { [HttpRequestHeader.ACCEPT]: HttpMediaType.HTML } }), _handleText);
307
+ }
308
+
309
+ /**
310
+ * TODO - Do I need this? What special handling might this need??
311
+ *
312
+ * @async
313
+ * @param {string} path
314
+ * @param {RequestOptions} [options = {}]
315
+ * @returns {Promise<string>}
316
+ */
317
+ async getScript(path, options = {}) {
318
+ return this.#get(path, _objectMerge(options, { headers: { [HttpRequestHeader.ACCEPT]: HttpMediaType.JAVA_SCRIPT } }), _handleText);
319
+ }
320
+
321
+ /**
322
+ *
323
+ * @async
324
+ * @param {string} path
325
+ * @param {RequestOptions} [options = {}]
326
+ * @returns {Promise<Blob>}
327
+ */
328
+ async getBlob(path, options = {}) {
329
+ return await this.#get(path, _objectMerge(options, { headers: { [HttpRequestHeader.ACCEPT]: HttpMediaType.BIN } }), _handleBlob);
330
+ }
331
+
332
+ /**
333
+ *
334
+ * @async
335
+ * @param {string} path
336
+ * @param {RequestOptions} [options = {}]
337
+ * @returns {Promise<string>}
338
+ */
339
+ async getImage(path, options = {}) {
340
+ return URL.createObjectURL(await this.#get(path, _objectMerge(options, { headers: { [HttpRequestHeader.ACCEPT]: 'image/*' } }), _handleBlob));
341
+ }
342
+
343
+ /**
344
+ *
345
+ * @async
346
+ * @param {string} path
347
+ * @param {RequestOptions} [options = {}]
348
+ * @returns {Promise<ArrayBuffer>}
349
+ */
350
+ async getBuffer(path, options = {}) {
351
+ return await this.#get(path, _objectMerge(options, { headers: { [HttpRequestHeader.ACCEPT]: HttpMediaType.BIN } }), _handleBuffer);
352
+ }
353
+
354
+ /**
355
+ *
356
+ * @async
357
+ * @param {string} path
358
+ * @param {RequestOptions} [options = {}]
359
+ * @returns {Promise<ReadableStream<Uint8Array>}
360
+ */
361
+ async getStream(path, options = {}) {
362
+ return await this.#get(path, _objectMerge(options, { headers: { [HttpRequestHeader.ACCEPT]: HttpMediaType.BIN } }), _handleReadableStream);
363
+ }
364
+
365
+ /**
366
+ *
367
+ * @param {string} path
368
+ * @param {RequestOptions} options
369
+ * @param {ResponseHandler} responseHandler
370
+ * @returns
371
+ */
372
+ async #get(path, options, responseHandler) {
373
+ return this.#request(path, _objectMerge(options, { method: Transportr.Method.GET }), responseHandler);
374
+ }
375
+
376
+ /**
377
+ *
378
+ * @private
379
+ * @async
380
+ * @param {string} path
381
+ * @param {RequestInit} options
382
+ * @param {ResponseHandler<ResponseBody>} [responseHandler]
383
+ * @returns {Promise<ResponseBody|Response>}
384
+ */
385
+ async #request(path, options, responseHandler) {
386
+ console.debug(`Calling '${path}'`);
387
+
388
+ /** @type {RequestOptions} */
389
+ const requestOptions = _objectMerge(this.#defaultRequestOptions, options);
390
+ const headers = new Headers(requestOptions.headers);
391
+
392
+ if (headers.get(Transportr.RequestHeader.CONTENT_TYPE) == Transportr.MediaType.JSON) {
393
+ requestOptions.body = JSON.stringify(requestOptions.body);
394
+ }
395
+
396
+ let response;
397
+ try {
398
+ response = await fetch(Transportr.#createUrl(this.#baseUrl, path, requestOptions.searchParams), requestOptions);
399
+ } catch (error) {
400
+ console.error(error);
401
+ // Need to ensure that the process terminates since an error occurred.
402
+ process.exit(1);
403
+ }
404
+
405
+ if (!response.ok) {
406
+ throw new HttpError(`An error has occurred with your request: ${response.status} - ${await response.text()}`);
407
+ }
408
+
409
+ try {
410
+ return await (responseHandler ? responseHandler(response) : Transportr.#processResponse(response));
411
+ } catch (error) {
412
+ console.error(error);
413
+ // Need to ensure that the process terminates since an error occurred.
414
+ process.exit(1);
415
+ }
416
+ }
417
+
418
+ /**
419
+ *
420
+ * @private
421
+ * @static
422
+ * @param {URL} url
423
+ * @param {string} path
424
+ * @param {Object} [searchParams = {}]
425
+ * @returns {URL}
426
+ */
427
+ static #createUrl(url, path, searchParams = {}) {
428
+ url = new URL(`${url.pathname.replace(endsWithSlashRegEx, '')}${path}`, url.origin);
429
+
430
+ for (const [ name, value ] of Object.entries(searchParams)) {
431
+ if (url.searchParams.has(name)) {
432
+ url.searchParams.set(name, value);
433
+ } else {
434
+ url.searchParams.append(name, value);
435
+ }
436
+ }
437
+
438
+ return url;
439
+ }
440
+
441
+ /**
442
+ *
443
+ * @private
444
+ * @static
445
+ * @async
446
+ * @param {Response} response
447
+ * @returns {Promise<ResponseBody|Response>}
448
+ */
449
+ static async #processResponse(response) {
450
+ const mediaType = new MediaType(response.headers.get(HttpResponseHeader.CONTENT_TYPE));
451
+
452
+ for (const [responseHandler, contentTypes] of Transportr.#contentTypeHandlers.entries()) {
453
+ if (contentTypes.has(mediaType.subtype)) {
454
+ return await responseHandler(response);
455
+ }
456
+ }
457
+
458
+ console.warn('Unable to process response. Unknown content-type or no response handler defined.');
459
+
460
+ return response;
461
+ }
462
+ }