Writing a custom fetch handler for JS Server Frameworks

Introduction

This is a quick look at performing fetch requests from within a JS server framework. This is written somewhat in the context of Hono but really this can be applied to framework.

Let's get started.

TL;DR

If you don't want to go through all the individual steps, here's the final code:

import { ofetch, type FetchOptions, type FetchHooks } from 'ofetch';
import defu from 'defu';

/**
 * ofetch 'onRequest' hook for logging the request.
 */
export const logRequest: FetchHooks['onRequest'] = ({ request, options }) => {
  console.log('-->', options.method || 'GET', `${options.baseURL || ''}${request.toString()}`);
};

/**
 * ofetch 'onResponse' hook for logging the response.
 */
export const logResponse: FetchHooks['onResponse'] = ({ request, response, options }) => {
  console.log('<--', options.method || 'GET', request.toString(), response.status);
};

/**
 * ofetch 'onResponseError' hook for logging the error response.
 */
export const logResponseError: FetchHooks['onResponseError'] = ({ request, response, options }) => {
  console.log(
    'xxx',
    options.method || 'GET',
    request.toString(),
    response.status,
    response._data
  );
};

export const useFetch = (baseURL: string, options?: FetchOptions) => {
  const defaults: FetchOptions = {
    baseURL,
    onRequest: [logRequest],
    onResponse: [logResponse],
    onResponseError: [logResponseError],
  };

  const opts = defu(options, defaults);

  const $fetch = ofetch.create({ baseURL });

  return { $fetch }
}

What are we trying to achieve?

We'll start with a simple baseline understanding of what we're trying to achieve. Often when writing an API we will potentially want to make calls to other APIs. For this we will take advantage of the native fetch API to perform this request, await it, get the response, etc...

This will probably look something like this:

const myFunction = <T>(input: T) => {
  // Some pre-amble code

  const res = await fetch('https://my-external-service/my-endpoint', {
    body: input,
  });

  return await res.json();
};

Now there's nothing particularly wrong with this code, outside of a lack of error handling and probably a whole host of other things.

But if you need to make several different requests, (potentially across a whole host of different functions) then you might so something like this:

const baseURL = 'https://my-external-service';

const myFunction = <T>(input: T) => {
  // Some pre-amble code

  const res = await fetch(`${baseURL}/my-endpoint`, {
    body: input,
  });

  return await res.json();
};

/// ...

Which helps again, but this could still be improved. Right now if we want to log things like the outgoing request and the incoming response, this is all done manually. And again, maintaining this code is a bit of a pain. Let's see what we can do to improve this further.

ofetch

Before we continue, I'm going to switch over to use ofetch instead of the native fetch API. This is purely a preference and you can easily adapt the code to fit either the native API or another library such as Axios.

If you haven't heard of ofetch then check it out; I think you'll be pleasantly surprised.

Let's Improve things

Here's my take on how to solve this. We'll start with taking what we had previously and improving it the base URL set up.

/* utils/useFetch or wherever you want */
import { ofetch } from 'ofetch';

export const useFetch = (baseURL: string) => {
  const $fetch = ofetch.create({ baseURL });

  return { $fetch }
}
/* some/service/layer/somewhere.ts */

const { $fetch } = useFetch('https://my-external-service');

const myFunction = <T>(input: T) => {
  // Some pre-amble code

  const res = await $fetch('/my-endpoint', {
    body: input,
  });

  return res; // ofetch helpfully unwraps the response for you based on the response content type.
};

Now, this might seem a bit odd, we've written more code not less, but this now makes things far more composable. We can easily reuse this throughout our application and we get a nice typesafe API too.

But we can improve things further. Let's add the ability to customise the created ofetch instance. We'll add in a new options parameter and take advantage of defu to smoosh (combine) these with our existing options.

/* utils/useFetch or wherever you want */
import { ofetch, type FetchOptions } from 'ofetch';
import defu from 'defu';

export const useFetch = (baseURL: string, options?: FetchOptions) => {
  const defaults: FetchOptions = {
    baseURL,
  };

  const opts = defu(options, defaults);

  const $fetch = ofetch.create({ baseURL });

  return { $fetch }
}

Now using this, looks like this:

/* some/service/layer/somewhere.ts */
const { $fetch } = useFetch('https://my-external-service', { myCustomOptions: 'for my whole service' });

const myFunction = <T>(input: T) => {
  // Some pre-amble code

  const res = await $fetch('/my-endpoint', {
    body: input,
    // And you can still customise the individual request here
  });

  return res;
};

Logging

As mentioned above, we might also want to automatically log every outgoing request and incoming response, we can modify things to make this happen automatically.

/* utils/useFetch or wherever you want */
import { ofetch, type FetchOptions, type FetchHooks } from 'ofetch';
import defu from 'defu';

/**
 * ofetch 'onRequest' hook for logging the request.
 */
export const logRequest: FetchHooks['onRequest'] = ({ request, options }) => {
  console.log('-->', options.method || 'GET', `${options.baseURL || ''}${request.toString()}`);
};

/**
 * ofetch 'onResponse' hook for logging the response.
 */
export const logResponse: FetchHooks['onResponse'] = ({ request, response, options }) => {
  console.log('<--', options.method || 'GET', request.toString(), response.status);
};

/**
 * ofetch 'onResponseError' hook for logging the error response.
 */
export const logResponseError: FetchHooks['onResponseError'] = ({ request, response, options }) => {
  console.log(
    'xxx',
    options.method || 'GET',
    request.toString(),
    response.status,
    response._data
  );
};

export const useFetch = (baseURL: string, options?: FetchOptions) => {
  const defaults: FetchOptions = {
    baseURL,
    onRequest: [logRequest],
    onResponse: [logResponse],
    onResponseError: [logResponseError],
  };

  const opts = defu(options, defaults);

  const $fetch = ofetch.create({ baseURL });

  return { $fetch }
}

This was, any time you use the $fetch method, you'll automatically log every you could need.

Summary

I feel like this finds a nice balance between ease of use and reusability for a developer. I hope you like it!

In our actual implementation, we've extended this with authorization headers retrieved from the Hono session and you can customise things even further to your liking.

Written by

Alex Ashwood

Software Engineer and maker of things.

main UTF-8MarkdownLn 1, Col 12026 © Alex Ashwood