@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/AGENTS.md +54 -25
- package/README.md +40 -20
- package/package.json +4 -2
- package/plugin/code.js +276 -23
- package/plugin/manifest.json +1 -1
- package/plugin/ui.html +49 -37
- package/src/bridge.ts +167 -101
- package/src/index.ts +224 -40
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">
|
|
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="
|
|
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">
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
32
|
-
const
|
|
33
|
-
$("
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 (!
|
|
61
|
-
$("stop").onclick = () => {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
console.log(
|
|
94
|
-
console.log(
|
|
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
|
|
98
|
-
const setup = () => `
|
|
99
|
-
Canvas writes (create frames/text/shapes
|
|
100
|
-
the
|
|
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.
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
}
|