@thepassle/app-tools 0.0.1 → 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.
- package/api/index.js +58 -123
- package/api/plugins/abort.js +36 -0
- package/api/plugins/cache.js +38 -0
- package/api/plugins/delay.js +7 -0
- package/api/plugins/jsonPrefix.js +26 -0
- package/api/plugins/logger.js +26 -0
- package/api/plugins/mock.js +33 -0
- package/package.json +13 -1
package/api/index.js
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
const TEN_MINUTES = 1000 * 60 * 10;
|
|
2
|
-
|
|
3
1
|
function getCookie(name, _document = document) {
|
|
4
2
|
const match = _document.cookie.match(new RegExp(`(^|;\\s*)(${name})=([^;]*)`));
|
|
5
3
|
return match ? decodeURIComponent(match[3]) : null;
|
|
6
4
|
}
|
|
7
5
|
|
|
8
|
-
function handleAbort(e) {
|
|
9
|
-
if (e.name !== 'AbortError') {
|
|
10
|
-
throw e;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
6
|
function handleStatus(response) {
|
|
15
7
|
if (!response.ok) {
|
|
16
8
|
throw new Error(response.statusText);
|
|
@@ -18,60 +10,46 @@ function handleStatus(response) {
|
|
|
18
10
|
return response;
|
|
19
11
|
}
|
|
20
12
|
|
|
21
|
-
/**
|
|
22
|
-
*
|
|
23
|
-
* @param {() => void} fn
|
|
24
|
-
* @param {number} ms
|
|
25
|
-
* @param {{
|
|
26
|
-
* signal?: AbortSignal
|
|
27
|
-
* }} options
|
|
28
|
-
*/
|
|
29
|
-
function setAbortableTimeout(fn, ms, {signal}) {
|
|
30
|
-
const timeout = setTimeout(fn, ms);
|
|
31
|
-
!signal?.aborted && signal?.addEventListener('abort', () => clearTimeout(timeout), {});
|
|
32
|
-
};
|
|
33
|
-
|
|
34
13
|
/**
|
|
35
14
|
* @typedef {object} Config
|
|
15
|
+
* @property {string} [xsrfHeaderName=X-CSRF-TOKEN]
|
|
36
16
|
* @property {string} [xsrfCookieName=XSRF-TOKEN]
|
|
37
|
-
* @property {Plugin[]} [plugins]
|
|
38
|
-
* @property {'text'|'json'|'stream'|'blob'|'arrayBuffer'|'formData'|'stream'} [responseType]
|
|
17
|
+
* @property {Plugin[]} [plugins=[]]
|
|
18
|
+
* @property {'text'|'json'|'stream'|'blob'|'arrayBuffer'|'formData'|'stream'} [responseType=json]
|
|
39
19
|
* @property {string} [baseURL]
|
|
40
20
|
*
|
|
41
21
|
* @typedef {(url: string, data?: object, opts?: RequestOptions) => Promise<FetchResponse>} BodyMethod
|
|
42
22
|
* @typedef {(url: string, opts?: RequestOptions) => Promise<FetchResponse>} BodylessMethod
|
|
43
|
-
* @typedef {(url: string) => any} MockFn
|
|
44
23
|
* @typedef {Response & { [key: string]: any }} FetchResponse
|
|
45
24
|
* @typedef {'GET'|'DELETE'|'HEAD'|'OPTIONS'|'POST'|'PUT'|'PATCH'} Method
|
|
46
25
|
*
|
|
47
26
|
* @typedef {{
|
|
48
|
-
* beforeFetch?: (meta: MetaParams) => void,
|
|
27
|
+
* beforeFetch?: (meta: MetaParams) => MetaParams | Promise<MetaParams> | void,
|
|
49
28
|
* afterFetch?: (res: Response) => Response | Promise<Response>,
|
|
29
|
+
* transform?: (data: any) => any,
|
|
30
|
+
* handleError?: (e: Error) => boolean
|
|
50
31
|
* }} Plugin
|
|
51
32
|
*
|
|
52
33
|
* @typedef {Object} CustomRequestOptions
|
|
53
34
|
* @property {(data: FetchResponse) => FetchResponse} [transform] - callback to transform the received data
|
|
54
35
|
* @property {'text'|'json'|'stream'|'blob'|'arrayBuffer'|'formData'|'stream'} [responseType] - responseType of the request, will call res[responseType](). Defaults to 'json'
|
|
55
|
-
* @property {boolean} [useAbort] - Whether or not to use an abortSignal to cancel subsequent requests that may get fired in quick succession. Defaults to false
|
|
56
|
-
* @property {boolean} [useCache] - Whether or not to cache responses. Defaults to false. When set to true, it will by default cache a request for 10 minutes. This can be customized via `cacheOptions`
|
|
57
|
-
* @property {(mockParams: MetaParams) => Response} [mock] - Return a custom `new Response` with mock data instead of firing the request. Can be used in combination with `delay` as well. E.g.: (meta) => new Response(JSON.stringify({foo:'bar'}, {status: 200}));
|
|
58
|
-
* @property {{maxAge?: number}} [cacheOptions] - Configure caching options
|
|
59
36
|
* @property {Record<string, string>} [params] - An object to be queryParam-ified and added to the request url
|
|
60
|
-
* @property {number} [delay] - Adds an artifical delay to resolving of the request, useful for testing
|
|
61
37
|
* @property {Plugin[]} [plugins] - Array of plugins. Plugins can be added on global level, or on a per request basis
|
|
62
38
|
* @property {string} [baseURL] - BaseURL to resolve all requests from. Can be set globally, or on a per request basis. When set on a per request basis, will override the globally set baseURL (if set)
|
|
63
39
|
*
|
|
64
40
|
* @typedef {RequestInit & CustomRequestOptions} RequestOptions
|
|
65
41
|
*
|
|
66
42
|
* @typedef {{
|
|
43
|
+
* responseType: string,
|
|
44
|
+
* baseURL: string,
|
|
67
45
|
* url: string,
|
|
68
|
-
* method:
|
|
46
|
+
* method: Method,
|
|
69
47
|
* opts?: RequestOptions,
|
|
70
48
|
* data?: any,
|
|
49
|
+
* fetchFn?: typeof window.fetch
|
|
71
50
|
* }} MetaParams
|
|
72
51
|
*/
|
|
73
52
|
|
|
74
|
-
|
|
75
53
|
/**
|
|
76
54
|
* @example
|
|
77
55
|
* const api = new Api({
|
|
@@ -87,12 +65,7 @@ function setAbortableTimeout(fn, ms, {signal}) {
|
|
|
87
65
|
*});
|
|
88
66
|
*/
|
|
89
67
|
export class Api {
|
|
90
|
-
|
|
91
|
-
#requests = new Map();
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* @param {Config} config
|
|
95
|
-
*/
|
|
68
|
+
/** @param {Config} config */
|
|
96
69
|
constructor(config = {}) {
|
|
97
70
|
this.config = {
|
|
98
71
|
xsrfCookieName: 'XSRF-TOKEN',
|
|
@@ -117,15 +90,15 @@ export class Api {
|
|
|
117
90
|
async fetch(url, method, opts, data) {
|
|
118
91
|
const plugins = [...this.config.plugins, ...(opts?.plugins || [])];
|
|
119
92
|
const csrfToken = getCookie(this.config.xsrfCookieName);
|
|
93
|
+
const xsrfHeaderName = this.config.xsrfHeaderName ?? 'X-CSRF-TOKEN';
|
|
120
94
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const requestKey = `${method}:${url}`;
|
|
95
|
+
let fetchFn = window.fetch;
|
|
96
|
+
let baseURL = opts?.baseURL ?? this.config?.baseURL ?? '';
|
|
97
|
+
let responseType = opts?.responseType ?? this.config.responseType;
|
|
125
98
|
|
|
126
99
|
const headers = new Headers({
|
|
127
100
|
'Content-Type': 'application/json',
|
|
128
|
-
...(csrfToken ? {
|
|
101
|
+
...(csrfToken ? { [xsrfHeaderName]: csrfToken } : {}),
|
|
129
102
|
...opts?.headers
|
|
130
103
|
});
|
|
131
104
|
|
|
@@ -133,93 +106,55 @@ export class Api {
|
|
|
133
106
|
url = url.replace(/^(?!.*\/\/)\/?/, baseURL + '/');
|
|
134
107
|
}
|
|
135
108
|
|
|
136
|
-
if(opts?.useCache) {
|
|
137
|
-
if(this.#cache.has(requestKey)) {
|
|
138
|
-
const cached = this.#cache.get(requestKey);
|
|
139
|
-
if(cached.updatedAt > Date.now() - (opts.cacheOptions?.maxAge || TEN_MINUTES)) {
|
|
140
|
-
return Promise.resolve(cached.data);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if(opts?.useAbort) {
|
|
146
|
-
if(this.#requests.has(requestKey)) {
|
|
147
|
-
const request = this.#requests.get(requestKey);
|
|
148
|
-
request.abort();
|
|
149
|
-
}
|
|
150
|
-
this.#requests.set(requestKey, new AbortController());
|
|
151
|
-
}
|
|
152
|
-
|
|
153
109
|
if(opts?.params) {
|
|
154
110
|
url += `${(~url.indexOf('?') ? '&' : '?')}${new URLSearchParams(opts.params)}`;
|
|
155
111
|
}
|
|
156
112
|
|
|
157
113
|
for(const { beforeFetch } of plugins) {
|
|
158
|
-
await beforeFetch?.({ url, method, opts, data });
|
|
114
|
+
const overrides = await beforeFetch?.({ responseType, fetchFn, baseURL, url, method, opts, data });
|
|
115
|
+
if(overrides) {
|
|
116
|
+
({ responseType, fetchFn, baseURL, url, method, opts, data } = {...overrides});
|
|
117
|
+
}
|
|
159
118
|
}
|
|
160
119
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
?
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
return res;
|
|
202
|
-
})
|
|
203
|
-
/** [STATUS] */
|
|
204
|
-
.then(handleStatus)
|
|
205
|
-
/** [RESPONSETYPE] */
|
|
206
|
-
.then(res => res[opts?.responseType || responseType]())
|
|
207
|
-
/** [TRANSFORM] */
|
|
208
|
-
.then(data => opts?.transform?.(data) ?? data)
|
|
209
|
-
/** [CACHE] */
|
|
210
|
-
.then(data => {
|
|
211
|
-
if(opts?.useCache) {
|
|
212
|
-
this.#cache.set(requestKey, {
|
|
213
|
-
updatedAt: Date.now(),
|
|
214
|
-
data
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
return data;
|
|
218
|
-
})
|
|
219
|
-
/** [DELAY] */
|
|
220
|
-
.then(data => opts?.delay ? new Promise(resolve => setTimeout(() => resolve(data), opts.delay)) : data)
|
|
221
|
-
/** [ABORT] */
|
|
222
|
-
.catch(handleAbort);
|
|
120
|
+
return fetchFn(url, {
|
|
121
|
+
method,
|
|
122
|
+
headers,
|
|
123
|
+
...(data ? { body: JSON.stringify(data) } : {}),
|
|
124
|
+
...(opts?.mode ? { mode: opts.mode } : {}),
|
|
125
|
+
...(opts?.credentials ? { credentials: opts.credentials } : {}),
|
|
126
|
+
...(opts?.cache ? { cache: opts.cache } : {}),
|
|
127
|
+
...(opts?.redirect ? { redirect: opts.redirect } : {}),
|
|
128
|
+
...(opts?.referrer ? { referrer: opts.referrer } : {}),
|
|
129
|
+
...(opts?.referrerPolicy ? { referrerPolicy: opts.referrerPolicy } : {}),
|
|
130
|
+
...(opts?.integrity ? { integrity: opts.integrity } : {}),
|
|
131
|
+
...(opts?.keepalive ? { keepalive: opts.keepalive } : {}),
|
|
132
|
+
...(opts?.signal ? { signal: opts.signal } : {}),
|
|
133
|
+
})
|
|
134
|
+
/** [PLUGINS - AFTERFETCH] */
|
|
135
|
+
.then(async res => {
|
|
136
|
+
for(const { afterFetch } of plugins) {
|
|
137
|
+
res = await afterFetch?.(res) ?? res;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return res;
|
|
141
|
+
})
|
|
142
|
+
/** [STATUS] */
|
|
143
|
+
.then(handleStatus)
|
|
144
|
+
/** [RESPONSETYPE] */
|
|
145
|
+
.then(res => res[responseType]())
|
|
146
|
+
.then(async data => {
|
|
147
|
+
for(const { transform } of plugins) {
|
|
148
|
+
data = await transform?.(data) ?? data;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return data;
|
|
152
|
+
})
|
|
153
|
+
/** [PLUGINS - HANDLEERROR] */
|
|
154
|
+
.catch(async e => {
|
|
155
|
+
const shouldThrow = (await Promise.all(plugins.map(({handleError}) => handleError?.(e) ?? false))).some(_ => !!_);
|
|
156
|
+
if(shouldThrow) throw e;
|
|
157
|
+
});
|
|
223
158
|
}
|
|
224
159
|
|
|
225
160
|
/** @type {BodylessMethod} */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @returns {import('../index').Plugin}
|
|
3
|
+
*/
|
|
4
|
+
export function abortPlugin() {
|
|
5
|
+
let requestId;
|
|
6
|
+
const requests = new Map();
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
beforeFetch: (meta) => {
|
|
10
|
+
const { method, url } = meta;
|
|
11
|
+
requestId = `${method}:${url}`;
|
|
12
|
+
|
|
13
|
+
if(requests.has(requestId)) {
|
|
14
|
+
const request = requests.get(requestId);
|
|
15
|
+
request.abort();
|
|
16
|
+
}
|
|
17
|
+
requests.set(requestId, new AbortController());
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
...meta,
|
|
21
|
+
opts: {
|
|
22
|
+
...meta.opts,
|
|
23
|
+
signal: requests.get(requestId).signal
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
afterFetch: (res) => {
|
|
28
|
+
requests.delete(requestId);
|
|
29
|
+
return res;
|
|
30
|
+
},
|
|
31
|
+
// return true if an error should throw, return false if an error should be ignored
|
|
32
|
+
handleError: ({name}) => name !== 'AbortError'
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const abort = abortPlugin();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const TEN_MINUTES = 1000 * 60 * 10;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {{maxAge?: number}} [options]
|
|
5
|
+
* @returns {import('../index').Plugin}
|
|
6
|
+
*/
|
|
7
|
+
export function cachePlugin({maxAge} = {}) {
|
|
8
|
+
let requestId;
|
|
9
|
+
const cache = new Map();
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
beforeFetch: (meta) => {
|
|
13
|
+
const { method, url } = meta;
|
|
14
|
+
requestId = `${method}:${url}`;
|
|
15
|
+
|
|
16
|
+
if(cache.has(requestId)) {
|
|
17
|
+
const cached = cache.get(requestId);
|
|
18
|
+
if(cached.updatedAt > Date.now() - (maxAge || TEN_MINUTES)) {
|
|
19
|
+
meta.fetchFn = () => Promise.resolve(new Response(JSON.stringify(cached.data), {status: 200}));
|
|
20
|
+
return meta;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
afterFetch: async (res) => {
|
|
25
|
+
const clone = await res.clone();
|
|
26
|
+
const data = await clone.json();
|
|
27
|
+
|
|
28
|
+
cache.set(requestId, {
|
|
29
|
+
updatedAt: Date.now(),
|
|
30
|
+
data
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return res;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const cache = cachePlugin();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} jsonPrefix
|
|
3
|
+
* @returns {import('../index').Plugin}
|
|
4
|
+
*/
|
|
5
|
+
export function jsonPrefixPlugin(jsonPrefix) {
|
|
6
|
+
let responseType;
|
|
7
|
+
return {
|
|
8
|
+
beforeFetch: ({responseType: type}) => {
|
|
9
|
+
responseType = type;
|
|
10
|
+
},
|
|
11
|
+
afterFetch: async (res) => {
|
|
12
|
+
if(jsonPrefix && responseType === 'json') {
|
|
13
|
+
let responseAsText = await res.text();
|
|
14
|
+
|
|
15
|
+
if(responseAsText.startsWith(jsonPrefix)) {
|
|
16
|
+
responseAsText = responseAsText.substring(jsonPrefix.length);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return new Response(responseAsText, res);
|
|
20
|
+
}
|
|
21
|
+
return res;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const jsonPrefix = jsonPrefixPlugin(`)]}',\n`);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {{
|
|
3
|
+
* collapsed?: boolean
|
|
4
|
+
* }} options
|
|
5
|
+
* @returns {import('../index').Plugin}
|
|
6
|
+
*/
|
|
7
|
+
export function loggerPlugin({collapsed = true} = {}) {
|
|
8
|
+
let m;
|
|
9
|
+
let start;
|
|
10
|
+
const group = collapsed ? 'groupCollapsed' : 'group';
|
|
11
|
+
return {
|
|
12
|
+
beforeFetch: (meta) => {
|
|
13
|
+
console[group](`[START] [${new Date().toLocaleTimeString()}] [${meta.method}] "${meta.url}"`);
|
|
14
|
+
console.table([meta]);
|
|
15
|
+
console.groupEnd()
|
|
16
|
+
start = Date.now();
|
|
17
|
+
m = meta;
|
|
18
|
+
},
|
|
19
|
+
afterFetch: (r) => {
|
|
20
|
+
console.log(`[END] [${m.method}] "${m.url}" Request took ${Date.now() - start}ms`);
|
|
21
|
+
return r;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const logger = loggerPlugin();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {() => void} f
|
|
3
|
+
* @param {number} ms
|
|
4
|
+
* @param {{
|
|
5
|
+
* signal?: AbortSignal
|
|
6
|
+
* }} [options]
|
|
7
|
+
*/
|
|
8
|
+
function setAbortableTimeout(f, ms, {signal}) {
|
|
9
|
+
let t;
|
|
10
|
+
if(!signal?.aborted) {
|
|
11
|
+
t = setTimeout(f, ms);
|
|
12
|
+
}
|
|
13
|
+
signal?.addEventListener('abort', () => clearTimeout(t), {once: true});
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {Response | (() => Response) | (() => Promise<Response>)} response
|
|
18
|
+
* @returns {import('../index').Plugin}
|
|
19
|
+
*/
|
|
20
|
+
export function mock(response) {
|
|
21
|
+
return {
|
|
22
|
+
beforeFetch: (meta) => {
|
|
23
|
+
meta.fetchFn = function mock(_, opts) {
|
|
24
|
+
return new Promise(r => setAbortableTimeout(
|
|
25
|
+
() => r(typeof response === 'function' ? response() : response),
|
|
26
|
+
Math.random() * 1000,
|
|
27
|
+
opts?.signal ? { signal: opts.signal } : {}
|
|
28
|
+
))
|
|
29
|
+
}
|
|
30
|
+
return meta;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thepassle/app-tools",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -11,12 +11,24 @@
|
|
|
11
11
|
"exports": {
|
|
12
12
|
"./state.js": "./state.js",
|
|
13
13
|
"./api.js": "./api.js",
|
|
14
|
+
"./api/plugins/abort.js": "./api/plugins/abort.js",
|
|
15
|
+
"./api/plugins/cache.js": "./api/plugins/cache.js",
|
|
16
|
+
"./api/plugins/delay.js": "./api/plugins/delay.js",
|
|
17
|
+
"./api/plugins/jsonPrefix.js": "./api/plugins/jsonPrefix.js",
|
|
18
|
+
"./api/plugins/mock.js": "./api/plugins/mock.js",
|
|
19
|
+
"./api/plugins/logger.js": "./api/plugins/logger.js",
|
|
14
20
|
"./utils.js": "./utils.js",
|
|
15
21
|
"./package.json": "./package.json"
|
|
16
22
|
},
|
|
17
23
|
"files": [
|
|
18
24
|
"./api.js",
|
|
19
25
|
"./api/index.js",
|
|
26
|
+
"./api/plugins/abort.js",
|
|
27
|
+
"./api/plugins/cache.js",
|
|
28
|
+
"./api/plugins/delay.js",
|
|
29
|
+
"./api/plugins/jsonPrefix.js",
|
|
30
|
+
"./api/plugins/mock.js",
|
|
31
|
+
"./api/plugins/logger.js",
|
|
20
32
|
"./state.js",
|
|
21
33
|
"./state/index.js",
|
|
22
34
|
"./utils.js",
|