UNPKG

fs-cachebox

Version:

A lightweight file-based caching library for Node.js. Stores key-value data on the filesystem with optional TTL support.

701 lines (698 loc) 19.9 kB
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, readFileSync, writeFileSync } from 'node:fs'; import { stat, readFile, writeFile, rm } from 'node:fs/promises'; import { gzipSync, gunzipSync } from 'node:zlib'; import path, { join } from 'node:path'; import { EventEmitter } from 'node:events'; import { stringify, parse } from 'devalue'; class CacheError extends Error { constructor(message, operation, key, originalError) { super(message); this.operation = operation; this.key = key; this.originalError = originalError; this.name = "CacheError"; if (originalError?.stack) { this.stack = `${this.stack} Caused by: ${originalError.stack}`; } } } class CacheBox extends EventEmitter { _cache = /* @__PURE__ */ new Map(); _cacheDir = ".cache"; _enableCompression = false; _compressionThreshold = 1024; _compressionLevel = 6; _maxSize; _maxFileSize = 50 * 1024 * 1024; // 50MB _defaultTTL = 0; _cleanupInterval = 5 * 60 * 1e3; // 5 minutes _enableAutoCleanup = true; _enableLogging = false; _cleanupTimer; _stats = { hits: 0, misses: 0, sets: 0, deletes: 0, errors: 0 }; constructor(options) { super(); this.setMaxListeners(100); if (options?.cacheDir) this._cacheDir = options.cacheDir; if (options?.enableCompression !== void 0) this._enableCompression = options.enableCompression; if (options?.compressionThreshold) this._compressionThreshold = options.compressionThreshold; if (options?.compressionLevel) { this._compressionLevel = Math.max( 1, Math.min(9, options.compressionLevel) ); } if (options?.maxSize) this._maxSize = options.maxSize; if (options?.maxFileSize) this._maxFileSize = options.maxFileSize; if (options?.defaultTTL) this._defaultTTL = options.defaultTTL; if (options?.cleanupInterval) this._cleanupInterval = options.cleanupInterval; if (options?.enableAutoCleanup !== void 0) this._enableAutoCleanup = options.enableAutoCleanup; if (options?.enableLogging !== void 0) this._enableLogging = options.enableLogging; this.load(); } /** * Validates cache key for security and filesystem compatibility */ validateKey(key) { if (!key || typeof key !== "string") return false; if (key.length === 0 || key.length > 1e6) return false; if (key.includes("..") || key.includes("/") || key.includes("\\")) return false; const invalidChars = /[<>:"/\\|?*\x00-\x1f]/; if (invalidChars.test(key)) return false; const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i; if (reservedNames.test(key)) return false; return true; } /** * Logs messages if logging is enabled */ log(level, message, extra) { if (this._enableLogging) { console[level](`[CacheBox] ${message}`, extra || ""); } } /** * Serializes and optionally compresses data */ serialize(value) { try { const serialized = stringify(value); const originalSize = Buffer.byteLength(serialized, "utf8"); if (originalSize > this._maxFileSize) { throw new CacheError( `Value size (${originalSize} bytes) exceeds maximum file size (${this._maxFileSize} bytes)`, "serialize" ); } if (this._enableCompression && originalSize > this._compressionThreshold) { try { const compressed = gzipSync(serialized, { level: this._compressionLevel }); const base64 = compressed.toString("base64"); if (base64.length < serialized.length) { return { data: base64, compressed: true, size: base64.length }; } } catch (compressionError) { this.log( "warn", "Compression failed, storing uncompressed", compressionError ); } } return { data: serialized, compressed: false, size: originalSize }; } catch (error) { throw new CacheError( "Serialization failed", "serialize", void 0, error ); } } /** * Deserializes and optionally decompresses data */ deserialize(data, compressed) { try { if (compressed) { const buffer = Buffer.from(data, "base64"); const decompressed = gunzipSync(buffer).toString("utf8"); return parse(decompressed); } return parse(data); } catch (error) { throw new CacheError( "Deserialization failed", "deserialize", void 0, error ); } } /** * Parses filename to extract metadata */ parseFileName(fileName) { const match = fileName.match(/^(.+)_(\d+)(_c)?$/); if (!match) return null; const key = match[1] || ""; return { key, ttl: Number(match[2]), compressed: Boolean(match[3]) }; } /** * Generates filename with metadata */ generateFileName(key, ttl, compressed) { return `${key}_${ttl}${compressed ? "_c" : ""}`; } /** * Removes expired entries and enforces size limits */ performCleanup() { try { const now = Date.now(); let expired = 0; let removed = 0; for (const [key, entry] of this._cache.entries()) { if (entry.ttl !== 0 && entry.ttl < now) { this.delete(key); expired++; this.emit("expire", { key, ttl: entry.ttl }); } } if (this._maxSize && this._cache.size > this._maxSize) { const sortedEntries = Array.from(this._cache.entries()).sort( ([, a], [, b]) => a.accessed - b.accessed ); const toRemove = sortedEntries.slice( 0, this._cache.size - this._maxSize ); for (const [key] of toRemove) { this.delete(key); removed++; } } if (expired > 0 || removed > 0) { this.emit("cleanup", { expired, removed }); this.log( "info", `Cleanup completed: ${expired} expired, ${removed} evicted` ); } } catch (error) { this.emitError("cleanup", void 0, error); } } /** * Starts automatic cleanup timer */ startAutoCleanup() { if (this._enableAutoCleanup && this._cleanupInterval > 0) { this._cleanupTimer = setInterval(() => { this.performCleanup(); }, this._cleanupInterval); } } /** * Emits error events with consistent formatting */ emitError(operation, key, error) { this._stats.errors++; const cacheError = error instanceof CacheError ? error : new CacheError( error?.message || "Unknown error", operation, key, error ); this.log( "error", `Error in ${operation}${key ? ` for key "${key}"` : ""}`, cacheError ); if (this.listenerCount("error") > 0) { this.emit("error", cacheError); } } /** * Initializes the cache system */ load() { try { const cacheDir = path.resolve(this._cacheDir); if (!existsSync(cacheDir)) { mkdirSync(cacheDir, { recursive: true }); } this.syncMemoryCache(); this.startAutoCleanup(); process.nextTick(() => { this.emit("ready", { entriesLoaded: this._cache.size, cacheDir: this._cacheDir }); }); this.log("info", `Cache initialized with ${this._cache.size} entries`); } catch (error) { this.emitError("initialization", void 0, error); } } /** * Synchronizes memory cache with filesystem */ syncMemoryCache() { try { const cacheDir = path.resolve(this._cacheDir); if (!existsSync(cacheDir)) return; const files = readdirSync(cacheDir); const newCache = /* @__PURE__ */ new Map(); const now = Date.now(); files.forEach((file) => { try { const parsed = this.parseFileName(file); if (parsed) { const { key, ttl, compressed } = parsed; if (ttl !== 0 && ttl < now) { rmSync(join(cacheDir, file)); return; } const filePath = join(cacheDir, file); const stats = statSync(filePath); newCache.set(key, { ttl, compressed, size: stats.size, created: stats.ctimeMs, accessed: stats.atimeMs }); } } catch (error) { this.log("warn", `Failed to process file ${file}`, error); } }); this._cache = newCache; } catch (error) { throw new CacheError( "Failed to sync memory cache", "sync", void 0, error ); } } // ========================= // PUBLIC SYNC METHODS // ========================= /** * Checks if a cache key exists and is not expired */ has(key) { if (!this.validateKey(key)) { this.emitError("has", key, new Error("Invalid key format")); return false; } if (!this._cache.has(key)) return false; const entry = this._cache.get(key); if (entry.ttl !== 0 && entry.ttl < Date.now()) { this.delete(key); return false; } return true; } /** * Retrieves a cached value by key */ get(key) { if (!this.validateKey(key)) { this.emitError("get", key, new Error("Invalid key format")); return null; } try { if (!this._cache.has(key)) { this._stats.misses++; return null; } const entry = this._cache.get(key); if (entry.ttl !== 0 && entry.ttl < Date.now()) { this.delete(key); this._stats.misses++; return null; } const fileName = this.generateFileName(key, entry.ttl, entry.compressed); const filePath = join(this._cacheDir, fileName); if (!existsSync(filePath)) { this._cache.delete(key); this._stats.misses++; return null; } const content = readFileSync(filePath, "utf8"); const result = this.deserialize(content, entry.compressed); entry.accessed = Date.now(); this._stats.hits++; return result; } catch (error) { this.emitError("get", key, error); this._stats.misses++; return null; } } /** * Stores a value in the cache */ set(key, value, ttl) { if (!this.validateKey(key)) { this.emitError("set", key, new Error("Invalid key format")); return false; } try { const effectiveTTL = ttl ?? this._defaultTTL; const fileTtl = effectiveTTL === 0 ? 0 : Date.now() + effectiveTTL; const { data, compressed, size } = this.serialize(value); const fileName = this.generateFileName(key, fileTtl, compressed); const filePath = join(this._cacheDir, fileName); if (this._cache.has(key)) { this.delete(key); } writeFileSync(filePath, data); const now = Date.now(); this._cache.set(key, { ttl: fileTtl, compressed, size, created: now, accessed: now }); this._stats.sets++; this.emit("change", { operation: "set", key, value }); if (this._maxSize && this._cache.size > this._maxSize) { this.performCleanup(); } return true; } catch (error) { this.emitError("set", key, error); return false; } } /** * Removes a cache entry */ delete(key) { if (!this.validateKey(key)) return false; try { if (!this._cache.has(key)) return true; const entry = this._cache.get(key); const fileName = this.generateFileName(key, entry.ttl, entry.compressed); const filePath = join(this._cacheDir, fileName); if (existsSync(filePath)) { rmSync(filePath); } this._cache.delete(key); this._stats.deletes++; this.emit("change", { operation: "delete", key }); return true; } catch (error) { this.emitError("delete", key, error); return false; } } /** * Clears all cache entries */ clear() { try { const cacheDir = path.resolve(this._cacheDir); const entriesBefore = this._cache.size; if (existsSync(cacheDir)) { const files = readdirSync(cacheDir); files.forEach((file) => rmSync(join(cacheDir, file))); } this._cache.clear(); this.emit("clear", { entriesRemoved: entriesBefore }); this.log("info", `Cache cleared: ${entriesBefore} entries removed`); return true; } catch (error) { this.emitError("clear", void 0, error); return false; } } /** * Batch set multiple entries */ setMany(entries) { const results = []; let success = 0; let failed = 0; for (const { key, value, ttl } of entries) { const result = this.set(key, value, ttl); results.push(result); if (result) success++; else failed++; } this.emit("change", { operation: "setMany", key: `${entries.length} entries` }); return { success, failed, results }; } /** * Batch get multiple entries */ getMany(keys) { return keys.map((key) => { const value = this.get(key); return { key, value, found: value !== null }; }); } // ========================= // PUBLIC ASYNC METHODS // ========================= /** * Async version of get method */ async getAsync(key) { if (!this.validateKey(key)) { this.emitError("getAsync", key, new Error("Invalid key format")); return null; } try { if (!this._cache.has(key)) { this._stats.misses++; return null; } const entry = this._cache.get(key); if (entry.ttl !== 0 && entry.ttl < Date.now()) { await this.deleteAsync(key); this._stats.misses++; return null; } const fileName = this.generateFileName(key, entry.ttl, entry.compressed); const filePath = join(this._cacheDir, fileName); try { await stat(filePath); } catch { this._cache.delete(key); this._stats.misses++; return null; } const content = await readFile(filePath, "utf8"); const result = this.deserialize(content, entry.compressed); entry.accessed = Date.now(); this._stats.hits++; return result; } catch (error) { this.emitError("getAsync", key, error); this._stats.misses++; return null; } } /** * Async version of set method */ async setAsync(key, value, ttl) { if (!this.validateKey(key)) { this.emitError("setAsync", key, new Error("Invalid key format")); return false; } try { const effectiveTTL = ttl ?? this._defaultTTL; const fileTtl = effectiveTTL === 0 ? 0 : Date.now() + effectiveTTL; const { data, compressed, size } = this.serialize(value); const fileName = this.generateFileName(key, fileTtl, compressed); const filePath = join(this._cacheDir, fileName); if (this._cache.has(key)) { await this.deleteAsync(key); } await writeFile(filePath, data); const now = Date.now(); this._cache.set(key, { ttl: fileTtl, compressed, size, created: now, accessed: now }); this._stats.sets++; this.emit("change", { operation: "setAsync", key, value }); if (this._maxSize && this._cache.size > this._maxSize) { this.performCleanup(); } return true; } catch (error) { this.emitError("setAsync", key, error); return false; } } /** * Async version of delete method */ async deleteAsync(key) { if (!this.validateKey(key)) return false; try { if (!this._cache.has(key)) return true; const entry = this._cache.get(key); const fileName = this.generateFileName(key, entry.ttl, entry.compressed); const filePath = join(this._cacheDir, fileName); try { await rm(filePath); } catch { } this._cache.delete(key); this._stats.deletes++; this.emit("change", { operation: "deleteAsync", key }); return true; } catch (error) { this.emitError("deleteAsync", key, error); return false; } } /** * Async batch operations */ async setManyAsync(entries) { const promises = entries.map( ({ key, value, ttl }) => this.setAsync(key, value, ttl) ); const results = await Promise.all(promises); const success = results.filter((r) => r).length; const failed = results.length - success; this.emit("change", { operation: "setManyAsync", key: `${entries.length} entries` }); return { success, failed, results }; } async getManyAsync(keys) { const promises = keys.map(async (key) => { const value = await this.getAsync(key); return { key, value, found: value !== null }; }); return Promise.all(promises); } // ========================= // UTILITY & STATS METHODS // ========================= /** * Returns current cache size */ size() { return this._cache.size; } /** * Returns all valid cache keys */ keys() { const now = Date.now(); return Array.from(this._cache.entries()).filter(([, entry]) => entry.ttl === 0 || entry.ttl > now).map(([key]) => key); } /** * Returns comprehensive cache statistics */ stats() { const now = Date.now(); const validEntries = Array.from(this._cache.entries()).filter( ([, entry]) => entry.ttl === 0 || entry.ttl > now ); const compressionStats = this.compressionStats(); const totalSize = validEntries.reduce( (sum, [, entry]) => sum + entry.size, 0 ); return { entries: validEntries.length, memoryEntries: this._cache.size, totalSize, averageSize: validEntries.length > 0 ? Math.round(totalSize / validEntries.length) : 0, cacheDir: this._cacheDir, configuration: { maxSize: this._maxSize, maxFileSize: this._maxFileSize, defaultTTL: this._defaultTTL, compressionEnabled: this._enableCompression, compressionThreshold: this._compressionThreshold, compressionLevel: this._compressionLevel }, performance: { hits: this._stats.hits, misses: this._stats.misses, hitRate: this._stats.hits + this._stats.misses > 0 ? this._stats.hits / (this._stats.hits + this._stats.misses) : 0, sets: this._stats.sets, deletes: this._stats.deletes, errors: this._stats.errors }, compression: compressionStats }; } /** * Returns compression-specific statistics */ compressionStats() { const entries = Array.from(this._cache.values()); const compressed = entries.filter((entry) => entry.compressed).length; const total = entries.length; return { totalEntries: total, compressedEntries: compressed, uncompressedEntries: total - compressed, compressionRatio: total > 0 ? compressed / total : 0, enabled: this._enableCompression, threshold: this._compressionThreshold, level: this._compressionLevel }; } /** * Manually trigger cleanup */ cleanup() { const before = this._cache.size; this.performCleanup(); const after = this._cache.size; return { expired: 0, // Will be emitted in cleanup event removed: before - after }; } /** * Stops auto-cleanup timer and closes cache */ close() { if (this._cleanupTimer) { clearInterval(this._cleanupTimer); this._cleanupTimer = void 0; } this.removeAllListeners(); this.log("info", "Cache closed"); } /** * Resets all statistics */ resetStats() { this._stats = { hits: 0, misses: 0, sets: 0, deletes: 0, errors: 0 }; } } export { CacheBox, CacheError };