/** * Version-keyed disk cache for Figma REST responses. * * Key insight: Figma returns a `version` string that changes on every file * edit. When version matches, cached JSON is authoritative; no network round * trip. We ALSO keep a short-lived version-probe cache (60s) so rapid-fire * tool calls in the same turn don't each probe Figma for freshness. * * Layout: * ~/.pi-figma/cache/ * meta/.json # { version, probedAt } * data/@/.json.gz * * GC strategy: keep the 2 most recent versions per file; delete older on * touch. Bounded disk footprint, no background process needed. */ import { createHash } from "node:crypto"; import { mkdir, readFile, readdir, rm, stat, writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { gunzipSync, gzipSync } from "node:zlib"; const ROOT = join(homedir(), ".pi-figma", "cache"); const META_DIR = join(ROOT, "meta"); const DATA_DIR = join(ROOT, "data"); const VERSION_PROBE_TTL_MS = 60_000; const VERSIONS_TO_KEEP = 2; interface MetaFile { version: string; probedAt: number; } // -------------------------------------------------------------------------- // // Public API // -------------------------------------------------------------------------- // /** Get the cached version of a file, if recently probed and still fresh. */ export async function getProbedVersion(fileKey: string): Promise { const meta = await readMeta(fileKey); if (!meta) return null; if (Date.now() - meta.probedAt > VERSION_PROBE_TTL_MS) return null; return meta.version; } /** Record the current version of a file (called after every probe/fetch). */ export async function recordVersion(fileKey: string, version: string): Promise { await mkdir(META_DIR, { recursive: true }); const payload: MetaFile = { version, probedAt: Date.now() }; await writeFile(metaPath(fileKey), JSON.stringify(payload), "utf-8"); } /** * Try to read a cached response. Returns parsed JSON or null on miss. * `endpoint` is a stable string identifying the request (path+query). */ export async function readCached( fileKey: string, version: string, endpoint: string, ): Promise { try { const buf = await readFile(dataPath(fileKey, version, endpoint)); const json = gunzipSync(buf).toString("utf-8"); return JSON.parse(json) as T; } catch { return null; } } /** Write a cached response. GCs older versions for this file. */ export async function writeCached( fileKey: string, version: string, endpoint: string, value: T, ): Promise { const dir = join(DATA_DIR, `${fileKey}@${safeVersion(version)}`); await mkdir(dir, { recursive: true }); const json = JSON.stringify(value); const gz = gzipSync(Buffer.from(json, "utf-8")); await writeFile(join(dir, hashEndpoint(endpoint) + ".json.gz"), gz); gcOldVersions(fileKey, version).catch(() => { /* best-effort */ }); } /** Compute disk usage for debugging / UI. */ export async function cacheStats(): Promise<{ files: number; bytes: number }> { let files = 0; let bytes = 0; try { const entries = await readdir(DATA_DIR); for (const e of entries) { const sub = join(DATA_DIR, e); const inner = await readdir(sub).catch(() => [] as string[]); for (const f of inner) { const s = await stat(join(sub, f)).catch(() => null); if (s && s.isFile()) { files++; bytes += s.size; } } } } catch { // cache never written yet } return { files, bytes }; } // -------------------------------------------------------------------------- // // Internals // -------------------------------------------------------------------------- // function metaPath(fileKey: string): string { return join(META_DIR, `${fileKey}.json`); } function dataPath(fileKey: string, version: string, endpoint: string): string { return join(DATA_DIR, `${fileKey}@${safeVersion(version)}`, hashEndpoint(endpoint) + ".json.gz"); } /** * Defense-in-depth: the `version` string comes from the Figma API response * and is interpolated into filesystem paths. Strip anything that could * escape the per-file cache directory. In practice Figma versions are * numeric strings, so this is a no-op on the happy path. */ function safeVersion(v: string): string { return v.replace(/[^A-Za-z0-9._-]/g, "_"); } function hashEndpoint(endpoint: string): string { return createHash("sha1").update(endpoint).digest("hex").slice(0, 16); } async function readMeta(fileKey: string): Promise { try { const raw = await readFile(metaPath(fileKey), "utf-8"); return JSON.parse(raw) as MetaFile; } catch { return null; } } async function gcOldVersions(fileKey: string, keepVersion: string): Promise { const entries = await readdir(DATA_DIR).catch(() => [] as string[]); const forFile = entries.filter((e) => e.startsWith(`${fileKey}@`)); if (forFile.length <= VERSIONS_TO_KEEP) return; const withMtime = await Promise.all( forFile.map(async (e) => ({ name: e, mtime: (await stat(join(DATA_DIR, e)).catch(() => null))?.mtimeMs ?? 0, })), ); withMtime.sort((a, b) => b.mtime - a.mtime); // Build a kept set of exactly VERSIONS_TO_KEEP entries. The just-written // version is always included (even if its mtime hasn't caught up yet), // then we fill the rest with the most recent survivors. const kept = new Set(); kept.add(`${fileKey}@${safeVersion(keepVersion)}`); for (const x of withMtime) { if (kept.size >= VERSIONS_TO_KEEP) break; kept.add(x.name); } for (const e of forFile) { if (!kept.has(e)) { await rm(join(DATA_DIR, e), { recursive: true, force: true }).catch(() => { /* ignore */ }); } } }