8. Plugin API compatibility layer
This section realises SRS §3.3 (CMD-10 to CMD-16).
Esta página está em inglês; a tradução está a caminho.
This section realises SRS §3.3 (CMD-10 to CMD-16).
8.1 The sandbox#
Scripts run in QuickJS compiled to WebAssembly (quickjs-emscripten, the synchronous release build).
Inside the sandbox there are only the standard ECMAScript built-ins, console, figma and ud (CMD-11). No
require, no import of anything but nothing, no file system, no network, no timers except a
setTimeout that resolves in the same run (some Figma scripts await it).
| Limit | Default | How |
|---|---|---|
| Time | 30 s (--timeout) |
QuickJS interrupt handler checks a deadline every few thousand instructions; host-side prepare time counts too |
| Memory | 256 MB | runtime.setMemoryLimit on the QuickJS heap |
| Nodes created | 100 000 | ctx.limit("nodes") in creating commands |
| Stack | 1 MB | runtime.setMaxStackSize |
A limit hit throws inside the script; the transaction is dropped (CMD-12).
Where scripts run. Headless: in the ud process. With an editor open: in the document’s script worker
(a worker_threads worker of the extension host), so a long script never blocks the extension host’s event
loop. The worker keeps a warm QuickJS module and a replica of the document that folds every committed event, so
a script starts on current state without copying the document. CMD-31’s start-up budget (under 50 ms) is
measured in the process that runs the script, from the request reaching that process to the first script
statement: the script worker when bridged, the long-running ud mcp process headless (which keeps QuickJS warm
the same way, §9.3). A one-shot headless ud use_figma from a terminal is excluded: it pays Node start-up, file
read and fold first, and is held to the headless read budget of §12 instead. The script runs on a draft of the
replica; its events go back to the controller, which commits them through the queue, rebasing over any edit the
user made meanwhile (§4.7). The result reports rebased: true and any conflicts.
8.2 How figma maps to the core and the registry#
The figma global is a JavaScript prelude evaluated in the sandbox before the script
(src/plugin-api/prelude/, written in TypeScript and compiled to one ES2020 string at build time). It defines
Figma’s node classes (FrameNode, TextNode, …) whose instances are thin handles holding an id. Every
property access crosses into the host through a handful of host functions:
script: frame.layoutMode = "VERTICAL"
prelude: set(id, "layoutMode", "VERTICAL") ── host function ──►
host: members["FrameNode.layoutMode"].write
→ ctx.run("layout.setAutoLayout", { node: id, direction: "vertical" }) (registry, on the draft)
script: frame.width
prelude: get(id, "width") ── host function ──►
host: members["SceneNode.width"].read → ctx.layout.size(id).width (core read; relayout if dirty)src/plugin-api/members.ts is the one table that defines the mapping:
{ owner: "FrameNode", member: "layoutMode", kind: "property", since: "0.3",
read: (n) => n.layoutMode ?? "NONE",
write: { command: "layout.setAutoLayout", params: (id, v) => ({ node: id, direction: dirOf(v) }) },
validate: enumOf("NONE", "HORIZONTAL", "VERTICAL", "GRID") }Rows exist for properties, methods (appendChild, findAll, clone, setBoundVariable, …), figma.*
functions (createFrame, getNodeByIdAsync, variables.createVariable, …) and node types. A mutating row
must name a registry command (CMD-13); the conformance test (§3.7) checks both directions.
Reads are batched where scripts are chatty: findAll and children return handles with a pre-read snapshot of
the commonly used properties (type, name, visible), invalidated by any write in the transaction.
Figma’s observable behaviour (CMD-15) is implemented in the prelude and the member map, not in the core:
| Behaviour | Implementation |
|---|---|
| Colours 0–1 | the core stores 0–1 RGBA, Figma’s convention |
fills, strokes, effects immutable |
getters return deep-frozen clones; assigning a mutated element throws Figma’s error message |
loadFontAsync before text edits |
text setters throw Cannot write to node with unloaded font "Inter Semi Bold" unless the font was loaded in this run |
| Figma style names | the font index exposes Figma-style names (“Semi Bold”) |
figma.mixed |
a unique symbol in the prelude; the host returns a marker for mixed values that the prelude turns into it |
resize, layoutSizingHorizontal/Vertical |
map to node.resize and layout.setSizing, including Figma’s errors when sizing is invalid for the parent |
setCurrentPageAsync |
awaited; changes figma.currentPage for the script; in a live editor also switches the page shown |
node.id |
the native id (nd-…) (CMD-3) |
8.3 Async semantics#
The script is wrapped as the body of an async function, so top-level await works. Async API members
(loadFontAsync, getNodeByIdAsync, createImageAsync, importComponentByKeyAsync, exportAsync) return
QuickJS promises created by the host. The host resolves them when the work is done (a command’s prepare, a
render) and then drains the QuickJS job queue (executePendingJobs). Synchronous members never yield, so within
one synchronous stretch of the script the document cannot change underneath it, as in Figma.
The run ends when the async function’s promise settles. Its value is serialised to JSON (with figma.mixed as
"<mixed>", nodes as {id, type, name}) and returned with createdNodeIds, mutatedNodeIds and the captured
console output (CMD-10).
8.4 Errors#
| Source | What the script sees | What the caller gets |
|---|---|---|
| Parameter validation of the command behind a member | Error with Figma’s wording where known (in set_layoutMode: Expected …) |
— (the script may catch it) |
| Member not implemented | UnsupportedApiError: figma.createSlice is not supported yet (planned for 0.4) (CMD-14) |
— |
| Uncaught error | — | { ok: false, code: "script", message, line, column, stack }, line numbers of the user’s code (the prelude is a separate eval) |
| Limit | an uncatchable interrupt | code: "limit", which limit |
| Licence | Error: Editing needs a UI Designer plan |
code: "licence", exit 4 |
8.5 Coverage table and typings#
scripts/gen-plugin-api.mjs reads members.ts and a pinned copy of @figma/plugin-typings
(test/fixtures/plugin-typings/) and generates:
- the coverage table that
ud plugin-api [--json]prints and CLI-001 §4 includes (member, statussupported | partial | planned (0.x) | not planned, notes); ud-plugin-api.d.ts: the subset of the typings that is implemented, plus theudglobal (CMD-14), shipped with the CLI and the skills;- the
UnsupportedApiErrorlist used by the prelude’s catch-all proxy trap: a member present in the typings but not in the map throws; a name present in neither behaves like a missing JavaScript property.
8.6 Conformance suite#
test/plugin-api/ holds scripts that were run in Figma through the Figma MCP server’s use_figma, with the
recorded result trees (*.figma.json). The suite runs each script against an empty .design file and compares
the resulting tree (types, names, geometry to 0.01 px, paints, layout properties, text characters and styles)
up to ids (CMD-15). Recording is a maintenance script (scripts/record-figma.mjs) run by a person with a Figma
account; the recordings are reviewed like code.