undici
Version:
An HTTP/1.1 client, written from scratch for Node.js
235 lines (201 loc) • 6.75 kB
JavaScript
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