@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 • 12.2 kB
JavaScript
/**
* 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;
}
//# sourceMappingURL=imageCache.js.map