fch
v5.1.5
Published
Fetch interface with better promises, deduplication, defaults, etc.
Downloads
13
Maintainers
Readme
Fch
A tiny library to make API calls easier. Similar to Axios, but tiny size and simpler API:
// Plain usage
import fch from "fch";
const mew = await fch("https://pokeapi.co/pokemon/150");
console.log(mew);
// As an API abstraction
const api = fch.create({ baseUrl: "https://pokeapi.co/" });
const mew = await api.get("/pokemon/150");
await api.patch("/pokemon/150", { type: "psychic" });
- Create instances with shared options across requests.
- Configurable cache that works in-memory, with redis, or others.
- Automatically encode and decode JSON bodies.
- Await/Async Promises;
>= 400 and <= 100
will throw an error. - Credentials: "include" by default
- Interceptors:
before
the request,after
the response and catch witherror
. - Designed for both Node.js and the browser through its extensible cache system.
- No dependencies; include it with a simple
<script>
on the browser. - Full Types definitions so you get nice autocomplete
import fch from "fch";
const api = fch.create({ baseUrl, headers, ...options });
api.get(url, { headers, ...options });
api.head(url, { headers, ...options });
api.post(url, body, { headers, ...options });
api.patch(url, body, { headers, ...options });
api.put(url, body, { headers, ...options });
api.del(url, { headers, ...options });
| Options | Default | Description |
| ------------------------- | ------------------ | ---------------------------------------- |
| method
| "get"
| Default method to use for the call |
| url
| "/"
| The path or url for the request |
| baseUrl
| null
| The shared base of the API |
| body
| null
| The body to send with the request |
| query
| {}
| Add query parameters to the URL |
| headers
| {}
| Shared headers across all requests |
| output
| "body"
| The return value of the API call |
| cache
| { expire: 0 }
| How long to reuse the response body |
| before
| req => req
| Process the request before sending it |
| after
| res => res
| Process the response before returning it |
| error
| err => throw err
| Process errors before returning them |
Getting Started
Install it in your project:
npm install fch
Then import it to be able to use it in your code:
import fch from "fch";
const body = await fch.get("/");
On the browser you can add it with a script and it will be available as fch
:
<!-- Import it as usual -->
<script src="https://cdn.jsdelivr.net/npm/fch" type="module"></script>
<script type="module">
fch("/hello");
</script>
Options
These are all available options and their defaults:
import api from "fch";
// General options with their defaults; all of these are also parameters:
api.method = "get"; // Default method to use for api()
api.url = "/"; // Relative or absolute url where the request is sent
api.baseUrl = null; // Set an API base URL reused all across requests
api.query = {}; // Merged with the query parameters passed manually
api.headers = {}; // Merged with the headers on a per-request basis
// Control simple variables
api.output = "body"; // Return the parsed body; use 'response' or 'stream' otherwise
api.cache = { expires: 0 }; // Avoid sending GET requests that were already sent recently
// Interceptors
api.before = (req) => req;
api.after = (res) => res;
api.error = (err) => {
throw err;
};
They can all be defined globally as shown above, passed manually as the options argument, or be used when creating a new instance.
Method
The HTTP method to make the request. When using the shorthand, it defaults to GET
. We recommend using the method syntax:
import api from "fch";
api.get("/cats");
api.post("/cats", { name: "snowball" });
api.put(`/cats/3`, { name: "snowball" });
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:
// Recommended way of dealing with methods:
api.get(...);
// INVALID; won't work
api.GET(...);
// Both of these are valid:
api({ method; 'GET' })
api({ method; 'get'})
Example: adding a new cat and fixing a typo:
import api from "fch";
const cats = await api.get("/cats");
console.log(cats);
const { id } = await api.post("/cats", { name: "snowbll" });
await api.patch(`/cats/${id}`, { name: "snowball" });
Url
Specify where to send the request to. It must be the first argument in all methods and the base call:
import api from "fch";
await api("/hello", { method: "post", body: "...", headers: {} });
await api.post("/hello", body, { headers: {} });
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
:
import api from "fch";
api.baseUrl = "https//api.filemon.io/";
api.get("/hello");
// Called https//api.filemon.io/hello
Note: with Node.js you need to either set an absolute baseUrl or make the URL absolute
Body
These docs refer to the REQUEST body, for the RESPONSE body see the Output docs.
The body
can be a string, a plain object|array, a FormData instance, a ReadableStream, a SubmitEvent or a HTMLFormElement. If it's a plain array or object, it'll be stringified and the header application/json
will be added:
import api from "api";
// Sending plain text
await api.post("/houses", "plain text");
// Will JSON.stringify it internally and add the JSON headers
await api.post("/houses", { id: 1, name: "Cute Cottage" });
// Send it as FormData
form.onsubmit = (e) => api.post("/houses", new FormData(e.target));
// We have some helpers so you can just pass the Event or <form> itself!
form.onsubmit = (e) => api.post("/houses", e); // does not work with jquery BTW
form.onsubmit = (e) => api.post("/houses", e.target);
form.onsubmit = (e) => api.post("/houses", new FormData(e.target));
The methods GET
, HEAD
and DELETE
do not accept a body and it'll be ignored if you try to force it into the options.
Query
You can easily pass url query parameters by using the option query
:
api.get("/cats", { query: { limit: 3 } });
// /cats?limit=3
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, or on an instance:
import fch from "fch";
fch.query.myparam = "abc";
fch.get("/cats", { query: { limit: 3 } });
// /cats?limit=3&myparam=abc
const api = fch.create({ query: { sort: "asc" } });
api.get("/cats");
// /cats?sort=asc&myparam=abc
For POST or others, they go into the options as usual:
fch.post("/cats", { my: "body" }, { query: { abc: "def" } });
// /cats?abc=def
Headers
You can define headers in 4 ways:
- Globally, in which case they'll be added to every request
- On an instance, so they are added every time you use that instance
- Per-request, so that they are only added to the current request.
- In the
before
interceptor, which again can be globa, on an instance, or per-request
import fch from "fch";
// Globally, so they are reused across all requests
fch.headers.a = "b";
// With an interceptor, in case you need dynamic headers per-request
fch.before = (req) => {
req.headers.c = "d";
return req;
};
// Set them for this single request:
fch.get("/hello", { headers: { e: "f" } });
// Total headers on the request:
// { a: 'b', c: 'd', e: 'f' }
When to use each?
- If you need headers shared across all requests, like an API key, then the global one is the best place.
- If it's all the requests to only certain endpoint, like we are communicating to multiple endpoints use the instance
- 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.
- When it changes on each request, it's not consistent or it's an one-off, use the option argument.
Output
The default output manipulation is to expect either plain TEXT
as plain/text
or JSON
as application/json
from the Content-Type
. If your API works with these (the vast majority of APIs do) then you should be fine out of the box!
const cats = await api.get("/cats");
console.log(cats); // [{ id: 1, name: 'Whiskers', ... }, ...]
Note: for your typical API, we recommend sending the proper headers from the API and not using the more advanced options below.
For more expressive control, you can use the output
option (either as a default when creating an instance or with each call), or using a method:
const api = fch.create({ output: "json" }); // JSON by default
const streamImg = await api.get("/cats/123/image", { output: "stream" }); // Stream the image
const streamImg2 = await api.get("/cats/123/image").stream(); // Shortcut for the one above
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:
// Return only the body (default)
const body = await api.get("/data");
// Return the whole response (with .body):
const response = await api.get("/data", { output: "response" });
// Return a plain body stream
const stream = await api.get("/data", { output: "stream" });
stream.pipeTo(outStream);
// Return a blob, since `response.blob()` is available:
const blob = await api.get("/data", { output: "blob" });
There are few options that can be specified:
output: "body"
(default): returns the body, parsed as JSON or plain TEXT depending on the headers.output: "response"
: return the full response with the propertiesbody
,headers
,status
. The body will be parsed as JSON or plain TEXT depending on the headers. If you want the rawresponse
, useraw
orclone
instead (see below in "raw" or "clone").output: "stream"
: return a web ReadableStream of the body as the result of the promise.output: "arrayBuffer"
*: returns an arrayBuffer of the response body.output: "blob"
*: returns an arrayBuffer of the response body.output: "clone"
*: returns the raw Response, with the raw body. See alsoraw
below.output: "formData"
* (might be unavailable): returns an instance of FormData with all the parsed data.output: "json"
*: attempts to parse the response as JSON.output: "text"
*: puts the response body as plain text.output: "raw"
*: an alias forclone
, returning the raw response (after passing throughafter
).
* Standard MDN methods
The output
values can all be used as a method as well. So all of these are equivalent:
const text = await api.get("/cats", { output: "text" });
const text = await api.get("/cats").text();
const raw = await api.get("/cats", { output: "raw" });
const raw = await api.get("/cats").raw();
For example, return the raw body as a ReadableStream
with the option stream
:
const stream = await api.get('/cats', { output: 'stream' });
stream.pipeTo(...);
// or
const stream = await api.get('/cats').stream();
stream.pipeTo(...);
Cache
The cache (disabled by default) is a great method to reduce the number of API requests we make. While it's possible to modify the global fch to add a cache, we recommend to always use it per-instance or per-request. Options:
expire
: the amount of time the cached data will be valid for, it can be a number (seconds) or a string such as1hour
,1week
, etc. (based on parse-duration)store
: the store where the cached data will be stored.shouldCache
: a function that returns a boolean to determine whether the current data should go through the cache process.createKey
: a function that takes the request and generates a unique key for that request, which will be the same for the next time that same request is made. Defaults to method+url.
Note: cache should only be used through
fch.create({ cache: ... })
, not through the global instance.
To activate the cache, we just need to set a time as such:
// This API has 1h by default:
const api = fch.create({
baseUrl: 'https://api.myweb.com/'
cache: '1h'
});
// This specific call will be cached for 20s
api.get('/somedata', { cache: '20s' });
Your cache will have a store
; by default we create an in-memory store, but you can also use redis
and it's fully compatible. Note now cache
is now an object:
import fch from "fch";
import { createClient } from "redis";
// You can either pass the store instance, or a promise that will
// return the instance. In this case we are doing the latter
const store = createClient().connect();
const api = fch.create({
cache: {
store: store,
expire: "1h",
},
});
That's the basic usage, but "invalidating cache" is not one of the complex topics in CS for no reason. Let's dig deeper. To clear the cache, you can call cache.clear()
at any time:
const api = fch.create({ cache: "1h" });
// Remove them all
await api.cache.clear();
You can always access the store of the instance through api.cache.store
, so we can do low-level operations on the store as such if needed:
import fch from "fch";
import { createClient } from "redis";
// Initialize it straight away
const api = fch.create({
cache: {
store: createClient().connect(),
expire: "1h",
},
});
// Later on, maybe in a different place
await api.cache.store.flushDB();
Finally, the other two bits that are relevant for cache are shouldCache
and createKey
. For the most basic examples the default probably works, but you might want more advanced configuration:
const api = fch.create({
cache: {
// Default shouldCache; Note the lowercase
shouldCache: (request) => request.method === "get",
// Default createKey;
createKey: (request) => request.method + ":" + request.url,
},
});
For example, if you want to differentiate the auth requests from the non-auth requests, you can do it so:
import api from "./api";
const onLogin = (user) => {
// ... Do some other stuff
// Remove the old requests since we were not auth'ed yet
api.cache.clear();
// Create a key unique for this user
api.cache.createKey = (req) => user.id + ":" + req.method + ":" + req.url;
};
Or maybe you just want to NOT cache any of the requests that have an Authorization
header, you can do so:
const api = fch.create({
cache: {
expire: "1week",
// Note the lowercase in both! we normalize them to be lowercase
shouldCache: (req) => req.method === "get" && !req.headers.authorization,
},
});
It is this flexible since you can use fch both in the front-end and back-end, so usually in each of them you might want to follow a slightly different strategy.
Creating a store.
You might want to create a custom store. Please have a look at src/store.js
, but basically you need an object that implements these methods:
type Store = {
get: (key: string) => Promise<any>,
set: (key: string, value: any, options?: { EX: number }) => Promise<null>,
del: (key: string) => Promise<null>,
exists: (key: string) => Promise<boolean>,
flushAll: () => Promise<any>,
};
Interceptors
You can also add the interceptors before
, after
and error
:
before
: Called when the request is fully formed, but before actually launching it.after
: Called just after the response is created and if there was no error, but before parsing anything else.error
: When the response is not okay, if possible it'll include theresponse
object.
Note: interceptors are never deduped/cached and always execute once per call, even if the main async fetch() has been deduped.
// Perform an action or request transformation before the request is sent
fch.before = async req => {
// Normalized request ready to be sent
...
return req;
};
// Perform an action or data transformation after the request is finished
fch.after = async res => {
// Full response as just after the request is made
...
return res;
};
// Perform an action or data transformation when an error is thrown
fch.error = async err => {
// Need to re-throw if we want to throw on error
...
throw err;
// OR, resolve it as if it didn't fail
return err.response;
// OR, resolve it with a custom value
return { message: 'Request failed with a code ' + err.response.status };
};
How to
Stop errors from throwing
While you can handle this on a per-request basis, if you want to overwrite the global behavior you can write a interceptor:
import fch from "fch";
fch.output = "response";
fch.error = (error) => error.response;
const res = await fch("/notfound");
expect(res.status).toBe(404);
Return the full response
By default a successful request will just return the data. However this one is configurable on a global level:
import fch from "fch";
fch.output = "response";
const res = await fch("/hello");
console.log(res.status);
Or on a per-request level:
import fch from "fch";
// Valid values are 'body' (default) or 'response'
const res = await fch("/hello", { output: "response" });
// Does not affect others
const body = await fch("/hello");
It does perform some basic parsing of the body
, if you don't want any of that you can retrieve the very raw response:
import fch from "fch";
// Valid values are 'body' (default) or 'response'
const res = await fch("/hello", { output: "raw" });
console.log(res.body); // ReadableStream
Set a base URL
There's a configuration parameter for that:
import fch from "fch";
fch.baseUrl = "https://api.filemon.io/";
// Calls "https://api.filemon.io/blabla"
const body = await fch.get("/blabla");
Set authorization headers
You can set that globally as a header:
import fch from "fch";
fch.headers.Authorization = "bearer abc";
const me = await fch("/users/me");
Or globally on a per-request basis, for example if you take the value from localStorage:
import fch from "fch";
// All the requests will add the Authorization header when the token is
// in localStorage
fch.before = (req) => {
if (localStorage.get("token")) {
req.headers.Authorization = "bearer " + localStorage.get("token");
}
return req;
};
const me = await fch("/users/me");
Or on a per-request basis, though we wouldn't recommend this:
import fch from "fch";
const me = await fch("/users/me", { headers: { Authorization: "bearer abc" } });
Create an instance
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:
import fch from 'fch';
const api = fch.create({
baseUrl: 'https://api.filemon.io/',
...
});
api.get('/hello'); // Gets https://api.filemon.io/hello
fch.get('/hello'); // Gets http://localhost:3000/hello (or wherever you are)
Note: for server-side (Node.js) usage, you always want to set baseUrl
.
Streaming a response body
To stream the body, you need to use the output: "stream"
option so that it returns a WebStream ReadableStream:
import fch from "fch";
// Valid values are 'body' (default) or 'response'
const stream = await fch("/data", { output: "stream" });
stream.pipeTo(outStream);
You might want to convert it to a Node.js ReadStream:
import fch from "fch";
import { Readable } from "node:stream";
const stream = await fch("/data", { output: "stream" });
const readableNodeStream = Readable.fromWeb(stream);
// ...
Cancel ongoing requests
You can cancel ongoing requests similarly to native fetch(), by passing it a signal:
import api from "fch";
const controller = new AbortController();
const signal = controller.signal;
abortButton.addEventListener("click", () => {
controller.abort();
console.log("Download aborted");
});
api.get(url, { signal });
Define shared options
You can also define values straight away:
import api from "fch";
api.baseUrl = "https://pokeapi.co/";
const mew = await api.get("/pokemon/150");
console.log(mew);
You can also create an instance that will have the same options for all requests made with that instance.
Node.js vs Browser
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.
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.
Differences with Axios
The main difference is that things are simplified with fch:
// Modify headers
axios.defaults.headers.Authorization = "...";
fch.headers.Authorization = "...";
// Set a base URL
axios.defaults.baseURL = "...";
fch.baseUrl = "...";
// Add an interceptor
axios.interceptors.request.use(fn);
fch.before = fn;
API size is also strikingly different, with 7.8kb for Axios and 1.1kb for fch.
As disadvantages, I can think of two major ones for fch
:
- Requires Node.js 18+, which is the version that includes
fetch()
by default. - Does not support many of the more advanced options, like
onUploadProgress
noronDownloadProgress
.
Releases
V4
Breaking changes:
- Only ESM exports. Meaning, if you use it in a browser you'll need the
<script type="module">
. - The method
fch.del()
(and derivates with fch.create()) have been renamed tofch.delete()
.
Changes:
- Added
output
options:raw
,stream
,arrayBuffer
,blob
,clone
,formData
,json
,text
- Gone from 1.2kb down to 1.0kb