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 aclient.
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 asegment.
Apartition
can contain multiplesegments,
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. Asegment
is a grouping of IDs within a single partition, in our example iscountries.
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: 'mystery-redis',
provider: {
constructor: require('@hapi/catbox-redis'),
options: { partition: 'hapi-cache' },
},
}
const plugin = {
name: 'mystery-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!