Files

144 lines
3.8 KiB
TypeScript

export interface TTLCache<K, V> {
get(key: K): V | undefined;
has(key: K): boolean;
set(key: K, value: V, options?: { at?: number }): V;
delete(key: K): boolean;
clear(): void;
prune(): number;
keys(): K[];
entries(): [K, V][];
readonly size: number;
}
interface TTLCacheOptions {
ttlMs?: number | (() => number);
maxSize?: number;
now?: () => number;
}
export function createTTLCache<K = unknown, V = unknown>({
ttlMs = 0,
maxSize = 100,
now = () => Date.now(),
}: TTLCacheOptions = {}): TTLCache<K, V> {
const map = new Map<K, { value: V; at: number }>();
function _resolveNow(): number {
try {
const value = Number(now());
return Number.isFinite(value) ? value : Date.now();
} catch {
return Date.now();
}
}
function _resolveTTL(): number {
try {
const value = typeof ttlMs === "function" ? Number(ttlMs()) : Number(ttlMs);
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
} catch {
return 0;
}
}
function _resolveMaxSize(): number {
try {
const value = Number(maxSize);
return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
} catch {
return 1;
}
}
function _isExpired(entry: { value: V; at: number } | undefined, nowValue: number, ttlValue: number): boolean {
if (!entry) return true;
if (!(ttlValue > 0)) return false;
return nowValue - Number(entry.at || 0) > ttlValue;
}
function _pruneExpired(nowValue = _resolveNow(), ttlValue = _resolveTTL()): void {
if (!(ttlValue > 0)) return;
for (const [key, entry] of map.entries()) {
if (_isExpired(entry, nowValue, ttlValue)) {
map.delete(key);
}
}
}
function _evictOverflow(): void {
const limit = _resolveMaxSize();
while (map.size > limit) {
const oldestKey = map.keys().next().value;
if (oldestKey === undefined) break;
map.delete(oldestKey);
}
}
function _touch(key: K, entry: { value: V; at: number }): void {
map.delete(key);
map.set(key, entry);
}
return {
get(key) {
const nowValue = _resolveNow();
const ttlValue = _resolveTTL();
_pruneExpired(nowValue, ttlValue);
const entry = map.get(key);
if (!entry) return undefined;
if (_isExpired(entry, nowValue, ttlValue)) {
map.delete(key);
return undefined;
}
return entry.value;
},
has(key) {
return this.get(key) !== undefined;
},
set(key, value, options = {}) {
const entryAtRaw = Number(options?.at);
const entryAt = Number.isFinite(entryAtRaw) ? entryAtRaw : _resolveNow();
const entry = { value, at: entryAt };
if (map.has(key)) {
_touch(key, entry);
} else {
map.set(key, entry);
}
_pruneExpired(_resolveNow(), _resolveTTL());
_evictOverflow();
return value;
},
delete(key) {
return map.delete(key);
},
clear() {
map.clear();
},
prune() {
_pruneExpired(_resolveNow(), _resolveTTL());
_evictOverflow();
return map.size;
},
keys() {
this.prune();
return Array.from(map.keys());
},
entries() {
this.prune();
return Array.from(map.entries()).map(([key, entry]) => [key, entry.value]);
},
get size() {
this.prune();
return map.size;
},
};
}