diff --git a/index.js b/index.js index 444706560ae..ec50f846361 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 = { + LruCacheStore: require('./lib/cache/lru-cache-store') } module.exports.buildConnector = buildConnector diff --git a/lib/cache/lru-cache-store.js b/lib/cache/lru-cache-store.js new file mode 100644 index 00000000000..521555c94b7 --- /dev/null +++ b/lib/cache/lru-cache-store.js @@ -0,0 +1,84 @@ +'use strict' + +const { canServeStale } = require('../util/cache.js') + +/** + * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore + * @implements {CacheStore} + */ +class LruCacheStore { + /** + * @type {Map} + */ + #data = new Map() + + /** + * @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 needsFlattening = false + const now = Date.now() + let value + for (let i = 0; i < values.length; i++) { + const current = values[i] + if (now >= current.staleAt && !canServeStale(current)) { + delete values[i] + needsFlattening = true + continue + } + + let matches = true + for (const key in current.vary) { + if (current.vary[key] !== req.headers[key]) { + matches = false + break + } + } + + if (matches) { + value = current + break + } + } + + if (needsFlattening) { + this.#data.set(key, values.filter(() => true)) + } + + 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 arr = this.#data.get(key) + if (!arr) { + arr = [] + this.#data.set(key, arr) + } + arr.push(value) + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req + * @returns {string} + */ + #makeKey (req) { + // https://www.rfc-editor.org/rfc/rfc9111.html#section-2-3 + return `${req.origin}:${req.path}:${req.method}` + } +} + +module.exports = LruCacheStore diff --git a/lib/handler/cache-handler.js b/lib/handler/cache-handler.js new file mode 100644 index 00000000000..54f022ccca0 --- /dev/null +++ b/lib/handler/cache-handler.js @@ -0,0 +1,218 @@ +'use strict' + +const util = require('../core/util.js') +const DecoratorHandler = require('../handler/decorator-handler') +const { + parseCacheControlHeader, + cacheDirectivesAllowCaching, + parseVaryHeader +} = require('../util/cache.js') + +/** + * This is the handler responsible for writing a request's response to a cache + * store. + * + * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-in-caches + */ +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 + } + + onHeaders ( + statusCode, + rawHeaders, + resume, + statusMessage, + headers = util.parseHeaders(rawHeaders) + ) { + 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']) { + return this.#handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage, + headers + ) + } + + const contentLength = Number(contentLengthHeader) + const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader) + const maxEntrySize = this.#opts.store.maxEntrySize ?? Infinity + + if ( + !isNaN(contentLength) && + maxEntrySize > contentLength && + cacheDirectivesAllowCaching(cacheControlDirectives, headers.vary) && + [200, 307].includes(statusCode) + ) { + const varyDirectives = headers.vary + ? parseVaryHeader(headers.vary) + : undefined + + const ttl = determineTtl(headers, cacheControlDirectives) * 1000 + if (ttl > 0) { + const strippedHeaders = stripNecessaryHeaders(rawHeaders, headers) + + const now = Date.now() + this.#value = { + complete: false, + data: { + statusCode, + statusMessage, + rawHeaders: strippedHeaders, + rawTrailers: null, + body: [] + }, + cachingDirectives: cacheControlDirectives, + vary: varyDirectives, + size: (rawHeaders?.reduce((xs, x) => xs + x.length, 0) ?? 0) + + (statusMessage?.length ?? 0) + + 64, + cachedAt: now, + staleAt: now + ttl, + deleteAt: 0 // TODO + } + } + } + + return this.#handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage, + headers + ) + } + + onData (chunk) { + if (this.#value) { + this.#value.size += chunk.bodyLength + + const maxEntrySize = this.#opts.store.maxEntrySize ?? Infinity + if (this.#value.size > maxEntrySize) { + this.#value = null + } else { + this.#value.data.body.push(chunk) + } + } + + return this.#handler.onData(chunk) + } + + 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.#opts.store.put(this.#req, this.#value).catch(err => { + throw err + }) + } + + return this.#handler.onComplete(rawTrailers) + } + + onError (err) { + this.#value = undefined + this.#handler.onError(err) + } +} + +/** + * @param {Record} headers + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @returns ttl for an object in seconds, 0 if it shouldn't be cached + */ +function determineTtl (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 + } + + 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 + } + + if (headers.expire) { + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 + return (new Date() - new Date(headers.expire)) / 1000 + } + + return 0 +} + +const HEADERS_TO_REMOVE = [ + 'connection' +] + +/** + * Strips headers required to be removed in cached responses + * @param {Buffer[]} rawHeaders + * @param {string[]} parsedHeaders + * @returns {Buffer[]} + */ +function stripNecessaryHeaders (rawHeaders, parsedHeaders) { + 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 HEADERS_TO_REMOVE) { + if (!strippedRawHeaders) { + strippedRawHeaders = rawHeaders.slice(0, n - 1) + } + } else if (strippedRawHeaders) { + strippedRawHeaders.push(rawHeaders[n]) + } + } + strippedRawHeaders ??= rawHeaders + + return strippedRawHeaders + ? strippedRawHeaders.filter(() => true) + : rawHeaders +} + +module.exports = CacheHandler diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js new file mode 100644 index 00000000000..fcf6c293388 --- /dev/null +++ b/lib/interceptor/cache.js @@ -0,0 +1,138 @@ +'use strict' + +const CacheHandler = require('../handler/cache-handler.js') +const LruCacheStore = require('../cache/lru-cache-store.js') + +/** + * 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 +) { + // Check value here as well since it still can be undefined if the store + // returned a promise + if (!value) { + // Request isn't cached, let's continue dispatching it + dispatch(opts, new CacheHandler(globalOpts, opts, handler)) + 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() + } + } + + handler.onComplete(rawTrailers) + } + } 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 +} + +/** + * @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) { + globalOpts.store = new LruCacheStore() + } + + 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) + } + + // Dump body + opts.body?.on('error', () => {}).resume() + + const result = globalOpts.store.get(opts) + if (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..8d09f1d044d --- /dev/null +++ b/lib/util/cache.js @@ -0,0 +1,166 @@ +/** + * @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 + * + * @param {string} header + * @returns {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} + */ +function parseCacheControlHeader (header) { + /** + * @type {import('../../types/cache-interceptor.d.ts').default.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) { + // https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2 + if (value[0] === '"') { + const headers = [value.substring(1)] + + // TODO explain + let foundEndingQuote = false + // no-cache="some-header, another-header" + 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())) + + headers[headers.length - 1] = headers[headers.length - 1].substring(0, headers[headers.length - 1].length - 1) + break + } + } + + if (!foundEndingQuote) { + continue + } + + output[key] = headers + } else { + // 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 {Record} headers + * @returns {Map} + */ +function parseVaryHeader (headers) { + const output = new Map() + + const varyingHeaders = headers.vary.toLowerCase().split(',') + for (const header of varyingHeaders) { + const trimmedHeader = header.trim() + + if (headers[trimmedHeader]) { + output.set(trimmedHeader, headers[trimmedHeader]) + } + } + + return output +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} directives + * @param {string | undefined} varyHeader + * @returns {boolean} + */ +function cacheDirectivesAllowCaching (directives, varyHeader) { + // TODO verify these + const cacheControlDirectiveChecks = directives.public && + !directives.private && + !directives['no-cache'] && + !directives['no-store'] && + !directives['no-transform'] && + !directives['must-understand'] && + !directives['must-revalidate'] && + !directives['proxy-revalidate'] + + const varyHeaderChecks = varyHeader ? varyHeader !== '*' : true + + return cacheControlDirectiveChecks && varyHeaderChecks +} + +/** + * + * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} value + * @returns {boolean} + */ +function canServeStale (value) { + + // if (cachingDirectives['stale-while-revalidate']) { + // const canRevalidateUntil = + // cachedAt + (cachingDirectives['stale-while-revalidate'] * 1000) + // return Date.now() <= canRevalidateUntil + // } + + // return false +} + +module.exports = { + parseCacheControlHeader, + parseVaryHeader, + cacheDirectivesAllowCaching, + canServeStale +} 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..d46f942fffc --- /dev/null +++ b/types/cache-interceptor.d.ts @@ -0,0 +1,92 @@ +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; + + delete(key: Dispatcher.RequestOptions): 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[] + cachingDirectives: CacheControlDirectives + /** + * 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 class LruCacheStore implements CacheStore { + get maxEntrySize (): number + get (key: Dispatcher.RequestOptions): CacheStoreValue[] | Promise + put (key: Dispatcher.RequestOptions, opts: CacheStoreValue): Promise + } + + export interface CacheControlDirectives { + '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; + } +} diff --git a/types/index.d.ts b/types/index.d.ts index 45276234925..d42e91ad2a4 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: { + LruCacheStore: typeof import('./cache-interceptor').default.LruCacheStore + } } 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 }