@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 ADDED
@@ -0,0 +1,9 @@
1
+ # `@thepassle/app-tools`
2
+
3
+ Collection of tools I regularly use to build apps. Maybe they're useful to somebody else. Maybe not.
4
+
5
+ ## Packages
6
+
7
+ - [`state`](/state/README.md)
8
+ - [`api`](/api/README.md)
9
+ - [`utils`](/utils/README.md)
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
@@ -0,0 +1,2 @@
1
+ export { when } from './utils/index.js';
2
+ export { createService } from './utils/Service.js';