import fs from 'node:fs'; import type { IncomingMessage } from 'node:http'; import path from 'node:path'; import type { Plugin, ViteDevServer } from 'vite'; import { WebSocket, WebSocketServer } from 'ws'; import type { RawData } from 'ws'; type ExtractZip = typeof import('extract-zip'); // Lazy-loaded to avoid pulling in iconv-lite at Vite config time. let _extractZip: ExtractZip | null = null; let _runCommand: typeof import('../scripts/utils/command-runtime.mjs').runCommand | null = null; async function getExtractZip() { if (!_extractZip) { const module = await import('extract-zip') as unknown as { default?: ExtractZip }; _extractZip = module.default || (module as ExtractZip); } return _extractZip; } async function getRunCommand() { if (!_runCommand) { _runCommand = (await import('../scripts/utils/command-runtime.mjs')).runCommand; } return _runCommand; } export interface WebSocketMessage { type: string; data?: unknown; payload?: unknown; client?: string; version?: string; widgetId?: string; pageId?: string; blurImages?: unknown; metadata?: unknown; } export interface ClientMeta { id: number; type: string; 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; } type MiddlewareResponse = { statusCode: number; setHeader(name: string, value: string): void; end(chunk?: unknown): void; }; const WS_PATH = '/ws'; const IGNORED_EXTRACT_ENTRIES = new Set(['__MACOSX', '.DS_Store']); const nodeCommand = process.execPath; /* ------------------------------------------------------------------ */ /* Utility helpers */ /* ------------------------------------------------------------------ */ 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('/')); } /* ------------------------------------------------------------------ */ /* WebSocket / HTTP helpers */ /* ------------------------------------------------------------------ */ function readRequestBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => resolve(body)); req.on('error', reject); }); } function sendJson(res: MiddlewareResponse, payload: unknown, status = 200): void { res.statusCode = status; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(payload)); } function parseSocketMessage(rawData: RawData): WebSocketMessage { const text = typeof rawData === 'string' ? rawData : Buffer.isBuffer(rawData) ? rawData.toString('utf8') : Array.isArray(rawData) ? Buffer.concat(rawData).toString('utf8') : Buffer.from(rawData as ArrayBuffer).toString('utf8'); return JSON.parse(text) as WebSocketMessage; } function sendWsMessage(ws: WebSocket, payload: unknown): void { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(payload)); } } function broadcast(clients: Set, message: WebSocketMessage): number { const data = JSON.stringify(message); let count = 0; for (const client of clients) { if (client.readyState === WebSocket.OPEN) { client.send(data); count += 1; } } return count; } /* ------------------------------------------------------------------ */ /* Chrome-export message handlers */ /* ------------------------------------------------------------------ */ function handleChromeExportInit( ws: WebSocket, message: WebSocketMessage, context: HandleMessageContext, ): void { 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' as const : 'zip' as const; 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 }); } function handleChromeExportChunk( ws: WebSocket, message: WebSocketMessage, context: HandleMessageContext, ): void { 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, }); } function handleChromeExportFile( ws: WebSocket, message: WebSocketMessage, context: HandleMessageContext, ): void { 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, }); } async function runConverterAndRespond( ws: WebSocket, session: UploadSession, sourceDir: string, context: HandleMessageContext, ): Promise { const outputName = session.pageName; if (!isSafeName(outputName)) { sendWsMessage(ws, { type: 'chrome-export:error', transferId: session.transferId, message: 'invalid pageName' }); return; } 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: session.transferId, stage: 'importing' }); const transferDir = path.join(context.projectRoot, 'temp', 'chrome-export', session.transferId); const runCmd = await getRunCommand(); void runCmd({ command: nodeCommand, args: commandArgs, cwd: context.projectRoot, capture: true, }).then((result: any) => { if (result.code !== 0) { sendWsMessage(ws, { type: 'chrome-export:error', transferId: session.transferId, message: result.stderr || result.stdout || 'import failed', }); } else { const outputDir = resolvePrototypeOutputDir(context.projectRoot, session.outputRelativeDir); sendWsMessage(ws, { type: 'chrome-export:done', transferId: session.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(session.transferId); } }).catch((error: any) => { sendWsMessage(ws, { type: 'chrome-export:error', transferId: session.transferId, message: error?.message || 'import failed', }); }); } function handleChromeExportComplete( ws: WebSocket, message: WebSocketMessage, context: HandleMessageContext, ): void { 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' }); getExtractZip().then((extract) => extract(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; runConverterAndRespond(ws, session, sourceDir, context); }) .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' }); } runConverterAndRespond(ws, session, session.filesRoot, context); } } function handleChromeExportAbort( ws: WebSocket, message: WebSocketMessage, context: HandleMessageContext, ): void { 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 }); } /* ------------------------------------------------------------------ */ /* Main socket message dispatcher */ /* ------------------------------------------------------------------ */ function handleSocketMessage( ws: WebSocket, message: WebSocketMessage, clients: Set, context: HandleMessageContext, ): void { switch (message.type) { case 'identify': { const meta = context.clientMeta.get(ws); if (meta) { meta.type = message.client || meta.type; meta.version = message.version || meta.version; } sendWsMessage(ws, { type: 'identified', message: '身份识别成功', }); return; } case 'ping': sendWsMessage(ws, { type: 'pong' }); return; case 'broadcast': broadcast(clients, { type: 'broadcast', data: message.data, }); return; case 'echo': sendWsMessage(ws, { type: 'echo', data: message.data, }); return; case 'chrome-export:init': handleChromeExportInit(ws, message, context); return; case 'chrome-export:chunk': handleChromeExportChunk(ws, message, context); return; case 'chrome-export:file': handleChromeExportFile(ws, message, context); return; case 'chrome-export:complete': handleChromeExportComplete(ws, message, context); return; case 'chrome-export:abort': handleChromeExportAbort(ws, message, context); return; default: sendWsMessage(ws, { type: 'unknown', message: `未知的消息类型: ${message.type}`, }); } } /* ------------------------------------------------------------------ */ /* Client list / send helpers */ /* ------------------------------------------------------------------ */ function serializeClients(clientMeta: Map) { const clients = Array.from(clientMeta.values()).map((item) => ({ id: item.id, type: item.type, version: item.version, address: item.address, connectedAt: item.connectedAt, })); const stats = clients.reduce>((acc, item) => { acc[item.type] = (acc[item.type] || 0) + 1; return acc; }, {}); return { clients, stats, total: clients.length, }; } function selectTargetClients( clients: Set, clientMeta: Map, targetClientTypes: string[], ): Set { if (targetClientTypes.length === 0) { return clients; } return new Set(Array.from(clients).filter((ws) => { const meta = clientMeta.get(ws); return Boolean(meta?.type && targetClientTypes.includes(meta.type)); })); } function normalizeTargetClientTypes(body: Record): string[] { if (Array.isArray(body.targetClientTypes)) { return body.targetClientTypes.filter((value): value is string => typeof value === 'string' && value.length > 0); } return typeof body.targetClientType === 'string' && body.targetClientType ? [body.targetClientType] : []; } function createRelayMessage(body: Record, data: unknown): WebSocketMessage { const message: WebSocketMessage = { type: body.type as string, data, }; if (typeof body.widgetId === 'string') message.widgetId = body.widgetId; if (typeof body.pageId === 'string') message.pageId = body.pageId; if (body.payload !== undefined) message.payload = body.payload; if (body.blurImages !== undefined) message.blurImages = body.blurImages; if (body.metadata !== undefined) message.metadata = body.metadata; return message; } async function handleSendRequest( req: IncomingMessage, res: MiddlewareResponse, clients: Set, clientMeta: Map, ): Promise { if (req.method !== 'POST') { sendJson(res, { error: 'Method not allowed' }, 405); return; } let body: Record; try { body = JSON.parse(await readRequestBody(req) || '{}') as Record; } catch { sendJson(res, { error: 'invalid json body' }, 400); return; } const type = body.type; if (!type || typeof type !== 'string') { sendJson(res, { error: 'type is required' }, 400); return; } if (type === 'sync-widget-content' || type === 'sync-page-content') { sendJson(res, { error: 'Figma 同步已下线,请使用导出 Make' }, 410); return; } const data = body.payload !== undefined ? body.payload : body.data; if (data === undefined || data === null) { sendJson(res, { error: 'data is required' }, 400); return; } const targetClientTypes = normalizeTargetClientTypes(body); const targetClients = selectTargetClients(clients, clientMeta, targetClientTypes); const allClientCount = clients.size; if (allClientCount === 0) { sendJson(res, { ok: true, sent: 0, warning: 'no clients connected' }); return; } sendJson(res, { ok: true, sent: targetClients.size, ...(targetClientTypes.length > 0 && targetClients.size === 0 ? { warning: 'no target clients connected' } : {}), }); setImmediate(() => { broadcast(targetClients, createRelayMessage(body, data)); }); } /* ------------------------------------------------------------------ */ /* Plugin entry */ /* ------------------------------------------------------------------ */ 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 projectRoot = process.cwd(); return { name: 'make-project-websocket', apply: 'serve', configureServer(server: ViteDevServer) { wss = new WebSocketServer({ noServer: true }); const handleUpgrade = (req: IncomingMessage, socket: any, head: Buffer) => { 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); const messageContext: HandleMessageContext = { clientMeta, uploadSessions, projectRoot, }; wss.on('connection', (ws: WebSocket, req: IncomingMessage) => { const url = new URL(req.url || '/', 'http://localhost'); const meta: ClientMeta = { id: nextClientId, type: url.searchParams.get('client') || 'unknown', version: url.searchParams.get('version') || undefined, address: req.socket.remoteAddress, connectedAt: Date.now(), }; nextClientId += 1; clients.add(ws); clientMeta.set(ws, meta); sendWsMessage(ws, { type: 'connected', message: 'WebSocket 连接成功', }); ws.on('message', (rawData) => { try { handleSocketMessage(ws, parseSocketMessage(rawData), clients, messageContext); } catch { sendWsMessage(ws, { type: 'error', message: '消息格式错误', }); } }); const cleanup = () => { clients.delete(ws); clientMeta.delete(ws); }; ws.on('close', cleanup); ws.on('error', cleanup); }); server.middlewares.use('/api/ws/clients', (req, res) => { if (req.method !== 'GET') { sendJson(res, { error: 'Method not allowed' }, 405); return; } sendJson(res, serializeClients(clientMeta)); }); server.middlewares.use('/api/ws/send', (req, res) => { void handleSendRequest(req, res, clients, clientMeta); }); server.httpServer?.on('close', () => { wss?.close(); clients.clear(); clientMeta.clear(); server.httpServer?.off?.('upgrade', handleUpgrade); }); }, }; }