Skip to content

Commit 2252554

Browse files
authored
feat: support request cache control directives (#3658)
Signed-off-by: flakey5 <[email protected]>
1 parent a73fd09 commit 2252554

File tree

2 files changed

+520
-18
lines changed

2 files changed

+520
-18
lines changed

lib/interceptor/cache.js

+116-18
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,82 @@ const util = require('../core/util')
66
const CacheHandler = require('../handler/cache-handler')
77
const MemoryCacheStore = require('../cache/memory-cache-store')
88
const CacheRevalidationHandler = require('../handler/cache-revalidation-handler')
9-
const { assertCacheStore, assertCacheMethods, makeCacheKey } = require('../util/cache.js')
9+
const { assertCacheStore, assertCacheMethods, makeCacheKey, parseCacheControlHeader } = require('../util/cache.js')
1010
const { nowAbsolute } = require('../util/timers.js')
1111

1212
const AGE_HEADER = Buffer.from('age')
1313

1414
/**
15-
* @typedef {import('../../types/cache-interceptor.d.ts').default.CachedResponse} CachedResponse
15+
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
1616
*/
17+
function sendGatewayTimeout (handler) {
18+
let aborted = false
19+
try {
20+
if (typeof handler.onConnect === 'function') {
21+
handler.onConnect(() => {
22+
aborted = true
23+
})
24+
25+
if (aborted) {
26+
return
27+
}
28+
}
29+
30+
if (typeof handler.onHeaders === 'function') {
31+
handler.onHeaders(504, [], () => {}, 'Gateway Timeout')
32+
if (aborted) {
33+
return
34+
}
35+
}
36+
37+
if (typeof handler.onComplete === 'function') {
38+
handler.onComplete([])
39+
}
40+
} catch (err) {
41+
if (typeof handler.onError === 'function') {
42+
handler.onError(err)
43+
}
44+
}
45+
}
46+
47+
/**
48+
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
49+
* @param {number} age
50+
* @param {import('../util/cache.js').CacheControlDirectives | undefined} cacheControlDirectives
51+
* @returns {boolean}
52+
*/
53+
function needsRevalidation (result, age, cacheControlDirectives) {
54+
if (cacheControlDirectives?.['no-cache']) {
55+
// Always revalidate requests with the no-cache directive
56+
return true
57+
}
58+
59+
const now = nowAbsolute()
60+
if (now > result.staleAt) {
61+
// Response is stale
62+
if (cacheControlDirectives?.['max-stale']) {
63+
// There's a threshold where we can serve stale responses, let's see if
64+
// we're in it
65+
// https://www.rfc-editor.org/rfc/rfc9111.html#name-max-stale
66+
const gracePeriod = result.staleAt + (cacheControlDirectives['max-stale'] * 1000)
67+
return now > gracePeriod
68+
}
69+
70+
return true
71+
}
72+
73+
if (cacheControlDirectives?.['min-fresh']) {
74+
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.3
75+
76+
// At this point, staleAt is always > now
77+
const timeLeftTillStale = result.staleAt - now
78+
const threshold = cacheControlDirectives['min-fresh'] * 1000
79+
80+
return timeLeftTillStale <= threshold
81+
}
82+
83+
return false
84+
}
1785

1886
/**
1987
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} [opts]
@@ -49,6 +117,14 @@ module.exports = (opts = {}) => {
49117
return dispatch(opts, handler)
50118
}
51119

120+
const requestCacheControl = opts.headers?.['cache-control']
121+
? parseCacheControlHeader(opts.headers['cache-control'])
122+
: undefined
123+
124+
if (requestCacheControl?.['no-store']) {
125+
return dispatch(opts, handler)
126+
}
127+
52128
/**
53129
* @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
54130
*/
@@ -59,13 +135,21 @@ module.exports = (opts = {}) => {
59135
// Where body can be a Buffer, string, stream or blob?
60136
const result = store.get(cacheKey)
61137
if (!result) {
138+
if (requestCacheControl?.['only-if-cached']) {
139+
// We only want cached responses
140+
// https://www.rfc-editor.org/rfc/rfc9111.html#name-only-if-cached
141+
sendGatewayTimeout(handler)
142+
return true
143+
}
144+
62145
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
63146
}
64147

65148
/**
66149
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
150+
* @param {number} age
67151
*/
68-
const respondWithCachedValue = ({ cachedAt, rawHeaders, statusCode, statusMessage, body }) => {
152+
const respondWithCachedValue = ({ rawHeaders, statusCode, statusMessage, body }, age) => {
69153
const stream = util.isStream(body)
70154
? body
71155
: Readable.from(body ?? [])
@@ -102,7 +186,6 @@ module.exports = (opts = {}) => {
102186
if (typeof handler.onHeaders === 'function') {
103187
// Add the age header
104188
// https://www.rfc-editor.org/rfc/rfc9111.html#name-age
105-
const age = Math.round((nowAbsolute() - cachedAt) / 1000)
106189

107190
// TODO (fix): What if rawHeaders already contains age header?
108191
rawHeaders = [...rawHeaders, AGE_HEADER, Buffer.from(`${age}`)]
@@ -133,21 +216,23 @@ module.exports = (opts = {}) => {
133216
throw new Error('stream is undefined but method isn\'t HEAD')
134217
}
135218

219+
const age = Math.round((nowAbsolute() - result.cachedAt) / 1000)
220+
if (requestCacheControl?.['max-age'] && age >= requestCacheControl['max-age']) {
221+
// Response is considered expired for this specific request
222+
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1
223+
return dispatch(opts, handler)
224+
}
225+
136226
// Check if the response is stale
137-
const now = nowAbsolute()
138-
if (now < result.staleAt) {
139-
// Dump request body.
140-
if (util.isStream(opts.body)) {
141-
opts.body.on('error', () => {}).destroy()
227+
if (needsRevalidation(result, age, requestCacheControl)) {
228+
if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) {
229+
// If body is is stream we can't revalidate...
230+
// TODO (fix): This could be less strict...
231+
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
142232
}
143-
respondWithCachedValue(result)
144-
} else if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) {
145-
// If body is is stream we can't revalidate...
146-
// TODO (fix): This could be less strict...
147-
dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
148-
} else {
149-
// Need to revalidate the response
150-
dispatch(
233+
234+
// We need to revalidate the response
235+
return dispatch(
151236
{
152237
...opts,
153238
headers: {
@@ -159,7 +244,7 @@ module.exports = (opts = {}) => {
159244
new CacheRevalidationHandler(
160245
(success) => {
161246
if (success) {
162-
respondWithCachedValue(result)
247+
respondWithCachedValue(result, age)
163248
} else if (util.isStream(result.body)) {
164249
result.body.on('error', () => {}).destroy()
165250
}
@@ -168,11 +253,24 @@ module.exports = (opts = {}) => {
168253
)
169254
)
170255
}
256+
257+
// Dump request body.
258+
if (util.isStream(opts.body)) {
259+
opts.body.on('error', () => {}).destroy()
260+
}
261+
respondWithCachedValue(result, age)
171262
}
172263

173264
if (typeof result.then === 'function') {
174265
result.then((result) => {
175266
if (!result) {
267+
if (requestCacheControl?.['only-if-cached']) {
268+
// We only want cached responses
269+
// https://www.rfc-editor.org/rfc/rfc9111.html#name-only-if-cached
270+
sendGatewayTimeout(handler)
271+
return true
272+
}
273+
176274
dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
177275
} else {
178276
handleResult(result)

0 commit comments

Comments
 (0)