/** * A browser client for the local Figma bridge. * * `tools/figma-bridge/bridge.mjs` is a loopback HTTP hub: a Figma plugin can * only call outwards, so the plugin long-polls it for commands and posts the * answers back. Anything that can reach `127.0.0.1:7332` can push a command in * — including this page, because the bridge answers every request (and the * preflight) with `Access-Control-Allow-Origin: *`. That is why there is no * Vite proxy here: there is nothing to work around. * * The protocol is one endpoint. `POST /rpc` with `{ cmd, args, timeout }` * answers with **the command's result as the raw body** — no envelope — or a * non-2xx with `{ error }`: 503 when the plugin window is closed, 504 when it * took the job and went quiet, 500 when it threw. `GET /health` is a simple * request and needs no preflight. * * Nothing in here is required for the showcase to work. Every caller treats a * failure as "no live data" and falls back to the committed capture. */ const BRIDGE_URL: string = import.meta.env.VITE_FIGMA_BRIDGE ?? 'http://localhost:7332'; /** Long enough for a page walk, short enough that a dead plugin is obvious. */ const DEFAULT_TIMEOUT = 20_000; export class BridgeError extends Error { constructor( message: string, readonly status: number ) { super(message); this.name = 'BridgeError'; } } export interface BridgeHealth { ok: boolean; port: number; plugin: { connected: boolean; lastSeen: number | null; idleMs: number | null; file: string | null; page: string | null; }; queued: number; inflight: number; } /** The serializer's node shape, narrowed to the fields this app reads. */ export interface FigmaNode { id: string; name: string; type: string; absoluteBoundingBox?: { x: number; y: number; width: number; height: number }; paddingLeft?: number; paddingRight?: number; paddingTop?: number; paddingBottom?: number; itemSpacing?: number; cornerRadius?: number; children?: FigmaNode[]; componentPropertyDefinitions?: Record; } export interface NodeAnswer { name: string; nodes: Record; } export interface CssAnswer { id: string; name: string; type: string; css: Record; } export interface SvgAnswer { id: string; name: string; svg: string; } export interface TextAnswer { id: string; name: string; count: number; texts: { id: string; name: string; characters: string; style: { fontFamily?: string; fontSize?: number; fontWeight?: number; lineHeightPx?: number; letterSpacing?: number; }; }[]; } export interface SelectAnswer { id: string; name: string; page: string; } export async function health(signal?: AbortSignal): Promise { const res = await fetch(`${BRIDGE_URL}/health`, { signal }); if (!res.ok) throw new BridgeError(`health answered ${res.status}`, res.status); return (await res.json()) as BridgeHealth; } interface RpcOptions { timeout?: number; /** Off for anything with a side effect or a moving answer. */ cache?: boolean; } /* One entry per command+args. It holds the promise rather than the value so a page that mounts, unmounts and remounts — every hash navigation — waits on the request already in flight instead of starting a second one. */ const cache = new Map>(); export function rpc(cmd: string, args: Record = {}, opts: RpcOptions = {}) { const { timeout = DEFAULT_TIMEOUT, cache: useCache = true } = opts; const key = `${cmd}:${JSON.stringify(args)}`; const hit = cache.get(key); if (useCache && hit) return hit as Promise; const pending = send(cmd, args, timeout); if (useCache) { cache.set(key, pending); /* A failure must not be remembered: the usual cause is a closed plugin window, and the next attempt is the one that should succeed. */ pending.catch(() => cache.delete(key)); } return pending; } async function send(cmd: string, args: Record, timeout: number): Promise { const abort = new AbortController(); const timer = setTimeout(() => abort.abort(), timeout); try { const res = await fetch(`${BRIDGE_URL}/rpc`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, /* The bridge gets the same budget, so a job this side gave up on is not left occupying the plugin. */ body: JSON.stringify({ cmd, args, timeout }), signal: abort.signal, }); const text = await res.text(); if (!res.ok) { let detail = text; try { detail = (JSON.parse(text) as { error?: string }).error ?? text; } catch { /* not JSON — surface the body as it came */ } throw new BridgeError(detail, res.status); } return JSON.parse(text) as T; } catch (err) { if (err instanceof BridgeError) throw err; if (err instanceof DOMException && err.name === 'AbortError') { throw new BridgeError(`\`${cmd}\` timed out after ${Math.round(timeout / 1000)}s.`, 504); } throw new BridgeError(`Cannot reach the bridge at ${BRIDGE_URL}.`, 0); } finally { clearTimeout(timer); } } /** * A component set with one record per variant child. * * `depth: 2` is deliberate. `figma_sets` walks a whole page and does not come * back inside two minutes on Button's 560 variants, but a node dump starts from * the set's id — one level of children, no grandchildren — and carries each * variant's id, name and box. That is everything the panel needs, in one call. */ export const fetchSet = (id: string) => rpc('node', { id, depth: 2 }, { timeout: 60_000 }); /** Figma's own `getCSSAsync()` for a node — what Dev Mode shows. */ export const fetchCss = (id: string) => rpc('css', { id }); export const fetchSvg = (id: string) => rpc('svg', { id }, { timeout: 30_000 }); /** * Every TEXT node under a variant, with its type settings. * * `getCSSAsync()` describes the frame it is called on, so the label's font * never appears in the CSS answer — this is where the type rows come from. */ export const fetchText = (id: string) => rpc('text', { id }); /** Moves the real Figma viewport. Mutating, so never cached. */ export const selectNode = (id: string) => rpc('select', { id }, { cache: false, timeout: 15_000 });