UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

377 lines (376 loc) 12.1 kB
/** * Image Cache Utility for NeuroLink * * Implements an LRU cache for downloaded images to avoid redundant URL downloads. * Addresses IMG-026: No Caching issue - same URL downloaded multiple times wasting bandwidth. * * Features: * - LRU (Least Recently Used) eviction strategy * - Configurable cache size and TTL * - Cache hit/miss metrics * - Content hash tracking for deduplication * * @module utils/imageCache */ import { createHash } from "crypto"; import { logger } from "./logger.js"; /** * LRU Cache for downloaded images * * Uses URL as primary key and tracks content hashes for deduplication. * Implements LRU eviction and configurable TTL for memory management. */ export class ImageCache { cache = new Map(); contentHashIndex = new Map(); // contentHash -> url maxSize; ttlMs; maxImageSize; enabled; stats = { hits: 0, misses: 0, evictions: 0, expirations: 0, totalRequests: 0, }; constructor(config = {}) { // Parse configuration with environment variable overrides const envEnabled = process.env.NEUROLINK_IMAGE_CACHE_ENABLED; const envMaxSize = process.env.NEUROLINK_IMAGE_CACHE_SIZE; const envTtlMs = process.env.NEUROLINK_IMAGE_CACHE_TTL_MS; const envMaxImageSize = process.env.NEUROLINK_IMAGE_MAX_SIZE; // Check if cache is enabled (default: false) this.enabled = envEnabled !== undefined ? envEnabled.toLowerCase() === "true" : false; this.maxSize = this.parseConfigValue(envMaxSize, config.maxSize, 100, 1, 1000); this.ttlMs = this.parseConfigValue(envTtlMs, config.ttlMs, 30 * 60 * 1000, // 30 minutes 1000, // 1 second min 24 * 60 * 60 * 1000); this.maxImageSize = this.parseConfigValue(envMaxImageSize, config.maxImageSize, 10 * 1024 * 1024, // 10MB 1024, // 1KB min 100 * 1024 * 1024); logger.debug("ImageCache initialized", { enabled: this.enabled, maxSize: this.maxSize, ttlMs: this.ttlMs, maxImageSize: this.maxImageSize, }); } /** * Parse a config value with bounds checking */ parseConfigValue(envValue, configValue, defaultValue, min, max) { let value; if (envValue !== undefined) { const parsed = parseInt(envValue, 10); value = isNaN(parsed) ? defaultValue : parsed; } else if (configValue !== undefined) { value = configValue; } else { value = defaultValue; } // Apply bounds if (value < min) { logger.warn(`Cache config value ${value} below minimum ${min}, using min`); return min; } if (value > max) { logger.warn(`Cache config value ${value} above maximum ${max}, using max`); return max; } return value; } /** * Normalize URL for consistent cache key generation * Removes tracking parameters and normalizes the URL */ normalizeUrl(url) { try { const parsed = new URL(url); // Remove common tracking parameters that don't affect content const trackingParams = [ "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "fbclid", "gclid", "_ga", ]; trackingParams.forEach((param) => parsed.searchParams.delete(param)); return parsed.toString(); } catch { // If URL parsing fails, use the original URL return url; } } /** * Generate content hash from image data */ generateContentHash(data) { const buffer = typeof data === "string" ? Buffer.from(data, "base64") : data; return createHash("sha256").update(buffer).digest("hex").substring(0, 16); } /** * Check if an entry is expired based on TTL */ isExpired(entry) { return Date.now() - entry.createdAt > this.ttlMs; } /** * Check if cache is enabled */ isEnabled() { return this.enabled; } /** * Get a cached image by URL * Returns null if not found or expired */ get(url) { // Return null immediately if cache is disabled if (!this.enabled) { return null; } this.stats.totalRequests++; const normalizedUrl = this.normalizeUrl(url); const entry = this.cache.get(normalizedUrl); if (!entry) { this.stats.misses++; logger.debug("Image cache miss", { url: normalizedUrl.substring(0, 50) }); return null; } // Check TTL expiration if (this.isExpired(entry)) { this.stats.expirations++; this.delete(normalizedUrl); logger.debug("Image cache entry expired", { url: normalizedUrl.substring(0, 50), }); return null; } // Update LRU info entry.lastAccessedAt = Date.now(); entry.accessCount++; // Move to end (most recently used) - delete and re-add this.cache.delete(normalizedUrl); this.cache.set(normalizedUrl, entry); this.stats.hits++; logger.debug("Image cache hit", { url: normalizedUrl.substring(0, 50), accessCount: entry.accessCount, }); return entry; } /** * Get a cached image by content hash * Useful for deduplication when the same image is accessed via different URLs */ getByContentHash(contentHash) { const url = this.contentHashIndex.get(contentHash); if (!url) { return null; } return this.get(url); } /** * Store an image in the cache */ set(url, dataUri, contentType, imageData) { // Skip caching if disabled if (!this.enabled) { logger.debug("Image caching disabled, skipping cache storage"); return; } const normalizedUrl = this.normalizeUrl(url); const size = imageData.length; // Skip caching if image exceeds max size if (size > this.maxImageSize) { logger.debug("Image too large to cache", { url: normalizedUrl.substring(0, 50), size, maxSize: this.maxImageSize, }); return; } // Generate content hash const contentHash = this.generateContentHash(imageData); // Check if same content already exists under different URL const existingUrl = this.contentHashIndex.get(contentHash); if (existingUrl && existingUrl !== normalizedUrl) { // Content already cached under different URL - create a shallow copy const existingEntry = this.cache.get(existingUrl); if (existingEntry && !this.isExpired(existingEntry)) { // Create a shallow copy for the new URL to avoid shared reference issues this.cache.set(normalizedUrl, { ...existingEntry }); // Update content hash index to point to the new URL as well this.contentHashIndex.set(contentHash, normalizedUrl); logger.debug("Image cache dedup hit", { newUrl: normalizedUrl.substring(0, 50), existingUrl: existingUrl.substring(0, 50), }); return; } } // Evict if at capacity while (this.cache.size >= this.maxSize) { this.evictOldest(); } const now = Date.now(); const entry = { dataUri, contentType, size, contentHash, createdAt: now, lastAccessedAt: now, accessCount: 1, }; this.cache.set(normalizedUrl, entry); this.contentHashIndex.set(contentHash, normalizedUrl); logger.debug("Image cached", { url: normalizedUrl.substring(0, 50), size, contentHash: contentHash.substring(0, 8), cacheSize: this.cache.size, }); } /** * Delete an entry from the cache */ delete(url) { const normalizedUrl = this.normalizeUrl(url); const entry = this.cache.get(normalizedUrl); if (entry) { // Remove from content hash index if (this.contentHashIndex.get(entry.contentHash) === normalizedUrl) { this.contentHashIndex.delete(entry.contentHash); } this.cache.delete(normalizedUrl); return true; } return false; } /** * Evict the oldest (least recently used) entry */ evictOldest() { // Map maintains insertion order, first entry is oldest const oldestKey = this.cache.keys().next().value; if (oldestKey !== undefined) { const entry = this.cache.get(oldestKey); if (entry) { if (this.contentHashIndex.get(entry.contentHash) === oldestKey) { this.contentHashIndex.delete(entry.contentHash); } } this.cache.delete(oldestKey); this.stats.evictions++; logger.debug("Image cache eviction", { url: String(oldestKey).substring(0, 50), }); } } /** * Clear all expired entries */ evictExpired() { let evicted = 0; const now = Date.now(); for (const [url, entry] of this.cache.entries()) { if (now - entry.createdAt > this.ttlMs) { this.delete(url); evicted++; this.stats.expirations++; } } if (evicted > 0) { logger.debug(`Evicted ${evicted} expired image cache entries`); } return evicted; } /** * Clear all entries from the cache */ clear() { const size = this.cache.size; this.cache.clear(); this.contentHashIndex.clear(); logger.debug(`Image cache cleared (${size} entries removed)`); } /** * Get cache statistics */ getStats() { let totalBytes = 0; for (const entry of this.cache.values()) { totalBytes += entry.size; } const hitRate = this.stats.totalRequests > 0 ? Number(((this.stats.hits / this.stats.totalRequests) * 100).toFixed(2)) : 0; return { ...this.stats, size: this.cache.size, totalBytes, hitRate, }; } /** * Check if a URL is cached and not expired */ has(url) { const normalizedUrl = this.normalizeUrl(url); const entry = this.cache.get(normalizedUrl); return entry !== null && entry !== undefined && !this.isExpired(entry); } /** * Get the current cache size */ getSize() { return this.cache.size; } /** * Get cache configuration */ getConfig() { return { enabled: this.enabled, maxSize: this.maxSize, ttlMs: this.ttlMs, maxImageSize: this.maxImageSize, }; } } // Global image cache instance let globalImageCache = null; /** * Get the global image cache instance * Creates a new instance if none exists */ export function getImageCache(config) { if (!globalImageCache) { globalImageCache = new ImageCache(config); } return globalImageCache; } /** * Reset the global image cache (useful for testing) */ export function resetImageCache() { if (globalImageCache) { globalImageCache.clear(); globalImageCache = null; } } /** * Get image cache statistics from the global instance */ export function getImageCacheStats() { return globalImageCache ? globalImageCache.getStats() : null; }