diff --git a/README.md b/README.md index 9cdbf3d3..47bc4287 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,41 @@ The `figma-developer-mcp` server can be configured by adding the following to yo } ``` -Or you can set `FIGMA_API_KEY` and `PORT` in the `env` field. +If you prefer to manage credentials via environment variables (as recommended in the MCP client spec), place them in the `env` object alongside your server definition. Example Cursor configuration: + +```jsonc +{ + "mcpServers": { + "Framelink MCP for Figma": { + "command": "npx", + "args": ["-y", "figma-developer-mcp", "--stdio"], + "env": { + "FIGMA_API_KEY": "YOUR-KEY", + "FIGMA_CACHING": "{\"ttl\":{\"value\":30,\"unit\":\"d\"}}", + "PORT": "3333" + } + } + } +} +``` If you need more information on how to configure the Framelink MCP for Figma, see the [Framelink docs](https://www.framelink.ai/docs/quickstart?utm_source=github&utm_medium=referral&utm_campaign=readme). +### Support for free Figma accounts: Persistent caching (optional) + +To avoid hitting Figma's heavy rate limits, you can tell the MCP server to cache full file responses on disk by setting a `FIGMA_CACHING` environment variable that contains a JSON object. + +```bash +FIGMA_CACHING='{ "ttl": { "value": 30, "unit": "d" } }' +``` + +Put this var into your mcp config json, see example above. + +- `cacheDir` (optional) controls where cached files are written. Relative paths are resolved against the current working directory and `~` expands to your home directory. If you omit it, the server defaults to `~/.cache/figma-mcp` on Linux, `~/Library/Caches/FigmaMcp` on macOS, and `%LOCALAPPDATA%/FigmaMcpCache` on Windows. +- `ttl` controls how long a cached file remains valid. It must contain a `value` (number) and a `unit` (`ms`, `s`, `m`, `h`, or `d`). + +When caching is enabled the server always fetches the full Figma file once, stores it on disk, and serves subsequent `get_figma_data` / `get_raw_node` requests from the cached copy until it expires. Delete the files inside `cacheDir` if you need to force a refresh. Leaving `FIGMA_CACHING` unset keeps the default non-cached behavior. + ## Star History Star History Chart diff --git a/src/config.ts b/src/config.ts index b63280b2..d16b7229 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,10 @@ import { config as loadEnv } from "dotenv"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; -import { resolve } from "path"; +import os from "os"; +import { isAbsolute, join, resolve } from "path"; import type { FigmaAuthOptions } from "./services/figma.js"; +import type { FigmaCachingOptions } from "./services/figma-file-cache.js"; interface ServerConfig { auth: FigmaAuthOptions; @@ -10,6 +12,7 @@ interface ServerConfig { host: string; outputFormat: "yaml" | "json"; skipImageDownloads?: boolean; + caching?: FigmaCachingOptions; configSources: { figmaApiKey: "cli" | "env"; figmaOAuthToken: "cli" | "env" | "none"; @@ -18,6 +21,7 @@ interface ServerConfig { outputFormat: "cli" | "env" | "default"; envFile: "cli" | "default"; skipImageDownloads?: "cli" | "env" | "default"; + caching?: "env"; }; } @@ -36,6 +40,16 @@ interface CliArgs { "skip-image-downloads"?: boolean; } +type DurationUnit = "ms" | "s" | "m" | "h" | "d"; + +const DURATION_IN_MS: Record = { + ms: 1, + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, +}; + export function getServerConfig(isStdioMode: boolean): ServerConfig { // Parse command line arguments const argv = yargs(hideBin(process.argv)) @@ -101,6 +115,7 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { host: "127.0.0.1", outputFormat: "yaml", skipImageDownloads: false, + caching: undefined, configSources: { figmaApiKey: "env", figmaOAuthToken: "none", @@ -109,6 +124,7 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { outputFormat: "default", envFile: envFileSource, skipImageDownloads: "default", + caching: undefined, }, }; @@ -171,6 +187,13 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { config.configSources.skipImageDownloads = "env"; } + // Handle FIGMA_CACHING + const cachingConfig = parseCachingConfig(process.env.FIGMA_CACHING); + if (cachingConfig) { + config.caching = cachingConfig; + config.configSources.caching = "env"; + } + // Validate configuration if (!auth.figmaApiKey && !auth.figmaOAuthToken) { console.error( @@ -202,6 +225,9 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { console.log( `- SKIP_IMAGE_DOWNLOADS: ${config.skipImageDownloads} (source: ${config.configSources.skipImageDownloads})`, ); + console.log( + `- FIGMA_CACHING: ${config.caching ? JSON.stringify({ cacheDir: config.caching.cacheDir, ttlMs: config.caching.ttlMs }) : "disabled"}`, + ); console.log(); // Empty line for better readability } @@ -210,3 +236,82 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { auth, }; } + +function parseCachingConfig(rawValue: string | undefined): FigmaCachingOptions | undefined { + if (!rawValue) return undefined; + + try { + const parsed = JSON.parse(rawValue) as { + cacheDir?: string; + ttl: { + value: number; + unit: DurationUnit; + }; + }; + + if (!parsed || typeof parsed !== "object") { + throw new Error("FIGMA_CACHING must be a JSON object"); + } + + if (!parsed.ttl || typeof parsed.ttl.value !== "number" || parsed.ttl.value <= 0) { + throw new Error("FIGMA_CACHING.ttl.value must be a positive number"); + } + + if (!parsed.ttl.unit || !(parsed.ttl.unit in DURATION_IN_MS)) { + throw new Error("FIGMA_CACHING.ttl.unit must be one of ms, s, m, h, d"); + } + + const ttlMs = parsed.ttl.value * DURATION_IN_MS[parsed.ttl.unit]; + const cacheDir = resolveCacheDir(parsed.cacheDir); + + return { + cacheDir, + ttlMs, + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Failed to parse FIGMA_CACHING: ${message}`); + process.exit(1); + } +} + +function resolveCacheDir(inputPath?: string): string { + const defaultDir = getDefaultCacheDir(); + if (!inputPath) { + return defaultDir; + } + + const expanded = expandHomeDir(inputPath.trim()); + if (isAbsolute(expanded)) { + return expanded; + } + return resolve(process.cwd(), expanded); +} + +function expandHomeDir(targetPath: string): string { + if (targetPath === "~") { + return os.homedir(); + } + + if (targetPath.startsWith("~/")) { + return resolve(os.homedir(), targetPath.slice(2)); + } + + return targetPath; +} + +function getDefaultCacheDir(): string { + const platform = process.platform; + if (platform === "win32") { + const base = process.env.LOCALAPPDATA || resolve(os.homedir(), "AppData", "Local"); + return join(base, "FigmaMcpCache"); + } + + if (platform === "darwin") { + return join(os.homedir(), "Library", "Caches", "FigmaMcp"); + } + + // linux and others -> use XDG cache dir + const xdgCache = process.env.XDG_CACHE_HOME || join(os.homedir(), ".cache"); + return join(xdgCache, "figma-mcp"); +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index b948d607..e5707aa9 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -7,6 +7,7 @@ import { type DownloadImagesParams, type GetFigmaDataParams, } from "./tools/index.js"; +import type { FigmaCachingOptions } from "~/services/figma-file-cache.js"; const serverInfo = { name: "Figma MCP Server", @@ -19,14 +20,20 @@ type CreateServerOptions = { isHTTP?: boolean; outputFormat?: "yaml" | "json"; skipImageDownloads?: boolean; + caching?: FigmaCachingOptions; }; function createServer( authOptions: FigmaAuthOptions, - { isHTTP = false, outputFormat = "yaml", skipImageDownloads = false }: CreateServerOptions = {}, + { + isHTTP = false, + outputFormat = "yaml", + skipImageDownloads = false, + caching, + }: CreateServerOptions = {}, ) { const server = new McpServer(serverInfo); - const figmaService = new FigmaService(authOptions); + const figmaService = new FigmaService(authOptions, caching); registerTools(server, figmaService, { outputFormat, skipImageDownloads }); Logger.isHTTP = isHTTP; diff --git a/src/mcp/tools/get-figma-data-tool.ts b/src/mcp/tools/get-figma-data-tool.ts index e97637a1..9135993b 100644 --- a/src/mcp/tools/get-figma-data-tool.ts +++ b/src/mcp/tools/get-figma-data-tool.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { GetFileResponse, GetFileNodesResponse } from "@figma/rest-api-spec"; -import { FigmaService } from "~/services/figma.js"; +import { FigmaService, type CacheInfo } from "~/services/figma.js"; import { simplifyRawFigmaObject, allExtractors, @@ -37,6 +37,27 @@ const parameters = { const parametersSchema = z.object(parameters); export type GetFigmaDataParams = z.infer; +// Format a human-readable cache notice +function formatCacheNotice(cachedAt: number, ttlMs: number): string { + const now = Date.now(); + const age = now - cachedAt; + const remaining = ttlMs - age; + + const formatDuration = (ms: number): string => { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ${hours % 24}h`; + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m`; + return `${seconds}s`; + }; + + return `ℹ️ Note: Using cached Figma data (fetched ${formatDuration(age)} ago, expires in ${formatDuration(remaining)}) due to FIGMA_CACHING environment variable.`; +} + // Simplified handler function async function getFigmaData( params: GetFigmaDataParams, @@ -57,10 +78,15 @@ async function getFigmaData( // Get raw Figma API response let rawApiResponse: GetFileResponse | GetFileNodesResponse; + let cacheInfo: CacheInfo; if (nodeId) { - rawApiResponse = await figmaService.getRawNode(fileKey, nodeId, depth); + const result = await figmaService.getRawNode(fileKey, nodeId, depth); + rawApiResponse = result.data; + cacheInfo = result.cacheInfo; } else { - rawApiResponse = await figmaService.getRawFile(fileKey, depth); + const result = await figmaService.getRawFile(fileKey, depth); + rawApiResponse = result.data; + cacheInfo = result.cacheInfo; } // Use unified design extraction (handles nodes + components consistently) @@ -85,9 +111,15 @@ async function getFigmaData( }; Logger.log(`Generating ${outputFormat.toUpperCase()} result from extracted data`); - const formattedResult = + let formattedResult = outputFormat === "json" ? JSON.stringify(result, null, 2) : yaml.dump(result); + // Prepend cache notice if data came from cache + if (cacheInfo.usedCache && cacheInfo.cachedAt && cacheInfo.ttlMs) { + const cacheNotice = formatCacheNotice(cacheInfo.cachedAt, cacheInfo.ttlMs); + formattedResult = `${cacheNotice}\n\n${formattedResult}`; + } + Logger.log("Sending result to client"); return { content: [{ type: "text" as const, text: formattedResult }], diff --git a/src/server.ts b/src/server.ts index 4bacfa86..f634d90d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -29,6 +29,7 @@ export async function startServer(): Promise { isHTTP: !isStdioMode, outputFormat: config.outputFormat, skipImageDownloads: config.skipImageDownloads, + caching: config.caching, }); if (isStdioMode) { diff --git a/src/services/figma-file-cache.test.ts b/src/services/figma-file-cache.test.ts new file mode 100644 index 00000000..2263b5c5 --- /dev/null +++ b/src/services/figma-file-cache.test.ts @@ -0,0 +1,82 @@ +import { vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "fs/promises"; +import os from "os"; +import path from "path"; +import { FigmaFileCache } from "./figma-file-cache.js"; +import type { GetFileResponse } from "@figma/rest-api-spec"; + +const SAMPLE_FILE: GetFileResponse = { + name: "Test File", + lastModified: new Date().toISOString(), + thumbnailUrl: "", + version: "1", + role: "viewer", + editorType: "figma", + document: { + id: "0:0", + name: "Document", + type: "DOCUMENT", + children: [], + }, + schemaVersion: 0, + components: {}, + componentSets: {}, + styles: {}, +} as unknown as GetFileResponse; + +async function createTempDir(): Promise { + return mkdtemp(path.join(os.tmpdir(), "figma-file-cache-test-")); +} + +async function cleanupDir(dir: string): Promise { + await rm(dir, { recursive: true, force: true }); +} + +describe("FigmaFileCache", () => { + it("stores and retrieves cached entries", async () => { + const dir = await createTempDir(); + try { + const cache = new FigmaFileCache({ cacheDir: dir, ttlMs: 60_000 }); + + await cache.set("ABC", SAMPLE_FILE); + const loaded = await cache.get("ABC"); + + expect(loaded?.data.name).toBe("Test File"); + } finally { + await cleanupDir(dir); + } + }); + + it("expires entries when ttl is exceeded", async () => { + const dir = await createTempDir(); + const cache = new FigmaFileCache({ cacheDir: dir, ttlMs: 10 }); + const dateSpy = vi.spyOn(Date, "now"); + try { + dateSpy.mockReturnValue(1000); + await cache.set("ABC", SAMPLE_FILE); + + dateSpy.mockReturnValue(1000 + 11); + const loaded = await cache.get("ABC"); + + expect(loaded).toBeNull(); + } finally { + dateSpy.mockRestore(); + await cleanupDir(dir); + } + }); + + it("handles corrupted cache files gracefully", async () => { + const dir = await createTempDir(); + try { + const filePath = path.join(dir, "ABC.json"); + await writeFile(filePath, "not-json"); + + const cache = new FigmaFileCache({ cacheDir: dir, ttlMs: 60_000 }); + const loaded = await cache.get("ABC"); + + expect(loaded).toBeNull(); + } finally { + await cleanupDir(dir); + } + }); +}); diff --git a/src/services/figma-file-cache.ts b/src/services/figma-file-cache.ts new file mode 100644 index 00000000..d0ae6af8 --- /dev/null +++ b/src/services/figma-file-cache.ts @@ -0,0 +1,130 @@ +import { access, constants, mkdir, readFile, rename, unlink, writeFile } from "fs/promises"; +import path from "path"; +import type { GetFileResponse } from "@figma/rest-api-spec"; +import { Logger } from "~/utils/logger.js"; + +export type FigmaCachingOptions = { + cacheDir: string; + ttlMs: number; +}; + +type StoredFilePayload = { + fetchedAt: number; + data: GetFileResponse; +}; + +export class FigmaFileCache { + private initPromise: Promise; + + constructor(private readonly options: FigmaCachingOptions) { + this.initPromise = this.initialize(); + } + + private async initialize(): Promise { + try { + // Create cache directory if it doesn't exist (like mkdir -p) + await mkdir(this.options.cacheDir, { recursive: true }); + + // Validate write permissions + await access(this.options.cacheDir, constants.W_OK); + + Logger.log(`[FigmaFileCache] Initialized cache directory: ${this.options.cacheDir}`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to initialize Figma cache: Cannot write to directory "${this.options.cacheDir}". ${message}`, + ); + } + } + + async waitForInit(): Promise { + await this.initPromise; + } + + private getCachePath(fileKey: string): string { + return path.join(this.options.cacheDir, `${fileKey}.json`); + } + + private isExpired(fetchedAt: number): boolean { + return Date.now() - fetchedAt > this.options.ttlMs; + } + + async get( + fileKey: string, + ): Promise<{ data: GetFileResponse; cachedAt: number; ttlMs: number } | null> { + await this.waitForInit(); + + // NOTE: Race condition possible - if multiple requests for the same uncached file + // arrive concurrently, they may all make separate API calls. This is a rare edge case + // and the complexity of deduplication is not warranted for this use case. + + const cachePath = this.getCachePath(fileKey); + + try { + const fileContents = await readFile(cachePath, "utf-8"); + const payload = JSON.parse(fileContents) as StoredFilePayload; + + if (!payload?.data || typeof payload.fetchedAt !== "number") { + Logger.log(`[FigmaFileCache] Cache file corrupted for ${fileKey}, removing`); + await this.safeDelete(cachePath); + return null; + } + + if (this.isExpired(payload.fetchedAt)) { + Logger.log(`[FigmaFileCache] Cache expired for ${fileKey}`); + await this.safeDelete(cachePath); + return null; + } + + Logger.log(`[FigmaFileCache] Cache hit for ${fileKey}`); + return { + data: payload.data, + cachedAt: payload.fetchedAt, + ttlMs: this.options.ttlMs, + }; + } catch (error: unknown) { + const err = error as { code?: string; message?: string }; + if (err?.code !== "ENOENT") { + const message = err?.message ?? String(error); + Logger.log(`[FigmaFileCache] Error reading cache for ${fileKey}: ${message}`); + } + return null; + } + } + + async set(fileKey: string, data: GetFileResponse): Promise { + await this.waitForInit(); + + const cachePath = this.getCachePath(fileKey); + const tempPath = `${cachePath}.tmp`; + const payload: StoredFilePayload = { + fetchedAt: Date.now(), + data, + }; + + try { + // Write to temporary file first, then atomically rename to avoid corruption + await writeFile(tempPath, JSON.stringify(payload, null, 2)); + await rename(tempPath, cachePath); + Logger.log(`[FigmaFileCache] Cached file ${fileKey}`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + Logger.log(`[FigmaFileCache] Failed to write cache for ${fileKey}: ${message}`); + // Clean up temp file on error + await this.safeDelete(tempPath); + throw new Error(`Figma cache write failed: ${message}`); + } + } + + private async safeDelete(cachePath: string): Promise { + try { + await unlink(cachePath); + } catch (error: unknown) { + const err = error as { code?: string; message?: string }; + if (err?.code !== "ENOENT") { + const message = err?.message ?? String(error); + Logger.log(`[FigmaFileCache] Error deleting cache file: ${message}`); + } + } + } +} diff --git a/src/services/figma.service-cache.test.ts b/src/services/figma.service-cache.test.ts new file mode 100644 index 00000000..e412eabd --- /dev/null +++ b/src/services/figma.service-cache.test.ts @@ -0,0 +1,105 @@ +import { vi } from "vitest"; +import { mkdtemp, rm } from "fs/promises"; +import os from "os"; +import path from "path"; +import type { GetFileResponse } from "@figma/rest-api-spec"; +import { FigmaService, type FigmaAuthOptions } from "./figma.js"; + +const AUTH_OPTIONS: FigmaAuthOptions = { + figmaApiKey: "test-key", + figmaOAuthToken: "", + useOAuth: false, +}; + +function createSampleFile(): GetFileResponse { + return { + name: "Sample", + lastModified: new Date().toISOString(), + thumbnailUrl: "", + version: "1", + role: "viewer", + editorType: "figma", + schemaVersion: 0, + components: {}, + componentSets: {}, + styles: {}, + document: { + id: "0:0", + name: "Document", + type: "DOCUMENT", + children: [ + { + id: "10:20", + name: "Page", + type: "CANVAS", + children: [ + { + id: "11:22", + name: "Frame", + type: "FRAME", + children: [], + }, + ], + }, + ], + }, + } as unknown as GetFileResponse; +} + +async function createCacheDir(): Promise { + return mkdtemp(path.join(os.tmpdir(), "figma-service-cache-test-")); +} + +async function cleanup(dir: string): Promise { + await rm(dir, { recursive: true, force: true }); +} + +describe("FigmaService caching", () => { + it("reuses cached files for repeated getRawFile calls", async () => { + const cacheDir = await createCacheDir(); + const sample = createSampleFile(); + const requestSpy = spyOnRequest().mockResolvedValue(sample); + + try { + const service = new FigmaService(AUTH_OPTIONS, { cacheDir, ttlMs: 60_000 }); + const first = await service.getRawFile("FILE123"); + const second = await service.getRawFile("FILE123"); + + expect(first.data.name).toBe("Sample"); + expect(second.data.document.id).toBe("0:0"); + expect(requestSpy).toHaveBeenCalledTimes(1); + } finally { + requestSpy.mockRestore(); + await cleanup(cacheDir); + } + }); + + it("serves node lookups from cached file without extra API calls", async () => { + const cacheDir = await createCacheDir(); + const sample = createSampleFile(); + const requestSpy = spyOnRequest().mockResolvedValue(sample); + + try { + const service = new FigmaService(AUTH_OPTIONS, { cacheDir, ttlMs: 60_000 }); + const nodeId = "10:20"; + + const first = await service.getRawNode("FILE456", nodeId); + expect(first.data.nodes[nodeId]).toBeDefined(); + expect(requestSpy).toHaveBeenCalledTimes(1); + + await service.getRawNode("FILE456", nodeId); + expect(requestSpy).toHaveBeenCalledTimes(1); + } finally { + requestSpy.mockRestore(); + await cleanup(cacheDir); + } + }); +}); +function spyOnRequest() { + return vi.spyOn( + FigmaService.prototype as unknown as { + request: (endpoint: string) => Promise; + }, + "request", + ); +} diff --git a/src/services/figma.ts b/src/services/figma.ts index bd8e0e78..4fd81806 100644 --- a/src/services/figma.ts +++ b/src/services/figma.ts @@ -4,11 +4,14 @@ import type { GetFileResponse, GetFileNodesResponse, GetImageFillsResponse, + Node as FigmaNode, + DocumentNode, Transform, } from "@figma/rest-api-spec"; import { downloadAndProcessImage, type ImageProcessingResult } from "~/utils/image-processing.js"; import { Logger, writeLogs } from "~/utils/logger.js"; import { fetchWithRetry } from "~/utils/fetch-with-retry.js"; +import { FigmaFileCache, type FigmaCachingOptions } from "./figma-file-cache.js"; export type FigmaAuthOptions = { figmaApiKey: string; @@ -16,6 +19,12 @@ export type FigmaAuthOptions = { useOAuth: boolean; }; +export type CacheInfo = { + usedCache: boolean; + cachedAt?: number; + ttlMs?: number; +}; + type SvgOptions = { outlineText: boolean; includeId: boolean; @@ -27,11 +36,18 @@ export class FigmaService { private readonly oauthToken: string; private readonly useOAuth: boolean; private readonly baseUrl = "https://api.figma.com/v1"; + private readonly fileCache?: FigmaFileCache; - constructor({ figmaApiKey, figmaOAuthToken, useOAuth }: FigmaAuthOptions) { + constructor( + { figmaApiKey, figmaOAuthToken, useOAuth }: FigmaAuthOptions, + cachingOptions?: FigmaCachingOptions, + ) { this.apiKey = figmaApiKey || ""; this.oauthToken = figmaOAuthToken || ""; this.useOAuth = !!useOAuth && !!this.oauthToken; + if (cachingOptions) { + this.fileCache = new FigmaFileCache(cachingOptions); + } } private getAuthHeaders(): Record { @@ -267,14 +283,30 @@ export class FigmaService { /** * Get raw Figma API response for a file (for use with flexible extractors) */ - async getRawFile(fileKey: string, depth?: number | null): Promise { - const endpoint = `/files/${fileKey}${depth ? `?depth=${depth}` : ""}`; - Logger.log(`Retrieving raw Figma file: ${fileKey} (depth: ${depth ?? "default"})`); + async getRawFile( + fileKey: string, + depth?: number | null, + ): Promise<{ data: GetFileResponse; cacheInfo: CacheInfo }> { + let response: GetFileResponse; + let cacheInfo: CacheInfo; - const response = await this.request(endpoint); - writeLogs("figma-raw.json", response); + if (this.fileCache) { + const cacheResult = await this.loadFileFromCache(fileKey); + response = cacheResult.data; + cacheInfo = cacheResult.cacheInfo; + + if (typeof depth === "number") { + const truncated = cloneFileResponseWithDepth(response, depth); + writeLogs("figma-raw.json", truncated); + return { data: truncated, cacheInfo }; + } + writeLogs("figma-raw.json", response); + return { data: response, cacheInfo }; + } - return response; + response = await this.fetchFileFromApi(fileKey, depth); + writeLogs("figma-raw.json", response); + return { data: response, cacheInfo: { usedCache: false } }; } /** @@ -284,7 +316,14 @@ export class FigmaService { fileKey: string, nodeId: string, depth?: number | null, - ): Promise { + ): Promise<{ data: GetFileNodesResponse; cacheInfo: CacheInfo }> { + if (this.fileCache) { + const cacheResult = await this.loadFileFromCache(fileKey); + const nodeResponse = buildNodeResponseFromFile(cacheResult.data, nodeId, depth); + writeLogs("figma-raw.json", nodeResponse); + return { data: nodeResponse, cacheInfo: cacheResult.cacheInfo }; + } + const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`; Logger.log( `Retrieving raw Figma node: ${nodeId} from ${fileKey} (depth: ${depth ?? "default"})`, @@ -293,6 +332,144 @@ export class FigmaService { const response = await this.request(endpoint); writeLogs("figma-raw.json", response); - return response; + return { data: response, cacheInfo: { usedCache: false } }; } + + private async loadFileFromCache( + fileKey: string, + ): Promise<{ data: GetFileResponse; cacheInfo: CacheInfo }> { + if (!this.fileCache) { + const data = await this.fetchFileFromApi(fileKey); + return { data, cacheInfo: { usedCache: false } }; + } + + const cacheResult = await this.fileCache.get(fileKey); + if (cacheResult) { + return { + data: cacheResult.data, + cacheInfo: { + usedCache: true, + cachedAt: cacheResult.cachedAt, + ttlMs: cacheResult.ttlMs, + }, + }; + } + + const fresh = await this.fetchFileFromApi(fileKey); + await this.fileCache.set(fileKey, fresh); + return { + data: fresh, + cacheInfo: { + usedCache: false, + }, + }; + } + + private async fetchFileFromApi(fileKey: string, depth?: number | null): Promise { + const endpoint = `/files/${fileKey}${depth ? `?depth=${depth}` : ""}`; + Logger.log( + `Retrieving raw Figma file: ${fileKey} (depth: ${depth ?? (this.fileCache ? "full" : "default")})`, + ); + + return this.request(endpoint); + } +} + +function cloneFileResponseWithDepth(file: GetFileResponse, depth: number): GetFileResponse { + if (depth === undefined || depth === null) { + return file; + } + + return { + ...file, + document: cloneNode(file.document, depth) as DocumentNode, + }; +} + +function cloneNode(node: T, depth?: number): T { + const clone = { ...node } as T & { children?: FigmaNode[] }; + + if (!nodeHasChildren(node)) { + delete clone.children; + return clone; + } + + if (depth === undefined || depth === null) { + clone.children = node.children.map((child) => cloneNode(child)); + return clone; + } + + if (depth <= 0) { + delete clone.children; + return clone; + } + + clone.children = node.children.map((child) => cloneNode(child, depth - 1)); + + return clone; +} + +function buildNodeResponseFromFile( + file: GetFileResponse, + nodeIdParam: string, + depth?: number | null, +): GetFileNodesResponse { + const nodeIds = nodeIdParam.split(";").filter((id) => id); + if (nodeIds.length === 0) { + throw new Error("No valid node IDs provided"); + } + + const nodesMap = findNodesById(file.document, new Set(nodeIds)); + const nodes: GetFileNodesResponse["nodes"] = {}; + + for (const id of nodeIds) { + const node = nodesMap.get(id); + if (!node) { + throw new Error(`Node ${id} not found in cached file`); + } + nodes[id] = { + document: cloneNode(node, depth ?? undefined), + components: file.components ?? {}, + componentSets: file.componentSets ?? {}, + styles: file.styles, + schemaVersion: file.schemaVersion, + }; + } + + return { + name: file.name, + lastModified: file.lastModified, + thumbnailUrl: file.thumbnailUrl ?? "", + version: file.version ?? "", + role: file.role ?? "viewer", + editorType: file.editorType ?? "figma", + nodes, + }; +} + +function findNodesById(root: DocumentNode, targetIds: Set): Map { + const result = new Map(); + const stack: FigmaNode[] = [root]; + + while (stack.length > 0 && result.size < targetIds.size) { + const current = stack.pop(); + if (!current) continue; + + if (targetIds.has(current.id)) { + result.set(current.id, current); + } + + if (nodeHasChildren(current)) { + stack.push(...current.children); + } + } + + return result; +} + +type NodeWithChildren = FigmaNode & { children: FigmaNode[] }; + +function nodeHasChildren(node: FigmaNode): node is NodeWithChildren { + const maybeChildren = (node as Partial).children; + return Array.isArray(maybeChildren) && maybeChildren.length > 0; }