@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 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: string,
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
- #cache = new Map();
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
- const baseURL = opts?.baseURL ?? this.config?.baseURL ?? '';
122
- const responseType = opts?.responseType ?? this.config.responseType;
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 ? { 'X-CSRF-TOKEN': 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
- 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);
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,7 @@
1
+ /**
2
+ * @param {number} ms
3
+ * @returns {import('../index').Plugin}
4
+ */
5
+ export const delayPlugin = (ms) => ({ afterFetch: () => new Promise(r => setTimeout(r,ms))});
6
+
7
+ export const delay = delayPlugin(1000);
@@ -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.1",
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",