Skip to content

Commit f6fc869

Browse files
authored
Merge pull request #3 from jkyberneees/cache-control-support
Cache-Control header support
2 parents f9706f2 + db12c09 commit f6fc869

File tree

6 files changed

+119
-16
lines changed

6 files changed

+119
-16
lines changed

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# http-cache-middleware
2-
High performance connect-like HTTP cache middleware for Node.js.
2+
High performance connect-like HTTP cache middleware for Node.js. So your latency can decrease to single digit milliseconds 🚀
33

44
> Uses `cache-manager` as caching layer, so multiple
55
storage engines are supported, i.e: Memory, Redis, ... https://www.npmjs.com/package/cache-manager
@@ -77,7 +77,34 @@ service.get('/numbers', (req, res) => {
7777
})
7878
```
7979

80-
### Invalidating caches
80+
### Caching on the browser side (304 status codes)
81+
> From version `1.2.x` you can also use the HTTP compatible `Cache-Control` header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
82+
When using the Cache-Control header, you can omit the custom `x-cache-timeout` header as the timeout can be passed using the `max-age` directive.
83+
84+
#### Direct usage:
85+
```js
86+
res.setHeader('cache-control', 'private, no-cache, max-age=300')
87+
res.setHeader('etag', '1')
88+
89+
res.end('5 minutes cacheable content here....')
90+
```
91+
92+
#### Indirect usage:
93+
When using:
94+
```js
95+
res.setHeader('x-cache-timeout', '5 minutes')
96+
```
97+
The middleware will now transparently generate default `Cache-Control` and `ETag` headers as described below:
98+
```js
99+
res.setHeader('cache-control', 'private, no-cache, max-age=300')
100+
res.setHeader('etag', '1')
101+
```
102+
This will enable browser clients to keep a copy of the cache on their side, but still being forced to validate
103+
the cache state on the server before using the cached response, therefore supporting gateway based cache invalidation.
104+
105+
> NOTE: In order to fetch the generated `Cache-Control` and `ETag` headers, there have to be at least one cache hit.
106+
107+
### Invalidating caches
81108
Services can easily expire cache entries on demand, i.e: when the data state changes. Here we use the `x-cache-expire` header to indicate the cache entries to expire using a matching pattern:
82109
```js
83110
res.setHeader('x-cache-expire', '*/numbers')

demos/basic.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,19 @@ service.get('/cache-on-get', (req, res) => {
1010
}, 50)
1111
})
1212

13+
service.get('/cache-control', (req, res) => {
14+
setTimeout(() => {
15+
// keep response in cache for 1 minute if not expired before
16+
res.setHeader('cache-control', 'private, no-cache, max-age=60')
17+
res.send('this supposed to be a cacheable response')
18+
}, 50)
19+
})
20+
1321
service.delete('/cache', (req, res) => {
1422
// ... the logic here changes the cache state
1523

1624
// expire the cache keys using pattern
17-
res.setHeader('x-cache-expire', '*/cache-on-get')
25+
res.setHeader('x-cache-expire', '*/cache-*')
1826
res.end()
1927
})
2028

index.js

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
const CacheManager = require('cache-manager')
22
const iu = require('middleware-if-unless')()
3+
const { parse: cacheControl } = require('@tusbar/cache-control')
34
const ms = require('ms')
45
const onEnd = require('on-http-end')
56
const getKeys = require('./get-keys')
67

78
const X_CACHE_EXPIRE = 'x-cache-expire'
89
const X_CACHE_TIMEOUT = 'x-cache-timeout'
910
const X_CACHE_HIT = 'x-cache-hit'
11+
const CACHE_ETAG = 'etag'
12+
const CACHE_CONTROL = 'cache-control'
13+
const CACHE_IF_NONE_MATCH = 'if-none-match'
1014

1115
const middleware = (opts) => async (req, res, next) => {
1216
opts = Object.assign({
@@ -31,6 +35,16 @@ const middleware = (opts) => async (req, res, next) => {
3135
if (cached) {
3236
// respond from cache if there is a hit
3337
let { status, headers, data } = JSON.parse(cached)
38+
39+
// pre-checking If-None-Match header
40+
if (req.headers[CACHE_IF_NONE_MATCH] === headers[CACHE_ETAG]) {
41+
res.setHeader('content-length', '0')
42+
res.statusCode = 304
43+
res.end()
44+
45+
return // exit because client cache state matches
46+
}
47+
3448
if (typeof data === 'object' && data.type === 'Buffer') {
3549
data = Buffer.from(data.data)
3650
}
@@ -53,17 +67,28 @@ const middleware = (opts) => async (req, res, next) => {
5367
const keysPattern = payload.headers[X_CACHE_EXPIRE].replace(/\s/g, '')
5468
const patterns = keysPattern.split(',')
5569
// delete keys on all cache tiers
56-
patterns.forEach(pattern =>
57-
opts.stores.forEach(cache =>
58-
getKeys(cache, pattern).then(keys =>
59-
mcache.del(keys))))
60-
} else if (payload.headers[X_CACHE_TIMEOUT]) {
61-
// we need to cache response
62-
mcache.set(req.cacheKey, JSON.stringify(payload), {
63-
// @NOTE: cache-manager uses seconds as TTL unit
64-
// restrict to min value "1 second"
65-
ttl: Math.max(ms(payload.headers[X_CACHE_TIMEOUT]), 1000) / 1000
66-
})
70+
patterns.forEach(pattern => opts.stores.forEach(store => getKeys(store, pattern).then(keys => mcache.del(keys))))
71+
} else if (payload.headers[X_CACHE_TIMEOUT] || payload.headers[CACHE_CONTROL]) {
72+
// extract cache ttl
73+
let ttl = 0
74+
if (payload.headers[CACHE_CONTROL]) {
75+
ttl = cacheControl(payload.headers[CACHE_CONTROL]).maxAge
76+
}
77+
if (!ttl) {
78+
ttl = Math.max(ms(payload.headers[X_CACHE_TIMEOUT]), 1000) / 1000 // min value: 1 second
79+
}
80+
81+
// setting cache-control header if absent
82+
if (!payload.headers[CACHE_CONTROL]) {
83+
payload.headers[CACHE_CONTROL] = `private, no-cache, max-age=${ttl}`
84+
}
85+
// setting ETag if absent
86+
if (!payload.headers[CACHE_ETAG]) {
87+
payload.headers[CACHE_ETAG] = '1'
88+
}
89+
90+
// cache response
91+
mcache.set(req.cacheKey, JSON.stringify(payload), { ttl })
6792
}
6893
})
6994

package-lock.json

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "http-cache-middleware",
3-
"version": "1.1.1",
3+
"version": "1.2.0",
44
"description": "HTTP Cache Middleware",
55
"main": "index.js",
66
"scripts": {
@@ -30,6 +30,7 @@
3030
"standard": "^12.0.1"
3131
},
3232
"dependencies": {
33+
"@tusbar/cache-control": "^0.3.1",
3334
"cache-manager": "^2.9.1",
3435
"matcher": "^2.0.0",
3536
"middleware-if-unless": "^1.2.1",

test/smoke.test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,18 @@ describe('cache middleware', () => {
3737
server.get('/cache-buffer', (req, res) => {
3838
setTimeout(() => {
3939
res.setHeader('x-cache-timeout', '1 minute')
40+
res.setHeader('etag', '1')
41+
res.setHeader('cache-control', 'no-cache')
4042
res.send(Buffer.from('world'))
4143
}, 50)
4244
})
4345

46+
server.get('/cache-control', (req, res) => {
47+
res.setHeader('cache-control', 'max-age=60')
48+
res.setHeader('etag', '1')
49+
res.send('cache')
50+
})
51+
4452
server.delete('/cache', (req, res) => {
4553
res.setHeader('x-cache-expire', '*/cache')
4654
res.end()
@@ -94,6 +102,35 @@ describe('cache middleware', () => {
94102
const res = await got('http://localhost:3000/cache-buffer')
95103
expect(res.body).to.equal('world')
96104
expect(res.headers['x-cache-hit']).to.equal('1')
105+
expect(res.headers['cache-control']).to.equal('no-cache')
106+
expect(res.headers['etag']).to.equal('1')
107+
})
108+
109+
it('cache hit (buffer) - Etag', async () => {
110+
const res = await got('http://localhost:3000/cache-buffer')
111+
expect(res.body).to.equal('world')
112+
expect(res.headers['x-cache-hit']).to.equal('1')
113+
expect(res.headers['etag']).to.equal('1')
114+
})
115+
116+
it('cache hit (buffer) - If-None-Match', async () => {
117+
const res = await got('http://localhost:3000/cache-buffer', {
118+
headers: {
119+
'If-None-Match': '1'
120+
}
121+
})
122+
expect(res.statusCode).to.equal(304)
123+
})
124+
125+
it('cache create - cache-control', async () => {
126+
const res = await got('http://localhost:3000/cache-control')
127+
expect(res.body).to.equal('cache')
128+
})
129+
130+
it('cache hit - cache-control', async () => {
131+
const res = await got('http://localhost:3000/cache-control')
132+
expect(res.body).to.equal('cache')
133+
expect(res.headers['x-cache-hit']).to.equal('1')
97134
})
98135

99136
it('close', async () => {

0 commit comments

Comments
 (0)