cache.js 11 KB

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