fch 3.0.6 → 4.0.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.
Files changed (4) hide show
  1. package/fetch.min.js +1 -1
  2. package/package.json +4 -6
  3. package/readme.md +193 -127
  4. package/fetch.js +0 -202
package/fetch.min.js CHANGED
@@ -1 +1 @@
1
- const hasPlainBody=e=>{if(!e.headers["content-type"]&&["object","array"].includes(typeof e.body)&&!(e.body instanceof FormData))return!0},createUrl=(e,t,r)=>{let[o,a={}]=e.split("?");const s=new URLSearchParams({...Object.fromEntries(new URLSearchParams(t)),...Object.fromEntries(new URLSearchParams(a))}).toString();if(s&&(o=o+"?"+s),!r)return o;return new URL(o,r).href},createHeaders=(e,t)=>{const r={...t,...e};for(let e in r){const t=r[e];delete r[e],r[e.toLowerCase()]=t}return r},createDedupe=(e,t)=>({save:r=>(e.set(t,r),r),get:()=>e.get(t),clear:()=>e.delete(t)}),createFetch=(e,{after:t,dedupe:r,error:o,output:a})=>fetch(e.url,e).then((async e=>{r&&r.clear();let s={status:e.status,statusText:e.statusText,headers:{}};for(let t of e.headers.keys())s.headers[t.toLowerCase()]=e.headers.get(t);if(!e.ok){const t=new Error(e.statusText);return t.response=s,o(t)}const d=e.headers.get("content-type"),n=d&&d.includes("application/json");return s.body=await(n?e.json():e.text()),t&&(s=t(s)),"body"===a?s.body:s})),create=(e={})=>{const t=new Map,r=async(e,o={})=>{"object"!=typeof o&&(o={}),o="string"==typeof e?{url:e,...o}:e||{};let{dedupe:a=r.dedupe,output:s=r.output,baseURL:d=r.baseURL,baseUrl:n=d||r.baseUrl,query:u={},before:c=r.before,after:h=r.after,error:l=r.error,...p}=o;if(u={...r.query,...u},p.url=createUrl(p.url??r.url,u,n),p.method=(p.method??r.method).toLowerCase(),p.headers=createHeaders(p.headers,r.headers),"get"!==p.method&&(a=!1),a&&(a=createDedupe(t,p.url)),!["body","response"].includes(s)){throw new Error(`options.output needs to be either "body" (default) or "response", not "${s}"`)}return hasPlainBody(p)&&(p.body=JSON.stringify(p.body),p.headers["content-type"]="application/json; charset=utf-8"),c&&(p=c(p)),a&&!p.signal?a.get()?a.get():a.save(createFetch(p,{dedupe:a,output:s,error:l,after:h})):createFetch(p,{output:s,error:l,after:h})};r.url=e.url??"/",r.method=e.method??"get",r.query=e.query??{},r.headers=e.headers??{},r.baseUrl=e.baseUrl??e.baseURL??null,r.dedupe=e.dedupe??!0,r.output=e.output??"body",r.credentials=e.credentials??"include",r.before=e.before??(e=>e),r.after=e.after??(e=>e),r.error=e.error??(e=>Promise.reject(e));return r.get=(e,t={})=>r(e,{...t}),r.head=(e,t={})=>r(e,{...t,method:"head"}),r.post=(e,t={})=>r(e,{...t,method:"post"}),r.patch=(e,t={})=>r(e,{...t,method:"patch"}),r.put=(e,t={})=>r(e,{...t,method:"put"}),r.del=(e,t={})=>r(e,{...t,method:"delete"}),r.create=create,r},fch=create();window.fch=fch;
1
+ const e=(e,{after:t,cache:r,error:o,output:a})=>fetch(e.url,e).then((async e=>{r&&r.clear();let s={status:e.status,statusText:e.statusText,headers:{}};for(let t of e.headers.keys())s.headers[t.toLowerCase()]=e.headers.get(t);if(!e.ok){const t=new Error(e.statusText);return t.response=s,o(t)}if(s=t(s),"stream"===a)return e.body;if(e[a]&&"function"==typeof e[a])return e[a]();const n=e.headers.get("content-type"),u=n&&n.includes("application/json");if(s.body=s.body||await(u?e.json():e.text()),"body"===a)return s.body;if("response"===a)return s;throw new Error(`Invalid option output="${a}"`)})),t=(r={})=>{const o=(()=>{const e=new Map;return t=>({save:r=>(e.set(t,r),r),get:()=>e.get(t),clear:()=>e.delete(t)})})(),a=async(t="/",r={})=>{"object"!=typeof r&&(r={}),r="string"==typeof t?{url:t,...r}:t;let{dedupe:s,output:n,before:u,after:d,error:c,...f}={...a,...r};f.url=((e,t,r)=>{let[o,a={}]=e.split("?");const s=new URLSearchParams({...Object.fromEntries(new URLSearchParams(t)),...Object.fromEntries(new URLSearchParams(a))}).toString();return s&&(o=o+"?"+s),r?new URL(o,r).href:o})(f.url,{...a.query,...r.query},f.baseUrl??f.baseURL),f.method=f.method.toLowerCase(),f.headers=(e=>{const t={};for(let[r,o]of Object.entries(e))t[r.toLowerCase()]=o;return t})({...a.headers,...r.headers});const p=!(!s||"get"!==f.method)&&o(f.url);var i;return"object"!=typeof(i=f.body)||i instanceof FormData||i.pipe||(f.body=JSON.stringify(f.body),f.headers["content-type"]="application/json"),f=u(f),p&&!f.signal?p.get()?p.get():p.save(e(f,{cache:p,output:n,error:c,after:d})):e(f,{output:n,error:c,after:d})};return a.url=r.url??"/",a.method=r.method??"get",a.query=r.query??{},a.headers=r.headers??{},a.baseUrl=r.baseUrl??r.baseURL??null,a.dedupe=r.dedupe??!0,a.output=r.output??"body",a.credentials=r.credentials??"include",a.before=r.before??(e=>e),a.after=r.after??(e=>e),a.error=r.error??(e=>{throw e}),["get","head","post","patch","put","delete"].map((e=>{a[e]=(t,r={})=>a(t,{...r,method:e})})),a.create=t,a};"undefined"!=typeof window&&(window.fch=t());export default t();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fch",
3
- "version": "3.0.6",
3
+ "version": "4.0.0",
4
4
  "description": "Fetch interface with better promises, deduplication, defaults, etc.",
5
5
  "homepage": "https://github.com/franciscop/fetch",
6
6
  "repository": "https://github.com/franciscop/fetch.git",
@@ -9,7 +9,8 @@
9
9
  "author": "Francisco Presencia <public@francisco.io> (https://francisco.io/)",
10
10
  "license": "MIT",
11
11
  "scripts": {
12
- "build": "terser --compress --mangle -- ./fetch.js | sed 's/export default fch;/window.fch=fch;/g' > ./fetch.min.js",
12
+ "build": "terser --compress --mangle --toplevel -o ./fetch.min.js -- ./fetch.js",
13
+ "size": "echo $(gzip -c fetch.min.js | wc -c) bytes",
13
14
  "start": "npm run watch # Start ~= Start dev",
14
15
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --detectOpenHandles",
15
16
  "watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch --coverage --detectOpenHandles"
@@ -22,11 +23,8 @@
22
23
  "async",
23
24
  "ajax"
24
25
  ],
25
- "main": "./fetch.js",
26
- "module": "./fetch.js",
27
- "browser": "./fetch.min.js",
26
+ "main": "./fetch.min.js",
28
27
  "files": [
29
- "fetch.js",
30
28
  "fetch.min.js"
31
29
  ],
32
30
  "type": "module",
package/readme.md CHANGED
@@ -3,47 +3,53 @@
3
3
  A tiny library to make API calls easier. Similar to Axios, but tiny size and simpler API:
4
4
 
5
5
  ```js
6
- import api from "fch";
7
- api.baseUrl = "https://pokeapi.co/";
8
- const mew = await api.get("/pokemon/150");
6
+ // Plain usage
7
+ import fch from "fch";
8
+ const mew = await fch("https://pokeapi.co/pokemon/150");
9
9
  console.log(mew);
10
+
11
+ // As an API abstraction
12
+ const api = fch.create({ baseUrl: "https://pokeapi.co/" });
13
+ const mew = await api.get("/pokemon/150");
14
+ await api.patch("/pokemon/150", { body: { type: "psychic" } });
10
15
  ```
11
16
 
12
17
  - Create instances with shared options across requests.
13
18
  - Automatically encode object and array bodies as JSON.
14
19
  - Automatically decode JSON responses based on the headers.
15
20
  - Await/Async Promises; `>= 400 and <= 100` will _reject_ with an error.
21
+ - Credentials: "include" by default
16
22
  - Interceptors: `before` the request, `after` the response and catch with `error`.
17
23
  - Deduplicates parallel GET requests.
18
24
  - Works the same way in Node.js and the browser.
19
25
  - No dependencies; include it with a simple `<script>` on the browser.
20
26
 
21
27
  ```js
22
- import api from 'fch';
23
-
24
- api.get(url, { headers, ...options })
25
- api.head(url, { headers, ...options })
26
- api.post(url, { body, headers, ...options })
27
- api.patch(url, { body, headers, ...options })
28
- api.put(url, { body, headers, ...options })
29
- api.del(url, { body, headers, ...options })
30
-
31
- api.create({ url, body, headers, ...options})
32
- ```
28
+ import api from "fch";
33
29
 
34
- | Options | Default | Description |
35
- | ----------------------| ----------| -----------------------------------|
36
- | [`method`](#method) | `"get"` | Default method to use for the call |
37
- | [`url`](#url) | `null` | The path or url for the request |
38
- | [`baseUrl`](#url) | `null` | The shared base of the API |
39
- | [`body`](#body) | `null` | The body to send with the request |
40
- | [`query`](#query) | `{}` | Add query parameters to the URL |
41
- | [`headers`](#headers) | `{}` | Shared headers across all requests |
42
- | [`output`](#output) | `"body"` | The return value of the API call |
43
- | [`dedupe`](#dedupe) | `true` | Reuse concurrently GET requests |
44
- | [`before`](#interceptors) |`req => req` |Process the request before sending it |
45
- | [`after`](#interceptors) |`res => res` |Process the response before returning it |
46
- | [`error`](#interceptors) |`err => throw err` |Process errors before returning them |
30
+ api.get(url, { headers, ...options });
31
+ api.head(url, { headers, ...options });
32
+ api.post(url, { body, headers, ...options });
33
+ api.patch(url, { body, headers, ...options });
34
+ api.put(url, { body, headers, ...options });
35
+ api.delete(url, { body, headers, ...options });
36
+
37
+ api.create({ url, body, headers, ...options });
38
+ ```
39
+
40
+ | Options | Default | Description |
41
+ | ------------------------- | ------------------ | ---------------------------------------- |
42
+ | [`method`](#method) | `"get"` | Default method to use for the call |
43
+ | [`url`](#url) | `"/"` | The path or url for the request |
44
+ | [`baseUrl`](#url) | `null` | The shared base of the API |
45
+ | [`body`](#body) | `null` | The body to send with the request |
46
+ | [`query`](#query) | `{}` | Add query parameters to the URL |
47
+ | [`headers`](#headers) | `{}` | Shared headers across all requests |
48
+ | [`output`](#output) | `"body"` | The return value of the API call |
49
+ | [`dedupe`](#dedupe) | `true` | Reuse concurrently GET requests |
50
+ | [`before`](#interceptors) | `req => req` | Process the request before sending it |
51
+ | [`after`](#interceptors) | `res => res` | Process the response before returning it |
52
+ | [`error`](#interceptors) | `err => throw err` | Process errors before returning them |
47
53
 
48
54
  ## Getting Started
49
55
 
@@ -56,17 +62,17 @@ npm install fch
56
62
  Then import it to be able to use it in your code:
57
63
 
58
64
  ```js
59
- import fch from 'fch';
60
- const body = await fch.get('/');
65
+ import fch from "fch";
66
+ const body = await fch.get("/");
61
67
  ```
62
68
 
63
69
  On the browser you can add it with a script and it will be available as `fch`:
64
70
 
65
71
  ```html
66
72
  <!-- Import it as usual -->
67
- <script src="https://cdn.jsdelivr.net/npm/fch"></script>
68
- <script>
69
- fch('/hello');
73
+ <script src="https://cdn.jsdelivr.net/npm/fch" type="module"></script>
74
+ <script type="module">
75
+ fch("/hello");
70
76
  </script>
71
77
  ```
72
78
 
@@ -75,23 +81,23 @@ On the browser you can add it with a script and it will be available as `fch`:
75
81
  These are all available options and their defaults:
76
82
 
77
83
  ```js
78
- import api from 'fch';
84
+ import api from "fch";
79
85
 
80
86
  // General options with their defaults; all of these are also parameters:
81
- api.method = 'get'; // Default method to use for api()
82
- api.url = '/'; // Relative or absolute url where the request is sent
83
- api.baseUrl = null; // Set an API base URL reused all across requests
84
- api.query = {}; // Merged with the query parameters passed manually
85
- api.headers = {}; // Merged with the headers on a per-request basis
87
+ api.method = "get"; // Default method to use for api()
88
+ api.url = "/"; // Relative or absolute url where the request is sent
89
+ api.baseUrl = null; // Set an API base URL reused all across requests
90
+ api.query = {}; // Merged with the query parameters passed manually
91
+ api.headers = {}; // Merged with the headers on a per-request basis
86
92
 
87
93
  // Control simple variables
88
- api.output = 'body'; // Return the body; use 'response' for the full response
89
- api.dedupe = true; // Avoid sending concurrent GET requests to the same path
94
+ api.output = "body"; // Return the parsed body; use 'response' or 'stream' otherwise
95
+ api.dedupe = true; // Avoid sending concurrent GET requests to the same path
90
96
 
91
97
  // Interceptors
92
- api.before = req => req;
93
- api.after = res => res;
94
- api.error = err => Promise.reject(err);
98
+ api.before = (req) => req;
99
+ api.after = (res) => res;
100
+ api.error = (err) => Promise.reject(err);
95
101
  ```
96
102
 
97
103
  They can all be defined globally as shown above, passed manually as the options argument, or be used when [creating a new instance](#create-an-instance).
@@ -101,11 +107,11 @@ They can all be defined globally as shown above, passed manually as the options
101
107
  The HTTP method to make the request. When using the shorthand, it defaults to `GET`. We recommend using the method syntax:
102
108
 
103
109
  ```js
104
- import api from 'fch';
110
+ import api from "fch";
105
111
 
106
- api.get('/cats');
107
- api.post('/cats', { body: { name: 'snowball' } });
108
- api.put(`/cats/3`, { body: { name: 'snowball' }});
112
+ api.get("/cats");
113
+ api.post("/cats", { body: { name: "snowball" } });
114
+ api.put(`/cats/3`, { body: { name: "snowball" } });
109
115
  ```
110
116
 
111
117
  You can use it with the plain function as an option parameter. The methods are all lowercase but the option as a parameter is case insensitive; it can be either uppercase or lowercase:
@@ -125,12 +131,12 @@ api({ method; 'get'})
125
131
  Example: adding a new cat and fixing a typo:
126
132
 
127
133
  ```js
128
- import api from 'fch';
134
+ import api from "fch";
129
135
 
130
- const cats = await api.get('/cats');
136
+ const cats = await api.get("/cats");
131
137
  console.log(cats);
132
- const { id } = await api.post('/cats', { body: { name: 'snowbll' } });
133
- await api.put(`/cats/${id}`, { body: { name: 'snowball' }})
138
+ const { id } = await api.post("/cats", { body: { name: "snowbll" } });
139
+ await api.put(`/cats/${id}`, { body: { name: "snowball" } });
134
140
  ```
135
141
 
136
142
  ### Url
@@ -138,23 +144,23 @@ await api.put(`/cats/${id}`, { body: { name: 'snowball' }})
138
144
  Specify where to send the request to. It's normally the first argument, though technically you can use both styles:
139
145
 
140
146
  ```js
141
- import api from 'fch';
147
+ import api from "fch";
142
148
 
143
149
  // Recommended way of specifying the Url
144
- await api.post('/hello', { body: '...', headers: {} })
150
+ await api.post("/hello", { body: "...", headers: {} });
145
151
 
146
152
  // These are also valid if you prefer their style; we won't judge
147
- await api('/hello', { method: 'post', body: '...', headers: {} });
148
- await api({ url: '/hello', method: 'post', headers: {}, body: '...' });
149
- await api.post({ url: '/hello', headers: {}, body: '...' });
153
+ await api("/hello", { method: "post", body: "...", headers: {} });
154
+ await api({ url: "/hello", method: "post", headers: {}, body: "..." });
155
+ await api.post({ url: "/hello", headers: {}, body: "..." });
150
156
  ```
151
157
 
152
158
  It can be either absolute or relative, in which case it'll use the local one in the page. It's recommending to set `baseUrl`:
153
159
 
154
160
  ```js
155
- import api from 'fch';
156
- api.baseUrl = 'https//api.filemon.io/';
157
- api.get('/hello');
161
+ import api from "fch";
162
+ api.baseUrl = "https//api.filemon.io/";
163
+ api.get("/hello");
158
164
  // Called https//api.filemon.io/hello
159
165
  ```
160
166
 
@@ -183,8 +189,10 @@ The methods `GET` and `HEAD` do not accept a body and it'll be ignored.
183
189
 
184
190
  The **response body** will be returned by default as the output of the call:
185
191
 
192
+ > See more info in [**Output**](#output)
193
+
186
194
  ```js
187
- const body = await api.get('/cats');
195
+ const body = await api.get("/cats");
188
196
  console.log(body);
189
197
  // [{ id: 1, }, ...]
190
198
  ```
@@ -194,51 +202,48 @@ When the server specifies the header `Content-Type` as `application/json`, then
194
202
  When the function returns the response (if you set `output: "response"` as an option), then the body can be accessed as `response.body`:
195
203
 
196
204
  ```js
197
- const response = await api.get('/cats', { output: 'response' });
205
+ const response = await api.get("/cats", { output: "response" });
198
206
  console.log(response.body);
199
207
  // [{ id: 1, }, ...]
200
208
  ```
201
209
 
202
-
203
210
  ### Query
204
211
 
205
212
  You can easily pass GET query parameters by using the option `query`:
206
213
 
207
214
  ```js
208
- api.get('/cats', { query: { limit: 3 } });
215
+ api.get("/cats", { query: { limit: 3 } });
209
216
  // /cats?limit=3
210
217
  ```
211
218
 
212
219
  While rare, some times you might want to persist a query parameter across requests and always include it; in that case, you can define it globally and it'll be added to every request:
213
220
 
214
221
  ```js
215
- import api from 'fch';
216
- api.query.myparam = 'abc';
222
+ import api from "fch";
223
+ api.query.myparam = "abc";
217
224
 
218
- api.get('/cats', { query: { limit: 3 } });
225
+ api.get("/cats", { query: { limit: 3 } });
219
226
  // /cats?limit=3&myparam=abc
220
227
  ```
221
228
 
222
-
223
229
  ### Headers
224
230
 
225
231
  You can define headers globally, in which case they'll be added to every request, or locally, so that they are only added to the current request. You can also add them in the `before` callback:
226
232
 
227
-
228
233
  ```js
229
- import api from 'fch';
234
+ import api from "fch";
230
235
 
231
236
  // Globally, so they are reused across all requests
232
- api.headers.a = 'b';
237
+ api.headers.a = "b";
233
238
 
234
239
  // With an interceptor, in case you need dynamic headers per-request
235
- api.before = req => {
236
- req.headers.c = 'd';
240
+ api.before = (req) => {
241
+ req.headers.c = "d";
237
242
  return req;
238
243
  };
239
244
 
240
245
  // Set them for this single request:
241
- api.get('/hello', { headers: { e: 'f' } });
246
+ api.get("/hello", { headers: { e: "f" } });
242
247
  // Total headers on the request:
243
248
  // { a: 'b', c: 'd', e: 'f' }
244
249
  ```
@@ -249,31 +254,49 @@ When to use each?
249
254
  - When you need to extract them dynamically from somewhere it's better to use the .before() interceptor. An example would be the user Authorization token.
250
255
  - When it changes on each request, it's not consistent or it's an one-off, use the option argument.
251
256
 
252
-
253
257
  ### Output
254
258
 
255
259
  This controls whether the call returns just the body (default), or the whole response. It can be controlled globally or on a per-request basis:
256
260
 
257
261
  ```js
258
- import api from 'fch';
259
-
260
- // "body" (default) or "response"
261
- api.output = 'body';
262
-
263
- // Return only the body, this is the default
264
- const body = await api.get('/data');
262
+ // Return only the body (default)
263
+ const body = await api.get("/data");
265
264
 
266
265
  // Return the whole response (with .body):
267
- const response = await api.get('/data', { output: 'response' });
266
+ const response = await api.get("/data", { output: "response" });
267
+
268
+ // Return a plain body stream
269
+ const stream = await api.get("/data", { output: "stream" });
270
+ stream.pipeTo(outStream);
268
271
 
269
- // Throws error
270
- const invalid = await api.get('/data', { output: 'invalid' });
272
+ // Return a blob, since `response.blob()` is available:
273
+ const blob = await api.get("/data", { output: "blob" });
271
274
  ```
272
275
 
276
+ There are few options that can be specified:
277
+
278
+ - `output: "body"` (default): returns the body, parsed as JSON or plain TEXT depending on the headers.
279
+ - `output: "response"`: return the full response with the properties `body`, `headers`, `status`, etc. The body will be parsed as JSON or plain TEXT depending on the headers. If you want the raw `response`, use `raw` or `clone` instead (see below in "raw" or "METHODS").
280
+ - `output: "stream"`: return a [web ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) of the body as the result of the promise.
281
+ - Any of [the valid methods](https://developer.mozilla.org/en-US/docs/Web/API/Response#methods):
282
+ - `output: "arrayBuffer"`: returns an arrayBuffer of the response body.
283
+ - `output: "blob"`: returns an arrayBuffer of the response body.
284
+ - `output: "clone"`: returns the raw Response, with the raw body. See also `raw` below.
285
+ - `output: formData`: returns an instance of FormData with all the parsed data.
286
+ - `output: json`: attempts to parse the response as JSON.
287
+ - `output: text`: puts the response body as plain text.
288
+ - `output: "raw"`: an alias for `clone`, returning the raw response (after passing through `after`).
289
+
290
+ For example, return the raw body as a `ReadableStream` with the option `stream`:
291
+
292
+ ```js
293
+ const stream = await api.get('/cats', { output: 'stream' });
294
+ stream.pipeTo(...);
295
+ ```
273
296
 
274
297
  ### Dedupe
275
298
 
276
- When you perform a GET request to a given URL, but another GET request *to the same* URL is ongoing, it'll **not** create a new request and instead reuse the response when the first one is finished:
299
+ When you perform a GET request to a given URL, but another GET request _to the same_ URL is ongoing, it'll **not** create a new request and instead reuse the response when the first one is finished:
277
300
 
278
301
  ```js
279
302
  fetch.mockOnce("a").mockOnce("b");
@@ -287,12 +310,12 @@ You can disable this by setting either the global `fch.dedupe` option to `false`
287
310
 
288
311
  ```js
289
312
  // Globally set it for all calls
290
- fch.dedupe = true; // [DEFAULT] Dedupes GET requests
313
+ fch.dedupe = true; // [DEFAULT] Dedupes GET requests
291
314
  fch.dedupe = false; // All fetch() calls trigger a network call
292
315
 
293
316
  // Set it on a per-call basis
294
- fch('/a', { dedupe: true }); // [DEFAULT] Dedupes GET requests
295
- fch('/a', { dedupe: false }) // All fetch() calls trigger a network call
317
+ fch("/a", { dedupe: true }); // [DEFAULT] Dedupes GET requests
318
+ fch("/a", { dedupe: false }); // All fetch() calls trigger a network call
296
319
  ```
297
320
 
298
321
  > We do not support deduping other methods besides `GET` right now
@@ -305,7 +328,7 @@ it("can opt out locally", async () => {
305
328
  const res = await Promise.all([
306
329
  fch("/a"),
307
330
  fch("/a", { dedupe: false }),
308
- fch("/a"), // Reuses the 1st response, not the 2nd one
331
+ fch("/a"), // Reuses the 1st response, not the 2nd one
309
332
  ]);
310
333
 
311
334
  expect(res).toEqual(["a", "b", "a"]);
@@ -317,6 +340,12 @@ it("can opt out locally", async () => {
317
340
 
318
341
  You can also easily add the interceptors `before`, `after` and `error`:
319
342
 
343
+ - `before`: Called when the request is fully formed, but before actually launching it.
344
+ - `before`: Called just after the response is created and if there was no error, but before parsing anything else.
345
+ - `error`: When the response is not okay, if possible it'll include the `response` object.
346
+
347
+ > Note: interceptors are never deduped/cached and always execute once per call, even if the main async fetch() has been deduped.
348
+
320
349
  ```js
321
350
  // Perform an action or request transformation before the request is sent
322
351
  fch.before = async req => {
@@ -346,7 +375,6 @@ fch.error = async err => {
346
375
  };
347
376
  ```
348
377
 
349
-
350
378
  ## How to
351
379
 
352
380
  ### Stop errors from throwing
@@ -354,10 +382,11 @@ fch.error = async err => {
354
382
  While you can handle this on a per-request basis, if you want to overwrite the global behavior you can write a interceptor:
355
383
 
356
384
  ```js
357
- import fch from 'fch';
358
- fch.error = error => error.response;
385
+ import fch from "fch";
386
+ fch.output = "response";
387
+ fch.error = (error) => error.response;
359
388
 
360
- const res = await fch('/notfound');
389
+ const res = await fch("/notfound");
361
390
  expect(res.status).toBe(404);
362
391
  ```
363
392
 
@@ -366,75 +395,83 @@ expect(res.status).toBe(404);
366
395
  By default a successful request will just return the data. However this one is configurable on a global level:
367
396
 
368
397
  ```js
369
- import fch from 'fch';
370
- fch.output = 'response'; // Valid values are 'body' (default) or 'response'
398
+ import fch from "fch";
399
+ fch.output = "response";
371
400
 
372
- const res = await fch('/hello');
401
+ const res = await fch("/hello");
402
+ console.log(res.status);
373
403
  ```
374
404
 
375
405
  Or on a per-request level:
376
406
 
377
407
  ```js
378
- import fch from 'fch';
408
+ import fch from "fch";
379
409
 
380
410
  // Valid values are 'body' (default) or 'response'
381
- const res = await fch('/hello', { output: 'response' });
411
+ const res = await fch("/hello", { output: "response" });
382
412
 
383
413
  // Does not affect others
384
- const body = await fch('/hello');
414
+ const body = await fch("/hello");
385
415
  ```
386
416
 
417
+ It does perform some basic parsing of the `body`, if you don't want any of that you can retrieve the very raw response:
418
+
419
+ ```js
420
+ import fch from "fch";
421
+
422
+ // Valid values are 'body' (default) or 'response'
423
+ const res = await fch("/hello", { output: "raw" });
424
+ console.log(res.body); // ReadableStream
425
+ ```
387
426
 
388
427
  ### Set a base URL
389
428
 
390
429
  There's a configuration parameter for that:
391
430
 
392
431
  ```js
393
- import fch from 'fch';
394
- fch.baseUrl = 'https://api.filemon.io/';
432
+ import fch from "fch";
433
+ fch.baseUrl = "https://api.filemon.io/";
395
434
 
396
435
  // Calls "https://api.filemon.io/blabla"
397
- const body = await fch.get('/blabla');
436
+ const body = await fch.get("/blabla");
398
437
  ```
399
438
 
400
-
401
439
  ### Set authorization headers
402
440
 
403
441
  You can set that globally as a header:
404
442
 
405
443
  ```js
406
- import fch from 'fch';
407
- fch.headers.Authorization = 'bearer abc';
444
+ import fch from "fch";
445
+ fch.headers.Authorization = "bearer abc";
408
446
 
409
- const me = await fch('/users/me');
447
+ const me = await fch("/users/me");
410
448
  ```
411
449
 
412
450
  Or globally on a per-request basis, for example if you take the value from localStorage:
413
451
 
414
452
  ```js
415
- import fch from 'fch';
453
+ import fch from "fch";
416
454
 
417
455
  // All the requests will add the Authorization header when the token is
418
456
  // in localStorage
419
- fch.before = req => {
420
- if (localStorage.get('token')) {
421
- req.headers.Authorization = 'bearer ' + localStorage.get('token');
457
+ fch.before = (req) => {
458
+ if (localStorage.get("token")) {
459
+ req.headers.Authorization = "bearer " + localStorage.get("token");
422
460
  }
423
461
  return req;
424
462
  };
425
463
 
426
- const me = await fch('/users/me');
464
+ const me = await fch("/users/me");
427
465
  ```
428
466
 
429
467
  Or on a per-request basis, though we wouldn't recommend this:
430
468
 
431
469
  ```js
432
- import fch from 'fch';
470
+ import fch from "fch";
433
471
 
434
- const me = await fch('/users/me', { headers: { Authorization: 'bearer abc' } });
472
+ const me = await fch("/users/me", { headers: { Authorization: "bearer abc" } });
435
473
  ```
436
474
 
437
-
438
475
  ### Create an instance
439
476
 
440
477
  You can create an instance with its own defaults and global options easily. It's common when writing an API that you want to encapsulate away:
@@ -453,28 +490,47 @@ fch.get('/hello'); // Gets http://localhost:3000/hello (or wherever you are)
453
490
 
454
491
  Note: for server-side (Node.js) usage, you always want to set `baseUrl`.
455
492
 
493
+ ### Streaming a response body
494
+
495
+ To stream the body, you need to use the `output: "stream"` option so that it returns a [WebStream ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream):
496
+
497
+ ```js
498
+ import fch from "fch";
499
+
500
+ // Valid values are 'body' (default) or 'response'
501
+ const stream = await fch("/data", { output: "stream" });
502
+ stream.pipeTo(outStream);
503
+ ```
456
504
 
505
+ You might want to convert it to a Node.js ReadStream:
506
+
507
+ ```js
508
+ import fch from "fch";
509
+ import { Readable } from "node:stream";
510
+
511
+ const stream = await fch("/data", { output: "stream" });
512
+ const readableNodeStream = Readable.fromWeb(stream);
513
+ // ...
514
+ ```
457
515
 
458
516
  ### Cancel ongoing requests
459
517
 
460
518
  You can cancel ongoing requests [similarly to native fetch()](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort#examples), by passing it a signal:
461
519
 
462
520
  ```js
463
- import api from 'fch';
521
+ import api from "fch";
464
522
 
465
523
  const controller = new AbortController();
466
524
  const signal = controller.signal;
467
525
 
468
- abortButton.addEventListener('click', () => {
526
+ abortButton.addEventListener("click", () => {
469
527
  controller.abort();
470
- console.log('Download aborted');
528
+ console.log("Download aborted");
471
529
  });
472
530
 
473
531
  api.get(url, { signal });
474
532
  ```
475
533
 
476
-
477
-
478
534
  ### Define shared options
479
535
 
480
536
  You can also define values straight away:
@@ -490,28 +546,24 @@ console.log(mew);
490
546
 
491
547
  You can also [create an instance](#create-an-instance) that will have the same options for all requests made with that instance.
492
548
 
493
-
494
-
495
549
  ### Node.js vs Browser
496
550
 
497
551
  First, we use the native Node.js' fetch() and the browser's native fetch(), so any difference between those also applies to this library. For example, if you were to call `"/"` in the browser it'd refer to the current URL, while in Node.js it'd fail since you need to specify the full URL. Some other places where you might find differences: CORS, cache, etc.
498
552
 
499
553
  In the library itself there's nothing different between the browser and Node.js, but it might be interesting to note that (if/when implemented) things like cache, etc. in Node.js are normally long-lived and shared, while in a browser request it'd bound to the request itself.
500
554
 
501
-
502
-
503
555
  ### Differences with Axios
504
556
 
505
557
  The main difference is that things are simplified with fch:
506
558
 
507
559
  ```js
508
560
  // Modify headers
509
- axios.defaults.headers.Authorization = '...';
510
- fch.headers.Authorization = '...';
561
+ axios.defaults.headers.Authorization = "...";
562
+ fch.headers.Authorization = "...";
511
563
 
512
564
  // Set a base URL
513
- axios.defaults.baseURL = '...';
514
- fch.baseUrl = '...';
565
+ axios.defaults.baseURL = "...";
566
+ fch.baseUrl = "...";
515
567
 
516
568
  // Add an interceptor
517
569
  axios.interceptors.request.use(fn);
@@ -524,3 +576,17 @@ As disadvantages, I can think of two major ones for `fch`:
524
576
 
525
577
  - Requires Node.js 18+, which is the version that includes `fetch()` by default.
526
578
  - Does not support many of the more advanced options, like `onUploadProgress` nor `onDownloadProgress`.
579
+
580
+ ## Releases
581
+
582
+ ### V4
583
+
584
+ Breaking changes:
585
+
586
+ - Only ESM exports. Meaning, if you use it in a browser you'll need the `<script type="module">`.
587
+ - The method `fch.del()` (and derivates with fch.create()) have been renamed to `fch.delete()`.
588
+
589
+ Changes:
590
+
591
+ - Added `output` options: `raw`, `stream`, `arrayBuffer`, `blob`, `clone`, `formData`, `json`, `text`
592
+ - Gone from 1.2kb down to 1.0kb
package/fetch.js DELETED
@@ -1,202 +0,0 @@
1
- // Plain-ish object
2
- const hasPlainBody = (options) => {
3
- if (options.headers["content-type"]) return;
4
- if (!["object", "array"].includes(typeof options.body)) return;
5
- if (options.body instanceof FormData) return;
6
- return true;
7
- };
8
-
9
- const createUrl = (url, query, base) => {
10
- let [path, urlQuery = {}] = url.split("?");
11
-
12
- // Merge global params with passed params with url params
13
- const entries = new URLSearchParams({
14
- ...Object.fromEntries(new URLSearchParams(query)),
15
- ...Object.fromEntries(new URLSearchParams(urlQuery)),
16
- }).toString();
17
- if (entries) {
18
- path = path + "?" + entries;
19
- }
20
-
21
- if (!base) return path;
22
- const fullUrl = new URL(path, base);
23
- return fullUrl.href;
24
- };
25
-
26
- const createHeaders = (user, base) => {
27
- // User-set headers overwrite the base headers
28
- const headers = { ...base, ...user };
29
-
30
- // Make the headers lowercase
31
- for (let key in headers) {
32
- const value = headers[key];
33
- delete headers[key];
34
- headers[key.toLowerCase()] = value;
35
- }
36
-
37
- return headers;
38
- };
39
-
40
- const createDedupe = (ongoing, url) => ({
41
- save: (prom) => {
42
- ongoing.set(url, prom);
43
- return prom;
44
- },
45
- get: () => ongoing.get(url),
46
- clear: () => ongoing.delete(url),
47
- });
48
-
49
- const createFetch = (request, { after, dedupe, error, output }) => {
50
- return fetch(request.url, request).then(async (res) => {
51
- // No longer ongoing at this point
52
- if (dedupe) dedupe.clear();
53
-
54
- // Need to manually create it to set some things like the proper response
55
- let response = {
56
- status: res.status,
57
- statusText: res.statusText,
58
- headers: {},
59
- };
60
- for (let key of res.headers.keys()) {
61
- response.headers[key.toLowerCase()] = res.headers.get(key);
62
- }
63
-
64
- // Oops, throw it
65
- if (!res.ok) {
66
- const err = new Error(res.statusText);
67
- err.response = response;
68
- return error(err);
69
- }
70
-
71
- // Automatically parse the response
72
- const type = res.headers.get("content-type");
73
- const isJson = type && type.includes("application/json");
74
- response.body = await (isJson ? res.json() : res.text());
75
-
76
- // Hijack the response and modify it
77
- if (after) {
78
- response = after(response);
79
- }
80
-
81
- if (output === "body") {
82
- return response.body;
83
- } else {
84
- return response;
85
- }
86
- });
87
- };
88
-
89
- const create = (defaults = {}) => {
90
- // DEDUPLICATION is created on a per-instance basis
91
- // To avoid making parallel requests to the same url if one is ongoing
92
- const ongoing = new Map();
93
-
94
- const fch = async (url, options = {}) => {
95
- // Second parameter always has to be an object, even when it defaults
96
- if (typeof options !== "object") options = {};
97
-
98
- // Accept either fch(options) or fch(url, options)
99
- options = typeof url === "string" ? { url, ...options } : url || {};
100
-
101
- // Exctract the options
102
- let {
103
- dedupe = fch.dedupe,
104
- output = fch.output,
105
- baseURL = fch.baseURL, // DO NOT USE; it's here only for user friendliness
106
- baseUrl = baseURL || fch.baseUrl,
107
-
108
- // Extract it since it should not be part of fetch()
109
- query = {},
110
-
111
- // Interceptors can also be passed as parameters
112
- before = fch.before,
113
- after = fch.after,
114
- error = fch.error,
115
-
116
- ...request
117
- } = options; // Local option OR global value (including defaults)
118
-
119
- // Merge it, first the global and then the local
120
- query = { ...fch.query, ...query };
121
- // Absolute URL if possible; Default method; merge the default headers
122
- request.url = createUrl(request.url ?? fch.url, query, baseUrl);
123
- request.method = (request.method ?? fch.method).toLowerCase();
124
- request.headers = createHeaders(request.headers, fch.headers);
125
-
126
- if (request.method !== "get") {
127
- dedupe = false;
128
- }
129
- if (dedupe) {
130
- dedupe = createDedupe(ongoing, request.url);
131
- }
132
-
133
- if (!["body", "response"].includes(output)) {
134
- const msg = `options.output needs to be either "body" (default) or "response", not "${output}"`;
135
- throw new Error(msg);
136
- }
137
-
138
- // JSON-encode plain objects
139
- if (hasPlainBody(request)) {
140
- request.body = JSON.stringify(request.body);
141
- request.headers["content-type"] = "application/json; charset=utf-8";
142
- }
143
-
144
- // Hijack the requeset and modify it
145
- if (before) {
146
- request = before(request);
147
- }
148
-
149
- // It should be cached and it's not being manually manipulated
150
- if (dedupe && !request.signal) {
151
- // It's already cached! Just return it
152
- if (dedupe.get()) return dedupe.get();
153
-
154
- // Otherwise, save it in the cache and return the promise
155
- return dedupe.save(
156
- createFetch(request, { dedupe, output, error, after })
157
- );
158
- } else {
159
- // PUT, POST, etc should never dedupe and just return the plain request
160
- return createFetch(request, { output, error, after });
161
- }
162
- };
163
-
164
- // Default values
165
- fch.url = defaults.url ?? "/";
166
- fch.method = defaults.method ?? "get";
167
- fch.query = defaults.query ?? {};
168
- fch.headers = defaults.headers ?? {};
169
- fch.baseUrl = defaults.baseUrl ?? defaults.baseURL ?? null;
170
-
171
- // Default options
172
- fch.dedupe = defaults.dedupe ?? true;
173
- fch.output = defaults.output ?? "body";
174
- fch.credentials = defaults.credentials ?? "include";
175
-
176
- // Interceptors
177
- fch.before = defaults.before ?? ((request) => request);
178
- fch.after = defaults.after ?? ((response) => response);
179
- fch.error = defaults.error ?? ((error) => Promise.reject(error));
180
-
181
- const get = (url, opts = {}) => fch(url, { ...opts });
182
- const head = (url, opts = {}) => fch(url, { ...opts, method: "head" });
183
- const post = (url, opts = {}) => fch(url, { ...opts, method: "post" });
184
- const patch = (url, opts = {}) => fch(url, { ...opts, method: "patch" });
185
- const put = (url, opts = {}) => fch(url, { ...opts, method: "put" });
186
- const del = (url, opts = {}) => fch(url, { ...opts, method: "delete" });
187
-
188
- fch.get = get;
189
- fch.head = head;
190
- fch.post = post;
191
- fch.patch = patch;
192
- fch.put = put;
193
- fch.del = del;
194
-
195
- fch.create = create;
196
-
197
- return fch;
198
- };
199
-
200
- const fch = create();
201
-
202
- export default fch;