@atezer/figma-mcp-bridge 1.7.27 → 1.7.29

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;
@@ -185,9 +197,71 @@ export class PluginBridgeServer {
185
197
  req.on("timeout", () => { req.destroy(); resolve("dead"); });
186
198
  });
187
199
  }
200
+ /**
201
+ * Probe a live F-MCP bridge's /status endpoint to get its health info.
202
+ * Returns { clients, uptime } or { -1, -1 } if the endpoint is unavailable
203
+ * (e.g. older bridge version without /status).
204
+ */
205
+ probeStatus(port, host) {
206
+ return new Promise((resolve) => {
207
+ const req = httpGet({ hostname: host, port, path: "/status", timeout: 1000 }, (res) => {
208
+ let body = "";
209
+ res.on("data", (chunk) => { body += chunk; });
210
+ res.on("end", () => {
211
+ try {
212
+ const data = JSON.parse(body);
213
+ resolve({
214
+ clients: typeof data.clients === "number" ? data.clients : -1,
215
+ uptime: typeof data.uptime === "number" ? data.uptime : -1,
216
+ });
217
+ }
218
+ catch {
219
+ resolve({ clients: -1, uptime: -1 });
220
+ }
221
+ });
222
+ });
223
+ req.on("error", () => resolve({ clients: -1, uptime: -1 }));
224
+ req.on("timeout", () => { req.destroy(); resolve({ clients: -1, uptime: -1 }); });
225
+ });
226
+ }
227
+ /**
228
+ * Send a POST /shutdown to an old F-MCP bridge. Calls onAccepted if the bridge
229
+ * responds with 200, or onRefused otherwise. On error/timeout, assumes the bridge
230
+ * may have already exited and calls onAccepted.
231
+ */
232
+ sendShutdownRequest(port, host, onAccepted, onRefused) {
233
+ console.error(` Sending shutdown request to old F-MCP bridge on port ${port}…\n`);
234
+ const req = httpRequest({ hostname: host, port, path: "/shutdown", method: "POST", timeout: 3000 }, (res) => {
235
+ let body = "";
236
+ res.on("data", (chunk) => { body += chunk; });
237
+ res.on("end", () => {
238
+ if (res.statusCode === 200) {
239
+ console.error(` Old bridge accepted shutdown. Retaking port ${port} in ${SHUTDOWN_TAKEOVER_DELAY_MS}ms…\n`);
240
+ onAccepted();
241
+ }
242
+ else {
243
+ console.error(`\n⚠️ Old bridge refused shutdown (status ${res.statusCode}). Trying next port…\n`);
244
+ logger.warn({ port, statusCode: res.statusCode }, "Old bridge refused shutdown");
245
+ onRefused();
246
+ }
247
+ });
248
+ });
249
+ req.on("error", () => {
250
+ // Old bridge unreachable — might have already exited
251
+ console.error(` Old bridge unreachable after shutdown request. Retrying port ${port}…\n`);
252
+ onAccepted();
253
+ });
254
+ req.on("timeout", () => {
255
+ req.destroy();
256
+ console.error(` Shutdown request timed out. Retrying port ${port}…\n`);
257
+ onAccepted();
258
+ });
259
+ req.end();
260
+ }
188
261
  /**
189
262
  * Send a POST /shutdown to an old F-MCP bridge on the given port,
190
263
  * wait for it to exit, then retry binding to the same port.
264
+ * @deprecated Legacy method — kept for backward compatibility. New code uses sendShutdownRequest + tryListenWithAutoIncrement.
191
265
  */
192
266
  requestShutdownAndRetry(port, host) {
193
267
  console.error(` Sending shutdown request to old F-MCP bridge on port ${port}…\n`);
@@ -223,18 +297,36 @@ export class PluginBridgeServer {
223
297
  });
224
298
  req.end();
225
299
  }
226
- tryListenFixed(port, isRetry) {
227
- const server = createServer((req, res) => {
300
+ // ──────────────────────────────────────────────────────────────────────
301
+ // HTTP server factory (shared between tryListenFixed and tryListenWithAutoIncrement)
302
+ // ──────────────────────────────────────────────────────────────────────
303
+ /** Create an HTTP server with /shutdown, /status, and default F-MCP marker endpoints. */
304
+ createBridgeHttpServer() {
305
+ return createServer((req, res) => {
228
306
  // Graceful shutdown endpoint: a new bridge instance requests this old one to exit
229
307
  if (req.method === "POST" && req.url === "/shutdown") {
230
308
  res.writeHead(200, { "Content-Type": "text/plain" });
231
309
  res.end("shutting down\n");
232
310
  logger.info("Received /shutdown request from new bridge instance — stopping gracefully");
233
311
  console.error("\n⚠️ Received shutdown request from new F-MCP bridge instance. Stopping…\n");
234
- // Defer stop to let the response flush
235
312
  setTimeout(() => this.stop(), 500);
236
313
  return;
237
314
  }
315
+ // Health check endpoint — used by new instances to decide coexistence vs takeover
316
+ if (req.method === "GET" && req.url === "/status") {
317
+ const body = JSON.stringify({
318
+ clients: this.connectedClientCount(),
319
+ uptime: Math.round(process.uptime()),
320
+ version: FMCP_VERSION,
321
+ });
322
+ res.writeHead(200, {
323
+ "Content-Type": "application/json",
324
+ "Access-Control-Allow-Origin": "*",
325
+ });
326
+ res.end(body);
327
+ return;
328
+ }
329
+ // Default F-MCP marker (used by probePort to detect F-MCP bridges)
238
330
  res.writeHead(200, {
239
331
  "Content-Type": "text/plain",
240
332
  "Access-Control-Allow-Origin": "*",
@@ -242,6 +334,268 @@ export class PluginBridgeServer {
242
334
  });
243
335
  res.end("F-MCP ATezer Bridge (connect via WebSocket)\n");
244
336
  });
337
+ }
338
+ /**
339
+ * Set up WebSocket server, heartbeat, client handling on a successfully bound HTTP server.
340
+ * Called from both tryListenFixed and tryListenWithAutoIncrement on bind success.
341
+ */
342
+ setupBridgeOnServer(server, port, bindHost) {
343
+ this.port = port;
344
+ process.env.FIGMA_PLUGIN_BRIDGE_PORT = String(port);
345
+ process.env.FIGMA_MCP_BRIDGE_PORT = String(port);
346
+ console.error(`F-MCP bridge listening on ws://${bindHost}:${port}\n`);
347
+ this.httpServer = server;
348
+ this.wss = new WebSocketServer({ server });
349
+ this.wss.on("connection", (ws) => {
350
+ const clientId = this.generateClientId();
351
+ const clientInfo = {
352
+ ws,
353
+ clientId,
354
+ fileKey: null,
355
+ fileName: null,
356
+ alive: true,
357
+ missedHeartbeats: 0,
358
+ connectedAt: Date.now(),
359
+ };
360
+ this.clients.set(clientId, clientInfo);
361
+ logger.info({ port: this.port, clientId, totalClients: this.clients.size }, "Plugin bridge: new plugin connected");
362
+ auditPlugin(this.auditLogPath, "plugin_connect");
363
+ ws.on("message", (data) => {
364
+ clientInfo.alive = true;
365
+ try {
366
+ const msg = JSON.parse(data.toString());
367
+ if (msg.type === "ready") {
368
+ const incomingFileKey = msg.fileKey || null;
369
+ const incomingFileName = msg.fileName || null;
370
+ if (incomingFileKey) {
371
+ const existing = this.findClientByFileKey(incomingFileKey);
372
+ if (existing && existing.clientId !== clientId) {
373
+ logger.info({ oldClientId: existing.clientId, newClientId: clientId, fileKey: incomingFileKey }, "Plugin bridge: replacing existing client for same fileKey");
374
+ this.removeClient(existing.clientId, "Replaced by new connection for same file");
375
+ try {
376
+ existing.ws.close();
377
+ }
378
+ catch { /* ignore */ }
379
+ }
380
+ }
381
+ clientInfo.fileKey = incomingFileKey;
382
+ clientInfo.fileName = incomingFileName;
383
+ logger.info({ clientId, fileKey: incomingFileKey, fileName: incomingFileName }, "Plugin bridge: client registered (fileKey=%s, fileName=%s)", incomingFileKey, incomingFileName);
384
+ ws.send(JSON.stringify({
385
+ type: "welcome",
386
+ bridgeVersion: FMCP_VERSION,
387
+ port: this.port,
388
+ clientId,
389
+ multiClient: true,
390
+ clientName: this.clientName,
391
+ }));
392
+ return;
393
+ }
394
+ if (msg.type === "pong" || msg.type === "keepalive") {
395
+ return;
396
+ }
397
+ if (msg.type === "setToken" && typeof msg.token === "string") {
398
+ const token = msg.token;
399
+ if (token) {
400
+ this.setFigmaRestToken(token);
401
+ logger.info({ clientId }, "Plugin bridge: REST API token set via plugin UI");
402
+ }
403
+ return;
404
+ }
405
+ if (msg.type === "clearToken") {
406
+ this.clearFigmaRestToken();
407
+ logger.info({ clientId }, "Plugin bridge: REST API token cleared via plugin UI");
408
+ return;
409
+ }
410
+ if (msg.id && this.pending.has(msg.id)) {
411
+ const p = this.pending.get(msg.id);
412
+ this.pending.delete(msg.id);
413
+ clearTimeout(p.timeout);
414
+ const durationMs = Date.now() - p.startTime;
415
+ if (msg.error) {
416
+ auditTool(this.auditLogPath, p.method, false, msg.error, durationMs);
417
+ p.reject(new Error(msg.error));
418
+ }
419
+ else {
420
+ if (msg.result === undefined) {
421
+ logger.warn({ method: p.method, msgId: msg.id }, "Plugin bridge: response has no result and no error");
422
+ }
423
+ auditTool(this.auditLogPath, p.method, true, undefined, durationMs);
424
+ p.resolve(msg.result);
425
+ }
426
+ }
427
+ }
428
+ catch (err) {
429
+ logger.warn({ err }, "Plugin bridge: invalid message from plugin");
430
+ }
431
+ });
432
+ ws.on("close", () => {
433
+ this.removeClient(clientId, "WebSocket closed");
434
+ });
435
+ ws.on("error", (err) => {
436
+ logger.warn({ err, clientId }, "Plugin bridge: client error");
437
+ });
438
+ });
439
+ logger.info({ port: this.port, host: bindHost }, "Plugin bridge server listening (ws://%s:%s) — multi-client enabled", bindHost, this.port);
440
+ // Notify async restart() / tryListenAsync() that binding succeeded
441
+ this._listenResolve?.(true);
442
+ this._listenResolve = null;
443
+ const heartbeat = () => {
444
+ for (const [cId, info] of this.clients) {
445
+ if (info.ws.readyState !== 1) {
446
+ this.removeClient(cId, "WebSocket not open");
447
+ continue;
448
+ }
449
+ if (!info.alive) {
450
+ info.missedHeartbeats++;
451
+ if (info.missedHeartbeats >= 3) {
452
+ logger.warn({ clientId: cId, fileKey: info.fileKey }, "Plugin bridge: client not responding to heartbeat, terminating");
453
+ try {
454
+ info.ws.terminate();
455
+ }
456
+ catch { /* ignore */ }
457
+ this.removeClient(cId, "Heartbeat timeout");
458
+ continue;
459
+ }
460
+ }
461
+ else {
462
+ info.missedHeartbeats = 0;
463
+ info.alive = false;
464
+ }
465
+ try {
466
+ info.ws.send(JSON.stringify({ type: "ping" }));
467
+ }
468
+ catch { /* ignore */ }
469
+ }
470
+ this.heartbeatTimer = setTimeout(heartbeat, HEARTBEAT_INTERVAL_MS);
471
+ };
472
+ this.heartbeatTimer = setTimeout(heartbeat, HEARTBEAT_INTERVAL_MS);
473
+ }
474
+ // ──────────────────────────────────────────────────────────────────────
475
+ // Smart auto-increment port binding (primary startup path)
476
+ // ──────────────────────────────────────────────────────────────────────
477
+ /**
478
+ * Try to bind starting from `port`, auto-incrementing through the valid range.
479
+ * - Healthy F-MCP bridges (active clients) are skipped.
480
+ * - Stale F-MCP bridges (0 clients, uptime ≥ 30s) are taken over.
481
+ * - Freshly started bridges (0 clients, uptime < 30s) are skipped.
482
+ * - Unknown/old-version bridges and non-F-MCP services are skipped.
483
+ *
484
+ * `_listenResolve` is called exactly once: on success or when all ports are exhausted.
485
+ */
486
+ tryListenWithAutoIncrement(port) {
487
+ if (port > MAX_PORT) {
488
+ const msg = `All ports ${MIN_PORT}–${MAX_PORT} are in use. Cannot start bridge. Free a port or restart a stale instance.`;
489
+ this.startError = msg;
490
+ console.error(`\n⚠️ ${msg}\n`);
491
+ logger.error(msg);
492
+ this._listenResolve?.(false);
493
+ this._listenResolve = null;
494
+ return;
495
+ }
496
+ const bindHost = process.env.FIGMA_BRIDGE_HOST || "127.0.0.1";
497
+ const server = this.createBridgeHttpServer();
498
+ server.on("error", (err) => {
499
+ if (err.code === "EADDRINUSE") {
500
+ server.close();
501
+ const probeHost = bindHost === "0.0.0.0" ? "127.0.0.1" : bindHost;
502
+ this.probePort(port, probeHost).then(async (status) => {
503
+ if (status === "fmcp") {
504
+ // F-MCP bridge detected — check health
505
+ const { clients, uptime } = await this.probeStatus(port, probeHost);
506
+ if (clients > 0) {
507
+ // HEALTHY bridge with active clients — coexist, skip to next port
508
+ logger.info({ port, clients }, "Port %d: healthy F-MCP bridge (%d clients), skipping to next port", port, clients);
509
+ console.error(` Port ${port}: healthy bridge (${clients} client(s)), trying ${port + 1}…\n`);
510
+ this.tryListenWithAutoIncrement(port + 1);
511
+ }
512
+ else if (clients === 0 && uptime >= 0 && uptime < FRESH_BRIDGE_UPTIME_THRESHOLD_S) {
513
+ // FRESHLY STARTED bridge (no clients yet, uptime < 30s) — skip, don't takeover
514
+ logger.info({ port, uptime }, "Port %d: freshly started F-MCP bridge (uptime %ds), skipping to next port", port, uptime);
515
+ console.error(` Port ${port}: freshly started bridge (${uptime}s uptime), trying ${port + 1}…\n`);
516
+ this.tryListenWithAutoIncrement(port + 1);
517
+ }
518
+ else if (clients === 0 && uptime >= FRESH_BRIDGE_UPTIME_THRESHOLD_S) {
519
+ // STALE bridge (0 clients, uptime ≥ 30s) — takeover
520
+ logger.info({ port, uptime }, "Port %d: stale F-MCP bridge (0 clients, %ds uptime), requesting shutdown", port, uptime);
521
+ console.error(`\n⚠️ Port ${port}: stale bridge (0 clients, ${uptime}s uptime). Requesting shutdown…\n`);
522
+ this.sendShutdownRequest(port, probeHost, () => {
523
+ // Shutdown accepted — retry same port after delay
524
+ setTimeout(() => {
525
+ const retryServer = this.createBridgeHttpServer();
526
+ retryServer.on("error", (retryErr) => {
527
+ if (retryErr.code === "EADDRINUSE") {
528
+ retryServer.close();
529
+ // Takeover failed — move to next port
530
+ logger.warn({ port }, "Port %d still busy after takeover, trying next", port);
531
+ this.tryListenWithAutoIncrement(port + 1);
532
+ return;
533
+ }
534
+ logger.error({ err: retryErr }, "Plugin bridge server error");
535
+ });
536
+ retryServer.listen(port, bindHost, () => {
537
+ this.setupBridgeOnServer(retryServer, port, bindHost);
538
+ });
539
+ }, SHUTDOWN_TAKEOVER_DELAY_MS);
540
+ }, () => {
541
+ // Shutdown refused — move to next port
542
+ this.tryListenWithAutoIncrement(port + 1);
543
+ });
544
+ }
545
+ else {
546
+ // Unknown health (old bridge version without /status, or probe failed)
547
+ // Safe choice: skip, don't kill
548
+ logger.info({ port, clients, uptime }, "Port %d: F-MCP bridge with unknown health (clients=%d, uptime=%d), skipping", port, clients, uptime);
549
+ console.error(` Port ${port}: F-MCP bridge (unknown health), trying ${port + 1}…\n`);
550
+ this.tryListenWithAutoIncrement(port + 1);
551
+ }
552
+ }
553
+ else if (status === "dead") {
554
+ // Port held by stale/unresponsive process — retry after delay
555
+ console.error(`\n⚠️ Port ${port} is busy but not responding. Retrying in ${STALE_PORT_RETRY_DELAY_MS}ms…\n`);
556
+ setTimeout(() => {
557
+ const retryServer = this.createBridgeHttpServer();
558
+ retryServer.on("error", (retryErr) => {
559
+ if (retryErr.code === "EADDRINUSE") {
560
+ retryServer.close();
561
+ // Still busy — move to next port
562
+ this.tryListenWithAutoIncrement(port + 1);
563
+ return;
564
+ }
565
+ logger.error({ err: retryErr }, "Plugin bridge server error");
566
+ });
567
+ retryServer.listen(port, bindHost, () => {
568
+ this.setupBridgeOnServer(retryServer, port, bindHost);
569
+ });
570
+ }, STALE_PORT_RETRY_DELAY_MS);
571
+ }
572
+ else {
573
+ // Non-F-MCP service — skip to next port
574
+ logger.info({ port }, "Port %d occupied by non-F-MCP service, skipping", port);
575
+ console.error(` Port ${port}: non-F-MCP service, trying ${port + 1}…\n`);
576
+ this.tryListenWithAutoIncrement(port + 1);
577
+ }
578
+ }).catch(() => {
579
+ // Probe failed — skip to next port
580
+ this.tryListenWithAutoIncrement(port + 1);
581
+ });
582
+ return;
583
+ }
584
+ logger.error({ err }, "Plugin bridge server error");
585
+ });
586
+ server.listen(port, bindHost, () => {
587
+ this.setupBridgeOnServer(server, port, bindHost);
588
+ });
589
+ }
590
+ // ──────────────────────────────────────────────────────────────────────
591
+ // Legacy fixed-port binding (kept for backward compatibility)
592
+ // ──────────────────────────────────────────────────────────────────────
593
+ /**
594
+ * @deprecated Legacy method — new startup uses tryListenWithAutoIncrement().
595
+ * Kept for backward compatibility with requestShutdownAndRetry retry path.
596
+ */
597
+ tryListenFixed(port, isRetry) {
598
+ const server = this.createBridgeHttpServer();
245
599
  const bindHost = process.env.FIGMA_BRIDGE_HOST || "127.0.0.1";
246
600
  server.on("error", (err) => {
247
601
  if (err.code === "EADDRINUSE") {
@@ -290,136 +644,7 @@ export class PluginBridgeServer {
290
644
  logger.error({ err }, "Plugin bridge server error");
291
645
  });
292
646
  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);
647
+ this.setupBridgeOnServer(server, port, bindHost);
423
648
  });
424
649
  }
425
650
  /**