@mp3wizard/figma-console-mcp 1.32.3 → 1.34.1

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.
Files changed (137) hide show
  1. package/README.md +25 -17
  2. package/dist/cloudflare/core/cloud-websocket-connector.js +18 -0
  3. package/dist/cloudflare/core/design-system-manifest.js +19 -14
  4. package/dist/cloudflare/core/design-system-tools.js +43 -34
  5. package/dist/cloudflare/core/diagnose-tool.js +4 -0
  6. package/dist/cloudflare/core/enrichment/enrichment-service.js +11 -5
  7. package/dist/cloudflare/core/enrichment/style-resolver.js +38 -18
  8. package/dist/cloudflare/core/figma-api.js +118 -54
  9. package/dist/cloudflare/core/figma-tools.js +179 -63
  10. package/dist/cloudflare/core/port-discovery.js +404 -31
  11. package/dist/cloudflare/core/tokens/alias-resolver.js +75 -5
  12. package/dist/cloudflare/core/tokens/config.js +10 -0
  13. package/dist/cloudflare/core/tokens/dialect.js +232 -0
  14. package/dist/cloudflare/core/tokens/figma-converter.js +144 -16
  15. package/dist/cloudflare/core/tokens/formatters/css-vars.js +21 -12
  16. package/dist/cloudflare/core/tokens/formatters/dtcg.js +106 -30
  17. package/dist/cloudflare/core/tokens/formatters/json.js +28 -10
  18. package/dist/cloudflare/core/tokens/formatters/scss.js +19 -13
  19. package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +15 -9
  20. package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +14 -9
  21. package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +11 -5
  22. package/dist/cloudflare/core/tokens/index.js +2 -1
  23. package/dist/cloudflare/core/tokens/parsers/dtcg.js +32 -5
  24. package/dist/cloudflare/core/tokens/schemas.js +4 -0
  25. package/dist/cloudflare/core/tokens-tools.js +1017 -88
  26. package/dist/cloudflare/core/version-tools.js +44 -3
  27. package/dist/cloudflare/core/websocket-connector.js +42 -0
  28. package/dist/cloudflare/core/websocket-server.js +99 -8
  29. package/dist/cloudflare/core/write-tools.js +355 -86
  30. package/dist/cloudflare/index.js +7 -7
  31. package/dist/core/design-system-manifest.d.ts +1 -0
  32. package/dist/core/design-system-manifest.d.ts.map +1 -1
  33. package/dist/core/design-system-manifest.js +19 -14
  34. package/dist/core/design-system-manifest.js.map +1 -1
  35. package/dist/core/design-system-tools.d.ts.map +1 -1
  36. package/dist/core/design-system-tools.js +43 -34
  37. package/dist/core/design-system-tools.js.map +1 -1
  38. package/dist/core/diagnose-tool.d.ts +8 -0
  39. package/dist/core/diagnose-tool.d.ts.map +1 -1
  40. package/dist/core/diagnose-tool.js +4 -0
  41. package/dist/core/diagnose-tool.js.map +1 -1
  42. package/dist/core/enrichment/enrichment-service.d.ts.map +1 -1
  43. package/dist/core/enrichment/enrichment-service.js +11 -5
  44. package/dist/core/enrichment/enrichment-service.js.map +1 -1
  45. package/dist/core/enrichment/style-resolver.d.ts +7 -2
  46. package/dist/core/enrichment/style-resolver.d.ts.map +1 -1
  47. package/dist/core/enrichment/style-resolver.js +38 -18
  48. package/dist/core/enrichment/style-resolver.js.map +1 -1
  49. package/dist/core/figma-api.d.ts +18 -9
  50. package/dist/core/figma-api.d.ts.map +1 -1
  51. package/dist/core/figma-api.js +118 -54
  52. package/dist/core/figma-api.js.map +1 -1
  53. package/dist/core/figma-connector.d.ts +12 -0
  54. package/dist/core/figma-connector.d.ts.map +1 -1
  55. package/dist/core/figma-tools.d.ts.map +1 -1
  56. package/dist/core/figma-tools.js +179 -63
  57. package/dist/core/figma-tools.js.map +1 -1
  58. package/dist/core/port-discovery.d.ts +40 -0
  59. package/dist/core/port-discovery.d.ts.map +1 -1
  60. package/dist/core/port-discovery.js +404 -31
  61. package/dist/core/port-discovery.js.map +1 -1
  62. package/dist/core/tokens/alias-resolver.d.ts +45 -3
  63. package/dist/core/tokens/alias-resolver.d.ts.map +1 -1
  64. package/dist/core/tokens/alias-resolver.js +75 -5
  65. package/dist/core/tokens/alias-resolver.js.map +1 -1
  66. package/dist/core/tokens/config.d.ts +28 -0
  67. package/dist/core/tokens/config.d.ts.map +1 -1
  68. package/dist/core/tokens/config.js +10 -0
  69. package/dist/core/tokens/config.js.map +1 -1
  70. package/dist/core/tokens/dialect.d.ts +107 -0
  71. package/dist/core/tokens/dialect.d.ts.map +1 -0
  72. package/dist/core/tokens/dialect.js +233 -0
  73. package/dist/core/tokens/dialect.js.map +1 -0
  74. package/dist/core/tokens/figma-converter.d.ts +23 -2
  75. package/dist/core/tokens/figma-converter.d.ts.map +1 -1
  76. package/dist/core/tokens/figma-converter.js +144 -16
  77. package/dist/core/tokens/figma-converter.js.map +1 -1
  78. package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -1
  79. package/dist/core/tokens/formatters/css-vars.js +21 -12
  80. package/dist/core/tokens/formatters/css-vars.js.map +1 -1
  81. package/dist/core/tokens/formatters/dtcg.d.ts +2 -2
  82. package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -1
  83. package/dist/core/tokens/formatters/dtcg.js +106 -30
  84. package/dist/core/tokens/formatters/dtcg.js.map +1 -1
  85. package/dist/core/tokens/formatters/json.d.ts.map +1 -1
  86. package/dist/core/tokens/formatters/json.js +28 -10
  87. package/dist/core/tokens/formatters/json.js.map +1 -1
  88. package/dist/core/tokens/formatters/scss.d.ts.map +1 -1
  89. package/dist/core/tokens/formatters/scss.js +19 -13
  90. package/dist/core/tokens/formatters/scss.js.map +1 -1
  91. package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -1
  92. package/dist/core/tokens/formatters/style-dictionary-v3.js +15 -9
  93. package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -1
  94. package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -1
  95. package/dist/core/tokens/formatters/tailwind-v4.js +14 -9
  96. package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -1
  97. package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -1
  98. package/dist/core/tokens/formatters/tokens-studio.js +11 -5
  99. package/dist/core/tokens/formatters/tokens-studio.js.map +1 -1
  100. package/dist/core/tokens/index.d.ts +2 -1
  101. package/dist/core/tokens/index.d.ts.map +1 -1
  102. package/dist/core/tokens/index.js +2 -1
  103. package/dist/core/tokens/index.js.map +1 -1
  104. package/dist/core/tokens/parsers/dtcg.js +32 -5
  105. package/dist/core/tokens/parsers/dtcg.js.map +1 -1
  106. package/dist/core/tokens/schemas.d.ts +3 -0
  107. package/dist/core/tokens/schemas.d.ts.map +1 -1
  108. package/dist/core/tokens/schemas.js +4 -0
  109. package/dist/core/tokens/schemas.js.map +1 -1
  110. package/dist/core/tokens/types.d.ts +57 -1
  111. package/dist/core/tokens/types.d.ts.map +1 -1
  112. package/dist/core/tokens/types.js.map +1 -1
  113. package/dist/core/tokens-tools.d.ts +250 -7
  114. package/dist/core/tokens-tools.d.ts.map +1 -1
  115. package/dist/core/tokens-tools.js +1017 -88
  116. package/dist/core/tokens-tools.js.map +1 -1
  117. package/dist/core/version-tools.d.ts.map +1 -1
  118. package/dist/core/version-tools.js +44 -3
  119. package/dist/core/version-tools.js.map +1 -1
  120. package/dist/core/websocket-connector.d.ts +38 -0
  121. package/dist/core/websocket-connector.d.ts.map +1 -1
  122. package/dist/core/websocket-connector.js +42 -0
  123. package/dist/core/websocket-connector.js.map +1 -1
  124. package/dist/core/websocket-server.d.ts +23 -0
  125. package/dist/core/websocket-server.d.ts.map +1 -1
  126. package/dist/core/websocket-server.js +99 -8
  127. package/dist/core/websocket-server.js.map +1 -1
  128. package/dist/core/write-tools.d.ts.map +1 -1
  129. package/dist/core/write-tools.js +355 -86
  130. package/dist/core/write-tools.js.map +1 -1
  131. package/dist/local.d.ts +0 -1
  132. package/dist/local.d.ts.map +1 -1
  133. package/dist/local.js +253 -63
  134. package/dist/local.js.map +1 -1
  135. package/figma-desktop-bridge/code.js +382 -28
  136. package/figma-desktop-bridge/ui.html +578 -292
  137. package/package.json +2 -2
@@ -663,7 +663,11 @@ getMetadataChanges) {
663
663
  });
664
664
  }
665
665
  catch (e) {
666
- logger.warn({ err: e }, "Author enrichment lookup failed; continuing without it");
666
+ // Degrading to null metadata IS surfaced to callers (formatter shows
667
+ // "metadata not available") — just make sure the underlying reason
668
+ // lands in the log for debugging.
669
+ const reason = e instanceof Error ? e.message : String(e);
670
+ logger.warn({ err: e, reason, page }, "Author enrichment lookup failed; continuing without it");
667
671
  break;
668
672
  }
669
673
  const versions = response?.versions || [];
@@ -929,6 +933,13 @@ getMetadataChanges) {
929
933
  apiCalls += candidates.apiCalls;
930
934
  cacheHits += candidates.cacheHits;
931
935
  const versions = candidates.versions; // newest-first, all OLDER than start
936
+ // If the version listing itself failed and NOTHING was collected, the
937
+ // binary search would run over an empty range and confidently report
938
+ // "introduced at start_version" when nothing was actually searched.
939
+ // Fail loudly instead of returning a wrong answer.
940
+ if (candidates.listFetchFailed && versions.length === 0) {
941
+ return errorResponse("version_list_unavailable", `version list unavailable: ${candidates.listFetchError ?? "unknown error"} — cannot attribute the change because no version history could be fetched to search. Retry, or check that your token has the file_versions:read scope ('Versions' Read on a PAT).`);
942
+ }
932
943
  // Step 3: Binary search for the LARGEST index (oldest version) where target exists.
933
944
  // Existence is assumed monotonic: if target exists at an OLDER version (larger
934
945
  // index), it must also exist at all NEWER versions up to start. We search for
@@ -1014,6 +1025,13 @@ getMetadataChanges) {
1014
1025
  certainty = "exact";
1015
1026
  }
1016
1027
  }
1028
+ // Version listing failed partway through — the candidate list is
1029
+ // truncated, so the searched range may not cover the true
1030
+ // introduction point. Surface the gap and downgrade confidence.
1031
+ if (candidates.listFetchFailed) {
1032
+ certainty = downgradeCertainty(certainty);
1033
+ notes.push(`Version listing failed partway through the blame walk (${candidates.listFetchError ?? "unknown error"}); only ${versions.length} version(s) were collected and searched. The true introduction point may lie outside the searched range, so attribution_certainty was downgraded one level.`);
1034
+ }
1017
1035
  notes.push("Binary search assumes the target's existence is monotonic (added once, never removed). If the target was added, removed, then re-added, this tool may report a different introduction point than the original.");
1018
1036
  if (usedSelection) {
1019
1037
  notes.push(`Auto-scoped to node ${resolvedNodeId} from the current Figma selection. Pass node_id explicitly to override.`);
@@ -1058,6 +1076,8 @@ getMetadataChanges) {
1058
1076
  let cursor;
1059
1077
  let apiCalls = 0;
1060
1078
  const cacheHits = 0; // version-list pagination is not snapshot-cached
1079
+ let listFetchFailed = false;
1080
+ let listFetchError;
1061
1081
  // Once we hit start_version's id in the list, switch to "collecting older" mode
1062
1082
  let foundStart = isCurrentSentinel(startVer);
1063
1083
  const MAX_PAGES = 10; // 10 × 50 = 500 versions hard cap on scan
@@ -1071,7 +1091,9 @@ getMetadataChanges) {
1071
1091
  apiCalls++;
1072
1092
  }
1073
1093
  catch (e) {
1074
- logger.warn({ err: e }, "Version list fetch failed during blame walk");
1094
+ listFetchFailed = true;
1095
+ listFetchError = e instanceof Error ? e.message : String(e);
1096
+ logger.warn({ err: e, reason: listFetchError, page }, "Version list fetch failed during blame walk");
1075
1097
  break;
1076
1098
  }
1077
1099
  const versions = response?.versions || [];
@@ -1099,9 +1121,28 @@ getMetadataChanges) {
1099
1121
  break;
1100
1122
  cursor = last.id;
1101
1123
  }
1102
- return { versions: collected, apiCalls, cacheHits };
1124
+ return { versions: collected, apiCalls, cacheHits, listFetchFailed, listFetchError };
1103
1125
  };
1104
1126
  }
1127
+ /**
1128
+ * Downgrade an attribution_certainty value one confidence level when the
1129
+ * searched version range is known to be incomplete (truncated version listing).
1130
+ * "exact" and "system_attributed" both mean "found the introducing version"
1131
+ * and downgrade to "exists_at_lookback_horizon" (the true introduction may lie
1132
+ * outside the searched range); the horizon case downgrades to
1133
+ * "metadata_unavailable", which is already the floor.
1134
+ */
1135
+ function downgradeCertainty(certainty) {
1136
+ switch (certainty) {
1137
+ case "exact":
1138
+ case "system_attributed":
1139
+ return "exists_at_lookback_horizon";
1140
+ case "exists_at_lookback_horizon":
1141
+ return "metadata_unavailable";
1142
+ default:
1143
+ return certainty;
1144
+ }
1145
+ }
1105
1146
  // Returns true if the target is present in the given node tree.
1106
1147
  function targetExists(node, target) {
1107
1148
  if (target.target_component_property) {
@@ -9,6 +9,40 @@
9
9
  */
10
10
  import { createChildLogger } from './logger.js';
11
11
  const logger = createChildLogger({ component: 'websocket-connector' });
12
+ /**
13
+ * Number of variants a CREATE_COMPONENT_SET request will produce:
14
+ * Mode B = componentIds.length; Mode A = the cartesian product of the
15
+ * property axes ({State:[a,b,c], Size:[s,l]} → 6).
16
+ */
17
+ export function componentSetVariantCount(params) {
18
+ if (params.componentIds?.length)
19
+ return params.componentIds.length;
20
+ if (params.properties) {
21
+ let count = 1;
22
+ for (const values of Object.values(params.properties)) {
23
+ count *= Math.max(1, values?.length ?? 1);
24
+ }
25
+ return count;
26
+ }
27
+ return 1;
28
+ }
29
+ /**
30
+ * Server-hop timeout for CREATE_COMPONENT_SET, scaled by variant count.
31
+ * The plugin builds all variants in ONE uncancellable pass, so timing out
32
+ * early doesn't stop the work — it just makes the report contradict the
33
+ * file state (set gets created after the "failure") and invites duplicate
34
+ * retries. Budget ~1.2s per variant with a 30s floor and 120s cap, plus a
35
+ * 5s buffer over the ui.html hop (which uses the same base formula) so the
36
+ * plugin-side result or error always wins the race.
37
+ *
38
+ * NOTE: ui.html's CREATE_COMPONENT_SET route and
39
+ * cloud-websocket-connector.ts mirror the base formula — keep all three in
40
+ * sync.
41
+ */
42
+ export function componentSetTimeoutMs(params) {
43
+ const count = componentSetVariantCount(params);
44
+ return Math.min(120000, Math.max(30000, count * 1200)) + 5000;
45
+ }
12
46
  export class WebSocketConnector {
13
47
  constructor(wsServer) {
14
48
  this.wsServer = wsServer;
@@ -188,6 +222,14 @@ export class WebSocketConnector {
188
222
  }
189
223
  return this.wsServer.sendCommand('INSTANTIATE_COMPONENT', params);
190
224
  }
225
+ async createComponentSet(params) {
226
+ // Cloning + combining large variant matrices is slow on heavy components
227
+ // and the plugin-side pass is uncancellable — a fixed 30s ceiling made a
228
+ // 48-variant set REPORT failure while the plugin kept going and created
229
+ // the set anyway (retry → duplicates). Scale the timeout with variant
230
+ // count instead (precedent: the token-import create phase).
231
+ return this.wsServer.sendCommand('CREATE_COMPONENT_SET', params, componentSetTimeoutMs(params));
232
+ }
191
233
  // ============================================================================
192
234
  // Node manipulation
193
235
  // ============================================================================
@@ -28,6 +28,41 @@ try {
28
28
  catch {
29
29
  // Non-critical — version will show as 0.0.0
30
30
  }
31
+ /**
32
+ * Extract the PLUGIN_VERSION constant from figma-desktop-bridge/code.js source.
33
+ * Returns null when the constant is absent or malformed.
34
+ */
35
+ export function parseBundledPluginVersion(codeJsSource) {
36
+ const match = codeJsSource.match(/var PLUGIN_VERSION\s*=\s*'(\d+\.\d+\.\d+)'/);
37
+ return match ? match[1] : null;
38
+ }
39
+ // The version of the plugin files THIS package ships — i.e. what a manifest
40
+ // re-import would install. This is deliberately NOT the package version:
41
+ // server-only releases (deps, docs, server code) bump package.json without
42
+ // touching figma-desktop-bridge/, and comparing the plugin against
43
+ // SERVER_VERSION would falsely nag users to re-import an already-current
44
+ // plugin (seen live: plugin 1.33.0 + server 1.33.1 → spurious update banner).
45
+ let BUNDLED_PLUGIN_VERSION = SERVER_VERSION;
46
+ try {
47
+ const parsed = parseBundledPluginVersion(readFileSync(join(PACKAGE_ROOT, 'figma-desktop-bridge', 'code.js'), 'utf-8'));
48
+ if (parsed)
49
+ BUNDLED_PLUGIN_VERSION = parsed;
50
+ }
51
+ catch {
52
+ // Non-critical — fall back to SERVER_VERSION (pre-v1.33.2 behavior)
53
+ }
54
+ /** Version of the plugin files bundled with this server (what a re-import installs). */
55
+ export function getBundledPluginVersion() {
56
+ return BUNDLED_PLUGIN_VERSION;
57
+ }
58
+ /**
59
+ * A plugin needs re-importing when it reports no version (predates version
60
+ * reporting) or a version different from the plugin files this server ships.
61
+ * The server's own package version is irrelevant here.
62
+ */
63
+ export function computePluginUpdateAvailable(pluginVersion, bundledPluginVersion) {
64
+ return !pluginVersion || pluginVersion !== bundledPluginVersion;
65
+ }
31
66
  const logger = createChildLogger({ component: 'websocket-server' });
32
67
  export class FigmaWebSocketServer extends EventEmitter {
33
68
  constructor(options) {
@@ -113,10 +148,12 @@ export class FigmaWebSocketServer extends EventEmitter {
113
148
  // Mitigate Cross-Site WebSocket Hijacking (CSWSH):
114
149
  // Reject connections from unexpected browser origins.
115
150
  const origin = info.origin;
151
+ // Exact match only — startsWith would let e.g.
152
+ // https://www.figma.com.attacker.example through.
116
153
  const allowed = !origin || // No origin — local process (e.g. Node.js client)
117
154
  origin === 'null' || // Sandboxed iframe / Figma Desktop plugin UI
118
- origin.startsWith('https://www.figma.com') ||
119
- origin.startsWith('https://figma.com');
155
+ origin === 'https://www.figma.com' ||
156
+ origin === 'https://figma.com';
120
157
  if (allowed) {
121
158
  callback(true);
122
159
  }
@@ -250,15 +287,35 @@ export class FigmaWebSocketServer extends EventEmitter {
250
287
  // Response to a command we sent
251
288
  if (message.id && this.pendingRequests.has(message.id)) {
252
289
  const pending = this.pendingRequests.get(message.id);
253
- clearTimeout(pending.timeoutId);
254
- this.pendingRequests.delete(message.id);
255
- if (message.error) {
256
- pending.reject(new Error(message.error));
290
+ // Verify the response arrived on the socket belonging to the file the
291
+ // command targeted — a matching id alone must not let a different
292
+ // client resolve (or spoof) another file's request.
293
+ const sender = this.findClientByWs(ws);
294
+ const socketVerified = sender
295
+ ? sender.fileKey === pending.targetFileKey
296
+ // Pre-identification socket (no FILE_INFO yet): only safe when it is
297
+ // unambiguous — exactly one socket connected to the server at all.
298
+ : (this.wss?.clients.size ?? 0) === 1;
299
+ if (!socketVerified) {
300
+ logger.warn({
301
+ id: message.id,
302
+ method: pending.method,
303
+ senderFileKey: sender?.fileKey ?? null,
304
+ targetFileKey: pending.targetFileKey,
305
+ }, 'Ignoring response from unexpected socket (id matched, sender did not) — treating as unsolicited');
306
+ // Leave the pending request in place; fall through to unsolicited handling.
257
307
  }
258
308
  else {
259
- pending.resolve(message.result);
309
+ clearTimeout(pending.timeoutId);
310
+ this.pendingRequests.delete(message.id);
311
+ if (message.error) {
312
+ pending.reject(new Error(message.error));
313
+ }
314
+ else {
315
+ pending.resolve(message.result);
316
+ }
317
+ return;
260
318
  }
261
- return;
262
319
  }
263
320
  // Unsolicited data from plugin (FILE_INFO, events, forwarded data)
264
321
  if (message.type) {
@@ -384,7 +441,15 @@ export class FigmaWebSocketServer extends EventEmitter {
384
441
  if (this._activeFileKey === previousEntry.fileKey) {
385
442
  this._activeFileKey = null;
386
443
  }
444
+ // In-flight commands to the old file can never receive a response now —
445
+ // reject them immediately instead of leaving them to hang until timeout
446
+ // (mirrors the same-file replacement path below).
447
+ this.rejectPendingRequestsForFile(previousEntry.fileKey, 'Plugin switched files');
387
448
  logger.info({ oldFileKey: previousEntry.fileKey, newFileKey: fileKey }, 'WebSocket client switched files');
449
+ this.emit('fileDisconnected', {
450
+ fileKey: previousEntry.fileKey,
451
+ fileName: previousEntry.client.fileInfo.fileName,
452
+ });
388
453
  }
389
454
  // If same fileKey already connected with a DIFFERENT ws, clean up old connection
390
455
  const existing = this.clients.get(fileKey);
@@ -401,6 +466,30 @@ export class FigmaWebSocketServer extends EventEmitter {
401
466
  existing.ws.close(1000, 'Replaced by same file reconnection');
402
467
  }
403
468
  }
469
+ // Version handshake: the plugin reports its code.js version in FILE_INFO.
470
+ // Figma caches plugin files at the app level, so a version mismatch means
471
+ // the user is running stale plugin code and must re-import manifest.json.
472
+ // A missing version means the plugin predates version reporting — also stale.
473
+ // Compared against the BUNDLED plugin version (what a re-import would
474
+ // install), NOT the server's package version — server-only releases don't
475
+ // change the plugin files, so they must not trigger the re-import banner.
476
+ const bundledPluginVersion = this.options.bundledPluginVersion ?? BUNDLED_PLUGIN_VERSION;
477
+ const pluginVersion = data.pluginVersion || null;
478
+ const pluginUpdateAvailable = computePluginUpdateAvailable(pluginVersion, bundledPluginVersion);
479
+ if (pluginUpdateAvailable) {
480
+ logger.warn({ fileKey, pluginVersion, bundledPluginVersion, serverVersion: SERVER_VERSION }, 'Imported plugin version differs from the plugin files bundled with this server — re-import manifest.json to update');
481
+ try {
482
+ ws.send(JSON.stringify({
483
+ type: 'PLUGIN_UPDATE_AVAILABLE',
484
+ serverVersion: SERVER_VERSION,
485
+ bundledPluginVersion,
486
+ pluginVersion,
487
+ }));
488
+ }
489
+ catch {
490
+ // Non-critical notification — never let it disrupt registration
491
+ }
492
+ }
404
493
  // Create client connection (preserve per-file state from previous connection of same file)
405
494
  this.clients.set(fileKey, {
406
495
  ws,
@@ -411,6 +500,8 @@ export class FigmaWebSocketServer extends EventEmitter {
411
500
  currentPageId: data.currentPageId || null,
412
501
  editorType: data.editorType || 'figma',
413
502
  connectedAt: Date.now(),
503
+ pluginVersion,
504
+ pluginUpdateAvailable,
414
505
  },
415
506
  selection: existing?.selection || null,
416
507
  documentChanges: existing?.documentChanges || [],