diff --git a/lib/handler/cache-handler.js b/lib/handler/cache-handler.js index 54f022ccca0..1b19e128eac 100644 --- a/lib/handler/cache-handler.js +++ b/lib/handler/cache-handler.js @@ -13,6 +13,9 @@ const { * store. * * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-in-caches + * + * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandlers} DispatchHandlers + * @implements {DispatchHandlers} */ class CacheHandler extends DecoratorHandler { /** @@ -45,6 +48,16 @@ class CacheHandler extends DecoratorHandler { this.#handler = handler } + /** + * @see {DispatchHandlers.onHeaders} + * + * @param {number} statusCode + * @param {Buffer[]} rawHeaders + * @param {() => void} resume + * @param {string} statusMessage + * @param {string[] | undefined} headers + * @returns + */ onHeaders ( statusCode, rawHeaders, @@ -54,10 +67,9 @@ class CacheHandler extends DecoratorHandler { ) { const cacheControlHeader = headers['cache-control'] const contentLengthHeader = headers['content-length'] - // TODO read cache control directives to see if we can cache requests with - // authorization headers - // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-in-caches - if (!cacheControlHeader || !contentLengthHeader || headers['authorization']) { + + if (!cacheControlHeader || !contentLengthHeader) { + // Don't have the headers we need, can't cache return this.#handler.onHeaders( statusCode, rawHeaders, @@ -66,26 +78,41 @@ class CacheHandler extends DecoratorHandler { headers ) } - - const contentLength = Number(contentLengthHeader) + // TODO store etags const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader) - const maxEntrySize = this.#opts.store.maxEntrySize ?? Infinity + if (headers['authorization'] && !canCacheAuthorizationHeader(cacheControlDirectives)) { + // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen + return this.#handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage, + headers + ) + } + + const contentLength = Number(contentLengthHeader) + const maxEntrySize = this.#getMaxEntrySize() if ( !isNaN(contentLength) && maxEntrySize > contentLength && cacheDirectivesAllowCaching(cacheControlDirectives, headers.vary) && [200, 307].includes(statusCode) ) { + // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-in-caches const varyDirectives = headers.vary ? parseVaryHeader(headers.vary) : undefined - const ttl = determineTtl(headers, cacheControlDirectives) * 1000 - if (ttl > 0) { - const strippedHeaders = stripNecessaryHeaders(rawHeaders, headers) + const currentSize = this.#getSizeOfBuffers(rawHeaders) + + (statusMessage?.length ?? 0) + + 64 + if (ttl > 0 && maxEntrySize > currentSize) { + const strippedHeaders = stripNecessaryHeaders(rawHeaders, headers) const now = Date.now() + this.#value = { complete: false, data: { @@ -97,9 +124,7 @@ class CacheHandler extends DecoratorHandler { }, cachingDirectives: cacheControlDirectives, vary: varyDirectives, - size: (rawHeaders?.reduce((xs, x) => xs + x.length, 0) ?? 0) + - (statusMessage?.length ?? 0) + - 64, + size: currentSize, cachedAt: now, staleAt: now + ttl, deleteAt: 0 // TODO @@ -116,12 +141,17 @@ class CacheHandler extends DecoratorHandler { ) } + /** + * @see {DispatchHandlers.onData} + * + * @param {Buffer} chunk + * @returns {boolean} + */ onData (chunk) { if (this.#value) { this.#value.size += chunk.bodyLength - const maxEntrySize = this.#opts.store.maxEntrySize ?? Infinity - if (this.#value.size > maxEntrySize) { + if (this.#value.size > this.#getMaxEntrySize()) { this.#value = null } else { this.#value.data.body.push(chunk) @@ -131,24 +161,59 @@ class CacheHandler extends DecoratorHandler { return this.#handler.onData(chunk) } + /** + * @see {DispatchHandlers.onComplete} + * + * @param {string[] | null} rawTrailers + */ onComplete (rawTrailers) { if (this.#value) { this.#value.complete = true this.#value.data.rawTrailers = rawTrailers - this.#value.size += rawTrailers?.reduce((xs, x) => xs + x.length, 0) ?? 0 + this.#value.size += this.#getSizeOfBuffers(rawTrailers) - this.#opts.store.put(this.#req, this.#value).catch(err => { - throw err - }) + // If we're still under the max entry size, let's add it to the cache + if (this.#getMaxEntrySize() > this.#value.size) { + this.#opts.store.put(this.#req, this.#value).catch(err => { + throw err + }) + } } return this.#handler.onComplete(rawTrailers) } + /** + * @see {DispatchHandlers.onError} + * + * @param {Error} err + */ onError (err) { this.#value = undefined this.#handler.onError(err) } + + /** + * @returns {number} + */ + #getMaxEntrySize () { + return this.#opts.store.maxEntrySize ?? Infinity + } + + /** + * + * @param {string[] | Buffer[]} arr + * @returns {number} + */ + #getSizeOfBuffers (arr) { + let size = 0 + + for (const buffer of arr) { + size += buffer.length + } + + return size + } } /** @@ -202,17 +267,36 @@ function stripNecessaryHeaders (rawHeaders, parsedHeaders) { if (headerName in HEADERS_TO_REMOVE) { if (!strippedRawHeaders) { - strippedRawHeaders = rawHeaders.slice(0, n - 1) + strippedRawHeaders = rawHeaders.slice(0, i - 1) + } else { + strippedRawHeaders.push(rawHeaders[i]) } - } else if (strippedRawHeaders) { - strippedRawHeaders.push(rawHeaders[n]) } } strippedRawHeaders ??= rawHeaders return strippedRawHeaders - ? strippedRawHeaders.filter(() => true) - : rawHeaders +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + */ +function canCacheAuthorizationHeader (cacheControlDirectives) { + if ( + Array.isArray(cacheControlDirectives['no-cache']) && + cacheControlDirectives['no-cache'].includes('authorization') + ) { + return false + } + + if ( + Array.isArray(cacheControlDirectives['private']) && + cacheControlDirectives['private'].includes('authorization') + ) { + return false + } + + return true } module.exports = CacheHandler diff --git a/lib/handler/cache-revalidation-handler.js b/lib/handler/cache-revalidation-handler.js new file mode 100644 index 00000000000..12786b5f5f7 --- /dev/null +++ b/lib/handler/cache-revalidation-handler.js @@ -0,0 +1,98 @@ +'use strict' + +const util = require('../core/util.js') +const DecoratorHandler = require('../handler/decorator-handler') + +/** + * TODO docs + * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-validation + * + * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandlers} DispatchHandlers + * @implements {DispatchHandlers} + */ +class CacheRevalidationHandler extends DecoratorHandler { + #successful = false + /** + * @type {() => void} + */ + #successCallback = null + /** + * @type {import('../../types/dispatcher.d.ts').default.DispatchHandlers} + */ + #handler = null + + /** + * @param {() => void} successCallback + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler + */ + constructor (successCallback, handler) { + super(handler) + + this.#successCallback = successCallback + this.#handler = handler + } + + /** + * @see {DispatchHandlers.onHeaders} + * + * @param {number} statusCode + * @param {Buffer[]} rawHeaders + * @param {() => void} resume + * @param {string} statusMessage + * @param {string[] | undefined} headers + * @returns {boolean} + */ + onHeaders ( + statusCode, + rawHeaders, + resume, + statusMessage, + headers = util.parseHeaders(rawHeaders) + ) { + if (statusCode === 304) { + // TODO update the cached value to be fresh again + this.#successCallback() + return true + } + + return this.#handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage, + headers + ) + } + + /** + * @see {DispatchHandlers.onData} + * + * @param {Buffer} chunk + * @returns {boolean} + */ + onData (chunk) { + return this.#successful ? true : this.#handler.onData(chunk) + } + + /** + * @see {DispatchHandlers.onComplete} + * + * @param {string[] | null} rawTrailers + */ + onComplete (rawTrailers) { + if (!this.#successful) { + this.#handler.onComplete(rawTrailers) + } + } + + /** + * @see {DispatchHandlers.onError} + * + * @param {Error} err + */ + onError (err) { + this.#handler.onError(err) + } +} + +module.exports = CacheRevalidationHandler diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js index fcf6c293388..a97330305da 100644 --- a/lib/interceptor/cache.js +++ b/lib/interceptor/cache.js @@ -2,6 +2,7 @@ const CacheHandler = require('../handler/cache-handler.js') const LruCacheStore = require('../cache/lru-cache-store.js') +const CacheRevalidationHandler = require('../handler/cache-revalidation-handler.js') /** * Gives the downstream handler the request's cached response or dispatches @@ -27,61 +28,62 @@ function handleCachedResult ( return } - if (Date.now() > value.staleAt && !revalidateResult(value)) { - // Response has expired and we can't serve it stale - dispatch(opts, new CacheHandler(globalOpts, opts, handler)) - return - } - - // Request is cached, let's return it - const ac = new AbortController() - const signal = ac.signal - try { - const { - statusCode, - statusMessage, - rawHeaders, - rawTrailers, - body, - cachedAt - } = value - - handler.onConnect(ac.abort) - signal.throwIfAborted() - - // https://www.rfc-editor.org/rfc/rfc9111.html#name-age - const age = Date.now() - cachedAt / 1000 - rawHeaders.push(Buffer.from(`age: ${age}`)) - - handler.onHeaders(statusCode, rawHeaders, () => {}, statusMessage) - signal.throwIfAborted() - - if (opts.method === 'HEAD') { - handler.onComplete([]) - } else { - for (const chunk of body) { - let ret = false - while (ret === false) { - ret = handler.onData(chunk) - signal.throwIfAborted() + const respondWithCachedValue = () => { + const ac = new AbortController() + const signal = ac.signal + try { + const { + statusCode, + statusMessage, + rawHeaders, + rawTrailers, + body, + cachedAt + } = value + + handler.onConnect(ac.abort) + signal.throwIfAborted() + + // https://www.rfc-editor.org/rfc/rfc9111.html#name-age + const age = Date.now() - cachedAt / 1000 + rawHeaders.push(Buffer.from(`age: ${age}`)) + + handler.onHeaders(statusCode, rawHeaders, () => {}, statusMessage) + signal.throwIfAborted() + + if (opts.method === 'HEAD') { + handler.onComplete([]) + } else { + for (const chunk of body) { + let ret = false + while (ret === false) { + ret = handler.onData(chunk) + signal.throwIfAborted() + } } - } - handler.onComplete(rawTrailers) + handler.onComplete(rawTrailers) + } + } catch (err) { + handler.onError(err) } - } catch (err) { - handler.onError(err) } -} -/** - * @see https://www.rfc-editor.org/rfc/rfc9111.html#validation.model - * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue | undefined} value - */ -function revalidateResult (value) { - // get the agent - // send the request - // this will probably be async + if (Date.now() > value.staleAt) { + // TODO add conditional headers to opts to match the data we have here + dispatch( + opts, + new CacheRevalidationHandler( + respondWithCachedValue, + new CacheHandler(globalOpts, opts, handler) + ) + ) + + return + } + + // Request is fresh, let's return it + respondWithCachedValue() } /** diff --git a/lib/util/cache.js b/lib/util/cache.js index 8d09f1d044d..c250eb0ea53 100644 --- a/lib/util/cache.js +++ b/lib/util/cache.js @@ -47,11 +47,23 @@ function parseCacheControlHeader (header) { case 'private': case 'no-cache': { if (value) { + // The private and no-cache directives can be unqualified (aka just + // `private` or `no-cache`) or qualified (w/ a value). When they're + // qualified, it's a list of headers like `no-cache=header1`, + // `no-cache="header1"`, or `no-cache="header1, header2"` + // If we're given multiple headers, the comma messes us up since + // we split the full header by commas. So, let's loop through the + // remaining parts in front of us until we find one that ends in a + // quote. We can then just splice all of the parts in between the + // starting quote and the ending quote out of the directives array + // and continue parsing like normal. // https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2 if (value[0] === '"') { + // Something like `no-cache=some-header` OR `no-cache="some-header, another-header"`. + + // Add the first header on and cut off the leading quote const headers = [value.substring(1)] - // TODO explain let foundEndingQuote = false // no-cache="some-header, another-header" for (let j = i; j < directives.length; j++) { @@ -66,12 +78,14 @@ function parseCacheControlHeader (header) { } if (!foundEndingQuote) { + // Something like `no-cache="some-header` with no end quote, + // let's just ignore it continue } output[key] = headers } else { - // no-cache=some-header + // Something like `no-cache=some-header` output[key] = [value] } @@ -127,10 +141,9 @@ function parseVaryHeader (headers) { * @returns {boolean} */ function cacheDirectivesAllowCaching (directives, varyHeader) { - // TODO verify these const cacheControlDirectiveChecks = directives.public && - !directives.private && - !directives['no-cache'] && + directives.private === true && + directives['no-cache'] === true && !directives['no-store'] && !directives['no-transform'] && !directives['must-understand'] &&