Skip to content

Commit 3ebfdd8

Browse files
committed
feat: support request cache control directives
Signed-off-by: flakey5 <73616808+flakey5@users.noreply.github.com>
1 parent 7ea49d3 commit 3ebfdd8

File tree

1 file changed

+96
-5
lines changed

1 file changed

+96
-5
lines changed

lib/interceptor/cache.js

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,52 @@
33
const CacheHandler = require('../handler/cache-handler')
44
const MemoryCacheStore = require('../cache/memory-cache-store')
55
const CacheRevalidationHandler = require('../handler/cache-revalidation-handler')
6-
const { UNSAFE_METHODS } = require('../util/cache.js')
6+
const { UNSAFE_METHODS, parseCacheControlHeader } = require('../util/cache.js')
77

88
const AGE_HEADER = Buffer.from('age')
99

10+
/**
11+
* @param {number} now
12+
* @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} value
13+
* @param {number} age
14+
* @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
15+
*/
16+
function needsRevalidation (now, value, age, cacheControlDirectives) {
17+
if (now > value.staleAt) {
18+
// Response is stale
19+
if (cacheControlDirectives?.['max-stale']) {
20+
// Check if the request doesn't mind a stale response
21+
// https://www.rfc-editor.org/rfc/rfc9111.html#name-max-stale
22+
const gracePeriod = value.staleAt + (cacheControlDirectives['max-stale'] * 1000)
23+
24+
return now > gracePeriod
25+
}
26+
27+
return true
28+
}
29+
30+
if (cacheControlDirectives?.['no-cache']) {
31+
// Always revalidate request with the no-cache parameter
32+
return true
33+
}
34+
35+
if (
36+
cacheControlDirectives &&
37+
cacheControlDirectives['max-age'] !== undefined &&
38+
cacheControlDirectives['max-stale'] !== undefined
39+
) {
40+
return true
41+
}
42+
43+
if (cacheControlDirectives?.['min-fresh']) {
44+
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.3
45+
const gracePeriod = age + (cacheControlDirectives['min-fresh'] * 1000)
46+
return (now - value.staleAt) > gracePeriod
47+
}
48+
49+
return false
50+
}
51+
1052
/**
1153
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions | undefined} globalOpts
1254
* @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor}
@@ -47,19 +89,63 @@ module.exports = globalOpts => {
4789

4890
return dispatch => {
4991
return (opts, handler) => {
50-
if (!opts.origin || !methods.includes(opts.method)) {
92+
const requestCacheControl = opts.headers?.['cache-control']
93+
? parseCacheControlHeader(opts.headers['cache-control'])
94+
: undefined
95+
96+
if (
97+
!opts.origin ||
98+
!methods.includes(opts.method) ||
99+
requestCacheControl?.['no-store']
100+
) {
51101
// Not a method we want to cache or we don't have the origin, skip
52102
return dispatch(opts, handler)
53103
}
54104

55105
const stream = globalOpts.store.createReadStream(opts)
56106
if (!stream) {
57107
// Request isn't cached
108+
if (requestCacheControl?.['only-if-cached']) {
109+
const ac = new AbortController()
110+
const signal = ac.signal
111+
112+
// We only want cached responses
113+
// https://www.rfc-editor.org/rfc/rfc9111.html#name-only-if-cached
114+
try {
115+
if (typeof handler.onConnect === 'function') {
116+
handler.onConnect(ac.abort)
117+
signal.throwIfAborted()
118+
}
119+
120+
if (typeof handler.onHeaders === 'function') {
121+
handler.onHeaders(504, [], () => {}, 'Gateway Timeout')
122+
signal.throwIfAborted()
123+
}
124+
125+
if (typeof handler.onComplete === 'function') {
126+
handler.onComplete([])
127+
}
128+
} catch (err) {
129+
if (typeof handler.onError === 'function') {
130+
handler.onError(err)
131+
}
132+
}
133+
}
134+
135+
// Dispatch it and add it to the cache
58136
return dispatch(opts, new CacheHandler(globalOpts, opts, handler))
59137
}
60138

61139
const { value } = stream
62140

141+
const age = Math.round((Date.now() - value.cachedAt) / 1000)
142+
if (requestCacheControl?.['max-age'] && age >= requestCacheControl['max-age']) {
143+
// Response is considered expired for this specific request
144+
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1
145+
// TODO we could also pass this to the cache handler to re-cache this if we want
146+
return dispatch(opts, handler)
147+
}
148+
63149
// Dump body on error
64150
if (typeof opts.body === 'object' && opts.body.constructor.name === 'Readable') {
65151
opts.body?.on('error', () => {}).resume()
@@ -112,10 +198,15 @@ module.exports = globalOpts => {
112198
}
113199
}
114200

115-
// Check if the response is stale
201+
// Check if the response needs revalidation
202+
// Reasons for this,
203+
// 1) the response is stale
204+
// 2) the request gives the no-cache directive
205+
// 3)
116206
const now = Date.now()
117-
if (now >= value.staleAt) {
118-
if (now >= value.deleteAt) {
207+
208+
if (needsRevalidation(now, value, age, requestCacheControl)) {
209+
if (now > value.deleteAt) {
119210
// Safety check in case the store gave us a response that should've been
120211
// deleted already
121212
dispatch(opts, new CacheHandler(globalOpts, opts, handler))

0 commit comments

Comments
 (0)