144 lines
3.8 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|