UNPKG

spex-mcp

Version:

MCP server for Figma SpeX plugin and Cursor AI integration

1,189 lines (1,017 loc) 43.8 kB
import { WebSocketServer } from 'ws'; import fs from 'fs'; import path from 'path'; import { createWriteStream } from 'fs'; import { promisify } from 'util'; import { pipeline } from 'stream'; import { createReadStream } from 'fs'; import { exec } from 'child_process'; import AdmZip from 'adm-zip'; const pipelineAsync = promisify(pipeline); const execAsync = promisify(exec); /** * Simple session-based connection management for preventing cross-client data leakage */ class SimpleSessionManager { constructor() { this.sessions = new Map(); // sessionId -> {plugin: ws, mcpClient: connection} this.connectionToSession = new Map(); // connection -> sessionId } // Register a connection with its session ID registerConnection(sessionId, connection, type) { if (!sessionId) { console.error('🚨 Connection rejected: No session ID provided'); connection.close(); return false; } // Initialize session if doesn't exist if (!this.sessions.has(sessionId)) { this.sessions.set(sessionId, { plugin: null, mcpClient: null, createdAt: Date.now() }); } const session = this.sessions.get(sessionId); // Normalize the type key for storage const storageKey = type === 'mcp-client' ? 'mcpClient' : type; session[storageKey] = connection; this.connectionToSession.set(connection, sessionId); console.error(`✅ ${type} connected to session: ${sessionId}`); // Check if session is complete (both plugin and MCP client connected) if (session.plugin && session.mcpClient) { console.error(`🔗 Session ${sessionId} is now complete and ready`); } return true; } // Find matching plugin for MCP client request findPluginForMCPClient(mcpClientConnection) { const sessionId = this.connectionToSession.get(mcpClientConnection); if (!sessionId) { return null; } const session = this.sessions.get(sessionId); return session?.plugin || null; } // Find matching MCP client for plugin response findMCPClientForPlugin(pluginConnection) { const sessionId = this.connectionToSession.get(pluginConnection); if (!sessionId) { return null; } const session = this.sessions.get(sessionId); return session?.mcpClient || null; } // Cleanup when connection closes unregisterConnection(connection) { const sessionId = this.connectionToSession.get(connection); if (sessionId) { const session = this.sessions.get(sessionId); if (session) { // Clear the specific connection type if (session.plugin === connection) { session.plugin = null; console.error(`🔌 Plugin disconnected from session: ${sessionId}`); } if (session.mcpClient === connection) { session.mcpClient = null; console.error(`🔌 MCP Client disconnected from session: ${sessionId}`); } // Clean up empty sessions if (!session.plugin && !session.mcpClient) { this.sessions.delete(sessionId); console.error(`🗑️ Empty session ${sessionId} cleaned up`); } } this.connectionToSession.delete(connection); } } } /** * FigmaPluginManager handles all communication with Figma SpeX plugins via WebSocket */ export class FigmaPluginManager { constructor(port = 8080, force = false) { this.figmaConnections = new Set(); this.wss = null; this.fileTransfers = new Map(); // Track ongoing file transfers this.port = port; this.force = force; this.sessionManager = new SimpleSessionManager(); } /** * Kill any process using the specified port */ async killPortProcess() { try { console.error(`🔍 Checking for processes using port ${this.port}...`); let command; const platform = process.platform; if (platform === 'win32') { // Windows: Use netstat to find process using the port command = `netstat -ano | findstr :${this.port}`; } else { // Linux/macOS: Use lsof to find process using the port command = `lsof -i :${this.port}`; } const { stdout, stderr } = await execAsync(command); if (!stdout.trim()) { console.error(`✅ Port ${this.port} is available`); return; } console.error(`⚠️ Found process(es) using port ${this.port}:`); console.error(stdout); // Extract PIDs and kill them if (platform === 'win32') { // Windows: Extract PID from netstat output const lines = stdout.split('\n'); const pids = new Set(); for (const line of lines) { const match = line.trim().match(/\s+(\d+)\s*$/); if (match) { pids.add(match[1]); } } for (const pid of pids) { if (pid && pid !== '0') { try { console.error(`💀 Killing process ${pid}...`); await execAsync(`taskkill /F /PID ${pid}`); console.error(`✅ Process ${pid} killed`); } catch (error) { console.error(`⚠️ Failed to kill process ${pid}:`, error.message); } } } } else { // Linux/macOS: Extract PID from lsof output const lines = stdout.split('\n'); const pids = new Set(); for (const line of lines) { const parts = line.trim().split(/\s+/); if (parts.length >= 2 && parts[1].match(/^\d+$/)) { pids.add(parts[1]); } } for (const pid of pids) { if (pid && pid !== '0') { try { console.error(`💀 Killing process ${pid}...`); await execAsync(`kill -9 ${pid}`); console.error(`✅ Process ${pid} killed`); } catch (error) { console.error(`⚠️ Failed to kill process ${pid}:`, error.message); } } } } // Wait a moment for processes to fully terminate await new Promise(resolve => setTimeout(resolve, 1000)); } catch (error) { // If the command fails, it usually means no process is using the port if (error.code === 1 || error.message.includes('No such process')) { console.error(`✅ Port ${this.port} is available`); } else { console.error(`⚠️ Error checking port ${this.port}:`, error.message); } } } /** * Start WebSocket server for Figma SpeX plugin connections */ async setupWebSocketServer() { // Kill existing processes on the port if --force is specified if (this.force) { await this.killPortProcess(); } // Create HTTP server first for health checks const { createServer } = await import('http'); this.httpServer = createServer(); // Add health check endpoint this.httpServer.on('request', (req, res) => { if (req.url === '/health' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'healthy', timestamp: new Date().toISOString(), version: process.env.npm_package_version || '1.0.0', uptime: process.uptime(), environment: process.env.NODE_ENV || 'development', connections: this.wss?.clients?.size || 0, sessions: this.sessionManager.sessions.size || 0 })); return; } // Handle other HTTP requests res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not Found' })); }); // Create WebSocket server attached to HTTP server this.wss = new WebSocketServer({ server: this.httpServer }); // Start HTTP server this.httpServer.listen(this.port, '0.0.0.0', () => { console.error(`🚀 Server running on port ${this.port}`); console.error(`🔗 WebSocket endpoint: ws://localhost:${this.port}`); console.error(`❤️ Health check: http://localhost:${this.port}/health`); }); this.wss.on('connection', (ws, request) => { // Extract session ID and type from URL query parameters const url = new URL(request.url, 'http://localhost'); const sessionId = url.searchParams.get('session-id'); const type = url.searchParams.get('type'); if (!sessionId) { console.error('🚨 Connection rejected: No session ID in URL'); ws.close(1008, 'Session ID required'); return; } if (!['plugin', 'mcp-client'].includes(type)) { console.error('🚨 Connection rejected: Invalid client type'); ws.close(1008, 'Invalid client type. Must be plugin or mcp-client'); return; } // Register connection with explicit type const registered = this.sessionManager.registerConnection(sessionId, ws, type); if (!registered) { return; // Connection was rejected and closed } console.error(`${type} connected with session: ${sessionId}`); this.figmaConnections.add(ws); ws.on('message', async (data, isBinary) => { try { if (isBinary) { // Handle binary data (file uploads) await this.handleBinaryMessage(ws, data); } else { // Handle text data (JSON messages) const textData = Buffer.isBuffer(data) ? data.toString('utf8') : data; const message = JSON.parse(textData); console.error('Received from Figma:', message); this.handleFigmaMessage(ws, message); } } catch (error) { console.error('Error parsing message from Figma:', error); } }); ws.on('close', () => { const sessionId = this.sessionManager.connectionToSession.get(ws); console.error(`Client disconnected from session: ${sessionId || 'unknown'}`); this.figmaConnections.delete(ws); // Clean up session this.sessionManager.unregisterConnection(ws); // Clean up any ongoing file transfers for this connection this.cleanupFileTransfers(ws); }); ws.on('error', (error) => { const sessionId = this.sessionManager.connectionToSession.get(ws); console.error(`WebSocket error in session ${sessionId || 'unknown'}:`, error); this.figmaConnections.delete(ws); this.sessionManager.unregisterConnection(ws); this.cleanupFileTransfers(ws); }); // Send welcome message with session info this.sendWelcomeMessage(ws, sessionId, type); }); } /** * Handle different types of messages from Figma SpeX plugins */ handleFigmaMessage(ws, message) { switch (message.name || message.type) { case 'hello-world': this.handleHelloWorld(ws); break; case 'plugin-ready': this.handlePluginReady(ws, message); break; case 'specs-data': this.handleSpecsData(ws, message.data); break; case 'file-upload-start': this.handleFileUploadStart(ws, message.data); break; case 'file-upload-end': this.handleFileUploadEnd(ws, message.data); break; case 'spec-request': // Route spec requests from MCP client to plugin in same session this.handleSpecRequestRouting(ws, message); break; case 'spec-response': // Route spec responses from plugin back to MCP client in same session this.handleSpecResponseRouting(ws, message); break; default: console.error('Unknown message type:', message.type || message.name); } } /** * Route spec requests from MCP client to plugin in same session */ handleSpecRequestRouting(ws, message) { try { const sessionId = this.sessionManager.connectionToSession.get(ws); if (!sessionId) { console.error('❌ Spec request from connection without session'); return; } // Find the plugin in the same session const targetPlugin = this.sessionManager.findPluginForMCPClient(ws); if (!targetPlugin) { console.error(`❌ No plugin found for MCP client in session: ${sessionId}`); // Send error back to MCP client const errorResponse = JSON.stringify({ type: 'spec-response', requestId: message.requestId, success: false, error: 'No plugin connected in this session', timestamp: new Date().toISOString() }); ws.send(errorResponse); return; } console.error(`📤 Routing spec request from MCP client to plugin in session: ${sessionId}`); console.error(` Request type: ${message.requestType}`); // Forward the message to the plugin targetPlugin.send(JSON.stringify(message)); } catch (error) { console.error('Error routing spec request:', error); } } /** * Route spec responses from plugin back to MCP client in same session */ handleSpecResponseRouting(ws, message) { try { const sessionId = this.sessionManager.connectionToSession.get(ws); if (!sessionId) { console.error('❌ Spec response from connection without session'); return; } // Debug: Print session state console.error(`🔍 Debug session state for ${sessionId}:`); const session = this.sessionManager.sessions.get(sessionId); if (session) { console.error(` Plugin connected: ${!!session.plugin}`); console.error(` MCP Client connected: ${!!session.mcpClient}`); console.error(` Current sender is plugin: ${session.plugin === ws}`); } else { console.error(` Session not found!`); } // Find the MCP client in the same session const targetMCPClient = this.sessionManager.findMCPClientForPlugin(ws); if (!targetMCPClient) { console.error(`❌ No MCP client found for plugin in session: ${sessionId}`); // Debug: List all sessions console.error(`🔍 All sessions:`); for (const [sid, sess] of this.sessionManager.sessions.entries()) { console.error(` ${sid}: plugin=${!!sess.plugin}, mcpClient=${!!sess.mcpClient}`); } return; } console.error(`📤 Routing spec response from plugin to MCP client in session: ${sessionId}`); console.error(` Request ID: ${message.requestId}`); // Forward the message to the MCP client targetMCPClient.send(JSON.stringify(message)); } catch (error) { console.error('Error routing spec response:', error); } } /** * Handle binary message data (file chunks) */ async handleBinaryMessage(ws, binaryData) { try { const transferId = this.getActiveTransferForConnection(ws); if (!transferId) { console.error('Received binary data without active transfer'); return; } const transfer = this.fileTransfers.get(transferId); if (!transfer) { console.error('Invalid transfer ID'); return; } // Write binary chunk to file if (!transfer.writeStream.write(binaryData)) { // Handle backpressure await new Promise(resolve => transfer.writeStream.once('drain', resolve)); } transfer.receivedBytes += binaryData.length; console.error(`Received chunk: ${binaryData.length} bytes (Total: ${transfer.receivedBytes}/${transfer.expectedSize})`); // Mark transfer as ready for completion (let file-upload-end handle the processing) if (transfer.receivedBytes >= transfer.expectedSize) { transfer.isComplete = true; console.error('File transfer completed, waiting for file-upload-end message'); } } catch (error) { console.error('Error handling binary message:', error); this.sendErrorResponse(ws, 'Error processing binary data: ' + error.message); } } /** * Handle file upload start message */ handleFileUploadStart(ws, data) { try { const { filename, fileSize, transferId } = data; if (!filename || !fileSize || !transferId) { throw new Error('Missing required file upload parameters'); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const safeFilename = filename || `specs-${timestamp}.zip`; const zipPath = path.join('/tmp', safeFilename); // Ensure /tmp directory exists if (!fs.existsSync('/tmp')) { fs.mkdirSync('/tmp', { recursive: true }); } // Create write stream const writeStream = createWriteStream(zipPath); // Store transfer info this.fileTransfers.set(transferId, { ws: ws, filename: safeFilename, zipPath: zipPath, expectedSize: fileSize, receivedBytes: 0, writeStream: writeStream, startTime: Date.now(), isComplete: false }); console.error(`Starting file upload: ${safeFilename} (${fileSize} bytes)`); // Send acknowledgment ws.send(JSON.stringify({ type: 'file-upload-ready', transferId: transferId, message: 'Ready to receive file data', timestamp: new Date().toISOString() })); } catch (error) { console.error('Error starting file upload:', error); this.sendErrorResponse(ws, 'Error starting file upload: ' + error.message); } } /** * Handle file upload end message */ async handleFileUploadEnd(ws, data) { try { const { transferId } = data; if (!transferId || !this.fileTransfers.has(transferId)) { throw new Error('Invalid or missing transfer ID'); } const transfer = this.fileTransfers.get(transferId); // Check if file transfer is actually complete if (!transfer.isComplete) { throw new Error(`File transfer incomplete: ${transfer.receivedBytes}/${transfer.expectedSize} bytes received`); } await this.finishFileTransfer(ws, transferId); } catch (error) { console.error('Error ending file upload:', error); this.sendErrorResponse(ws, 'Error ending file upload: ' + error.message); } } /** * Finish file transfer and process the uploaded file */ async finishFileTransfer(ws, transferId) { const transfer = this.fileTransfers.get(transferId); if (!transfer) { throw new Error('Transfer not found'); } try { // Close the write stream transfer.writeStream.end(); // Wait for stream to finish await new Promise((resolve, reject) => { transfer.writeStream.on('finish', resolve); transfer.writeStream.on('error', reject); }); console.error(`File upload completed: ${transfer.filename}`); // Process the uploaded zip file const result = await this.processUploadedZipFile(transfer); // Send success response ws.send(JSON.stringify({ type: 'specs-data-response', success: true, message: 'File uploaded and processed successfully', data: result, transferId: transferId, timestamp: new Date().toISOString(), })); // Clean up transfer this.fileTransfers.delete(transferId); } catch (error) { console.error('Error finishing file transfer:', error); // Clean up on error try { if (fs.existsSync(transfer.zipPath)) { fs.unlinkSync(transfer.zipPath); } } catch (cleanupError) { console.error('Error during cleanup:', cleanupError); } this.fileTransfers.delete(transferId); throw error; } } /** * Process uploaded file (supports zip files and other formats) */ async processUploadedZipFile(transfer) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const extractPath = path.join('/tmp', `extracted-${timestamp}`); try { console.error(`Processing uploaded file: ${transfer.filename} (${transfer.expectedSize} bytes)`); // Verify file exists and get actual size if (!fs.existsSync(transfer.zipPath)) { throw new Error(`File not found: ${transfer.zipPath}`); } const stats = fs.statSync(transfer.zipPath); // Validate that we received all expected bytes if (stats.size !== transfer.expectedSize) { throw new Error(`File size mismatch: expected ${transfer.expectedSize} bytes, got ${stats.size} bytes`); } // Read file and check for zip signature const fileBuffer = fs.readFileSync(transfer.zipPath); // Check for zip file signature (PK\003\004 or PK\005\006) const zipSignature1 = Buffer.from([0x50, 0x4B, 0x03, 0x04]); // Local file header const zipSignature2 = Buffer.from([0x50, 0x4B, 0x05, 0x06]); // End of central directory const hasValidSignature = fileBuffer.indexOf(zipSignature1) === 0 || fileBuffer.indexOf(zipSignature2) >= 0; if (hasValidSignature && stats.size >= 22) { // Process as zip file return await this.processZipFile(transfer, fileBuffer, extractPath, stats); } else { // Process as regular file (text, JSON, etc.) return await this.processRegularFile(transfer, fileBuffer, stats); } } catch (error) { // Clean up on error try { if (fs.existsSync(transfer.zipPath)) { fs.unlinkSync(transfer.zipPath); } if (fs.existsSync(extractPath)) { fs.rmSync(extractPath, { recursive: true, force: true }); } } catch (cleanupError) { console.error('Error during cleanup:', cleanupError); } throw new Error(`Failed to process file: ${error.message}`); } } /** * Process zip file extraction */ async processZipFile(transfer, fileBuffer, extractPath, stats) { console.error(`Extracting zip file to: ${extractPath}`); const zip = new AdmZip(transfer.zipPath); // Create extraction directory if (!fs.existsSync(extractPath)) { fs.mkdirSync(extractPath, { recursive: true }); } // Extract all files zip.extractAllTo(extractPath, true); // Get list of extracted files const extractedFiles = this.getDirectoryContents(extractPath); console.error(`Zip file extracted successfully. Files extracted: ${extractedFiles.length}`); return { type: 'zip', originalFilename: transfer.filename, filePath: transfer.zipPath, extractPath: extractPath, fileSize: stats.size, uploadDuration: Date.now() - transfer.startTime, extractedFiles: extractedFiles, processedAt: new Date().toISOString() }; } /** * Process regular file (text, JSON, etc.) */ async processRegularFile(transfer, fileBuffer, stats) { console.error(`Processing as regular file`); // Try to detect file type let fileType = 'binary'; let content = null; try { // Try to parse as text content = fileBuffer.toString('utf8'); fileType = 'text'; // Try to parse as JSON JSON.parse(content); fileType = 'json'; } catch (e) { // Not JSON, keep as text or binary if (fileType === 'text') { // Check if it looks like valid text const hasNullBytes = fileBuffer.indexOf(0) !== -1; if (hasNullBytes) { fileType = 'binary'; content = null; } } } console.error(`File processed as ${fileType} (${stats.size} bytes)`); return { type: 'file', fileType: fileType, originalFilename: transfer.filename, filePath: transfer.zipPath, fileSize: stats.size, uploadDuration: Date.now() - transfer.startTime, content: fileType === 'text' || fileType === 'json' ? content : null, contentPreview: content ? content.substring(0, 200) + (content.length > 200 ? '...' : '') : 'Binary content', processedAt: new Date().toISOString() }; } /** * Get active transfer ID for a WebSocket connection */ getActiveTransferForConnection(ws) { for (const [transferId, transfer] of this.fileTransfers.entries()) { if (transfer.ws === ws) { return transferId; } } return null; } /** * Clean up file transfers for a disconnected WebSocket */ cleanupFileTransfers(ws) { const transfersToDelete = []; for (const [transferId, transfer] of this.fileTransfers.entries()) { if (transfer.ws === ws) { // Close write stream if open if (transfer.writeStream && !transfer.writeStream.destroyed) { transfer.writeStream.destroy(); } // Delete incomplete file try { if (fs.existsSync(transfer.zipPath)) { fs.unlinkSync(transfer.zipPath); } } catch (error) { console.error('Error cleaning up file:', error); } transfersToDelete.push(transferId); } } transfersToDelete.forEach(id => this.fileTransfers.delete(id)); } /** * Send error response */ sendErrorResponse(ws, errorMessage) { ws.send(JSON.stringify({ type: 'specs-data-response', success: false, message: 'Error processing specs data', error: errorMessage, timestamp: new Date().toISOString(), })); } /** * Handle plugin ready message */ handlePluginReady(ws, message) { console.error('Figma plugin is ready:', message.timestamp); ws.send(JSON.stringify({ type: 'plugin-ready-response', message: 'Server received plugin ready signal', serverTime: new Date().toISOString(), clientTime: message.timestamp, })); } /** * Send hello world response to Figma plugin */ handleHelloWorld(ws) { console.error('Hello world received from Figma plugin'); ws.send(JSON.stringify({ type: 'function-response', message: 'Hello world received at ' + new Date().toISOString(), timestamp: new Date().toISOString(), })); } /** * Handle specs data from Figma plugin - now supports both JSON and binary file uploads */ async handleSpecsData(ws, data) { try { console.error('Specs data received:', typeof data, data?.type || 'unknown type'); if (!data) { throw new Error('No data received'); } // Handle regular JSON specs data console.error('Processing regular specs data:', data); ws.send(JSON.stringify({ type: 'specs-data-response', success: true, message: 'Specs data received and processed successfully', data: { type: 'json', receivedAt: new Date().toISOString(), dataSize: JSON.stringify(data).length }, timestamp: new Date().toISOString(), })); } catch (error) { console.error('Error processing specs data:', error); this.sendErrorResponse(ws, error.message); } } /** * Recursively get directory contents */ getDirectoryContents(dirPath, relativePath = '') { const files = []; try { const items = fs.readdirSync(dirPath); for (const item of items) { const fullPath = path.join(dirPath, item); const relativeFilePath = path.join(relativePath, item); const stats = fs.statSync(fullPath); if (stats.isDirectory()) { files.push({ type: 'directory', name: item, path: relativeFilePath, size: 0 }); // Recursively get contents of subdirectory const subFiles = this.getDirectoryContents(fullPath, relativeFilePath); files.push(...subFiles); } else { files.push({ type: 'file', name: item, path: relativeFilePath, size: stats.size, modified: stats.mtime.toISOString() }); } } } catch (error) { console.error(`Error reading directory ${dirPath}:`, error); } return files; } /** * Send welcome message to newly connected plugin */ sendWelcomeMessage(ws, sessionId, clientType) { ws.send(JSON.stringify({ type: 'welcome', sessionId: sessionId, clientType: clientType, message: 'Connected to MCP Figma Server', timestamp: new Date().toISOString(), })); } /** * Broadcast message to all connected Figma plugins */ broadcastToFigmaPlugins(message) { const promises = Array.from(this.figmaConnections).map(ws => this.sendMessageToFigma(ws, message) ); return Promise.allSettled(promises); } /** * Send message to specific Figma plugin with timeout */ sendMessageToFigma(ws, message) { return new Promise((resolve, reject) => { if (ws.readyState !== ws.OPEN) { reject(new Error('WebSocket not open')); return; } const timeout = setTimeout(() => { reject(new Error('Timeout waiting for Figma response')); }, 5000); const responseHandler = (data) => { try { const response = JSON.parse(data); if (response.type === message.type + '-response') { clearTimeout(timeout); ws.off('message', responseHandler); resolve(response.data); } } catch (error) { // Ignore parsing errors for other messages } }; ws.on('message', responseHandler); ws.send(JSON.stringify(message)); }); } /** * Request a specific spec file from a plugin matching the MCP client's session */ async requestSpecFileFromSpecificPlugin(pluginConnection, requestType, fileName = null, requestData = null) { if (!pluginConnection || pluginConnection.readyState !== pluginConnection.OPEN) { return { success: false, error: 'No active WebSocket connection to specified plugin' }; } const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Build message based on request type let message = { type: 'spec-request', requestType: requestType, requestId: requestId, timestamp: new Date().toISOString(), }; // Add fileName for file-based requests if (fileName) { message.fileName = fileName; } // Add requestData for complex requests (like image generation) if (requestData) { try { // If requestData is a string, try to parse it as JSON message.requestData = typeof requestData === 'string' ? JSON.parse(requestData) : requestData; } catch (error) { // If parsing fails, treat as raw string message.requestData = requestData; } } const sessionId = this.sessionManager.connectionToSession.get(pluginConnection); console.error(`Requesting: ${requestType}${fileName ? ` (${fileName})` : ''} from session ${sessionId}`); return new Promise((resolve) => { const timeout = setTimeout(() => { resolve({ success: false, error: 'Timeout waiting for response from Figma plugin' }); }, 15000); // 15 second timeout const responseHandler = (event) => { try { const response = JSON.parse(event.data); if (response.type === 'spec-response' && response.requestId === requestId) { clearTimeout(timeout); pluginConnection.removeEventListener('message', responseHandler); if (response.success) { resolve({ success: true, data: response.data }); } else { resolve({ success: false, error: response.error || 'Unknown error from plugin' }); } } } catch (error) { // Ignore parsing errors for other messages } }; pluginConnection.addEventListener('message', responseHandler); pluginConnection.send(JSON.stringify(message)); }); } /** * Request a specific spec file or image from connected Figma plugins * Enhanced to support image requests with additional parameters */ async requestSpecFile(requestType, fileName = null, requestData = null) { if (this.figmaConnections.size === 0) { return { success: false, error: 'No Figma plugins connected' }; } const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Build message based on request type let message = { type: 'spec-request', requestType: requestType, requestId: requestId, timestamp: new Date().toISOString(), }; // Add fileName for file-based requests if (fileName) { message.fileName = fileName; } // Add requestData for complex requests (like image generation) if (requestData) { try { // If requestData is a string, try to parse it as JSON message.requestData = typeof requestData === 'string' ? JSON.parse(requestData) : requestData; } catch (error) { // If parsing fails, treat as raw string message.requestData = requestData; } } console.error(`Requesting: ${requestType}${fileName ? ` (${fileName})` : ''}${requestData ? ' with parameters' : ''}`); // Send request to the first connected plugin (assuming single plugin for now) const ws = Array.from(this.figmaConnections)[0]; if (!ws || ws.readyState !== ws.OPEN) { return { success: false, error: 'No active WebSocket connection to Figma plugin' }; } return new Promise((resolve) => { const timeout = setTimeout(() => { resolve({ success: false, error: 'Timeout waiting for response from Figma plugin' }); }, 15000); // 15 second timeout for image generation const responseHandler = (event) => { try { const response = JSON.parse(event.data); if (response.type === 'spec-response' && response.requestId === requestId) { clearTimeout(timeout); ws.removeEventListener('message', responseHandler); if (response.success) { resolve({ success: true, data: response.data }); } else { resolve({ success: false, error: response.error || 'Unknown error from plugin' }); } } } catch (error) { // Ignore parsing errors for other messages } }; ws.addEventListener('message', responseHandler); ws.send(JSON.stringify(message)); }); } /** * Close all connections and shutdown server */ shutdown() { return new Promise((resolve) => { let shutdownCount = 0; const totalShutdowns = (this.wss ? 1 : 0) + (this.httpServer ? 1 : 0); const checkComplete = () => { shutdownCount++; if (shutdownCount >= totalShutdowns) { resolve(); } }; if (totalShutdowns === 0) { resolve(); return; } if (this.wss) { // Clean up all file transfers for (const [transferId, transfer] of this.fileTransfers.entries()) { if (transfer.writeStream && !transfer.writeStream.destroyed) { transfer.writeStream.destroy(); } try { if (fs.existsSync(transfer.zipPath)) { fs.unlinkSync(transfer.zipPath); } } catch (error) { console.error('Error cleaning up file during shutdown:', error); } } this.fileTransfers.clear(); // Close all client connections first this.figmaConnections.forEach(ws => { if (ws.readyState === ws.OPEN) { ws.close(); } }); this.figmaConnections.clear(); // Close the WebSocket server and wait for it to fully close this.wss.close((error) => { if (error) { console.error('Error closing WebSocket server:', error); } else { console.error('WebSocket server shut down completely'); } this.wss = null; checkComplete(); }); } if (this.httpServer) { // Close the HTTP server this.httpServer.close((error) => { if (error) { console.error('Error closing HTTP server:', error); } else { console.error('HTTP server shut down completely'); } this.httpServer = null; checkComplete(); }); } }); } }