'use strict' const util = require('../core/util') const { parseCacheControlHeader, parseVaryHeader, isEtagUsable } = require('../util/cache') const { parseHttpDate } = require('../util/date.js') function noop () {} // Status codes that we can use some heuristics on to cache const HEURISTICALLY_CACHEABLE_STATUS_CODES = [ 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501 ] const MAX_RESPONSE_AGE = 2147483647000 /** * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler * * @implements {DispatchHandler} */ class CacheHandler { /** * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} */ #cacheKey /** * @type {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions['type']} */ #cacheType /** * @type {number | undefined} */ #cacheByDefault /** * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore} */ #store /** * @type {import('../../types/dispatcher.d.ts').default.DispatchHandler} */ #handler /** * @type {import('node:stream').Writable | undefined} */ #writeStream /** * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} opts * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey * @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler */ constructor ({ store, type, cacheByDefault }, cacheKey, handler) { this.#store = store this.#cacheType = type this.#cacheByDefault = cacheByDefault this.#cacheKey = cacheKey this.#handler = handler } onRequestStart (controller, context) { this.#writeStream?.destroy() this.#writeStream = undefined this.#handler.onRequestStart?.(controller, context) } onRequestUpgrade (controller, statusCode, headers, socket) { this.#handler.onRequestUpgrade?.(controller, statusCode, headers, socket) } /** * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller * @param {number} statusCode * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders * @param {string} statusMessage */ onResponseStart ( controller, statusCode, resHeaders, statusMessage ) { const downstreamOnHeaders = () => this.#handler.onResponseStart?.( controller, statusCode, resHeaders, statusMessage ) if ( !util.safeHTTPMethods.includes(this.#cacheKey.method) && statusCode >= 200 && statusCode <= 399 ) { // Successful response to an unsafe method, delete it from cache // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response try { this.#store.delete(this.#cacheKey)?.catch?.(noop) } catch { // Fail silently } return downstreamOnHeaders() } const cacheControlHeader = resHeaders['cache-control'] const heuristicallyCacheable = resHeaders['last-modified'] && HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode) if ( !cacheControlHeader && !resHeaders['expires'] && !heuristicallyCacheable && !this.#cacheByDefault ) { // Don't have anything to tell us this response is cachable and we're not // caching by default return downstreamOnHeaders() } const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {} if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives)) { return downstreamOnHeaders() } const now = Date.now() const resAge = resHeaders.age ? getAge(resHeaders.age) : undefined if (resAge && resAge >= MAX_RESPONSE_AGE) { // Response considered stale return downstreamOnHeaders() } const resDate = typeof resHeaders.date === 'string' ? parseHttpDate(resHeaders.date) : undefined const staleAt = determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives) ?? this.#cacheByDefault if (staleAt === undefined || (resAge && resAge > staleAt)) { return downstreamOnHeaders() } const baseTime = resDate ? resDate.getTime() : now const absoluteStaleAt = staleAt + baseTime if (now >= absoluteStaleAt) { // Response is already stale return downstreamOnHeaders() } let varyDirectives if (this.#cacheKey.headers && resHeaders.vary) { varyDirectives = parseVaryHeader(resHeaders.vary, this.#cacheKey.headers) if (!varyDirectives) { // Parse error return downstreamOnHeaders() } } const deleteAt = determineDeleteAt(baseTime, cacheControlDirectives, absoluteStaleAt) const strippedHeaders = stripNecessaryHeaders(resHeaders, cacheControlDirectives) /** * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} */ const value = { statusCode, statusMessage, headers: strippedHeaders, vary: varyDirectives, cacheControlDirectives, cachedAt: resAge ? now - resAge : now, staleAt: absoluteStaleAt, deleteAt } if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) { value.etag = resHeaders.etag } this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value) if (!this.#writeStream) { return downstreamOnHeaders() } const handler = this this.#writeStream .on('drain', () => controller.resume()) .on('error', function () { // TODO (fix): Make error somehow observable? handler.#writeStream = undefined // Delete the value in case the cache store is holding onto state from // the call to createWriteStream handler.#store.delete(handler.#cacheKey) }) .on('close', function () { if (handler.#writeStream === this) { handler.#writeStream = undefined } // TODO (fix): Should we resume even if was paused downstream? controller.resume() }) return downstreamOnHeaders() } onResponseData (controller, chunk) { if (this.#writeStream?.write(chunk) === false) { controller.pause() } this.#handler.onResponseData?.(controller, chunk) } onResponseEnd (controller, trailers) { this.#writeStream?.end() this.#handler.onResponseEnd?.(controller, trailers) } onResponseError (controller, err) { this.#writeStream?.destroy(err) this.#writeStream = undefined this.#handler.onResponseError?.(controller, err) } } /** * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen * * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType * @param {number} statusCode * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives */ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives) { if (statusCode !== 200 && statusCode !== 307) { return false } if (cacheControlDirectives['no-store']) { return false } if (cacheType === 'shared' && cacheControlDirectives.private === true) { return false } // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5 if (resHeaders.vary?.includes('*')) { return false } // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen if (resHeaders.authorization) { if (!cacheControlDirectives.public || typeof resHeaders.authorization !== 'string') { return false } if ( Array.isArray(cacheControlDirectives['no-cache']) && cacheControlDirectives['no-cache'].includes('authorization') ) { return false } if ( Array.isArray(cacheControlDirectives['private']) && cacheControlDirectives['private'].includes('authorization') ) { return false } } return true } /** * @param {string | string[]} ageHeader * @returns {number | undefined} */ function getAge (ageHeader) { const age = parseInt(Array.isArray(ageHeader) ? ageHeader[0] : ageHeader) return isNaN(age) ? undefined : age * 1000 } /** * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType * @param {number} now * @param {number | undefined} age * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders * @param {Date | undefined} responseDate * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives * * @returns {number | undefined} time that the value is stale at in seconds or undefined if it shouldn't be cached */ function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheControlDirectives) { if (cacheType === 'shared') { // 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 !== undefined) { return sMaxAge > 0 ? sMaxAge * 1000 : undefined } } const maxAge = cacheControlDirectives['max-age'] if (maxAge !== undefined) { return maxAge > 0 ? maxAge * 1000 : undefined } if (typeof resHeaders.expires === 'string') { // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 const expiresDate = parseHttpDate(resHeaders.expires) if (expiresDate) { if (now >= expiresDate.getTime()) { return undefined } if (responseDate) { if (responseDate >= expiresDate) { return undefined } if (age !== undefined && age > (expiresDate - responseDate)) { return undefined } } return expiresDate.getTime() - now } } if (typeof resHeaders['last-modified'] === 'string') { // https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-heuristic-fresh const lastModified = new Date(resHeaders['last-modified']) if (isValidDate(lastModified)) { if (lastModified.getTime() >= now) { return undefined } const responseAge = now - lastModified.getTime() return responseAge * 0.1 } } if (cacheControlDirectives.immutable) { // https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2 return 31536000 } return undefined } /** * @param {number} now * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives * @param {number} staleAt */ function determineDeleteAt (now, cacheControlDirectives, staleAt) { let staleWhileRevalidate = -Infinity let staleIfError = -Infinity let immutable = -Infinity if (cacheControlDirectives['stale-while-revalidate']) { staleWhileRevalidate = staleAt + (cacheControlDirectives['stale-while-revalidate'] * 1000) } if (cacheControlDirectives['stale-if-error']) { staleIfError = staleAt + (cacheControlDirectives['stale-if-error'] * 1000) } if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity) { immutable = now + 31536000000 } return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable) } /** * Strips headers required to be removed in cached responses * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives * @returns {Record} */ function stripNecessaryHeaders (resHeaders, cacheControlDirectives) { const headersToRemove = [ 'connection', 'proxy-authenticate', 'proxy-authentication-info', 'proxy-authorization', 'proxy-connection', 'te', 'transfer-encoding', 'upgrade', // We'll add age back when serving it 'age' ] if (resHeaders['connection']) { if (Array.isArray(resHeaders['connection'])) { // connection: a // connection: b headersToRemove.push(...resHeaders['connection'].map(header => header.trim())) } else { // connection: a, b headersToRemove.push(...resHeaders['connection'].split(',').map(header => header.trim())) } } if (Array.isArray(cacheControlDirectives['no-cache'])) { headersToRemove.push(...cacheControlDirectives['no-cache']) } if (Array.isArray(cacheControlDirectives['private'])) { headersToRemove.push(...cacheControlDirectives['private']) } let strippedHeaders for (const headerName of headersToRemove) { if (resHeaders[headerName]) { strippedHeaders ??= { ...resHeaders } delete strippedHeaders[headerName] } } return strippedHeaders ?? resHeaders } /** * @param {Date} date * @returns {boolean} */ function isValidDate (date) { return date instanceof Date && Number.isFinite(date.valueOf()) } module.exports = CacheHandler