@brianbuie/node-kit 0.1.0 → 0.2.0

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.
@@ -1,5 +1,5 @@
1
1
  export type Route = string | URL;
2
- export type Query = Record<string, string | number | boolean | undefined>;
2
+ export type Query = Record<string, string | number | boolean | null | undefined>;
3
3
  export type FetchOptions = RequestInit & {
4
4
  base?: string;
5
5
  query?: Query;
@@ -29,7 +29,16 @@ export declare class Fetcher {
29
29
  retryDelay?: number;
30
30
  };
31
31
  constructor(opts?: FetchOptions);
32
- makeUrl(route: Route, query?: Query): URL;
32
+ /**
33
+ * Build URL with URLSearchParams if query is provided
34
+ * Also returns domain, to help with cookies
35
+ */
36
+ buildUrl(route: Route, opts?: FetchOptions): [URL, string];
37
+ /**
38
+ * Builds request, merging defaultOptions and provided options
39
+ * Includes Abort signal for timeout
40
+ */
41
+ buildRequest(route: Route, opts?: FetchOptions): [Request, FetchOptions, string];
33
42
  /**
34
43
  * Builds and performs the request, merging provided options with defaultOptions
35
44
  * If `opts.data` is provided, method is updated to POST, content-type json, data is stringified in the body
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,39 @@ export class Fetcher {
14
15
  };
15
16
  this.defaultOptions = merge(defaultOptions, opts);
16
17
  }
17
- makeUrl(route, query = {}) {
18
- const params = new URLSearchParams(Object.entries(query)
19
- .filter(([_k, val]) => Boolean(val))
20
- .map(([key, val]) => [key, `${val}`])).toString();
21
- const search = params ? '?' + params : '';
22
- return new URL(route + search, this.defaultOptions.base);
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 = Object.entries(mergedOptions.query || {})
25
+ .filter(([_k, val]) => val !== undefined)
26
+ .map(([key, val]) => [key, `${val}`]);
27
+ const search = params.length > 0 ? '?' + new URLSearchParams(params).toString() : '';
28
+ const url = new URL(route + search, this.defaultOptions.base);
29
+ const domain = extractDomain(url.href);
30
+ return [url, domain];
31
+ }
32
+ /**
33
+ * Builds request, merging defaultOptions and provided options
34
+ * Includes Abort signal for timeout
35
+ */
36
+ buildRequest(route, opts = {}) {
37
+ const mergedOptions = merge({}, this.defaultOptions, opts);
38
+ const { query, data, timeout, retries, ...init } = mergedOptions;
39
+ if (data) {
40
+ init.headers = init.headers || {};
41
+ init.headers['content-type'] = init.headers['content-type'] || 'application/json';
42
+ init.method = init.method || 'POST';
43
+ init.body = JSON.stringify(data);
44
+ }
45
+ if (timeout) {
46
+ init.signal = AbortSignal.timeout(timeout);
47
+ }
48
+ const [url, domain] = this.buildUrl(route, mergedOptions);
49
+ const req = new Request(url, init);
50
+ return [req, mergedOptions, domain];
23
51
  }
24
52
  /**
25
53
  * Builds and performs the request, merging provided options with defaultOptions
@@ -27,19 +55,13 @@ export class Fetcher {
27
55
  * Retries on local or network error, with increasing backoff
28
56
  */
29
57
  async fetch(route, opts = {}) {
30
- const { query, data, timeout, retries, ...o } = merge({}, this.defaultOptions, opts);
31
- if (data) {
32
- o.headers = o.headers || {};
33
- o.headers['content-type'] = o.headers['content-type'] || 'application/json';
34
- o.method = o.method || 'POST';
35
- o.body = JSON.stringify(data);
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) });
58
+ const [_req, options] = this.buildRequest(route, opts);
59
+ const maxAttempts = (options.retries || 0) + 1;
60
+ let attempt = 0;
61
+ while (attempt < maxAttempts) {
62
+ attempt++;
63
+ // Rebuild request on every attempt to reset AbortSignal.timeout
64
+ const [req] = this.buildRequest(route, opts);
43
65
  const res = await fetch(req)
44
66
  .then(r => {
45
67
  if (!r.ok)
@@ -47,16 +69,16 @@ export class Fetcher {
47
69
  return r;
48
70
  })
49
71
  .catch(async (error) => {
50
- if (attempted < attempts) {
51
- const wait = attempted * 3000;
52
- console.warn(`Fetcher (attempt ${attempted} of ${attempts})`, error);
72
+ if (attempt < maxAttempts) {
73
+ const wait = attempt * 3000;
74
+ console.warn(`${req.method} ${req.url} (attempt ${attempt} of ${maxAttempts})`, error);
53
75
  await new Promise(resolve => setTimeout(resolve, wait));
54
76
  }
55
77
  });
56
78
  if (res)
57
79
  return [res, req];
58
80
  }
59
- throw new Error(`Failed to fetch ${url.href}`);
81
+ throw new Error(`Failed to fetch ${_req.url}`);
60
82
  }
61
83
  async fetchText(route, opts = {}) {
62
84
  return this.fetch(route, opts).then(async ([res, req]) => {
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
- # Node Kit
1
+ # Node Kit • ![NPM Version](https://img.shields.io/npm/v/%40brianbuie%2Fnode-kit)
2
2
 
3
3
  Basic tools for quick node.js projects
4
4
 
5
- ## Using in other projects
5
+ ## Installing
6
6
 
7
7
  ```
8
8
  npm add @brianbuie/node-kit
@@ -12,17 +12,100 @@ npm add @brianbuie/node-kit
12
12
  import { thing } from '@brianbuie/node-kit';
13
13
  ```
14
14
 
15
+ ## Features
16
+
17
+ ### Fetcher
18
+
19
+ ```ts
20
+ import { Fetcher } from '@brianbuie/node-kit';
21
+
22
+ // All requests will include Authorization header
23
+ const api = new Fetcher({
24
+ base: 'https://www.example.com',
25
+ headers: {
26
+ Authorization: `Bearer ${process.env.EXAMPLE_SECRET}`,
27
+ },
28
+ });
29
+
30
+ // GET https://www.example.com/route
31
+ // returns [Response, Request]
32
+ const [res] = await api.fetch('/route');
33
+
34
+ // GET https://www.example.com/other-route
35
+ // returns [string, Response, Request]
36
+ const [text] = await api.fetchText('/other-route');
37
+
38
+ // GET https://www.example.com/thing?page=1
39
+ // returns [Thing, Response, Request]
40
+ const [data] = await api.fetchJson<Thing>('/thing', { query: { page: 1 } });
41
+
42
+ // POST https://www.example.com/thing (data is sent as JSON in body)
43
+ // returns [Thing, Response, Request]
44
+ const [result] = await api.fetchJson<Thing>('/thing', { data: { example: 1 } });
45
+ ```
46
+
47
+ ### Jwt
48
+
49
+ Save a JSON Web Token in memory and reuse it throughout the process.
50
+
51
+ ```js
52
+ import { Jwt, Fetcher } from '@brianbuie/node-kit';
53
+
54
+ const apiJwt = new Jwt({
55
+ payload: {
56
+ example: 'value',
57
+ },
58
+ options: {
59
+ algorithm: 'HS256',
60
+ },
61
+ seconds: 60,
62
+ key: process.env.JWT_KEY,
63
+ });
64
+
65
+ const api = new Fetcher({
66
+ base: 'https://example.com',
67
+ headers: {
68
+ Authorization: `Bearer ${apiJwt.token}`,
69
+ },
70
+ });
71
+ ```
72
+
73
+ > TODO: expiration is not checked again when provided in a header
74
+
75
+ ### Log
76
+
77
+ Chalk output in development, structured JSON when running in gcloud
78
+
79
+ ```js
80
+ import { Log } from '@brianbuie/node-kit';
81
+
82
+ Log.info('message', { other: 'details' });
83
+
84
+ // Print in development, or if process.env.DEBUG or --debug argument is present
85
+ Log.debug('message', Response);
86
+
87
+ // Log details and throw
88
+ Log.error('Something happened', details, moreDetails);
89
+ ```
90
+
91
+ ### snapshot
92
+
93
+ Gets all enumerable and non-enumerable properties, so they can be included in JSON.stringify. Helpful for built-in objects, like Error, Request, Response, Headers, Map, etc.
94
+
95
+ ```js
96
+ fs.writeFileSync('result.json', JSON.stringify(snapshot(response), null, 2));
97
+ ```
98
+
15
99
  ## Publishing changes to this package
16
100
 
17
- Commit all changes, then run
101
+ Commit all changes, then run:
18
102
 
19
103
  ```
20
104
  npm version [patch|minor|major] [-m "custom commit message"]
21
105
  ```
22
106
 
23
107
  - Bumps version in `package.json`
24
- - Runs tests
25
- - Commits changes
26
- - Tags commit with new version
27
- - Pushes commit and tags to github
28
- - The new tag will trigger github action to publish to npm
108
+ - Runs tests (`"preversion"` script in `package.json`)
109
+ - Creates new commit, tagged with version
110
+ - Pushes commit and tags to github (`"postversion"` script in `package.json`)
111
+ - The new tag will trigger github action to publish to npm (`.github/actions/publish.yml`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brianbuie/node-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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
  },
@@ -7,33 +7,46 @@ describe('Fetcher', () => {
7
7
 
8
8
  it('should make URL', () => {
9
9
  const route = '/example/route';
10
- const url = statusApi.makeUrl(route);
10
+ const [url] = statusApi.buildUrl(route);
11
11
  assert(url.href.includes(route));
12
12
  });
13
13
 
14
- it('should throw when no base', () => {
14
+ it('should throw when URL is invalid', () => {
15
15
  try {
16
16
  const empty = new Fetcher();
17
- empty.makeUrl('/');
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.makeUrl('/', { key: 'value' });
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.makeUrl('/', { key: undefined });
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.makeUrl(route);
49
+ const [url] = statusApi.buildUrl(route);
37
50
  assert(url.href === statusApi.defaultOptions.base + route);
38
51
  });
39
52
 
package/src/Fetcher.ts CHANGED
@@ -1,8 +1,9 @@
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
- export type Query = Record<string, string | number | boolean | undefined>;
6
+ export type Query = Record<string, string | number | boolean | null | undefined>;
6
7
 
7
8
  export type FetchOptions = RequestInit & {
8
9
  base?: string;
@@ -31,14 +32,40 @@ export class Fetcher {
31
32
  this.defaultOptions = merge(defaultOptions, opts);
32
33
  }
33
34
 
34
- makeUrl(route: Route, query: Query = {}) {
35
- const params = new URLSearchParams(
36
- Object.entries(query)
37
- .filter(([_k, val]) => Boolean(val))
38
- .map(([key, val]) => [key, `${val}`])
39
- ).toString();
40
- const search = params ? '?' + params : '';
41
- return new URL(route + search, this.defaultOptions.base);
35
+ /**
36
+ * Build URL with URLSearchParams if query is provided
37
+ * Also returns domain, to help with cookies
38
+ */
39
+ buildUrl(route: Route, opts: FetchOptions = {}): [URL, string] {
40
+ const mergedOptions = merge({}, this.defaultOptions, opts);
41
+ const params = Object.entries(mergedOptions.query || {})
42
+ .filter(([_k, val]) => val !== undefined)
43
+ .map(([key, val]) => [key, `${val}`]);
44
+ const search = params.length > 0 ? '?' + new URLSearchParams(params).toString() : '';
45
+ const url = new URL(route + search, this.defaultOptions.base);
46
+ const domain = extractDomain(url.href) as string;
47
+ return [url, domain];
48
+ }
49
+
50
+ /**
51
+ * Builds request, merging defaultOptions and provided options
52
+ * Includes Abort signal for timeout
53
+ */
54
+ buildRequest(route: Route, opts: FetchOptions = {}): [Request, FetchOptions, string] {
55
+ const mergedOptions = merge({}, this.defaultOptions, opts);
56
+ const { query, data, timeout, retries, ...init } = mergedOptions;
57
+ if (data) {
58
+ init.headers = init.headers || {};
59
+ init.headers['content-type'] = init.headers['content-type'] || 'application/json';
60
+ init.method = init.method || 'POST';
61
+ init.body = JSON.stringify(data);
62
+ }
63
+ if (timeout) {
64
+ init.signal = AbortSignal.timeout(timeout);
65
+ }
66
+ const [url, domain] = this.buildUrl(route, mergedOptions);
67
+ const req = new Request(url, init);
68
+ return [req, mergedOptions, domain];
42
69
  }
43
70
 
44
71
  /**
@@ -47,35 +74,28 @@ export class Fetcher {
47
74
  * Retries on local or network error, with increasing backoff
48
75
  */
49
76
  async fetch(route: Route, opts: FetchOptions = {}): Promise<[Response, Request]> {
50
- const { query, data, timeout, retries, ...o } = merge({}, this.defaultOptions, opts);
51
- if (data) {
52
- o.headers = o.headers || {};
53
- o.headers['content-type'] = o.headers['content-type'] || 'application/json';
54
- o.method = o.method || 'POST';
55
- o.body = JSON.stringify(data);
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) });
77
+ const [_req, options] = this.buildRequest(route, opts);
78
+ const maxAttempts = (options.retries || 0) + 1;
79
+ let attempt = 0;
80
+ while (attempt < maxAttempts) {
81
+ attempt++;
82
+ // Rebuild request on every attempt to reset AbortSignal.timeout
83
+ const [req] = this.buildRequest(route, opts);
64
84
  const res = await fetch(req)
65
85
  .then(r => {
66
86
  if (!r.ok) throw new Error(r.statusText);
67
87
  return r;
68
88
  })
69
89
  .catch(async error => {
70
- if (attempted < attempts) {
71
- const wait = attempted * 3000;
72
- console.warn(`Fetcher (attempt ${attempted} of ${attempts})`, error);
90
+ if (attempt < maxAttempts) {
91
+ const wait = attempt * 3000;
92
+ console.warn(`${req.method} ${req.url} (attempt ${attempt} of ${maxAttempts})`, error);
73
93
  await new Promise(resolve => setTimeout(resolve, wait));
74
94
  }
75
95
  });
76
96
  if (res) return [res, req];
77
97
  }
78
- throw new Error(`Failed to fetch ${url.href}`);
98
+ throw new Error(`Failed to fetch ${_req.url}`);
79
99
  }
80
100
 
81
101
  async fetchText(route: Route, opts: FetchOptions = {}): Promise<[string, Response, Request]> {