// Copyright 2017-2026 @pezkuwi/rpc-provider authors & contributors // SPDX-License-Identifier: Apache-2.0 // Assuming all 1.5MB responses, we apply a default allowing for 192MB // cache space (depending on the historic queries this would vary, metadata // for Zagros/Pezkuwi/Bizinikiwi falls between 600-750K, 2x for estimate) export const DEFAULT_CAPACITY = 1024; export const DEFAULT_TTL = 30000; // 30 seconds const MAX_TTL = 1800_000; // 30 minutes // If the user decides to disable the TTL we set the value // to a very high number (A year = 365 * 24 * 60 * 60 * 1000). const DISABLED_TTL = 31_536_000_000; class LRUNode { readonly key: string; #expires: number; #ttl: number; readonly createdAt: number; public next: LRUNode; public prev: LRUNode; constructor (key: string, ttl: number) { this.key = key; this.#ttl = ttl; this.#expires = Date.now() + ttl; this.createdAt = Date.now(); this.next = this.prev = this; } public refresh (): void { this.#expires = Date.now() + this.#ttl; } public get expiry (): number { return this.#expires; } } // https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU export class LRUCache { readonly capacity: number; readonly #data = new Map(); readonly #refs = new Map(); #length = 0; #head: LRUNode; #tail: LRUNode; readonly #ttl: number; constructor (capacity = DEFAULT_CAPACITY, ttl: number | null = DEFAULT_TTL) { // Validate capacity if (!Number.isInteger(capacity) || capacity < 0) { throw new Error(`LRUCache initialization error: 'capacity' must be a non-negative integer. Received: ${capacity}`); } // Validate ttl if (ttl !== null && (!Number.isFinite(ttl) || ttl < 0 || ttl > MAX_TTL)) { throw new Error(`LRUCache initialization error: 'ttl' must be between 0 and ${MAX_TTL} ms or null to disable. Received: ${ttl}`); } this.capacity = capacity; ttl ? this.#ttl = ttl : this.#ttl = DISABLED_TTL; this.#head = this.#tail = new LRUNode('', this.#ttl); } get ttl (): number | null { return this.#ttl; } get length (): number { return this.#length; } get lengthData (): number { return this.#data.size; } get lengthRefs (): number { return this.#refs.size; } entries (): [string, unknown][] { const keys = this.keys(); const count = keys.length; const entries = new Array<[string, unknown]>(count); for (let i = 0; i < count; i++) { const key = keys[i]; entries[i] = [key, this.#data.get(key)]; } return entries; } keys (): string[] { const keys: string[] = []; if (this.#length) { let curr = this.#head; while (curr !== this.#tail) { keys.push(curr.key); curr = curr.next; } keys.push(curr.key); } return keys; } get (key: string): T | null { const data = this.#data.get(key); if (data) { this.#toHead(key); // Evict TTL once data is refreshed this.#evictTTL(); return data as T; } this.#evictTTL(); return null; } set (key: string, value: T): void { if (this.#data.has(key)) { this.#toHead(key); } else { const node = new LRUNode(key, this.#ttl); this.#refs.set(node.key, node); if (this.length === 0) { this.#head = this.#tail = node; } else { this.#head.prev = node; node.next = this.#head; this.#head = node; } if (this.#length === this.capacity) { this.#data.delete(this.#tail.key); this.#refs.delete(this.#tail.key); this.#tail = this.#tail.prev; this.#tail.next = this.#head; } else { this.#length += 1; } } // Evict TTL once data is refreshed or added this.#evictTTL(); this.#data.set(key, value); } #evictTTL () { // Find last node to keep // traverse map to find the expired nodes while (this.#tail.expiry && this.#tail.expiry < Date.now() && this.#length > 0) { this.#refs.delete(this.#tail.key); this.#data.delete(this.#tail.key); this.#length -= 1; this.#tail = this.#tail.prev; this.#tail.next = this.#head; } if (this.#length === 0) { this.#head = this.#tail = new LRUNode('', this.#ttl); } } #toHead (key: string): void { const ref = this.#refs.get(key); if (ref && ref !== this.#head) { ref.refresh(); ref.prev.next = ref.next; ref.next.prev = ref.prev; ref.next = this.#head; this.#head.prev = ref; this.#head = ref; } } }