import type { Plugin, ViteDevServer } from 'vite'; import { WebSocketServer, WebSocket } from 'ws'; import type { IncomingMessage } from 'http'; import fs from 'fs'; import path from 'path'; import extractZip from 'extract-zip'; import { runCommand } from '../scripts/utils/command-runtime.mjs'; export interface WebSocketMessage { type: string; data?: any; payload?: any; client?: string; version?: string; } export interface ClientMeta { id: number; type: string; // 'figma' | 'vscode' | 'browser' | 'unknown' version?: string; address?: string; connectedAt: number; } interface UploadSession { transferId: string; pageName: string; displayName?: string; outputRelativeDir: string; fileName: string; mode: 'zip' | 'files'; totalChunks: number; totalBytes?: number; receivedChunks: number; receivedBytes: number; chunks: Map; filesRoot?: string; filesReceived: number; startedAt: number; } interface HandleMessageContext { clientMeta: Map; uploadSessions: Map; projectRoot: string; } const IGNORED_EXTRACT_ENTRIES = new Set(['__MACOSX', '.DS_Store']); const nodeCommand = process.execPath; function ensureDir(dirPath: string) { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } } function inferExtractedRootFolder(extractDir: string) { if (!fs.existsSync(extractDir)) { return { entryCount: 0, hasRootFolder: false, rootFolderName: '' }; } const entries = fs .readdirSync(extractDir, { withFileTypes: true }) .filter(entry => !IGNORED_EXTRACT_ENTRIES.has(entry.name)); if (entries.length === 1 && entries[0].isDirectory()) { return { entryCount: entries.length, hasRootFolder: true, rootFolderName: entries[0].name }; } return { entryCount: entries.length, hasRootFolder: false, rootFolderName: '' }; } function isSafeName(value: string) { return Boolean(value && value.trim() && !value.includes('..') && !/[\\/]/.test(value)); } function isSafeRelativePath(value: string) { if (!value || typeof value !== 'string') return false; const normalized = value.replace(/\\/g, '/'); if (normalized.startsWith('/') || normalized.startsWith('~')) return false; if (normalized.split('/').some(part => part === '..')) return false; return true; } function isValidDisplayName(value?: string) { if (value === undefined) return true; const text = String(value).trim(); return text.length > 0 && text.length <= 200; } function normalizeRelativeDir(value: string) { return value .replace(/\\/g, '/') .split('/') .filter(Boolean) .join('/'); } function resolveOutputRelativeDir(data: any, fallbackName: string) { const candidates = [ data?.outputRelativeDir, data?.outputPath, data?.targetPath, data?.targetDir, data?.folderPath, data?.relativePath, data?.pagePath, ]; for (const candidate of candidates) { if (typeof candidate !== 'string') continue; const normalized = normalizeRelativeDir(candidate.trim()); if (!normalized) continue; if (!isSafeRelativePath(normalized)) return null; const segments = normalized.split('/'); if (segments.length === 0 || segments.some(segment => !isSafeName(segment))) { return null; } return normalized; } return fallbackName; } function resolvePrototypeOutputDir(projectRoot: string, outputRelativeDir: string) { return path.join(projectRoot, 'src', 'prototypes', ...outputRelativeDir.split('/')); } function sendWsMessage(ws: WebSocket, payload: any) { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(payload)); } } /** * WebSocket 插件 - 在 Vite 开发服务器上添加 WebSocket 支持 */ export function websocketPlugin(): Plugin { let wss: WebSocketServer | null = null; const clients = new Set(); const clientMeta = new Map(); const uploadSessions = new Map(); let nextClientId = 1; const WS_PATH = '/ws'; const projectRoot = process.cwd(); return { name: 'vite-websocket', apply: 'serve', configureServer(server: ViteDevServer) { // 使用独立的 noServer 模式,避免抢占 Vite 自带的 HMR WebSocket 升级请求 // (此前共享同一个 httpServer 且指定 path 会拦截非 /ws 升级,导致 HMR 报 Invalid frame header) wss = new WebSocketServer({ noServer: true }); const handleUpgrade = (req: IncomingMessage, socket: any, head: Buffer) => { // 只处理 /ws 的升级请求,其余交给 Vite 自己的 HMR 逻辑 const pathname = req.url ? new URL(req.url, 'http://localhost').pathname : ''; if (pathname !== WS_PATH) { return; } wss?.handleUpgrade(req, socket, head, (ws) => { wss?.emit('connection', ws, req); }); }; server.httpServer?.on('upgrade', handleUpgrade); wss.on('connection', (ws: WebSocket, req: IncomingMessage) => { // 从 URL 查询参数中获取客户端类型 const url = new URL(req.url || '', 'http://localhost'); const clientType = url.searchParams.get('client') || 'unknown'; const clientVersion = url.searchParams.get('version') || undefined; console.log('[WebSocket] 新客户端连接:', { type: clientType, version: clientVersion, address: req.socket.remoteAddress }); clients.add(ws); const meta: ClientMeta = { id: nextClientId++, type: clientType, version: clientVersion, address: req.socket.remoteAddress, connectedAt: Date.now() }; clientMeta.set(ws, meta); // 发送欢迎消息 ws.send(JSON.stringify({ type: 'connected', message: 'WebSocket 连接成功' })); // 处理客户端消息 ws.on('message', (rawData) => { try { const payloadText = typeof rawData === 'string' ? rawData : Buffer.isBuffer(rawData) ? rawData.toString() : Array.isArray(rawData) ? Buffer.concat(rawData).toString() : Buffer.from(rawData as ArrayBuffer).toString(); const message: WebSocketMessage = JSON.parse(payloadText); const client = clientMeta.get(ws); const bodyKeys = message && typeof message === 'object' ? Object.keys(message as any) : []; const dataKeys = message?.data && typeof message.data === 'object' ? Object.keys(message.data) : []; console.log('[WebSocket] 收到 WS 消息:', { clientId: client?.id, address: client?.address, type: message?.type, hasData: message?.data !== undefined, bodyKeys, dataKeys, bytes: Buffer.byteLength(payloadText, 'utf8') }); // 处理不同类型的消息 handleMessage(ws, message, clients, { clientMeta, uploadSessions, projectRoot }); } catch (err) { console.error('[WebSocket] 解析消息失败:', err); ws.send(JSON.stringify({ type: 'error', message: '消息格式错误' })); } }); // 处理连接关闭 ws.on('close', () => { console.log('[WebSocket] 客户端断开连接'); clients.delete(ws); clientMeta.delete(ws); }); // 处理错误 ws.on('error', (err) => { console.error('[WebSocket] 连接错误:', err); clients.delete(ws); clientMeta.delete(ws); }); }); // HTTP API: 获取当前 WS 客户端信息 server.middlewares.use('/api/ws/clients', (req, res) => { if (req.method !== 'GET') { res.statusCode = 405; res.end('Method Not Allowed'); return; } const list = Array.from(clientMeta.values()).map((item) => ({ id: item.id, type: item.type, version: item.version, address: item.address, connectedAt: item.connectedAt })); // 统计各类型客户端数量 const stats = Array.from(clientMeta.values()).reduce((acc, item) => { acc[item.type] = (acc[item.type] || 0) + 1; return acc; }, {} as Record); res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ clients: list, stats, total: list.length })); }); // HTTP API: 发送消息给全部客户端 server.middlewares.use('/api/ws/send', (req, res) => { if (req.method !== 'POST') { res.statusCode = 405; res.end('Method Not Allowed'); return; } let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { try { const parsedBody = JSON.parse(body || '{}'); const { type } = parsedBody; const payload = (parsedBody as any).payload; const targetClientType = (parsedBody as any).targetClientType; const targetClientTypes = (parsedBody as any).targetClientTypes; // 兼容历史字段:payload / data const data = payload !== undefined ? payload : (parsedBody as any).data; // 验证 type if (!type || typeof type !== 'string') { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'type is required' })); return; } // 验证 data if (data === undefined || data === null) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'data is required' })); return; } // 针对 sync-widget-content 的特殊验证 if (type === 'sync-widget-content') { // 验证 widgetId(必需字段) if (!parsedBody.widgetId || typeof parsedBody.widgetId !== 'string') { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'widgetId is required for sync-widget-content' })); return; } // 验证 data.layers 不为空 if (!data || typeof data !== 'object' || !data.layers) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'data.layers is required' })); return; } if (!Array.isArray(data.layers) || data.layers.length === 0) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'data.layers array is empty' })); return; } } // 针对 sync-page-content 的验证 if (type === 'sync-page-content') { // 验证 data.layers 不为空 if (!data || typeof data !== 'object' || !data.layers) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'data.layers is required' })); return; } if (!Array.isArray(data.layers) || data.layers.length === 0) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'data.layers array is empty' })); return; } } const allClientCount = clients.size; const normalizedTargetClientTypes = Array.isArray(targetClientTypes) ? targetClientTypes.filter((v) => typeof v === 'string' && v) : typeof targetClientType === 'string' && targetClientType ? [targetClientType] : []; // 这里的 targetClientTypes 是插件层的“按客户端类型定向”机制。 // 当前前端仅对 vscode/cursor 做 open-file 的稳定投递;其他 IDE 本轮通过系统命令打开,不走 WS 定向。 const targetClients = normalizedTargetClientTypes.length === 0 ? clients : new Set( Array.from(clients).filter((ws) => { const meta = clientMeta.get(ws); return meta?.type && normalizedTargetClientTypes.includes(meta.type); }) ); const targetClientCount = targetClients.size; // 如果没有客户端连接 if (allClientCount === 0) { res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ ok: true, sent: 0, warning: 'no clients connected' })); return; } console.log('[WebSocket] /api/ws/send 请求:', { type, hasPayload: data !== undefined, clientCount: allClientCount, targetClientCount, targetClientTypes: normalizedTargetClientTypes, bodyKeys: parsedBody && typeof parsedBody === 'object' ? Object.keys(parsedBody) : [] }); // 立即返回成功状态(不等待渲染完成) res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ ok: true, sent: targetClientCount, ...(normalizedTargetClientTypes.length > 0 && targetClientCount === 0 ? { warning: 'no target clients connected' } : {}) })); // 异步广播消息(不阻塞响应) setImmediate(() => { try { // 构建完整的消息对象,包含所有字段(type, widgetId/pageId, data, blurImages, metadata 等) const message: any = { type, ...(parsedBody.widgetId ? { widgetId: parsedBody.widgetId } : {}), ...(parsedBody.pageId ? { pageId: parsedBody.pageId } : {}), ...(payload !== undefined ? { payload } : {}), data, ...(parsedBody.blurImages !== undefined ? { blurImages: parsedBody.blurImages } : {}), ...(parsedBody.metadata ? { metadata: parsedBody.metadata } : {}) }; const sentCount = broadcast(targetClients, message); console.log('[WebSocket] 消息已广播:', { type, sentCount }); } catch (err) { console.error('[WebSocket] 广播消息失败:', err); } }); } catch (err) { console.error('[WebSocket] /api/ws/send 解析失败:', err); res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'invalid json body' })); } }); }); // 服务器关闭时清理 server.httpServer?.on('close', () => { if (wss) { wss.close(); clients.clear(); console.log('[WebSocket] 服务器已关闭'); } server.httpServer?.off?.('upgrade', handleUpgrade); }); } }; } /** * 处理 WebSocket 消息 */ function handleMessage( ws: WebSocket, message: WebSocketMessage, clients: Set, context: HandleMessageContext ) { switch (message.type) { case 'identify': // 客户端身份识别(支持连接后再发送身份信息) { const meta = Array.from(clients).find(c => c === ws); if (meta) { const clientInfo = context.clientMeta.get(ws); if (clientInfo) { clientInfo.type = message.client || clientInfo.type; clientInfo.version = message.version || clientInfo.version; context.clientMeta.set(ws, clientInfo); console.log('[WebSocket] 客户端已识别:', { id: clientInfo.id, type: clientInfo.type, version: clientInfo.version }); } } ws.send(JSON.stringify({ type: 'identified', message: '身份识别成功' })); } break; case 'ping': ws.send(JSON.stringify({ type: 'pong' })); break; case 'broadcast': // 广播消息给所有客户端 broadcast(clients, { type: 'broadcast', data: message.data }); break; case 'echo': // 回显消息 ws.send(JSON.stringify({ type: 'echo', data: message.data })); break; case 'chrome-export:init': { const data = (message.data ?? message.payload ?? {}) as any; const transferId = String(data.transferId || '').trim(); const pageName = String(data.pageName || '').trim(); const displayName = data.displayName !== undefined ? String(data.displayName).trim() : undefined; const mode = data.mode === 'files' ? 'files' : 'zip'; const totalChunks = Number(data.totalChunks); const totalBytes = typeof data.totalBytes === 'number' ? data.totalBytes : undefined; const fileNameRaw = String(data.fileName || 'chrome-export.zip'); const fileName = path.basename(fileNameRaw || 'chrome-export.zip'); const outputRelativeDir = resolveOutputRelativeDir(data, pageName); if (!transferId || !isSafeName(transferId)) { return sendWsMessage(ws, { type: 'chrome-export:error', message: 'transferId is invalid' }); } if (!pageName || !isSafeName(pageName)) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'pageName is invalid' }); } if (!isValidDisplayName(displayName)) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'displayName is invalid' }); } if (!outputRelativeDir) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'output path is invalid' }); } if (mode === 'zip' && (!Number.isFinite(totalChunks) || totalChunks <= 0)) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'totalChunks is invalid' }); } if (context.uploadSessions.has(transferId)) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'transferId already exists' }); } const transferDir = path.join(context.projectRoot, 'temp', 'chrome-export', transferId); const filesRoot = mode === 'files' ? path.join(transferDir, 'files') : undefined; if (filesRoot) { ensureDir(filesRoot); } const session: UploadSession = { transferId, pageName, displayName, outputRelativeDir, fileName, mode, totalChunks, totalBytes, receivedChunks: 0, receivedBytes: 0, chunks: new Map(), filesRoot, filesReceived: 0, startedAt: Date.now() }; context.uploadSessions.set(transferId, session); sendWsMessage(ws, { type: 'chrome-export:ack', transferId }); } break; case 'chrome-export:chunk': { const data = (message.data ?? message.payload ?? {}) as any; const transferId = String(data.transferId || '').trim(); const chunkIndex = Number(data.index); const chunkData = data.data; if (!transferId || !context.uploadSessions.has(transferId)) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'unknown transferId' }); } const session = context.uploadSessions.get(transferId)!; if (session.mode !== 'zip') { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'chunk not allowed for files mode' }); } if (!Number.isFinite(chunkIndex) || chunkIndex < 0) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid chunk index' }); } if (typeof chunkData !== 'string' || !chunkData) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid chunk data' }); } if (chunkIndex >= session.totalChunks) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'chunk index out of range' }); } if (!session.chunks.has(chunkIndex)) { const buffer = Buffer.from(chunkData, 'base64'); session.chunks.set(chunkIndex, buffer); session.receivedChunks = session.chunks.size; session.receivedBytes += buffer.byteLength; } sendWsMessage(ws, { type: 'chrome-export:progress', transferId, receivedChunks: session.receivedChunks, totalChunks: session.totalChunks, receivedBytes: session.receivedBytes, totalBytes: session.totalBytes }); } break; case 'chrome-export:file': { const data = (message.data ?? message.payload ?? {}) as any; const transferId = String(data.transferId || '').trim(); const relativePath = String(data.path || data.relativePath || '').trim(); const fileData = data.data; if (!transferId || !context.uploadSessions.has(transferId)) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'unknown transferId' }); } const session = context.uploadSessions.get(transferId)!; if (session.mode !== 'files') { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'file not allowed for zip mode' }); } if (!isSafeRelativePath(relativePath)) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid file path' }); } if (typeof fileData !== 'string' || !fileData) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid file data' }); } if (!session.filesRoot) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'files root not ready' }); } const targetPath = path.join(session.filesRoot, relativePath); ensureDir(path.dirname(targetPath)); const buffer = Buffer.from(fileData, 'base64'); fs.writeFileSync(targetPath, buffer); session.filesReceived += 1; sendWsMessage(ws, { type: 'chrome-export:progress', transferId, filesReceived: session.filesReceived }); } break; case 'chrome-export:complete': { const data = (message.data ?? message.payload ?? {}) as any; const transferId = String(data.transferId || '').trim(); if (!transferId || !context.uploadSessions.has(transferId)) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'unknown transferId' }); } const session = context.uploadSessions.get(transferId)!; const inboxRoot = path.join(context.projectRoot, 'temp', 'chrome-export'); const transferDir = path.join(inboxRoot, transferId); const extractDir = path.join(transferDir, 'extract'); if (session.mode === 'zip') { const missing: number[] = []; for (let i = 0; i < session.totalChunks; i += 1) { if (!session.chunks.has(i)) { missing.push(i); } } if (missing.length > 0) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'missing chunks', missing }); } const orderedBuffers: Buffer[] = new Array(session.totalChunks); for (let i = 0; i < session.totalChunks; i += 1) { orderedBuffers[i] = session.chunks.get(i)!; } const zipBuffer = Buffer.concat(orderedBuffers); const zipPath = path.join(transferDir, session.fileName); ensureDir(transferDir); fs.writeFileSync(zipPath, zipBuffer); if (fs.existsSync(extractDir)) { fs.rmSync(extractDir, { recursive: true, force: true }); } ensureDir(extractDir); sendWsMessage(ws, { type: 'chrome-export:status', transferId, stage: 'extracting' }); extractZip(zipPath, { dir: extractDir }) .then(() => { const inferred = inferExtractedRootFolder(extractDir); if (inferred.entryCount === 0) { throw new Error('empty zip'); } const sourceDir = inferred.hasRootFolder ? path.join(extractDir, inferred.rootFolderName) : extractDir; const outputName = session.pageName; if (!isSafeName(outputName)) { throw new Error('invalid pageName'); } const scriptPath = path.join(context.projectRoot, 'scripts', 'chrome-export-converter.mjs'); const commandArgs = [scriptPath, sourceDir, outputName]; if (session.displayName) { commandArgs.push('--display-name', session.displayName); } commandArgs.push('--target-dir', session.outputRelativeDir); sendWsMessage(ws, { type: 'chrome-export:status', transferId, stage: 'importing' }); void runCommand({ command: nodeCommand, args: commandArgs, cwd: context.projectRoot, capture: true, }).then((result) => { if (result.code !== 0) { sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: result.stderr || result.stdout || 'import failed' }); } else { const outputDir = resolvePrototypeOutputDir(context.projectRoot, session.outputRelativeDir); sendWsMessage(ws, { type: 'chrome-export:done', transferId, pageName: outputName, displayName: session.displayName, outputRelativeDir: session.outputRelativeDir, sourceDir, outputDir, stdout: result.stdout ? String(result.stdout).trim() : undefined, stderr: result.stderr ? String(result.stderr).trim() : undefined }); if (fs.existsSync(transferDir)) { fs.rmSync(transferDir, { recursive: true, force: true }); } context.uploadSessions.delete(transferId); } }).catch((error: any) => { sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: error?.message || 'import failed' }); }); }) .catch((error: any) => { sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: error?.message || 'extract failed' }); }); } else { if (!session.filesRoot) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'files root not ready' }); } const sourceDir = session.filesRoot; const outputName = session.pageName; if (!isSafeName(outputName)) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid pageName' }); } const scriptPath = path.join(context.projectRoot, 'scripts', 'chrome-export-converter.mjs'); const commandArgs = [scriptPath, sourceDir, outputName]; if (session.displayName) { commandArgs.push('--display-name', session.displayName); } commandArgs.push('--target-dir', session.outputRelativeDir); sendWsMessage(ws, { type: 'chrome-export:status', transferId, stage: 'importing' }); void runCommand({ command: nodeCommand, args: commandArgs, cwd: context.projectRoot, capture: true, }).then((result) => { if (result.code !== 0) { sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: result.stderr || result.stdout || 'import failed' }); } else { const outputDir = resolvePrototypeOutputDir(context.projectRoot, session.outputRelativeDir); sendWsMessage(ws, { type: 'chrome-export:done', transferId, pageName: outputName, displayName: session.displayName, outputRelativeDir: session.outputRelativeDir, sourceDir, outputDir, stdout: result.stdout ? String(result.stdout).trim() : undefined, stderr: result.stderr ? String(result.stderr).trim() : undefined }); if (fs.existsSync(transferDir)) { fs.rmSync(transferDir, { recursive: true, force: true }); } context.uploadSessions.delete(transferId); } }).catch((error: any) => { sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: error?.message || 'import failed' }); }); } } break; case 'chrome-export:abort': { const data = (message.data ?? message.payload ?? {}) as any; const transferId = String(data.transferId || '').trim(); if (!transferId || !context.uploadSessions.has(transferId)) { return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'unknown transferId' }); } context.uploadSessions.delete(transferId); sendWsMessage(ws, { type: 'chrome-export:aborted', transferId }); } break; default: // 未知消息类型,可以在这里添加自定义处理逻辑 console.log('[WebSocket] 未处理的消息类型:', message.type); ws.send(JSON.stringify({ type: 'unknown', message: `未知的消息类型: ${message.type}` })); } } /** * 广播消息给所有连接的客户端 */ function broadcast(clients: Set, message: WebSocketMessage) { const data = JSON.stringify(message); let count = 0; clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(data); count += 1; } }); return count; }