express-cache-middleware
Version:
An Express middleware designed to intercept responses and cache them.
174 lines (154 loc) • 5.74 kB
JavaScript
const util = require('util')
const intercept = require('express-mung')
/**
* @external {ExpressApp} https://expressjs.com/en/api.html#app
*/
/**
* @external {ExpressRequest} https://expressjs.com/en/api.html#req
*/
/**
* @external {ExpressResponse} https://expressjs.com/en/api.html#res
*/
/**
* @external {CacheManager} https://www.npmjs.com/package/cache-manager
*/
/**
* A dummy function for the getCacheKey option
* @param {ExpressRequest} req
* @return {*} The same URL as was passed in
*/
function passthroughUrl(req) {
return req._parsedUrl.path
}
/**
* A dummy function for the hydrate option
* @param {ExpressResponse} res Passed-in Express response
* @param {Buffer|string} data The data returned from cache
* @return {[Promise]} Alternative to calling callback, return a Promise
*/
async function passthroughResponse(req, res, data) {
return data
}
/**
* A function to handle the hydration process
* @param {ExpressResponse} res Passed-in Express response
* @param {Buffer|string} data Data coming from cache to be hydrated
* @param {Function} hydrate Function to invoke on data
* @return {[Promise]}
*/
function hydrateHandler(req, res, data, hydrate) {
return new Promise((resolve, reject) => {
let finishCalled = false
const finish = (err, result) => {
if( finishCalled ) {
console.warn('Finish already called -- do not return a promise and call the callback from the same hydrate function!')
return
}
finishCalled = true
if( err ) { reject(err) }
resolve(result)
}
const hydratePromise = hydrate(req, res, data, finish)
if( typeof hydratePromise.then === 'function' ) {
hydratePromise.then(result => finish(null, result), err => finish(err))
}
})
}
/**
* An object that specifies default values for constructor options
* @type {Object}
*/
const defaultOptions = {
getCacheKey: passthroughUrl,
hydrate: passthroughResponse,
}
class CacheMiddleware {
/**
* constructor function
* @param {CacheManager} cacheManager A `.caching()` response from a cache-manager instance
* @param {Object} [options={}] A passed-in object of options. Merged with defaultOptions
* @param {?function} options.getCacheKey A function to process an incoming request into a cache key.
* Good for sanitizing a request to the most unique data, e.g. removing useless query params
* @param {?function} options.hydrate A function to process an outgoing cache response.
* Since the backend can return data any way it chooses, this is important.
*/
constructor(cacheManager, options = {}) {
if( !cacheManager ) {
throw new Error('Must be constructed with a cache-manager as the first argument!')
}
this.cache = cacheManager
this.options = Object.assign({}, defaultOptions, options)
if( typeof this.options.getCacheKey !== 'function' ) {
throw new Error('getCacheKey option must be a function!')
}
if( typeof this.options.hydrate !== 'function' ) {
throw new Error('hydrate option must be a function!')
}
// Bind cache API functions to our object for direct access.
['get', 'set', 'mget', 'mset', 'del', 'setex', 'reset', 'keys', 'ttl'].forEach(op => {
if( op in this.cache ) {
this[op] = this.cache[op]
this[`${op}Async`] = util.promisify(this.cache[op])
}
})
}
/**
* A function to attach this middleware to an Express instance.
* This is necessary because there are multiple layers to attach.
* Perhaps if mung ever gets a single total interception middleware, we can just return the route.
* @param {ExpressApp} app The Express app instance.
*/
attach(app) {
// Intercept request to get from cache if possible
app.use(this.cacheRoute.bind(this))
// Any requests after this will be stored in cache.
// TODO: figure out if I should homogenize requests by invoking hydrate here
// app.use(intercept.json((json, req, res) => this.cacheSet(req.cacheKey, json)))
// json is not needed since it invokes send on its own.
app.use(intercept.write((buffer, encoding, req, res) => this.cacheSet(req.cacheKey, buffer)))
app.use(intercept.send((chunk, req, res) => this.cacheSet(req.cacheKey, chunk)))
}
/**
* An incoming request middleware, to retrieve data from cache or pass to the actual backend.
* @param {ExpressRequest} req
* @param {ExpressResponse} res
* @param {Function} next A callback to invoke when we don't have a cached response
*/
async cacheRoute(req, res, next) {
try {
const cacheKey = this.options.getCacheKey(req)
req.cacheKey = cacheKey
const result = await this.cacheGet(cacheKey)
if( result ) {
// If returning from cache, all we have is the raw data. Hydrate it.
const hydratedData = await hydrateHandler(req, res, result, this.options.hydrate)
res.send(hydratedData)
return
}
next()
} catch (err) {
next(err)
}
}
/**
* For cacheRoute's use, a wrapper for the underlying cacheManager func.
* @param {string} key The cache key to retrieve
* @param {Function} cb Callback to invoke when complete
* @return {Promise}
*/
async cacheGet(key) {
const value = await this.getAsync(key)
return value
}
/**
* For cacheRoute's use, a wrapper for the underlying cacheManager func.
* @param {string} key The cache key to store
* @param {*} value The value to store
* @return {Promise}
*/
cacheSet(key, value) {
this.set(key, value)
return value
}
}
module.exports = CacheMiddleware