Oscar Funes' Blog

Using cache in hapi.js server

April 25, 2020

For the last few months, I’ve been using hapi.js at work. We’re planning on migrating an application from express.js to this framework. I’ve been reading the documentation, and even opened a request in the hapi.js repository.

How to register a cache provider?

Catbox provides the cache infrastructure for a Hapi.js server.

const server = hapi.server({
  cache: {
    provider: {
      constructor: require('@hapi/catbox-redis'),
      options: { partition: 'hapi-cache' },
    },
  },
})

This configuration only defines where you can store data, but doesn’t start saving things into the cache. That has to be done explicitly by the app developer.

It’s worth noting that hapi injects the options object to the provider constructor, in this example, to catbox-redis.

Hapi ships with a default cache storage, which is catbox-memory, in the example above, I didn’t set a name for that cache, so it overrides catbox-memory as the default.

If you want to avoid that, you can pass a name property, sibling to provider and options, e.g.,

const server = hapi.server({
  cache: {
    name: 'redis',
    provider: {
      constructor: require('@hapi/catbox-redis'),
      options: { partition: 'hapi-cache', host: 'localhost', port: '6380' },
    },
  },
})

How to use the configured cache storage?

server.cache()

The first way to access the storage is through the server.cache() method, it accepts the options related to a catbox policy.

A policy is a higher-level abstraction to a client. While a client allows get/set/drop keys mainly, a policy provides a more convenient cache interface where you can set expire rules, staleness, drops on errors, and so on, you can also access stats and events.

const cache = server.cache({
  segment: 'countries', // name of the segment where were are storing values
  expiresIn: 60 * 60 * 1000, // milliseconds
})

If you’ve noticed, when configuring, we set a partition, and now we set a segment. A partition can contain multiple segments, expectation is that a partition is an isolated area where the cache is stored. Partitions are implemented differently in the different strategies, meaning Redis from memory from MongoDB, and others. A segment is a grouping of IDs within a single partition, in our example is countries. Segments avoid collision of IDs within a partition.

Now that we provided a cache, we can use the operations from a policy:

await cache.set('norway', { capital: 'oslo' })
// if using redis, this would be stored with Key `hapi-cache:countries:norway`.

const value = await cache.get('norway') // returns { capital: "oslo" }

console.log(cache.stats)

server.method()

The second way to store elements in the cache is by caching a server.method You can create server methods to share cross-cutting concerns of functionality across an application. They also allowed to be cached; generally we want this with external calls, like calling an upstream service.

const Wreck = require('@hapi/wreck')

async function getCountryByName(name) {
  const { res, payload } = await Wreck.get(
    `https://restcountries.eu/rest/v2/name/${name}`
  )
  return payload
}

server.method('countryByName', getCountryByName, {
  cache: {
    expiresIn: 60 * 60 * 1000,
    generateTimeout: 100, // how long can the getCountryByName take
  },
})

const value = server.methods.countryByName('norway')

In this example, we don’t need a segment, because hapi assigns the segment to be #name where name is the method name, i.e., #countryByName.

The keys will end up being hapi-cache:#countryByName:norway.

We can achieve a similar functionality using server.cache by providing a generateFunc. For example:

const cache = server.cache({
  cache: 'redis', // required when choosing a cache storage different from default
  segment: 'countries', // name of the segment where were are storing values
  expiresIn: 60 * 60 * 1000, // milliseconds
  generateTimeout: 100, // require when using generateFunc
  async generateFunc(name) {
    const { res, payload } = await Wreck.get(
      `https://restcountries.eu/rest/v2/name/${name}`
    )
    return payload
  },
})

const value = await cache.get('norway')

In this case, there’s no need to do a set first, because when doing a .get('norway') and that not being in the cache, it invokes the generate function to create and store that value in the cache.

Also, generateFunc is how hapi internally assigns server.methods with a cache instance.

Manually handling TTL of items in the cache

Time to live (TTL) is the time that an object is stored in a caching system before it’s deleted or refreshed.

Having a server.method as a generateFunc allows us to access a property of the signature, which is flags.

Every generate function signature is: async function(id, flags), the flags is what we’re interested right now. It is an object which contains a ttl property, which we can use to override the expiresIn value for an element, or set as 0 for skip storing the value in the cache.

In the case of the restcountries api, they send back a cache-control header, which we could use to set the TTL of the element we’re returning from the server method.

Let’s take again our server method example:

const Wreck = require('@hapi/wreck')

async function getCountryByName(name) {
  const { res, payload } = await Wreck.get(
    `https://restcountries.eu/rest/v2/name/${name}`
  )

  if (!res.heades['cache-control']) {
    // use expiresIn value
    return payload
  }

  const cacheControl = Wreck.parseCacheControl(res.heades['cache-control'])

  if (cacheControl['no-store']) {
    // don't store in cache
    flags.ttl = 0
  }

  if (cacheControl['max-age']) {
    // override default expiresIn
    flags.ttl = cacheControl['max-age']
  }

  return payload
}

server.method('countryByName', getCountryByName, {
  cache: {
    expiresIn: 60 * 60 * 1000,
    generateTimeout: 100, // how long can the getCountryByName take
  },
})

const value = server.methods.countryByName('norway')

Adding storage in runtime

There’s a method called server.cache.provision(), which allows provisioning at runtime in the same way as at server creation through the hapi.server() method.

For example, mimicking the original example:

server.cache.provision({
  name: 'runtime-redis',
  provider: {
    constructor: require('@hapi/catbox-redis'),
    options: { partition: 'hapi-cache' },
  },
})

Using provision() requires to provide a name property. Due to the server already being initialized, and hapi sets the default store upon creation of a server.

My initial thoughts when using this was a bit of confusion, because why would you need this as an app developer. I imagine someone else can find a use case as an app developer. But I found the use case while being a plugin developer. :P

Recently at work, we wanted to cache specific values, surfaced through a server method, which was configured by a plugin. But we wanted to neither trust on the default cache storage nor an app developer providing a custom cache at the server level, because they might not be needing to use a cache, besides our plugin.

We decided to use this functionality, for example:

const Wreck = require('@hapi/wreck')

const internals = {}

internals.provision = {
  name: 'mistery-redis',
  provider: {
    constructor: require('@hapi/catbox-redis'),
    options: { partition: 'hapi-cache' },
  },
}

const plugin = {
  name: 'mistery-method',
  register(server /* options */) {
    server.cache.provision(internals.provision)

    const getRandomString = async length => {
      // calling random.org
      const { payload } = await Wreck.get(
        `https://www.random.org/strings/?num=1&len=${length}&digits=on&upperalpha=on&loweralpha=on&unique=on&format=plain&rnd=new`
      )
      return payload
    }

    server.method('getRandomString', getRandomString, {
      cache: {
        generateTimeout: 100,
        cache: internals.provision.name, // choosing our runtime provision
      },
    })
  },
}

module.exports = plugin

If you wanted to provide flexibility for users, you could receive configuration for the provision through the plugin options object.

Another thing you might want to provide is the ability for an app developer to delete the cache, either entirely or per key.

The server method exposes a .drop and .stats as part of its API, for example:

server.methods.getRandomString.cache.drop(8)

console.log(server.methods.getRandomString.cache.stats)

Conclusion

I think Hapi provides a compelling way to add cache storage, interact with it, and also have a bit of abstraction over get/set operations.

It helps create a standard API over which both plugin and app developers can interact with confidence.

Catbox is powerful, and I would recommend reading the documentation to grasp all that it can do entirely.

Happy coding!


I enjoy distributed systems, software architecture, emotional intelligence and how they intersect.