@thepassle/app-tools 0.0.1
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/README.md +9 -0
- package/api/index.js +241 -0
- package/api.js +1 -0
- package/package.json +39 -0
- package/state/index.js +30 -0
- package/state.js +1 -0
- package/utils/index.js +18 -0
- package/utils.js +2 -0
package/README.md
ADDED
package/api/index.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
const TEN_MINUTES = 1000 * 60 * 10;
|
|
2
|
+
|
|
3
|
+
function getCookie(name, _document = document) {
|
|
4
|
+
const match = _document.cookie.match(new RegExp(`(^|;\\s*)(${name})=([^;]*)`));
|
|
5
|
+
return match ? decodeURIComponent(match[3]) : null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function handleAbort(e) {
|
|
9
|
+
if (e.name !== 'AbortError') {
|
|
10
|
+
throw e;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function handleStatus(response) {
|
|
15
|
+
if (!response.ok) {
|
|
16
|
+
throw new Error(response.statusText);
|
|
17
|
+
}
|
|
18
|
+
return response;
|
|
19
|
+
}
|
|
20
|
+
|
|
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
|
+
/**
|
|
35
|
+
* @typedef {object} Config
|
|
36
|
+
* @property {string} [xsrfCookieName=XSRF-TOKEN]
|
|
37
|
+
* @property {Plugin[]} [plugins]
|
|
38
|
+
* @property {'text'|'json'|'stream'|'blob'|'arrayBuffer'|'formData'|'stream'} [responseType]
|
|
39
|
+
* @property {string} [baseURL]
|
|
40
|
+
*
|
|
41
|
+
* @typedef {(url: string, data?: object, opts?: RequestOptions) => Promise<FetchResponse>} BodyMethod
|
|
42
|
+
* @typedef {(url: string, opts?: RequestOptions) => Promise<FetchResponse>} BodylessMethod
|
|
43
|
+
* @typedef {(url: string) => any} MockFn
|
|
44
|
+
* @typedef {Response & { [key: string]: any }} FetchResponse
|
|
45
|
+
* @typedef {'GET'|'DELETE'|'HEAD'|'OPTIONS'|'POST'|'PUT'|'PATCH'} Method
|
|
46
|
+
*
|
|
47
|
+
* @typedef {{
|
|
48
|
+
* beforeFetch?: (meta: MetaParams) => void,
|
|
49
|
+
* afterFetch?: (res: Response) => Response | Promise<Response>,
|
|
50
|
+
* }} Plugin
|
|
51
|
+
*
|
|
52
|
+
* @typedef {Object} CustomRequestOptions
|
|
53
|
+
* @property {(data: FetchResponse) => FetchResponse} [transform] - callback to transform the received data
|
|
54
|
+
* @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
|
+
* @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
|
+
* @property {Plugin[]} [plugins] - Array of plugins. Plugins can be added on global level, or on a per request basis
|
|
62
|
+
* @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
|
+
*
|
|
64
|
+
* @typedef {RequestInit & CustomRequestOptions} RequestOptions
|
|
65
|
+
*
|
|
66
|
+
* @typedef {{
|
|
67
|
+
* url: string,
|
|
68
|
+
* method: string,
|
|
69
|
+
* opts?: RequestOptions,
|
|
70
|
+
* data?: any,
|
|
71
|
+
* }} MetaParams
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @example
|
|
77
|
+
* const api = new Api({
|
|
78
|
+
* xsrfCookieName: 'XSRF-COOKIE',
|
|
79
|
+
* baseURL: 'https://api.foo.com/',
|
|
80
|
+
* responseType: 'text',
|
|
81
|
+
* plugins: [
|
|
82
|
+
* {
|
|
83
|
+
* beforeFetch: ({url, method, opts, data}) => {},
|
|
84
|
+
* afterFetch: (res) => res,
|
|
85
|
+
* }
|
|
86
|
+
* ]
|
|
87
|
+
*});
|
|
88
|
+
*/
|
|
89
|
+
export class Api {
|
|
90
|
+
#cache = new Map();
|
|
91
|
+
#requests = new Map();
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {Config} config
|
|
95
|
+
*/
|
|
96
|
+
constructor(config = {}) {
|
|
97
|
+
this.config = {
|
|
98
|
+
xsrfCookieName: 'XSRF-TOKEN',
|
|
99
|
+
plugins: [],
|
|
100
|
+
responseType: 'json',
|
|
101
|
+
...config
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @param {Plugin} plugin */
|
|
106
|
+
addPlugin(plugin) {
|
|
107
|
+
this.config.plugins.push(plugin);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} url
|
|
112
|
+
* @param {Method} method
|
|
113
|
+
* @param {RequestOptions} [opts]
|
|
114
|
+
* @param {object} [data]
|
|
115
|
+
* @returns
|
|
116
|
+
*/
|
|
117
|
+
async fetch(url, method, opts, data) {
|
|
118
|
+
const plugins = [...this.config.plugins, ...(opts?.plugins || [])];
|
|
119
|
+
const csrfToken = getCookie(this.config.xsrfCookieName);
|
|
120
|
+
|
|
121
|
+
const baseURL = opts?.baseURL ?? this.config?.baseURL ?? '';
|
|
122
|
+
const responseType = opts?.responseType ?? this.config.responseType;
|
|
123
|
+
|
|
124
|
+
const requestKey = `${method}:${url}`;
|
|
125
|
+
|
|
126
|
+
const headers = new Headers({
|
|
127
|
+
'Content-Type': 'application/json',
|
|
128
|
+
...(csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}),
|
|
129
|
+
...opts?.headers
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if(baseURL) {
|
|
133
|
+
url = url.replace(/^(?!.*\/\/)\/?/, baseURL + '/');
|
|
134
|
+
}
|
|
135
|
+
|
|
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
|
+
if(opts?.params) {
|
|
154
|
+
url += `${(~url.indexOf('?') ? '&' : '?')}${new URLSearchParams(opts.params)}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for(const { beforeFetch } of plugins) {
|
|
158
|
+
await beforeFetch?.({ url, method, opts, data });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const signal = {...(opts?.useAbort
|
|
162
|
+
? { signal: this.#requests.get(requestKey).signal }
|
|
163
|
+
: opts?.signal
|
|
164
|
+
? { signal: opts.signal }
|
|
165
|
+
: {}
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
return (opts?.mock
|
|
169
|
+
? new Promise(res => setAbortableTimeout(
|
|
170
|
+
() => res(opts.mock?.({url, method, opts, data})),
|
|
171
|
+
opts?.delay ?? Math.random() * 1000,
|
|
172
|
+
signal)
|
|
173
|
+
)
|
|
174
|
+
: fetch(url, {
|
|
175
|
+
method,
|
|
176
|
+
headers,
|
|
177
|
+
...(data ? { body: JSON.stringify(data) } : {}),
|
|
178
|
+
...(opts?.mode ? { mode: opts.mode } : {}),
|
|
179
|
+
...(opts?.credentials ? { credentials: opts.credentials } : {}),
|
|
180
|
+
...(opts?.cache ? { cache: opts.cache } : {}),
|
|
181
|
+
...(opts?.redirect ? { redirect: opts.redirect } : {}),
|
|
182
|
+
...(opts?.referrer ? { referrer: opts.referrer } : {}),
|
|
183
|
+
...(opts?.referrerPolicy ? { referrerPolicy: opts.referrerPolicy } : {}),
|
|
184
|
+
...(opts?.integrity ? { integrity: opts.integrity } : {}),
|
|
185
|
+
...(opts?.keepalive ? { keepalive: opts.keepalive } : {}),
|
|
186
|
+
...signal
|
|
187
|
+
}))
|
|
188
|
+
/** [ABORT] */
|
|
189
|
+
.then(res => {
|
|
190
|
+
if(opts?.useAbort) {
|
|
191
|
+
this.#requests.delete(requestKey);
|
|
192
|
+
}
|
|
193
|
+
return res;
|
|
194
|
+
})
|
|
195
|
+
/** [PLUGINS] */
|
|
196
|
+
.then(async res => {
|
|
197
|
+
for(const { afterFetch } of plugins) {
|
|
198
|
+
res = await afterFetch?.(res) ?? res;
|
|
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);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @type {BodylessMethod} */
|
|
226
|
+
get = (url, opts) => this.fetch(url, 'GET', opts);
|
|
227
|
+
/** @type {BodylessMethod} */
|
|
228
|
+
options = (url, opts) => this.fetch(url, 'OPTIONS', opts);
|
|
229
|
+
/** @type {BodylessMethod} */
|
|
230
|
+
delete = (url, opts) => this.fetch(url, 'DELETE', opts);
|
|
231
|
+
/** @type {BodylessMethod} */
|
|
232
|
+
head = (url, opts) => this.fetch(url, 'HEAD', opts);
|
|
233
|
+
/** @type {BodyMethod} */
|
|
234
|
+
post = (url, data, opts) => this.fetch(url, 'POST', opts, data);
|
|
235
|
+
/** @type {BodyMethod} */
|
|
236
|
+
put = (url, data, opts) => this.fetch(url, 'PUT', opts, data);
|
|
237
|
+
/** @type {BodyMethod} */
|
|
238
|
+
patch = (url, data, opts) => this.fetch(url, 'PATCH', opts, data);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export const api = new Api();
|
package/api.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Api, api } from './api/index.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thepassle/app-tools",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "wtr api/api.test.js --node-resolve",
|
|
9
|
+
"test:watch": "npm run test -- --watch"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
"./state.js": "./state.js",
|
|
13
|
+
"./api.js": "./api.js",
|
|
14
|
+
"./utils.js": "./utils.js",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"./api.js",
|
|
19
|
+
"./api/index.js",
|
|
20
|
+
"./state.js",
|
|
21
|
+
"./state/index.js",
|
|
22
|
+
"./utils.js",
|
|
23
|
+
"./utils/index.js"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"state",
|
|
27
|
+
"api",
|
|
28
|
+
"fetch",
|
|
29
|
+
"client",
|
|
30
|
+
"abortcontroller"
|
|
31
|
+
],
|
|
32
|
+
"author": "",
|
|
33
|
+
"license": "ISC",
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@open-wc/testing": "^3.1.6",
|
|
36
|
+
"@web/test-runner": "^0.13.31",
|
|
37
|
+
"sinon": "^14.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/state/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `'state-changed'` event
|
|
3
|
+
* @example this.dispatchEvent(new StateEvent(data));
|
|
4
|
+
*/
|
|
5
|
+
export class StateEvent extends Event {
|
|
6
|
+
constructor(state = {}) {
|
|
7
|
+
super('state-changed');
|
|
8
|
+
this.state = state;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class State extends EventTarget {
|
|
13
|
+
#state;
|
|
14
|
+
|
|
15
|
+
constructor(initialState) {
|
|
16
|
+
super();
|
|
17
|
+
this.#state = initialState;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
setState(state) {
|
|
21
|
+
this.#state = typeof state === 'function' ? state(this.#state) : state;
|
|
22
|
+
this.dispatchEvent(new StateEvent(this.#state));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
getState() {
|
|
26
|
+
return this.#state;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const state = new State({});
|
package/state.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { StateEvent, State, state } from './state/index.js';
|
package/utils/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntax sugar to conditionally render a template
|
|
3
|
+
*
|
|
4
|
+
* @param {boolean} expression
|
|
5
|
+
* @param {() => any} trueValue
|
|
6
|
+
* @param {() => any} [falseValue]
|
|
7
|
+
* @returns {any | undefined}
|
|
8
|
+
*/
|
|
9
|
+
export function when(expression, trueValue, falseValue) {
|
|
10
|
+
if (expression) {
|
|
11
|
+
return trueValue();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (falseValue) {
|
|
15
|
+
return falseValue();
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
package/utils.js
ADDED