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
JavaScript
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 };