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
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;
}