import returnFetch from "return-fetch";
const fetchExtended = returnFetch({
baseUrl: "https://jsonplaceholder.typicode.com",
headers: { Accept: "application/json" },
interceptors: {
request: async (args) => {
console.log("********* before sending request *********");
console.log("url:", args[0].toString());
console.log("requestInit:", args[1], "\n\n");
return args;
},
response: async (response, requestArgs) => {
console.log("********* after receiving response *********");
console.log("url:", requestArgs[0].toString());
console.log("requestInit:", requestArgs[1], "\n\n");
return response;
},
},
});
fetchExtended("/todos/1", { method: "GET" })
.then((it) => it.text())
.then(console.log);
Click above 'Run' button.
To see how powerful it is, go to an example composing multiple customized returnFetch
.
Background
The Next.js framework(which I love so much) v13 App Router uses
its own fetch
implementation that extends node-fetch
to do
server side things like caching. I was accustomed to using Axios for API calls, but
I have felt that now is the time to replace Axios with fetch
finally. The most disappointing aspect I found when
trying to replace Axios with fetch
was that fetch
does not have any interceptors. I thought surely someone
must have implemented it, so I searched for libraries. However, there was no library capable of handling various
situations, only one that could add a single request and response interceptors to the global fetch
. That is the
reason why I decided to implement it myself.
Philosophy
In implementing the fetch
interceptors, I considered the following points:
- Minimalistic. I decided to only implement the following additional functions and not others:
- Implementing request and response interceptors
- Specifying a baseUrl
- Setting a default header
- No peer dependencies. I decided not to use any other libraries. I would like to keep the library
as light as possible, and running on any execution environments which have
fetch
(e.g. Node.js, Web Browsers, React Native, Web Workers). - It should be easy to add interceptors because
return-fetch
will provide minimal functionality. Users should be able to extend fetch as they wish. - Codes to add interceptors should be reusable and able to maintain the Single Responsibility Principle (SRP), and it should be possible to combine interceptors that adhere to the SRP.
- Liskov Substitution Principle (LSP) should be maintained. The
fetch
function created byreturn-fetch
should be able to replace the standardfetch
function anywhere without any problems.
Good Things
- Superlight bundle size < 1KB.
- No peer dependencies.
- No side effects. Pure functional.
- No classes. Just a function.
- Recursive type definition to chain functions infinitely.
- Any execution environment having fetch, possible for any
fetch
polyfills. - 100% TypeScript.
- 100% test coverage.
Installation
Package Manager
Via npm
npm install return-fetch
Via yarn
yarn add return-fetch
Via pnpm
pnpm add return-fetch
<script> tag
<!--
Pick your favourite CDN:
- https://unpkg.com/return-fetch
- https://cdn.jsdelivr.net/npm/return-fetch
- https://www.skypack.dev/view/return-fetch
- …
-->
<!-- UMD import as window.returnFetch -->
<script src="https://unpkg.com/return-fetch"></script>
<!-- Modern import -->
<script type="module">
import returnFetch from 'https://cdn.skypack.dev/return-fetch/dist/index.js'
// ... //
</script>
Demo
Run on Stickblitz.
Types
Usage
You can find the source code of below examples here.
#1. Display/Hide loading indicator
import returnFetch, { ReturnFetch } from "return-fetch";
import { displayLoadingIndicator, hideLoadingIndicator } from "@/your/adorable/loading/indicator";
// Write your own high order function to display/hide loading indicator
const returnFetchWithLoadingIndicator: ReturnFetch = (args) => returnFetch({
...args,
interceptors: {
request: async (args) => {
setLoading(true);
return args;
},
response: async (response) => {
setLoading(false);
return response;
},
},
})
// Create an extended fetch function and use it instead of the global fetch.
export const fetchExtended = returnFetchWithLoadingIndicator({
// default options
});
//////////////////// Use it somewhere ////////////////////
fetchExtended("/sample/api");
Click above 'Run' button. You will see a loading indicator.
#2. Throw an error if a response status is greater than or equal to 400
import returnFetch, { ReturnFetch } from "return-fetch";
// Write your own high order function to throw an error if a response status is greater than or equal to 400.
const returnFetchThrowingErrorByStatusCode: ReturnFetch = (args) => returnFetch({
...args,
interceptors: {
response: async (response) => {
if (response.status >= 400) {
throw await response.text().then(Error);
}
return response;
},
},
})
// Create an extended fetch function and use it instead of the global fetch.
export const fetchExtended = returnFetchThrowingErrorByStatusCode({
// default options
});
//////////////////// Use it somewhere ////////////////////
fetchExtended("/sample/api/400").catch((e) => { alert(e.message); });
Click above 'Run' button. You will see an alert.
#3. Serialize request body and deserialize response body
You can import below example code because it is published as a separated npm return-fetch-json
import returnFetch, { FetchArgs, ReturnFetchDefaultOptions } from "return-fetch";
// Use as a replacer of `RequestInit`
type JsonRequestInit = Omit<NonNullable<FetchArgs[1]>, "body"> & { body?: object };
// Use as a replacer of `Response`
export type ResponseGenericBody<T> = Omit<
Awaited<ReturnType<typeof fetch>>,
keyof Body | "clone"
> & {
body: T;
};
export type JsonResponse<T> = T extends object
? ResponseGenericBody<T>
: ResponseGenericBody<unknown>;
// this resembles the default behavior of axios json parser
// https://github.com/axios/axios/blob/21a5ad34c4a5956d81d338059ac0dd34a19ed094/lib/defaults/index.js#L25
const parseJsonSafely = (text: string): object | string => {
try {
return JSON.parse(text);
} catch (e) {
if ((e as Error).name !== "SyntaxError") {
throw e;
}
return text.trim();
}
};
// Write your own high order function to serialize request body and deserialize response body.
export const returnFetchJson = (args?: ReturnFetchDefaultOptions) => {
const fetch = returnFetch(args);
return async <T>(
url: FetchArgs[0],
init?: JsonRequestInit,
): Promise<JsonResponse<T>> => {
const response = await fetch(url, {
...init,
body: init?.body && JSON.stringify(init.body),
});
const body = parseJsonSafely(await response.text()) as T;
return {
headers: response.headers,
ok: response.ok,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
type: response.type,
url: response.url,
body,
} as JsonResponse<T>;
};
};
// Create an extended fetch function and use it instead of the global fetch.
export const fetchExtended = returnFetchJson({
// default options
});
//////////////////// Use it somewhere ////////////////////
export type ApiResponse<T> = {
status: number;
statusText: string;
data: T;
};
fetchExtended<ApiResponse<{ message: string }>>("/sample/api/echo", {
method: "POST",
body: { message: "Hello, world!" }, // body should be an object.
}).then(it => it.body);
Click above 'Run' button.
#4. Compose above three high order functions to create your awesome fetch 🥳
Because of the recursive type definition, you can chain extended returnFetch
functions as many as you want. It
allows you to write extending functions which are responsible only for a single feature. It is a good practice to stick
to the Single Responsibility Principle and writing a reusable function to write clean code.
import {
returnFetchJson,
returnFetchThrowingErrorByStatusCode,
returnFetchWithLoadingIndicator
} from "@/your/customized/return-fetch";
/*
Compose high order functions to create your awesome fetch.
1. Add loading indicator.
2. Throw an error when a response's status code is equal to 400 or greater.
3. Serialize request body and deserialize response body as json and return it.
*/
export const fetchExtended = returnFetchJson({
fetch: returnFetchThrowingErrorByStatusCode({
fetch: returnFetchWithLoadingIndicator({
// default options
}),
}),
});
//////////////////// Use it somewhere ////////////////////
fetchExtended("/sample/api/echo", {
method: "POST",
body: { message: "Hello, world!" }, // body should be an object.
}).catch((e) => { alert(e.message); });
Click above 'Run' button. You will see a loading indicator.
#5. Use any fetch
implementation
The fetch
has been added since Node.js v17.5 as an experimental feature,
also available from Node.js v16.15 and
still experimental(29 JUL 2023, v20.5.0). You can use
'node-fetch' as a polyfill for Node.js v16.15 or lower,
or 'whatwg-fetch' for old web browsers, or
'cross-fetch' for both web browser and Node.js.
Next.js has already included 'node-fetch' and they extend it for server-side things like caching.
Whatever a fetch
you use, you can use return-fetch
as long as the fetch
you use is compatible with the
Fetch API.
#5-1. node-fetch
I implemented a simple proxy for https://postman-echo.com with node-fetch
as an example using
Next.js route handler.
// src/app/sample/api/proxy/postman-echo/node-fetch/[[...path]]/route.ts
import { NextRequest } from "next/server";
import nodeFetch from "node-fetch";
import returnFetch, { ReturnFetchDefaultOptions } from "return-fetch";
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"; // to turn off SSL certificate verification on server side
const pathPrefix = "/sample/api/proxy/postman-echo/node-fetch";
export async function GET(request: NextRequest) {
const { nextUrl, method, headers } = request;
const fetch = returnFetch({
// Use node-fetch instead of global fetch
fetch: nodeFetch as ReturnFetchDefaultOptions["fetch"],
baseUrl: "https://postman-echo.com",
});
const response = await fetch(nextUrl.pathname.replace(pathPrefix, ""), {
method,
headers,
});
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
Send a request to the proxy route.
import {
returnFetchJson,
returnFetchThrowingErrorByStatusCode,
returnFetchWithLoadingIndicator
} from "@/your/customized/return-fetch";
export const fetchExtended = returnFetchJson({
fetch: returnFetchThrowingErrorByStatusCode({
fetch: returnFetchWithLoadingIndicator({
baseUrl: "https://return-fetch.myeongjae.kim",
}),
}),
});
//////////////////// Use it somewhere ////////////////////
fetchExtended(
"/sample/api/proxy/postman-echo/node-fetch/get",
{
headers: {
"X-My-Custom-Header": "Hello World!"
}
},
);
Click above 'Run' button.
#5-2. whatwg-fetch
whatwg-fetch
is a polyfill for browsers. I am going to send a request using whatwg-fetch
to the proxy route.
import {
returnFetchJson,
returnFetchThrowingErrorByStatusCode,
returnFetchWithLoadingIndicator
} from "@/your/customized/return-fetch";
import { fetch as whatwgFetch } from "whatwg-fetch";
export const fetchExtended = returnFetchJson({
fetch: returnFetchThrowingErrorByStatusCode({
fetch: returnFetchWithLoadingIndicator({
fetch: whatwgFetch, // use whatwgFetch instead of browser's global fetch
baseUrl: "https://return-fetch.myeongjae.kim",
}),
}),
});
//////////////////// Use it somewhere ////////////////////
fetchExtended(
"/sample/api/proxy/postman-echo/node-fetch/get",
{
headers: {
"X-My-Custom-Header": "Hello World!"
}
},
);
Click above 'Run' button.
#5-3. cross-fetch
I implemented a simple proxy for https://postman-echo.com with cross-fetch
as an example using
Next.js route handler.
// src/app/sample/api/proxy/postman-echo/cross-fetch/[[...path]]/route.ts
import { NextRequest } from "next/server";
import crossFetch from "cross-fetch";
import returnFetch from "return-fetch";
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"; // to turn off SSL certificate verification on server side
const pathPrefix = "/sample/api/proxy/postman-echo/cross-fetch";
export async function GET(request: NextRequest) {
const { nextUrl, method, headers } = request;
const fetch = returnFetch({
fetch: crossFetch, // Use cross-fetch instead of built-in Next.js fetch
baseUrl: "https://postman-echo.com",
});
const response = await fetch(nextUrl.pathname.replace(pathPrefix, ""), {
method,
headers,
});
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
Send a request to the proxy route using cross-fetch
on the client-side also.
import {
returnFetchJson,
returnFetchThrowingErrorByStatusCode,
returnFetchWithLoadingIndicator
} from "@/your/customized/return-fetch";
import crossFetch from "cross-fetch";
export const fetchExtended = returnFetchJson({
fetch: returnFetchThrowingErrorByStatusCode({
fetch: returnFetchWithLoadingIndicator({
fetch: crossFetch, // Use cross-fetch instead of browser's global fetch
baseUrl: "https://return-fetch.myeongjae.kim",
}),
}),
});
//////////////////// Use it somewhere ////////////////////
fetchExtended(
"/sample/api/proxy/postman-echo/node-fetch/get",
{
headers: {
"X-My-Custom-Header": "Hello World!"
}
},
);
Click above 'Run' button.
#5-4. Next.js built-in fetch
I implemented a simple proxy for https://postman-echo.com with Next.js built-in fetch
as an example using
Next.js route handler.
// src/app/sample/api/proxy/postman-echo/nextjs-fetch/[[...path]]/route.ts
import { NextRequest } from "next/server";
import returnFetch from "return-fetch";
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"; // to turn off SSL certificate verification on server side
const pathPrefix = "/sample/api/proxy/postman-echo/nextjs-fetch";
export async function GET(request: NextRequest) {
const { nextUrl, method, headers } = request;
const fetch = returnFetch({
// omit fetch option to use Next.js built-in fetch
baseUrl: "https://postman-echo.com",
});
const response = await fetch(nextUrl.pathname.replace(pathPrefix, ""), {
method,
headers,
});
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
Send a request to the proxy route using web browser default fetch
.
import {
returnFetchJson,
returnFetchThrowingErrorByStatusCode,
returnFetchWithLoadingIndicator
} from "@/your/customized/return-fetch";
export const fetchExtended = returnFetchJson({
fetch: returnFetchThrowingErrorByStatusCode({
fetch: returnFetchWithLoadingIndicator({
baseUrl: "https://return-fetch.myeongjae.kim",
}),
}),
});
//////////////////// Use it somewhere ////////////////////
fetchExtended(
"/sample/api/proxy/postman-echo/nextjs-fetch/get",
{
headers: {
"X-My-Custom-Header": "Hello World!"
}
},
);
Click above 'Run' button.
#5-5. React Native
(I have not written documents for React Native yet, but it surely works with React Native becuase it does not have
any dependencies on a specific fetch
implementation.)
#6. Replace default fetch
with your customized returnFetch
import {
returnFetchJson,
returnFetchThrowingErrorByStatusCode,
returnFetchWithLoadingIndicator
} from "@/your/customized/return-fetch";
// save global fetch reference.
const globalFetch = fetch;
export const fetchExtended = returnFetchThrowingErrorByStatusCode({
fetch: returnFetchWithLoadingIndicator({
fetch: globalFetch, // use global fetch as a base.
}),
});
// replace global fetch with your customized fetch.
globalThis.fetch = fetchExtended;
//////////////////// Use it somewhere ////////////////////
fetch("/sample/api/echo", {
method: "POST",
body: JSON.stringify({ message: "Hello, world!" })
}).catch((e) => { alert(e.message); });
I didn't write an interactive example for this because replacing global fetch with a customized one will break other examples. Test on your own example and you are going to see that it works well.
#7. Use URL
or Request
object as a first argument of fetch
The type of a first argument of fetch
is Request | string | URL
and a second argument is
RequestInit | undefined
. Through above examples, we used a string as a first argument of fetch
.
return-fetch
is also able to handle a Request
or URL
object as a first argument. It does not restrict
any features of fetch
.
Be careful! the default options' baseURL does not applied to a URL
or Request
object. Default headers are
still applied to a Request
object as you expected.
#7-1. URL
object as a first argument
Even a baseUrl
is set to 'https://google.com', it is not applied to a URL
object. An URL
object cannot be
created if an argument does not have origin. You should set a full URL
to a URL
object, so a baseUrl
will
be ignored.
import returnFetch from "return-fetch";
const fetchExtended = returnFetch({
baseUrl: "https://google.com",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-My-Header": "Hello, world!",
},
});
fetchExtended(
/*
Even a baseURL is set to 'https://google.com', it is not applied to a URL object.
An URL object cannot be created if an argument does not have origin.
You should set a full URL to a URL object, so a baseURL will be ignored.
*/
new Request(new URL("https://return-fetch.myeongjae.kim/sample/api/proxy/postman-echo/nextjs-fetch/post"), {
method: "PUT",
body: JSON.stringify({ message: "overwritten by requestInit" }),
headers: {
"X-My-Headers-In-Request-Object": "Works well!",
},
}),
{
method: "POST",
body: JSON.stringify({ message: "Hello, world!" }),
},
)
.then((it) => it.json())
.then(console.log);
Click above 'Run' button.
#7-2. Request
object as a first argument
Even a baseUrl
is set to 'https://google.com', it is not applied to a Request
object. While creating a
Request
object, an origin is set to 'https://return-fetch.myeongjae.kim', which is the origin of this page,
so baseUrl
will be ignored.
On Node.js, a Request
object cannot be created without an origin, same as URL
object.
import returnFetch from "return-fetch";
const fetchExtended = returnFetch({
baseUrl: "https://google.com",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-My-Header": "Hello, world!",
},
});
fetchExtended(
/*
Even a baseURL is set to 'https://google.com', it is not applied to a Request object.
While creating a Request object, an origin is set to 'https://return-fetch.myeongjae.kim',
which is the origin of this page, so baseURL will be ignored.
*/
new Request("/sample/api/proxy/postman-echo/node-fetch/post", {
method: "PUT",
body: JSON.stringify({ message: "overwritten by requestInit" }),
headers: {
"X-My-Headers-In-Request-Object": "Works well!",
},
}),
{
method: "POST",
body: JSON.stringify({ message: "Hello, world!" }),
},
)
.then((it) => it.json())
.then(console.log);
Click above 'Run' button.
#8. Retry a request
Interceptors are async functions. You can make an async call in interceptors. This example shows how to retry a request when a response status is 401.
let retryCount = 0;
const returnFetchRetry: ReturnFetch = (args) => returnFetch({
...args,
interceptors: {
response: async (response, requestArgs, fetch) => {
if (response.status !== 401) {
return response;
}
console.log("not authorized, trying to get refresh cookie..");
const responseToRefreshCookie = await fetch(
"https://httpstat.us/200",
);
if (responseToRefreshCookie.status !== 200) {
throw Error("failed to refresh cookie");
}
retryCount += 1;
console.log(`(#${retryCount}) succeeded to refresh cookie and retry request`);
return fetch(...requestArgs);
},
},
});
const fetchExtended = returnFetchRetry({
baseUrl: "https://httpstat.us",
});
fetchExtended("/401")
.then((it) => it.text())
.then((it) => `Response body: "${it}"`)
.then(console.log)
.then(() => console.log("\n Total counts of request: " + (retryCount + 1)))
Click above 'Run' button.
#8-1. Doubling retry counts
If you nest returnFetchRetry
, you can retry a request more than once. When you nest 4 times, you can retry a
request 16 times (I know it is too much, but isn't it fun?).
let retryCount = 0;
// create a fetch function with baseUrl applied
const fetchBaseUrlApplied = returnFetch({ baseUrl: "https://httpstat.us" });
const returnFetchRetry: ReturnFetch = (args) => returnFetch({
...args,
// use fetchBaseUrlApplied as a default fetch function
fetch: args?.fetch ?? fetchBaseUrlApplied,
interceptors: {
response: async (response, requestArgs, fetch) => {
if (response.status !== 401) {
return response;
}
console.log("not authorized, trying to get refresh cookie..");
const responseToRefreshCookie = await fetch(
"/200",
);
if (responseToRefreshCookie.status !== 200) {
throw Error("failed to refresh cookie");
}
retryCount += 1;
console.log(`(#${retryCount}) succeeded to refresh cookie and retry request`);
return fetch(...requestArgs);
},
},
});
const nest = (
remaining: number,
providedFetch = fetchBaseUrlApplied,
): ReturnType<ReturnFetch> =>
remaining > 0
? nest(remaining - 1, returnFetchRetry({ fetch: providedFetch }))
: providedFetch;
// nest 4 times -> 2^4 = 16
const fetchExtended = nest(4);
fetchExtended("/401")
.then((it) => it.text())
.then((it) => `Response body: "${it}"`)
.then(console.log)
.then(() => console.log("\n Total counts of request: " + (retryCount + 1)))
Click above 'Run' button.
Derived Packages
- return-fetch-json: A package that serialize request body object and deserialize response body as a JSON object.