|
3 | 3 | const CacheHandler = require('../handler/cache-handler') |
4 | 4 | const MemoryCacheStore = require('../cache/memory-cache-store') |
5 | 5 | const CacheRevalidationHandler = require('../handler/cache-revalidation-handler') |
6 | | -const { UNSAFE_METHODS } = require('../util/cache.js') |
| 6 | +const { UNSAFE_METHODS, parseCacheControlHeader } = require('../util/cache.js') |
7 | 7 |
|
8 | 8 | const AGE_HEADER = Buffer.from('age') |
9 | 9 |
|
| 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 | + |
10 | 52 | /** |
11 | 53 | * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions | undefined} globalOpts |
12 | 54 | * @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor} |
@@ -47,19 +89,63 @@ module.exports = globalOpts => { |
47 | 89 |
|
48 | 90 | return dispatch => { |
49 | 91 | 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 | + ) { |
51 | 101 | // Not a method we want to cache or we don't have the origin, skip |
52 | 102 | return dispatch(opts, handler) |
53 | 103 | } |
54 | 104 |
|
55 | 105 | const stream = globalOpts.store.createReadStream(opts) |
56 | 106 | if (!stream) { |
57 | 107 | // 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 |
58 | 136 | return dispatch(opts, new CacheHandler(globalOpts, opts, handler)) |
59 | 137 | } |
60 | 138 |
|
61 | 139 | const { value } = stream |
62 | 140 |
|
| 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 | + |
63 | 149 | // Dump body on error |
64 | 150 | if (typeof opts.body === 'object' && opts.body.constructor.name === 'Readable') { |
65 | 151 | opts.body?.on('error', () => {}).resume() |
@@ -112,10 +198,15 @@ module.exports = globalOpts => { |
112 | 198 | } |
113 | 199 | } |
114 | 200 |
|
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) |
116 | 206 | 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) { |
119 | 210 | // Safety check in case the store gave us a response that should've been |
120 | 211 | // deleted already |
121 | 212 | dispatch(opts, new CacheHandler(globalOpts, opts, handler)) |
|
0 commit comments