Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,17 +127,36 @@ Sometimes is required to expire cache entries using multiple patterns, that is a
res.setHeader('x-cache-expire', '*/pattern1,*/pattern2')
```

#### Direclty invalidating caches from stores
```js
const stores = [redisCache]
const middleware = require('http-cache-middleware')({
stores
})

const { deleteKeys } = require('http-cache-middleware/utils')
deleteKeys(stores, '*/pattern1,*/pattern2')
```

### Custom cache keys
Cache keys are generated using: `req.method + req.url`, however, for indexing/segmenting requirements it makes sense to allow cache keys extensions.

For doing this, we simply recommend using middlewares to extend the keys before caching checks happen:
To accomplish this, we simply recommend using middlewares to extend the keys before caching checks happen:
```js
service.use((req, res, next) => {
req.cacheAppendKey = (req) => req.user.id // here cache key will be: req.method + req.url + req.user.id
return next()
})
```
> In this example we also distinguish cache entries by `user.id`, very important for authorization reasons.
> In this example we also distinguish cache entries by `user.id`, commonly used for authorization reasons.

In case full control of the `cache-key` value is preferred, just populate the `req.cacheKey` property with a `string` value. In this case, the req.method + req.url prefix is discarded:
```js
service.use((req, res, next) => {
req.cacheKey = 'CUSTOM-CACHE-KEY'
return next()
})
```

### Disable cache for custom endpoints
You can also disable cache checks for certain requests programmatically:
Expand Down
16 changes: 0 additions & 16 deletions get-keys.js

This file was deleted.

179 changes: 84 additions & 95 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,128 +5,117 @@ const iu = require('middleware-if-unless')()
const { parse: cacheControl } = require('@tusbar/cache-control')
const ms = require('ms')
const onEnd = require('on-http-end')
const getKeys = require('./get-keys')
const { get, deleteKeys, DATA_POSTFIX } = require('./utils')

const X_CACHE_EXPIRE = 'x-cache-expire'
const X_CACHE_TIMEOUT = 'x-cache-timeout'
const X_CACHE_HIT = 'x-cache-hit'
const CACHE_ETAG = 'etag'
const CACHE_CONTROL = 'cache-control'
const CACHE_IF_NONE_MATCH = 'if-none-match'
const DATA_POSTFIX = '-d'

const middleware = (opts) => async (req, res, next) => {
try {
opts = Object.assign({
stores: [CacheManager.caching({ store: 'memory', max: 1000, ttl: 30 })]
}, opts)
const middleware = (opts) => {
opts = Object.assign({
stores: [CacheManager.caching({ store: 'memory', max: 1000, ttl: 30 })]
}, opts)
const mcache = CacheManager.multiCaching(opts.stores)

// creating multi-cache instance
const mcache = CacheManager.multiCaching(opts.stores)
return async (req, res, next) => {
try {
if (req.cacheDisabled) return next()

if (req.cacheDisabled) return next()
if (typeof req.cacheKey !== 'string') {
let { url, cacheAppendKey = req => '' } = req
cacheAppendKey = await cacheAppendKey(req)

let { url, cacheAppendKey = req => '' } = req
cacheAppendKey = await cacheAppendKey(req)

const key = req.method + url + cacheAppendKey
// ref cache key on req object
req.cacheKey = key
const key = req.method + url + cacheAppendKey
// ref cache key on req object
req.cacheKey = key
}

// try to retrieve cached response metadata
const metadata = await get(mcache, key)
// try to retrieve cached response metadata
const metadata = await get(mcache, req.cacheKey)

if (metadata) {
if (metadata) {
// respond from cache if there is a hit
const { status, headers, encoding } = JSON.parse(metadata)

// pre-checking If-None-Match header
if (req.headers[CACHE_IF_NONE_MATCH] && req.headers[CACHE_IF_NONE_MATCH] === headers[CACHE_ETAG]) {
res.setHeader('content-length', '0')
res.statusCode = 304
res.end()

return
} else {
// try to retrieve cached response data
const payload = await get(mcache, key + DATA_POSTFIX)
if (payload) {
let { data } = JSON.parse(payload)
if (typeof data === 'object' && data.type === 'Buffer') {
data = Buffer.from(data.data)
}
headers[X_CACHE_HIT] = '1'

// set cached response headers
Object.keys(headers).forEach(header => res.setHeader(header, headers[header]))
const { status, headers, encoding } = JSON.parse(metadata)

// send cached payload
req.cacheHit = true
res.statusCode = status
res.end(data, encoding)
// pre-checking If-None-Match header
if (req.headers[CACHE_IF_NONE_MATCH] && req.headers[CACHE_IF_NONE_MATCH] === headers[CACHE_ETAG]) {
res.setHeader('content-length', '0')
res.statusCode = 304
res.end()

return
} else {
// try to retrieve cached response data
const payload = await get(mcache, req.cacheKey + DATA_POSTFIX)
if (payload) {
let { data } = JSON.parse(payload)
if (typeof data === 'object' && data.type === 'Buffer') {
data = Buffer.from(data.data)
}
headers[X_CACHE_HIT] = '1'

// set cached response headers
Object.keys(headers).forEach(header => res.setHeader(header, headers[header]))

// send cached payload
req.cacheHit = true
res.statusCode = status
res.end(data, encoding)

return
}
}
}
}

onEnd(res, async (payload) => {
if (payload.status === 304) return

if (payload.headers[X_CACHE_EXPIRE]) {
// support service level expiration
const keysPattern = payload.headers[X_CACHE_EXPIRE].replace(/\s/g, '')
const patterns = keysPattern.split(',').map(pattern => pattern.endsWith('*')
? pattern
: [pattern, pattern + DATA_POSTFIX]
).reduce((acc, item) => {
if (Array.isArray(item)) {
acc.push(...item)
} else {
acc.push(item)
onEnd(res, async (payload) => {
if (payload.status === 304) return

if (payload.headers[X_CACHE_EXPIRE]) {
// support service level expiration
const keysPattern = payload.headers[X_CACHE_EXPIRE].replace(/\s/g, '')
const patterns = keysPattern.split(',')
// delete keys on all cache tiers
deleteKeys(opts.stores, patterns)
} else if (payload.headers[X_CACHE_TIMEOUT] || payload.headers[CACHE_CONTROL]) {
// extract cache ttl
let ttl = 0
if (payload.headers[CACHE_CONTROL]) {
ttl = cacheControl(payload.headers[CACHE_CONTROL]).maxAge
}
if (!ttl) {
if (payload.headers[X_CACHE_TIMEOUT]) {
ttl = Math.max(ms(payload.headers[X_CACHE_TIMEOUT]), 1000) / 1000 // min value: 1 second
} else {
return // no TTL found, we don't cache
}
}

return acc
}, [])
// delete keys on all cache tiers
patterns.forEach(pattern => opts.stores.forEach(store => getKeys(store, pattern).then(keys => keys.length > 0 ? mcache.del(keys) : null)))
} else if (payload.headers[X_CACHE_TIMEOUT] || payload.headers[CACHE_CONTROL]) {
// extract cache ttl
let ttl = 0
if (payload.headers[CACHE_CONTROL]) {
ttl = cacheControl(payload.headers[CACHE_CONTROL]).maxAge
}
if (!ttl) {
if (payload.headers[X_CACHE_TIMEOUT]) {
ttl = Math.max(ms(payload.headers[X_CACHE_TIMEOUT]), 1000) / 1000 // min value: 1 second
} else {
return // no TTL found, we don't cache
// setting cache-control header if absent
if (!payload.headers[CACHE_CONTROL]) {
payload.headers[CACHE_CONTROL] = `private, no-cache, max-age=${ttl}`
}
// setting ETag if absent
if (!payload.headers[CACHE_ETAG]) {
payload.headers[CACHE_ETAG] = Math.random().toString(36).substring(2, 16)
}
}

// setting cache-control header if absent
if (!payload.headers[CACHE_CONTROL]) {
payload.headers[CACHE_CONTROL] = `private, no-cache, max-age=${ttl}`
}
// setting ETag if absent
if (!payload.headers[CACHE_ETAG]) {
payload.headers[CACHE_ETAG] = Math.random().toString(36).substring(2, 16)
// cache response data
await mcache.set(req.cacheKey + DATA_POSTFIX, JSON.stringify({ data: payload.data }), { ttl })
delete payload.data
// cache response metadata
await mcache.set(req.cacheKey, JSON.stringify(payload), { ttl })
}
})

// cache response data
await mcache.set(req.cacheKey + DATA_POSTFIX, JSON.stringify({ data: payload.data }), { ttl })
delete payload.data
// cache response metadata
await mcache.set(req.cacheKey, JSON.stringify(payload), { ttl })
}
})

return next()
} catch (err) {
return next(err)
return next()
} catch (err) {
return next(err)
}
}
}

const get = (cache, key) => cache.getAndPassUp(key)

module.exports = iu(middleware)
module.exports.deleteKeys = deleteKeys
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
],
"files": [
"index.js",
"get-keys.js",
"utils.js",
"README.md",
"LICENSE"
],
Expand Down
2 changes: 1 addition & 1 deletion test/get-keys.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

/* global describe, it */

const getKeys = require('./../get-keys')
const { getKeys } = require('./../utils')
const expect = require('chai').expect

describe('get-keys', () => {
Expand Down
17 changes: 17 additions & 0 deletions test/smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ describe('cache middleware', () => {

next()
})
server.use((req, res, next) => {
if (req.url === '/custom-cache-key') {
req.cacheKey = 'my-custom-cache-key'
}

next()
})

server.use(middleware)

server.get('/health', (req, res) => {
Expand All @@ -29,6 +37,10 @@ describe('cache middleware', () => {
}, 50)
})

server.get('/custom-cache-key', (req, res) => {
res.send(req.cacheKey)
})

server.get('/cache', (req, res) => {
setTimeout(() => {
res.setHeader('x-cache-timeout', '1 minute')
Expand Down Expand Up @@ -83,6 +95,11 @@ describe('cache middleware', () => {
expect(res.headers['x-cache-hit']).to.equal(undefined)
})

it('custom cache key', async () => {
const res = await got('http://localhost:3000/custom-cache-key')
expect(res.body).to.equal('my-custom-cache-key')
})

it('create cache', async () => {
const res = await got('http://localhost:3000/cache')
expect(res.body).to.equal('hello')
Expand Down
42 changes: 42 additions & 0 deletions utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use strict'

const matcher = require('matcher')

const DATA_POSTFIX = '-d'

const getKeys = (cache, pattern) => new Promise((resolve) => {
if (pattern.indexOf('*') > -1) {
const args = [pattern, (_, res) => resolve(matcher(res, [pattern]))]
if (cache.store.name !== 'redis') {
args.shift()
}

cache.keys.apply(cache, args)
} else resolve([pattern])
})

const get = (cache, key) => cache.getAndPassUp(key)

const deleteKeys = (stores, patterns) => {
patterns = patterns.map(pattern => pattern.endsWith('*')
? pattern
: [pattern, pattern + DATA_POSTFIX]
).reduce((acc, item) => {
if (Array.isArray(item)) {
acc.push(...item)
} else {
acc.push(item)
}

return acc
}, [])

patterns.forEach(pattern => stores.forEach(store => getKeys(store, pattern).then(keys => keys.length > 0 ? store.del(keys) : null)))
}

module.exports = {
get,
deleteKeys,
getKeys,
DATA_POSTFIX
}