cache.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. 'use strict'
  2. const assert = require('node:assert')
  3. const { Readable } = require('node:stream')
  4. const util = require('../core/util')
  5. const CacheHandler = require('../handler/cache-handler')
  6. const MemoryCacheStore = require('../cache/memory-cache-store')
  7. const CacheRevalidationHandler = require('../handler/cache-revalidation-handler')
  8. const { assertCacheStore, assertCacheMethods, makeCacheKey, normalizeHeaders, parseCacheControlHeader } = require('../util/cache.js')
  9. const { AbortError } = require('../core/errors.js')
  10. /**
  11. * @param {(string | RegExp)[] | undefined} origins
  12. * @param {string} name
  13. */
  14. function assertCacheOrigins (origins, name) {
  15. if (origins === undefined) return
  16. if (!Array.isArray(origins)) {
  17. throw new TypeError(`expected ${name} to be an array or undefined, got ${typeof origins}`)
  18. }
  19. for (let i = 0; i < origins.length; i++) {
  20. const origin = origins[i]
  21. if (typeof origin !== 'string' && !(origin instanceof RegExp)) {
  22. throw new TypeError(`expected ${name}[${i}] to be a string or RegExp, got ${typeof origin}`)
  23. }
  24. }
  25. }
  26. const nop = () => {}
  27. /**
  28. * @typedef {(options: import('../../types/dispatcher.d.ts').default.DispatchOptions, handler: import('../../types/dispatcher.d.ts').default.DispatchHandler) => void} DispatchFn
  29. */
  30. /**
  31. * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
  32. * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives
  33. * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
  34. * @returns {boolean}
  35. */
  36. function needsRevalidation (result, cacheControlDirectives, { headers = {} }) {
  37. // Always revalidate requests with the no-cache request directive.
  38. if (cacheControlDirectives?.['no-cache']) {
  39. return true
  40. }
  41. // Always revalidate requests with unqualified no-cache response directive.
  42. if (result.cacheControlDirectives?.['no-cache'] && !Array.isArray(result.cacheControlDirectives['no-cache'])) {
  43. return true
  44. }
  45. // Always revalidate requests with conditional headers.
  46. if (headers['if-modified-since'] || headers['if-none-match']) {
  47. return true
  48. }
  49. return false
  50. }
  51. /**
  52. * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
  53. * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives
  54. * @returns {boolean}
  55. */
  56. function isStale (result, cacheControlDirectives) {
  57. const now = Date.now()
  58. if (now > result.staleAt) {
  59. // Response is stale
  60. if (cacheControlDirectives?.['max-stale']) {
  61. // There's a threshold where we can serve stale responses, let's see if
  62. // we're in it
  63. // https://www.rfc-editor.org/rfc/rfc9111.html#name-max-stale
  64. const gracePeriod = result.staleAt + (cacheControlDirectives['max-stale'] * 1000)
  65. return now > gracePeriod
  66. }
  67. return true
  68. }
  69. if (cacheControlDirectives?.['min-fresh']) {
  70. // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.3
  71. // At this point, staleAt is always > now
  72. const timeLeftTillStale = result.staleAt - now
  73. const threshold = cacheControlDirectives['min-fresh'] * 1000
  74. return timeLeftTillStale <= threshold
  75. }
  76. return false
  77. }
  78. /**
  79. * Check if we're within the stale-while-revalidate window for a stale response
  80. * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
  81. * @returns {boolean}
  82. */
  83. function withinStaleWhileRevalidateWindow (result) {
  84. const staleWhileRevalidate = result.cacheControlDirectives?.['stale-while-revalidate']
  85. if (!staleWhileRevalidate) {
  86. return false
  87. }
  88. const now = Date.now()
  89. const staleWhileRevalidateExpiry = result.staleAt + (staleWhileRevalidate * 1000)
  90. return now <= staleWhileRevalidateExpiry
  91. }
  92. /**
  93. * @param {DispatchFn} dispatch
  94. * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} globalOpts
  95. * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
  96. * @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler
  97. * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
  98. * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} reqCacheControl
  99. */
  100. function handleUncachedResponse (
  101. dispatch,
  102. globalOpts,
  103. cacheKey,
  104. handler,
  105. opts,
  106. reqCacheControl
  107. ) {
  108. if (reqCacheControl?.['only-if-cached']) {
  109. let aborted = false
  110. try {
  111. if (typeof handler.onConnect === 'function') {
  112. handler.onConnect(() => {
  113. aborted = true
  114. })
  115. if (aborted) {
  116. return
  117. }
  118. }
  119. if (typeof handler.onHeaders === 'function') {
  120. handler.onHeaders(504, [], nop, 'Gateway Timeout')
  121. if (aborted) {
  122. return
  123. }
  124. }
  125. if (typeof handler.onComplete === 'function') {
  126. handler.onComplete([])
  127. }
  128. } catch (err) {
  129. if (typeof handler.onError === 'function') {
  130. handler.onError(err)
  131. }
  132. }
  133. return true
  134. }
  135. return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
  136. }
  137. /**
  138. * @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler
  139. * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
  140. * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
  141. * @param {number} age
  142. * @param {any} context
  143. * @param {boolean} isStale
  144. */
  145. function sendCachedValue (handler, opts, result, age, context, isStale) {
  146. // TODO (perf): Readable.from path can be optimized...
  147. const stream = util.isStream(result.body)
  148. ? result.body
  149. : Readable.from(result.body ?? [])
  150. assert(!stream.destroyed, 'stream should not be destroyed')
  151. assert(!stream.readableDidRead, 'stream should not be readableDidRead')
  152. const controller = {
  153. resume () {
  154. stream.resume()
  155. },
  156. pause () {
  157. stream.pause()
  158. },
  159. get paused () {
  160. return stream.isPaused()
  161. },
  162. get aborted () {
  163. return stream.destroyed
  164. },
  165. get reason () {
  166. return stream.errored
  167. },
  168. abort (reason) {
  169. stream.destroy(reason ?? new AbortError())
  170. }
  171. }
  172. stream
  173. .on('error', function (err) {
  174. if (!this.readableEnded) {
  175. if (typeof handler.onResponseError === 'function') {
  176. handler.onResponseError(controller, err)
  177. } else {
  178. throw err
  179. }
  180. }
  181. })
  182. .on('close', function () {
  183. if (!this.errored) {
  184. handler.onResponseEnd?.(controller, {})
  185. }
  186. })
  187. handler.onRequestStart?.(controller, context)
  188. if (stream.destroyed) {
  189. return
  190. }
  191. // Add the age header
  192. // https://www.rfc-editor.org/rfc/rfc9111.html#name-age
  193. const headers = { ...result.headers, age: String(age) }
  194. if (isStale) {
  195. // Add warning header
  196. // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Warning
  197. headers.warning = '110 - "response is stale"'
  198. }
  199. handler.onResponseStart?.(controller, result.statusCode, headers, result.statusMessage)
  200. if (opts.method === 'HEAD') {
  201. stream.destroy()
  202. } else {
  203. stream.on('data', function (chunk) {
  204. handler.onResponseData?.(controller, chunk)
  205. })
  206. }
  207. }
  208. /**
  209. * @param {DispatchFn} dispatch
  210. * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} globalOpts
  211. * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
  212. * @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler
  213. * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
  214. * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} reqCacheControl
  215. * @param {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined} result
  216. */
  217. function handleResult (
  218. dispatch,
  219. globalOpts,
  220. cacheKey,
  221. handler,
  222. opts,
  223. reqCacheControl,
  224. result
  225. ) {
  226. if (!result) {
  227. return handleUncachedResponse(dispatch, globalOpts, cacheKey, handler, opts, reqCacheControl)
  228. }
  229. const now = Date.now()
  230. if (now > result.deleteAt) {
  231. // Response is expired, cache store shouldn't have given this to us
  232. return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
  233. }
  234. const age = Math.round((now - result.cachedAt) / 1000)
  235. if (reqCacheControl?.['max-age'] && age >= reqCacheControl['max-age']) {
  236. // Response is considered expired for this specific request
  237. // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1
  238. return dispatch(opts, handler)
  239. }
  240. const stale = isStale(result, reqCacheControl)
  241. const revalidate = needsRevalidation(result, reqCacheControl, opts)
  242. // Check if the response is stale
  243. if (stale || revalidate) {
  244. if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) {
  245. // If body is a stream we can't revalidate...
  246. // TODO (fix): This could be less strict...
  247. return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
  248. }
  249. // RFC 5861: If we're within stale-while-revalidate window, serve stale immediately
  250. // and revalidate in background, unless immediate revalidation is necessary
  251. if (!revalidate && withinStaleWhileRevalidateWindow(result)) {
  252. // Serve stale response immediately
  253. sendCachedValue(handler, opts, result, age, null, true)
  254. // Start background revalidation (fire-and-forget)
  255. queueMicrotask(() => {
  256. const headers = {
  257. ...opts.headers,
  258. 'if-modified-since': new Date(result.cachedAt).toUTCString()
  259. }
  260. if (result.etag) {
  261. headers['if-none-match'] = result.etag
  262. }
  263. if (result.vary) {
  264. for (const key in result.vary) {
  265. if (result.vary[key] != null) {
  266. headers[key] = result.vary[key]
  267. }
  268. }
  269. }
  270. // Background revalidation - update cache if we get new data
  271. dispatch(
  272. {
  273. ...opts,
  274. headers
  275. },
  276. new CacheHandler(globalOpts, cacheKey, {
  277. // Silent handler that just updates the cache
  278. onRequestStart () {},
  279. onRequestUpgrade () {},
  280. onResponseStart () {},
  281. onResponseData () {},
  282. onResponseEnd () {},
  283. onResponseError () {}
  284. })
  285. )
  286. })
  287. return true
  288. }
  289. let withinStaleIfErrorThreshold = false
  290. const staleIfErrorExpiry = result.cacheControlDirectives['stale-if-error'] ?? reqCacheControl?.['stale-if-error']
  291. if (staleIfErrorExpiry) {
  292. withinStaleIfErrorThreshold = now < (result.staleAt + (staleIfErrorExpiry * 1000))
  293. }
  294. const headers = {
  295. ...opts.headers,
  296. 'if-modified-since': new Date(result.cachedAt).toUTCString()
  297. }
  298. if (result.etag) {
  299. headers['if-none-match'] = result.etag
  300. }
  301. if (result.vary) {
  302. for (const key in result.vary) {
  303. if (result.vary[key] != null) {
  304. headers[key] = result.vary[key]
  305. }
  306. }
  307. }
  308. // We need to revalidate the response
  309. return dispatch(
  310. {
  311. ...opts,
  312. headers
  313. },
  314. new CacheRevalidationHandler(
  315. (success, context) => {
  316. if (success) {
  317. // TODO: successful revalidation should be considered fresh (not give stale warning).
  318. sendCachedValue(handler, opts, result, age, context, stale)
  319. } else if (util.isStream(result.body)) {
  320. result.body.on('error', nop).destroy()
  321. }
  322. },
  323. new CacheHandler(globalOpts, cacheKey, handler),
  324. withinStaleIfErrorThreshold
  325. )
  326. )
  327. }
  328. // Dump request body.
  329. if (util.isStream(opts.body)) {
  330. opts.body.on('error', nop).destroy()
  331. }
  332. sendCachedValue(handler, opts, result, age, null, false)
  333. }
  334. /**
  335. * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} [opts]
  336. * @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor}
  337. */
  338. module.exports = (opts = {}) => {
  339. const {
  340. store = new MemoryCacheStore(),
  341. methods = ['GET'],
  342. cacheByDefault = undefined,
  343. type = 'shared',
  344. origins = undefined
  345. } = opts
  346. if (typeof opts !== 'object' || opts === null) {
  347. throw new TypeError(`expected type of opts to be an Object, got ${opts === null ? 'null' : typeof opts}`)
  348. }
  349. assertCacheStore(store, 'opts.store')
  350. assertCacheMethods(methods, 'opts.methods')
  351. assertCacheOrigins(origins, 'opts.origins')
  352. if (typeof cacheByDefault !== 'undefined' && typeof cacheByDefault !== 'number') {
  353. throw new TypeError(`expected opts.cacheByDefault to be number or undefined, got ${typeof cacheByDefault}`)
  354. }
  355. if (typeof type !== 'undefined' && type !== 'shared' && type !== 'private') {
  356. throw new TypeError(`expected opts.type to be shared, private, or undefined, got ${typeof type}`)
  357. }
  358. const globalOpts = {
  359. store,
  360. methods,
  361. cacheByDefault,
  362. type
  363. }
  364. const safeMethodsToNotCache = util.safeHTTPMethods.filter(method => methods.includes(method) === false)
  365. return dispatch => {
  366. return (opts, handler) => {
  367. if (!opts.origin || safeMethodsToNotCache.includes(opts.method)) {
  368. // Not a method we want to cache or we don't have the origin, skip
  369. return dispatch(opts, handler)
  370. }
  371. // Check if origin is in whitelist
  372. if (origins !== undefined) {
  373. const requestOrigin = opts.origin.toString().toLowerCase()
  374. let isAllowed = false
  375. for (let i = 0; i < origins.length; i++) {
  376. const allowed = origins[i]
  377. if (typeof allowed === 'string') {
  378. if (allowed.toLowerCase() === requestOrigin) {
  379. isAllowed = true
  380. break
  381. }
  382. } else if (allowed.test(requestOrigin)) {
  383. isAllowed = true
  384. break
  385. }
  386. }
  387. if (!isAllowed) {
  388. return dispatch(opts, handler)
  389. }
  390. }
  391. opts = {
  392. ...opts,
  393. headers: normalizeHeaders(opts)
  394. }
  395. const reqCacheControl = opts.headers?.['cache-control']
  396. ? parseCacheControlHeader(opts.headers['cache-control'])
  397. : undefined
  398. if (reqCacheControl?.['no-store']) {
  399. return dispatch(opts, handler)
  400. }
  401. /**
  402. * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
  403. */
  404. const cacheKey = makeCacheKey(opts)
  405. const result = store.get(cacheKey)
  406. if (result && typeof result.then === 'function') {
  407. return result
  408. .then(result => handleResult(dispatch,
  409. globalOpts,
  410. cacheKey,
  411. handler,
  412. opts,
  413. reqCacheControl,
  414. result
  415. ))
  416. } else {
  417. return handleResult(
  418. dispatch,
  419. globalOpts,
  420. cacheKey,
  421. handler,
  422. opts,
  423. reqCacheControl,
  424. result
  425. )
  426. }
  427. }
  428. }
  429. }