@thepassle/app-tools 0.0.1 → 0.0.4

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 CHANGED
@@ -6,4 +6,5 @@ Collection of tools I regularly use to build apps. Maybe they're useful to someb
6
6
 
7
7
  - [`state`](/state/README.md)
8
8
  - [`api`](/api/README.md)
9
+ - [`pwa`](/pwa/README.md)
9
10
  - [`utils`](/utils/README.md)
package/api/index.js CHANGED
@@ -1,16 +1,3 @@
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
1
  function handleStatus(response) {
15
2
  if (!response.ok) {
16
3
  throw new Error(response.statusText);
@@ -18,64 +5,48 @@ function handleStatus(response) {
18
5
  return response;
19
6
  }
20
7
 
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
8
  /**
35
9
  * @typedef {object} Config
36
- * @property {string} [xsrfCookieName=XSRF-TOKEN]
37
- * @property {Plugin[]} [plugins]
38
- * @property {'text'|'json'|'stream'|'blob'|'arrayBuffer'|'formData'|'stream'} [responseType]
10
+ * @property {Plugin[]} [plugins=[]]
11
+ * @property {'text'|'json'|'stream'|'blob'|'arrayBuffer'|'formData'|'stream'} [responseType=json]
39
12
  * @property {string} [baseURL]
40
13
  *
41
14
  * @typedef {(url: string, data?: object, opts?: RequestOptions) => Promise<FetchResponse>} BodyMethod
42
15
  * @typedef {(url: string, opts?: RequestOptions) => Promise<FetchResponse>} BodylessMethod
43
- * @typedef {(url: string) => any} MockFn
44
16
  * @typedef {Response & { [key: string]: any }} FetchResponse
45
17
  * @typedef {'GET'|'DELETE'|'HEAD'|'OPTIONS'|'POST'|'PUT'|'PATCH'} Method
46
18
  *
47
19
  * @typedef {{
48
- * beforeFetch?: (meta: MetaParams) => void,
20
+ * beforeFetch?: (meta: MetaParams) => MetaParams | Promise<MetaParams> | void,
49
21
  * afterFetch?: (res: Response) => Response | Promise<Response>,
22
+ * transform?: (data: any) => any,
23
+ * handleError?: (e: Error) => boolean
50
24
  * }} Plugin
51
25
  *
52
26
  * @typedef {Object} CustomRequestOptions
53
27
  * @property {(data: FetchResponse) => FetchResponse} [transform] - callback to transform the received data
54
28
  * @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
29
  * @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
30
  * @property {Plugin[]} [plugins] - Array of plugins. Plugins can be added on global level, or on a per request basis
62
31
  * @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
32
  *
64
33
  * @typedef {RequestInit & CustomRequestOptions} RequestOptions
65
34
  *
66
35
  * @typedef {{
36
+ * responseType: string,
37
+ * baseURL: string,
67
38
  * url: string,
68
- * method: string,
39
+ * method: Method,
40
+ * headers: Headers,
69
41
  * opts?: RequestOptions,
70
42
  * data?: any,
43
+ * fetchFn: typeof globalThis.fetch
71
44
  * }} MetaParams
72
45
  */
73
46
 
74
-
75
47
  /**
76
48
  * @example
77
49
  * const api = new Api({
78
- * xsrfCookieName: 'XSRF-COOKIE',
79
50
  * baseURL: 'https://api.foo.com/',
80
51
  * responseType: 'text',
81
52
  * plugins: [
@@ -87,15 +58,9 @@ function setAbortableTimeout(fn, ms, {signal}) {
87
58
  *});
88
59
  */
89
60
  export class Api {
90
- #cache = new Map();
91
- #requests = new Map();
92
-
93
- /**
94
- * @param {Config} config
95
- */
61
+ /** @param {Config} config */
96
62
  constructor(config = {}) {
97
63
  this.config = {
98
- xsrfCookieName: 'XSRF-TOKEN',
99
64
  plugins: [],
100
65
  responseType: 'json',
101
66
  ...config
@@ -116,16 +81,12 @@ export class Api {
116
81
  */
117
82
  async fetch(url, method, opts, data) {
118
83
  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
84
 
126
- const headers = new Headers({
85
+ let fetchFn = globalThis.fetch;
86
+ let baseURL = opts?.baseURL ?? this.config?.baseURL ?? '';
87
+ let responseType = opts?.responseType ?? this.config.responseType;
88
+ let headers = new Headers({
127
89
  'Content-Type': 'application/json',
128
- ...(csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}),
129
90
  ...opts?.headers
130
91
  });
131
92
 
@@ -133,93 +94,55 @@ export class Api {
133
94
  url = url.replace(/^(?!.*\/\/)\/?/, baseURL + '/');
134
95
  }
135
96
 
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
97
  if(opts?.params) {
154
98
  url += `${(~url.indexOf('?') ? '&' : '?')}${new URLSearchParams(opts.params)}`;
155
99
  }
156
100
 
157
101
  for(const { beforeFetch } of plugins) {
158
- await beforeFetch?.({ url, method, opts, data });
102
+ const overrides = await beforeFetch?.({ responseType, headers, fetchFn, baseURL, url, method, opts, data });
103
+ if(overrides) {
104
+ ({ responseType, headers, fetchFn, baseURL, url, method, opts, data } = {...overrides});
105
+ }
159
106
  }
160
107
 
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);
108
+ return fetchFn(url, {
109
+ method,
110
+ headers,
111
+ ...(data ? { body: JSON.stringify(data) } : {}),
112
+ ...(opts?.mode ? { mode: opts.mode } : {}),
113
+ ...(opts?.credentials ? { credentials: opts.credentials } : {}),
114
+ ...(opts?.cache ? { cache: opts.cache } : {}),
115
+ ...(opts?.redirect ? { redirect: opts.redirect } : {}),
116
+ ...(opts?.referrer ? { referrer: opts.referrer } : {}),
117
+ ...(opts?.referrerPolicy ? { referrerPolicy: opts.referrerPolicy } : {}),
118
+ ...(opts?.integrity ? { integrity: opts.integrity } : {}),
119
+ ...(opts?.keepalive ? { keepalive: opts.keepalive } : {}),
120
+ ...(opts?.signal ? { signal: opts.signal } : {}),
121
+ })
122
+ /** [PLUGINS - AFTERFETCH] */
123
+ .then(async res => {
124
+ for(const { afterFetch } of plugins) {
125
+ res = await afterFetch?.(res) ?? res;
126
+ }
127
+
128
+ return res;
129
+ })
130
+ /** [STATUS] */
131
+ .then(handleStatus)
132
+ /** [RESPONSETYPE] */
133
+ .then(res => res[responseType]())
134
+ .then(async data => {
135
+ for(const { transform } of plugins) {
136
+ data = await transform?.(data) ?? data;
137
+ }
138
+
139
+ return data;
140
+ })
141
+ /** [PLUGINS - HANDLEERROR] */
142
+ .catch(async e => {
143
+ const shouldThrow = (await Promise.all(plugins.map(({handleError}) => handleError?.(e) ?? false))).some(_ => !!_);
144
+ if(shouldThrow) throw e;
145
+ });
223
146
  }
224
147
 
225
148
  /** @type {BodylessMethod} */
@@ -238,4 +161,4 @@ export class Api {
238
161
  patch = (url, data, opts) => this.fetch(url, 'PATCH', opts, data);
239
162
  }
240
163
 
241
- export const api = new Api();
164
+ export const api = new Api();
@@ -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.4",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -10,13 +10,27 @@
10
10
  },
11
11
  "exports": {
12
12
  "./state.js": "./state.js",
13
+ "./pwa.js": "./pwa.js",
13
14
  "./api.js": "./api.js",
15
+ "./api/plugins/abort.js": "./api/plugins/abort.js",
16
+ "./api/plugins/cache.js": "./api/plugins/cache.js",
17
+ "./api/plugins/delay.js": "./api/plugins/delay.js",
18
+ "./api/plugins/jsonPrefix.js": "./api/plugins/jsonPrefix.js",
19
+ "./api/plugins/mock.js": "./api/plugins/mock.js",
20
+ "./api/plugins/logger.js": "./api/plugins/logger.js",
21
+ "./api/plugins/xsrf.js": "./api/plugins/xsrf.js",
14
22
  "./utils.js": "./utils.js",
15
23
  "./package.json": "./package.json"
16
24
  },
17
25
  "files": [
18
26
  "./api.js",
19
27
  "./api/index.js",
28
+ "./api/plugins/abort.js",
29
+ "./api/plugins/cache.js",
30
+ "./api/plugins/delay.js",
31
+ "./api/plugins/jsonPrefix.js",
32
+ "./api/plugins/mock.js",
33
+ "./api/plugins/logger.js",
20
34
  "./state.js",
21
35
  "./state/index.js",
22
36
  "./utils.js",