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/lon are rounded to 2 decimals and zoom to an integer so nearby views * share a cache entry; all other params are sorted for order-independence. */ buildKey(params: URLSearchParams): string { const normalized: Record = {}; const lat = params.get('lat'); const lon = params.get('lon'); const zoom = params.get('zoom'); if (lat && lon && zoom) { normalized.lat = parseFloat(lat).toFixed(2); normalized.lon = parseFloat(lon).toFixed(2); normalized.zoom = Math.round(parseFloat(zoom)).toString(); } const quantized = new Set(['lat', 'lon', 'zoom']); const keys = [...new Set(params.keys())].filter((k) => !quantized.has(k)).sort(); for (const key of keys) { normalized[key] = params.getAll(key).sort().join(','); } 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}.jpg`); } /** * 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); } }