lissa 1.0.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.
@@ -0,0 +1,22 @@
1
+ const basic = (headers = {}, params = {}) => ({
2
+ headers: new Headers(headers),
3
+ params,
4
+ });
5
+
6
+ export default {
7
+ 'adapter': 'fetch',
8
+ 'method': 'get',
9
+ 'headers': new Headers({
10
+ 'Content-Type': 'application/json',
11
+ }),
12
+ 'params': {},
13
+ 'paramsSerializer': 'simple',
14
+ 'urlBuilder': 'simple',
15
+ 'responseType': 'json',
16
+
17
+ 'get': basic(),
18
+ 'post': basic(),
19
+ 'put': basic(),
20
+ 'patch': basic(),
21
+ 'delete': basic(),
22
+ };
@@ -0,0 +1,13 @@
1
+ export class ConnectionError extends Error {
2
+ constructor(...args) {
3
+ super('ConnectionError', ...args);
4
+ this.name = 'ConnectionError';
5
+ }
6
+ }
7
+
8
+ export class ResponseError extends Error {
9
+ constructor(...args) {
10
+ super('ResponseError', ...args);
11
+ this.name = 'ResponseError';
12
+ }
13
+ }
@@ -0,0 +1,182 @@
1
+ import LissaRequest from './request.js';
2
+ import { deepMerge, normalizeOptions } from '../utils/helper.js';
3
+
4
+ const type = Symbol('type');
5
+
6
+ export default class Lissa {
7
+ #options;
8
+ #hooks;
9
+
10
+ static create(...args) {
11
+ if (args[0] && typeof args[0] !== 'string') args.unshift(null);
12
+ const [baseURL = null, options = {}] = args;
13
+
14
+ if (baseURL) options.baseURL = baseURL;
15
+
16
+ return new Lissa(options);
17
+ }
18
+
19
+ constructor(options = {}) {
20
+ this.#options = normalizeOptions(options);
21
+ this.#hooks = new Set();
22
+ }
23
+
24
+ get options() {
25
+ return this.#options;
26
+ }
27
+
28
+ get beforeRequestHooks() {
29
+ return this.#hooks.values().filter(l => l[type] === 'beforeRequest');
30
+ }
31
+
32
+ get beforeFetchHooks() {
33
+ return this.#hooks.values().filter(l => l[type] === 'beforeFetch');
34
+ }
35
+
36
+ get responseHooks() {
37
+ return this.#hooks.values().filter(l => l[type] === 'onResponse');
38
+ }
39
+
40
+ get errorHooks() {
41
+ return this.#hooks.values().filter(l => l[type] === 'onError');
42
+ }
43
+
44
+ use(plugin) {
45
+ plugin(this);
46
+ return this;
47
+ }
48
+
49
+ beforeRequest(hook) {
50
+ hook[type] = 'beforeRequest';
51
+ this.#hooks.add(hook);
52
+ return this;
53
+ }
54
+
55
+ beforeFetch(hook) {
56
+ hook[type] = 'beforeFetch';
57
+ this.#hooks.add(hook);
58
+ return this;
59
+ }
60
+
61
+ onResponse(hook) {
62
+ hook[type] = 'onResponse';
63
+ this.#hooks.add(hook);
64
+ return this;
65
+ }
66
+
67
+ onError(hook) {
68
+ hook[type] = 'onError';
69
+ this.#hooks.add(hook);
70
+ return this;
71
+ }
72
+
73
+ extend(options) {
74
+ options = normalizeOptions(options);
75
+ options = deepMerge(this.#options, options);
76
+
77
+ const newInstance = new Lissa(options);
78
+ newInstance.#hooks = new Set(this.#hooks);
79
+ return newInstance;
80
+ }
81
+
82
+ authenticate(username, password) {
83
+ return this.extend({
84
+ authenticate: { username, password },
85
+ });
86
+ }
87
+
88
+ get(url, options = {}) {
89
+ return LissaRequest.init(this, {
90
+ ...options,
91
+ method: 'get',
92
+ url,
93
+ });
94
+ }
95
+
96
+ post(url, body, options = {}) {
97
+ return LissaRequest.init(this, {
98
+ ...options,
99
+ method: 'post',
100
+ url,
101
+ body,
102
+ });
103
+ }
104
+
105
+ put(url, body, options = {}) {
106
+ return LissaRequest.init(this, {
107
+ ...options,
108
+ method: 'put',
109
+ url,
110
+ body,
111
+ });
112
+ }
113
+
114
+ patch(url, body, options = {}) {
115
+ return LissaRequest.init(this, {
116
+ ...options,
117
+ method: 'patch',
118
+ url,
119
+ body,
120
+ });
121
+ }
122
+
123
+ delete(url, options = {}) {
124
+ return LissaRequest.init(this, {
125
+ ...options,
126
+ method: 'delete',
127
+ url,
128
+ });
129
+ }
130
+
131
+ request(options = {}) {
132
+ return LissaRequest.init(this, { ...options });
133
+ }
134
+
135
+ upload(file, ...args) {
136
+ const url
137
+ = args.find(arg => typeof arg === 'string') || '';
138
+
139
+ const onProgress
140
+ = args.find(arg => typeof arg === 'function') || null;
141
+
142
+ const options
143
+ = args.find(arg => typeof arg === 'object') || {};
144
+
145
+ if (url) options.url = url;
146
+ if (onProgress) options.onUploadProgress = onProgress;
147
+
148
+ // Upload with progress tracking to an http/1.1 server is unfortunately not
149
+ // support yet with fetch in a browser so we set adapter to xhr for now.
150
+ // Upload with process tracking to an http/2 server is experimental. Feel
151
+ // free to test an http/2 upload by setting adapter explicitly to "fetch"
152
+ if (onProgress && !options.adapter && typeof window !== 'undefined') {
153
+ options.adapter = 'xhr';
154
+ }
155
+
156
+ return LissaRequest.init(this, {
157
+ method: 'post',
158
+ ...options,
159
+ body: file,
160
+ });
161
+ }
162
+
163
+ download(...args) {
164
+ const url
165
+ = args.find(arg => typeof arg === 'string') || '';
166
+
167
+ const onProgress
168
+ = args.find(arg => typeof arg === 'function') || null;
169
+
170
+ const options
171
+ = args.find(arg => typeof arg === 'object') || {};
172
+
173
+ if (url) options.url = url;
174
+ if (onProgress) options.onDownloadProgress = onProgress;
175
+
176
+ return LissaRequest.init(this, {
177
+ method: 'get',
178
+ responseType: 'file',
179
+ ...options,
180
+ });
181
+ }
182
+ }
@@ -0,0 +1,488 @@
1
+ import defaults from './defaults.js';
2
+ import { ConnectionError, ResponseError } from './errors.js';
3
+ import OpenPromise from '../utils/OpenPromise.js';
4
+ import { resolveCause, deepMerge, stringifyParams, stringToBase64, throttle } from '../utils/helper.js';
5
+
6
+ export default class LissaRequest extends OpenPromise {
7
+
8
+ static init(lissa, options) {
9
+ const request = new LissaRequest();
10
+ request.#init(lissa, options);
11
+
12
+ const keepStack = new Error();
13
+ keepStack.name = 'Code';
14
+
15
+ setTimeout(async () => {
16
+ try {
17
+ const response = await request.#execute();
18
+ request.resolve(response);
19
+ }
20
+ catch (err) {
21
+ request.reject(resolveCause(err, keepStack));
22
+ }
23
+ }, 1);
24
+
25
+ return request;
26
+ }
27
+
28
+ #lissa;
29
+ #options;
30
+ #locked;
31
+
32
+ #init(lissa, options) {
33
+ this.#lissa = lissa;
34
+
35
+ if (options.headers) options.headers = new Headers(options.headers);
36
+ this.#options = options;
37
+
38
+ this.#locked = false;
39
+ }
40
+
41
+ get options() {
42
+ return this.#options;
43
+ }
44
+
45
+ baseURL(baseURL) {
46
+ return this.#addOptions({ baseURL });
47
+ }
48
+
49
+ url(url) {
50
+ return this.#addOptions({ url });
51
+ }
52
+
53
+ method(method) {
54
+ return this.#addOptions({ method });
55
+ }
56
+
57
+ headers(headers) {
58
+ return this.#addOptions({ headers });
59
+ }
60
+
61
+ authenticate(username, password) {
62
+ return this.#addOptions({
63
+ authenticate: { username, password },
64
+ });
65
+ }
66
+
67
+ params(params) {
68
+ return this.#addOptions({ params });
69
+ }
70
+
71
+ body(body) {
72
+ return this.#addOptions({ body });
73
+ }
74
+
75
+ timeout(timeout) {
76
+ return this.#addOptions({ timeout });
77
+ }
78
+
79
+ signal(signal) {
80
+ return this.#addOptions({ signal });
81
+ }
82
+
83
+ responseType(responseType) {
84
+ return this.#addOptions({ responseType });
85
+ }
86
+
87
+ onUploadProgress(onProgress) {
88
+ return this.#addOptions({ onUploadProgress: onProgress });
89
+ }
90
+
91
+ onDownloadProgress(onProgress) {
92
+ return this.#addOptions({ onDownloadProgress: onProgress });
93
+ }
94
+
95
+ #addOptions(options) {
96
+ if (this.#locked) {
97
+ console.warn(new Error('Request options cannot be changed anymore after execution start!'));
98
+ return;
99
+ }
100
+
101
+ if (options.headers) options.headers = new Headers(options.headers);
102
+ this.#options = deepMerge(this.#options, options);
103
+ return this;
104
+ }
105
+
106
+ async #execute() {
107
+ this.#locked = true;
108
+ const lissa = this.#lissa;
109
+
110
+ // Build options
111
+
112
+ const method = (this.#options.method || lissa.options.method || defaults.method).toLowerCase();
113
+
114
+ let {
115
+ get: _get, // omit
116
+ post: _post, // omit
117
+ put: _put, // omit
118
+ patch: _patch, // omit
119
+ delete: _del, // omit
120
+ ...options
121
+ } = deepMerge(
122
+ defaults,
123
+ defaults[method],
124
+ lissa.options,
125
+ lissa.options[method],
126
+ this.#options,
127
+ );
128
+
129
+ for (const hook of lissa.beforeRequestHooks) {
130
+ options = await hook.call(this, options) || options;
131
+ }
132
+
133
+ const $options = {
134
+ ...options,
135
+ };
136
+
137
+ // Build fetch request
138
+
139
+ if (options.authenticate) {
140
+ const { username, password } = options.authenticate;
141
+ delete options.authenticate;
142
+
143
+ const auth = stringToBase64(`${username}:${password}`);
144
+ options.headers.set('Authorization', `Basic ${auth}`);
145
+ }
146
+
147
+ if (options.params) {
148
+ const params = stringifyParams(options.params, options.paramsSerializer);
149
+ delete options.paramsSerializer;
150
+
151
+ options.params = params ? `?${params}` : '';
152
+ }
153
+
154
+ if (options.body != null) {
155
+ if (options.body instanceof Blob && options.body.type && !this.#options.headers?.has('Content-Type')) {
156
+ options.headers.delete('Content-Type');
157
+ }
158
+ else if (options.body instanceof FormData) {
159
+ options.headers.delete('Content-Type');
160
+ }
161
+ else if (options.body instanceof URLSearchParams) {
162
+ options.headers.set('Content-Type', 'application/x-www-form-urlencoded');
163
+ }
164
+ else if (options.body.constructor === Object) {
165
+ options.body = JSON.stringify(options.body);
166
+ }
167
+ }
168
+ else {
169
+ options.headers.delete('Content-Type');
170
+ }
171
+
172
+ if (options.timeout) {
173
+ if (options.signal) {
174
+ options.signal = AbortSignal.any([
175
+ options.signal,
176
+ AbortSignal.timeout(options.timeout),
177
+ ]);
178
+ }
179
+ else {
180
+ options.signal = AbortSignal.timeout(options.timeout);
181
+ }
182
+
183
+ delete options.timeout;
184
+ }
185
+
186
+ let baseURL, url, params, urlBuilder, adapter;
187
+ ({ baseURL, url, params, urlBuilder, adapter, ...options } = options);
188
+
189
+ const fetchUrl = ((
190
+ baseURL,
191
+ url,
192
+ urlBuilder,
193
+ ) => {
194
+ if (typeof urlBuilder === 'function') return urlBuilder(url, baseURL);
195
+ if (urlBuilder === 'extended') return new URL(url, baseURL || undefined);
196
+ return `${baseURL || ''}${url}`;
197
+ })(
198
+ baseURL,
199
+ `${url || ''}${params || ''}`,
200
+ urlBuilder,
201
+ );
202
+
203
+ options.method = method.toUpperCase();
204
+
205
+ // Actual fetch
206
+ let returnValue = adapter === 'xhr'
207
+ ? await this.#executeXhr({ url: new URL(fetchUrl), ...options })
208
+ : await this.#executeFetch({ url: new URL(fetchUrl), ...options });
209
+
210
+ returnValue.options = $options;
211
+
212
+ // finish
213
+
214
+ const hooks = returnValue instanceof Error
215
+ ? lissa.errorHooks
216
+ : lissa.responseHooks;
217
+
218
+ for (const hook of hooks) {
219
+ const newReturn = await hook.call(this, returnValue);
220
+
221
+ if (newReturn !== undefined) {
222
+ returnValue = newReturn;
223
+ break;
224
+ }
225
+ }
226
+
227
+ if (returnValue instanceof Error) throw returnValue;
228
+ return returnValue;
229
+ }
230
+
231
+ async #executeFetch({ url, responseType, onUploadProgress, onDownloadProgress, ...options }) {
232
+ let request = { url, options };
233
+
234
+ for (const hook of this.#lissa.beforeFetchHooks) {
235
+ request = await hook.call(this, request) || request;
236
+ }
237
+
238
+ let fetchRequest = new Request(request.url, request.options);
239
+
240
+ if (onUploadProgress && fetchRequest.body && request.options.body) {
241
+
242
+ // To track the upload progress we can only do it with Streams and track
243
+ // the byte flow. This only actually track bytes read not bytes send
244
+ // and is overall not a good solution.
245
+
246
+ // In Browsers upload a ReadableStream to an http/1.1 server is
247
+ // also not support yet with fetch. Upload a ReadableStream to an
248
+ // http/2 server is experimental due to the required option duplex which
249
+ // is experimental.
250
+
251
+ // The funny part is fetchRequest.body is already a ReadableStream no
252
+ // matter the http version and fetchRequest.duplex is already 'half'.
253
+ // And setting it again to a ReadableStream requires duplex to be set
254
+ // to 'half' again and now uploading to http/1.1 is not working anymore :D
255
+
256
+ // Pls can we get actual progress events for fetch :)
257
+
258
+ const { body } = request.options;
259
+ const total = body.byteLength || body.size || body.length;
260
+ const trackProgressBridge = createTrackProgressBridge(total, onUploadProgress);
261
+
262
+ fetchRequest = new Request(fetchRequest, {
263
+ duplex: 'half',
264
+ body: fetchRequest.body.pipeThrough(trackProgressBridge),
265
+ });
266
+ }
267
+
268
+ let returnValue;
269
+
270
+ try {
271
+ const response = await fetch(fetchRequest).catch((error) => {
272
+ if (error.name === 'TypeError') {
273
+ throw new ConnectionError({ cause: error });
274
+ }
275
+
276
+ throw error;
277
+ });
278
+
279
+ // Explicit zero content length allows for skipping body parsing
280
+ const hasContent = response.headers.get('Content-Length') !== '0' && !!response.body;
281
+
282
+ let data = hasContent ? response.body : null;
283
+
284
+ if (data && onDownloadProgress) {
285
+ const total = +response.headers.get('Content-Length');
286
+ const trackProgressBridge = createTrackProgressBridge(total, onDownloadProgress);
287
+ data = data.pipeThrough(trackProgressBridge);
288
+ }
289
+
290
+ if (data) {
291
+ switch (responseType) {
292
+ case 'text':
293
+ data = await streamToText(data);
294
+ break;
295
+ case 'json':
296
+ data = await streamToText(data);
297
+ if (!data) break;
298
+
299
+ try {
300
+ data = JSON.parse(data);
301
+ }
302
+ catch (error) {
303
+ data = null;
304
+ console.error(error);
305
+ }
306
+ break;
307
+ case 'file':
308
+ data = await streamToFile(response.headers, data);
309
+ break;
310
+ }
311
+ }
312
+
313
+ returnValue = response.ok ? {} : new ResponseError();
314
+ returnValue.response = response;
315
+ returnValue.headers = response.headers;
316
+ returnValue.status = response.status;
317
+ returnValue.data = hasContent && data || null;
318
+ }
319
+ catch (error) {
320
+ // error should be one of ConnectionError, AbortError or TimeoutError
321
+ returnValue = error;
322
+ }
323
+
324
+ returnValue.request = request;
325
+
326
+ return returnValue;
327
+ }
328
+
329
+ async #executeXhr({ url, ...options }) {
330
+ let request = { url, options };
331
+
332
+ for (const hook of this.#lissa.beforeFetchHooks) {
333
+ request = await hook.call(this, request) || request;
334
+ }
335
+
336
+ ({ url, options } = request);
337
+
338
+ if (options.signal?.aborted) return options.signal.reason || new DOMException('This operation was aborted', 'AbortError');
339
+
340
+ const xhr = new XMLHttpRequest();
341
+ xhr.open(options.method, url);
342
+
343
+ const responsePromise = new Promise((resolve, reject) => {
344
+ xhr.addEventListener('loadend', () => {
345
+ const { status } = xhr;
346
+
347
+ const headers = new Headers();
348
+ xhr.getAllResponseHeaders()?.trim().split(/[\r\n]+/).forEach((line) => {
349
+ const parts = line.split(': ');
350
+ const name = parts.shift();
351
+ const value = parts.join(': ');
352
+ if (name) {
353
+ headers.append(name, value);
354
+ }
355
+ });
356
+
357
+ resolve({
358
+ ok: status === 0 || (status >= 200 && status < 400),
359
+ status,
360
+ headers,
361
+ body: xhr.response,
362
+ });
363
+ });
364
+
365
+ xhr.addEventListener('abort', () => {
366
+ reject(options.signal?.reason || new DOMException('This operation was aborted', 'AbortError'));
367
+ });
368
+
369
+ xhr.addEventListener('error', () => {
370
+ reject(new ConnectionError());
371
+ });
372
+ });
373
+
374
+ options.headers.forEach((value, name) => xhr.setRequestHeader(name, value));
375
+
376
+ if (options.credentials === 'include') xhr.withCredentials = true;
377
+
378
+ if (options.signal) options.signal.addEventListener('abort', () => xhr.abort());
379
+
380
+ xhr.responseType = options.responseType === 'raw' || options.responseType === 'file' ? 'blob' : options.responseType;
381
+
382
+ if (options.onUploadProgress) {
383
+ xhr.upload.addEventListener('progress', (evt) => {
384
+ options.onUploadProgress(evt.loaded, evt.total);
385
+ });
386
+ }
387
+
388
+ if (options.onDownloadProgress) {
389
+ xhr.addEventListener('progress', (evt) => {
390
+ options.onDownloadProgress(evt.loaded, evt.total);
391
+ });
392
+ }
393
+
394
+ let returnValue;
395
+
396
+ try {
397
+ xhr.send(options.body);
398
+
399
+ const response = await responsePromise;
400
+
401
+ returnValue = response.ok ? {} : new ResponseError();
402
+ returnValue.response = xhr;
403
+ returnValue.headers = response.headers;
404
+ returnValue.status = response.status;
405
+ returnValue.data = response.body;
406
+
407
+ if (options.responseType === 'file') {
408
+ const type = response.headers.get('Content-Type');
409
+ const filename = getFilenameFromContentDisposition(response.headers.get('Content-Disposition'));
410
+
411
+ returnValue.data = new File([response.body], filename, {
412
+ 'type': type,
413
+ });
414
+ }
415
+ }
416
+ catch (error) {
417
+ // error should be one of ConnectionError, AbortError or TimeoutError
418
+ returnValue = error;
419
+ }
420
+
421
+ returnValue.request = request;
422
+
423
+ return returnValue;
424
+ }
425
+ }
426
+
427
+ function createTrackProgressBridge(total, onProgress = () => {}) {
428
+ const throttledOnProgress = throttle(onProgress);
429
+
430
+ let bytes = 0;
431
+ return new TransformStream({
432
+ transform(chunk, controller) {
433
+ controller.enqueue(chunk);
434
+ bytes += chunk.byteLength;
435
+ throttledOnProgress(bytes, total);
436
+ },
437
+ });
438
+ }
439
+
440
+ async function streamToFile(headers, stream) {
441
+ const type = headers.get('Content-Type');
442
+ const filename = getFilenameFromContentDisposition(headers.get('Content-Disposition'));
443
+
444
+ const chunks = await streamToBuffer(stream);
445
+
446
+ return new File(chunks, filename, {
447
+ 'type': type,
448
+ });
449
+ }
450
+
451
+ async function streamToText(stream) {
452
+ stream = stream.pipeThrough(new TextDecoderStream());
453
+ const chunks = await streamToBuffer(stream);
454
+
455
+ return chunks.length ? chunks.join('') : null;
456
+ }
457
+
458
+ async function streamToBuffer(stream) {
459
+ const reader = stream.getReader();
460
+ const chunks = [];
461
+ while (true) {
462
+ const { done, value } = await reader.read();
463
+ if (done) break;
464
+ chunks.push(value);
465
+ }
466
+ return chunks;
467
+ }
468
+
469
+ function getFilenameFromContentDisposition(header) {
470
+ if (!header) return null;
471
+
472
+ const parts = header.split(';').slice(1);
473
+
474
+ for (const part of parts) {
475
+ const [key, value] = part.split('=').map(s => s.trim());
476
+
477
+ if (key.toLowerCase() === 'filename' && value) {
478
+ if (value.startsWith('"') && value.endsWith('"')) {
479
+ const inner = value.slice(1, -1);
480
+ return inner.replace(/\\(.)/g, '$1');
481
+ }
482
+
483
+ return value;
484
+ }
485
+ }
486
+
487
+ return null;
488
+ }
package/lib/exports.js ADDED
@@ -0,0 +1,3 @@
1
+ export { default as defaults } from './core/defaults.js';
2
+ export * from './core/errors.js';
3
+ export * from './plugins/index.js';