cache.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. 'use strict'
  2. const {
  3. safeHTTPMethods,
  4. pathHasQueryOrFragment
  5. } = require('../core/util')
  6. const { serializePathWithQuery } = require('../core/util')
  7. /**
  8. * @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts
  9. */
  10. function makeCacheKey (opts) {
  11. if (!opts.origin) {
  12. throw new Error('opts.origin is undefined')
  13. }
  14. let fullPath = opts.path || '/'
  15. if (opts.query && !pathHasQueryOrFragment(opts.path)) {
  16. fullPath = serializePathWithQuery(fullPath, opts.query)
  17. }
  18. return {
  19. origin: opts.origin.toString(),
  20. method: opts.method,
  21. path: fullPath,
  22. headers: opts.headers
  23. }
  24. }
  25. /**
  26. * @param {Record<string, string[] | string>}
  27. * @returns {Record<string, string[] | string>}
  28. */
  29. function normalizeHeaders (opts) {
  30. let headers
  31. if (opts.headers == null) {
  32. headers = {}
  33. } else if (typeof opts.headers[Symbol.iterator] === 'function') {
  34. headers = {}
  35. for (const x of opts.headers) {
  36. if (!Array.isArray(x)) {
  37. throw new Error('opts.headers is not a valid header map')
  38. }
  39. const [key, val] = x
  40. if (typeof key !== 'string' || typeof val !== 'string') {
  41. throw new Error('opts.headers is not a valid header map')
  42. }
  43. headers[key.toLowerCase()] = val
  44. }
  45. } else if (typeof opts.headers === 'object') {
  46. headers = {}
  47. for (const key of Object.keys(opts.headers)) {
  48. headers[key.toLowerCase()] = opts.headers[key]
  49. }
  50. } else {
  51. throw new Error('opts.headers is not an object')
  52. }
  53. return headers
  54. }
  55. /**
  56. * @param {any} key
  57. */
  58. function assertCacheKey (key) {
  59. if (typeof key !== 'object') {
  60. throw new TypeError(`expected key to be object, got ${typeof key}`)
  61. }
  62. for (const property of ['origin', 'method', 'path']) {
  63. if (typeof key[property] !== 'string') {
  64. throw new TypeError(`expected key.${property} to be string, got ${typeof key[property]}`)
  65. }
  66. }
  67. if (key.headers !== undefined && typeof key.headers !== 'object') {
  68. throw new TypeError(`expected headers to be object, got ${typeof key}`)
  69. }
  70. }
  71. /**
  72. * @param {any} value
  73. */
  74. function assertCacheValue (value) {
  75. if (typeof value !== 'object') {
  76. throw new TypeError(`expected value to be object, got ${typeof value}`)
  77. }
  78. for (const property of ['statusCode', 'cachedAt', 'staleAt', 'deleteAt']) {
  79. if (typeof value[property] !== 'number') {
  80. throw new TypeError(`expected value.${property} to be number, got ${typeof value[property]}`)
  81. }
  82. }
  83. if (typeof value.statusMessage !== 'string') {
  84. throw new TypeError(`expected value.statusMessage to be string, got ${typeof value.statusMessage}`)
  85. }
  86. if (value.headers != null && typeof value.headers !== 'object') {
  87. throw new TypeError(`expected value.rawHeaders to be object, got ${typeof value.headers}`)
  88. }
  89. if (value.vary !== undefined && typeof value.vary !== 'object') {
  90. throw new TypeError(`expected value.vary to be object, got ${typeof value.vary}`)
  91. }
  92. if (value.etag !== undefined && typeof value.etag !== 'string') {
  93. throw new TypeError(`expected value.etag to be string, got ${typeof value.etag}`)
  94. }
  95. }
  96. /**
  97. * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-cache-control
  98. * @see https://www.iana.org/assignments/http-cache-directives/http-cache-directives.xhtml
  99. * @param {string | string[]} header
  100. * @returns {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives}
  101. */
  102. function parseCacheControlHeader (header) {
  103. /**
  104. * @type {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives}
  105. */
  106. const output = {}
  107. let directives
  108. if (Array.isArray(header)) {
  109. directives = []
  110. for (const directive of header) {
  111. directives.push(...directive.split(','))
  112. }
  113. } else {
  114. directives = header.split(',')
  115. }
  116. for (let i = 0; i < directives.length; i++) {
  117. const directive = directives[i].toLowerCase()
  118. const keyValueDelimiter = directive.indexOf('=')
  119. let key
  120. let value
  121. if (keyValueDelimiter !== -1) {
  122. key = directive.substring(0, keyValueDelimiter).trimStart()
  123. value = directive.substring(keyValueDelimiter + 1)
  124. } else {
  125. key = directive.trim()
  126. }
  127. switch (key) {
  128. case 'min-fresh':
  129. case 'max-stale':
  130. case 'max-age':
  131. case 's-maxage':
  132. case 'stale-while-revalidate':
  133. case 'stale-if-error': {
  134. if (value === undefined || value[0] === ' ') {
  135. continue
  136. }
  137. if (
  138. value.length >= 2 &&
  139. value[0] === '"' &&
  140. value[value.length - 1] === '"'
  141. ) {
  142. value = value.substring(1, value.length - 1)
  143. }
  144. const parsedValue = parseInt(value, 10)
  145. // eslint-disable-next-line no-self-compare
  146. if (parsedValue !== parsedValue) {
  147. continue
  148. }
  149. if (key === 'max-age' && key in output && output[key] >= parsedValue) {
  150. continue
  151. }
  152. output[key] = parsedValue
  153. break
  154. }
  155. case 'private':
  156. case 'no-cache': {
  157. if (value) {
  158. // The private and no-cache directives can be unqualified (aka just
  159. // `private` or `no-cache`) or qualified (w/ a value). When they're
  160. // qualified, it's a list of headers like `no-cache=header1`,
  161. // `no-cache="header1"`, or `no-cache="header1, header2"`
  162. // If we're given multiple headers, the comma messes us up since
  163. // we split the full header by commas. So, let's loop through the
  164. // remaining parts in front of us until we find one that ends in a
  165. // quote. We can then just splice all of the parts in between the
  166. // starting quote and the ending quote out of the directives array
  167. // and continue parsing like normal.
  168. // https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2
  169. if (value[0] === '"') {
  170. // Something like `no-cache="some-header"` OR `no-cache="some-header, another-header"`.
  171. // Add the first header on and cut off the leading quote
  172. const headers = [value.substring(1)]
  173. let foundEndingQuote = value[value.length - 1] === '"'
  174. if (!foundEndingQuote) {
  175. // Something like `no-cache="some-header, another-header"`
  176. // This can still be something invalid, e.g. `no-cache="some-header, ...`
  177. for (let j = i + 1; j < directives.length; j++) {
  178. const nextPart = directives[j]
  179. const nextPartLength = nextPart.length
  180. headers.push(nextPart.trim())
  181. if (nextPartLength !== 0 && nextPart[nextPartLength - 1] === '"') {
  182. foundEndingQuote = true
  183. break
  184. }
  185. }
  186. }
  187. if (foundEndingQuote) {
  188. let lastHeader = headers[headers.length - 1]
  189. if (lastHeader[lastHeader.length - 1] === '"') {
  190. lastHeader = lastHeader.substring(0, lastHeader.length - 1)
  191. headers[headers.length - 1] = lastHeader
  192. }
  193. if (key in output) {
  194. output[key] = output[key].concat(headers)
  195. } else {
  196. output[key] = headers
  197. }
  198. }
  199. } else {
  200. // Something like `no-cache="some-header"`
  201. if (key in output) {
  202. output[key] = output[key].concat(value)
  203. } else {
  204. output[key] = [value]
  205. }
  206. }
  207. break
  208. }
  209. }
  210. // eslint-disable-next-line no-fallthrough
  211. case 'public':
  212. case 'no-store':
  213. case 'must-revalidate':
  214. case 'proxy-revalidate':
  215. case 'immutable':
  216. case 'no-transform':
  217. case 'must-understand':
  218. case 'only-if-cached':
  219. if (value) {
  220. // These are qualified (something like `public=...`) when they aren't
  221. // allowed to be, skip
  222. continue
  223. }
  224. output[key] = true
  225. break
  226. default:
  227. // Ignore unknown directives as per https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.3-1
  228. continue
  229. }
  230. }
  231. return output
  232. }
  233. /**
  234. * @param {string | string[]} varyHeader Vary header from the server
  235. * @param {Record<string, string | string[]>} headers Request headers
  236. * @returns {Record<string, string | string[]>}
  237. */
  238. function parseVaryHeader (varyHeader, headers) {
  239. if (typeof varyHeader === 'string' && varyHeader.includes('*')) {
  240. return headers
  241. }
  242. const output = /** @type {Record<string, string | string[] | null>} */ ({})
  243. const varyingHeaders = typeof varyHeader === 'string'
  244. ? varyHeader.split(',')
  245. : varyHeader
  246. for (const header of varyingHeaders) {
  247. const trimmedHeader = header.trim().toLowerCase()
  248. output[trimmedHeader] = headers[trimmedHeader] ?? null
  249. }
  250. return output
  251. }
  252. /**
  253. * Note: this deviates from the spec a little. Empty etags ("", W/"") are valid,
  254. * however, including them in cached resposnes serves little to no purpose.
  255. *
  256. * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-etag
  257. *
  258. * @param {string} etag
  259. * @returns {boolean}
  260. */
  261. function isEtagUsable (etag) {
  262. if (etag.length <= 2) {
  263. // Shortest an etag can be is two chars (just ""). This is where we deviate
  264. // from the spec requiring a min of 3 chars however
  265. return false
  266. }
  267. if (etag[0] === '"' && etag[etag.length - 1] === '"') {
  268. // ETag: ""asd123"" or ETag: "W/"asd123"", kinda undefined behavior in the
  269. // spec. Some servers will accept these while others don't.
  270. // ETag: "asd123"
  271. return !(etag[1] === '"' || etag.startsWith('"W/'))
  272. }
  273. if (etag.startsWith('W/"') && etag[etag.length - 1] === '"') {
  274. // ETag: W/"", also where we deviate from the spec & require a min of 3
  275. // chars
  276. // ETag: for W/"", W/"asd123"
  277. return etag.length !== 4
  278. }
  279. // Anything else
  280. return false
  281. }
  282. /**
  283. * @param {unknown} store
  284. * @returns {asserts store is import('../../types/cache-interceptor.d.ts').default.CacheStore}
  285. */
  286. function assertCacheStore (store, name = 'CacheStore') {
  287. if (typeof store !== 'object' || store === null) {
  288. throw new TypeError(`expected type of ${name} to be a CacheStore, got ${store === null ? 'null' : typeof store}`)
  289. }
  290. for (const fn of ['get', 'createWriteStream', 'delete']) {
  291. if (typeof store[fn] !== 'function') {
  292. throw new TypeError(`${name} needs to have a \`${fn}()\` function`)
  293. }
  294. }
  295. }
  296. /**
  297. * @param {unknown} methods
  298. * @returns {asserts methods is import('../../types/cache-interceptor.d.ts').default.CacheMethods[]}
  299. */
  300. function assertCacheMethods (methods, name = 'CacheMethods') {
  301. if (!Array.isArray(methods)) {
  302. throw new TypeError(`expected type of ${name} needs to be an array, got ${methods === null ? 'null' : typeof methods}`)
  303. }
  304. if (methods.length === 0) {
  305. throw new TypeError(`${name} needs to have at least one method`)
  306. }
  307. for (const method of methods) {
  308. if (!safeHTTPMethods.includes(method)) {
  309. throw new TypeError(`element of ${name}-array needs to be one of following values: ${safeHTTPMethods.join(', ')}, got ${method}`)
  310. }
  311. }
  312. }
  313. /**
  314. * Creates a string key for request deduplication purposes.
  315. * This key is used to identify in-flight requests that can be shared.
  316. * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
  317. * @param {Set<string>} [excludeHeaders] Set of lowercase header names to exclude from the key
  318. * @returns {string}
  319. */
  320. function makeDeduplicationKey (cacheKey, excludeHeaders) {
  321. // Create a deterministic string key from the cache key
  322. // Include origin, method, path, and sorted headers
  323. let key = `${cacheKey.origin}:${cacheKey.method}:${cacheKey.path}`
  324. if (cacheKey.headers) {
  325. const sortedHeaders = Object.keys(cacheKey.headers).sort()
  326. for (const header of sortedHeaders) {
  327. // Skip excluded headers
  328. if (excludeHeaders?.has(header.toLowerCase())) {
  329. continue
  330. }
  331. const value = cacheKey.headers[header]
  332. key += `:${header}=${Array.isArray(value) ? value.join(',') : value}`
  333. }
  334. }
  335. return key
  336. }
  337. module.exports = {
  338. makeCacheKey,
  339. normalizeHeaders,
  340. assertCacheKey,
  341. assertCacheValue,
  342. parseCacheControlHeader,
  343. parseVaryHeader,
  344. isEtagUsable,
  345. assertCacheMethods,
  346. assertCacheStore,
  347. makeDeduplicationKey
  348. }