/**
* Minimal Figma REST API client using native fetch.
* Authenticates with Personal Access Token via X-Figma-Token header.
* Retries on 429 with exponential backoff.
*/
const BASE_URL = 'https://api.figma.com';
const MAX_RETRIES = 3;
const INITIAL_BACKOFF_MS = 1000;
export interface FigmaImageOptions {
format?: 'png' | 'jpg' | 'svg' | 'pdf';
scale?: number;
}
export interface FigmaImageResponse {
err: string | null;
images: Record<string, string | null>;
}
export class FigmaClient {
private token: string;
constructor(token: string) {
this.token = token;
}
/**
* GET /v1/files/:key/nodes?ids=...&depth=...
* Returns specific nodes from a file.
*/
async getFileNodes(
fileKey: string,
nodeIds: string[],
depth?: number,
): Promise<Record<string, unknown>> {
const params = new URLSearchParams({ids: nodeIds.join(',')});
if (depth !== undefined) params.set('depth', String(depth));
return this.request(`/v1/files/${fileKey}/nodes?${params}`);
}
/**
* GET /v1/files/:key?ids=...&depth=...
* Returns entire file or specific nodes.
*/
async getFile(
fileKey: string,
nodeIds?: string[],
depth?: number,
): Promise<Record<string, unknown>> {
const params = new URLSearchParams();
if (nodeIds?.length) params.set('ids', nodeIds.join(','));
if (depth !== undefined) params.set('depth', String(depth));
const query = params.toString();
return this.request(`/v1/files/${fileKey}${query ? `?${query}` : ''}`);
}
/**
* GET /v1/images/:key?ids=...&format=...&scale=...
* Returns URLs to rendered images of file nodes.
*/
async getImage(
fileKey: string,
nodeIds: string[],
options: FigmaImageOptions = {},
): Promise<FigmaImageResponse> {
const params = new URLSearchParams({ids: nodeIds.join(',')});
if (options.format) params.set('format', options.format);
if (options.scale !== undefined) params.set('scale', String(options.scale));
return this.request(`/v1/images/${fileKey}?${params}`);
}
private async request<T>(path: string): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch(`${BASE_URL}${path}`, {
headers: {'X-Figma-Token': this.token},
});
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const waitMs = retryAfter
? Number(retryAfter) * 1000
: INITIAL_BACKOFF_MS * Math.pow(2, attempt);
if (attempt < MAX_RETRIES) {
console.error(`Figma API rate limited, retrying in ${waitMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
await sleep(waitMs);
continue;
}
throw new Error('Figma API rate limit exceeded after maximum retries');
}
if (response.status === 401) {
throw new Error('Figma API authentication failed: invalid or expired token');
}
if (response.status === 403) {
throw new Error('Figma API access denied: token lacks permission for this resource');
}
if (response.status === 404) {
throw new Error('Figma API resource not found: check file key and node IDs');
}
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`Figma API error ${response.status}: ${body}`);
}
return (await response.json()) as T;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Only retry on rate limit (handled above), not on other errors
if (!(error instanceof Error && error.message.includes('rate limit'))) {
throw lastError;
}
}
}
throw lastError ?? new Error('Figma API request failed');
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}