UNPKG

@digitalsamba/embedded-api-mcp-server

Version:

Digital Samba Embedded API MCP Server - Model Context Protocol server for Digital Samba's Embedded API

307 lines 9.56 kB
/** * Digital Samba MCP Server - Cache Module * * This module provides caching functionality for the Digital Samba MCP Server. * It implements a flexible caching system for API responses, reducing load on * the Digital Samba API and improving response times for clients. * * Features include: * - Configurable TTL (Time-To-Live) for cached responses * - Memory-based cache storage * - Optional Redis-based storage for distributed deployments * - Cache invalidation strategies * - Support for conditional requests (ETag, If-Modified-Since) * * @module cache * @author Digital Samba Team * @version 0.1.0 */ // Node.js built-in modules import { createHash } from "crypto"; // External dependencies // None for now // Local modules import logger from "./logger.js"; /** * Default cache options */ export const defaultCacheOptions = { ttl: 5 * 60 * 1000, // 5 minutes by default maxItems: 1000, useEtag: true, keyGenerator: (namespace, id) => `${namespace}:${id}`, serializer: (value) => JSON.stringify(value), deserializer: (value) => JSON.parse(value), }; /** * Memory-based cache implementation */ export class MemoryCache { /** * Creates a new MemoryCache * @param options Cache options */ constructor(options = {}) { this.options = { ...defaultCacheOptions, ...options }; this.cache = new Map(); logger.debug("Cache initialized", { ttl: this.options.ttl, maxItems: this.options.maxItems, }); // Start periodic cleanup this.startCleanupInterval(); } /** * Generates an ETag for a value * * Creates a unique identifier based on the content hash that can be used * for HTTP conditional requests (If-None-Match header). * * @param {unknown} value - Value to generate ETag for * @returns {string} ETag string (MD5 hash truncated to 16 chars) * @private */ generateEtag(value) { const serialized = typeof value === "string" ? value : this.options.serializer(value); // Generate deterministic ETag based on content hash return createHash("md5").update(serialized).digest("hex").substring(0, 16); } /** * Starts the cleanup interval to remove expired items */ startCleanupInterval() { const cleanupInterval = Math.min(this.options.ttl / 2, 60 * 1000); // At most once per minute this.cleanupTimer = setInterval(() => { this.cleanup(); }, cleanupInterval); } /** * Cleans up expired cache entries */ cleanup() { const now = Date.now(); let expiredCount = 0; for (const [key, entry] of this.cache.entries()) { if (entry.expires <= now) { this.cache.delete(key); expiredCount++; } } if (expiredCount > 0) { logger.debug("Cache cleanup completed", { expiredCount }); } } /** * Sets a value in the cache * * This method stores a value in the cache with automatic expiration and optional * ETag generation for conditional requests. It handles cache size limits by * evicting the oldest entries when necessary. * * @param {string} namespace - Cache namespace (e.g., 'rooms', 'sessions') * @param {string} id - Unique identifier within the namespace * @param {T} value - Value to cache * @param {number} [ttl] - Optional TTL override in milliseconds * @returns {CacheEntry<T>} The cached entry with metadata * * @example * // Cache a room object for 10 minutes * cache.set('rooms', 'room-123', roomData, 600000); * * @example * // Cache with default TTL * cache.set('sessions', 'session-456', sessionData); */ set(namespace, id, value, ttl) { const key = this.options.keyGenerator(namespace, id); const now = Date.now(); const expires = now + (ttl || this.options.ttl); // Generate ETag if enabled const etag = this.options.useEtag ? this.generateEtag(value) : undefined; const entry = { value, expires, etag, lastModified: new Date(), }; // Check if we need to evict items to stay within size limits // Only evict if we're adding a new item (not updating existing) if (this.options.maxItems && this.cache.size >= this.options.maxItems && !this.cache.has(key)) { this.evictOldest(); } this.cache.set(key, entry); logger.debug("Cache item set", { namespace, id, ttl: ttl || this.options.ttl, }); return entry; } /** * Gets a value from the cache * @param namespace Cache namespace * @param id Item identifier * @returns The cached entry or undefined if not found or expired */ get(namespace, id) { const key = this.options.keyGenerator(namespace, id); const entry = this.cache.get(key); if (!entry) { logger.debug("Cache miss", { namespace, id }); return undefined; } // Check if expired if (entry.expires <= Date.now()) { logger.debug("Cache entry expired", { namespace, id }); this.cache.delete(key); return undefined; } logger.debug("Cache hit", { namespace, id }); return entry; } /** * Deletes a value from the cache * @param namespace Cache namespace * @param id Item identifier * @returns Whether the item was deleted */ delete(namespace, id) { const key = this.options.keyGenerator(namespace, id); const deleted = this.cache.delete(key); if (deleted) { logger.debug("Cache item deleted", { namespace, id }); } return deleted; } /** * Invalidates a specific cache entry in a namespace * @param namespace Cache namespace * @param id Item identifier * @returns Whether the item was invalidated */ invalidate(namespace, id) { const key = this.options.keyGenerator(namespace, id); const deleted = this.cache.delete(key); if (deleted) { logger.debug("Cache item invalidated", { namespace, id }); } return deleted; } /** * Invalidates all items in a namespace * @param namespace Cache namespace * @returns Number of items invalidated */ invalidateNamespace(namespace) { let count = 0; const prefix = `${namespace}:`; for (const key of this.cache.keys()) { if (key.startsWith(prefix)) { this.cache.delete(key); count++; } } if (count > 0) { logger.debug("Cache namespace invalidated", { namespace, count }); } return count; } /** * Checks if a value is fresh based on conditional headers * @param namespace Cache namespace * @param id Item identifier * @param etag ETag to compare * @param modifiedSince Last-Modified date to compare * @returns Whether the cached value is considered fresh */ isFresh(namespace, id, etag, modifiedSince) { const entry = this.get(namespace, id); if (!entry) { return false; } // Check ETag if provided if (etag && entry.etag === etag) { return true; } // Check Last-Modified if provided if (modifiedSince && entry.lastModified && entry.lastModified <= modifiedSince) { return true; } return false; } /** * Evicts the oldest item from the cache * @returns Whether an item was evicted */ evictOldest() { if (this.cache.size === 0) { return false; } let oldestKey = null; let oldestTime = Infinity; for (const [key, entry] of this.cache.entries()) { if (entry.expires < oldestTime) { oldestKey = key; oldestTime = entry.expires; } } if (oldestKey) { this.cache.delete(oldestKey); logger.debug("Cache item evicted", { key: oldestKey }); return true; } return false; } /** * Clears the entire cache */ clear() { const size = this.cache.size; this.cache.clear(); logger.info("Cache cleared", { itemsCleared: size }); } /** * Gets cache statistics * @returns Cache statistics */ getStats() { const now = Date.now(); let validItems = 0; let expiredItems = 0; for (const entry of this.cache.values()) { if (entry.expires > now) { validItems++; } else { expiredItems++; } } return { totalItems: this.cache.size, validItems, expiredItems, maxItems: this.options.maxItems, }; } /** * Destroys the cache and clears cleanup timers */ destroy() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = undefined; } this.cache.clear(); } } /** * Exports the default cache */ export default { MemoryCache, }; //# sourceMappingURL=cache.js.map