235 lines
6.6 KiB
JavaScript
235 lines
6.6 KiB
JavaScript
'use strict'
|
|
|
|
const { Writable } = require('node:stream')
|
|
const { EventEmitter } = require('node:events')
|
|
const { assertCacheKey, assertCacheValue } = require('../util/cache.js')
|
|
|
|
/**
|
|
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheKey} CacheKey
|
|
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheValue} CacheValue
|
|
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
|
|
* @typedef {import('../../types/cache-interceptor.d.ts').default.GetResult} GetResult
|
|
*/
|
|
|
|
/**
|
|
* @implements {CacheStore}
|
|
* @extends {EventEmitter}
|
|
*/
|
|
class MemoryCacheStore extends EventEmitter {
|
|
#maxCount = 1024
|
|
#maxSize = 104857600 // 100MB
|
|
#maxEntrySize = 5242880 // 5MB
|
|
|
|
#size = 0
|
|
#count = 0
|
|
#entries = new Map()
|
|
#hasEmittedMaxSizeEvent = false
|
|
|
|
/**
|
|
* @param {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts | undefined} [opts]
|
|
*/
|
|
constructor (opts) {
|
|
super()
|
|
if (opts) {
|
|
if (typeof opts !== 'object') {
|
|
throw new TypeError('MemoryCacheStore options must be an object')
|
|
}
|
|
|
|
if (opts.maxCount !== undefined) {
|
|
if (
|
|
typeof opts.maxCount !== 'number' ||
|
|
!Number.isInteger(opts.maxCount) ||
|
|
opts.maxCount < 0
|
|
) {
|
|
throw new TypeError('MemoryCacheStore options.maxCount must be a non-negative integer')
|
|
}
|
|
this.#maxCount = opts.maxCount
|
|
}
|
|
|
|
if (opts.maxSize !== undefined) {
|
|
if (
|
|
typeof opts.maxSize !== 'number' ||
|
|
!Number.isInteger(opts.maxSize) ||
|
|
opts.maxSize < 0
|
|
) {
|
|
throw new TypeError('MemoryCacheStore options.maxSize must be a non-negative integer')
|
|
}
|
|
this.#maxSize = opts.maxSize
|
|
}
|
|
|
|
if (opts.maxEntrySize !== undefined) {
|
|
if (
|
|
typeof opts.maxEntrySize !== 'number' ||
|
|
!Number.isInteger(opts.maxEntrySize) ||
|
|
opts.maxEntrySize < 0
|
|
) {
|
|
throw new TypeError('MemoryCacheStore options.maxEntrySize must be a non-negative integer')
|
|
}
|
|
this.#maxEntrySize = opts.maxEntrySize
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current size of the cache in bytes
|
|
* @returns {number} The current size of the cache in bytes
|
|
*/
|
|
get size () {
|
|
return this.#size
|
|
}
|
|
|
|
/**
|
|
* Check if the cache is full (either max size or max count reached)
|
|
* @returns {boolean} True if the cache is full, false otherwise
|
|
*/
|
|
isFull () {
|
|
return this.#size >= this.#maxSize || this.#count >= this.#maxCount
|
|
}
|
|
|
|
/**
|
|
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} req
|
|
* @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined}
|
|
*/
|
|
get (key) {
|
|
assertCacheKey(key)
|
|
|
|
const topLevelKey = `${key.origin}:${key.path}`
|
|
|
|
const now = Date.now()
|
|
const entries = this.#entries.get(topLevelKey)
|
|
|
|
const entry = entries ? findEntry(key, entries, now) : null
|
|
|
|
return entry == null
|
|
? undefined
|
|
: {
|
|
statusMessage: entry.statusMessage,
|
|
statusCode: entry.statusCode,
|
|
headers: entry.headers,
|
|
body: entry.body,
|
|
vary: entry.vary ? entry.vary : undefined,
|
|
etag: entry.etag,
|
|
cacheControlDirectives: entry.cacheControlDirectives,
|
|
cachedAt: entry.cachedAt,
|
|
staleAt: entry.staleAt,
|
|
deleteAt: entry.deleteAt
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
|
|
* @param {import('../../types/cache-interceptor.d.ts').default.CacheValue} val
|
|
* @returns {Writable | undefined}
|
|
*/
|
|
createWriteStream (key, val) {
|
|
assertCacheKey(key)
|
|
assertCacheValue(val)
|
|
|
|
const topLevelKey = `${key.origin}:${key.path}`
|
|
|
|
const store = this
|
|
const entry = { ...key, ...val, body: [], size: 0 }
|
|
|
|
return new Writable({
|
|
write (chunk, encoding, callback) {
|
|
if (typeof chunk === 'string') {
|
|
chunk = Buffer.from(chunk, encoding)
|
|
}
|
|
|
|
entry.size += chunk.byteLength
|
|
|
|
if (entry.size >= store.#maxEntrySize) {
|
|
this.destroy()
|
|
} else {
|
|
entry.body.push(chunk)
|
|
}
|
|
|
|
callback(null)
|
|
},
|
|
final (callback) {
|
|
let entries = store.#entries.get(topLevelKey)
|
|
if (!entries) {
|
|
entries = []
|
|
store.#entries.set(topLevelKey, entries)
|
|
}
|
|
const previousEntry = findEntry(key, entries, Date.now())
|
|
if (previousEntry) {
|
|
const index = entries.indexOf(previousEntry)
|
|
entries.splice(index, 1, entry)
|
|
store.#size -= previousEntry.size
|
|
} else {
|
|
entries.push(entry)
|
|
store.#count += 1
|
|
}
|
|
|
|
store.#size += entry.size
|
|
|
|
// Check if cache is full and emit event if needed
|
|
if (store.#size > store.#maxSize || store.#count > store.#maxCount) {
|
|
// Emit maxSizeExceeded event if we haven't already
|
|
if (!store.#hasEmittedMaxSizeEvent) {
|
|
store.emit('maxSizeExceeded', {
|
|
size: store.#size,
|
|
maxSize: store.#maxSize,
|
|
count: store.#count,
|
|
maxCount: store.#maxCount
|
|
})
|
|
store.#hasEmittedMaxSizeEvent = true
|
|
}
|
|
|
|
// Perform eviction
|
|
for (const [key, entries] of store.#entries) {
|
|
for (const entry of entries.splice(0, entries.length / 2)) {
|
|
store.#size -= entry.size
|
|
store.#count -= 1
|
|
}
|
|
if (entries.length === 0) {
|
|
store.#entries.delete(key)
|
|
}
|
|
}
|
|
|
|
// Reset the event flag after eviction
|
|
if (store.#size < store.#maxSize && store.#count < store.#maxCount) {
|
|
store.#hasEmittedMaxSizeEvent = false
|
|
}
|
|
}
|
|
|
|
callback(null)
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @param {CacheKey} key
|
|
*/
|
|
delete (key) {
|
|
if (typeof key !== 'object') {
|
|
throw new TypeError(`expected key to be object, got ${typeof key}`)
|
|
}
|
|
|
|
const topLevelKey = `${key.origin}:${key.path}`
|
|
|
|
for (const entry of this.#entries.get(topLevelKey) ?? []) {
|
|
this.#size -= entry.size
|
|
this.#count -= 1
|
|
}
|
|
this.#entries.delete(topLevelKey)
|
|
}
|
|
}
|
|
|
|
function findEntry (key, entries, now) {
|
|
return entries.find((entry) => (
|
|
entry.deleteAt > now &&
|
|
entry.method === key.method &&
|
|
(entry.vary == null || Object.keys(entry.vary).every(headerName => {
|
|
if (entry.vary[headerName] === null) {
|
|
return key.headers[headerName] === undefined
|
|
}
|
|
|
|
return entry.vary[headerName] === key.headers[headerName]
|
|
}))
|
|
))
|
|
}
|
|
|
|
module.exports = MemoryCacheStore
|