Skip to main content
Glama
1yhy
by 1yhy
disk-cache.ts15.2 kB
/** * Disk Cache * * Persistent file-based cache for Figma data and images. * Acts as L2 cache layer after in-memory LRU cache. * * @module services/cache/disk-cache */ import fs from "fs"; import path from "path"; import crypto from "crypto"; import os from "os"; import type { DiskCacheConfig, CacheEntryMeta, DiskCacheStats } from "./types.js"; /** * Default disk cache configuration */ const DEFAULT_CONFIG: DiskCacheConfig = { cacheDir: path.join(os.homedir(), ".figma-mcp-cache"), maxSize: 500 * 1024 * 1024, // 500MB ttl: 24 * 60 * 60 * 1000, // 24 hours }; /** * Disk-based persistent cache */ export class DiskCache { private config: DiskCacheConfig; private dataDir: string; private imageDir: string; private metadataDir: string; private stats: { hits: number; misses: number }; constructor(config: Partial<DiskCacheConfig> = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; this.dataDir = path.join(this.config.cacheDir, "data"); this.imageDir = path.join(this.config.cacheDir, "images"); this.metadataDir = path.join(this.config.cacheDir, "metadata"); this.stats = { hits: 0, misses: 0 }; this.ensureCacheDirectories(); } /** * Ensure cache directories exist */ private ensureCacheDirectories(): void { try { [this.config.cacheDir, this.dataDir, this.imageDir, this.metadataDir].forEach((dir) => { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } }); } catch (error) { console.warn("Failed to create cache directories:", error); } } /** * Generate cache key from components */ static generateKey(fileKey: string, nodeId?: string, depth?: number): string { const keyParts = [fileKey]; if (nodeId) keyParts.push(`node-${nodeId}`); if (depth !== undefined) keyParts.push(`depth-${depth}`); const keyString = keyParts.join("_"); return crypto.createHash("md5").update(keyString).digest("hex"); } /** * Generate image cache key */ static generateImageKey(fileKey: string, nodeId: string, format: string): string { const keyString = `${fileKey}_${nodeId}_${format}`; return crypto.createHash("md5").update(keyString).digest("hex"); } // ==================== Node Data Operations ==================== /** * Get cached node data */ async get<T>( fileKey: string, nodeId?: string, depth?: number, version?: string, ): Promise<T | null> { try { const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth); const dataPath = path.join(this.dataDir, `${cacheKey}.json`); const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`); // Check if files exist if (!fs.existsSync(dataPath) || !fs.existsSync(metadataPath)) { this.stats.misses++; return null; } // Read metadata and check expiration const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); // Check TTL expiration if (Date.now() > metadata.expiresAt) { this.deleteByKey(cacheKey); this.stats.misses++; return null; } // Check version mismatch if (version && metadata.version && metadata.version !== version) { this.deleteByKey(cacheKey); this.stats.misses++; return null; } // Read and return cached data const data = JSON.parse(fs.readFileSync(dataPath, "utf-8")); this.stats.hits++; return data as T; } catch (error) { console.warn("Failed to read disk cache:", error); this.stats.misses++; return null; } } /** * Set node data cache */ async set<T>( data: T, fileKey: string, nodeId?: string, depth?: number, version?: string, ): Promise<void> { try { const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth); const dataPath = path.join(this.dataDir, `${cacheKey}.json`); const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`); const dataString = JSON.stringify(data, null, 2); // Create metadata const metadata: CacheEntryMeta = { key: cacheKey, createdAt: Date.now(), expiresAt: Date.now() + this.config.ttl, fileKey, nodeId, depth, version, size: Buffer.byteLength(dataString, "utf-8"), }; // Write data and metadata fs.writeFileSync(dataPath, dataString); fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); // Enforce size limit asynchronously this.enforceSizeLimit().catch(() => {}); } catch (error) { console.warn("Failed to write disk cache:", error); } } /** * Check if cache entry exists */ async has(fileKey: string, nodeId?: string, depth?: number): Promise<boolean> { const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth); const dataPath = path.join(this.dataDir, `${cacheKey}.json`); const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`); if (!fs.existsSync(dataPath) || !fs.existsSync(metadataPath)) { return false; } try { const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); return Date.now() <= metadata.expiresAt; } catch { return false; } } /** * Delete cache entry by key */ private deleteByKey(cacheKey: string): void { try { const dataPath = path.join(this.dataDir, `${cacheKey}.json`); const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`); if (fs.existsSync(dataPath)) fs.unlinkSync(dataPath); if (fs.existsSync(metadataPath)) fs.unlinkSync(metadataPath); } catch { // Ignore deletion errors } } /** * Delete cache for a file key */ async delete(fileKey: string, nodeId?: string, depth?: number): Promise<boolean> { const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth); this.deleteByKey(cacheKey); return true; } /** * Invalidate all cache entries for a file */ async invalidateFile(fileKey: string): Promise<number> { let invalidated = 0; try { const metadataFiles = fs.readdirSync(this.metadataDir); for (const file of metadataFiles) { if (!file.endsWith(".meta.json") || file.startsWith("img_")) continue; const metadataPath = path.join(this.metadataDir, file); try { const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); if (metadata.fileKey === fileKey) { const cacheKey = file.replace(".meta.json", ""); this.deleteByKey(cacheKey); invalidated++; } } catch { // Skip individual file errors } } } catch (error) { console.warn("Failed to invalidate file cache:", error); } return invalidated; } // ==================== Image Operations ==================== /** * Check if image is cached */ async hasImage(fileKey: string, nodeId: string, format: string): Promise<string | null> { try { const cacheKey = DiskCache.generateImageKey(fileKey, nodeId, format); const imagePath = path.join(this.imageDir, `${cacheKey}.${format.toLowerCase()}`); const metadataPath = path.join(this.metadataDir, `img_${cacheKey}.meta.json`); if (!fs.existsSync(imagePath) || !fs.existsSync(metadataPath)) { return null; } // Check expiration const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); if (Date.now() > metadata.expiresAt) { this.deleteImageByKey(cacheKey, format); return null; } return imagePath; } catch { return null; } } /** * Cache image file */ async cacheImage( sourcePath: string, fileKey: string, nodeId: string, format: string, ): Promise<string> { try { const cacheKey = DiskCache.generateImageKey(fileKey, nodeId, format); const cachedImagePath = path.join(this.imageDir, `${cacheKey}.${format.toLowerCase()}`); const metadataPath = path.join(this.metadataDir, `img_${cacheKey}.meta.json`); // Copy image to cache fs.copyFileSync(sourcePath, cachedImagePath); // Get file size const stats = fs.statSync(cachedImagePath); // Create metadata const metadata: CacheEntryMeta = { key: cacheKey, createdAt: Date.now(), expiresAt: Date.now() + this.config.ttl, fileKey, nodeId, size: stats.size, }; fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); return cachedImagePath; } catch (error) { console.warn("Failed to cache image:", error); return sourcePath; } } /** * Copy image from cache to target path */ async copyImageFromCache( fileKey: string, nodeId: string, format: string, targetPath: string, ): Promise<boolean> { const cachedPath = await this.hasImage(fileKey, nodeId, format); if (!cachedPath) return false; try { const targetDir = path.dirname(targetPath); if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } fs.copyFileSync(cachedPath, targetPath); return true; } catch { return false; } } /** * Delete image cache by key */ private deleteImageByKey(cacheKey: string, format: string): void { try { const imagePath = path.join(this.imageDir, `${cacheKey}.${format.toLowerCase()}`); const metadataPath = path.join(this.metadataDir, `img_${cacheKey}.meta.json`); if (fs.existsSync(imagePath)) fs.unlinkSync(imagePath); if (fs.existsSync(metadataPath)) fs.unlinkSync(metadataPath); } catch { // Ignore errors } } // ==================== Maintenance Operations ==================== /** * Clean all expired cache entries */ async cleanExpired(): Promise<number> { let deletedCount = 0; const now = Date.now(); try { const metadataFiles = fs.readdirSync(this.metadataDir); for (const file of metadataFiles) { if (!file.endsWith(".meta.json")) continue; const metadataPath = path.join(this.metadataDir, file); try { const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); if (now > metadata.expiresAt) { const cacheKey = file.replace(".meta.json", ""); if (file.startsWith("img_")) { // Image cache const imgCacheKey = cacheKey.replace("img_", ""); ["png", "jpg", "svg", "pdf"].forEach((format) => { const imagePath = path.join(this.imageDir, `${imgCacheKey}.${format}`); if (fs.existsSync(imagePath)) fs.unlinkSync(imagePath); }); } else { // Data cache const dataPath = path.join(this.dataDir, `${cacheKey}.json`); if (fs.existsSync(dataPath)) fs.unlinkSync(dataPath); } fs.unlinkSync(metadataPath); deletedCount++; } } catch { // Skip individual errors } } } catch (error) { console.warn("Failed to clean expired cache:", error); } return deletedCount; } /** * Enforce size limit by removing oldest entries */ async enforceSizeLimit(): Promise<number> { let removedCount = 0; try { const stats = await this.getStats(); if (stats.totalSize <= this.config.maxSize) { return 0; } // Get all metadata entries sorted by creation time const entries: Array<{ path: string; meta: CacheEntryMeta }> = []; const metadataFiles = fs.readdirSync(this.metadataDir); for (const file of metadataFiles) { if (!file.endsWith(".meta.json")) continue; const metadataPath = path.join(this.metadataDir, file); try { const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); entries.push({ path: metadataPath, meta: metadata }); } catch { // Skip invalid entries } } // Sort by creation time (oldest first) entries.sort((a, b) => a.meta.createdAt - b.meta.createdAt); // Remove oldest entries until under limit let currentSize = stats.totalSize; for (const entry of entries) { if (currentSize <= this.config.maxSize) break; const cacheKey = path.basename(entry.path).replace(".meta.json", ""); if (cacheKey.startsWith("img_")) { const imgKey = cacheKey.replace("img_", ""); ["png", "jpg", "svg", "pdf"].forEach((format) => { const imagePath = path.join(this.imageDir, `${imgKey}.${format}`); if (fs.existsSync(imagePath)) { currentSize -= fs.statSync(imagePath).size; fs.unlinkSync(imagePath); } }); } else { const dataPath = path.join(this.dataDir, `${cacheKey}.json`); if (fs.existsSync(dataPath)) { currentSize -= fs.statSync(dataPath).size; fs.unlinkSync(dataPath); } } fs.unlinkSync(entry.path); currentSize -= entry.meta.size || 0; removedCount++; } } catch (error) { console.warn("Failed to enforce size limit:", error); } return removedCount; } /** * Clear all cache */ async clearAll(): Promise<void> { try { [this.dataDir, this.imageDir, this.metadataDir].forEach((dir) => { if (fs.existsSync(dir)) { const files = fs.readdirSync(dir); files.forEach((file) => { fs.unlinkSync(path.join(dir, file)); }); } }); this.stats = { hits: 0, misses: 0 }; } catch (error) { console.warn("Failed to clear cache:", error); } } /** * Get cache statistics */ async getStats(): Promise<DiskCacheStats> { let totalSize = 0; let nodeFileCount = 0; let imageFileCount = 0; try { if (fs.existsSync(this.dataDir)) { const dataFiles = fs.readdirSync(this.dataDir); nodeFileCount = dataFiles.filter((f) => f.endsWith(".json")).length; dataFiles.forEach((file) => { const stat = fs.statSync(path.join(this.dataDir, file)); totalSize += stat.size; }); } if (fs.existsSync(this.imageDir)) { const imageFiles = fs.readdirSync(this.imageDir); imageFileCount = imageFiles.length; imageFiles.forEach((file) => { const stat = fs.statSync(path.join(this.imageDir, file)); totalSize += stat.size; }); } } catch { // Ignore errors } return { hits: this.stats.hits, misses: this.stats.misses, totalSize, maxSize: this.config.maxSize, nodeFileCount, imageFileCount, }; } /** * Get cache directory path */ getCacheDir(): string { return this.config.cacheDir; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/1yhy/Figma-Context-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server