/** * Figma REST client. * * Responsibilities: * - Resolve auth (FIGMA_PAT env > ~/.pi-figma/config.json). * - Parse file keys / node IDs from URLs or raw strings. * - Hit the REST API with optional caching. * - Translate HTTP errors into actionable Error messages. * * Token-efficiency contract: callers MUST NOT return the raw response to the * LLM. Use src/transforms.ts before constructing a tool result. */ import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { getProbedVersion, readCached, recordVersion, writeCached, } from "./cache.js"; import type { FigmaFileResponse, FigmaNodesResponse } from "./types.js"; const BASE_URL = "https://api.figma.com"; const CONFIG_PATH = join(homedir(), ".pi-figma", "config.json"); const REQUEST_TIMEOUT_MS = 30_000; interface ConfigFile { pat?: string; } // -------------------------------------------------------------------------- // // Auth // -------------------------------------------------------------------------- // let cachedToken: string | null | undefined; export async function resolveToken(): Promise { if (cachedToken) return cachedToken; const envToken = process.env.FIGMA_PAT || process.env.FIGMA_TOKEN || process.env.FIGMA_API_KEY; if (envToken) { cachedToken = envToken.trim(); return cachedToken; } try { const raw = await readFile(CONFIG_PATH, "utf-8"); const cfg = JSON.parse(raw) as ConfigFile; if (cfg.pat) { cachedToken = cfg.pat.trim(); return cachedToken; } } catch { /* no config file — fall through */ } throw new Error( `No Figma token found. Set FIGMA_PAT env var or create ${CONFIG_PATH} with {"pat":"figd_..."}.`, ); } // -------------------------------------------------------------------------- // // Key / ID parsing // -------------------------------------------------------------------------- // /** * Accepts any of: * - a raw key like "abc123XYZ" * - a URL like "https://www.figma.com/file/abc123XYZ/Name" * - a URL like "https://www.figma.com/design/abc123XYZ/Name?node-id=1-2" * Returns the file key. */ export function parseFileKey(input: string): string { const trimmed = input.trim(); const urlMatch = trimmed.match(/figma\.com\/(?:file|design|proto|board|slides)\/([A-Za-z0-9]+)/i); if (urlMatch) return urlMatch[1]!; if (/^[A-Za-z0-9]+$/.test(trimmed)) return trimmed; throw new Error(`Could not parse Figma file key from: ${input}`); } /** * Figma node IDs use ":" internally ("1:234") but "-" in URL params ("1-234"). * Normalize to the API form. */ export function normalizeNodeId(input: string): string { const t = input.trim(); if (t.includes(":")) return t; if (/^\d+-\d+$/.test(t)) return t.replace(/-/, ":"); // Accept raw numeric or already-normalized forms; let the API surface errors. return t; } /** Try to extract a node-id from a full Figma URL if present. */ export function parseNodeIdFromUrl(input: string): string | null { const m = input.match(/[?&]node-id=([0-9A-Za-z%:\-]+)/); if (!m) return null; const raw = m[1]!; // decodeURIComponent throws on malformed %-escapes. Fall back to the raw // match so a bad URL produces a best-effort node id rather than a crash. let decoded: string; try { decoded = decodeURIComponent(raw); } catch { decoded = raw; } return normalizeNodeId(decoded); } // -------------------------------------------------------------------------- // // HTTP // -------------------------------------------------------------------------- // async function figmaFetch(path: string, signal?: AbortSignal): Promise { const token = await resolveToken(); const url = `${BASE_URL}${path}`; // Enforce a hard timeout even when the caller's signal never fires. // AbortSignal.any is available in Node 20+, which matches our engines. const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; let res: Response; try { res = await fetch(url, { headers: { "X-Figma-Token": token }, signal: combined, }); } catch (err) { if (timeoutSignal.aborted && !(signal?.aborted ?? false)) { throw new Error(`Figma API request timed out after ${REQUEST_TIMEOUT_MS}ms: ${path}`); } throw err; } if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error( `Figma API ${res.status} ${res.statusText} for ${path}${body ? `: ${body.slice(0, 200)}` : ""}`, ); } return (await res.json()) as T; } // -------------------------------------------------------------------------- // // High-level operations (always version-aware + cached) // -------------------------------------------------------------------------- // /** * Probe the file's current `version` via a minimal-depth fetch. Result is * memoized for 60s across calls in the same pi session. */ export async function probeVersion( fileKey: string, signal?: AbortSignal, ): Promise<{ version: string; lastModified: string; name: string }> { const cached = await getProbedVersion(fileKey); if (cached) { // We need name + lastModified too for outline-style responses; re-read from // the cached depth=1 blob if present. const cachedDepth1 = await readCached( fileKey, cached, "file?depth=1", ); if (cachedDepth1) { return { version: cached, lastModified: cachedDepth1.lastModified, name: cachedDepth1.name }; } } const resp = await figmaFetch( `/v1/files/${encodeURIComponent(fileKey)}?depth=1`, signal, ); await recordVersion(fileKey, resp.version); await writeCached(fileKey, resp.version, "file?depth=1", resp); return { version: resp.version, lastModified: resp.lastModified, name: resp.name }; } /** Full-file fetch with caller-supplied depth. Cached per (key, version, depth). */ export async function getFile( fileKey: string, depth: number | undefined, signal?: AbortSignal, ): Promise { const probe = await probeVersion(fileKey, signal); const endpoint = depth === undefined ? "file" : `file?depth=${depth}`; const cached = await readCached(fileKey, probe.version, endpoint); if (cached) return cached; const q = depth === undefined ? "" : `?depth=${depth}`; const resp = await figmaFetch( `/v1/files/${encodeURIComponent(fileKey)}${q}`, signal, ); await writeCached(fileKey, resp.version, endpoint, resp); if (resp.version !== probe.version) { // File was edited between probe and fetch — record newer version. await recordVersion(fileKey, resp.version); } return resp; } /** Subtree fetch — far cheaper than full file for a targeted question. */ export async function getNodes( fileKey: string, nodeIds: string[], depth: number | undefined, signal?: AbortSignal, ): Promise { if (nodeIds.length === 0) throw new Error("getNodes: at least one node id required"); const probe = await probeVersion(fileKey, signal); const ids = nodeIds.map(normalizeNodeId).sort().join(","); const endpoint = `nodes?ids=${ids}${depth === undefined ? "" : `&depth=${depth}`}`; const cached = await readCached(fileKey, probe.version, endpoint); if (cached) return cached; const params = new URLSearchParams({ ids }); if (depth !== undefined) params.set("depth", String(depth)); const resp = await figmaFetch( `/v1/files/${encodeURIComponent(fileKey)}/nodes?${params.toString()}`, signal, ); await writeCached(fileKey, resp.version, endpoint, resp); if (resp.version !== probe.version) { await recordVersion(fileKey, resp.version); } return resp; }