@atezer/figma-mcp-bridge 1.7.28 → 1.7.30
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 +75 -0
- package/README.md +1 -1
- package/dist/core/plugin-bridge-connector.d.ts +6 -0
- package/dist/core/plugin-bridge-connector.d.ts.map +1 -1
- package/dist/core/plugin-bridge-connector.js +13 -0
- package/dist/core/plugin-bridge-connector.js.map +1 -1
- package/dist/core/plugin-bridge-server.d.ts +51 -4
- package/dist/core/plugin-bridge-server.d.ts.map +1 -1
- package/dist/core/plugin-bridge-server.js +389 -143
- package/dist/core/plugin-bridge-server.js.map +1 -1
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/dist/local-plugin-only.d.ts.map +1 -1
- package/dist/local-plugin-only.js +232 -36
- package/dist/local-plugin-only.js.map +1 -1
- package/package.json +1 -1
- package/skills/figma-canvas-ops/SKILL.md +7 -4
- package/skills/fmcp-project-rules/SKILL.md +9 -5
- package/skills/generate-figma-screen/SKILL.md +24 -15
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
* (e.g. Figma Desktop + FigJam browser + Figma browser — all on one port).
|
|
7
7
|
* Each connected plugin identifies itself with a fileKey; requests are routed accordingly.
|
|
8
8
|
*
|
|
9
|
-
* Port strategy:
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Port strategy: smart auto-increment with coexistence.
|
|
10
|
+
* - If the preferred port (default 5454) is occupied by a HEALTHY F-MCP bridge
|
|
11
|
+
* (active clients), the server skips to the next port (5455, 5456, …).
|
|
12
|
+
* - If the port is occupied by a STALE F-MCP bridge (0 clients, uptime ≥ 30s),
|
|
13
|
+
* the server sends a /shutdown request and takes over.
|
|
14
|
+
* - If the port is occupied by a non-F-MCP service or unresponsive process,
|
|
15
|
+
* the server skips to the next port.
|
|
16
|
+
* - The Figma plugin scans all ports 5454–5470 automatically.
|
|
12
17
|
*/
|
|
13
18
|
import { WebSocketServer } from "ws";
|
|
14
19
|
import { createServer, get as httpGet, request as httpRequest } from "http";
|
|
@@ -21,6 +26,8 @@ const MIN_PORT = 5454;
|
|
|
21
26
|
const MAX_PORT = 5470;
|
|
22
27
|
const STALE_PORT_RETRY_DELAY_MS = 1500;
|
|
23
28
|
const SHUTDOWN_TAKEOVER_DELAY_MS = 2000;
|
|
29
|
+
/** Bridges with 0 clients AND uptime below this are considered freshly started, not stale. */
|
|
30
|
+
const FRESH_BRIDGE_UPTIME_THRESHOLD_S = 30;
|
|
24
31
|
export class PluginBridgeServer {
|
|
25
32
|
constructor(port, options) {
|
|
26
33
|
this.wss = null;
|
|
@@ -86,7 +93,7 @@ export class PluginBridgeServer {
|
|
|
86
93
|
}
|
|
87
94
|
this.startError = null;
|
|
88
95
|
this.detectClientNameAsync();
|
|
89
|
-
this.
|
|
96
|
+
this.tryListenWithAutoIncrement(this.preferredPort);
|
|
90
97
|
}
|
|
91
98
|
/** Get last startup error (null if running fine). */
|
|
92
99
|
getStartError() {
|
|
@@ -101,15 +108,16 @@ export class PluginBridgeServer {
|
|
|
101
108
|
this.startError = null;
|
|
102
109
|
return this.tryListenAsync(clamped);
|
|
103
110
|
}
|
|
104
|
-
/** Async listen attempt — resolves when port binds successfully or
|
|
111
|
+
/** Async listen attempt — resolves when port binds successfully or all ports exhausted. */
|
|
105
112
|
tryListenAsync(port) {
|
|
106
113
|
return new Promise((resolve) => {
|
|
107
|
-
|
|
114
|
+
// 30s timeout covers worst case: 17 ports × ~2s probe each
|
|
115
|
+
const TIMEOUT_MS = 30000;
|
|
108
116
|
const timer = setTimeout(() => {
|
|
109
117
|
this._listenResolve = null;
|
|
110
|
-
resolve({ success: false, port, error: this.startError || `Port
|
|
118
|
+
resolve({ success: false, port, error: this.startError || `Port bind timeout (${TIMEOUT_MS}ms)` });
|
|
111
119
|
}, TIMEOUT_MS);
|
|
112
|
-
// Store
|
|
120
|
+
// Store callback so tryListenWithAutoIncrement/setupBridgeOnServer can notify us
|
|
113
121
|
this._listenResolve = (success) => {
|
|
114
122
|
clearTimeout(timer);
|
|
115
123
|
if (success) {
|
|
@@ -119,13 +127,17 @@ export class PluginBridgeServer {
|
|
|
119
127
|
resolve({ success: false, port, error: this.startError || "Port bind failed" });
|
|
120
128
|
}
|
|
121
129
|
};
|
|
122
|
-
this.
|
|
130
|
+
this.tryListenWithAutoIncrement(port);
|
|
123
131
|
});
|
|
124
132
|
}
|
|
125
133
|
/** Currently listening port (or preferred port if not yet listening). */
|
|
126
134
|
getPort() {
|
|
127
135
|
return this.port;
|
|
128
136
|
}
|
|
137
|
+
/** User/config preferred port before auto-increment fallback. */
|
|
138
|
+
getPreferredPort() {
|
|
139
|
+
return this.preferredPort;
|
|
140
|
+
}
|
|
129
141
|
/** Whether WebSocket server is actively listening. */
|
|
130
142
|
isListening() {
|
|
131
143
|
return this.wss !== null;
|
|
@@ -158,6 +170,23 @@ export class PluginBridgeServer {
|
|
|
158
170
|
}
|
|
159
171
|
return this.getDefaultClient();
|
|
160
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Wait for a client to become ready (fileKey populated via "ready" message).
|
|
175
|
+
* Polls at 200ms intervals. Used to handle the race between plugin connection
|
|
176
|
+
* and the first incoming MCP request.
|
|
177
|
+
*/
|
|
178
|
+
async waitForClient(fileKey, timeoutMs = 2000) {
|
|
179
|
+
const deadline = Date.now() + timeoutMs;
|
|
180
|
+
while (Date.now() < deadline) {
|
|
181
|
+
const client = this.resolveClient(fileKey);
|
|
182
|
+
if (client && client.ws.readyState === 1) {
|
|
183
|
+
if (!fileKey || client.fileKey === fileKey)
|
|
184
|
+
return client;
|
|
185
|
+
}
|
|
186
|
+
await new Promise(r => setTimeout(r, 200));
|
|
187
|
+
}
|
|
188
|
+
return this.resolveClient(fileKey);
|
|
189
|
+
}
|
|
161
190
|
removeClient(clientId, reason) {
|
|
162
191
|
const info = this.clients.get(clientId);
|
|
163
192
|
if (!info)
|
|
@@ -185,9 +214,71 @@ export class PluginBridgeServer {
|
|
|
185
214
|
req.on("timeout", () => { req.destroy(); resolve("dead"); });
|
|
186
215
|
});
|
|
187
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Probe a live F-MCP bridge's /status endpoint to get its health info.
|
|
219
|
+
* Returns { clients, uptime } or { -1, -1 } if the endpoint is unavailable
|
|
220
|
+
* (e.g. older bridge version without /status).
|
|
221
|
+
*/
|
|
222
|
+
probeStatus(port, host) {
|
|
223
|
+
return new Promise((resolve) => {
|
|
224
|
+
const req = httpGet({ hostname: host, port, path: "/status", timeout: 1000 }, (res) => {
|
|
225
|
+
let body = "";
|
|
226
|
+
res.on("data", (chunk) => { body += chunk; });
|
|
227
|
+
res.on("end", () => {
|
|
228
|
+
try {
|
|
229
|
+
const data = JSON.parse(body);
|
|
230
|
+
resolve({
|
|
231
|
+
clients: typeof data.clients === "number" ? data.clients : -1,
|
|
232
|
+
uptime: typeof data.uptime === "number" ? data.uptime : -1,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
resolve({ clients: -1, uptime: -1 });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
req.on("error", () => resolve({ clients: -1, uptime: -1 }));
|
|
241
|
+
req.on("timeout", () => { req.destroy(); resolve({ clients: -1, uptime: -1 }); });
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Send a POST /shutdown to an old F-MCP bridge. Calls onAccepted if the bridge
|
|
246
|
+
* responds with 200, or onRefused otherwise. On error/timeout, assumes the bridge
|
|
247
|
+
* may have already exited and calls onAccepted.
|
|
248
|
+
*/
|
|
249
|
+
sendShutdownRequest(port, host, onAccepted, onRefused) {
|
|
250
|
+
console.error(` Sending shutdown request to old F-MCP bridge on port ${port}…\n`);
|
|
251
|
+
const req = httpRequest({ hostname: host, port, path: "/shutdown", method: "POST", timeout: 3000 }, (res) => {
|
|
252
|
+
let body = "";
|
|
253
|
+
res.on("data", (chunk) => { body += chunk; });
|
|
254
|
+
res.on("end", () => {
|
|
255
|
+
if (res.statusCode === 200) {
|
|
256
|
+
console.error(` Old bridge accepted shutdown. Retaking port ${port} in ${SHUTDOWN_TAKEOVER_DELAY_MS}ms…\n`);
|
|
257
|
+
onAccepted();
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
console.error(`\n⚠️ Old bridge refused shutdown (status ${res.statusCode}). Trying next port…\n`);
|
|
261
|
+
logger.warn({ port, statusCode: res.statusCode }, "Old bridge refused shutdown");
|
|
262
|
+
onRefused();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
req.on("error", () => {
|
|
267
|
+
// Old bridge unreachable — might have already exited
|
|
268
|
+
console.error(` Old bridge unreachable after shutdown request. Retrying port ${port}…\n`);
|
|
269
|
+
onAccepted();
|
|
270
|
+
});
|
|
271
|
+
req.on("timeout", () => {
|
|
272
|
+
req.destroy();
|
|
273
|
+
console.error(` Shutdown request timed out. Retrying port ${port}…\n`);
|
|
274
|
+
onAccepted();
|
|
275
|
+
});
|
|
276
|
+
req.end();
|
|
277
|
+
}
|
|
188
278
|
/**
|
|
189
279
|
* Send a POST /shutdown to an old F-MCP bridge on the given port,
|
|
190
280
|
* wait for it to exit, then retry binding to the same port.
|
|
281
|
+
* @deprecated Legacy method — kept for backward compatibility. New code uses sendShutdownRequest + tryListenWithAutoIncrement.
|
|
191
282
|
*/
|
|
192
283
|
requestShutdownAndRetry(port, host) {
|
|
193
284
|
console.error(` Sending shutdown request to old F-MCP bridge on port ${port}…\n`);
|
|
@@ -223,18 +314,36 @@ export class PluginBridgeServer {
|
|
|
223
314
|
});
|
|
224
315
|
req.end();
|
|
225
316
|
}
|
|
226
|
-
|
|
227
|
-
|
|
317
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
318
|
+
// HTTP server factory (shared between tryListenFixed and tryListenWithAutoIncrement)
|
|
319
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
320
|
+
/** Create an HTTP server with /shutdown, /status, and default F-MCP marker endpoints. */
|
|
321
|
+
createBridgeHttpServer() {
|
|
322
|
+
return createServer((req, res) => {
|
|
228
323
|
// Graceful shutdown endpoint: a new bridge instance requests this old one to exit
|
|
229
324
|
if (req.method === "POST" && req.url === "/shutdown") {
|
|
230
325
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
231
326
|
res.end("shutting down\n");
|
|
232
327
|
logger.info("Received /shutdown request from new bridge instance — stopping gracefully");
|
|
233
328
|
console.error("\n⚠️ Received shutdown request from new F-MCP bridge instance. Stopping…\n");
|
|
234
|
-
// Defer stop to let the response flush
|
|
235
329
|
setTimeout(() => this.stop(), 500);
|
|
236
330
|
return;
|
|
237
331
|
}
|
|
332
|
+
// Health check endpoint — used by new instances to decide coexistence vs takeover
|
|
333
|
+
if (req.method === "GET" && req.url === "/status") {
|
|
334
|
+
const body = JSON.stringify({
|
|
335
|
+
clients: this.connectedClientCount(),
|
|
336
|
+
uptime: Math.round(process.uptime()),
|
|
337
|
+
version: FMCP_VERSION,
|
|
338
|
+
});
|
|
339
|
+
res.writeHead(200, {
|
|
340
|
+
"Content-Type": "application/json",
|
|
341
|
+
"Access-Control-Allow-Origin": "*",
|
|
342
|
+
});
|
|
343
|
+
res.end(body);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// Default F-MCP marker (used by probePort to detect F-MCP bridges)
|
|
238
347
|
res.writeHead(200, {
|
|
239
348
|
"Content-Type": "text/plain",
|
|
240
349
|
"Access-Control-Allow-Origin": "*",
|
|
@@ -242,6 +351,268 @@ export class PluginBridgeServer {
|
|
|
242
351
|
});
|
|
243
352
|
res.end("F-MCP ATezer Bridge (connect via WebSocket)\n");
|
|
244
353
|
});
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Set up WebSocket server, heartbeat, client handling on a successfully bound HTTP server.
|
|
357
|
+
* Called from both tryListenFixed and tryListenWithAutoIncrement on bind success.
|
|
358
|
+
*/
|
|
359
|
+
setupBridgeOnServer(server, port, bindHost) {
|
|
360
|
+
this.port = port;
|
|
361
|
+
process.env.FIGMA_PLUGIN_BRIDGE_PORT = String(port);
|
|
362
|
+
process.env.FIGMA_MCP_BRIDGE_PORT = String(port);
|
|
363
|
+
console.error(`F-MCP bridge listening on ws://${bindHost}:${port}\n`);
|
|
364
|
+
this.httpServer = server;
|
|
365
|
+
this.wss = new WebSocketServer({ server });
|
|
366
|
+
this.wss.on("connection", (ws) => {
|
|
367
|
+
const clientId = this.generateClientId();
|
|
368
|
+
const clientInfo = {
|
|
369
|
+
ws,
|
|
370
|
+
clientId,
|
|
371
|
+
fileKey: null,
|
|
372
|
+
fileName: null,
|
|
373
|
+
alive: true,
|
|
374
|
+
missedHeartbeats: 0,
|
|
375
|
+
connectedAt: Date.now(),
|
|
376
|
+
};
|
|
377
|
+
this.clients.set(clientId, clientInfo);
|
|
378
|
+
logger.info({ port: this.port, clientId, totalClients: this.clients.size }, "Plugin bridge: new plugin connected");
|
|
379
|
+
auditPlugin(this.auditLogPath, "plugin_connect");
|
|
380
|
+
ws.on("message", (data) => {
|
|
381
|
+
clientInfo.alive = true;
|
|
382
|
+
try {
|
|
383
|
+
const msg = JSON.parse(data.toString());
|
|
384
|
+
if (msg.type === "ready") {
|
|
385
|
+
const incomingFileKey = msg.fileKey || null;
|
|
386
|
+
const incomingFileName = msg.fileName || null;
|
|
387
|
+
if (incomingFileKey) {
|
|
388
|
+
const existing = this.findClientByFileKey(incomingFileKey);
|
|
389
|
+
if (existing && existing.clientId !== clientId) {
|
|
390
|
+
logger.info({ oldClientId: existing.clientId, newClientId: clientId, fileKey: incomingFileKey }, "Plugin bridge: replacing existing client for same fileKey");
|
|
391
|
+
this.removeClient(existing.clientId, "Replaced by new connection for same file");
|
|
392
|
+
try {
|
|
393
|
+
existing.ws.close();
|
|
394
|
+
}
|
|
395
|
+
catch { /* ignore */ }
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
clientInfo.fileKey = incomingFileKey;
|
|
399
|
+
clientInfo.fileName = incomingFileName;
|
|
400
|
+
logger.info({ clientId, fileKey: incomingFileKey, fileName: incomingFileName }, "Plugin bridge: client registered (fileKey=%s, fileName=%s)", incomingFileKey, incomingFileName);
|
|
401
|
+
ws.send(JSON.stringify({
|
|
402
|
+
type: "welcome",
|
|
403
|
+
bridgeVersion: FMCP_VERSION,
|
|
404
|
+
port: this.port,
|
|
405
|
+
clientId,
|
|
406
|
+
multiClient: true,
|
|
407
|
+
clientName: this.clientName,
|
|
408
|
+
}));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (msg.type === "pong" || msg.type === "keepalive") {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (msg.type === "setToken" && typeof msg.token === "string") {
|
|
415
|
+
const token = msg.token;
|
|
416
|
+
if (token) {
|
|
417
|
+
this.setFigmaRestToken(token);
|
|
418
|
+
logger.info({ clientId }, "Plugin bridge: REST API token set via plugin UI");
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (msg.type === "clearToken") {
|
|
423
|
+
this.clearFigmaRestToken();
|
|
424
|
+
logger.info({ clientId }, "Plugin bridge: REST API token cleared via plugin UI");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (msg.id && this.pending.has(msg.id)) {
|
|
428
|
+
const p = this.pending.get(msg.id);
|
|
429
|
+
this.pending.delete(msg.id);
|
|
430
|
+
clearTimeout(p.timeout);
|
|
431
|
+
const durationMs = Date.now() - p.startTime;
|
|
432
|
+
if (msg.error) {
|
|
433
|
+
auditTool(this.auditLogPath, p.method, false, msg.error, durationMs);
|
|
434
|
+
p.reject(new Error(msg.error));
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
if (msg.result === undefined) {
|
|
438
|
+
logger.warn({ method: p.method, msgId: msg.id }, "Plugin bridge: response has no result and no error");
|
|
439
|
+
}
|
|
440
|
+
auditTool(this.auditLogPath, p.method, true, undefined, durationMs);
|
|
441
|
+
p.resolve(msg.result);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
logger.warn({ err }, "Plugin bridge: invalid message from plugin");
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
ws.on("close", () => {
|
|
450
|
+
this.removeClient(clientId, "WebSocket closed");
|
|
451
|
+
});
|
|
452
|
+
ws.on("error", (err) => {
|
|
453
|
+
logger.warn({ err, clientId }, "Plugin bridge: client error");
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
logger.info({ port: this.port, host: bindHost }, "Plugin bridge server listening (ws://%s:%s) — multi-client enabled", bindHost, this.port);
|
|
457
|
+
// Notify async restart() / tryListenAsync() that binding succeeded
|
|
458
|
+
this._listenResolve?.(true);
|
|
459
|
+
this._listenResolve = null;
|
|
460
|
+
const heartbeat = () => {
|
|
461
|
+
for (const [cId, info] of this.clients) {
|
|
462
|
+
if (info.ws.readyState !== 1) {
|
|
463
|
+
this.removeClient(cId, "WebSocket not open");
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (!info.alive) {
|
|
467
|
+
info.missedHeartbeats++;
|
|
468
|
+
if (info.missedHeartbeats >= 3) {
|
|
469
|
+
logger.warn({ clientId: cId, fileKey: info.fileKey }, "Plugin bridge: client not responding to heartbeat, terminating");
|
|
470
|
+
try {
|
|
471
|
+
info.ws.terminate();
|
|
472
|
+
}
|
|
473
|
+
catch { /* ignore */ }
|
|
474
|
+
this.removeClient(cId, "Heartbeat timeout");
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
info.missedHeartbeats = 0;
|
|
480
|
+
info.alive = false;
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
info.ws.send(JSON.stringify({ type: "ping" }));
|
|
484
|
+
}
|
|
485
|
+
catch { /* ignore */ }
|
|
486
|
+
}
|
|
487
|
+
this.heartbeatTimer = setTimeout(heartbeat, HEARTBEAT_INTERVAL_MS);
|
|
488
|
+
};
|
|
489
|
+
this.heartbeatTimer = setTimeout(heartbeat, HEARTBEAT_INTERVAL_MS);
|
|
490
|
+
}
|
|
491
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
492
|
+
// Smart auto-increment port binding (primary startup path)
|
|
493
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
494
|
+
/**
|
|
495
|
+
* Try to bind starting from `port`, auto-incrementing through the valid range.
|
|
496
|
+
* - Healthy F-MCP bridges (active clients) are skipped.
|
|
497
|
+
* - Stale F-MCP bridges (0 clients, uptime ≥ 30s) are taken over.
|
|
498
|
+
* - Freshly started bridges (0 clients, uptime < 30s) are skipped.
|
|
499
|
+
* - Unknown/old-version bridges and non-F-MCP services are skipped.
|
|
500
|
+
*
|
|
501
|
+
* `_listenResolve` is called exactly once: on success or when all ports are exhausted.
|
|
502
|
+
*/
|
|
503
|
+
tryListenWithAutoIncrement(port) {
|
|
504
|
+
if (port > MAX_PORT) {
|
|
505
|
+
const msg = `All ports ${MIN_PORT}–${MAX_PORT} are in use. Cannot start bridge. Free a port or restart a stale instance.`;
|
|
506
|
+
this.startError = msg;
|
|
507
|
+
console.error(`\n⚠️ ${msg}\n`);
|
|
508
|
+
logger.error(msg);
|
|
509
|
+
this._listenResolve?.(false);
|
|
510
|
+
this._listenResolve = null;
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const bindHost = process.env.FIGMA_BRIDGE_HOST || "127.0.0.1";
|
|
514
|
+
const server = this.createBridgeHttpServer();
|
|
515
|
+
server.on("error", (err) => {
|
|
516
|
+
if (err.code === "EADDRINUSE") {
|
|
517
|
+
server.close();
|
|
518
|
+
const probeHost = bindHost === "0.0.0.0" ? "127.0.0.1" : bindHost;
|
|
519
|
+
this.probePort(port, probeHost).then(async (status) => {
|
|
520
|
+
if (status === "fmcp") {
|
|
521
|
+
// F-MCP bridge detected — check health
|
|
522
|
+
const { clients, uptime } = await this.probeStatus(port, probeHost);
|
|
523
|
+
if (clients > 0) {
|
|
524
|
+
// HEALTHY bridge with active clients — coexist, skip to next port
|
|
525
|
+
logger.info({ port, clients }, "Port %d: healthy F-MCP bridge (%d clients), skipping to next port", port, clients);
|
|
526
|
+
console.error(` Port ${port}: healthy bridge (${clients} client(s)), trying ${port + 1}…\n`);
|
|
527
|
+
this.tryListenWithAutoIncrement(port + 1);
|
|
528
|
+
}
|
|
529
|
+
else if (clients === 0 && uptime >= 0 && uptime < FRESH_BRIDGE_UPTIME_THRESHOLD_S) {
|
|
530
|
+
// FRESHLY STARTED bridge (no clients yet, uptime < 30s) — skip, don't takeover
|
|
531
|
+
logger.info({ port, uptime }, "Port %d: freshly started F-MCP bridge (uptime %ds), skipping to next port", port, uptime);
|
|
532
|
+
console.error(` Port ${port}: freshly started bridge (${uptime}s uptime), trying ${port + 1}…\n`);
|
|
533
|
+
this.tryListenWithAutoIncrement(port + 1);
|
|
534
|
+
}
|
|
535
|
+
else if (clients === 0 && uptime >= FRESH_BRIDGE_UPTIME_THRESHOLD_S) {
|
|
536
|
+
// STALE bridge (0 clients, uptime ≥ 30s) — takeover
|
|
537
|
+
logger.info({ port, uptime }, "Port %d: stale F-MCP bridge (0 clients, %ds uptime), requesting shutdown", port, uptime);
|
|
538
|
+
console.error(`\n⚠️ Port ${port}: stale bridge (0 clients, ${uptime}s uptime). Requesting shutdown…\n`);
|
|
539
|
+
this.sendShutdownRequest(port, probeHost, () => {
|
|
540
|
+
// Shutdown accepted — retry same port after delay
|
|
541
|
+
setTimeout(() => {
|
|
542
|
+
const retryServer = this.createBridgeHttpServer();
|
|
543
|
+
retryServer.on("error", (retryErr) => {
|
|
544
|
+
if (retryErr.code === "EADDRINUSE") {
|
|
545
|
+
retryServer.close();
|
|
546
|
+
// Takeover failed — move to next port
|
|
547
|
+
logger.warn({ port }, "Port %d still busy after takeover, trying next", port);
|
|
548
|
+
this.tryListenWithAutoIncrement(port + 1);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
logger.error({ err: retryErr }, "Plugin bridge server error");
|
|
552
|
+
});
|
|
553
|
+
retryServer.listen(port, bindHost, () => {
|
|
554
|
+
this.setupBridgeOnServer(retryServer, port, bindHost);
|
|
555
|
+
});
|
|
556
|
+
}, SHUTDOWN_TAKEOVER_DELAY_MS);
|
|
557
|
+
}, () => {
|
|
558
|
+
// Shutdown refused — move to next port
|
|
559
|
+
this.tryListenWithAutoIncrement(port + 1);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
// Unknown health (old bridge version without /status, or probe failed)
|
|
564
|
+
// Safe choice: skip, don't kill
|
|
565
|
+
logger.info({ port, clients, uptime }, "Port %d: F-MCP bridge with unknown health (clients=%d, uptime=%d), skipping", port, clients, uptime);
|
|
566
|
+
console.error(` Port ${port}: F-MCP bridge (unknown health), trying ${port + 1}…\n`);
|
|
567
|
+
this.tryListenWithAutoIncrement(port + 1);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
else if (status === "dead") {
|
|
571
|
+
// Port held by stale/unresponsive process — retry after delay
|
|
572
|
+
console.error(`\n⚠️ Port ${port} is busy but not responding. Retrying in ${STALE_PORT_RETRY_DELAY_MS}ms…\n`);
|
|
573
|
+
setTimeout(() => {
|
|
574
|
+
const retryServer = this.createBridgeHttpServer();
|
|
575
|
+
retryServer.on("error", (retryErr) => {
|
|
576
|
+
if (retryErr.code === "EADDRINUSE") {
|
|
577
|
+
retryServer.close();
|
|
578
|
+
// Still busy — move to next port
|
|
579
|
+
this.tryListenWithAutoIncrement(port + 1);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
logger.error({ err: retryErr }, "Plugin bridge server error");
|
|
583
|
+
});
|
|
584
|
+
retryServer.listen(port, bindHost, () => {
|
|
585
|
+
this.setupBridgeOnServer(retryServer, port, bindHost);
|
|
586
|
+
});
|
|
587
|
+
}, STALE_PORT_RETRY_DELAY_MS);
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
// Non-F-MCP service — skip to next port
|
|
591
|
+
logger.info({ port }, "Port %d occupied by non-F-MCP service, skipping", port);
|
|
592
|
+
console.error(` Port ${port}: non-F-MCP service, trying ${port + 1}…\n`);
|
|
593
|
+
this.tryListenWithAutoIncrement(port + 1);
|
|
594
|
+
}
|
|
595
|
+
}).catch(() => {
|
|
596
|
+
// Probe failed — skip to next port
|
|
597
|
+
this.tryListenWithAutoIncrement(port + 1);
|
|
598
|
+
});
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
logger.error({ err }, "Plugin bridge server error");
|
|
602
|
+
});
|
|
603
|
+
server.listen(port, bindHost, () => {
|
|
604
|
+
this.setupBridgeOnServer(server, port, bindHost);
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
608
|
+
// Legacy fixed-port binding (kept for backward compatibility)
|
|
609
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
610
|
+
/**
|
|
611
|
+
* @deprecated Legacy method — new startup uses tryListenWithAutoIncrement().
|
|
612
|
+
* Kept for backward compatibility with requestShutdownAndRetry retry path.
|
|
613
|
+
*/
|
|
614
|
+
tryListenFixed(port, isRetry) {
|
|
615
|
+
const server = this.createBridgeHttpServer();
|
|
245
616
|
const bindHost = process.env.FIGMA_BRIDGE_HOST || "127.0.0.1";
|
|
246
617
|
server.on("error", (err) => {
|
|
247
618
|
if (err.code === "EADDRINUSE") {
|
|
@@ -290,136 +661,7 @@ export class PluginBridgeServer {
|
|
|
290
661
|
logger.error({ err }, "Plugin bridge server error");
|
|
291
662
|
});
|
|
292
663
|
server.listen(port, bindHost, () => {
|
|
293
|
-
this.port
|
|
294
|
-
process.env.FIGMA_PLUGIN_BRIDGE_PORT = String(port);
|
|
295
|
-
process.env.FIGMA_MCP_BRIDGE_PORT = String(port);
|
|
296
|
-
console.error(`F-MCP bridge listening on ws://${bindHost}:${port}\n`);
|
|
297
|
-
this.httpServer = server;
|
|
298
|
-
this.wss = new WebSocketServer({ server });
|
|
299
|
-
this.wss.on("connection", (ws) => {
|
|
300
|
-
const clientId = this.generateClientId();
|
|
301
|
-
const clientInfo = {
|
|
302
|
-
ws,
|
|
303
|
-
clientId,
|
|
304
|
-
fileKey: null,
|
|
305
|
-
fileName: null,
|
|
306
|
-
alive: true,
|
|
307
|
-
missedHeartbeats: 0,
|
|
308
|
-
connectedAt: Date.now(),
|
|
309
|
-
};
|
|
310
|
-
this.clients.set(clientId, clientInfo);
|
|
311
|
-
logger.info({ port: this.port, clientId, totalClients: this.clients.size }, "Plugin bridge: new plugin connected");
|
|
312
|
-
auditPlugin(this.auditLogPath, "plugin_connect");
|
|
313
|
-
ws.on("message", (data) => {
|
|
314
|
-
clientInfo.alive = true;
|
|
315
|
-
try {
|
|
316
|
-
const msg = JSON.parse(data.toString());
|
|
317
|
-
if (msg.type === "ready") {
|
|
318
|
-
const incomingFileKey = msg.fileKey || null;
|
|
319
|
-
const incomingFileName = msg.fileName || null;
|
|
320
|
-
if (incomingFileKey) {
|
|
321
|
-
const existing = this.findClientByFileKey(incomingFileKey);
|
|
322
|
-
if (existing && existing.clientId !== clientId) {
|
|
323
|
-
logger.info({ oldClientId: existing.clientId, newClientId: clientId, fileKey: incomingFileKey }, "Plugin bridge: replacing existing client for same fileKey");
|
|
324
|
-
this.removeClient(existing.clientId, "Replaced by new connection for same file");
|
|
325
|
-
try {
|
|
326
|
-
existing.ws.close();
|
|
327
|
-
}
|
|
328
|
-
catch { /* ignore */ }
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
clientInfo.fileKey = incomingFileKey;
|
|
332
|
-
clientInfo.fileName = incomingFileName;
|
|
333
|
-
logger.info({ clientId, fileKey: incomingFileKey, fileName: incomingFileName }, "Plugin bridge: client registered (fileKey=%s, fileName=%s)", incomingFileKey, incomingFileName);
|
|
334
|
-
ws.send(JSON.stringify({
|
|
335
|
-
type: "welcome",
|
|
336
|
-
bridgeVersion: FMCP_VERSION,
|
|
337
|
-
port: this.port,
|
|
338
|
-
clientId,
|
|
339
|
-
multiClient: true,
|
|
340
|
-
clientName: this.clientName,
|
|
341
|
-
}));
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
if (msg.type === "pong" || msg.type === "keepalive") {
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
if (msg.type === "setToken" && typeof msg.token === "string") {
|
|
348
|
-
const token = msg.token;
|
|
349
|
-
if (token) {
|
|
350
|
-
this.setFigmaRestToken(token);
|
|
351
|
-
logger.info({ clientId }, "Plugin bridge: REST API token set via plugin UI");
|
|
352
|
-
}
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
if (msg.type === "clearToken") {
|
|
356
|
-
this.clearFigmaRestToken();
|
|
357
|
-
logger.info({ clientId }, "Plugin bridge: REST API token cleared via plugin UI");
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
if (msg.id && this.pending.has(msg.id)) {
|
|
361
|
-
const p = this.pending.get(msg.id);
|
|
362
|
-
this.pending.delete(msg.id);
|
|
363
|
-
clearTimeout(p.timeout);
|
|
364
|
-
const durationMs = Date.now() - p.startTime;
|
|
365
|
-
if (msg.error) {
|
|
366
|
-
auditTool(this.auditLogPath, p.method, false, msg.error, durationMs);
|
|
367
|
-
p.reject(new Error(msg.error));
|
|
368
|
-
}
|
|
369
|
-
else {
|
|
370
|
-
if (msg.result === undefined) {
|
|
371
|
-
logger.warn({ method: p.method, msgId: msg.id }, "Plugin bridge: response has no result and no error");
|
|
372
|
-
}
|
|
373
|
-
auditTool(this.auditLogPath, p.method, true, undefined, durationMs);
|
|
374
|
-
p.resolve(msg.result);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
catch (err) {
|
|
379
|
-
logger.warn({ err }, "Plugin bridge: invalid message from plugin");
|
|
380
|
-
}
|
|
381
|
-
});
|
|
382
|
-
ws.on("close", () => {
|
|
383
|
-
this.removeClient(clientId, "WebSocket closed");
|
|
384
|
-
});
|
|
385
|
-
ws.on("error", (err) => {
|
|
386
|
-
logger.warn({ err, clientId }, "Plugin bridge: client error");
|
|
387
|
-
});
|
|
388
|
-
});
|
|
389
|
-
logger.info({ port: this.port, host: bindHost }, "Plugin bridge server listening (ws://%s:%s) — multi-client enabled", bindHost, this.port);
|
|
390
|
-
// Notify async restart() that binding succeeded
|
|
391
|
-
this._listenResolve?.(true);
|
|
392
|
-
this._listenResolve = null;
|
|
393
|
-
const heartbeat = () => {
|
|
394
|
-
for (const [clientId, info] of this.clients) {
|
|
395
|
-
if (info.ws.readyState !== 1) {
|
|
396
|
-
this.removeClient(clientId, "WebSocket not open");
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
if (!info.alive) {
|
|
400
|
-
info.missedHeartbeats++;
|
|
401
|
-
if (info.missedHeartbeats >= 3) {
|
|
402
|
-
logger.warn({ clientId, fileKey: info.fileKey }, "Plugin bridge: client not responding to heartbeat, terminating");
|
|
403
|
-
try {
|
|
404
|
-
info.ws.terminate();
|
|
405
|
-
}
|
|
406
|
-
catch { /* ignore */ }
|
|
407
|
-
this.removeClient(clientId, "Heartbeat timeout");
|
|
408
|
-
continue;
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
else {
|
|
412
|
-
info.missedHeartbeats = 0;
|
|
413
|
-
info.alive = false;
|
|
414
|
-
}
|
|
415
|
-
try {
|
|
416
|
-
info.ws.send(JSON.stringify({ type: "ping" }));
|
|
417
|
-
}
|
|
418
|
-
catch { /* ignore */ }
|
|
419
|
-
}
|
|
420
|
-
this.heartbeatTimer = setTimeout(heartbeat, HEARTBEAT_INTERVAL_MS);
|
|
421
|
-
};
|
|
422
|
-
this.heartbeatTimer = setTimeout(heartbeat, HEARTBEAT_INTERVAL_MS);
|
|
664
|
+
this.setupBridgeOnServer(server, port, bindHost);
|
|
423
665
|
});
|
|
424
666
|
}
|
|
425
667
|
/**
|
|
@@ -428,7 +670,11 @@ export class PluginBridgeServer {
|
|
|
428
670
|
* Otherwise routes to the most recently connected client.
|
|
429
671
|
*/
|
|
430
672
|
async request(method, params, fileKey) {
|
|
431
|
-
|
|
673
|
+
let client = this.resolveClient(fileKey);
|
|
674
|
+
// If no client found, wait briefly for plugin "ready" (race condition: plugin connected but fileKey not yet set)
|
|
675
|
+
if (!client || client.ws.readyState !== 1) {
|
|
676
|
+
client = await this.waitForClient(fileKey, 2000);
|
|
677
|
+
}
|
|
432
678
|
if (!client || client.ws.readyState !== 1) {
|
|
433
679
|
if (fileKey) {
|
|
434
680
|
const available = this.listConnectedFiles();
|