123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- '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) {
- // Allow caching for status codes 200 and 307 (original behavior)
- // Also allow caching for other status codes that are heuristically cacheable
- // when they have explicit cache directives
- if (statusCode !== 200 && statusCode !== 307 && !HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode)) {
- 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<string, string | string []>}
- */
- 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
|