@atezer/figma-mcp-bridge 1.2.0 → 1.2.2
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/CHANGELOG.md +62 -0
- package/README.md +99 -9
- package/dist/cloudflare/cloud-cors.js +40 -0
- package/dist/cloudflare/cloud-mode-kv.js +86 -0
- package/dist/cloudflare/cloud-mode-routes.js +97 -0
- package/dist/cloudflare/cloud-relay-session.js +141 -0
- package/dist/cloudflare/core/config.js +1 -1
- package/dist/cloudflare/core/figma-url.js +48 -0
- package/dist/cloudflare/core/plugin-bridge-connector.js +52 -43
- package/dist/cloudflare/core/plugin-bridge-server.js +211 -87
- package/dist/cloudflare/index.js +243 -4
- package/dist/core/config.js +1 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/figma-url.d.ts +10 -0
- package/dist/core/figma-url.d.ts.map +1 -0
- package/dist/core/figma-url.js +49 -0
- package/dist/core/figma-url.js.map +1 -0
- package/dist/core/plugin-bridge-connector.d.ts +6 -1
- package/dist/core/plugin-bridge-connector.d.ts.map +1 -1
- package/dist/core/plugin-bridge-connector.js +52 -43
- package/dist/core/plugin-bridge-connector.js.map +1 -1
- package/dist/core/plugin-bridge-server.d.ts +47 -14
- package/dist/core/plugin-bridge-server.d.ts.map +1 -1
- package/dist/core/plugin-bridge-server.js +211 -87
- package/dist/core/plugin-bridge-server.js.map +1 -1
- package/dist/local-plugin-only.d.ts.map +1 -1
- package/dist/local-plugin-only.js +163 -43
- package/dist/local-plugin-only.js.map +1 -1
- package/f-mcp-plugin/README.md +13 -5
- package/f-mcp-plugin/code.js +216 -2
- package/f-mcp-plugin/manifest.json +6 -2
- package/f-mcp-plugin/ui.html +694 -213
- package/package.json +7 -6
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plugin Bridge WebSocket Server
|
|
3
3
|
*
|
|
4
|
-
* Listens for connections from the F-MCP ATezer Bridge plugin
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Listens on a FIXED port for connections from the F-MCP ATezer Bridge plugin
|
|
5
|
+
* (no CDP needed). Supports MULTIPLE simultaneous plugin connections
|
|
6
|
+
* (e.g. Figma Desktop + FigJam browser + Figma browser — all on one port).
|
|
7
|
+
* Each connected plugin identifies itself with a fileKey; requests are routed accordingly.
|
|
8
|
+
*
|
|
9
|
+
* Port strategy: no auto-scanning. If the configured port is busy, the server
|
|
10
|
+
* probes it to distinguish a live F-MCP instance from a stale/dead process.
|
|
11
|
+
* Stale ports get one automatic retry after a short delay.
|
|
7
12
|
*/
|
|
8
13
|
import { WebSocketServer } from "ws";
|
|
9
14
|
import { createServer, get as httpGet } from "http";
|
|
@@ -12,41 +17,84 @@ import { auditTool, auditPlugin } from "./audit-log.js";
|
|
|
12
17
|
const HEARTBEAT_INTERVAL_MS = 3000;
|
|
13
18
|
const MIN_PORT = 5454;
|
|
14
19
|
const MAX_PORT = 5470;
|
|
20
|
+
const STALE_PORT_RETRY_DELAY_MS = 1500;
|
|
15
21
|
export class PluginBridgeServer {
|
|
16
22
|
constructor(port, options) {
|
|
17
23
|
this.wss = null;
|
|
18
24
|
this.httpServer = null;
|
|
19
|
-
this.
|
|
20
|
-
this.clientAlive = false;
|
|
21
|
-
this.missedHeartbeats = 0;
|
|
25
|
+
this.clients = new Map();
|
|
22
26
|
this.pending = new Map();
|
|
23
27
|
this.requestTimeoutMs = 120000;
|
|
24
28
|
this.heartbeatTimer = null;
|
|
25
|
-
this.
|
|
29
|
+
this.clientIdCounter = 0;
|
|
30
|
+
const clamped = Math.max(MIN_PORT, Math.min(MAX_PORT, port));
|
|
31
|
+
this.preferredPort = clamped;
|
|
32
|
+
this.port = clamped;
|
|
26
33
|
this.auditLogPath = options?.auditLogPath;
|
|
27
34
|
}
|
|
28
|
-
/**
|
|
29
|
-
* Start the WebSocket server on the configured port. Fails loudly if port is in use. Idempotent.
|
|
30
|
-
*/
|
|
31
35
|
start() {
|
|
32
36
|
if (this.wss) {
|
|
33
37
|
logger.debug({ port: this.port }, "Plugin bridge server already running");
|
|
34
38
|
return;
|
|
35
39
|
}
|
|
36
|
-
this.
|
|
40
|
+
this.tryListenFixed(this.preferredPort, false);
|
|
41
|
+
}
|
|
42
|
+
generateClientId() {
|
|
43
|
+
return `client_${Date.now()}_${++this.clientIdCounter}`;
|
|
44
|
+
}
|
|
45
|
+
findClientByFileKey(fileKey) {
|
|
46
|
+
for (const client of this.clients.values()) {
|
|
47
|
+
if (client.fileKey === fileKey && client.ws.readyState === 1) {
|
|
48
|
+
return client;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
getDefaultClient() {
|
|
54
|
+
let latest;
|
|
55
|
+
for (const client of this.clients.values()) {
|
|
56
|
+
if (client.ws.readyState !== 1)
|
|
57
|
+
continue;
|
|
58
|
+
if (!latest || client.connectedAt > latest.connectedAt) {
|
|
59
|
+
latest = client;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return latest;
|
|
63
|
+
}
|
|
64
|
+
resolveClient(fileKey) {
|
|
65
|
+
if (fileKey) {
|
|
66
|
+
return this.findClientByFileKey(fileKey) ?? this.getDefaultClient();
|
|
67
|
+
}
|
|
68
|
+
return this.getDefaultClient();
|
|
37
69
|
}
|
|
38
|
-
|
|
70
|
+
removeClient(clientId, reason) {
|
|
71
|
+
const info = this.clients.get(clientId);
|
|
72
|
+
if (!info)
|
|
73
|
+
return;
|
|
74
|
+
this.clients.delete(clientId);
|
|
75
|
+
this.rejectPendingForClient(clientId, reason);
|
|
76
|
+
auditPlugin(this.auditLogPath, "plugin_disconnect");
|
|
77
|
+
logger.info({ clientId, fileKey: info.fileKey, fileName: info.fileName }, "Plugin bridge: client disconnected (%s)", reason);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Probe a port via HTTP to determine if a live F-MCP bridge is already
|
|
81
|
+
* running or if the port is held by a stale/dead process.
|
|
82
|
+
* Returns "fmcp" | "other" | "dead".
|
|
83
|
+
*/
|
|
84
|
+
probePort(port, host) {
|
|
39
85
|
return new Promise((resolve) => {
|
|
40
|
-
const req = httpGet(
|
|
86
|
+
const req = httpGet({ hostname: host, port, path: "/", timeout: 2000 }, (res) => {
|
|
41
87
|
let body = "";
|
|
42
|
-
res.on("data", (
|
|
43
|
-
res.on("end", () =>
|
|
88
|
+
res.on("data", (chunk) => { body += chunk; });
|
|
89
|
+
res.on("end", () => {
|
|
90
|
+
resolve(body.includes("F-MCP") ? "fmcp" : "other");
|
|
91
|
+
});
|
|
44
92
|
});
|
|
45
|
-
req.on("error", () => resolve(
|
|
46
|
-
req.
|
|
93
|
+
req.on("error", () => resolve("dead"));
|
|
94
|
+
req.on("timeout", () => { req.destroy(); resolve("dead"); });
|
|
47
95
|
});
|
|
48
96
|
}
|
|
49
|
-
|
|
97
|
+
tryListenFixed(port, isRetry) {
|
|
50
98
|
const server = createServer((_req, res) => {
|
|
51
99
|
res.writeHead(200, {
|
|
52
100
|
"Content-Type": "text/plain",
|
|
@@ -55,53 +103,90 @@ export class PluginBridgeServer {
|
|
|
55
103
|
});
|
|
56
104
|
res.end("F-MCP ATezer Bridge (connect via WebSocket)\n");
|
|
57
105
|
});
|
|
106
|
+
const bindHost = process.env.FIGMA_BRIDGE_HOST || "127.0.0.1";
|
|
58
107
|
server.on("error", (err) => {
|
|
59
108
|
if (err.code === "EADDRINUSE") {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
109
|
+
server.close();
|
|
110
|
+
if (isRetry) {
|
|
111
|
+
console.error(`\n❌ Port ${port} is still busy after retry.\n` +
|
|
112
|
+
` A process may be holding this port. Find and stop it:\n` +
|
|
113
|
+
` lsof -i :${port} (macOS/Linux)\n` +
|
|
114
|
+
` Then restart, or set FIGMA_PLUGIN_BRIDGE_PORT to a different value (${MIN_PORT}–${MAX_PORT}).\n`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const probeHost = bindHost === "0.0.0.0" ? "127.0.0.1" : bindHost;
|
|
119
|
+
this.probePort(port, probeHost).then((status) => {
|
|
120
|
+
if (status === "fmcp") {
|
|
121
|
+
console.error(`\n⚠️ Port ${port} is already in use by another F-MCP bridge instance.\n` +
|
|
122
|
+
` One bridge is enough for all Figma/FigJam windows.\n` +
|
|
123
|
+
` If you need a separate session, set FIGMA_PLUGIN_BRIDGE_PORT to a different value (${MIN_PORT}–${MAX_PORT}).\n`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
else if (status === "dead") {
|
|
127
|
+
console.error(`\n⚠️ Port ${port} is busy but not responding (stale process).\n` +
|
|
128
|
+
` Retrying in ${STALE_PORT_RETRY_DELAY_MS}ms…\n`);
|
|
129
|
+
setTimeout(() => this.tryListenFixed(port, true), STALE_PORT_RETRY_DELAY_MS);
|
|
70
130
|
}
|
|
71
131
|
else {
|
|
72
|
-
console.error(`\n❌ Port ${port} is
|
|
73
|
-
`
|
|
74
|
-
|
|
132
|
+
console.error(`\n❌ Port ${port} is in use by a different service (not F-MCP).\n` +
|
|
133
|
+
` Free the port or set FIGMA_PLUGIN_BRIDGE_PORT to a different value (${MIN_PORT}–${MAX_PORT}).\n`);
|
|
134
|
+
process.exit(1);
|
|
75
135
|
}
|
|
76
|
-
process.exit(1);
|
|
77
136
|
});
|
|
78
|
-
server.close();
|
|
79
137
|
return;
|
|
80
138
|
}
|
|
81
139
|
logger.error({ err }, "Plugin bridge server error");
|
|
82
140
|
});
|
|
83
|
-
const bindHost = process.env.FIGMA_BRIDGE_HOST || "127.0.0.1";
|
|
84
141
|
server.listen(port, bindHost, () => {
|
|
85
142
|
this.port = port;
|
|
143
|
+
process.env.FIGMA_PLUGIN_BRIDGE_PORT = String(port);
|
|
144
|
+
process.env.FIGMA_MCP_BRIDGE_PORT = String(port);
|
|
145
|
+
console.error(`F-MCP bridge listening on ws://${bindHost}:${port}\n`);
|
|
86
146
|
this.httpServer = server;
|
|
87
147
|
this.wss = new WebSocketServer({ server });
|
|
88
148
|
this.wss.on("connection", (ws) => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
149
|
+
const clientId = this.generateClientId();
|
|
150
|
+
const clientInfo = {
|
|
151
|
+
ws,
|
|
152
|
+
clientId,
|
|
153
|
+
fileKey: null,
|
|
154
|
+
fileName: null,
|
|
155
|
+
alive: true,
|
|
156
|
+
missedHeartbeats: 0,
|
|
157
|
+
connectedAt: Date.now(),
|
|
158
|
+
};
|
|
159
|
+
this.clients.set(clientId, clientInfo);
|
|
160
|
+
logger.info({ port: this.port, clientId, totalClients: this.clients.size }, "Plugin bridge: new plugin connected");
|
|
97
161
|
auditPlugin(this.auditLogPath, "plugin_connect");
|
|
98
162
|
ws.on("message", (data) => {
|
|
99
|
-
|
|
163
|
+
clientInfo.alive = true;
|
|
100
164
|
try {
|
|
101
165
|
const msg = JSON.parse(data.toString());
|
|
102
166
|
if (msg.type === "ready") {
|
|
103
|
-
|
|
104
|
-
|
|
167
|
+
const incomingFileKey = msg.fileKey || null;
|
|
168
|
+
const incomingFileName = msg.fileName || null;
|
|
169
|
+
if (incomingFileKey) {
|
|
170
|
+
const existing = this.findClientByFileKey(incomingFileKey);
|
|
171
|
+
if (existing && existing.clientId !== clientId) {
|
|
172
|
+
logger.info({ oldClientId: existing.clientId, newClientId: clientId, fileKey: incomingFileKey }, "Plugin bridge: replacing existing client for same fileKey");
|
|
173
|
+
this.removeClient(existing.clientId, "Replaced by new connection for same file");
|
|
174
|
+
try {
|
|
175
|
+
existing.ws.close();
|
|
176
|
+
}
|
|
177
|
+
catch { /* ignore */ }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
clientInfo.fileKey = incomingFileKey;
|
|
181
|
+
clientInfo.fileName = incomingFileName;
|
|
182
|
+
logger.info({ clientId, fileKey: incomingFileKey, fileName: incomingFileName }, "Plugin bridge: client registered (fileKey=%s, fileName=%s)", incomingFileKey, incomingFileName);
|
|
183
|
+
ws.send(JSON.stringify({
|
|
184
|
+
type: "welcome",
|
|
185
|
+
bridgeVersion: "1.1.0",
|
|
186
|
+
port: this.port,
|
|
187
|
+
clientId,
|
|
188
|
+
multiClient: true,
|
|
189
|
+
}));
|
|
105
190
|
return;
|
|
106
191
|
}
|
|
107
192
|
if (msg.type === "pong" || msg.type === "keepalive") {
|
|
@@ -127,42 +212,37 @@ export class PluginBridgeServer {
|
|
|
127
212
|
}
|
|
128
213
|
});
|
|
129
214
|
ws.on("close", () => {
|
|
130
|
-
|
|
131
|
-
this.client = null;
|
|
132
|
-
this.clientAlive = false;
|
|
133
|
-
this.clearHeartbeatTimers();
|
|
134
|
-
this.rejectAllPending("Plugin disconnected");
|
|
135
|
-
auditPlugin(this.auditLogPath, "plugin_disconnect");
|
|
136
|
-
logger.info("Plugin bridge: plugin disconnected");
|
|
137
|
-
}
|
|
215
|
+
this.removeClient(clientId, "WebSocket closed");
|
|
138
216
|
});
|
|
139
217
|
ws.on("error", (err) => {
|
|
140
|
-
logger.warn({ err }, "Plugin bridge: client error");
|
|
218
|
+
logger.warn({ err, clientId }, "Plugin bridge: client error");
|
|
141
219
|
});
|
|
142
220
|
});
|
|
143
|
-
logger.info({ port: this.port, host: bindHost }, "Plugin bridge server listening (ws://%s:%s)", bindHost, this.port);
|
|
221
|
+
logger.info({ port: this.port, host: bindHost }, "Plugin bridge server listening (ws://%s:%s) — multi-client enabled", bindHost, this.port);
|
|
144
222
|
this.heartbeatTimer = setInterval(() => {
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
this.
|
|
148
|
-
|
|
149
|
-
|
|
223
|
+
for (const [clientId, info] of this.clients) {
|
|
224
|
+
if (info.ws.readyState !== 1) {
|
|
225
|
+
this.removeClient(clientId, "WebSocket not open");
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (!info.alive) {
|
|
229
|
+
info.missedHeartbeats++;
|
|
230
|
+
if (info.missedHeartbeats >= 3) {
|
|
231
|
+
logger.warn({ clientId, fileKey: info.fileKey }, "Plugin bridge: client not responding to heartbeat, terminating");
|
|
150
232
|
try {
|
|
151
|
-
|
|
233
|
+
info.ws.terminate();
|
|
152
234
|
}
|
|
153
235
|
catch { /* ignore */ }
|
|
154
|
-
this.
|
|
155
|
-
|
|
156
|
-
this.missedHeartbeats = 0;
|
|
157
|
-
return;
|
|
236
|
+
this.removeClient(clientId, "Heartbeat timeout");
|
|
237
|
+
continue;
|
|
158
238
|
}
|
|
159
239
|
}
|
|
160
240
|
else {
|
|
161
|
-
|
|
162
|
-
|
|
241
|
+
info.missedHeartbeats = 0;
|
|
242
|
+
info.alive = false;
|
|
163
243
|
}
|
|
164
244
|
try {
|
|
165
|
-
|
|
245
|
+
info.ws.send(JSON.stringify({ type: "ping" }));
|
|
166
246
|
}
|
|
167
247
|
catch { /* ignore */ }
|
|
168
248
|
}
|
|
@@ -170,10 +250,21 @@ export class PluginBridgeServer {
|
|
|
170
250
|
});
|
|
171
251
|
}
|
|
172
252
|
/**
|
|
173
|
-
* Send a request to
|
|
253
|
+
* Send a request to a plugin and wait for the response.
|
|
254
|
+
* If fileKey is specified, routes to the client serving that file.
|
|
255
|
+
* Otherwise routes to the most recently connected client.
|
|
174
256
|
*/
|
|
175
|
-
async request(method, params) {
|
|
176
|
-
|
|
257
|
+
async request(method, params, fileKey) {
|
|
258
|
+
const client = this.resolveClient(fileKey);
|
|
259
|
+
if (!client || client.ws.readyState !== 1) {
|
|
260
|
+
if (fileKey) {
|
|
261
|
+
const available = this.listConnectedFiles();
|
|
262
|
+
const fileList = available.length > 0
|
|
263
|
+
? ` Connected files: ${available.map(f => `${f.fileName || "?"} (${f.fileKey || "?"})`).join(", ")}`
|
|
264
|
+
: "";
|
|
265
|
+
throw new Error(`No plugin connected for fileKey "${fileKey}".${fileList} ` +
|
|
266
|
+
"Open the target file in Figma and run the F-MCP ATezer Bridge plugin.");
|
|
267
|
+
}
|
|
177
268
|
throw new Error("F-MCP ATezer Bridge plugin not connected. Open Figma, run the F-MCP ATezer Bridge plugin, and ensure it shows 'Bridge active' (no debug port needed).");
|
|
178
269
|
}
|
|
179
270
|
const id = `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
@@ -192,9 +283,10 @@ export class PluginBridgeServer {
|
|
|
192
283
|
timeout,
|
|
193
284
|
method,
|
|
194
285
|
startTime,
|
|
286
|
+
clientId: client.clientId,
|
|
195
287
|
});
|
|
196
288
|
try {
|
|
197
|
-
|
|
289
|
+
client.ws.send(JSON.stringify(req));
|
|
198
290
|
}
|
|
199
291
|
catch (err) {
|
|
200
292
|
this.pending.delete(id);
|
|
@@ -204,11 +296,49 @@ export class PluginBridgeServer {
|
|
|
204
296
|
}
|
|
205
297
|
});
|
|
206
298
|
}
|
|
207
|
-
isConnected() {
|
|
208
|
-
|
|
299
|
+
isConnected(fileKey) {
|
|
300
|
+
if (fileKey) {
|
|
301
|
+
const client = this.findClientByFileKey(fileKey);
|
|
302
|
+
return !!client && client.ws.readyState === 1;
|
|
303
|
+
}
|
|
304
|
+
for (const client of this.clients.values()) {
|
|
305
|
+
if (client.ws.readyState === 1)
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
return false;
|
|
209
309
|
}
|
|
210
|
-
|
|
211
|
-
|
|
310
|
+
listConnectedFiles() {
|
|
311
|
+
const result = [];
|
|
312
|
+
for (const client of this.clients.values()) {
|
|
313
|
+
if (client.ws.readyState === 1) {
|
|
314
|
+
result.push({
|
|
315
|
+
clientId: client.clientId,
|
|
316
|
+
fileKey: client.fileKey,
|
|
317
|
+
fileName: client.fileName,
|
|
318
|
+
connectedAt: client.connectedAt,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return result.sort((a, b) => b.connectedAt - a.connectedAt);
|
|
323
|
+
}
|
|
324
|
+
connectedClientCount() {
|
|
325
|
+
let count = 0;
|
|
326
|
+
for (const client of this.clients.values()) {
|
|
327
|
+
if (client.ws.readyState === 1)
|
|
328
|
+
count++;
|
|
329
|
+
}
|
|
330
|
+
return count;
|
|
331
|
+
}
|
|
332
|
+
rejectPendingForClient(clientId, reason) {
|
|
333
|
+
for (const [id, p] of this.pending) {
|
|
334
|
+
if (p.clientId === clientId) {
|
|
335
|
+
clearTimeout(p.timeout);
|
|
336
|
+
const durationMs = Date.now() - p.startTime;
|
|
337
|
+
auditTool(this.auditLogPath, p.method, false, reason, durationMs);
|
|
338
|
+
p.reject(new Error(`Plugin bridge request '${p.method}' failed: ${reason}`));
|
|
339
|
+
this.pending.delete(id);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
212
342
|
}
|
|
213
343
|
rejectAllPending(reason) {
|
|
214
344
|
for (const [id, p] of this.pending) {
|
|
@@ -224,20 +354,14 @@ export class PluginBridgeServer {
|
|
|
224
354
|
clearInterval(this.heartbeatTimer);
|
|
225
355
|
this.heartbeatTimer = null;
|
|
226
356
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
p.reject(new Error("Plugin bridge server stopped"));
|
|
230
|
-
}
|
|
231
|
-
this.pending.clear();
|
|
232
|
-
if (this.client) {
|
|
357
|
+
this.rejectAllPending("Plugin bridge server stopped");
|
|
358
|
+
for (const client of this.clients.values()) {
|
|
233
359
|
try {
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
catch {
|
|
237
|
-
// ignore
|
|
360
|
+
client.ws.close();
|
|
238
361
|
}
|
|
239
|
-
|
|
362
|
+
catch { /* ignore */ }
|
|
240
363
|
}
|
|
364
|
+
this.clients.clear();
|
|
241
365
|
if (this.wss) {
|
|
242
366
|
this.wss.close();
|
|
243
367
|
this.wss = null;
|