@brianbuie/node-kit 0.1.1 → 0.2.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/.dist/Fetcher.d.ts +13 -2
- package/.dist/Fetcher.js +55 -23
- package/package.json +2 -1
- package/src/Fetcher.test.ts +25 -6
- package/src/Fetcher.ts +56 -27
package/.dist/Fetcher.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type Route = string | URL;
|
|
2
|
-
|
|
2
|
+
type QueryVal = string | number | boolean | null | undefined;
|
|
3
|
+
export type Query = Record<string, QueryVal | QueryVal[]>;
|
|
3
4
|
export type FetchOptions = RequestInit & {
|
|
4
5
|
base?: string;
|
|
5
6
|
query?: Query;
|
|
@@ -29,7 +30,16 @@ export declare class Fetcher {
|
|
|
29
30
|
retryDelay?: number;
|
|
30
31
|
};
|
|
31
32
|
constructor(opts?: FetchOptions);
|
|
32
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Build URL with URLSearchParams if query is provided
|
|
35
|
+
* Also returns domain, to help with cookies
|
|
36
|
+
*/
|
|
37
|
+
buildUrl(route: Route, opts?: FetchOptions): [URL, string];
|
|
38
|
+
/**
|
|
39
|
+
* Builds request, merging defaultOptions and provided options
|
|
40
|
+
* Includes Abort signal for timeout
|
|
41
|
+
*/
|
|
42
|
+
buildRequest(route: Route, opts?: FetchOptions): [Request, FetchOptions, string];
|
|
33
43
|
/**
|
|
34
44
|
* Builds and performs the request, merging provided options with defaultOptions
|
|
35
45
|
* If `opts.data` is provided, method is updated to POST, content-type json, data is stringified in the body
|
|
@@ -39,3 +49,4 @@ export declare class Fetcher {
|
|
|
39
49
|
fetchText(route: Route, opts?: FetchOptions): Promise<[string, Response, Request]>;
|
|
40
50
|
fetchJson<T>(route: Route, opts?: FetchOptions): Promise<[T, Response, Request]>;
|
|
41
51
|
}
|
|
52
|
+
export {};
|
package/.dist/Fetcher.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { merge } from 'lodash-es';
|
|
2
|
+
import extractDomain from 'extract-domain';
|
|
2
3
|
/**
|
|
3
4
|
* Fetcher provides a quick way to set up a basic API connection
|
|
4
5
|
* with options applied to every request
|
|
@@ -14,12 +15,49 @@ export class Fetcher {
|
|
|
14
15
|
};
|
|
15
16
|
this.defaultOptions = merge(defaultOptions, opts);
|
|
16
17
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Build URL with URLSearchParams if query is provided
|
|
20
|
+
* Also returns domain, to help with cookies
|
|
21
|
+
*/
|
|
22
|
+
buildUrl(route, opts = {}) {
|
|
23
|
+
const mergedOptions = merge({}, this.defaultOptions, opts);
|
|
24
|
+
const params = [];
|
|
25
|
+
Object.entries(mergedOptions.query || {}).forEach(([key, val]) => {
|
|
26
|
+
if (val === undefined)
|
|
27
|
+
return;
|
|
28
|
+
if (Array.isArray(val)) {
|
|
29
|
+
val.forEach(v => {
|
|
30
|
+
params.push([key, `${v}`]);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
params.push([key, `${val}`]);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
const search = params.length > 0 ? '?' + new URLSearchParams(params).toString() : '';
|
|
38
|
+
const url = new URL(route + search, this.defaultOptions.base);
|
|
39
|
+
const domain = extractDomain(url.href);
|
|
40
|
+
return [url, domain];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Builds request, merging defaultOptions and provided options
|
|
44
|
+
* Includes Abort signal for timeout
|
|
45
|
+
*/
|
|
46
|
+
buildRequest(route, opts = {}) {
|
|
47
|
+
const mergedOptions = merge({}, this.defaultOptions, opts);
|
|
48
|
+
const { query, data, timeout, retries, ...init } = mergedOptions;
|
|
49
|
+
if (data) {
|
|
50
|
+
init.headers = init.headers || {};
|
|
51
|
+
init.headers['content-type'] = init.headers['content-type'] || 'application/json';
|
|
52
|
+
init.method = init.method || 'POST';
|
|
53
|
+
init.body = JSON.stringify(data);
|
|
54
|
+
}
|
|
55
|
+
if (timeout) {
|
|
56
|
+
init.signal = AbortSignal.timeout(timeout);
|
|
57
|
+
}
|
|
58
|
+
const [url, domain] = this.buildUrl(route, mergedOptions);
|
|
59
|
+
const req = new Request(url, init);
|
|
60
|
+
return [req, mergedOptions, domain];
|
|
23
61
|
}
|
|
24
62
|
/**
|
|
25
63
|
* Builds and performs the request, merging provided options with defaultOptions
|
|
@@ -27,19 +65,13 @@ export class Fetcher {
|
|
|
27
65
|
* Retries on local or network error, with increasing backoff
|
|
28
66
|
*/
|
|
29
67
|
async fetch(route, opts = {}) {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const url = this.makeUrl(route, query);
|
|
38
|
-
const attempts = retries + 1;
|
|
39
|
-
let attempted = 0;
|
|
40
|
-
while (attempted < attempts) {
|
|
41
|
-
attempted++;
|
|
42
|
-
const req = new Request(url, { ...o, signal: AbortSignal.timeout(timeout) });
|
|
68
|
+
const [_req, options] = this.buildRequest(route, opts);
|
|
69
|
+
const maxAttempts = (options.retries || 0) + 1;
|
|
70
|
+
let attempt = 0;
|
|
71
|
+
while (attempt < maxAttempts) {
|
|
72
|
+
attempt++;
|
|
73
|
+
// Rebuild request on every attempt to reset AbortSignal.timeout
|
|
74
|
+
const [req] = this.buildRequest(route, opts);
|
|
43
75
|
const res = await fetch(req)
|
|
44
76
|
.then(r => {
|
|
45
77
|
if (!r.ok)
|
|
@@ -47,16 +79,16 @@ export class Fetcher {
|
|
|
47
79
|
return r;
|
|
48
80
|
})
|
|
49
81
|
.catch(async (error) => {
|
|
50
|
-
if (
|
|
51
|
-
const wait =
|
|
52
|
-
console.warn(
|
|
82
|
+
if (attempt < maxAttempts) {
|
|
83
|
+
const wait = attempt * 3000;
|
|
84
|
+
console.warn(`${req.method} ${req.url} (attempt ${attempt} of ${maxAttempts})`, error);
|
|
53
85
|
await new Promise(resolve => setTimeout(resolve, wait));
|
|
54
86
|
}
|
|
55
87
|
});
|
|
56
88
|
if (res)
|
|
57
89
|
return [res, req];
|
|
58
90
|
}
|
|
59
|
-
throw new Error(`Failed to fetch ${url
|
|
91
|
+
throw new Error(`Failed to fetch ${_req.url}`);
|
|
60
92
|
}
|
|
61
93
|
async fetchText(route, opts = {}) {
|
|
62
94
|
return this.fetch(route, opts).then(async ([res, req]) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brianbuie/node-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"@types/lodash-es": "^4.17.12",
|
|
30
30
|
"@types/node": "^24.9.1",
|
|
31
31
|
"chalk": "^5.6.2",
|
|
32
|
+
"extract-domain": "^5.0.2",
|
|
32
33
|
"jsonwebtoken": "^9.0.2",
|
|
33
34
|
"lodash-es": "^4.17.21"
|
|
34
35
|
},
|
package/src/Fetcher.test.ts
CHANGED
|
@@ -7,36 +7,55 @@ describe('Fetcher', () => {
|
|
|
7
7
|
|
|
8
8
|
it('should make URL', () => {
|
|
9
9
|
const route = '/example/route';
|
|
10
|
-
const url = statusApi.
|
|
10
|
+
const [url] = statusApi.buildUrl(route);
|
|
11
11
|
assert(url.href.includes(route));
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
it('should throw when
|
|
14
|
+
it('should throw when URL is invalid', () => {
|
|
15
15
|
try {
|
|
16
16
|
const empty = new Fetcher();
|
|
17
|
-
empty.
|
|
17
|
+
empty.buildUrl('/');
|
|
18
18
|
} catch (e) {
|
|
19
19
|
return;
|
|
20
20
|
}
|
|
21
21
|
throw new Error('Ignored invalid URL');
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
it('should identify the correct domain', () => {
|
|
25
|
+
const test = new Fetcher({ base: 'https://subdomain.example.org' });
|
|
26
|
+
const [_, domain] = test.buildUrl('');
|
|
27
|
+
assert(domain === 'example.org');
|
|
28
|
+
});
|
|
29
|
+
|
|
24
30
|
it('should handle query parameters', () => {
|
|
25
|
-
const url = statusApi.
|
|
31
|
+
const [url] = statusApi.buildUrl('/', { query: { key: 'value' } });
|
|
26
32
|
assert(url.href.includes('?key=value'));
|
|
27
33
|
});
|
|
28
34
|
|
|
29
35
|
it('should handle undefined query params', () => {
|
|
30
|
-
const url = statusApi.
|
|
36
|
+
const [url] = statusApi.buildUrl('/', { query: { key: undefined } });
|
|
31
37
|
assert(!url.href.includes('key'));
|
|
32
38
|
});
|
|
33
39
|
|
|
40
|
+
it('should keep falsey query params', () => {
|
|
41
|
+
const [url] = statusApi.buildUrl('/', { query: { zero: 0, false: false, null: null } });
|
|
42
|
+
assert(url.href.includes('zero=0'));
|
|
43
|
+
assert(url.href.includes('false=false'));
|
|
44
|
+
assert(url.href.includes('null=null'));
|
|
45
|
+
});
|
|
46
|
+
|
|
34
47
|
it('should handle no query params', () => {
|
|
35
48
|
const route = '/';
|
|
36
|
-
const url = statusApi.
|
|
49
|
+
const [url] = statusApi.buildUrl(route);
|
|
37
50
|
assert(url.href === statusApi.defaultOptions.base + route);
|
|
38
51
|
});
|
|
39
52
|
|
|
53
|
+
it('should handle array query param', () => {
|
|
54
|
+
const [url] = statusApi.buildUrl('/', { query: { multiple: [1, 2] } });
|
|
55
|
+
assert(url.href.includes('multiple=1'));
|
|
56
|
+
assert(url.href.includes('multiple=2'));
|
|
57
|
+
});
|
|
58
|
+
|
|
40
59
|
it('should throw on bad request', async () => {
|
|
41
60
|
try {
|
|
42
61
|
await statusApi.fetch('/404', { retries: 0 });
|
package/src/Fetcher.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { merge } from 'lodash-es';
|
|
2
|
+
import extractDomain from 'extract-domain';
|
|
2
3
|
|
|
3
4
|
export type Route = string | URL;
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
type QueryVal = string | number | boolean | null | undefined;
|
|
7
|
+
export type Query = Record<string, QueryVal | QueryVal[]>;
|
|
6
8
|
|
|
7
9
|
export type FetchOptions = RequestInit & {
|
|
8
10
|
base?: string;
|
|
@@ -31,14 +33,48 @@ export class Fetcher {
|
|
|
31
33
|
this.defaultOptions = merge(defaultOptions, opts);
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Build URL with URLSearchParams if query is provided
|
|
38
|
+
* Also returns domain, to help with cookies
|
|
39
|
+
*/
|
|
40
|
+
buildUrl(route: Route, opts: FetchOptions = {}): [URL, string] {
|
|
41
|
+
const mergedOptions = merge({}, this.defaultOptions, opts);
|
|
42
|
+
const params: [string, string][] = [];
|
|
43
|
+
Object.entries(mergedOptions.query || {}).forEach(([key, val]) => {
|
|
44
|
+
if (val === undefined) return;
|
|
45
|
+
if (Array.isArray(val)) {
|
|
46
|
+
val.forEach(v => {
|
|
47
|
+
params.push([key, `${v}`]);
|
|
48
|
+
});
|
|
49
|
+
} else {
|
|
50
|
+
params.push([key, `${val}`]);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const search = params.length > 0 ? '?' + new URLSearchParams(params).toString() : '';
|
|
54
|
+
const url = new URL(route + search, this.defaultOptions.base);
|
|
55
|
+
const domain = extractDomain(url.href) as string;
|
|
56
|
+
return [url, domain];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Builds request, merging defaultOptions and provided options
|
|
61
|
+
* Includes Abort signal for timeout
|
|
62
|
+
*/
|
|
63
|
+
buildRequest(route: Route, opts: FetchOptions = {}): [Request, FetchOptions, string] {
|
|
64
|
+
const mergedOptions = merge({}, this.defaultOptions, opts);
|
|
65
|
+
const { query, data, timeout, retries, ...init } = mergedOptions;
|
|
66
|
+
if (data) {
|
|
67
|
+
init.headers = init.headers || {};
|
|
68
|
+
init.headers['content-type'] = init.headers['content-type'] || 'application/json';
|
|
69
|
+
init.method = init.method || 'POST';
|
|
70
|
+
init.body = JSON.stringify(data);
|
|
71
|
+
}
|
|
72
|
+
if (timeout) {
|
|
73
|
+
init.signal = AbortSignal.timeout(timeout);
|
|
74
|
+
}
|
|
75
|
+
const [url, domain] = this.buildUrl(route, mergedOptions);
|
|
76
|
+
const req = new Request(url, init);
|
|
77
|
+
return [req, mergedOptions, domain];
|
|
42
78
|
}
|
|
43
79
|
|
|
44
80
|
/**
|
|
@@ -47,35 +83,28 @@ export class Fetcher {
|
|
|
47
83
|
* Retries on local or network error, with increasing backoff
|
|
48
84
|
*/
|
|
49
85
|
async fetch(route: Route, opts: FetchOptions = {}): Promise<[Response, Request]> {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const url = this.makeUrl(route, query);
|
|
58
|
-
|
|
59
|
-
const attempts = retries + 1;
|
|
60
|
-
let attempted = 0;
|
|
61
|
-
while (attempted < attempts) {
|
|
62
|
-
attempted++;
|
|
63
|
-
const req = new Request(url, { ...o, signal: AbortSignal.timeout(timeout) });
|
|
86
|
+
const [_req, options] = this.buildRequest(route, opts);
|
|
87
|
+
const maxAttempts = (options.retries || 0) + 1;
|
|
88
|
+
let attempt = 0;
|
|
89
|
+
while (attempt < maxAttempts) {
|
|
90
|
+
attempt++;
|
|
91
|
+
// Rebuild request on every attempt to reset AbortSignal.timeout
|
|
92
|
+
const [req] = this.buildRequest(route, opts);
|
|
64
93
|
const res = await fetch(req)
|
|
65
94
|
.then(r => {
|
|
66
95
|
if (!r.ok) throw new Error(r.statusText);
|
|
67
96
|
return r;
|
|
68
97
|
})
|
|
69
98
|
.catch(async error => {
|
|
70
|
-
if (
|
|
71
|
-
const wait =
|
|
72
|
-
console.warn(
|
|
99
|
+
if (attempt < maxAttempts) {
|
|
100
|
+
const wait = attempt * 3000;
|
|
101
|
+
console.warn(`${req.method} ${req.url} (attempt ${attempt} of ${maxAttempts})`, error);
|
|
73
102
|
await new Promise(resolve => setTimeout(resolve, wait));
|
|
74
103
|
}
|
|
75
104
|
});
|
|
76
105
|
if (res) return [res, req];
|
|
77
106
|
}
|
|
78
|
-
throw new Error(`Failed to fetch ${url
|
|
107
|
+
throw new Error(`Failed to fetch ${_req.url}`);
|
|
79
108
|
}
|
|
80
109
|
|
|
81
110
|
async fetchText(route: Route, opts: FetchOptions = {}): Promise<[string, Response, Request]> {
|