A simple and powerful high order function to extend fetch
for baseUrl, default headers, and
interceptors.
See interactive documentation
or
demo.
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 (requestArgs, response) => {
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);
Output
********* before sending request *********
url: https://jsonplaceholder.typicode.com/todos/1
requestInit: { method: 'GET', headers: { Accept: 'application/json' } }
********* after receiving response *********
url: https://jsonplaceholder.typicode.com/todos/1
requestInit: { method: 'GET', headers: { Accept: 'application/json' } }
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
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.
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.
- 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.
Via npm
npm install return-fetch
Via yarn
yarn add return-fetch
Via pnpm
pnpm add return-fetch
<!--
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>
Run on Stickblitz.
https://return-fetch.myeongjae.kim/docs/types/ReturnFetchDefaultOptions.html
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 (requestArgs, 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");
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));
You can import below example code because it is published as a separated npm package: 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<string>;
// 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);
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: "this is an object of `ApiResponse['data']`"}, // body should be an object.
}).catch((e) => alert(e.message));
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.
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!"
}
},
);
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!"
}
},
);
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!"
}
},
);
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!"
}
},
);
(I have not written a documents for React Native yet, but it surely works with React Native becuase it does not have
any dependencies on a specific fetch
implementation.)
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.
window.fetch = fetchExtended;
//////////////////// Use it somewhere ////////////////////
fetch("/sample/api/echo", {
method: "POST",
body: JSON.stringify({message: "Hello, world!"})
}).catch((e) => alert(e.message));
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.
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);
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);
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)))
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)))
- return-fetch-json: A package that serialize request body object and deserialize response body as a JSON object.
MIT © Myeongjae Kim