sqlite-cache-store.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. 'use strict'
  2. const { Writable } = require('node:stream')
  3. const { assertCacheKey, assertCacheValue } = require('../util/cache.js')
  4. let DatabaseSync
  5. const VERSION = 3
  6. // 2gb
  7. const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
  8. /**
  9. * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
  10. * @implements {CacheStore}
  11. *
  12. * @typedef {{
  13. * id: Readonly<number>,
  14. * body?: Uint8Array
  15. * statusCode: number
  16. * statusMessage: string
  17. * headers?: string
  18. * vary?: string
  19. * etag?: string
  20. * cacheControlDirectives?: string
  21. * cachedAt: number
  22. * staleAt: number
  23. * deleteAt: number
  24. * }} SqliteStoreValue
  25. */
  26. module.exports = class SqliteCacheStore {
  27. #maxEntrySize = MAX_ENTRY_SIZE
  28. #maxCount = Infinity
  29. /**
  30. * @type {import('node:sqlite').DatabaseSync}
  31. */
  32. #db
  33. /**
  34. * @type {import('node:sqlite').StatementSync}
  35. */
  36. #getValuesQuery
  37. /**
  38. * @type {import('node:sqlite').StatementSync}
  39. */
  40. #updateValueQuery
  41. /**
  42. * @type {import('node:sqlite').StatementSync}
  43. */
  44. #insertValueQuery
  45. /**
  46. * @type {import('node:sqlite').StatementSync}
  47. */
  48. #deleteExpiredValuesQuery
  49. /**
  50. * @type {import('node:sqlite').StatementSync}
  51. */
  52. #deleteByUrlQuery
  53. /**
  54. * @type {import('node:sqlite').StatementSync}
  55. */
  56. #countEntriesQuery
  57. /**
  58. * @type {import('node:sqlite').StatementSync | null}
  59. */
  60. #deleteOldValuesQuery
  61. /**
  62. * @param {import('../../types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts | undefined} opts
  63. */
  64. constructor (opts) {
  65. if (opts) {
  66. if (typeof opts !== 'object') {
  67. throw new TypeError('SqliteCacheStore options must be an object')
  68. }
  69. if (opts.maxEntrySize !== undefined) {
  70. if (
  71. typeof opts.maxEntrySize !== 'number' ||
  72. !Number.isInteger(opts.maxEntrySize) ||
  73. opts.maxEntrySize < 0
  74. ) {
  75. throw new TypeError('SqliteCacheStore options.maxEntrySize must be a non-negative integer')
  76. }
  77. if (opts.maxEntrySize > MAX_ENTRY_SIZE) {
  78. throw new TypeError('SqliteCacheStore options.maxEntrySize must be less than 2gb')
  79. }
  80. this.#maxEntrySize = opts.maxEntrySize
  81. }
  82. if (opts.maxCount !== undefined) {
  83. if (
  84. typeof opts.maxCount !== 'number' ||
  85. !Number.isInteger(opts.maxCount) ||
  86. opts.maxCount < 0
  87. ) {
  88. throw new TypeError('SqliteCacheStore options.maxCount must be a non-negative integer')
  89. }
  90. this.#maxCount = opts.maxCount
  91. }
  92. }
  93. if (!DatabaseSync) {
  94. DatabaseSync = require('node:sqlite').DatabaseSync
  95. }
  96. this.#db = new DatabaseSync(opts?.location ?? ':memory:')
  97. this.#db.exec(`
  98. PRAGMA journal_mode = WAL;
  99. PRAGMA synchronous = NORMAL;
  100. PRAGMA temp_store = memory;
  101. PRAGMA optimize;
  102. CREATE TABLE IF NOT EXISTS cacheInterceptorV${VERSION} (
  103. -- Data specific to us
  104. id INTEGER PRIMARY KEY AUTOINCREMENT,
  105. url TEXT NOT NULL,
  106. method TEXT NOT NULL,
  107. -- Data returned to the interceptor
  108. body BUF NULL,
  109. deleteAt INTEGER NOT NULL,
  110. statusCode INTEGER NOT NULL,
  111. statusMessage TEXT NOT NULL,
  112. headers TEXT NULL,
  113. cacheControlDirectives TEXT NULL,
  114. etag TEXT NULL,
  115. vary TEXT NULL,
  116. cachedAt INTEGER NOT NULL,
  117. staleAt INTEGER NOT NULL
  118. );
  119. CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_getValuesQuery ON cacheInterceptorV${VERSION}(url, method, deleteAt);
  120. CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteByUrlQuery ON cacheInterceptorV${VERSION}(deleteAt);
  121. `)
  122. this.#getValuesQuery = this.#db.prepare(`
  123. SELECT
  124. id,
  125. body,
  126. deleteAt,
  127. statusCode,
  128. statusMessage,
  129. headers,
  130. etag,
  131. cacheControlDirectives,
  132. vary,
  133. cachedAt,
  134. staleAt
  135. FROM cacheInterceptorV${VERSION}
  136. WHERE
  137. url = ?
  138. AND method = ?
  139. ORDER BY
  140. deleteAt ASC
  141. `)
  142. this.#updateValueQuery = this.#db.prepare(`
  143. UPDATE cacheInterceptorV${VERSION} SET
  144. body = ?,
  145. deleteAt = ?,
  146. statusCode = ?,
  147. statusMessage = ?,
  148. headers = ?,
  149. etag = ?,
  150. cacheControlDirectives = ?,
  151. cachedAt = ?,
  152. staleAt = ?
  153. WHERE
  154. id = ?
  155. `)
  156. this.#insertValueQuery = this.#db.prepare(`
  157. INSERT INTO cacheInterceptorV${VERSION} (
  158. url,
  159. method,
  160. body,
  161. deleteAt,
  162. statusCode,
  163. statusMessage,
  164. headers,
  165. etag,
  166. cacheControlDirectives,
  167. vary,
  168. cachedAt,
  169. staleAt
  170. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  171. `)
  172. this.#deleteByUrlQuery = this.#db.prepare(
  173. `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ?`
  174. )
  175. this.#countEntriesQuery = this.#db.prepare(
  176. `SELECT COUNT(*) AS total FROM cacheInterceptorV${VERSION}`
  177. )
  178. this.#deleteExpiredValuesQuery = this.#db.prepare(
  179. `DELETE FROM cacheInterceptorV${VERSION} WHERE deleteAt <= ?`
  180. )
  181. this.#deleteOldValuesQuery = this.#maxCount === Infinity
  182. ? null
  183. : this.#db.prepare(`
  184. DELETE FROM cacheInterceptorV${VERSION}
  185. WHERE id IN (
  186. SELECT
  187. id
  188. FROM cacheInterceptorV${VERSION}
  189. ORDER BY cachedAt DESC
  190. LIMIT ?
  191. )
  192. `)
  193. }
  194. close () {
  195. this.#db.close()
  196. }
  197. /**
  198. * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
  199. * @returns {(import('../../types/cache-interceptor.d.ts').default.GetResult & { body?: Buffer }) | undefined}
  200. */
  201. get (key) {
  202. assertCacheKey(key)
  203. const value = this.#findValue(key)
  204. return value
  205. ? {
  206. body: value.body ? Buffer.from(value.body.buffer, value.body.byteOffset, value.body.byteLength) : undefined,
  207. statusCode: value.statusCode,
  208. statusMessage: value.statusMessage,
  209. headers: value.headers ? JSON.parse(value.headers) : undefined,
  210. etag: value.etag ? value.etag : undefined,
  211. vary: value.vary ? JSON.parse(value.vary) : undefined,
  212. cacheControlDirectives: value.cacheControlDirectives
  213. ? JSON.parse(value.cacheControlDirectives)
  214. : undefined,
  215. cachedAt: value.cachedAt,
  216. staleAt: value.staleAt,
  217. deleteAt: value.deleteAt
  218. }
  219. : undefined
  220. }
  221. /**
  222. * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
  223. * @param {import('../../types/cache-interceptor.d.ts').default.CacheValue & { body: null | Buffer | Array<Buffer>}} value
  224. */
  225. set (key, value) {
  226. assertCacheKey(key)
  227. const url = this.#makeValueUrl(key)
  228. const body = Array.isArray(value.body) ? Buffer.concat(value.body) : value.body
  229. const size = body?.byteLength
  230. if (size && size > this.#maxEntrySize) {
  231. return
  232. }
  233. const existingValue = this.#findValue(key, true)
  234. if (existingValue) {
  235. // Updating an existing response, let's overwrite it
  236. this.#updateValueQuery.run(
  237. body,
  238. value.deleteAt,
  239. value.statusCode,
  240. value.statusMessage,
  241. value.headers ? JSON.stringify(value.headers) : null,
  242. value.etag ? value.etag : null,
  243. value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
  244. value.cachedAt,
  245. value.staleAt,
  246. existingValue.id
  247. )
  248. } else {
  249. this.#prune()
  250. // New response, let's insert it
  251. this.#insertValueQuery.run(
  252. url,
  253. key.method,
  254. body,
  255. value.deleteAt,
  256. value.statusCode,
  257. value.statusMessage,
  258. value.headers ? JSON.stringify(value.headers) : null,
  259. value.etag ? value.etag : null,
  260. value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
  261. value.vary ? JSON.stringify(value.vary) : null,
  262. value.cachedAt,
  263. value.staleAt
  264. )
  265. }
  266. }
  267. /**
  268. * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
  269. * @param {import('../../types/cache-interceptor.d.ts').default.CacheValue} value
  270. * @returns {Writable | undefined}
  271. */
  272. createWriteStream (key, value) {
  273. assertCacheKey(key)
  274. assertCacheValue(value)
  275. let size = 0
  276. /**
  277. * @type {Buffer[] | null}
  278. */
  279. const body = []
  280. const store = this
  281. return new Writable({
  282. decodeStrings: true,
  283. write (chunk, encoding, callback) {
  284. size += chunk.byteLength
  285. if (size < store.#maxEntrySize) {
  286. body.push(chunk)
  287. } else {
  288. this.destroy()
  289. }
  290. callback()
  291. },
  292. final (callback) {
  293. store.set(key, { ...value, body })
  294. callback()
  295. }
  296. })
  297. }
  298. /**
  299. * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
  300. */
  301. delete (key) {
  302. if (typeof key !== 'object') {
  303. throw new TypeError(`expected key to be object, got ${typeof key}`)
  304. }
  305. this.#deleteByUrlQuery.run(this.#makeValueUrl(key))
  306. }
  307. #prune () {
  308. if (Number.isFinite(this.#maxCount) && this.size <= this.#maxCount) {
  309. return 0
  310. }
  311. {
  312. const removed = this.#deleteExpiredValuesQuery.run(Date.now()).changes
  313. if (removed) {
  314. return removed
  315. }
  316. }
  317. {
  318. const removed = this.#deleteOldValuesQuery?.run(Math.max(Math.floor(this.#maxCount * 0.1), 1)).changes
  319. if (removed) {
  320. return removed
  321. }
  322. }
  323. return 0
  324. }
  325. /**
  326. * Counts the number of rows in the cache
  327. * @returns {Number}
  328. */
  329. get size () {
  330. const { total } = this.#countEntriesQuery.get()
  331. return total
  332. }
  333. /**
  334. * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
  335. * @returns {string}
  336. */
  337. #makeValueUrl (key) {
  338. return `${key.origin}/${key.path}`
  339. }
  340. /**
  341. * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
  342. * @param {boolean} [canBeExpired=false]
  343. * @returns {SqliteStoreValue | undefined}
  344. */
  345. #findValue (key, canBeExpired = false) {
  346. const url = this.#makeValueUrl(key)
  347. const { headers, method } = key
  348. /**
  349. * @type {SqliteStoreValue[]}
  350. */
  351. const values = this.#getValuesQuery.all(url, method)
  352. if (values.length === 0) {
  353. return undefined
  354. }
  355. const now = Date.now()
  356. for (const value of values) {
  357. if (now >= value.deleteAt && !canBeExpired) {
  358. return undefined
  359. }
  360. let matches = true
  361. if (value.vary) {
  362. const vary = JSON.parse(value.vary)
  363. for (const header in vary) {
  364. if (!headerValueEquals(headers[header], vary[header])) {
  365. matches = false
  366. break
  367. }
  368. }
  369. }
  370. if (matches) {
  371. return value
  372. }
  373. }
  374. return undefined
  375. }
  376. }
  377. /**
  378. * @param {string|string[]|null|undefined} lhs
  379. * @param {string|string[]|null|undefined} rhs
  380. * @returns {boolean}
  381. */
  382. function headerValueEquals (lhs, rhs) {
  383. if (lhs == null && rhs == null) {
  384. return true
  385. }
  386. if ((lhs == null && rhs != null) ||
  387. (lhs != null && rhs == null)) {
  388. return false
  389. }
  390. if (Array.isArray(lhs) && Array.isArray(rhs)) {
  391. if (lhs.length !== rhs.length) {
  392. return false
  393. }
  394. return lhs.every((x, i) => x === rhs[i])
  395. }
  396. return lhs === rhs
  397. }