diff --git a/index.js b/index.js index 444706560ae..be2269ad90e 100644 --- a/index.js +++ b/index.js @@ -39,7 +39,12 @@ module.exports.RedirectHandler = RedirectHandler module.exports.interceptors = { redirect: require('./lib/interceptor/redirect'), retry: require('./lib/interceptor/retry'), - dump: require('./lib/interceptor/dump') + dump: require('./lib/interceptor/dump'), + cache: require('./lib/interceptor/cache') +} + +module.exports.cacheStores = { + MemoryCacheStore: require('./lib/cache/memory-cache-store') } module.exports.buildConnector = buildConnector diff --git a/lib/cache/memory-cache-store.js b/lib/cache/memory-cache-store.js new file mode 100644 index 00000000000..c43ae358f3c --- /dev/null +++ b/lib/cache/memory-cache-store.js @@ -0,0 +1,101 @@ +'use strict' + +/** + * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore + * @implements {CacheStore} + */ +class MemoryCacheStore { + /** + * @type {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts} opts + */ + #opts = {} + /** + * @type {Map} + */ + #data = new Map() + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts | undefined} opts + */ + constructor (opts) { + this.#opts = opts ?? {} + + if (!this.#opts.maxEntrySize) { + this.#opts.maxEntrySize = Infinity + } + } + + get maxEntrySize () { + return this.#opts.maxEntrySize + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req + * @returns {Promise} + */ + get (req) { + const key = this.#makeKey(req) + + const values = this.#data.get(key) + if (!values) { + return + } + + let value + const now = Date.now() + for (let i = values.length - 1; i >= 0; i--) { + const current = values[i] + if (now >= current.deleteAt) { + // Delete the expired ones + values.splice(i, 1) + continue + } + + let matches = true + + if (current.vary) { + for (const key in current.vary) { + if (current.vary[key] !== req.headers[key]) { + matches = false + break + } + } + } + + if (matches) { + value = current + break + } + } + + return value + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req + * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} value + */ + put (req, value) { + const key = this.#makeKey(req) + + let values = this.#data.get(key) + if (!values) { + values = [] + this.#data.set(key, values) + } + + values.push(value) + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req + * @returns {string} + */ + #makeKey (req) { + // TODO origin is undefined + // https://www.rfc-editor.org/rfc/rfc9111.html#section-2-3 + return `${req.origin}:${req.path}:${req.method}` + } +} + +module.exports = MemoryCacheStore diff --git a/lib/handler/cache-handler.js b/lib/handler/cache-handler.js new file mode 100644 index 00000000000..9ea2e41666c --- /dev/null +++ b/lib/handler/cache-handler.js @@ -0,0 +1,353 @@ +'use strict' + +const util = require('../core/util') +const DecoratorHandler = require('../handler/decorator-handler') +const { parseCacheControlHeader, parseVaryHeader } = require('../util/cache') + +class CacheHandler extends DecoratorHandler { + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheOptions} + */ + #opts = null + /** + * @type {import('../../types/dispatcher.d.ts').default.RequestOptions} + */ + #req = null + /** + * @type {import('../../types/dispatcher.d.ts').default.DispatchHandlers} + */ + #handler = null + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue | undefined} + */ + #value = null + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} opts + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler + */ + constructor (opts, req, handler) { + super(handler) + + this.#opts = opts + this.#req = req + 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, + resume, + statusMessage, + headers = util.parseHeaders(rawHeaders) + ) { + const cacheControlHeader = headers['cache-control'] + const contentLengthHeader = headers['content-length'] + + if (!cacheControlHeader || !contentLengthHeader) { + // Don't have the headers we need, can't cache + return this.#handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage, + headers + ) + } + + const maxEntrySize = this.#getMaxEntrySize() + const contentLength = Number(headers['content-length']) + const currentSize = + this.#getSizeOfBuffers(rawHeaders) + (statusMessage?.length ?? 0) + 64 + if ( + isNaN(contentLength) || + contentLength > maxEntrySize || + currentSize > maxEntrySize + ) { + return this.#handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage, + headers + ) + } + + const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader) + if (!canCacheResponse(statusCode, headers, cacheControlDirectives)) { + return this.#handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage, + headers + ) + } + + const now = Date.now() + const staleAt = determineStaleAt(headers, cacheControlDirectives) + if (staleAt) { + const varyDirectives = headers.vary + ? parseVaryHeader(headers.vary, this.#req.headers) + : undefined + const deleteAt = determineDeleteAt(cacheControlDirectives, staleAt) + + const strippedHeaders = stripNecessaryHeaders( + rawHeaders, + headers, + cacheControlDirectives + ) + + this.#value = { + complete: false, + statusCode, + statusMessage, + rawHeaders: strippedHeaders, + body: [], + vary: varyDirectives, + size: currentSize, + cachedAt: now, + staleAt: now + staleAt, + deleteAt: now + deleteAt + } + } + + return this.#handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage, + headers + ) + } + + /** + * @see {DispatchHandlers.onData} + * + * @param {Buffer} chunk + * @returns {boolean} + */ + onData (chunk) { + if (this.#value) { + this.#value.size += chunk.length + + if (this.#value.size > this.#getMaxEntrySize()) { + this.#value = null + } else { + this.#value.body.push(chunk) + } + } + + return this.#handler.onData(chunk) + } + + /** + * @see {DispatchHandlers.onComplete} + * + * @param {string[] | null} rawTrailers + */ + onComplete (rawTrailers) { + if (this.#value) { + this.#value.complete = true + this.#value.rawTrailers = rawTrailers + this.#value.size += this.#getSizeOfBuffers(rawTrailers) + + // If we're still under the max entry size, let's add it to the cache + if (this.#getMaxEntrySize() > this.#value.size) { + const result = this.#opts.store.put(this.#req, this.#value) + if (result && result.constructor.name === 'Promise') { + result.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 + } +} + +/** + * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen + * + * @param {number} statusCode + * @param {Record} headers + * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives + */ +function canCacheResponse (statusCode, headers, cacheControlDirectives) { + if (![200, 307].includes(statusCode)) { + return false + } + + if ( + !cacheControlDirectives.public && + !cacheControlDirectives['s-maxage'] && + !cacheControlDirectives['must-revalidate'] + ) { + // Response can't be used in a shared cache + return false + } + + if ( + // TODO double check these + cacheControlDirectives.private === true || + cacheControlDirectives['no-cache'] === true || + cacheControlDirectives['no-store'] || + cacheControlDirectives['no-transform'] || + cacheControlDirectives['must-understand'] || + cacheControlDirectives['proxy-revalidate'] + ) { + return false + } + + // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5 + if (headers.vary === '*') { + return false + } + + // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen + if (headers['authorization']) { + 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 +} + +/** + * @param {Record} headers + * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives + * + * @returns {number | undefined} time that the value is stale at or undefined if it shouldn't be cached + */ +function determineStaleAt (headers, cacheControlDirectives) { + // Prioritize s-maxage since we're a shared cache + // s-maxage > max-age > Expire + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3 + const sMaxAge = cacheControlDirectives['s-maxage'] + if (sMaxAge) { + return sMaxAge * 1000 + } + + if (cacheControlDirectives.immutable) { + // https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2 + return 31536000 + } + + const maxAge = cacheControlDirectives['max-age'] + if (maxAge) { + return maxAge * 1000 + } + + if (headers.expire) { + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 + return new Date() - new Date(headers.expire) + } + + return undefined +} + +/** + * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives + * @param {number} staleAt + */ +function determineDeleteAt (cacheControlDirectives, staleAt) { + if (cacheControlDirectives['stale-while-revalidate']) { + return (cacheControlDirectives['stale-while-revalidate'] * 1000) + } + + return staleAt +} + +/** + * Strips headers required to be removed in cached responses + * @param {Buffer[]} rawHeaders + * @param {string[]} parsedHeaders + * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives + * @returns {Buffer[]} + */ +function stripNecessaryHeaders (rawHeaders, parsedHeaders, cacheControlDirectives) { + const headersToRemove = ['connection'] + + if (Array.isArray(cacheControlDirectives['no-cache'])) { + headersToRemove.push(...cacheControlDirectives['no-cache']) + } + + if (Array.isArray(cacheControlDirectives['private'])) { + headersToRemove.push(...cacheControlDirectives['private']) + } + + let strippedRawHeaders + for (let i = 0; i < parsedHeaders.length; i++) { + const header = parsedHeaders[i] + const kvDelimiterIndex = header.indexOf(':') + const headerName = header.substring(0, kvDelimiterIndex) + + if (headerName in headersToRemove) { + if (!strippedRawHeaders) { + strippedRawHeaders = rawHeaders.slice(0, i - 1) + } else { + strippedRawHeaders.push(rawHeaders[i]) + } + } + } + + strippedRawHeaders ??= rawHeaders + + return strippedRawHeaders +} + +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..5ea70c20070 --- /dev/null +++ b/lib/handler/cache-revalidation-handler.js @@ -0,0 +1,105 @@ +'use strict' + +const DecoratorHandler = require('../handler/decorator-handler') + +/** + * This takes care of revalidation requests we send to the origin. If we get + * a response indicating that what we have is cached (via a HTTP 304), we can + * continue using the cached value. Otherwise, we'll receive the new response + * here, which we then just pass on to the next handler (most likely a + * CacheHandler). Note that this assumes the proper headers were already + * included in the request to tell the origin that we want to revalidate the + * response (i.e. if-modified-since). + * + * @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 Function to call if the cached value is valid + * @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 = undefined + ) { + // https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-a-validation-respo + if (statusCode === 304) { + this.#successful = true + 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 new file mode 100644 index 00000000000..7967f2aa981 --- /dev/null +++ b/lib/interceptor/cache.js @@ -0,0 +1,158 @@ +'use strict' + +const CacheHandler = require('../handler/cache-handler') +const MemoryCacheStore = require('../cache/memory-cache-store') +const CacheRevalidationHandler = require('../handler/cache-revalidation-handler') + +/** + * Gives the downstream handler the request's cached response or dispatches + * it if it isn't cached + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} globalOpts + * @param {*} dispatch TODO type + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler + * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue | undefined} value + */ +function handleCachedResult ( + globalOpts, + dispatch, + opts, + handler, + value +) { + if (!value) { + // Request isn't cached, let's continue dispatching it + dispatch(opts, new CacheHandler(globalOpts, opts, handler)) + return + } + + // Dump body on error + opts.body?.on('error', () => {}).resume() + + const respondWithCachedValue = () => { + const ac = new AbortController() + const signal = ac.signal + + try { + handler.onConnect(ac.abort) + signal.throwIfAborted() + + // Add the age header + // https://www.rfc-editor.org/rfc/rfc9111.html#name-age + const age = Math.round((Date.now() - value.cachedAt) / 1000) + + // Copy the headers in case we got this from an in-memory store. We don't + // want to modify the original response. + const headers = [...value.rawHeaders] + headers.push(Buffer.from('age'), Buffer.from(`${age}`)) + + handler.onHeaders(value.statusCode, headers, () => {}, value.statusMessage) + signal.throwIfAborted() + + if (opts.method === 'HEAD') { + handler.onComplete([]) + } else { + for (const chunk of value.body) { + while (!handler.onData(chunk)) { + signal.throwIfAborted() + } + } + + handler.onComplete(value.rawTrailers ?? null) + } + } catch (err) { + handler.onError(err) + } + } + + // Check if the response is stale + const now = Date.now() + if (now > value.staleAt) { + if (now > value.deleteAt) { + // Safety check in case the store gave us a response that should've been + // deleted already + dispatch(opts, new CacheHandler(globalOpts, opts, handler)) + return + } + + // Need to revalidate the request + opts.headers.push(`if-modified-since: ${new Date(value.cachedAt).toUTCString()}`) + + dispatch( + opts, + new CacheRevalidationHandler( + respondWithCachedValue, + new CacheHandler(globalOpts, opts, handler) + ) + ) + + return + } + + // Response is still fresh, let's return it + respondWithCachedValue() +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions | undefined} globalOpts + * @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor} + */ +module.exports = globalOpts => { + if (!globalOpts) { + globalOpts = {} + } + + if (globalOpts.store) { + for (const fn of ['get', 'put', 'delete']) { + if (typeof globalOpts.store[fn] !== 'function') { + throw new Error(`CacheStore needs a \`${fn}()\` function`) + } + } + + if (!globalOpts.store.maxEntrySize) { + throw new Error('CacheStore needs a maxEntrySize getter') + } + + if (globalOpts.store.maxEntrySize <= 0) { + throw new Error('CacheStore maxEntrySize needs to be >= 1') + } + } else { + globalOpts.store = new MemoryCacheStore() + } + + if (!globalOpts.methods) { + globalOpts.methods = ['GET'] + } + + return dispatch => { + return (opts, handler) => { + if (!globalOpts.methods.includes(opts.method)) { + // Not a method we want to cache, skip + return dispatch(opts, handler) + } + + const result = globalOpts.store.get(opts) + if (result && result.constructor.name === 'Promise') { + result.then(value => { + handleCachedResult( + globalOpts, + dispatch, + opts, + handler, + value + ) + }) + } else { + handleCachedResult( + globalOpts, + dispatch, + opts, + handler, + result + ) + } + + return true + } + } +} diff --git a/lib/util/cache.js b/lib/util/cache.js new file mode 100644 index 00000000000..f6b7d50d09f --- /dev/null +++ b/lib/util/cache.js @@ -0,0 +1,161 @@ +/** + * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-cache-control + * @see https://www.iana.org/assignments/http-cache-directives/http-cache-directives.xhtml + * + * @typedef {{ + * 'max-stale'?: number; + * 'min-fresh'?: number; + * 'max-age'?: number; + * 's-maxage'?: number; + * 'stale-while-revalidate'?: number; + * 'stale-if-error'?: number; + * public?: true; + * private?: true; + * 'no-store'?: true; + * 'no-cache'?: true | string[]; + * 'must-revalidate'?: true; + * 'proxy-revalidate'?: true; + * immutable?: true; + * 'no-transform'?: true; + * 'must-understand'?: true; + * 'only-if-cached'?: true; + * }} CacheControlDirectives + * + * @param {string} header + * @returns {CacheControlDirectives} + */ +function parseCacheControlHeader (header) { + /** + * @type {import('../util/cache.js').CacheControlDirectives} + */ + const output = {} + + const directives = header.toLowerCase().split(',') + for (let i = 0; i < directives.length; i++) { + const directive = directives[i] + const keyValueDelimiter = directive.indexOf('=') + + let key + let value + if (keyValueDelimiter !== -1) { + key = directive.substring(0, keyValueDelimiter).trim() + value = directive + .substring(keyValueDelimiter + 1, directive.length) + .trim() + .toLowerCase() + } else { + key = directive.trim() + } + + switch (key) { + case 'min-fresh': + case 'max-stale': + case 'max-age': + case 's-maxage': + case 'stale-while-revalidate': + case 'stale-if-error': { + const parsedValue = parseInt(value, 10) + if (isNaN(parsedValue)) { + continue + } + + output[key] = parsedValue + + break + } + 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)] + + let foundEndingQuote = false + for (let j = i; j < directives.length; j++) { + const nextPart = directives[j] + if (nextPart.endsWith('"')) { + foundEndingQuote = true + headers.push(...directives.splice(i + 1, j - 1).map(header => header.trim())) + + const lastHeader = header[headers.length - 1] + headers[headers.length - 1] = lastHeader.substring(0, lastHeader.length - 1) + break + } + } + + if (!foundEndingQuote) { + // Something like `no-cache="some-header` with no end quote, + // let's just ignore it + continue + } + + output[key] = headers + } else { + // Something like `no-cache=some-header` + output[key] = [value] + } + + break + } + } + // eslint-disable-next-line no-fallthrough + case 'public': + case 'no-store': + case 'must-revalidate': + case 'proxy-revalidate': + case 'immutable': + case 'no-transform': + case 'must-understand': + case 'only-if-cached': + if (value) { + continue + } + + output[key] = true + break + default: + // Ignore unknown directives as per https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.3-1 + continue + } + } + + return output +} + +/** + * @param {string} varyHeader Vary header from the server + * @param {Record} headers Request headers + * @returns {Record} + */ +function parseVaryHeader (varyHeader, headers) { + const output = {} + + const varyingHeaders = varyHeader.toLowerCase().split(',') + for (const header of varyingHeaders) { + const trimmedHeader = header.trim() + + if (headers[trimmedHeader]) { + output[trimmedHeader] = headers[trimmedHeader] + } + } + + return output +} + +module.exports = { + parseCacheControlHeader, + parseVaryHeader +} diff --git a/test/cache-interceptor/cache-stores.js b/test/cache-interceptor/cache-stores.js new file mode 100644 index 00000000000..70bb39fff13 --- /dev/null +++ b/test/cache-interceptor/cache-stores.js @@ -0,0 +1,167 @@ +const { describe, test } = require('node:test') +const { deepStrictEqual, strictEqual } = require('node:assert') +const MemoryCacheStore = require('../../lib/cache/memory-cache-store') + +const cacheStoresToTest = [ + MemoryCacheStore +] + +for (const CacheStore of cacheStoresToTest) { + describe(CacheStore.prototype.constructor.name, () => { + test('basic functionality', async () => { + // Checks that it can store & fetch different responses + const store = new CacheStore() + + const request = { + origin: 'localhost', + path: '/', + method: 'GET', + headers: {} + } + const requestValue = { + complete: true, + statusCode: 200, + statusMessage: '', + rawHeaders: [1, 2, 3], + rawTrailers: [4, 5, 6], + body: ['part1', 'part2'], + size: 16, + cachedAt: Date.now(), + staleAt: Date.now() + 10000, + deleteAt: Date.now() + 20000 + } + + // Sanity check + strictEqual(await store.get(request), undefined) + + // Add a response to the cache and try fetching it with a deep copy of + // the original request + await store.put(request, requestValue) + deepStrictEqual(store.get(structuredClone(request)), requestValue) + + const anotherRequest = { + origin: 'localhost', + path: '/asd', + method: 'GET', + headers: {} + } + const anotherValue = { + complete: true, + statusCode: 200, + statusMessage: '', + rawHeaders: [1, 2, 3], + rawTrailers: [4, 5, 6], + body: ['part1', 'part2'], + size: 16, + cachedAt: Date.now(), + staleAt: Date.now() + 10000, + deleteAt: Date.now() + 20000 + } + + // We haven't cached this one yet, make sure it doesn't confuse it with + // another request + strictEqual(await store.get(anotherRequest), undefined) + + // Add a response to the cache and try fetching it with a deep copy of + // the original request + await store.put(anotherRequest, anotherValue) + deepStrictEqual(store.get(structuredClone(anotherRequest)), anotherValue) + }) + + test('returns stale response if possible', async () => { + const request = { + origin: 'localhost', + path: '/', + method: 'GET', + headers: {} + } + const requestValue = { + complete: true, + statusCode: 200, + statusMessage: '', + rawHeaders: [1, 2, 3], + rawTrailers: [4, 5, 6], + body: ['part1', 'part2'], + size: 16, + cachedAt: Date.now() - 10000, + staleAt: Date.now() - 1, + deleteAt: Date.now() + 20000 + } + + const store = new CacheStore() + await store.put(request, requestValue) + deepStrictEqual(await store.get(request), requestValue) + }) + + test('doesn\'t return response past deletedAt', async () => { + const request = { + origin: 'localhost', + path: '/', + method: 'GET', + headers: {} + } + const requestValue = { + complete: true, + statusCode: 200, + statusMessage: '', + rawHeaders: [1, 2, 3], + rawTrailers: [4, 5, 6], + body: ['part1', 'part2'], + size: 16, + cachedAt: Date.now() - 20000, + staleAt: Date.now() - 10000, + deleteAt: Date.now() - 5 + } + + const store = new CacheStore() + await store.put(request, requestValue) + strictEqual(await store.get(request), undefined) + }) + + test('respects vary directives', async () => { + const store = new CacheStore() + + const request = { + origin: 'localhost', + path: '/', + method: 'GET', + headers: { + 'some-header': 'hello world' + } + } + const requestValue = { + complete: true, + statusCode: 200, + statusMessage: '', + rawHeaders: [1, 2, 3], + rawTrailers: [4, 5, 6], + body: ['part1', 'part2'], + vary: { + 'some-header': 'hello world' + }, + size: 16, + cachedAt: Date.now(), + staleAt: Date.now() + 10000, + deleteAt: Date.now() + 20000 + } + + // Sanity check + strictEqual(await store.get(request), undefined) + + await store.put(request, requestValue) + deepStrictEqual(store.get(request), requestValue) + + const nonMatchingRequest = { + origin: 'localhost', + path: '/', + method: 'GET', + headers: { + 'some-header': 'another-value' + } + } + deepStrictEqual(store.get(nonMatchingRequest), undefined) + + deepStrictEqual(store.get(structuredClone(request)), requestValue) + }) + }) +} diff --git a/test/cache-interceptor/interceptor.js b/test/cache-interceptor/interceptor.js new file mode 100644 index 00000000000..2303649ebe5 --- /dev/null +++ b/test/cache-interceptor/interceptor.js @@ -0,0 +1,134 @@ +'use strict' + +const { describe, test, after } = require('node:test') +const { strictEqual } = require('node:assert') +const { createServer } = require('node:http') +const { Client, interceptors } = require('../../index') +const { once } = require('node:events') + +// e2e tests, checks just the public api stuff basically +describe('Cache Interceptor', () => { + test('doesn\'t cache request w/ no cache-control header', async () => { + let requestsToOrigin = 0 + + const server = createServer((_, res) => { + requestsToOrigin++ + res.end('asd') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + strictEqual(requestsToOrigin, 0) + + // Send initial request. This should reach the origin + let response = await client.request({ method: 'GET', path: '/' }) + strictEqual(requestsToOrigin, 1) + strictEqual(await getResponse(response.body), 'asd') + + // Send second request that should be handled by cache + response = await client.request({ method: 'GET', path: '/' }) + strictEqual(requestsToOrigin, 2) + strictEqual(await getResponse(response.body), 'asd') + }) + + test('caches request successfully', async () => { + let requestsToOrigin = 0 + + const server = createServer((_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 'public, s-maxage=10') + res.end('asd') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + strictEqual(requestsToOrigin, 0) + + // Send initial request. This should reach the origin + let response = await client.request({ method: 'GET', path: '/' }) + strictEqual(requestsToOrigin, 1) + strictEqual(await getResponse(response.body), 'asd') + + // Send second request that should be handled by cache + response = await client.request({ method: 'GET', path: '/' }) + strictEqual(requestsToOrigin, 1) + strictEqual(await getResponse(response.body), 'asd') + strictEqual(response.headers.age, '0') + }) + + test('respects vary header', async () => { + let requestsToOrigin = 0 + + const server = createServer((req, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 'public, s-maxage=10') + res.setHeader('vary', 'some-header, another-header') + + if (req.headers['some-header'] === 'abc123') { + res.end('asd') + } else { + res.end('dsa') + } + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + strictEqual(requestsToOrigin, 0) + + // Send initial request. This should reach the origin + let response = await client.request({ + method: 'GET', + path: '/', + headers: { + 'some-header': 'abc123', + 'another-header': '123abc' + } + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await getResponse(response.body), 'asd') + + // Make another request with changed headers, this should miss + const secondResponse = await client.request({ + method: 'GET', + path: '/', + headers: { + 'some-header': 'qwerty', + 'another-header': 'asdfg' + } + }) + strictEqual(requestsToOrigin, 2) + strictEqual(await getResponse(secondResponse.body), 'dsa') + + // Resend the first request again which should still be cahced + response = await client.request({ + method: 'GET', + path: '/', + headers: { + 'some-header': 'abc123', + 'another-header': '123abc' + } + }) + strictEqual(requestsToOrigin, 2) + strictEqual(await getResponse(response.body), 'asd') + }) +}) + +async function getResponse (body) { + const buffers = [] + for await (const data of body) { + buffers.push(data) + } + return Buffer.concat(buffers).toString('utf8') +} diff --git a/test/cache-interceptor/utils.js b/test/cache-interceptor/utils.js new file mode 100644 index 00000000000..9b4e247ddca --- /dev/null +++ b/test/cache-interceptor/utils.js @@ -0,0 +1,164 @@ +'use strict' + +const { describe, test } = require('node:test') +const { deepStrictEqual } = require('node:assert') +const { parseCacheControlHeader, parseVaryHeader } = require('../../lib/util/cache') + +describe('parseCacheControlHeader', () => { + test('all directives are parsed properly when in their correct format', () => { + const directives = parseCacheControlHeader( + 'max-stale=1, min-fresh=1, max-age=1, s-maxage=1, stale-while-revalidate=1, stale-if-error=1, public, private, no-store, no-cache, must-revalidate, proxy-revalidate, immutable, no-transform, must-understand, only-if-cached' + ) + deepStrictEqual(directives, { + 'max-stale': 1, + 'min-fresh': 1, + 'max-age': 1, + 's-maxage': 1, + 'stale-while-revalidate': 1, + 'stale-if-error': 1, + public: true, + private: true, + 'no-store': true, + 'no-cache': true, + 'must-revalidate': true, + 'proxy-revalidate': true, + immutable: true, + 'no-transform': true, + 'must-understand': true, + 'only-if-cached': true + }) + }) + + test('handles weird spacings', () => { + const directives = parseCacheControlHeader( + 'max-stale=1, min-fresh=1, max-age=1,s-maxage=1, stale-while-revalidate=1,stale-if-error=1,public,private' + ) + deepStrictEqual(directives, { + 'max-stale': 1, + 'min-fresh': 1, + 'max-age': 1, + 's-maxage': 1, + 'stale-while-revalidate': 1, + 'stale-if-error': 1, + public: true, + private: true + }) + }) + + test('unknown directives are ignored', () => { + const directives = parseCacheControlHeader('max-age=123, something-else=456') + deepStrictEqual(directives, { 'max-age': 123 }) + }) + + test('directives with incorrect types are ignored', () => { + const directives = parseCacheControlHeader('max-age=true, only-if-cached=123') + deepStrictEqual(directives, {}) + }) + + test('the last instance of a directive takes precedence', () => { + const directives = parseCacheControlHeader('max-age=1, max-age=2') + deepStrictEqual(directives, { 'max-age': 2 }) + }) + + test('case insensitive', () => { + const directives = parseCacheControlHeader('Max-Age=123') + deepStrictEqual(directives, { 'max-age': 123 }) + }) + + test('no-cache with headers', () => { + let directives = parseCacheControlHeader('max-age=10, no-cache=some-header, only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + 'no-cache': [ + 'some-header' + ], + 'only-if-cached': true + }) + + directives = parseCacheControlHeader('max-age=10, no-cache="some-header", only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + 'no-cache': [ + 'some-header' + ], + 'only-if-cached': true + }) + + directives = parseCacheControlHeader('max-age=10, no-cache="some-header, another-one", only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + 'no-cache': [ + 'some-header', + 'another-one' + ], + 'only-if-cached': true + }) + }) + + test('private with headers', () => { + let directives = parseCacheControlHeader('max-age=10, private=some-header, only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + private: [ + 'some-header' + ], + 'only-if-cached': true + }) + + directives = parseCacheControlHeader('max-age=10, private="some-header", only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + private: [ + 'some-header' + ], + 'only-if-cached': true + }) + + directives = parseCacheControlHeader('max-age=10, private="some-header, another-one", only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + private: [ + 'some-header', + 'another-one' + ], + 'only-if-cached': true + }) + + // Missing ending quote, invalid & should be skipped + directives = parseCacheControlHeader('max-age=10, private="some-header, another-one, only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + 'only-if-cached': true + }) + }) +}) + +describe('parseVaryHeader', () => { + test('basic usage', () => { + const output = parseVaryHeader({ + vary: 'some-header, another-one', + 'some-header': 'asd', + 'another-one': '123', + 'third-header': 'cool' + }) + deepStrictEqual(output, new Map([ + ['some-header', 'asd'], + ['another-one', '123'] + ])) + }) + + test('handles weird spacings', () => { + const output = parseVaryHeader({ + vary: 'some-header, another-one,something-else', + 'some-header': 'asd', + 'another-one': '123', + 'something-else': 'asd123', + 'third-header': 'cool' + }) + deepStrictEqual(output, new Map([ + ['some-header', 'asd'], + ['another-one', '123'], + ['something-else', 'asd123'] + ])) + }) +}) diff --git a/types/cache-interceptor.d.ts b/types/cache-interceptor.d.ts new file mode 100644 index 00000000000..a7275871960 --- /dev/null +++ b/types/cache-interceptor.d.ts @@ -0,0 +1,79 @@ +import Dispatcher from './dispatcher' + +export default CacheHandler + +declare namespace CacheHandler { + export interface CacheOptions { + store?: CacheStore + + /** + * The methods to cache, defaults to just GET + */ + methods?: ('GET' | 'HEAD' | 'POST' | 'PATCH')[] + } + + /** + * Underlying storage provider for cached responses + */ + export interface CacheStore { + /** + * The max size of each value. If the content-length header is greater than + * this or the response ends up over this, the response will not be cached + * @default Infinity + */ + get maxEntrySize(): number + + get(key: Dispatcher.RequestOptions): CacheStoreValue[] | Promise; + + put(key: Dispatcher.RequestOptions, opts: CacheStoreValue): void | Promise; + } + + export interface CacheStoreValue { + /** + * True if the response is complete, otherwise the request is still in-flight + */ + complete: boolean; + statusCode: number; + statusMessage: string; + rawHeaders: Buffer[]; + rawTrailers?: Buffer[]; + body: string[] + /** + * Headers defined by the Vary header and their respective values for + * later comparison + */ + vary?: Record; + /** + * Actual size of the response (i.e. size of headers + body + trailers) + */ + size: number; + /** + * Time in millis that this value was cached + */ + cachedAt: number; + /** + * Time in millis that this value is considered stale + */ + staleAt: number; + /** + * Time in millis that this value is to be deleted from the cache. This is + * either the same as staleAt or the `max-stale` caching directive. + */ + deleteAt: number; + } + + export interface MemoryCacheStoreOpts { + /** + * @default Infinity + */ + maxEntrySize?: number + } + + export class MemoryCacheStore implements CacheStore { + constructor (opts?: MemoryCacheStoreOpts) + + get maxEntrySize (): number + get (key: Dispatcher.RequestOptions): CacheStoreValue[] | Promise + put (key: Dispatcher.RequestOptions, opts: CacheStoreValue): Promise + } +} diff --git a/types/index.d.ts b/types/index.d.ts index 45276234925..fed36ab8643 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -64,4 +64,7 @@ declare namespace Undici { const FormData: typeof import('./formdata').FormData const caches: typeof import('./cache').caches const interceptors: typeof import('./interceptors').default + const cacheStores: { + MemoryCacheStore: typeof import('./cache-interceptor').default.MemoryCacheStore + } } diff --git a/types/interceptors.d.ts b/types/interceptors.d.ts index 53835e01299..ee15b1c6f0e 100644 --- a/types/interceptors.d.ts +++ b/types/interceptors.d.ts @@ -1,3 +1,4 @@ +import CacheHandler from './cache-interceptor' import Dispatcher from './dispatcher' import RetryHandler from './retry-handler' @@ -8,10 +9,12 @@ declare namespace Interceptors { export type RetryInterceptorOpts = RetryHandler.RetryOptions export type RedirectInterceptorOpts = { maxRedirections?: number } export type ResponseErrorInterceptorOpts = { throwOnError: boolean } + export type CacheInterceptorOpts = CacheHandler.CacheOptions export function createRedirectInterceptor (opts: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor export function dump (opts?: DumpInterceptorOpts): Dispatcher.DispatcherComposeInterceptor export function retry (opts?: RetryInterceptorOpts): Dispatcher.DispatcherComposeInterceptor export function redirect (opts?: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor export function responseError (opts?: ResponseErrorInterceptorOpts): Dispatcher.DispatcherComposeInterceptor + export function cache (opts?: CacheInterceptorOpts): Dispatcher.DispatcherComposeInterceptor }