import { createHash } from 'crypto'; import { existsSync, mkdirSync, statSync, createReadStream, writeFileSync } from 'fs'; import { join } from 'path'; import type { Readable } from 'stream'; const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours export class ScreenshotCache { private dir: string; constructor(dir: string) { this.dir = dir; if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } /** * Build a cache key by quantizing view params and hashing. * - lat/lng quantized to 2 decimal places * - zoom quantized to integer * - filters sorted alphabetically */ buildKey(params: Record): string { const normalized: Record = {}; // Parse and quantize the view param (lat,lng,zoom) if (params.v) { const parts = params.v.split(','); if (parts.length === 3) { const lat = parseFloat(parts[0]).toFixed(2); const lng = parseFloat(parts[1]).toFixed(2); const zoom = Math.round(parseFloat(parts[2])).toString(); normalized.v = `${lat},${lng},${zoom}`; } else { normalized.v = params.v; } } // Sort filters if (params.f) { const segments = params.f.split(',').sort(); normalized.f = segments.join(','); } if (params.poi) { const cats = params.poi.split(',').sort(); normalized.poi = cats.join(','); } if (params.tab) { normalized.tab = params.tab; } const input = JSON.stringify(normalized); const hash = createHash('sha256').update(input).digest('hex').substring(0, 16); return hash; } getPath(key: string): string { return join(this.dir, `${key}.png`); } /** * Returns a readable stream if a fresh cached file exists, null otherwise. */ get(key: string): Readable | null { const path = this.getPath(key); if (!existsSync(path)) return null; try { const stat = statSync(path); const age = Date.now() - stat.mtimeMs; if (age > MAX_AGE_MS) return null; return createReadStream(path); } catch { return null; } } /** * Write screenshot bytes to cache. */ set(key: string, data: Buffer): void { const path = this.getPath(key); writeFileSync(path, data); } }