@todoforai/figma-api 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plugin/ui.html CHANGED
@@ -3,62 +3,74 @@
3
3
  <head><meta charset="utf-8" /></head>
4
4
  <body style="font:13px -apple-system,Segoe UI,Roboto,sans-serif;margin:0;padding:14px;color:#1a1a1a">
5
5
  <h3 style="margin:0 0 6px">figma-api bridge</h3>
6
- <p style="margin:0 0 10px;color:#666">Connect to the relay, then drive drawing from the <code>figma-api</code> CLI.</p>
6
+ <p style="margin:0 0 10px;color:#666">Joins the relay's WebSocket channel, then drive it from the <code>figma-api</code> CLI (or any cursor-talk-to-figma client).</p>
7
7
 
8
- <label style="display:block;margin:6px 0 2px;font-weight:600">Relay URL</label>
9
- <input id="relay" value="http://localhost:8917" style="width:100%;box-sizing:border-box;padding:7px;border:1px solid #ccc;border-radius:6px" />
8
+ <label style="display:block;margin:6px 0 2px;font-weight:600">Relay WebSocket URL</label>
9
+ <input id="url" value="ws://localhost:3055" style="width:100%;box-sizing:border-box;padding:7px;border:1px solid #ccc;border-radius:6px" />
10
+
11
+ <label style="display:block;margin:8px 0 2px;font-weight:600">Channel</label>
12
+ <input id="channel" value="figma-api" style="width:100%;box-sizing:border-box;padding:7px;border:1px solid #ccc;border-radius:6px" />
10
13
 
11
14
  <div style="display:flex;gap:8px;margin-top:10px">
12
15
  <button id="connect" style="flex:1;padding:9px;background:#1F6FEB;color:#fff;border:0;border-radius:8px;font-weight:600;cursor:pointer">Connect</button>
13
- <button id="stop" style="flex:1;padding:9px;background:#eee;color:#333;border:0;border-radius:8px;font-weight:600;cursor:pointer">Stop</button>
16
+ <button id="stop" style="flex:1;padding:9px;background:#eee;color:#333;border:0;border-radius:8px;font-weight:600;cursor:pointer">Disconnect</button>
14
17
  </div>
15
18
 
16
19
  <div id="status" style="margin-top:10px;color:#666;min-height:18px">Idle.</div>
17
- <pre id="log" style="margin-top:8px;background:#0d1117;color:#8b949e;padding:8px;border-radius:6px;height:120px;overflow:auto;font-size:11px"></pre>
20
+ <pre id="log" style="margin-top:8px;background:#0d1117;color:#8b949e;padding:8px;border-radius:6px;height:110px;overflow:auto;font-size:11px"></pre>
18
21
 
19
22
  <script>
20
23
  const $ = (id) => document.getElementById(id);
21
- let running = false;
22
24
  const log = (m) => { const l = $("log"); l.textContent += m + "\n"; l.scrollTop = l.scrollHeight; };
25
+ let socket = null, channel = null;
23
26
 
24
- let resolveResult = null;
25
- // plugin code → result → resolve the pending exec
27
+ // plugin code → command result → relay (cursor-talk-to-figma envelope)
26
28
  onmessage = (e) => {
27
29
  const m = e.data.pluginMessage;
28
- if (m && m.type === "result" && resolveResult) { const r = resolveResult; resolveResult = null; r(m); }
30
+ if (!m || !socket || socket.readyState !== 1) return;
31
+ if (m.type === "command-result") {
32
+ log("▶ " + m.id + " ok");
33
+ socket.send(JSON.stringify({ id: m.id, type: "message", channel, message: { id: m.id, result: m.result } }));
34
+ } else if (m.type === "command-error") {
35
+ log("▶ " + m.id + " ✗ " + m.error);
36
+ socket.send(JSON.stringify({ id: m.id, type: "message", channel, message: { id: m.id, error: m.error } }));
37
+ }
29
38
  };
30
39
 
31
- async function loop() {
32
- const relay = $("relay").value.replace(/\/$/, "");
33
- $("status").textContent = "🟢 Connected — polling " + relay;
34
- while (running) {
35
- let cmd;
36
- try {
37
- cmd = await (await fetch(relay + "/poll")).json();
38
- } catch (_) {
39
- $("status").textContent = "🔴 Relay unreachable — retrying";
40
- await new Promise((res) => setTimeout(res, 1500));
41
- continue;
40
+ function connect() {
41
+ const url = $("url").value.trim();
42
+ channel = $("channel").value.trim();
43
+ $("status").textContent = "Connecting to " + url + " …";
44
+ socket = new WebSocket(url);
45
+
46
+ socket.onopen = () => {
47
+ const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
48
+ socket.send(JSON.stringify({ id, type: "join", channel }));
49
+ log("joining channel: " + channel);
50
+ };
51
+ socket.onmessage = (event) => {
52
+ let data; try { data = JSON.parse(event.data); } catch (_) { return; }
53
+ if (data.type === "system") {
54
+ const sm = data.message;
55
+ // structured join ack, or "Joined channel: …" — not the "please join" welcome
56
+ if ((sm && typeof sm === "object" && sm.result) || (typeof sm === "string" && sm.indexOf("Joined channel") === 0)) {
57
+ $("status").textContent = "🟢 Connected — channel: " + channel;
58
+ } else if (typeof sm === "string") log("· " + sm);
59
+ return;
42
60
  }
43
- if (!cmd || !cmd.op) continue;
44
- log("◀ " + cmd.op + " (" + cmd.id + ")");
45
- // Run one command at a time and wait for its result (no overlap).
46
- const m = await new Promise((resolve) => {
47
- resolveResult = resolve;
48
- parent.postMessage({ pluginMessage: { type: "exec", cmd } }, "*");
49
- });
50
- let bodyResult = m.result;
51
- try { JSON.stringify(bodyResult); } catch (_) { bodyResult = { error: "result not serializable" }; }
52
- log("▶ " + m.id + " " + JSON.stringify(bodyResult).slice(0, 60));
53
- try {
54
- await fetch(relay + "/result", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: m.id, result: bodyResult }) });
55
- } catch (_) { log("⚠ result POST failed"); }
56
- }
57
- $("status").textContent = "⏹ Stopped.";
61
+ // commands arrive as a "broadcast" event from the other peer; data.message is the payload
62
+ const payload = data.message;
63
+ if (payload && payload.command) {
64
+ log("◀ " + payload.command + " (" + payload.id + ")");
65
+ parent.postMessage({ pluginMessage: { type: "execute-command", id: payload.id, command: payload.command, params: payload.params } }, "*");
66
+ }
67
+ };
68
+ socket.onclose = () => { $("status").textContent = "⏹ Disconnected."; socket = null; };
69
+ socket.onerror = () => { $("status").textContent = "🔴 WebSocket error is the relay running? (figma-api bridge)"; };
58
70
  }
59
71
 
60
- $("connect").onclick = () => { if (!running) { running = true; loop(); } };
61
- $("stop").onclick = () => { running = false; };
72
+ $("connect").onclick = () => { if (!socket) connect(); };
73
+ $("stop").onclick = () => { if (socket) socket.close(); };
62
74
  </script>
63
75
  </body>
64
76
  </html>
package/src/bridge.ts CHANGED
@@ -1,15 +1,28 @@
1
- // Relay/bridge between the figma-api CLI and the Figma plugin.
2
- // The plugin can't be addressed directly, but it can poll over HTTP — so the CLI
3
- // enqueues commands here, the plugin long-polls /poll, executes them on the
4
- // canvas, and posts the result back to /result.
1
+ // WebSocket relay + CLI peer for the Figma plugin bridge.
5
2
  //
6
- // figma-api draw-* ──POST /cmd──▶ relay ◀──GET /poll── plugin
7
- // ──POST /result─▶ (draws, returns ids)
3
+ // The Figma REST API can't create canvas nodes (frames/text/shapes) — the Plugin
4
+ // API can. The plugin can't be addressed directly, so a relay sits between it and
5
+ // the CLI. We speak the cursor-talk-to-figma WebSocket protocol, so this relay is
6
+ // a drop-in for that ecosystem: its MCP server can drive our plugin, and our CLI
7
+ // can drive theirs.
8
+ //
9
+ // figma-api create-frame … ──ws──▶ relay (channel broadcast) ◀──ws── plugin
10
+ //
11
+ // Protocol (join/ack are channel-scoped; forwarding is cross-channel — see
12
+ // startBridge — so a plugin on its own random channel still reaches the CLI):
13
+ // join: { type:"join", channel } → { type:"system", message:{ id, result } }
14
+ // command: { type:"message", channel, message:{ id, command, params } }
15
+ // result: { type:"message", channel, message:{ id, result } | { id, error } }
16
+ // each is delivered to the other peer wrapped as { type:"broadcast", message }.
8
17
 
9
18
  import { resolve } from "path";
10
19
  import { existsSync } from "fs";
20
+ import type { ServerWebSocket } from "bun";
11
21
 
12
- interface Cmd { id: string; op: string; [k: string]: unknown }
22
+ export const DEFAULT_PORT = 3055;
23
+ export const DEFAULT_CHANNEL = "figma-api";
24
+
25
+ interface WsData { channel?: string }
13
26
 
14
27
  /** Absolute path to the bundled plugin/manifest.json, or a clone hint if missing. */
15
28
  function manifestPath(): string {
@@ -19,114 +32,167 @@ function manifestPath(): string {
19
32
  : `${p} (missing — clone it: git clone https://github.com/todoforai/figma-api)`;
20
33
  }
21
34
 
22
- const queue: Cmd[] = [];
23
- const resultWaiters = new Map<string, (v: unknown) => void>();
24
- const pollWaiters: ((c: Cmd | null) => void)[] = [];
25
- let lastSeen = 0;
26
-
27
- function json(body: unknown, status = 200): Response {
28
- return new Response(JSON.stringify(body), {
29
- status,
30
- headers: { "content-type": "application/json", "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
31
- });
32
- }
33
-
34
35
  export function startBridge(port: number): void {
35
- Bun.serve({
36
+ const channels = new Map<string, Set<ServerWebSocket<WsData>>>();
37
+ // Every connected peer, regardless of channel. The relay is single-user and
38
+ // local (one plugin + one CLI at a time), so we bridge across channels: the
39
+ // CLI joins "figma-api" while the community TTF plugin picks its own random
40
+ // channel — forwarding to all *other* peers spares the user copying it.
41
+ const all = new Set<ServerWebSocket<WsData>>();
42
+
43
+ Bun.serve<WsData, undefined>({
36
44
  port,
37
- // Bun's default idleTimeout (10s) is shorter than our 25s /poll long-poll and
38
- // 30s /cmd result-wait, so it would sever those connections mid-wait — making
39
- // the plugin's fetch throw ("Relay unreachable") and commands silently time
40
- // out. Hold connections long enough to cover both.
41
- idleTimeout: 60,
42
- async fetch(req) {
43
- const url = new URL(req.url);
44
- if (req.method === "OPTIONS") return json({}, 204);
45
-
46
- // Health / status
47
- if (url.pathname === "/health") {
48
- return json({ ok: true, queued: queue.length, pluginSeenMsAgo: lastSeen ? Date.now() - lastSeen : null });
49
- }
45
+ fetch(req, server) {
46
+ if (server.upgrade(req, { data: {} })) return;
47
+ return new Response("figma-api bridge: WebSocket only", { status: 426 });
48
+ },
49
+ websocket: {
50
+ // Match cursor-talk-to-figma's relay (src/socket.ts) so either side is a
51
+ // drop-in: welcome on open, two-part join ack, peer notices, broadcasts
52
+ // wrapped as {type:"broadcast", sender:"peer", message:<inner>}.
53
+ open(ws) {
54
+ all.add(ws);
55
+ ws.send(JSON.stringify({ type: "system", message: "Please join a channel to start chatting" }));
56
+ },
57
+ message(ws, raw) {
58
+ let msg: any;
59
+ try { msg = JSON.parse(String(raw)); } catch { return; }
50
60
 
51
- // CLI enqueue a command and wait for the plugin's result
52
- if (url.pathname === "/cmd" && req.method === "POST") {
53
- const body = (await req.json()) as Record<string, unknown>;
54
- const id = crypto.randomUUID().slice(0, 8);
55
- const cmd: Cmd = { id, op: String(body.op), ...body };
56
- const waiter = pollWaiters.shift();
57
- if (waiter) waiter(cmd);
58
- else queue.push(cmd);
59
- const result = await new Promise((resolve) => {
60
- resultWaiters.set(id, resolve);
61
- setTimeout(() => { if (resultWaiters.delete(id)) resolve({ timeout: true }); }, 30000);
62
- });
63
- return json({ id, result });
64
- }
61
+ if (msg.type === "join") {
62
+ const ch = typeof msg.channel === "string" && msg.channel ? msg.channel : DEFAULT_CHANNEL;
63
+ if (ws.data.channel && ws.data.channel !== ch) channels.get(ws.data.channel)?.delete(ws);
64
+ let peers = channels.get(ch);
65
+ if (!peers) channels.set(ch, (peers = new Set()));
66
+ ws.data.channel = ch;
67
+ ws.send(JSON.stringify({ type: "system", message: `Joined channel: ${ch}`, channel: ch }));
68
+ ws.send(JSON.stringify({ type: "system", message: { id: msg.id, result: `Connected to channel: ${ch}` }, channel: ch }));
69
+ for (const peer of peers) if (peer !== ws) peer.send(JSON.stringify({ type: "system", message: "A new user has joined the channel", channel: ch }));
70
+ peers.add(ws);
71
+ console.log(`peer joined channel "${ch}" (${peers.size} in channel)`);
72
+ return;
73
+ }
65
74
 
66
- // Plugin long-poll for the next command
67
- if (url.pathname === "/poll") {
68
- lastSeen = Date.now();
69
- const next = queue.shift();
70
- if (next) return json(next);
71
- const cmd = await new Promise<Cmd | null>((resolve) => {
72
- pollWaiters.push(resolve);
73
- setTimeout(() => {
74
- const i = pollWaiters.indexOf(resolve);
75
- if (i >= 0) { pollWaiters.splice(i, 1); resolve(null); }
76
- }, 25000);
77
- });
78
- return json(cmd ?? {});
79
- }
75
+ const ch = ws.data.channel;
76
+ if (!ch || !channels.get(ch)?.has(ws)) { ws.send(JSON.stringify({ type: "error", message: "You must join the channel first" })); return; }
80
77
 
81
- // Plugin post a command result
82
- if (url.pathname === "/result" && req.method === "POST") {
83
- const body = (await req.json()) as { id: string; result: unknown };
84
- const w = resultWaiters.get(body.id);
85
- if (w) { resultWaiters.delete(body.id); w(body.result); }
86
- return json({ ok: true });
87
- }
78
+ // Forward across ALL peers (not just the sender's channel) so the CLI and
79
+ // the community plugin bridge even when their channel names differ.
80
+ // Forward progress updates verbatim to the other peers (upstream parity).
81
+ if (msg.type === "progress_update") {
82
+ for (const peer of all) if (peer !== ws) peer.send(JSON.stringify(msg));
83
+ return;
84
+ }
85
+ if (msg.type !== "message") return;
88
86
 
89
- return json({ error: "not found" }, 404);
87
+ // Broadcast the inner message to the OTHER peers (sender never gets its own).
88
+ const out = JSON.stringify({ type: "broadcast", sender: "peer", channel: ch, message: msg.message });
89
+ for (const peer of all) if (peer !== ws) peer.send(out);
90
+ },
91
+ close(ws) {
92
+ all.delete(ws);
93
+ const ch = ws.data.channel;
94
+ if (!ch) return;
95
+ const peers = channels.get(ch);
96
+ if (!peers) return;
97
+ peers.delete(ws);
98
+ for (const peer of peers) peer.send(JSON.stringify({ type: "system", message: "A user has left the channel", channel: ch }));
99
+ if (peers.size === 0) channels.delete(ch);
100
+ },
90
101
  },
91
102
  });
92
- console.log(`figma-api bridge listening on http://localhost:${port}`);
93
- console.log("Endpoints: POST /cmd GET /poll POST /result GET /health");
94
- console.log("Point the plugin at this URL (or a cloudflared tunnel of it), then run draw-* commands.");
103
+
104
+ console.log(`figma-api bridge (WebSocket) listening on ws://localhost:${port}`);
105
+ console.log(`Open a plugin, click Connect (any channel the relay bridges them), then run figma-api commands.`);
95
106
  }
96
107
 
97
- /** Setup instructions shown only when a canvas-write command can't reach the plugin. */
98
- const setup = () => `
99
- Canvas writes (create frames/text/shapes, edit variables off-Enterprise) aren't in
100
- the Figma REST API — they need the plugin bridge, which the user must install.
108
+ /** Setup instructions shown only when a canvas command can't reach the plugin. */
109
+ const setup = (port: number) => `
110
+ Canvas writes (create frames/text/shapes) aren't in the Figma REST API — they need
111
+ the plugin bridge, which the user must install.
101
112
 
102
- AGENT: be explicit with the user about this. Explain that the bridge plugin grants
103
- write access to their Figma documents, confirm they want to install it, then walk
104
- them through these steps (the plugin runs in THEIR Figma, you can't do step 2 for
105
- them):
113
+ AGENT: be explicit with the user about this. The bridge plugin grants write access
114
+ to their Figma documents confirm they want it, then walk them through (the plugin
115
+ runs in THEIR Figma, you can't do the plugin step for them):
106
116
  1. Start the relay (keep running): figma-api bridge
107
- 2. In the Figma DESKTOP app: Plugins → Development → Import plugin from manifest
108
- ${manifestPath()}
109
- Run it, paste the relay URL (default http://localhost:8917), click Connect.
117
+
118
+ 2. Install & connect a plugin — pick ONE:
119
+ EASY (recommended): install "Cursor Talk To Figma MCP" from the Figma
120
+ Community (figma.com/community/plugin/1485687494525374295), run it, set
121
+ port ${port}, click Connect. It picks its own random channel — that's fine,
122
+ the relay bridges channels so you do NOT need to copy it or pass --channel.
123
+ ADVANCED: in the Figma DESKTOP app, Plugins → Development → Import plugin
124
+ from manifest → ${manifestPath()}
125
+ run it, set URL ws://localhost:${port}, channel "${DEFAULT_CHANNEL}", Connect.
126
+
110
127
  3. Retry. Verify the round-trip with: figma-api ping
111
- Cross-machine: expose the relay via 'cloudflared tunnel --url http://localhost:8917'
112
- and paste that https URL into the plugin. Browser-only Figma can't load dev plugins.`;
113
-
114
- /** CLI helper: push a command to a running bridge and return the plugin's result. */
115
- export async function sendCommand(relay: string, op: string, params: Record<string, unknown>): Promise<void> {
116
- const res = await fetch(`${relay.replace(/\/$/, "")}/cmd`, {
117
- method: "POST",
118
- headers: { "content-type": "application/json" },
119
- body: JSON.stringify({ op, ...params }),
120
- }).catch((e) => {
121
- console.error(`Bridge relay not reachable at ${relay} (${e.message}).${setup()}`);
122
- process.exit(1);
128
+
129
+ USE THE DESKTOP APP. Both plugins can be *opened* in browser Figma, but a browser
130
+ tab can't reach ws://localhost:${port} (mixed-content: an https page blocks insecure
131
+ ws), so it never reaches "Connected". Desktop has no such block. If desktop is truly
132
+ impossible, expose the relay over TLS 'cloudflared tunnel --url http://localhost:${port}'
133
+ and paste that wss:// URL into the plugin. (Dev-manifest import is desktop-only.)`;
134
+
135
+ /** CLI peer: connect to the relay, send one command, return the plugin's result. */
136
+ export function sendCommand(
137
+ wsUrl: string,
138
+ channel: string,
139
+ command: string,
140
+ params: Record<string, unknown>,
141
+ timeoutMs = 30000,
142
+ ): Promise<unknown> {
143
+ const port = Number(new URL(wsUrl).port) || DEFAULT_PORT;
144
+ return new Promise((resolveP, rejectP) => {
145
+ const ws = new WebSocket(wsUrl);
146
+ const id = crypto.randomUUID().slice(0, 8);
147
+ let joined = false;
148
+
149
+ const timer = setTimeout(() => {
150
+ ws.close();
151
+ rejectP(new Error(`Timed out waiting for the plugin — relay is up but no plugin is Connected.${setup(port)}`));
152
+ }, timeoutMs);
153
+
154
+ ws.addEventListener("open", () => ws.send(JSON.stringify({ id, type: "join", channel })));
155
+ ws.addEventListener("error", () => {
156
+ clearTimeout(timer);
157
+ rejectP(new Error(`Bridge relay not reachable at ${wsUrl}.${setup(port)}`));
158
+ });
159
+ ws.addEventListener("message", (ev) => {
160
+ let data: any;
161
+ try { data = JSON.parse(String(ev.data)); } catch { return; }
162
+ // Wait for the structured join ack (ignore string system notices / welcome)
163
+ // before sending the command, else the relay rejects it as "not in channel".
164
+ if (data.type === "system") {
165
+ const sm = data.message;
166
+ if (!joined && sm && typeof sm === "object" && sm.result === `Connected to channel: ${channel}`) {
167
+ joined = true;
168
+ ws.send(JSON.stringify({ id, type: "message", channel, message: { id, command, params } }));
169
+ }
170
+ return;
171
+ }
172
+ // The plugin's result arrives as a "broadcast" event; data.message is the payload.
173
+ const m = data.message;
174
+ if (m && m.id === id) {
175
+ clearTimeout(timer);
176
+ ws.close();
177
+ if (m.error) rejectP(new Error(m.error));
178
+ else resolveP(m.result);
179
+ }
180
+ });
123
181
  });
124
- const data = (await (res as Response).json()) as { result?: { timeout?: boolean; error?: string } };
125
- const result = data.result;
126
- if (result?.timeout) {
127
- console.error(`Timed out waiting for the plugin — the relay is up but no plugin is Connected.${setup()}`);
182
+ }
183
+
184
+ /** Run a command and print its result; exit non-zero on failure. */
185
+ export async function runCommand(
186
+ wsUrl: string,
187
+ channel: string,
188
+ command: string,
189
+ params: Record<string, unknown>,
190
+ ): Promise<void> {
191
+ try {
192
+ const result = await sendCommand(wsUrl, channel, command, params);
193
+ console.log(JSON.stringify(result ?? null, null, 2));
194
+ } catch (e) {
195
+ console.error((e as Error).message);
128
196
  process.exit(1);
129
197
  }
130
- console.log(JSON.stringify(result ?? data, null, 2));
131
- if (result?.error) process.exit(1);
132
198
  }