diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js index 9895fcb8a86..3cfbcd05154 100644 --- a/lib/interceptor/cache.js +++ b/lib/interceptor/cache.js @@ -6,14 +6,82 @@ const util = require('../core/util') const CacheHandler = require('../handler/cache-handler') const MemoryCacheStore = require('../cache/memory-cache-store') const CacheRevalidationHandler = require('../handler/cache-revalidation-handler') -const { assertCacheStore, assertCacheMethods, makeCacheKey } = require('../util/cache.js') +const { assertCacheStore, assertCacheMethods, makeCacheKey, parseCacheControlHeader } = require('../util/cache.js') const { nowAbsolute } = require('../util/timers.js') const AGE_HEADER = Buffer.from('age') /** - * @typedef {import('../../types/cache-interceptor.d.ts').default.CachedResponse} CachedResponse + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler */ +function sendGatewayTimeout (handler) { + let aborted = false + try { + if (typeof handler.onConnect === 'function') { + handler.onConnect(() => { + aborted = true + }) + + if (aborted) { + return + } + } + + if (typeof handler.onHeaders === 'function') { + handler.onHeaders(504, [], () => {}, 'Gateway Timeout') + if (aborted) { + return + } + } + + if (typeof handler.onComplete === 'function') { + handler.onComplete([]) + } + } catch (err) { + if (typeof handler.onError === 'function') { + handler.onError(err) + } + } +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result + * @param {number} age + * @param {import('../util/cache.js').CacheControlDirectives | undefined} cacheControlDirectives + * @returns {boolean} + */ +function needsRevalidation (result, age, cacheControlDirectives) { + if (cacheControlDirectives?.['no-cache']) { + // Always revalidate requests with the no-cache directive + return true + } + + const now = nowAbsolute() + if (now > result.staleAt) { + // Response is stale + if (cacheControlDirectives?.['max-stale']) { + // There's a threshold where we can serve stale responses, let's see if + // we're in it + // https://www.rfc-editor.org/rfc/rfc9111.html#name-max-stale + const gracePeriod = result.staleAt + (cacheControlDirectives['max-stale'] * 1000) + return now > gracePeriod + } + + return true + } + + if (cacheControlDirectives?.['min-fresh']) { + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.3 + + // At this point, staleAt is always > now + const timeLeftTillStale = result.staleAt - now + const threshold = cacheControlDirectives['min-fresh'] * 1000 + + return timeLeftTillStale <= threshold + } + + return false +} /** * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} [opts] @@ -49,6 +117,14 @@ module.exports = (opts = {}) => { return dispatch(opts, handler) } + const requestCacheControl = opts.headers?.['cache-control'] + ? parseCacheControlHeader(opts.headers['cache-control']) + : undefined + + if (requestCacheControl?.['no-store']) { + return dispatch(opts, handler) + } + /** * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} */ @@ -59,13 +135,21 @@ module.exports = (opts = {}) => { // Where body can be a Buffer, string, stream or blob? const result = store.get(cacheKey) if (!result) { + if (requestCacheControl?.['only-if-cached']) { + // We only want cached responses + // https://www.rfc-editor.org/rfc/rfc9111.html#name-only-if-cached + sendGatewayTimeout(handler) + return true + } + return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) } /** * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result + * @param {number} age */ - const respondWithCachedValue = ({ cachedAt, rawHeaders, statusCode, statusMessage, body }) => { + const respondWithCachedValue = ({ rawHeaders, statusCode, statusMessage, body }, age) => { const stream = util.isStream(body) ? body : Readable.from(body ?? []) @@ -102,7 +186,6 @@ module.exports = (opts = {}) => { if (typeof handler.onHeaders === 'function') { // Add the age header // https://www.rfc-editor.org/rfc/rfc9111.html#name-age - const age = Math.round((nowAbsolute() - cachedAt) / 1000) // TODO (fix): What if rawHeaders already contains age header? rawHeaders = [...rawHeaders, AGE_HEADER, Buffer.from(`${age}`)] @@ -133,21 +216,23 @@ module.exports = (opts = {}) => { throw new Error('stream is undefined but method isn\'t HEAD') } + const age = Math.round((nowAbsolute() - result.cachedAt) / 1000) + if (requestCacheControl?.['max-age'] && age >= requestCacheControl['max-age']) { + // Response is considered expired for this specific request + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1 + return dispatch(opts, handler) + } + // Check if the response is stale - const now = nowAbsolute() - if (now < result.staleAt) { - // Dump request body. - if (util.isStream(opts.body)) { - opts.body.on('error', () => {}).destroy() + if (needsRevalidation(result, age, requestCacheControl)) { + if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) { + // If body is is stream we can't revalidate... + // TODO (fix): This could be less strict... + return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) } - respondWithCachedValue(result) - } else if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) { - // If body is is stream we can't revalidate... - // TODO (fix): This could be less strict... - dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) - } else { - // Need to revalidate the response - dispatch( + + // We need to revalidate the response + return dispatch( { ...opts, headers: { @@ -158,7 +243,7 @@ module.exports = (opts = {}) => { new CacheRevalidationHandler( (success) => { if (success) { - respondWithCachedValue(result) + respondWithCachedValue(result, age) } else if (util.isStream(result.body)) { result.body.on('error', () => {}).destroy() } @@ -167,11 +252,24 @@ module.exports = (opts = {}) => { ) ) } + + // Dump request body. + if (util.isStream(opts.body)) { + opts.body.on('error', () => {}).destroy() + } + respondWithCachedValue(result, age) } if (typeof result.then === 'function') { result.then((result) => { if (!result) { + if (requestCacheControl?.['only-if-cached']) { + // We only want cached responses + // https://www.rfc-editor.org/rfc/rfc9111.html#name-only-if-cached + sendGatewayTimeout(handler) + return true + } + dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) } else { handleResult(result) diff --git a/test/interceptors/cache.js b/test/interceptors/cache.js index 954a009c870..3027dfdc3fe 100644 --- a/test/interceptors/cache.js +++ b/test/interceptors/cache.js @@ -338,4 +338,408 @@ describe('Cache Interceptor', () => { const response = await client.request(request) strictEqual(await response.body.text(), 'asd') }) + + test('requests w/ unsafe methods never get cached', async () => { + const server = createServer((req, res) => { + res.setHeader('cache-control', 'public, s-maxage=1') + res.end('asd') + }).listen(0) + + after(() => server.close()) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache({ + store: { + get () { + return undefined + }, + createWriteStream (key) { + fail(key.method) + }, + delete () {} + } + })) + + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + await client.request({ + origin: 'localhost', + method, + path: '/' + }) + } + }) + + describe('Client-side directives', () => { + test('max-age', async () => { + const clock = FakeTimers.install({ + shouldClearNativeTimers: true + }) + tick(0) + + let requestsToOrigin = 0 + const server = createServer((_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 'public, s-maxage=100') + res.end('asd') + }).listen(0) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + clock.uninstall() + server.close() + await client.close() + }) + + await once(server, 'listening') + + strictEqual(requestsToOrigin, 0) + + // Send initial request. This should reach the origin + let response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/' + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + // Send second request that should be handled by cache + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/' + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + strictEqual(response.headers.age, '0') + + // Send third request w/ the directive, this should be handled by the cache + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + 'cache-control': 'max-age=5' + } + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + clock.tick(6000) + tick(6000) + + // Send fourth request w/ the directive, age should be 6 now so this + // should hit the origin + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + 'cache-control': 'max-age=5' + } + }) + strictEqual(requestsToOrigin, 2) + strictEqual(await response.body.text(), 'asd') + }) + + test('max-stale', async () => { + let requestsToOrigin = 0 + + const clock = FakeTimers.install({ + shouldClearNativeTimers: true + }) + tick(0) + + const server = createServer((req, res) => { + res.setHeader('cache-control', 'public, s-maxage=1, stale-while-revalidate=10') + + if (requestsToOrigin === 1) { + notEqual(req.headers['if-modified-since'], undefined) + + res.statusCode = 304 + res.end() + } else { + res.end('asd') + } + + requestsToOrigin++ + }).listen(0) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await client.close() + clock.uninstall() + }) + + await once(server, 'listening') + + strictEqual(requestsToOrigin, 0) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // Send initial request. This should reach the origin + let response = await client.request(request) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + clock.tick(1500) + tick(1500) + + // Now we send a second request. This should be within the max stale + // threshold, so a request shouldn't be made to the origin + response = await client.request({ + ...request, + headers: { + 'cache-control': 'max-stale=5' + } + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + // Send a third request. This shouldn't be within the max stale threshold + // so a request should be made to the origin + response = await client.request({ + ...request, + headers: { + 'cache-control': 'max-stale=0' + } + }) + strictEqual(requestsToOrigin, 2) + strictEqual(await response.body.text(), 'asd') + }) + + test('min-fresh', async () => { + let requestsToOrigin = 0 + + const clock = FakeTimers.install({ + shouldClearNativeTimers: true + }) + tick(0) + + const server = createServer((req, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 'public, s-maxage=10') + res.end('asd') + }).listen(0) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await client.close() + clock.uninstall() + }) + + await once(server, 'listening') + + strictEqual(requestsToOrigin, 0) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // Send initial request. This should reach the origin + let response = await client.request(request) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + // Fast forward more. Response has 8sec TTL left after + clock.tick(2000) + tick(2000) + + // Now we send a second request. This should be within the threshold, so + // a request shouldn't be made to the origin + response = await client.request({ + ...request, + headers: { + 'cache-control': 'min-fresh=5' + } + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + // Fast forward more. Response has 2sec TTL left after + clock.tick(6000) + tick(6000) + + // Send the second request again, this time it shouldn't be within the + // threshold and a request should be made to the origin. + response = await client.request({ + ...request, + headers: { + 'cache-control': 'min-fresh=5' + } + }) + strictEqual(requestsToOrigin, 2) + strictEqual(await response.body.text(), 'asd') + }) + + test('no-cache', async () => { + let requestsToOrigin = 0 + const server = createServer((req, res) => { + if (requestsToOrigin === 1) { + notEqual(req.headers['if-modified-since'], undefined) + res.statusCode = 304 + res.end() + } else { + res.setHeader('cache-control', 'public, s-maxage=100') + res.end('asd') + } + + requestsToOrigin++ + }).listen(0) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await client.close() + }) + + await once(server, 'listening') + + strictEqual(requestsToOrigin, 0) + + // Send initial request. This should reach the origin + let response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + 'cache-control': 'no-cache' + } + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + // Send second request, a validation request should be sent + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + 'cache-control': 'no-cache' + } + }) + strictEqual(requestsToOrigin, 2) + strictEqual(await response.body.text(), 'asd') + + // Send third request w/o no-cache, this should be handled by the cache + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/' + }) + strictEqual(requestsToOrigin, 2) + strictEqual(await response.body.text(), 'asd') + }) + + test('no-store', async () => { + const server = createServer((req, res) => { + res.setHeader('cache-control', 'public, s-maxage=100') + res.end('asd') + }).listen(0) + + const store = new cacheStores.MemoryCacheStore() + store.createWriteStream = (...args) => { + fail('shouln\'t have reached this') + } + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache({ store })) + + after(async () => { + server.close() + await client.close() + }) + + await once(server, 'listening') + + // Send initial request. This should reach the origin + const response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + 'cache-control': 'no-store' + } + }) + strictEqual(await response.body.text(), 'asd') + }) + + test('only-if-cached', async () => { + let requestsToOrigin = 0 + const server = createServer((_, res) => { + res.setHeader('cache-control', 'public, s-maxage=100') + res.end('asd') + requestsToOrigin++ + }).listen(0) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await client.close() + }) + + await once(server, 'listening') + + // Send initial request. This should reach the origin + let response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/' + }) + equal(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + // Send second request, this shouldn't reach the origin + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + 'cache-control': 'only-if-cached' + } + }) + equal(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + // Send third request to an uncached resource, this should return a 504 + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/bla', + headers: { + 'cache-control': 'only-if-cached' + } + }) + equal(response.statusCode, 504) + + // Send fourth request to an uncached resource w/ a , this should return a 504 + response = await client.request({ + origin: 'localhost', + method: 'DELETE', + path: '/asd123', + headers: { + 'cache-control': 'only-if-cached' + } + }) + equal(response.statusCode, 504) + }) + }) })