cache.js 19 KB

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