'use strict' const util = require('../core/util') const { parseCacheControlHeader, hasInvalidCacheControlDirective, parseVaryHeader, hasVaryStar, isInvalidOrWildcardVaryHeader, 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 ] // Status codes which semantic is not handled by the cache // https://datatracker.ietf.org/doc/html/rfc9111#section-3 // This list should not grow beyond 206 unless the RFC is updated // by a newer one including more. Please introduce another list if // implementing caching of responses with the 'must-understand' directive. const NOT_UNDERSTOOD_STATUS_CODES = [ 206 ] const MAX_RESPONSE_AGE = 2147483647000 // Retention for revalidation-only entries (zero freshness lifetime but a // validator present); each successful revalidation re-stores the entry. const REVALIDATION_ONLY_RETENTION = 86400000 // 24 hours function trimOWS (value) { return value.replace(/^[\t ]+|[\t ]+$/g, '') } function arrayIncludes (array, value) { for (let i = 0; i < array.length; i++) { if (array[i] === value) { return true } } return false } function appendConnectionHeaderTokens (headersToRemove, connectionHeader) { const values = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader] for (let i = 0; i < values.length; i++) { const tokens = values[i].split(',') for (let j = 0; j < tokens.length; j++) { headersToRemove.push(trimOWS(tokens[j]).toLowerCase()) } } } function getSameOriginPath (cacheKey, location) { if (typeof location !== 'string') { return undefined } let originUrl let requestUrl let locationUrl try { originUrl = new URL(cacheKey.origin) requestUrl = new URL(cacheKey.path, originUrl) locationUrl = new URL(location, requestUrl) } catch { return undefined } if (locationUrl.origin !== originUrl.origin) { return undefined } return locationUrl.pathname + locationUrl.search } function deleteCachedUri (store, cacheKey, path) { deleteCachedValue(store, { ...cacheKey, path }) for (let i = 0; i < util.safeHTTPMethods.length; i++) { const method = util.safeHTTPMethods[i] if (method !== cacheKey.method) { deleteCachedValue(store, { ...cacheKey, method, path }) } } } function deleteLocationTargets (store, cacheKey, headerValue) { if (headerValue === undefined) { return } const values = Array.isArray(headerValue) ? headerValue : [headerValue] for (let i = 0; i < values.length; i++) { const path = getSameOriginPath(cacheKey, values[i]) if (path !== undefined) { deleteCachedUri(store, cacheKey, path) } } } function invalidateUnsafeRequest (store, cacheKey, resHeaders) { deleteCachedUri(store, cacheKey, cacheKey.path) deleteLocationTargets(store, cacheKey, resHeaders.location) deleteLocationTargets(store, cacheKey, resHeaders['content-location']) } /** * @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 ) const handler = this if ( !arrayIncludes(util.safeHTTPMethods, 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 invalidateUnsafeRequest(this.#store, this.#cacheKey, resHeaders) return downstreamOnHeaders() } const cacheControlHeader = resHeaders['cache-control'] const heuristicallyCacheable = resHeaders['last-modified'] && arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) if ( !cacheControlHeader && !resHeaders['expires'] && !heuristicallyCacheable && !this.#cacheByDefault ) { if (statusCode === 304 && resHeaders.vary && isInvalidOrWildcardVaryHeader(resHeaders.vary)) { deleteCachedValue(this.#store, this.#cacheKey) } // 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, this.#cacheKey.headers)) { if (statusCode === 304 && (cacheControlHeader || revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives))) { deleteCachedValue(this.#store, this.#cacheKey) } return downstreamOnHeaders() } const now = Date.now() const resAge = Object.hasOwn(resHeaders, 'age') ? getAge(resHeaders.age) : undefined if (resAge !== undefined && resAge >= MAX_RESPONSE_AGE) { // Response considered stale deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) return downstreamOnHeaders() } const resDate = Object.hasOwn(resHeaders, 'date') ? getDate(resHeaders.date) : undefined if (resDate === null) { deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) return downstreamOnHeaders() } const apparentAge = resDate ? Math.max(0, now - resDate.getTime()) : 0 const currentAge = Math.max(apparentAge, resAge ?? 0) const hasValidator = (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) || typeof resHeaders['last-modified'] === 'string' const staleAt = determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives, hasValidator) ?? this.#cacheByDefault // Zero freshness lifetime but a validator: stale from the start, yet still // storable since each reuse is preceded by a revalidation request. // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4 const revalidationOnly = staleAt === 0 && hasValidator if (staleAt === undefined || (currentAge >= staleAt && !revalidationOnly)) { if (cacheControlHeader || staleAt !== undefined) { deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) } return downstreamOnHeaders() } const baseTime = now - currentAge const absoluteStaleAt = staleAt + baseTime if (now >= absoluteStaleAt && !revalidationOnly) { // Response is already stale deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) return downstreamOnHeaders() } let varyDirectives if (this.#cacheKey.headers && resHeaders.vary) { varyDirectives = parseVaryHeader(resHeaders.vary, this.#cacheKey.headers) if (!varyDirectives) { // Parse error return downstreamOnHeaders() } } const cachedAt = baseTime const deleteAt = determineDeleteAt(baseTime, now, 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, staleAt: absoluteStaleAt, deleteAt } // Not modified, re-use the cached value // https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-304-not-modified if (statusCode === 304) { const handle304 = (cachedValue) => { if (!cachedValue) { // Do not create a new cache entry, as a 304 won't have a body - so cannot be cached. return downstreamOnHeaders() } // Re-use the cached value: statuscode, statusmessage, headers and body value.statusCode = cachedValue.statusCode value.statusMessage = cachedValue.statusMessage value.etag = cachedValue.etag value.vary = varyDirectives ?? cachedValue.vary value.headers = { ...cachedValue.headers, ...strippedHeaders } downstreamOnHeaders() this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value) if (!this.#writeStream || !cachedValue?.body) { return } if (typeof cachedValue.body.values === 'function') { const bodyIterator = cachedValue.body.values() const streamCachedBody = () => { for (const chunk of bodyIterator) { const full = this.#writeStream.write(chunk) === false this.#handler.onResponseData?.(controller, chunk) // when stream is full stop writing until we get a 'drain' event if (full) { break } } } this.#writeStream .on('error', function () { handler.#writeStream = undefined handler.#store.delete(handler.#cacheKey) }) .on('drain', () => { streamCachedBody() }) .on('close', function () { if (handler.#writeStream === this) { handler.#writeStream = undefined } }) streamCachedBody() } else if (typeof cachedValue.body.on === 'function') { // Readable stream body (e.g. from async/remote cache stores) cachedValue.body .on('data', (chunk) => { this.#writeStream.write(chunk) this.#handler.onResponseData?.(controller, chunk) }) .on('end', () => { this.#writeStream.end() }) .on('error', () => { this.#writeStream = undefined this.#store.delete(this.#cacheKey) }) this.#writeStream .on('error', function () { handler.#writeStream = undefined handler.#store.delete(handler.#cacheKey) }) .on('close', function () { if (handler.#writeStream === this) { handler.#writeStream = undefined } }) } } /** * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} */ const result = this.#store.get(this.#cacheKey) if (result && typeof result.then === 'function') { result.then(handle304) } else { handle304(result) } } else { 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() } 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() }) 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) } } /** * @param {import('../../types/cache-interceptor.d.ts').default.CacheStore} store * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey */ function deleteCachedValue (store, cacheKey) { try { store.delete(cacheKey)?.catch?.(noop) } catch { // Fail silently } } function deleteCachedValueIfNotModified (statusCode, store, cacheKey) { if (statusCode === 304) { deleteCachedValue(store, cacheKey) } } /** * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives * @returns {boolean} */ function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheControlDirectives) { return cacheControlDirectives['no-store'] === true || (cacheType === 'shared' && cacheControlDirectives.private === true) || (resHeaders.vary ? isInvalidOrWildcardVaryHeader(resHeaders.vary) : false) } /** * @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 * @param {import('../../types/header.d.ts').IncomingHttpHeaders} [reqHeaders] */ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives, reqHeaders) { // Status code must be final and understood. if (statusCode < 200 || arrayIncludes(NOT_UNDERSTOOD_STATUS_CODES, statusCode)) { return false } // Responses with neither status codes that are heuristically cacheable, nor "explicit enough" caching // directives, are not cacheable. "Explicit enough": see https://www.rfc-editor.org/rfc/rfc9111.html#section-3 if (!arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) && !resHeaders['expires'] && !cacheControlDirectives.public && cacheControlDirectives['max-age'] === undefined && // RFC 9111: a private response directive, if the cache is not shared !(cacheControlDirectives.private && cacheType === 'private') && !(cacheControlDirectives['s-maxage'] !== undefined && cacheType === 'shared') ) { 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 && hasVaryStar(resHeaders.vary)) { return false } // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen if (reqHeaders != null && Object.hasOwn(reqHeaders, 'authorization')) { if ( !cacheControlDirectives.public && !cacheControlDirectives['s-maxage'] && !cacheControlDirectives['must-revalidate'] ) { return false } if (typeof reqHeaders.authorization !== 'string') { return false } if ( Array.isArray(cacheControlDirectives['no-cache']) && arrayIncludes(cacheControlDirectives['no-cache'], 'authorization') ) { return false } if ( Array.isArray(cacheControlDirectives['private']) && arrayIncludes(cacheControlDirectives['private'], 'authorization') ) { return false } } return true } /** * @param {string | string[]} dateHeader * @returns {Date | null | undefined} */ function getDate (dateHeader) { let dateValue = dateHeader if (Array.isArray(dateValue)) { if (dateValue.length !== 1) { return null } dateValue = dateValue[0] } if (typeof dateValue !== 'string') { return null } return parseHttpDate(dateValue) } /** * @param {string | string[]} ageHeader * @returns {number | undefined} */ function getAge (ageHeader) { let ageValue = ageHeader if (Array.isArray(ageValue)) { if (ageValue.length !== 1) { return MAX_RESPONSE_AGE } ageValue = ageValue[0] } if (typeof ageValue !== 'string' || !/^[\t ]*[0-9]+[\t ]*$/.test(ageValue)) { return MAX_RESPONSE_AGE } const age = BigInt(ageValue.replace(/^[\t ]+|[\t ]+$/g, '')) if (age >= BigInt(MAX_RESPONSE_AGE / 1000)) { return MAX_RESPONSE_AGE } return Number(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 * @param {boolean} hasValidator whether the response has a validator (etag or * last-modified) that revalidation requests can be made with * * @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, hasValidator) { 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 if (hasInvalidCacheControlDirective(cacheControlDirectives, 's-maxage')) { return 0 } const sMaxAge = cacheControlDirectives['s-maxage'] if (sMaxAge !== undefined) { if (sMaxAge > 0) { return sMaxAge * 1000 } // Immediately stale, but storable if we can revalidate it before reuse. return 0 } } if (hasInvalidCacheControlDirective(cacheControlDirectives, 'max-age')) { return 0 } const maxAge = cacheControlDirectives['max-age'] if (maxAge !== undefined) { if (maxAge > 0) { return maxAge * 1000 } // Immediately stale, but storable if we can revalidate it before reuse. return 0 } if (Object.hasOwn(resHeaders, 'expires')) { // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 if (typeof resHeaders.expires !== 'string') { return 0 } const expiresDate = parseHttpDate(resHeaders.expires) if (!expiresDate) { return 0 } if (now >= expiresDate.getTime()) { return 0 } if (responseDate) { if (responseDate >= expiresDate) { return 0 } const freshnessLifetime = expiresDate.getTime() - responseDate.getTime() if (age !== undefined && age >= freshnessLifetime) { return 0 } return freshnessLifetime } return expiresDate.getTime() - now } if (typeof resHeaders['last-modified'] === 'string') { // https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-heuristic-fresh const lastModified = parseHttpDate(resHeaders['last-modified']) if (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 31536000000 } if (cacheControlDirectives['no-cache'] === true && hasValidator) { // No freshness source, but a validator lets us revalidate before reuse. // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4 return 0 } return undefined } /** * @param {number} baseTime * @param {number} cachedAt * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives * @param {number} staleAt */ function determineDeleteAt (baseTime, cachedAt, 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 (cacheControlDirectives.immutable && staleWhileRevalidate === -Infinity && staleIfError === -Infinity) { immutable = cachedAt + 31536000000 } // When no stale directives or immutable flag, add a revalidation buffer // equal to the freshness lifetime so the entry survives past staleAt long // enough to be revalidated instead of silently disappearing. // // Response Date headers only have second precision, so baseTime can trail the // actual cache insertion time by up to ~1s. Pad the buffer by that bounded // skew so short-lived entries do not disappear exactly when they should be // revalidated. if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity && immutable === -Infinity) { const freshnessLifetime = staleAt - baseTime if (freshnessLifetime <= 0) { // Revalidation-only entry: no freshness lifetime to size the buffer on, // so retain it for a bounded window instead. return cachedAt + REVALIDATION_ONLY_RETENTION } const datePrecisionPadding = Math.min(Math.max(cachedAt - baseTime, 0), 1000) return staleAt + freshnessLifetime + datePrecisionPadding } 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']) { appendConnectionHeaderTokens(headersToRemove, resHeaders['connection']) } 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 (Object.hasOwn(resHeaders, headerName)) { strippedHeaders ??= { ...resHeaders } delete strippedHeaders[headerName] } } return strippedHeaders ?? resHeaders } module.exports = CacheHandler