@mp3wizard/figma-console-mcp 1.14.0
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/LICENSE +21 -0
- package/README.md +816 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.js +278 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts +29 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.js +358 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.js +342 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.js +231 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/engine.d.ts +27 -0
- package/dist/apps/design-system-dashboard/scoring/engine.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/engine.js +93 -0
- package/dist/apps/design-system-dashboard/scoring/engine.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.js +309 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.js +350 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/types.d.ts +89 -0
- package/dist/apps/design-system-dashboard/scoring/types.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/types.js +41 -0
- package/dist/apps/design-system-dashboard/scoring/types.js.map +1 -0
- package/dist/apps/design-system-dashboard/server.d.ts +24 -0
- package/dist/apps/design-system-dashboard/server.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/server.js +160 -0
- package/dist/apps/design-system-dashboard/server.js.map +1 -0
- package/dist/apps/token-browser/server.d.ts +26 -0
- package/dist/apps/token-browser/server.d.ts.map +1 -0
- package/dist/apps/token-browser/server.js +137 -0
- package/dist/apps/token-browser/server.js.map +1 -0
- package/dist/browser/base.d.ts +58 -0
- package/dist/browser/base.d.ts.map +1 -0
- package/dist/browser/base.js +6 -0
- package/dist/browser/base.js.map +1 -0
- package/dist/browser/local.d.ts +87 -0
- package/dist/browser/local.d.ts.map +1 -0
- package/dist/browser/local.js +318 -0
- package/dist/browser/local.js.map +1 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +277 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +357 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +341 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +230 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +92 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +308 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +349 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +40 -0
- package/dist/cloudflare/apps/design-system-dashboard/server.js +159 -0
- package/dist/cloudflare/apps/token-browser/server.js +136 -0
- package/dist/cloudflare/browser/base.js +5 -0
- package/dist/cloudflare/browser/cloudflare.js +156 -0
- package/dist/cloudflare/browser-manager.js +157 -0
- package/dist/cloudflare/core/cloud-websocket-connector.js +267 -0
- package/dist/cloudflare/core/cloud-websocket-relay.js +199 -0
- package/dist/cloudflare/core/comment-tools.js +292 -0
- package/dist/cloudflare/core/config.js +161 -0
- package/dist/cloudflare/core/console-monitor.js +427 -0
- package/dist/cloudflare/core/design-code-tools.js +2504 -0
- package/dist/cloudflare/core/design-system-manifest.js +260 -0
- package/dist/cloudflare/core/design-system-tools.js +863 -0
- package/dist/cloudflare/core/enrichment/enrichment-service.js +272 -0
- package/dist/cloudflare/core/enrichment/index.js +7 -0
- package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
- package/dist/cloudflare/core/enrichment/style-resolver.js +326 -0
- package/dist/cloudflare/core/figma-api.js +409 -0
- package/dist/cloudflare/core/figma-connector.js +7 -0
- package/dist/cloudflare/core/figma-desktop-connector.js +1184 -0
- package/dist/cloudflare/core/figma-reconstruction-spec.js +402 -0
- package/dist/cloudflare/core/figma-style-extractor.js +311 -0
- package/dist/cloudflare/core/figma-tools.js +2947 -0
- package/dist/cloudflare/core/logger.js +53 -0
- package/dist/cloudflare/core/port-discovery.js +282 -0
- package/dist/cloudflare/core/snippet-injector.js +96 -0
- package/dist/cloudflare/core/types/design-code.js +4 -0
- package/dist/cloudflare/core/types/enriched.js +5 -0
- package/dist/cloudflare/core/types/index.js +4 -0
- package/dist/cloudflare/core/websocket-connector.js +256 -0
- package/dist/cloudflare/core/websocket-server.js +646 -0
- package/dist/cloudflare/core/write-tools.js +2091 -0
- package/dist/cloudflare/index.js +2899 -0
- package/dist/cloudflare/test-browser.js +88 -0
- package/dist/core/comment-tools.d.ts +11 -0
- package/dist/core/comment-tools.d.ts.map +1 -0
- package/dist/core/comment-tools.js +293 -0
- package/dist/core/comment-tools.js.map +1 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +162 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/console-monitor.d.ts +82 -0
- package/dist/core/console-monitor.d.ts.map +1 -0
- package/dist/core/console-monitor.js +428 -0
- package/dist/core/console-monitor.js.map +1 -0
- package/dist/core/design-code-tools.d.ts +127 -0
- package/dist/core/design-code-tools.d.ts.map +1 -0
- package/dist/core/design-code-tools.js +2505 -0
- package/dist/core/design-code-tools.js.map +1 -0
- package/dist/core/design-system-manifest.d.ts +272 -0
- package/dist/core/design-system-manifest.d.ts.map +1 -0
- package/dist/core/design-system-manifest.js +261 -0
- package/dist/core/design-system-manifest.js.map +1 -0
- package/dist/core/design-system-tools.d.ts +17 -0
- package/dist/core/design-system-tools.d.ts.map +1 -0
- package/dist/core/design-system-tools.js +864 -0
- package/dist/core/design-system-tools.js.map +1 -0
- package/dist/core/enrichment/enrichment-service.d.ts +52 -0
- package/dist/core/enrichment/enrichment-service.d.ts.map +1 -0
- package/dist/core/enrichment/enrichment-service.js +273 -0
- package/dist/core/enrichment/enrichment-service.js.map +1 -0
- package/dist/core/enrichment/index.d.ts +8 -0
- package/dist/core/enrichment/index.d.ts.map +1 -0
- package/dist/core/enrichment/index.js +8 -0
- package/dist/core/enrichment/index.js.map +1 -0
- package/dist/core/enrichment/relationship-mapper.d.ts +106 -0
- package/dist/core/enrichment/relationship-mapper.d.ts.map +1 -0
- package/dist/core/enrichment/relationship-mapper.js +352 -0
- package/dist/core/enrichment/relationship-mapper.js.map +1 -0
- package/dist/core/enrichment/style-resolver.d.ts +80 -0
- package/dist/core/enrichment/style-resolver.d.ts.map +1 -0
- package/dist/core/enrichment/style-resolver.js +327 -0
- package/dist/core/enrichment/style-resolver.js.map +1 -0
- package/dist/core/figma-api.d.ts +201 -0
- package/dist/core/figma-api.d.ts.map +1 -0
- package/dist/core/figma-api.js +410 -0
- package/dist/core/figma-api.js.map +1 -0
- package/dist/core/figma-connector.d.ts +48 -0
- package/dist/core/figma-connector.d.ts.map +1 -0
- package/dist/core/figma-connector.js +8 -0
- package/dist/core/figma-connector.js.map +1 -0
- package/dist/core/figma-desktop-connector.d.ts +265 -0
- package/dist/core/figma-desktop-connector.d.ts.map +1 -0
- package/dist/core/figma-desktop-connector.js +1184 -0
- package/dist/core/figma-desktop-connector.js.map +1 -0
- package/dist/core/figma-reconstruction-spec.d.ts +166 -0
- package/dist/core/figma-reconstruction-spec.d.ts.map +1 -0
- package/dist/core/figma-reconstruction-spec.js +403 -0
- package/dist/core/figma-reconstruction-spec.js.map +1 -0
- package/dist/core/figma-style-extractor.d.ts +76 -0
- package/dist/core/figma-style-extractor.d.ts.map +1 -0
- package/dist/core/figma-style-extractor.js +312 -0
- package/dist/core/figma-style-extractor.js.map +1 -0
- package/dist/core/figma-tools.d.ts +23 -0
- package/dist/core/figma-tools.d.ts.map +1 -0
- package/dist/core/figma-tools.js +2948 -0
- package/dist/core/figma-tools.js.map +1 -0
- package/dist/core/logger.d.ts +22 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +54 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/port-discovery.d.ts +110 -0
- package/dist/core/port-discovery.d.ts.map +1 -0
- package/dist/core/port-discovery.js +283 -0
- package/dist/core/port-discovery.js.map +1 -0
- package/dist/core/snippet-injector.d.ts +24 -0
- package/dist/core/snippet-injector.d.ts.map +1 -0
- package/dist/core/snippet-injector.js +97 -0
- package/dist/core/snippet-injector.js.map +1 -0
- package/dist/core/types/design-code.d.ts +262 -0
- package/dist/core/types/design-code.d.ts.map +1 -0
- package/dist/core/types/design-code.js +5 -0
- package/dist/core/types/design-code.js.map +1 -0
- package/dist/core/types/enriched.d.ts +213 -0
- package/dist/core/types/enriched.d.ts.map +1 -0
- package/dist/core/types/enriched.js +6 -0
- package/dist/core/types/enriched.js.map +1 -0
- package/dist/core/types/index.d.ts +112 -0
- package/dist/core/types/index.d.ts.map +1 -0
- package/dist/core/types/index.js +5 -0
- package/dist/core/types/index.js.map +1 -0
- package/dist/core/websocket-connector.d.ts +55 -0
- package/dist/core/websocket-connector.d.ts.map +1 -0
- package/dist/core/websocket-connector.js +257 -0
- package/dist/core/websocket-connector.js.map +1 -0
- package/dist/core/websocket-server.d.ts +191 -0
- package/dist/core/websocket-server.d.ts.map +1 -0
- package/dist/core/websocket-server.js +647 -0
- package/dist/core/websocket-server.js.map +1 -0
- package/dist/core/write-tools.d.ts +7 -0
- package/dist/core/write-tools.d.ts.map +1 -0
- package/dist/core/write-tools.js +2092 -0
- package/dist/core/write-tools.js.map +1 -0
- package/dist/local.d.ts +84 -0
- package/dist/local.d.ts.map +1 -0
- package/dist/local.js +5039 -0
- package/dist/local.js.map +1 -0
- package/figma-desktop-bridge/README.md +313 -0
- package/figma-desktop-bridge/code.js +2818 -0
- package/figma-desktop-bridge/manifest.json +67 -0
- package/figma-desktop-bridge/ui.html +1236 -0
- package/package.json +87 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Bridge Server (Multi-Client)
|
|
3
|
+
*
|
|
4
|
+
* Creates a WebSocket server that multiple Desktop Bridge plugin instances connect to.
|
|
5
|
+
* Each instance represents a different Figma file and is identified by its fileKey
|
|
6
|
+
* (sent via FILE_INFO on connection). Per-file state (selection, document changes,
|
|
7
|
+
* console logs) is maintained independently.
|
|
8
|
+
*
|
|
9
|
+
* Active file tracking: The "active" file is automatically switched when the user
|
|
10
|
+
* interacts with a file (selection/page changes) or can be set explicitly via
|
|
11
|
+
* setActiveFile(). All backward-compatible getters return data from the active file.
|
|
12
|
+
*
|
|
13
|
+
* Data flow: MCP Server ←WebSocket→ ui.html ←postMessage→ code.js ←figma.*→ Figma
|
|
14
|
+
*/
|
|
15
|
+
import { WebSocketServer as WSServer, WebSocket } from 'ws';
|
|
16
|
+
import { EventEmitter } from 'events';
|
|
17
|
+
import { readFileSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { createChildLogger } from './logger.js';
|
|
20
|
+
// Read version from package.json
|
|
21
|
+
// Uses __dirname in CJS/Jest context, falls back to process.cwd() in ESM runtime
|
|
22
|
+
let SERVER_VERSION = '0.0.0';
|
|
23
|
+
try {
|
|
24
|
+
const base = typeof __dirname !== 'undefined' ? join(__dirname, '..', '..') : process.cwd();
|
|
25
|
+
SERVER_VERSION = JSON.parse(readFileSync(join(base, 'package.json'), 'utf-8')).version;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Non-critical — version will show as 0.0.0
|
|
29
|
+
}
|
|
30
|
+
const logger = createChildLogger({ component: 'websocket-server' });
|
|
31
|
+
export class FigmaWebSocketServer extends EventEmitter {
|
|
32
|
+
constructor(options) {
|
|
33
|
+
super();
|
|
34
|
+
this.wss = null;
|
|
35
|
+
/** Named clients indexed by fileKey — each represents a connected Figma file */
|
|
36
|
+
this.clients = new Map();
|
|
37
|
+
/** Clients awaiting FILE_INFO identification, mapped to their pending timeout */
|
|
38
|
+
this._pendingClients = new Map();
|
|
39
|
+
/** The fileKey of the currently active (targeted) file */
|
|
40
|
+
this._activeFileKey = null;
|
|
41
|
+
this.pendingRequests = new Map();
|
|
42
|
+
this.requestIdCounter = 0;
|
|
43
|
+
this._isStarted = false;
|
|
44
|
+
this._startedAt = Date.now();
|
|
45
|
+
this.consoleBufferSize = 1000;
|
|
46
|
+
this.documentChangeBufferSize = 200;
|
|
47
|
+
this.options = options;
|
|
48
|
+
this._startedAt = Date.now();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Start the WebSocket server
|
|
52
|
+
*/
|
|
53
|
+
async start() {
|
|
54
|
+
if (this._isStarted)
|
|
55
|
+
return;
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
try {
|
|
58
|
+
this.wss = new WSServer({
|
|
59
|
+
port: this.options.port,
|
|
60
|
+
host: this.options.host || 'localhost',
|
|
61
|
+
maxPayload: 25 * 1024 * 1024, // 25MB — sufficient for screenshots; set FIGMA_WS_MAX_PAYLOAD_MB env var to override
|
|
62
|
+
verifyClient: (info, callback) => {
|
|
63
|
+
// Mitigate Cross-Site WebSocket Hijacking (CSWSH):
|
|
64
|
+
// Reject connections from unexpected browser origins.
|
|
65
|
+
const origin = info.origin;
|
|
66
|
+
const allowed = !origin || // No origin — local process (e.g. Node.js client)
|
|
67
|
+
origin === 'null' || // Sandboxed iframe / Figma Desktop plugin UI
|
|
68
|
+
origin.startsWith('https://www.figma.com') ||
|
|
69
|
+
origin.startsWith('https://figma.com');
|
|
70
|
+
if (allowed) {
|
|
71
|
+
callback(true);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
logger.warn({ origin }, 'Rejected WebSocket connection from unauthorized origin');
|
|
75
|
+
callback(false, 403, 'Unauthorized Origin');
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
this.wss.on('listening', () => {
|
|
80
|
+
this._isStarted = true;
|
|
81
|
+
logger.info({ port: this.options.port, host: this.options.host || 'localhost' }, 'WebSocket bridge server started');
|
|
82
|
+
resolve();
|
|
83
|
+
});
|
|
84
|
+
this.wss.on('error', (error) => {
|
|
85
|
+
if (!this._isStarted) {
|
|
86
|
+
reject(error);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
logger.error({ error }, 'WebSocket server error');
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
this.wss.on('connection', (ws) => {
|
|
93
|
+
// Add to pending until FILE_INFO identifies the file
|
|
94
|
+
const pendingTimeout = setTimeout(() => {
|
|
95
|
+
if (this._pendingClients.has(ws)) {
|
|
96
|
+
this._pendingClients.delete(ws);
|
|
97
|
+
logger.warn('Pending WebSocket client timed out without sending FILE_INFO');
|
|
98
|
+
ws.close(1000, 'File identification timeout');
|
|
99
|
+
}
|
|
100
|
+
}, 30000);
|
|
101
|
+
this._pendingClients.set(ws, pendingTimeout);
|
|
102
|
+
// Send server identity to the client for debugging and logging
|
|
103
|
+
try {
|
|
104
|
+
ws.send(JSON.stringify({
|
|
105
|
+
type: 'SERVER_HELLO',
|
|
106
|
+
data: {
|
|
107
|
+
port: this.wss.address()?.port,
|
|
108
|
+
pid: process.pid,
|
|
109
|
+
serverVersion: SERVER_VERSION,
|
|
110
|
+
startedAt: this._startedAt,
|
|
111
|
+
},
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Non-critical — client will still work without SERVER_HELLO
|
|
116
|
+
}
|
|
117
|
+
logger.info({ totalClients: this.clients.size, pendingClients: this._pendingClients.size }, 'New WebSocket connection (pending file identification)');
|
|
118
|
+
ws.on('message', (data) => {
|
|
119
|
+
try {
|
|
120
|
+
let text;
|
|
121
|
+
if (typeof data === 'string') {
|
|
122
|
+
text = data;
|
|
123
|
+
}
|
|
124
|
+
else if (Buffer.isBuffer(data)) {
|
|
125
|
+
text = data.toString();
|
|
126
|
+
}
|
|
127
|
+
else if (Array.isArray(data)) {
|
|
128
|
+
text = Buffer.concat(data).toString();
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
text = Buffer.from(data).toString();
|
|
132
|
+
}
|
|
133
|
+
const message = JSON.parse(text);
|
|
134
|
+
this.handleMessage(message, ws);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
logger.error({ error }, 'Failed to parse WebSocket message');
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
ws.on('close', (code, reason) => {
|
|
141
|
+
this.handleClientDisconnect(ws, code, reason.toString());
|
|
142
|
+
});
|
|
143
|
+
ws.on('error', (error) => {
|
|
144
|
+
logger.error({ error }, 'WebSocket client error');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
reject(error);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Find a named client connection by its WebSocket reference
|
|
155
|
+
*/
|
|
156
|
+
findClientByWs(ws) {
|
|
157
|
+
for (const [fileKey, client] of this.clients) {
|
|
158
|
+
if (client.ws === ws)
|
|
159
|
+
return { fileKey, client };
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Handle incoming message from a plugin UI WebSocket connection
|
|
165
|
+
*/
|
|
166
|
+
handleMessage(message, ws) {
|
|
167
|
+
// Response to a command we sent
|
|
168
|
+
if (message.id && this.pendingRequests.has(message.id)) {
|
|
169
|
+
const pending = this.pendingRequests.get(message.id);
|
|
170
|
+
clearTimeout(pending.timeoutId);
|
|
171
|
+
this.pendingRequests.delete(message.id);
|
|
172
|
+
if (message.error) {
|
|
173
|
+
pending.reject(new Error(message.error));
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
pending.resolve(message.result);
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// Unsolicited data from plugin (FILE_INFO, events, forwarded data)
|
|
181
|
+
if (message.type) {
|
|
182
|
+
// FILE_INFO promotes pending clients to named clients
|
|
183
|
+
if (message.type === 'FILE_INFO' && message.data) {
|
|
184
|
+
this.handleFileInfo(message.data, ws);
|
|
185
|
+
}
|
|
186
|
+
// Buffer document changes for the specific file
|
|
187
|
+
if (message.type === 'DOCUMENT_CHANGE' && message.data) {
|
|
188
|
+
const found = this.findClientByWs(ws);
|
|
189
|
+
if (found) {
|
|
190
|
+
const entry = {
|
|
191
|
+
hasStyleChanges: message.data.hasStyleChanges,
|
|
192
|
+
hasNodeChanges: message.data.hasNodeChanges,
|
|
193
|
+
changedNodeIds: message.data.changedNodeIds || [],
|
|
194
|
+
changeCount: message.data.changeCount || 0,
|
|
195
|
+
timestamp: message.data.timestamp || Date.now(),
|
|
196
|
+
};
|
|
197
|
+
found.client.documentChanges.push(entry);
|
|
198
|
+
if (found.client.documentChanges.length > this.documentChangeBufferSize) {
|
|
199
|
+
found.client.documentChanges.shift();
|
|
200
|
+
}
|
|
201
|
+
found.client.lastActivity = Date.now();
|
|
202
|
+
}
|
|
203
|
+
this.emit('documentChange', { fileKey: found?.fileKey ?? null, ...message.data });
|
|
204
|
+
}
|
|
205
|
+
// Track selection changes — user interaction makes this the active file
|
|
206
|
+
if (message.type === 'SELECTION_CHANGE' && message.data) {
|
|
207
|
+
const found = this.findClientByWs(ws);
|
|
208
|
+
if (found) {
|
|
209
|
+
found.client.selection = message.data;
|
|
210
|
+
found.client.lastActivity = Date.now();
|
|
211
|
+
this._activeFileKey = found.fileKey;
|
|
212
|
+
}
|
|
213
|
+
this.emit('selectionChange', { fileKey: found?.fileKey ?? null, ...message.data });
|
|
214
|
+
}
|
|
215
|
+
// Track page changes — user interaction makes this the active file
|
|
216
|
+
if (message.type === 'PAGE_CHANGE' && message.data) {
|
|
217
|
+
const found = this.findClientByWs(ws);
|
|
218
|
+
if (found) {
|
|
219
|
+
found.client.fileInfo.currentPage = message.data.pageName;
|
|
220
|
+
found.client.fileInfo.currentPageId = message.data.pageId || null;
|
|
221
|
+
found.client.lastActivity = Date.now();
|
|
222
|
+
this._activeFileKey = found.fileKey;
|
|
223
|
+
}
|
|
224
|
+
this.emit('pageChange', { fileKey: found?.fileKey ?? null, ...message.data });
|
|
225
|
+
}
|
|
226
|
+
// Capture console logs for the specific file
|
|
227
|
+
if (message.type === 'CONSOLE_CAPTURE' && message.data) {
|
|
228
|
+
const found = this.findClientByWs(ws);
|
|
229
|
+
const data = message.data;
|
|
230
|
+
const entry = {
|
|
231
|
+
timestamp: data.timestamp || Date.now(),
|
|
232
|
+
level: data.level || 'log',
|
|
233
|
+
message: typeof data.message === 'string' ? data.message.substring(0, 1000) : String(data.message),
|
|
234
|
+
args: Array.isArray(data.args) ? data.args.slice(0, 10) : [],
|
|
235
|
+
source: 'plugin',
|
|
236
|
+
};
|
|
237
|
+
if (found) {
|
|
238
|
+
found.client.consoleLogs.push(entry);
|
|
239
|
+
if (found.client.consoleLogs.length > this.consoleBufferSize) {
|
|
240
|
+
found.client.consoleLogs.shift();
|
|
241
|
+
}
|
|
242
|
+
found.client.lastActivity = Date.now();
|
|
243
|
+
}
|
|
244
|
+
this.emit('consoleLog', entry);
|
|
245
|
+
}
|
|
246
|
+
this.emit('pluginMessage', message);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
logger.debug({ message }, 'Unhandled WebSocket message');
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Handle FILE_INFO message — promotes pending clients to named clients.
|
|
253
|
+
* This is the critical multi-client identification step: each plugin reports
|
|
254
|
+
* its fileKey on connect, allowing the server to track multiple files.
|
|
255
|
+
*/
|
|
256
|
+
handleFileInfo(data, ws) {
|
|
257
|
+
const fileKey = data.fileKey || null;
|
|
258
|
+
if (!fileKey) {
|
|
259
|
+
logger.warn('FILE_INFO received without fileKey — client remains pending');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// Remove from pending clients (cancel identification timeout)
|
|
263
|
+
const pendingTimeout = this._pendingClients.get(ws);
|
|
264
|
+
if (pendingTimeout) {
|
|
265
|
+
clearTimeout(pendingTimeout);
|
|
266
|
+
this._pendingClients.delete(ws);
|
|
267
|
+
}
|
|
268
|
+
// Check if this ws was already registered under a different fileKey
|
|
269
|
+
// (shouldn't happen in practice — each plugin instance is per-file)
|
|
270
|
+
const previousEntry = this.findClientByWs(ws);
|
|
271
|
+
if (previousEntry && previousEntry.fileKey !== fileKey) {
|
|
272
|
+
this.clients.delete(previousEntry.fileKey);
|
|
273
|
+
if (this._activeFileKey === previousEntry.fileKey) {
|
|
274
|
+
this._activeFileKey = null;
|
|
275
|
+
}
|
|
276
|
+
logger.info({ oldFileKey: previousEntry.fileKey, newFileKey: fileKey }, 'WebSocket client switched files');
|
|
277
|
+
}
|
|
278
|
+
// If same fileKey already connected with a DIFFERENT ws, clean up old connection
|
|
279
|
+
const existing = this.clients.get(fileKey);
|
|
280
|
+
if (existing && existing.ws !== ws) {
|
|
281
|
+
logger.info({ fileKey }, 'Replacing existing connection for same file');
|
|
282
|
+
if (existing.gracePeriodTimer) {
|
|
283
|
+
clearTimeout(existing.gracePeriodTimer);
|
|
284
|
+
}
|
|
285
|
+
// Reject any in-flight commands before replacing — the old ws close event
|
|
286
|
+
// won't find this fileKey in the map after overwrite, so pending requests
|
|
287
|
+
// would hang until timeout otherwise.
|
|
288
|
+
this.rejectPendingRequestsForFile(fileKey, 'Connection replaced by same file reconnection');
|
|
289
|
+
if (existing.ws.readyState === WebSocket.OPEN || existing.ws.readyState === WebSocket.CONNECTING) {
|
|
290
|
+
existing.ws.close(1000, 'Replaced by same file reconnection');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Create client connection (preserve per-file state from previous connection of same file)
|
|
294
|
+
this.clients.set(fileKey, {
|
|
295
|
+
ws,
|
|
296
|
+
fileInfo: {
|
|
297
|
+
fileName: data.fileName,
|
|
298
|
+
fileKey,
|
|
299
|
+
currentPage: data.currentPage,
|
|
300
|
+
currentPageId: data.currentPageId || null,
|
|
301
|
+
connectedAt: Date.now(),
|
|
302
|
+
},
|
|
303
|
+
selection: existing?.selection || null,
|
|
304
|
+
documentChanges: existing?.documentChanges || [],
|
|
305
|
+
consoleLogs: existing?.consoleLogs || [],
|
|
306
|
+
lastActivity: Date.now(),
|
|
307
|
+
gracePeriodTimer: null,
|
|
308
|
+
});
|
|
309
|
+
// Most recently connected file becomes active (user just opened the plugin there).
|
|
310
|
+
// On bulk reconnect the order is non-deterministic, but the first user interaction
|
|
311
|
+
// (SELECTION_CHANGE or PAGE_CHANGE) will correct the active file immediately.
|
|
312
|
+
this._activeFileKey = fileKey;
|
|
313
|
+
logger.info({
|
|
314
|
+
fileName: data.fileName,
|
|
315
|
+
fileKey,
|
|
316
|
+
totalClients: this.clients.size,
|
|
317
|
+
isActive: this._activeFileKey === fileKey,
|
|
318
|
+
}, 'File connected via WebSocket');
|
|
319
|
+
// Emit both events for backward compat and new features
|
|
320
|
+
this.emit('connected');
|
|
321
|
+
this.emit('fileConnected', { fileKey, fileName: data.fileName });
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Handle a client WebSocket disconnecting.
|
|
325
|
+
* Starts a grace period before removing the client to allow reconnection.
|
|
326
|
+
*/
|
|
327
|
+
handleClientDisconnect(ws, code, reason) {
|
|
328
|
+
// Check if it was a pending client (never identified itself)
|
|
329
|
+
const pendingTimeout = this._pendingClients.get(ws);
|
|
330
|
+
if (pendingTimeout) {
|
|
331
|
+
clearTimeout(pendingTimeout);
|
|
332
|
+
this._pendingClients.delete(ws);
|
|
333
|
+
logger.info('Pending WebSocket client disconnected before file identification');
|
|
334
|
+
this.emit('disconnected');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// Find which named client this belongs to
|
|
338
|
+
const found = this.findClientByWs(ws);
|
|
339
|
+
if (!found) {
|
|
340
|
+
logger.debug('Unknown WebSocket client disconnected');
|
|
341
|
+
this.emit('disconnected');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const { fileKey, client } = found;
|
|
345
|
+
logger.info({ fileKey, fileName: client.fileInfo.fileName, code, reason }, 'File disconnected from WebSocket');
|
|
346
|
+
// Start grace period — keep state but clean up if not reconnected
|
|
347
|
+
client.gracePeriodTimer = setTimeout(() => {
|
|
348
|
+
client.gracePeriodTimer = null;
|
|
349
|
+
// Only remove if the client in the map is still the disconnected one
|
|
350
|
+
const current = this.clients.get(fileKey);
|
|
351
|
+
if (current && current.ws === ws) {
|
|
352
|
+
this.clients.delete(fileKey);
|
|
353
|
+
this.rejectPendingRequestsForFile(fileKey, 'WebSocket client disconnected');
|
|
354
|
+
// If active file disconnected, switch to another connected file
|
|
355
|
+
if (this._activeFileKey === fileKey) {
|
|
356
|
+
this._activeFileKey = null;
|
|
357
|
+
for (const [fk, c] of this.clients) {
|
|
358
|
+
if (c.ws.readyState === WebSocket.OPEN) {
|
|
359
|
+
this._activeFileKey = fk;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
this.emit('fileDisconnected', { fileKey, fileName: client.fileInfo.fileName });
|
|
365
|
+
}
|
|
366
|
+
}, 5000);
|
|
367
|
+
this.emit('disconnected');
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Send a command to a plugin UI and wait for the response.
|
|
371
|
+
* By default targets the active file. Pass targetFileKey to target a specific file.
|
|
372
|
+
*/
|
|
373
|
+
sendCommand(method, params = {}, timeoutMs = 15000, targetFileKey) {
|
|
374
|
+
return new Promise((resolve, reject) => {
|
|
375
|
+
const fileKey = targetFileKey || this._activeFileKey;
|
|
376
|
+
if (!fileKey) {
|
|
377
|
+
reject(new Error('No active file connected. Make sure the Desktop Bridge plugin is open in Figma.'));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const client = this.clients.get(fileKey);
|
|
381
|
+
if (!client || client.ws.readyState !== WebSocket.OPEN) {
|
|
382
|
+
reject(new Error('No WebSocket client connected. Make sure the Desktop Bridge plugin is open in Figma.'));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const id = `ws_${++this.requestIdCounter}_${Date.now()}`;
|
|
386
|
+
const timeoutId = setTimeout(() => {
|
|
387
|
+
if (this.pendingRequests.has(id)) {
|
|
388
|
+
this.pendingRequests.delete(id);
|
|
389
|
+
reject(new Error(`WebSocket command ${method} timed out after ${timeoutMs}ms`));
|
|
390
|
+
}
|
|
391
|
+
}, timeoutMs);
|
|
392
|
+
this.pendingRequests.set(id, {
|
|
393
|
+
resolve,
|
|
394
|
+
reject,
|
|
395
|
+
method,
|
|
396
|
+
timeoutId,
|
|
397
|
+
createdAt: Date.now(),
|
|
398
|
+
targetFileKey: fileKey,
|
|
399
|
+
});
|
|
400
|
+
const message = JSON.stringify({ id, method, params });
|
|
401
|
+
try {
|
|
402
|
+
client.ws.send(message);
|
|
403
|
+
}
|
|
404
|
+
catch (sendError) {
|
|
405
|
+
this.pendingRequests.delete(id);
|
|
406
|
+
clearTimeout(timeoutId);
|
|
407
|
+
reject(new Error(`Failed to send WebSocket command ${method}: ${sendError instanceof Error ? sendError.message : String(sendError)}`));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
client.lastActivity = Date.now();
|
|
411
|
+
logger.debug({ id, method, fileKey }, 'Sent WebSocket command');
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Check if any named client is connected (transport availability check)
|
|
416
|
+
*/
|
|
417
|
+
isClientConnected() {
|
|
418
|
+
for (const [, client] of this.clients) {
|
|
419
|
+
if (client.ws.readyState === WebSocket.OPEN) {
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Whether the server has been started
|
|
427
|
+
*/
|
|
428
|
+
isStarted() {
|
|
429
|
+
return this._isStarted;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Get the bound address info (port, host, family).
|
|
433
|
+
* Only available after the server has started listening.
|
|
434
|
+
* Returns the actual port — critical when using port 0 for OS-assigned ports.
|
|
435
|
+
*/
|
|
436
|
+
address() {
|
|
437
|
+
if (!this.wss)
|
|
438
|
+
return null;
|
|
439
|
+
const addr = this.wss.address();
|
|
440
|
+
if (typeof addr === 'string')
|
|
441
|
+
return null; // Unix socket path, not applicable
|
|
442
|
+
return addr;
|
|
443
|
+
}
|
|
444
|
+
// ============================================================================
|
|
445
|
+
// Active file getters (backward compatible — return active file's state)
|
|
446
|
+
// ============================================================================
|
|
447
|
+
/**
|
|
448
|
+
* Get info about the currently active Figma file.
|
|
449
|
+
* Returns null if no file is active or connected.
|
|
450
|
+
*/
|
|
451
|
+
getConnectedFileInfo() {
|
|
452
|
+
if (!this._activeFileKey)
|
|
453
|
+
return null;
|
|
454
|
+
const client = this.clients.get(this._activeFileKey);
|
|
455
|
+
return client?.fileInfo || null;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Get the current user selection in the active Figma file
|
|
459
|
+
*/
|
|
460
|
+
getCurrentSelection() {
|
|
461
|
+
if (!this._activeFileKey)
|
|
462
|
+
return null;
|
|
463
|
+
const client = this.clients.get(this._activeFileKey);
|
|
464
|
+
return client?.selection || null;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Get buffered document change events from the active file
|
|
468
|
+
*/
|
|
469
|
+
getDocumentChanges(options) {
|
|
470
|
+
if (!this._activeFileKey)
|
|
471
|
+
return [];
|
|
472
|
+
const client = this.clients.get(this._activeFileKey);
|
|
473
|
+
if (!client)
|
|
474
|
+
return [];
|
|
475
|
+
let filtered = [...client.documentChanges];
|
|
476
|
+
if (options?.since !== undefined) {
|
|
477
|
+
filtered = filtered.filter((e) => e.timestamp >= options.since);
|
|
478
|
+
}
|
|
479
|
+
if (options?.count !== undefined && options.count > 0) {
|
|
480
|
+
filtered = filtered.slice(-options.count);
|
|
481
|
+
}
|
|
482
|
+
return filtered;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Clear document change buffer for the active file
|
|
486
|
+
*/
|
|
487
|
+
clearDocumentChanges() {
|
|
488
|
+
if (!this._activeFileKey)
|
|
489
|
+
return 0;
|
|
490
|
+
const client = this.clients.get(this._activeFileKey);
|
|
491
|
+
if (!client)
|
|
492
|
+
return 0;
|
|
493
|
+
const count = client.documentChanges.length;
|
|
494
|
+
client.documentChanges = [];
|
|
495
|
+
return count;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Get console logs from the active file with optional filtering
|
|
499
|
+
*/
|
|
500
|
+
getConsoleLogs(options) {
|
|
501
|
+
if (!this._activeFileKey)
|
|
502
|
+
return [];
|
|
503
|
+
const client = this.clients.get(this._activeFileKey);
|
|
504
|
+
if (!client)
|
|
505
|
+
return [];
|
|
506
|
+
let filtered = [...client.consoleLogs];
|
|
507
|
+
if (options?.since !== undefined) {
|
|
508
|
+
filtered = filtered.filter((log) => log.timestamp >= options.since);
|
|
509
|
+
}
|
|
510
|
+
if (options?.level && options.level !== 'all') {
|
|
511
|
+
filtered = filtered.filter((log) => log.level === options.level);
|
|
512
|
+
}
|
|
513
|
+
if (options?.count !== undefined && options.count > 0) {
|
|
514
|
+
filtered = filtered.slice(-options.count);
|
|
515
|
+
}
|
|
516
|
+
return filtered;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Clear console log buffer for the active file
|
|
520
|
+
*/
|
|
521
|
+
clearConsoleLogs() {
|
|
522
|
+
if (!this._activeFileKey)
|
|
523
|
+
return 0;
|
|
524
|
+
const client = this.clients.get(this._activeFileKey);
|
|
525
|
+
if (!client)
|
|
526
|
+
return 0;
|
|
527
|
+
const count = client.consoleLogs.length;
|
|
528
|
+
client.consoleLogs = [];
|
|
529
|
+
return count;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Get console monitoring status for the active file
|
|
533
|
+
*/
|
|
534
|
+
getConsoleStatus() {
|
|
535
|
+
const client = this._activeFileKey ? this.clients.get(this._activeFileKey) : null;
|
|
536
|
+
const logs = client?.consoleLogs || [];
|
|
537
|
+
return {
|
|
538
|
+
isMonitoring: !!(client && client.ws.readyState === WebSocket.OPEN),
|
|
539
|
+
anyClientConnected: this.isClientConnected(),
|
|
540
|
+
logCount: logs.length,
|
|
541
|
+
bufferSize: this.consoleBufferSize,
|
|
542
|
+
workerCount: 0,
|
|
543
|
+
oldestTimestamp: logs[0]?.timestamp,
|
|
544
|
+
newestTimestamp: logs[logs.length - 1]?.timestamp,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
// ============================================================================
|
|
548
|
+
// Multi-client methods
|
|
549
|
+
// ============================================================================
|
|
550
|
+
/**
|
|
551
|
+
* Get info about all connected Figma files.
|
|
552
|
+
* Returns an array of ConnectedFileInfo for each file with an active WebSocket.
|
|
553
|
+
*/
|
|
554
|
+
getConnectedFiles() {
|
|
555
|
+
const files = [];
|
|
556
|
+
for (const [fileKey, client] of this.clients) {
|
|
557
|
+
if (client.ws.readyState === WebSocket.OPEN) {
|
|
558
|
+
files.push({
|
|
559
|
+
...client.fileInfo,
|
|
560
|
+
isActive: fileKey === this._activeFileKey,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return files;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Set the active file by fileKey. Returns true if the file is connected.
|
|
568
|
+
*/
|
|
569
|
+
setActiveFile(fileKey) {
|
|
570
|
+
const client = this.clients.get(fileKey);
|
|
571
|
+
if (client && client.ws.readyState === WebSocket.OPEN) {
|
|
572
|
+
this._activeFileKey = fileKey;
|
|
573
|
+
logger.info({ fileKey, fileName: client.fileInfo.fileName }, 'Active file switched');
|
|
574
|
+
this.emit('activeFileChanged', { fileKey, fileName: client.fileInfo.fileName });
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Get the currently active file's key
|
|
581
|
+
*/
|
|
582
|
+
getActiveFileKey() {
|
|
583
|
+
return this._activeFileKey;
|
|
584
|
+
}
|
|
585
|
+
// ============================================================================
|
|
586
|
+
// Cleanup
|
|
587
|
+
// ============================================================================
|
|
588
|
+
/**
|
|
589
|
+
* Reject pending requests that were sent to a specific file
|
|
590
|
+
*/
|
|
591
|
+
rejectPendingRequestsForFile(fileKey, reason) {
|
|
592
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
593
|
+
if (pending.targetFileKey === fileKey) {
|
|
594
|
+
clearTimeout(pending.timeoutId);
|
|
595
|
+
pending.reject(new Error(reason));
|
|
596
|
+
this.pendingRequests.delete(id);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Reject all pending requests (used during shutdown)
|
|
602
|
+
*/
|
|
603
|
+
rejectPendingRequests(reason) {
|
|
604
|
+
for (const [, pending] of this.pendingRequests) {
|
|
605
|
+
clearTimeout(pending.timeoutId);
|
|
606
|
+
pending.reject(new Error(reason));
|
|
607
|
+
}
|
|
608
|
+
this.pendingRequests.clear();
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Stop the server and clean up all connections
|
|
612
|
+
*/
|
|
613
|
+
async stop() {
|
|
614
|
+
// Clear all per-client grace period timers
|
|
615
|
+
for (const [, client] of this.clients) {
|
|
616
|
+
if (client.gracePeriodTimer) {
|
|
617
|
+
clearTimeout(client.gracePeriodTimer);
|
|
618
|
+
client.gracePeriodTimer = null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// Clear pending client identification timeouts
|
|
622
|
+
for (const [, timeout] of this._pendingClients) {
|
|
623
|
+
clearTimeout(timeout);
|
|
624
|
+
}
|
|
625
|
+
this._pendingClients.clear();
|
|
626
|
+
this.rejectPendingRequests('WebSocket server shutting down');
|
|
627
|
+
// Terminate all connected clients so wss.close() resolves promptly
|
|
628
|
+
if (this.wss) {
|
|
629
|
+
for (const ws of this.wss.clients) {
|
|
630
|
+
ws.terminate();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
this.clients.clear();
|
|
634
|
+
this._activeFileKey = null;
|
|
635
|
+
if (this.wss) {
|
|
636
|
+
return new Promise((resolve) => {
|
|
637
|
+
this.wss.close(() => {
|
|
638
|
+
this._isStarted = false;
|
|
639
|
+
logger.info('WebSocket bridge server stopped');
|
|
640
|
+
resolve();
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
this._isStarted = false;
|
|
645
|
+
}
|
|
646
|
+
}
|