@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.
@@ -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: graceful takeover. If the configured port is busy with another
10
- * F-MCP instance, the server sends a /shutdown request to the old bridge and
11
- * retries after it exits. Stale ports get one automatic retry after a short delay.
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.tryListenFixed(this.preferredPort, false);
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 fails. */
111
+ /** Async listen attempt — resolves when port binds successfully or all ports exhausted. */
105
112
  tryListenAsync(port) {
106
113
  return new Promise((resolve) => {
107
- const TIMEOUT_MS = 5000;
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 ${port} bind timeout (${TIMEOUT_MS}ms)` });
118
+ resolve({ success: false, port, error: this.startError || `Port bind timeout (${TIMEOUT_MS}ms)` });
111
119
  }, TIMEOUT_MS);
112
- // Store original callback so tryListenFixed can notify us
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.tryListenFixed(port, false);
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
- tryListenFixed(port, isRetry) {
227
- const server = createServer((req, res) => {
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 = 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
- const client = this.resolveClient(fileKey);
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();