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..f890d12d97c --- /dev/null +++ b/lib/cache/lru-cache-store.js @@ -0,0 +1,82 @@ +'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 undefined + } + + let needsFlattening = false + const now = Date.now() + let value + for (let i = 0; i < values.length; i++) { + const current = values[i] + if (now >= current.expiresAt && !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) + + if (!this.#data.has(key)) { + this.#data.set(key, []) + } + + this.#data.get(key).push(value) + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req + * @returns {string} + */ + #makeKey (req) { + 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..d42dcb14448 --- /dev/null +++ b/lib/handler/cache-handler.js @@ -0,0 +1,202 @@ +'use strict' + +const util = require('../core/util.js') +const DecoratorHandler = require('../handler/decorator-handler') +const { + parseCacheControlHeader, + shouldRequestBeCached, + parseVaryHeader +} = require('../util/cache.js') + +/** + * Takes a request, copies its data to a cache store, and passes it to the next + * handler. + */ +class CacheHandler extends DecoratorHandler { + #globalOpts + #req + #handler + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue | undefined} + */ + #value = undefined + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} globalOpts + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler + */ + constructor (globalOpts, req, handler) { + super(handler) + + this.#globalOpts = globalOpts + this.#req = req + this.#handler = handler + } + + onHeaders ( + statusCode, + rawHeaders, + resume, + statusMessage, + headers = util.parseHeaders(rawHeaders) + ) { + const cacheControlHeader = headers['cache-control'] + + // Cache control header is missing or the status code isn't what we want, + // let's not try to cache this response + if (!cacheControlHeader || !(statusCode in this.#globalOpts.statusCodes)) { + return this.#handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage, + headers + ) + } + + const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader) + + const contentLength = headers['content-length'] + ? Number(headers['content-length']) + : Infinity + + const maxEntrySize = this.#globalOpts.store.maxEntrySize ?? Infinity + + if ( + maxEntrySize > contentLength && + shouldRequestBeCached(cacheControlDirectives, headers.vary) + ) { + const varyDirectives = headers.vary + ? parseVaryHeader(headers.vary) + : undefined + + const ttl = determineTtl(headers, cacheControlDirectives) + 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, + expiresAt: now + ttl + } + } + } + + return this.#handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage, + headers + ) + } + + onData (chunk) { + if (this.#value) { + this.#value.size += chunk.bodyLength + + const maxEntrySize = this.#globalOpts.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.#globalOpts.store.put(this.#req, this.#value).catch(err => { + throw err + }) + } + + return this.#handler.onComplete(rawTrailers) + } +} + +/** + * @param {Record} headers + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @returns ttl for an object, 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) { + // Lazy deep clone + strippedRawHeaders = structuredClone(rawHeaders) + } + + delete strippedRawHeaders[i] + } + } + + 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..a1e6aee7efe --- /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 +) { + const ac = new AbortController() + const signal = ac.signal + + // 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 + } + + // TODO: check if the value is expired or not. If it is, then we need to send + // a request to the origin revalidating it to make sure we can serve it + // stale, and then also mark it as still-fresh somehow + + // Request is cached, let's return it + try { + const { + statusCode, + statusMessage, + rawHeaders, + rawTrailers, + body, + cachedAt, + expiresAt + } = value + + handler.onConnect(ac.abort) + signal.throwIfAborted() + + // https://www.rfc-editor.org/rfc/rfc9111.html#name-age + const age = expiresAt - 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) + } +} + +/** + * @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'] + } + + if (!globalOpts.statusCodes) { + globalOpts.statusCodes = [200] + } + + 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) { + // Request isn't cached, let's continue dispatching it + dispatch(opts, new CacheHandler(globalOpts, opts, handler)) + return + } + + 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..4ed07e1282d --- /dev/null +++ b/lib/util/cache.js @@ -0,0 +1,129 @@ +/** + * @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 (const directive of directives) { + 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() + } 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 'public': + case 'private': + case 'no-store': + case 'no-cache': + 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 shouldRequestBeCached (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} param0 + * @returns {boolean} + */ +function canServeStale ({ cachingDirectives, cachedAt }) { + if (cachingDirectives['stale-while-revalidate']) { + const canRevalidateUntil = + cachedAt + (cachingDirectives['stale-while-revalidate'] * 1000) + return Date.now() <= canRevalidateUntil + } + + return false +} + +module.exports = { + parseCacheControlHeader, + parseVaryHeader, + shouldRequestBeCached, + canServeStale +} diff --git a/test/cache-interceptor/utils.js b/test/cache-interceptor/utils.js new file mode 100644 index 00000000000..bb7fa06a838 --- /dev/null +++ b/test/cache-interceptor/utils.js @@ -0,0 +1,97 @@ +'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 }) + }) +}) + +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..9ceb97e55cb --- /dev/null +++ b/types/cache-interceptor.d.ts @@ -0,0 +1,75 @@ +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')[] + /** + * The response status codes to cache, or true to cache all of them + * Defaults to just 200 + */ + statusCodes?: boolean | number[] + } + + export interface CacheStore { + get maxEntrySize(): number + + get(key: Dispatcher.RequestOptions): CacheStoreValue[] | Promise; + + put(key: Dispatcher.RequestOptions, opts: CacheStoreValue): 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 expires in the cache + */ + expiresAt: number; + } + + 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; + 'must-revalidate'?: true; + 'proxy-revalidate'?: true; + immutable?: true; + 'no-transform'?: true; + 'must-understand'?: true; + 'only-if-cached'?: true; + } +} diff --git a/types/interceptors.d.ts b/types/interceptors.d.ts index 53835e01299..988c47fe2bc 100644 --- a/types/interceptors.d.ts +++ b/types/interceptors.d.ts @@ -1,3 +1,4 @@ +import CacheHandler from "./cache"; 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 }