Skip to content

Commit ab98a26

Browse files
committed
🚀 First launch
1 parent 8bd4b09 commit ab98a26

File tree

6 files changed

+4103
-0
lines changed

6 files changed

+4103
-0
lines changed

.editorconfig

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
root = true
2+
3+
[*]
4+
charset = utf-8
5+
end_of_line = lf
6+
insert_final_newline = true
7+
indent_style = space
8+
indent_size = 2
9+
trim_trailing_whitespace = true

.prettierrc

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"arrowParens": "always",
3+
"bracketSpacing": false,
4+
"semi": false,
5+
"singleQuote": true,
6+
"printWidth": 80,
7+
"trailingComma": "es5",
8+
"overrides": [
9+
{
10+
"files": ["*.json", ".prettierrc", ".babelrc"],
11+
"options": { "parser": "json" }
12+
}
13+
]
14+
}

index.js

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
'use strict'
2+
3+
const fp = require('fastify-plugin')
4+
const Keyv = require('keyv')
5+
const BPromise = require('bluebird')
6+
const crypto = require('crypto')
7+
8+
const cache = new Keyv()
9+
10+
const CACHEABLE_METHODS = ['GET']
11+
const INTERVAL = 200
12+
const X_RESPONSE_CACHE = 'x-response-cache'
13+
const X_RESPONSE_CACHE_HIT = 'hit'
14+
const X_RESPONSE_CACHE_MISS = 'miss'
15+
16+
const isCacheableRequest = (req) => {
17+
return CACHEABLE_METHODS.includes(req.raw.method)
18+
}
19+
20+
const buildCacheKey = (req, {headers}) => {
21+
const {url, headers: requestHeaders} = req.raw
22+
const additionalCondition = headers.reduce((acc, header) => {
23+
return `${acc}__${header}:${requestHeaders[header] || ''}`
24+
}, '')
25+
const data = `${url}__${additionalCondition}`
26+
const encrytedData = crypto.createHash('md5').update(data)
27+
const key = encrytedData.digest('hex')
28+
29+
return key
30+
}
31+
32+
const waitForCacheFulfilled = async (key, timeout) => {
33+
let cachedString = await cache.get(key)
34+
let waitedFor = 0
35+
36+
while (!cachedString) {
37+
await BPromise.delay(INTERVAL)
38+
cachedString = await cache.get(key)
39+
40+
waitedFor += INTERVAL
41+
if (!cachedString && waitedFor > timeout) {
42+
return
43+
}
44+
}
45+
46+
return cachedString
47+
}
48+
49+
const createOnRequestHandler = ({
50+
ttl,
51+
additionalCondition: {headers},
52+
}) => async (req, res) => {
53+
if (!isCacheableRequest(req)) {
54+
return
55+
}
56+
57+
const key = buildCacheKey(req, {headers})
58+
const requestKey = `${key}__requested`
59+
const isRequestExisted = await cache.get(requestKey)
60+
61+
if (isRequestExisted) {
62+
const cachedString = await waitForCacheFulfilled(key, ttl)
63+
64+
if (cachedString) {
65+
const cached = JSON.parse(cachedString)
66+
res.header(X_RESPONSE_CACHE, X_RESPONSE_CACHE_HIT)
67+
68+
return res.code(cached.statusCode).send(cached.payload)
69+
} else {
70+
res.header(X_RESPONSE_CACHE, X_RESPONSE_CACHE_MISS)
71+
}
72+
} else {
73+
await cache.set(requestKey, 'cached', ttl)
74+
res.header(X_RESPONSE_CACHE, X_RESPONSE_CACHE_MISS)
75+
}
76+
}
77+
78+
const createOnSendHandler = ({ttl, additionalCondition: {headers}}) => async (
79+
req,
80+
res,
81+
payload
82+
) => {
83+
if (!isCacheableRequest(req)) {
84+
return
85+
}
86+
87+
const key = buildCacheKey(req, {headers})
88+
89+
await cache.set(
90+
key,
91+
JSON.stringify({
92+
statusCode: res.statusCode,
93+
payload,
94+
}),
95+
ttl
96+
)
97+
}
98+
99+
const responseCachingPlugin = (
100+
instance,
101+
{ttl = 1000, additionalCondition = {}},
102+
next
103+
) => {
104+
const headers = additionalCondition.headers || []
105+
const opts = {ttl, additionalCondition: {headers}}
106+
107+
instance.addHook('onRequest', createOnRequestHandler(opts))
108+
instance.addHook('onSend', createOnSendHandler(opts))
109+
110+
return next()
111+
}
112+
113+
module.exports = fp(responseCachingPlugin, {
114+
fastify: '3.x',
115+
name: 'fastify-response-caching',
116+
})

index.test.js

+184
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
'use strict'
2+
3+
const test = require('tap').test
4+
5+
const axios = require('axios')
6+
const fastify = require('fastify')
7+
const BPromise = require('bluebird')
8+
9+
const plugin = require('./index.js')
10+
11+
test('should cache the cacheable request', (t) => {
12+
t.plan(6)
13+
const instance = fastify()
14+
instance.register(plugin, {ttl: 1000})
15+
instance.get('/cache', (req, res) => {
16+
res.send({hello: 'world'})
17+
})
18+
instance.listen(0, async (err) => {
19+
if (err) t.threw(err)
20+
instance.server.unref()
21+
const portNum = instance.server.address().port
22+
const address = `http://127.0.0.1:${portNum}/cache`
23+
const [response1, response2] = await BPromise.all([
24+
axios.get(address),
25+
axios.get(address),
26+
])
27+
t.is(response1.status, 200)
28+
t.is(response2.status, 200)
29+
t.is(response1.headers['x-response-cache'], 'miss')
30+
t.is(response2.headers['x-response-cache'], 'hit')
31+
t.deepEqual(response1.data, {hello: 'world'})
32+
t.deepEqual(response2.data, {hello: 'world'})
33+
})
34+
})
35+
36+
test('should not cache the uncacheable request', (t) => {
37+
t.plan(6)
38+
const instance = fastify()
39+
instance.register(plugin, {ttl: 1000})
40+
instance.post('/no-cache', (req, res) => {
41+
res.send({hello: 'world'})
42+
})
43+
instance.listen(0, async (err) => {
44+
if (err) t.threw(err)
45+
instance.server.unref()
46+
const portNum = instance.server.address().port
47+
const address = `http://127.0.0.1:${portNum}/no-cache`
48+
const [response1, response2] = await BPromise.all([
49+
axios.post(address, {}),
50+
axios.post(address, {}),
51+
])
52+
t.is(response1.status, 200)
53+
t.is(response2.status, 200)
54+
t.notOk(response1.headers['x-response-cache'])
55+
t.notOk(response2.headers['x-response-cache'])
56+
t.deepEqual(response1.data, {hello: 'world'})
57+
t.deepEqual(response2.data, {hello: 'world'})
58+
})
59+
})
60+
61+
test('should apply ttl config', (t) => {
62+
t.plan(9)
63+
const instance = fastify()
64+
instance.register(plugin, {ttl: 2000})
65+
instance.get('/ttl', (req, res) => {
66+
res.send({hello: 'world'})
67+
})
68+
instance.listen(0, async (err) => {
69+
if (err) t.threw(err)
70+
instance.server.unref()
71+
const portNum = instance.server.address().port
72+
const address = `http://127.0.0.1:${portNum}/ttl`
73+
const [response1, response2] = await BPromise.all([
74+
axios.get(address),
75+
axios.get(address),
76+
])
77+
await BPromise.delay(3000)
78+
const response3 = await axios.get(address)
79+
t.is(response1.status, 200)
80+
t.is(response2.status, 200)
81+
t.is(response3.status, 200)
82+
t.is(response1.headers['x-response-cache'], 'miss')
83+
t.is(response2.headers['x-response-cache'], 'hit')
84+
t.is(response3.headers['x-response-cache'], 'miss')
85+
t.deepEqual(response1.data, {hello: 'world'})
86+
t.deepEqual(response2.data, {hello: 'world'})
87+
t.deepEqual(response3.data, {hello: 'world'})
88+
})
89+
})
90+
91+
test('should apply additionalCondition config', (t) => {
92+
t.plan(12)
93+
const instance = fastify()
94+
instance.register(plugin, {
95+
additionalCondition: {
96+
headers: ['x-should-applied'],
97+
},
98+
})
99+
instance.get('/headers', (req, res) => {
100+
res.send({hello: 'world'})
101+
})
102+
instance.listen(0, async (err) => {
103+
if (err) t.threw(err)
104+
instance.server.unref()
105+
const portNum = instance.server.address().port
106+
const address = `http://127.0.0.1:${portNum}/headers`
107+
const [response1, response2, response3, response4] = await BPromise.all([
108+
axios.get(address, {
109+
headers: {'x-should-applied': 'yes'},
110+
}),
111+
axios.get(address, {
112+
headers: {'x-should-applied': 'yes'},
113+
}),
114+
axios.get(address, {
115+
headers: {'x-should-applied': 'no'},
116+
}),
117+
axios.get(address),
118+
])
119+
t.is(response1.status, 200)
120+
t.is(response2.status, 200)
121+
t.is(response3.status, 200)
122+
t.is(response4.status, 200)
123+
t.is(response1.headers['x-response-cache'], 'miss')
124+
t.is(response2.headers['x-response-cache'], 'hit')
125+
t.is(response3.headers['x-response-cache'], 'miss')
126+
t.is(response4.headers['x-response-cache'], 'miss')
127+
t.deepEqual(response1.data, {hello: 'world'})
128+
t.deepEqual(response2.data, {hello: 'world'})
129+
t.deepEqual(response3.data, {hello: 'world'})
130+
t.deepEqual(response4.data, {hello: 'world'})
131+
})
132+
})
133+
134+
test('should waiting for cache if multiple same request come in', (t) => {
135+
t.plan(6)
136+
const instance = fastify()
137+
instance.register(plugin, {ttl: 5000})
138+
instance.get('/waiting', async (req, res) => {
139+
await BPromise.delay(3000)
140+
res.send({hello: 'world'})
141+
})
142+
instance.listen(0, async (err) => {
143+
if (err) t.threw(err)
144+
instance.server.unref()
145+
const portNum = instance.server.address().port
146+
const address = `http://127.0.0.1:${portNum}/waiting`
147+
const [response1, response2] = await BPromise.all([
148+
axios.get(address),
149+
axios.get(address),
150+
])
151+
t.is(response1.status, 200)
152+
t.is(response2.status, 200)
153+
t.is(response1.headers['x-response-cache'], 'miss')
154+
t.is(response2.headers['x-response-cache'], 'hit')
155+
t.deepEqual(response1.data, {hello: 'world'})
156+
t.deepEqual(response2.data, {hello: 'world'})
157+
})
158+
})
159+
160+
test('should not waiting for cache due to timeout', (t) => {
161+
t.plan(6)
162+
const instance = fastify()
163+
instance.register(plugin)
164+
instance.get('/abort', async (req, res) => {
165+
await BPromise.delay(2000)
166+
res.send({hello: 'world'})
167+
})
168+
instance.listen(0, async (err) => {
169+
if (err) t.threw(err)
170+
instance.server.unref()
171+
const portNum = instance.server.address().port
172+
const address = `http://127.0.0.1:${portNum}/abort`
173+
const [response1, response2] = await BPromise.all([
174+
axios.get(address),
175+
axios.get(address),
176+
])
177+
t.is(response1.status, 200)
178+
t.is(response2.status, 200)
179+
t.is(response1.headers['x-response-cache'], 'miss')
180+
t.is(response2.headers['x-response-cache'], 'miss')
181+
t.deepEqual(response1.data, {hello: 'world'})
182+
t.deepEqual(response2.data, {hello: 'world'})
183+
})
184+
})

0 commit comments

Comments
 (0)