diff --git a/docs/docs/api/CacheStore.md b/docs/docs/api/CacheStore.md index c3caac08ec2..20a9deafe47 100644 --- a/docs/docs/api/CacheStore.md +++ b/docs/docs/api/CacheStore.md @@ -2,7 +2,8 @@ A Cache Store is responsible for storing and retrieving cached responses. It is also responsible for deciding which specific response to use based off of -a response's `Vary` header (if present). +a response's `Vary` header (if present). It is expected to be compliant with +[RFC-9111](https://www.rfc-editor.org/rfc/rfc9111.html). ## Pre-built Cache Stores @@ -33,27 +34,33 @@ The store must implement the following functions: ### Getter: `isFull` -This tells the cache interceptor if the store is full or not. If this is true, +Optional. This tells the cache interceptor if the store is full or not. If this is true, the cache interceptor will not attempt to cache the response. -### Function: `createReadStream` +### Function: `get` Parameters: * **req** `Dispatcher.RequestOptions` - Incoming request -Returns: `CacheStoreReadable | Promise | undefined` - If the request is cached, a readable for the body is returned. Otherwise, `undefined` is returned. +Returns: `GetResult | Promise | undefined` - If the request is cached, the cached response is returned. If the request's method is anything other than HEAD, the response is also returned. +If the request isn't cached, `undefined` is returned. + +Response properties: + +* **response** `CachedResponse` - The cached response data. +* **body** `Readable | undefined` - The response's body. ### Function: `createWriteStream` Parameters: * **req** `Dispatcher.RequestOptions` - Incoming request -* **value** `CacheStoreValue` - Response to store +* **value** `CachedResponse` - Response to store -Returns: `CacheStoreWriteable | undefined` - If the store is full, return `undefined`. Otherwise, return a writable so that the cache interceptor can stream the body and trailers to the store. +Returns: `Writable | undefined` - If the store is full, return `undefined`. Otherwise, return a writable so that the cache interceptor can stream the body and trailers to the store. -## `CacheStoreValue` +## `CachedResponse` This is an interface containing the majority of a response's data (minus the body). @@ -67,15 +74,11 @@ This is an interface containing the majority of a response's data (minus the bod ### Property `rawHeaders` -`(Buffer | Buffer[])[]` - The response's headers. - -### Property `rawTrailers` - -`string[] | undefined` - The response's trailers. +`Buffer[]` - The response's headers. ### Property `vary` -`Record | undefined` - The headers defined by the response's `Vary` header +`Record | undefined` - The headers defined by the response's `Vary` header and their respective values for later comparison For example, for a response like @@ -107,22 +110,3 @@ This would be is either the same sa staleAt or the `max-stale` caching directive. The store must not return a response after the time defined in this property. - -## `CacheStoreReadable` - -This extends Node's [`Readable`](https://nodejs.org/api/stream.html#class-streamreadable) -and defines extra properties relevant to the cache interceptor. - -### Getter: `value` - -The response's [`CacheStoreValue`](#cachestorevalue) - -## `CacheStoreWriteable` - -This extends Node's [`Writable`](https://nodejs.org/api/stream.html#class-streamwritable) -and defines extra properties relevant to the cache interceptor. - -### Setter: `rawTrailers` - -If the response has trailers, the cache interceptor will pass them to the cache -interceptor through this method. diff --git a/lib/cache/memory-cache-store.js b/lib/cache/memory-cache-store.js index bd8f6e33d97..dfd74b12b5a 100644 --- a/lib/cache/memory-cache-store.js +++ b/lib/cache/memory-cache-store.js @@ -7,11 +7,9 @@ const { Writable, Readable } = require('node:stream') * @implements {CacheStore} * * @typedef {{ - * readers: number - * readLock: boolean - * writeLock: boolean - * opts: import('../../types/cache-interceptor.d.ts').default.CacheStoreValue - * body: Buffer[] + * locked: boolean + * opts: import('../../types/cache-interceptor.d.ts').default.CachedResponse + * body?: Buffer[] * }} MemoryStoreValue */ class MemoryCacheStore { @@ -19,15 +17,10 @@ class MemoryCacheStore { #maxEntrySize = Infinity - /** - * @type {((err) => void) | undefined} - */ - #errorCallback = undefined - #entryCount = 0 /** - * @type {Map>} + * @type {Map>} */ #data = new Map() @@ -61,13 +54,6 @@ class MemoryCacheStore { } this.#maxEntrySize = opts.maxEntrySize } - - if (opts.errorCallback !== undefined) { - if (typeof opts.errorCallback !== 'function') { - throw new TypeError('MemoryCacheStore options.errorCallback must be a function') - } - this.#errorCallback = opts.errorCallback - } } } @@ -76,36 +62,53 @@ class MemoryCacheStore { } /** - * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req - * @returns {import('../../types/cache-interceptor.d.ts').default.CacheStoreReadable | undefined} + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key + * @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined} */ - createReadStream (req) { - if (typeof req !== 'object') { - throw new TypeError(`expected req to be object, got ${typeof req}`) + get (key) { + if (typeof key !== 'object') { + throw new TypeError(`expected key to be object, got ${typeof key}`) } - const values = this.#getValuesForRequest(req, false) + const values = this.#getValuesForRequest(key, false) if (!values) { return undefined } - const value = this.#findValue(req, values) + const value = this.#findValue(key, values) - if (!value || value.readLock) { + if (!value || value.locked) { return undefined } - return new MemoryStoreReadableStream(value) + /** + * @type {Readable | undefined} + */ + let readable + if (value.body) { + readable = new Readable() + + for (const chunk of value.body) { + readable.push(chunk) + } + + readable.push(null) + } + + return { + response: value.opts, + body: readable + } } /** - * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req - * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} opts - * @returns {import('../../types/cache-interceptor.d.ts').default.CacheStoreWriteable | undefined} + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key + * @param {import('../../types/cache-interceptor.d.ts').default.CachedResponse} opts + * @returns {Writable | undefined} */ - createWriteStream (req, opts) { - if (typeof req !== 'object') { - throw new TypeError(`expected req to be object, got ${typeof req}`) + createWriteStream (key, opts) { + if (typeof key !== 'object') { + throw new TypeError(`expected key to be object, got ${typeof key}`) } if (typeof opts !== 'object') { throw new TypeError(`expected value to be object, got ${typeof opts}`) @@ -115,9 +118,13 @@ class MemoryCacheStore { return undefined } - const values = this.#getValuesForRequest(req, true) + const values = this.#getValuesForRequest(key, true) - let value = this.#findValue(req, values) + /** + * @type {(MemoryStoreValue & { index: number }) | undefined} + */ + let value = this.#findValue(key, values) + let valueIndex = value?.index if (!value) { // The value doesn't already exist, meaning we haven't cached this // response before. Let's assign it a value and insert it into our data @@ -131,11 +138,8 @@ class MemoryCacheStore { this.#entryCount++ value = { - readers: 0, - readLock: false, - writeLock: false, - opts, - body: [] + locked: true, + opts } // We want to sort our responses in decending order by their deleteAt @@ -147,9 +151,11 @@ class MemoryCacheStore { // Our value is either the only response for this path or our deleteAt // time is sooner than all the other responses values.push(value) + valueIndex = values.length - 1 } else if (opts.deleteAt >= values[0].deleteAt) { // Our deleteAt is later than everyone elses values.unshift(value) + valueIndex = 0 } else { // We're neither in the front or the end, let's just binary search to // find our stop we need to be in @@ -165,6 +171,7 @@ class MemoryCacheStore { const middleValue = values[middleIndex] if (opts.deleteAt === middleIndex) { values.splice(middleIndex, 0, value) + valueIndex = middleIndex break } else if (opts.deleteAt > middleValue.opts.deleteAt) { endIndex = middleIndex @@ -178,7 +185,7 @@ class MemoryCacheStore { } else { // Check if there's already another request writing to the value or // a request reading from it - if (value.writeLock || value.readLock) { + if (value.locked) { return undefined } @@ -186,70 +193,98 @@ class MemoryCacheStore { value.body = [] } - const writable = new MemoryStoreWritableStream( - value, - this.#maxEntrySize - ) + let currentSize = 0 + /** + * @type {Buffer[] | null} + */ + let body = key.method !== 'HEAD' ? [] : null + const maxEntrySize = this.#maxEntrySize - // Remove the value if there was some error - writable.on('error', (err) => { - values.filter(current => value !== current) - if (this.#errorCallback) { - this.#errorCallback(err) - } - }) + const writable = new Writable({ + write (chunk, encoding, callback) { + if (key.method === 'HEAD') { + throw new Error('HEAD request shouldn\'t have a body') + } + + if (!body) { + return callback() + } - writable.on('bodyOversized', () => { - values.filter(current => value !== current) + if (typeof chunk === 'string') { + chunk = Buffer.from(chunk, encoding) + } + + currentSize += chunk.byteLength + + if (currentSize >= maxEntrySize) { + body = null + this.end() + shiftAtIndex(values, valueIndex) + return callback() + } + + body.push(chunk) + callback() + }, + final (callback) { + value.locked = false + if (body !== null) { + value.body = body + } + + callback() + } }) return writable } /** - * @param {string} origin + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key */ - deleteByOrigin (origin) { - this.#data.delete(origin) + deleteByKey (key) { + this.#data.delete(`${key.origin}:${key.path}`) } /** * Gets all of the requests of the same origin, path, and method. Does not * take the `vary` property into account. - * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key * @param {boolean} [makeIfDoesntExist=false] + * @returns {MemoryStoreValue[] | undefined} */ - #getValuesForRequest (req, makeIfDoesntExist) { + #getValuesForRequest (key, makeIfDoesntExist) { // https://www.rfc-editor.org/rfc/rfc9111.html#section-2-3 - let cachedPaths = this.#data.get(req.origin) + const topLevelKey = `${key.origin}:${key.path}` + let cachedPaths = this.#data.get(topLevelKey) if (!cachedPaths) { if (!makeIfDoesntExist) { return undefined } cachedPaths = new Map() - this.#data.set(req.origin, cachedPaths) + this.#data.set(topLevelKey, cachedPaths) } - let values = cachedPaths.get(`${req.path}:${req.method}`) - if (!values && makeIfDoesntExist) { - values = [] - cachedPaths.set(`${req.path}:${req.method}`, values) + let value = cachedPaths.get(key.method) + if (!value && makeIfDoesntExist) { + value = [] + cachedPaths.set(key.method, value) } - return values + return value } /** * Given a list of values of a certain request, this decides the best value * to respond with. - * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} req * @param {MemoryStoreValue[]} values - * @returns {MemoryStoreValue | undefined} + * @returns {(MemoryStoreValue & { index: number }) | undefined} */ #findValue (req, values) { /** - * @type {MemoryStoreValue} + * @type {MemoryStoreValue | undefined} */ let value const now = Date.now() @@ -280,7 +315,10 @@ class MemoryCacheStore { } if (matches) { - value = current + value = { + ...current, + index: i + } break } } @@ -289,130 +327,16 @@ class MemoryCacheStore { } } -class MemoryStoreReadableStream extends Readable { - /** - * @type {MemoryStoreValue} - */ - #value - - /** - * @type {Buffer[]} - */ - #chunksToSend = [] - - /** - * @param {MemoryStoreValue} value - */ - constructor (value) { - super() - - if (value.readLock) { - throw new Error('can\'t read a locked value') - } - - this.#value = value - this.#chunksToSend = value?.body ? [...value.body, null] : [null] - - this.#value.readers++ - this.#value.writeLock = true - - this.on('close', () => { - this.#value.readers-- - - if (this.#value.readers === 0) { - this.#value.writeLock = false - } - }) - } - - get value () { - return this.#value.opts - } - - /** - * @param {number} size - */ - _read (size) { - if (this.#chunksToSend.length === 0) { - throw new Error('no chunks left to read, stream should have closed') - } - - if (size > this.#chunksToSend.length) { - size = this.#chunksToSend.length - } - - for (let i = 0; i < size; i++) { - this.push(this.#chunksToSend.shift()) - } - } -} - -class MemoryStoreWritableStream extends Writable { - /** - * @type {MemoryStoreValue} - */ - #value - #currentSize = 0 - #maxEntrySize = 0 - /** - * @type {Buffer[]|null} - */ - #body = [] - - /** - * @param {MemoryStoreValue} value - * @param {number} maxEntrySize - */ - constructor (value, maxEntrySize) { - super() - this.#value = value - this.#value.readLock = true - this.#maxEntrySize = maxEntrySize ?? Infinity - } - - get rawTrailers () { - return this.#value.opts.rawTrailers - } - - /** - * @param {string[] | undefined} trailers - */ - set rawTrailers (trailers) { - this.#value.opts.rawTrailers = trailers - } - - /** - * @param {Buffer} chunk - * @param {string} encoding - * @param {BufferEncoding} encoding - */ - _write (chunk, encoding, callback) { - if (typeof chunk === 'string') { - chunk = Buffer.from(chunk, encoding) - } - - this.#currentSize += chunk.byteLength - if (this.#currentSize < this.#maxEntrySize) { - this.#body.push(chunk) - } else { - this.#body = null // release memory as early as possible - this.emit('bodyOversized') - } - - callback() +/** + * @param {any[]} array Array to modify + * @param {number} idx Index to delete + */ +function shiftAtIndex (array, idx) { + for (let i = idx + 1; idx < array.length; i++) { + array[i - 1] = array[i] } - /** - * @param {() => void} callback - */ - _final (callback) { - if (this.#currentSize < this.#maxEntrySize) { - this.#value.readLock = false - this.#value.body = this.#body - } - - callback() - } + array.length-- } module.exports = MemoryCacheStore diff --git a/lib/core/util.js b/lib/core/util.js index fb311a7d36b..fcdd5da0483 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -10,7 +10,7 @@ const nodeUtil = require('node:util') const { stringify } = require('node:querystring') const { EventEmitter: EE } = require('node:events') const { InvalidArgumentError } = require('./errors') -const { headerNameLowerCasedRecord, getHeaderNameAsBuffer } = require('./constants') +const { headerNameLowerCasedRecord } = require('./constants') const { tree } = require('./tree') const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(v => Number(v)) @@ -436,44 +436,6 @@ function parseHeaders (headers, obj) { return obj } -/** - * @param {Record} headers - * @returns {(Buffer | Buffer[])[]} - */ -function encodeHeaders (headers) { - const headerNames = Object.keys(headers) - - /** - * @type {Buffer[]|Buffer[][]} - */ - const rawHeaders = new Array(headerNames.length * 2) - - let rawHeadersIndex = 0 - for (const header of headerNames) { - let rawValue - const value = headers[header] - if (Array.isArray(value)) { - rawValue = new Array(value.length) - - for (let i = 0; i < value.length; i++) { - rawValue[i] = Buffer.from(value[i]) - } - } else { - rawValue = Buffer.from(value) - } - - const headerBuffer = getHeaderNameAsBuffer(header) - - rawHeaders[rawHeadersIndex] = headerBuffer - rawHeadersIndex++ - - rawHeaders[rawHeadersIndex] = rawValue - rawHeadersIndex++ - } - - return rawHeaders -} - /** * @param {Buffer[]} headers * @returns {string[]} @@ -516,6 +478,17 @@ function parseRawHeaders (headers) { return ret } +/** + * @param {string[]} headers + * @param {Buffer[]} headers + */ +function encodeRawHeaders (headers) { + if (!Array.isArray(headers)) { + throw new TypeError('expected headers to be an array') + } + return headers.map(x => Buffer.from(x)) +} + /** * @param {*} buffer * @returns {buffer is Buffer} @@ -901,8 +874,8 @@ module.exports = { removeAllListeners, errorRequest, parseRawHeaders, + encodeRawHeaders, parseHeaders, - encodeHeaders, parseKeepAliveTimeout, destroy, bodyLength, diff --git a/lib/handler/cache-handler.js b/lib/handler/cache-handler.js index 9bc9a776747..94f1ce99873 100644 --- a/lib/handler/cache-handler.js +++ b/lib/handler/cache-handler.js @@ -14,14 +14,14 @@ function noop () {} */ class CacheHandler extends DecoratorHandler { /** - * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore} + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} */ - #store + #cacheKey /** - * @type {import('../../types/dispatcher.d.ts').default.RequestOptions} + * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore} */ - #requestOptions + #store /** * @type {import('../../types/dispatcher.d.ts').default.DispatchHandlers} @@ -29,22 +29,22 @@ class CacheHandler extends DecoratorHandler { #handler /** - * @type {import('../../types/cache-interceptor.d.ts').default.CacheStoreWriteable | undefined} + * @type {import('node:stream').Writable | undefined} */ #writeStream /** * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} opts - * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} requestOptions + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler */ - constructor (opts, requestOptions, handler) { + constructor (opts, cacheKey, handler) { const { store } = opts super(handler) this.#store = store - this.#requestOptions = requestOptions + this.#cacheKey = cacheKey this.#handler = handler } @@ -88,35 +88,34 @@ class CacheHandler extends DecoratorHandler { } if ( - !util.safeHTTPMethods.includes(this.#requestOptions.method) && + !util.safeHTTPMethods.includes(this.#cacheKey.method) && statusCode >= 200 && statusCode <= 399 ) { // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response try { - this.#store.deleteByOrigin(this.#requestOptions.origin.toString())?.catch?.(noop) + this.#store.deleteByKey(this.#cacheKey).catch?.(noop) } catch { // Fail silently } return downstreamOnHeaders() } - const headers = util.parseHeaders(rawHeaders) + const parsedRawHeaders = util.parseRawHeaders(rawHeaders) + const headers = util.parseHeaders(parsedRawHeaders) const cacheControlHeader = headers['cache-control'] - if (!cacheControlHeader || typeof cacheControlHeader !== 'string') { - // Don't have cache-control, can't cache. - return downstreamOnHeaders() - } + const isCacheFull = typeof this.#store.isFull !== 'undefined' + ? this.#store.isFull + : false - const contentLengthHeader = headers['content-length'] - const contentLength = contentLengthHeader ? Number(contentLengthHeader) : null - if (!Number.isInteger(contentLength)) { - // Don't know the final size, don't cache. - // TODO (fix): Why not cache? + if ( + !cacheControlHeader || + isCacheFull + ) { + // Don't have the cache control header or the cache is full return downstreamOnHeaders() } - const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader) if (!canCacheResponse(statusCode, headers, cacheControlDirectives)) { return downstreamOnHeaders() @@ -125,42 +124,54 @@ class CacheHandler extends DecoratorHandler { const now = Date.now() const staleAt = determineStaleAt(now, headers, cacheControlDirectives) if (staleAt) { - const varyDirectives = headers.vary - ? parseVaryHeader(headers.vary, this.#requestOptions.headers) + const varyDirectives = this.#cacheKey.headers && headers.vary + ? parseVaryHeader(headers.vary, this.#cacheKey.headers) : undefined const deleteAt = determineDeleteAt(now, cacheControlDirectives, staleAt) const strippedHeaders = stripNecessaryHeaders( rawHeaders, - headers, + parsedRawHeaders, cacheControlDirectives ) - this.#writeStream = this.#store.createWriteStream(this.#requestOptions, { - statusCode, - statusMessage, - rawHeaders: strippedHeaders, - vary: varyDirectives, - cachedAt: now, - staleAt, - deleteAt - }) - - if (this.#writeStream) { - const handler = this - this.#writeStream - .on('drain', resume) - .on('error', function () { + if (this.#cacheKey.method === 'HEAD') { + this.#store.createWriteStream(this.#cacheKey, { + statusCode, + statusMessage, + rawHeaders: strippedHeaders, + vary: varyDirectives, + cachedAt: now, + staleAt, + deleteAt + }) + } else { + this.#writeStream = this.#store.createWriteStream(this.#cacheKey, { + statusCode, + statusMessage, + rawHeaders: strippedHeaders, + vary: varyDirectives, + cachedAt: now, + staleAt, + deleteAt + }) + + if (this.#writeStream) { + const handler = this + this.#writeStream + .on('drain', resume) + .on('error', function () { // TODO (fix): Make error somehow observable? - }) - .on('close', function () { - if (handler.#writeStream === this) { - handler.#writeStream = undefined - } - - // TODO (fix): Should we resume even if was paused downstream? - resume() - }) + }) + .on('close', function () { + if (handler.#writeStream === this) { + handler.#writeStream = undefined + } + + // TODO (fix): Should we resume even if was paused downstream? + resume() + }) + } } } @@ -194,7 +205,6 @@ class CacheHandler extends DecoratorHandler { */ onComplete (rawTrailers) { if (this.#writeStream) { - this.#writeStream.rawTrailers = rawTrailers ?? [] this.#writeStream.end() } @@ -224,7 +234,7 @@ class CacheHandler extends DecoratorHandler { * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen * * @param {number} statusCode - * @param {Record} headers + * @param {Record} headers * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives */ function canCacheResponse (statusCode, headers, cacheControlDirectives) { @@ -236,7 +246,6 @@ function canCacheResponse (statusCode, headers, cacheControlDirectives) { } if ( - !cacheControlDirectives.public || cacheControlDirectives.private === true || cacheControlDirectives['no-cache'] === true || cacheControlDirectives['no-store'] @@ -250,7 +259,11 @@ function canCacheResponse (statusCode, headers, cacheControlDirectives) { } // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen - if (headers['authorization']) { + if (headers.authorization) { + if (!cacheControlDirectives.public || typeof headers.authorization !== 'string') { + return false + } + if ( Array.isArray(cacheControlDirectives['no-cache']) && cacheControlDirectives['no-cache'].includes('authorization') @@ -295,9 +308,12 @@ function determineStaleAt (now, headers, cacheControlDirectives) { return now + (maxAge * 1000) } - if (headers.expire) { + if (headers.expire && typeof headers.expire === 'string') { // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 - return now + (Date.now() - new Date(headers.expire).getTime()) + const expiresDate = new Date(headers.expire) + if (expiresDate instanceof Date && !isNaN(expiresDate)) { + return now + (Date.now() - expiresDate.getTime()) + } } return undefined @@ -319,11 +335,11 @@ function determineDeleteAt (now, cacheControlDirectives, staleAt) { /** * Strips headers required to be removed in cached responses * @param {Buffer[]} rawHeaders - * @param {Record} parsedHeaders + * @param {string[]} parsedRawHeaders * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives - * @returns {(Buffer|Buffer[])[]} + * @returns {Buffer[]} */ -function stripNecessaryHeaders (rawHeaders, parsedHeaders, cacheControlDirectives) { +function stripNecessaryHeaders (rawHeaders, parsedRawHeaders, cacheControlDirectives) { const headersToRemove = ['connection'] if (Array.isArray(cacheControlDirectives['no-cache'])) { @@ -334,39 +350,52 @@ function stripNecessaryHeaders (rawHeaders, parsedHeaders, cacheControlDirective headersToRemove.push(...cacheControlDirectives['private']) } - /** - * These are the headers that are okay to cache. If this is assigned, we need - * to remake the buffer representation of the headers - * @type {Record | undefined} - */ let strippedHeaders - const headerNames = Object.keys(parsedHeaders) - for (let i = 0; i < headerNames.length; i++) { - const header = headerNames[i] + let offset = 0 + for (let i = 0; i < parsedRawHeaders.length; i += 2) { + const headerName = parsedRawHeaders[i] - if (headersToRemove.includes(header)) { - // We have a at least one header we want to remove + if (headersToRemove.includes(headerName)) { + // We have at least one header we want to remove if (!strippedHeaders) { - // This is the first header we want to remove, let's create the object - // and backfill the previous headers into it - strippedHeaders = {} - - for (let j = 0; j < i; j++) { - strippedHeaders[headerNames[j]] = parsedHeaders[headerNames[j]] + // This is the first header we want to remove, let's create the array + // Since we're stripping headers, this will over allocate. We'll trim + // it later. + strippedHeaders = new Array(parsedRawHeaders.length) + + // Backfill the previous headers into it + for (let j = 0; j < i; j += 2) { + strippedHeaders[j] = parsedRawHeaders[j] + strippedHeaders[j + 1] = parsedRawHeaders[j + 1] } } + // We can't map indices 1:1 from stripped headers to rawHeaders without + // creating holes (if we skip a header, we now have two holes where at + // element should be). So, let's keep an offset to keep strippedHeaders + // flattened. We can also use this at the end for trimming the empty + // elements off of strippedHeaders. + offset += 2 + continue } - // This header is fine. Let's add it to strippedHeaders if it exists. + // We want to keep this header. Let's add it to strippedHeaders if it exists if (strippedHeaders) { - strippedHeaders[header] = parsedHeaders[header] + strippedHeaders[i - offset] = parsedRawHeaders[i] + strippedHeaders[i + 1 - offset] = parsedRawHeaders[i + 1] } } - return strippedHeaders ? util.encodeHeaders(strippedHeaders) : rawHeaders + if (strippedHeaders) { + // Trim off the empty values at the end + strippedHeaders.length -= offset + } + + return strippedHeaders + ? util.encodeRawHeaders(strippedHeaders) + : rawHeaders } module.exports = CacheHandler diff --git a/lib/handler/cache-revalidation-handler.js b/lib/handler/cache-revalidation-handler.js index 729cb57d03d..9e0a7f288bc 100644 --- a/lib/handler/cache-revalidation-handler.js +++ b/lib/handler/cache-revalidation-handler.js @@ -29,6 +29,7 @@ class CacheRevalidationHandler extends DecoratorHandler { #handler #abort + /** * @param {(boolean) => void} callback Function to call if the cached value is valid * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js index 945eeeb924a..fca9ee831e1 100644 --- a/lib/interceptor/cache.js +++ b/lib/interceptor/cache.js @@ -5,12 +5,12 @@ 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 } = require('../util/cache.js') +const { assertCacheStore, assertCacheMethods, makeCacheKey } = require('../util/cache.js') const AGE_HEADER = Buffer.from('age') /** - * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} CacheStoreValue + * @typedef {import('../../types/cache-interceptor.d.ts').default.CachedResponse} CachedResponse */ /** @@ -47,27 +47,30 @@ module.exports = (opts = {}) => { return dispatch(opts, handler) } + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + const cacheKey = makeCacheKey(opts) + // TODO (perf): For small entries support returning a Buffer instead of a stream. // Maybe store should return { staleAt, headers, body, etc... } instead of a stream + stream.value? // Where body can be a Buffer, string, stream or blob? - - const stream = store.createReadStream(opts) - if (!stream) { + const result = store.get(cacheKey) + if (!result) { // Request isn't cached - return dispatch(opts, new CacheHandler(globalOpts, opts, handler)) + return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) } /** - * @param {import('node:stream').Readable} stream - * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} value + * @param {import('node:stream').Readable | undefined} stream + * @param {import('../../types/cache-interceptor.d.ts').default.CachedResponse} value */ const respondWithCachedValue = (stream, value) => { - assert(!stream.destroyed, 'stream should not be destroyed') - assert(!stream.readableDidRead, 'stream should not be readableDidRead') - + assert(!stream || !stream.destroyed, 'stream should not be destroyed') + assert(!stream || !stream.readableDidRead, 'stream should not be readableDidRead') try { stream - .on('error', function (err) { + ?.on('error', function (err) { if (!this.readableEnded) { if (typeof handler.onError === 'function') { handler.onError(err) @@ -76,21 +79,20 @@ module.exports = (opts = {}) => { throw err }) } - } else { - // Ignore error... } }) .on('close', function () { if (!this.errored && typeof handler.onComplete === 'function') { - handler.onComplete(value.rawTrailers ?? []) + handler.onComplete([]) } }) if (typeof handler.onConnect === 'function') { handler.onConnect((err) => { - stream.destroy(err) + stream?.destroy(err) }) - if (stream.destroyed) { + + if (stream?.destroyed) { return } } @@ -103,13 +105,17 @@ module.exports = (opts = {}) => { // TODO (fix): What if rawHeaders already contains age header? const rawHeaders = [...value.rawHeaders, AGE_HEADER, Buffer.from(`${age}`)] - if (handler.onHeaders(value.statusCode, rawHeaders, () => stream.resume(), value.statusMessage) === false) { - stream.pause() + if (handler.onHeaders(value.statusCode, rawHeaders, () => stream?.resume(), value.statusMessage) === false) { + stream?.pause() } } if (opts.method === 'HEAD') { - stream.destroy() + if (typeof handler.onComplete === 'function') { + handler.onComplete([]) + } + + stream?.destroy() } else { stream.on('data', function (chunk) { if (typeof handler.onData === 'function' && !handler.onData(chunk)) { @@ -118,18 +124,18 @@ module.exports = (opts = {}) => { }) } } catch (err) { - stream.destroy(err) + stream?.destroy(err) } } /** - * @param {import('node:stream').Readable | undefined} stream - * @param {CacheStoreValue | undefined} value + * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result */ - const handleStream = (stream, value) => { - if (!stream || !value) { - stream?.on('error', () => {}).destroy() - return dispatch(opts, new CacheHandler(globalOpts, opts, handler)) + const handleStream = (result) => { + const { response: value, body: stream } = result + + if (!stream && opts.method !== 'HEAD') { + throw new Error('stream is undefined but method isn\'t HEAD') } // Check if the response is stale @@ -143,7 +149,7 @@ module.exports = (opts = {}) => { } 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, opts, handler)) + dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) } else { // Need to revalidate the response dispatch( @@ -162,22 +168,23 @@ module.exports = (opts = {}) => { stream.on('error', () => {}).destroy() } }, - new CacheHandler(globalOpts, opts, handler) + new CacheHandler(globalOpts, cacheKey, handler) ) ) } } - if (util.isStream(stream)) { - handleStream(stream, stream.value) - } else { - Promise.resolve(stream).then(stream => handleStream(stream, stream?.value), err => { - if (typeof handler.onError === 'function') { - handler.onError(err) - } else { - throw err + if (typeof result.then === 'function') { + result.then((result) => { + if (!result) { + // Request isn't cached + return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) } - }) + + handleStream(result) + }).catch(err => handler.onError(err)) + } else { + handleStream(result) } return true diff --git a/lib/util/cache.js b/lib/util/cache.js index 48a91da3e74..0386a450a27 100644 --- a/lib/util/cache.js +++ b/lib/util/cache.js @@ -4,6 +4,28 @@ const { safeHTTPMethods } = require('../core/util') +/** + * + * @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts + */ +function makeCacheKey (opts) { + if (!opts.origin) { + throw new Error('opts.origin is undefined') + } + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + const cacheKey = { + origin: opts.origin.toString(), + method: opts.method, + path: opts.path, + headers: opts.headers + } + + return cacheKey +} + /** * @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 @@ -27,7 +49,7 @@ const { * 'only-if-cached'?: true; * }} CacheControlDirectives * - * @param {string} header + * @param {string | string[]} header * @returns {CacheControlDirectives} */ function parseCacheControlHeader (header) { @@ -36,9 +58,9 @@ function parseCacheControlHeader (header) { */ const output = {} - const directives = header.toLowerCase().split(',') + const directives = Array.isArray(header) ? header : header.split(',') for (let i = 0; i < directives.length; i++) { - const directive = directives[i] + const directive = directives[i].toLowerCase() const keyValueDelimiter = directive.indexOf('=') let key @@ -154,20 +176,22 @@ function parseCacheControlHeader (header) { } /** - * @param {string} varyHeader Vary header from the server - * @param {Record} headers Request headers - * @returns {Record} + * @param {string | string[]} varyHeader Vary header from the server + * @param {Record} headers Request headers + * @returns {Record} */ function parseVaryHeader (varyHeader, headers) { - if (varyHeader === '*') { + if (typeof varyHeader === 'string' && varyHeader === '*') { return headers } - const output = /** @type {Record} */ ({}) + const output = /** @type {Record} */ ({}) - const varyingHeaders = varyHeader.toLowerCase().split(',') + const varyingHeaders = typeof varyHeader === 'string' + ? varyHeader.split(',') + : varyHeader for (const header of varyingHeaders) { - const trimmedHeader = header.trim() + const trimmedHeader = header.trim().toLowerCase() if (headers[trimmedHeader]) { output[trimmedHeader] = headers[trimmedHeader] @@ -186,14 +210,14 @@ function assertCacheStore (store, name = 'CacheStore') { throw new TypeError(`expected type of ${name} to be a CacheStore, got ${store === null ? 'null' : typeof store}`) } - for (const fn of ['createReadStream', 'createWriteStream', 'deleteByOrigin']) { + for (const fn of ['get', 'createWriteStream', 'deleteByKey']) { if (typeof store[fn] !== 'function') { throw new TypeError(`${name} needs to have a \`${fn}()\` function`) } } - if (typeof store.isFull !== 'boolean') { - throw new TypeError(`${name} needs a isFull getter with type boolean, current type: ${typeof store.isFull}`) + if (typeof store.isFull !== 'undefined' && typeof store.isFull !== 'boolean') { + throw new TypeError(`${name} needs a isFull getter with type boolean or undefined, current type: ${typeof store.isFull}`) } } /** @@ -217,6 +241,7 @@ function assertCacheMethods (methods, name = 'CacheMethods') { } module.exports = { + makeCacheKey, parseCacheControlHeader, parseVaryHeader, assertCacheMethods, diff --git a/test/cache-interceptor/cache-stores.js b/test/cache-interceptor/cache-stores.js index 6de536e70ca..1acaff43aea 100644 --- a/test/cache-interceptor/cache-stores.js +++ b/test/cache-interceptor/cache-stores.js @@ -91,9 +91,9 @@ function cacheStoreTests (CacheStore) { test('matches interface', async () => { const store = new CacheStore() equal(typeof store.isFull, 'boolean') - equal(typeof store.createReadStream, 'function') + equal(typeof store.get, 'function') equal(typeof store.createWriteStream, 'function') - equal(typeof store.deleteByOrigin, 'function') + equal(typeof store.deleteByKey, 'function') }) // Checks that it can store & fetch different responses @@ -113,7 +113,6 @@ function cacheStoreTests (CacheStore) { deleteAt: Date.now() + 20000 } const requestBody = ['asd', '123'] - const requestTrailers = ['a', 'b', 'c'] /** * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore} @@ -121,21 +120,20 @@ function cacheStoreTests (CacheStore) { const store = new CacheStore() // Sanity check - equal(store.createReadStream(request), undefined) + equal(store.get(request), undefined) // Write the response to the store let writeStream = store.createWriteStream(request, requestValue) notEqual(writeStream, undefined) - writeResponse(writeStream, requestBody, requestTrailers) + writeResponse(writeStream, requestBody) // Now try fetching it with a deep copy of the original request - let readStream = store.createReadStream(structuredClone(request)) - notEqual(readStream, undefined) + let readResult = store.get(structuredClone(request)) + notEqual(readResult, undefined) - deepStrictEqual(await readResponse(readStream), { + deepStrictEqual(await readResponse(readResult), { ...requestValue, - body: requestBody, - rawTrailers: requestTrailers + body: requestBody }) // Now let's write another request to the store @@ -154,11 +152,10 @@ function cacheStoreTests (CacheStore) { deleteAt: Date.now() + 20000 } const anotherBody = ['asd2', '1234'] - const anotherTrailers = ['d', 'e', 'f'] // We haven't cached this one yet, make sure it doesn't confuse it with // another request - equal(store.createReadStream(anotherRequest), undefined) + equal(store.get(anotherRequest), undefined) // Now let's cache it writeStream = store.createWriteStream(anotherRequest, { @@ -166,14 +163,13 @@ function cacheStoreTests (CacheStore) { body: [] }) notEqual(writeStream, undefined) - writeResponse(writeStream, anotherBody, anotherTrailers) + writeResponse(writeStream, anotherBody) - readStream = store.createReadStream(anotherRequest) - notEqual(readStream, undefined) - deepStrictEqual(await readResponse(readStream), { + readResult = store.get(anotherRequest) + notEqual(readResult, undefined) + deepStrictEqual(await readResponse(readResult), { ...anotherValue, - body: anotherBody, - rawTrailers: anotherTrailers + body: anotherBody }) }) @@ -193,7 +189,6 @@ function cacheStoreTests (CacheStore) { deleteAt: Date.now() + 20000 } const requestBody = ['part1', 'part2'] - const requestTrailers = [4, 5, 6] /** * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore} @@ -202,13 +197,12 @@ function cacheStoreTests (CacheStore) { const writeStream = store.createWriteStream(request, requestValue) notEqual(writeStream, undefined) - writeResponse(writeStream, requestBody, requestTrailers) + writeResponse(writeStream, requestBody) - const readStream = store.createReadStream(request) - deepStrictEqual(await readResponse(readStream), { + const readResult = store.get(request) + deepStrictEqual(await readResponse(readResult), { ...requestValue, - body: requestBody, - rawTrailers: requestTrailers + body: requestBody }) }) @@ -227,7 +221,6 @@ function cacheStoreTests (CacheStore) { deleteAt: Date.now() - 5 } const requestBody = ['part1', 'part2'] - const rawTrailers = ['4', '5', '6'] /** * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore} @@ -236,9 +229,9 @@ function cacheStoreTests (CacheStore) { const writeStream = store.createWriteStream(request, requestValue) notEqual(writeStream, undefined) - writeResponse(writeStream, requestBody, rawTrailers) + writeResponse(writeStream, requestBody) - equal(store.createReadStream(request), undefined) + equal(store.get(request), undefined) }) test('respects vary directives', async () => { @@ -262,7 +255,6 @@ function cacheStoreTests (CacheStore) { deleteAt: Date.now() + 20000 } const requestBody = ['part1', 'part2'] - const requestTrailers = ['4', '5', '6'] /** * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore} @@ -270,18 +262,17 @@ function cacheStoreTests (CacheStore) { const store = new CacheStore() // Sanity check - equal(store.createReadStream(request), undefined) + equal(store.get(request), undefined) const writeStream = store.createWriteStream(request, requestValue) notEqual(writeStream, undefined) - writeResponse(writeStream, requestBody, requestTrailers) + writeResponse(writeStream, requestBody) - const readStream = store.createReadStream(structuredClone(request)) + const readStream = store.get(structuredClone(request)) notEqual(readStream, undefined) deepStrictEqual(await readResponse(readStream), { ...requestValue, - body: requestBody, - rawTrailers: requestTrailers + body: requestBody }) const nonMatchingRequest = { @@ -292,7 +283,7 @@ function cacheStoreTests (CacheStore) { 'some-header': 'another-value' } } - equal(store.createReadStream(nonMatchingRequest), undefined) + equal(store.get(nonMatchingRequest), undefined) }) }) } @@ -321,42 +312,45 @@ test('MemoryCacheStore locks values properly', async () => { // Value should now be locked, we shouldn't be able to create a readable or // another writable to it until the first one finishes - equal(store.createReadStream(request), undefined) + equal(store.get(request), undefined) equal(store.createWriteStream(request, requestValue), undefined) // Close the writable, this should unlock it - writeResponse(writable, ['asd'], []) + writeResponse(writable, ['asd']) // Stream is now closed, let's lock any new write streams - const readable = store.createReadStream(request) - notEqual(readable, undefined) - equal(store.createWriteStream(request, requestValue), undefined) + const result = store.get(request) + notEqual(result, undefined) - // Consume & close the readable, this should lift the write lock - await readResponse(readable) + // Consume & close the result, this should lift the write lock + await readResponse(result) notEqual(store.createWriteStream(request, requestValue), undefined) }) /** - * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreWriteable} stream + * @param {import('node:stream').Writable} stream * @param {string[]} body - * @param {string[]} trailers */ -function writeResponse (stream, body, trailers) { +function writeResponse (stream, body) { for (const chunk of body) { stream.write(Buffer.from(chunk)) } - stream.rawTrailers = trailers stream.end() } /** - * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreReadable} stream - * @returns {Promise} + * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result + * @returns {Promise} */ -async function readResponse (stream) { +async function readResponse ({ response, body: stream }) { + notEqual(response, undefined) + notEqual(stream, undefined) + + /** + * @type {Buffer[]} + */ const body = [] stream.on('data', chunk => { body.push(chunk.toString()) @@ -365,7 +359,7 @@ async function readResponse (stream) { await once(stream, 'end') return { - ...stream.value, + ...response, body } } diff --git a/test/cache-interceptor/utils.js b/test/cache-interceptor/utils.js index a676d62d16e..f7e0a948ef3 100644 --- a/test/cache-interceptor/utils.js +++ b/test/cache-interceptor/utils.js @@ -131,6 +131,49 @@ describe('parseCacheControlHeader', () => { 'only-if-cached': true }) }) + + test('handles multiple headers correctly', () => { + // For requests like + // cache-control: max-stale=1 + // cache-control: min-fresh-1 + // ... + 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 + }) + }) }) describe('parseVaryHeader', () => { @@ -159,4 +202,16 @@ describe('parseVaryHeader', () => { 'something-else': 'asd123' }) }) + + test('handles multiple headers correctly', () => { + const output = parseVaryHeader(['some-header', 'another-one'], { + 'some-header': 'asd', + 'another-one': '123', + 'third-header': 'cool' + }) + deepStrictEqual(output, { + 'some-header': 'asd', + 'another-one': '123' + }) + }) }) diff --git a/test/interceptors/cache.js b/test/interceptors/cache.js index c99cb52279f..a116b9af13c 100644 --- a/test/interceptors/cache.js +++ b/test/interceptors/cache.js @@ -251,7 +251,7 @@ describe('Cache Interceptor', () => { }) }) - test('unsafe methods call the store\'s deleteByOrigin function', async () => { + test('unsafe methods call the store\'s deleteByKey function', async () => { const server = createServer((_, res) => { res.end('asd') }).listen(0) @@ -259,13 +259,13 @@ describe('Cache Interceptor', () => { after(() => server.close()) await once(server, 'listening') - let deleteByOriginCalled = false + let deleteByKeyCalled = false const store = new cacheStores.MemoryCacheStore() - const originalDeleteByOrigin = store.deleteByOrigin.bind(store) - store.deleteByOrigin = (origin) => { - deleteByOriginCalled = true - originalDeleteByOrigin(origin) + const originalDeleteByKey = store.deleteByKey.bind(store) + store.deleteByKey = (key) => { + deleteByKeyCalled = true + originalDeleteByKey(key) } const client = new Client(`http://localhost:${server.address().port}`) @@ -281,7 +281,7 @@ describe('Cache Interceptor', () => { path: '/' }) - equal(deleteByOriginCalled, false) + equal(deleteByKeyCalled, false) // Make sure other safe methods that we don't want to cache don't cause a cache purge await client.request({ @@ -290,11 +290,11 @@ describe('Cache Interceptor', () => { path: '/' }) - strictEqual(deleteByOriginCalled, false) + strictEqual(deleteByKeyCalled, false) // Make sure the common unsafe methods cause cache purges for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { - deleteByOriginCalled = false + deleteByKeyCalled = false await client.request({ origin: 'localhost', @@ -302,7 +302,37 @@ describe('Cache Interceptor', () => { path: '/' }) - equal(deleteByOriginCalled, true, method) + equal(deleteByKeyCalled, true, method) } }) + + test('necessary headers are stripped', async () => { + const server = createServer((req, res) => { + res.setHeader('cache-control', 'public, s-maxage=1, stale-while-revalidate=10, no-cache=should-be-stripped') + res.setHeader('should-be-stripped', 'hello world') + res.setHeader('should-not-be-stripped', 'dsa321') + + res.end('asd') + }).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') + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // Send initial request. This should reach the origin + const response = await client.request(request) + strictEqual(await response.body.text(), 'asd') + }) }) diff --git a/test/types/cache-interceptor.test-d.ts b/test/types/cache-interceptor.test-d.ts index 016b5ecad1e..53dab2592dc 100644 --- a/test/types/cache-interceptor.test-d.ts +++ b/test/types/cache-interceptor.test-d.ts @@ -1,19 +1,19 @@ +import { Writable } from 'node:stream' import { expectAssignable, expectNotAssignable } from 'tsd' import CacheInterceptor from '../../types/cache-interceptor' -import Dispatcher from '../../types/dispatcher' const store: CacheInterceptor.CacheStore = { isFull: false, - createReadStream (_: Dispatcher.RequestOptions): CacheInterceptor.CacheStoreReadable | undefined { + get (_: CacheInterceptor.CacheKey): CacheInterceptor.GetResult | Promise | undefined { throw new Error('stub') }, - createWriteStream (_: Dispatcher.RequestOptions, _2: CacheInterceptor.CacheStoreValue): CacheInterceptor.CacheStoreWriteable | undefined { + createWriteStream (_: CacheInterceptor.CacheKey, _2: CacheInterceptor.CachedResponse): Writable | undefined { throw new Error('stub') }, - deleteByOrigin (_: string): void | Promise { + deleteByKey (_: CacheInterceptor.CacheKey): void | Promise { throw new Error('stub') } } @@ -23,7 +23,7 @@ expectAssignable({ store }) expectAssignable({ methods: [] }) expectAssignable({ store, methods: ['GET'] }) -expectAssignable({ +expectAssignable({ statusCode: 200, statusMessage: 'OK', rawHeaders: [], @@ -32,24 +32,21 @@ expectAssignable({ deleteAt: 0 }) -expectAssignable({ +expectAssignable({ statusCode: 200, statusMessage: 'OK', rawHeaders: [], - rawTrailers: [], vary: {}, cachedAt: 0, staleAt: 0, deleteAt: 0 }) -expectNotAssignable({}) -expectNotAssignable({ +expectNotAssignable({}) +expectNotAssignable({ statusCode: '123', statusMessage: 123, rawHeaders: '', - rawTrailers: '', - body: 0, vary: '', size: '', cachedAt: '', diff --git a/types/cache-interceptor.d.ts b/types/cache-interceptor.d.ts index efbe78b19c7..5fefebafe65 100644 --- a/types/cache-interceptor.d.ts +++ b/types/cache-interceptor.d.ts @@ -1,5 +1,4 @@ import { Readable, Writable } from 'node:stream' -import Dispatcher from './dispatcher' export default CacheHandler @@ -19,6 +18,24 @@ declare namespace CacheHandler { methods?: CacheMethods[] } + export interface CacheKey { + origin: string + method: string + path: string + headers?: Record + } + + export interface DeleteByUri { + origin: string + method: string + path: string + } + + export interface GetResult { + response: CachedResponse + body?: Readable + } + /** * Underlying storage provider for cached responses */ @@ -26,36 +43,24 @@ declare namespace CacheHandler { /** * Whether or not the cache is full and can not store any more responses */ - get isFull(): boolean - - createReadStream(req: Dispatcher.RequestOptions): CacheStoreReadable | Promise | undefined + get isFull(): boolean | undefined - createWriteStream(req: Dispatcher.RequestOptions, value: Omit): CacheStoreWriteable | undefined + get(key: CacheKey): GetResult | Promise | undefined - /** - * Delete all of the cached responses from a certain origin (host) - */ - deleteByOrigin(origin: string): void | Promise - } + createWriteStream(key: CacheKey, value: CachedResponse): Writable | undefined - export interface CacheStoreReadable extends Readable { - get value(): CacheStoreValue + deleteByKey(key: CacheKey): void | Promise; } - export interface CacheStoreWriteable extends Writable { - set rawTrailers(rawTrailers: string[] | undefined) - } - - export interface CacheStoreValue { + export interface CachedResponse { statusCode: number; statusMessage: string; - rawHeaders: (Buffer | Buffer[])[]; - rawTrailers?: string[]; + rawHeaders: Buffer[]; /** * Headers defined by the Vary header and their respective values for * later comparison */ - vary?: Record; + vary?: Record; /** * Time in millis that this value was cached */ @@ -73,12 +78,12 @@ declare namespace CacheHandler { export interface MemoryCacheStoreOpts { /** - * @default Infinity - */ + * @default Infinity + */ maxEntries?: number /** - * @default Infinity - */ + * @default Infinity + */ maxEntrySize?: number errorCallback?: (err: Error) => void } @@ -88,11 +93,11 @@ declare namespace CacheHandler { get isFull (): boolean - createReadStream (req: Dispatcher.RequestOptions): CacheStoreReadable | undefined + get (key: CacheKey): GetResult | Promise | undefined - createWriteStream (req: Dispatcher.RequestOptions, value: CacheStoreValue): CacheStoreWriteable + createWriteStream (key: CacheKey, value: CachedResponse): Writable | undefined - deleteByOrigin (origin: string): void + deleteByKey (uri: DeleteByUri): void } export interface SqliteCacheStoreOpts {