diff --git a/README.md b/README.md index d502c52..1f2097d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MCP Excalidraw Server: Advanced Live Visual Diagramming with AI Integration -A comprehensive system that combines **Excalidraw's powerful drawing capabilities** with **Model Context Protocol (MCP)** integration, enabling AI agents to create and manipulate diagrams in real-time on a live canvas. +A comprehensive **TypeScript-based** system that combines **Excalidraw's powerful drawing capabilities** with **Model Context Protocol (MCP)** integration, enabling AI agents to create and manipulate diagrams in real-time on a live canvas. ## 🚦 Current Status & Version Information @@ -54,6 +54,12 @@ For the most stable experience, we recommend using the local development setup. ## 🌟 Key Features +### **Modern TypeScript Architecture** +- **Full TypeScript Migration**: Complete type safety for backend and frontend +- **Comprehensive Type Definitions**: Excalidraw elements, API responses, WebSocket messages +- **Strict Type Checking**: Enhanced development experience and compile-time error detection +- **Type-Safe React Components**: TSX components with proper props typing + ### **Real-time Canvas Integration** - Elements created via MCP appear instantly on the live canvas - WebSocket-based real-time synchronization @@ -71,8 +77,8 @@ For the most stable experience, we recommend using the local development setup. - **Advanced Features**: grouping, alignment, distribution, locking ### **Robust Architecture** -- Express.js backend with REST API + WebSocket -- React frontend with official Excalidraw package +- TypeScript-based Express.js backend with REST API + WebSocket +- React frontend with official Excalidraw package and TypeScript - Dual-path element loading for reliability - Auto-reconnection and error handling @@ -133,10 +139,13 @@ docker run -p 3000:3000 mcp-excalidraw-server | Script | Description | |--------|-------------| -| `npm start` | Start MCP server (`src/index.js`) | -| `npm run canvas` | Start canvas server (`src/server.js`) | -| `npm run build` | Build frontend for production | -| `npm run dev` | Start canvas + Vite dev server | +| `npm start` | Build and start MCP server (`dist/index.js`) | +| `npm run canvas` | Build and start canvas server (`dist/server.js`) | +| `npm run build` | Build both frontend and TypeScript backend | +| `npm run build:frontend` | Build React frontend only | +| `npm run build:server` | Compile TypeScript backend to JavaScript | +| `npm run dev` | Start TypeScript watch mode + Vite dev server | +| `npm run type-check` | Run TypeScript type checking without compilation | | `npm run production` | Build + start in production mode | ## 🎯 Usage Guide @@ -225,13 +234,13 @@ For the **local development version** (most stable), add this configuration to y "mcpServers": { "excalidraw": { "command": "node", - "args": ["/absolute/path/to/mcp_excalidraw/src/index.js"] + "args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"] } } } ``` -**Important**: Replace `/absolute/path/to/mcp_excalidraw` with the actual absolute path to your cloned repository. +**Important**: Replace `/absolute/path/to/mcp_excalidraw` with the actual absolute path to your cloned repository. Note that the path now points to `dist/index.js` (the compiled TypeScript output). ### **🔧 Alternative Configurations (Beta)** @@ -272,7 +281,7 @@ Add to your `.cursor/mcp.json`: "mcpServers": { "excalidraw": { "command": "node", - "args": ["/absolute/path/to/mcp_excalidraw/src/index.js"] + "args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"] } } } @@ -288,7 +297,7 @@ For VS Code MCP extension, add to your settings: "servers": { "excalidraw": { "command": "node", - "args": ["/absolute/path/to/mcp_excalidraw/src/index.js"] + "args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"] } } } @@ -342,22 +351,29 @@ The canvas server provides these REST endpoints: ## 🏗️ Development Architecture ### **Frontend** (`frontend/src/`) -- **React + Vite**: Modern build system -- **Official Excalidraw**: `@excalidraw/excalidraw` package -- **WebSocket Client**: Real-time element sync -- **Clean UI**: Production-ready interface +- **React + TypeScript**: Modern TSX components with full type safety +- **Vite Build System**: Fast development and optimized production builds +- **Official Excalidraw**: `@excalidraw/excalidraw` package with TypeScript types +- **WebSocket Client**: Type-safe real-time element synchronization +- **Clean UI**: Production-ready interface with proper TypeScript typing -### **Canvas Server** (`src/server.js`) -- **Express.js**: REST API + static file serving -- **WebSocket**: Real-time client communication -- **Element Storage**: In-memory with persistence options -- **CORS**: Cross-origin support +### **Canvas Server** (`src/server.ts` → `dist/server.js`) +- **TypeScript + Express.js**: Fully typed REST API + static file serving +- **WebSocket**: Type-safe real-time client communication +- **Element Storage**: In-memory with comprehensive type definitions +- **CORS**: Cross-origin support with proper typing -### **MCP Server** (`src/index.js`) -- **MCP Protocol**: Standard Model Context Protocol -- **Canvas Sync**: HTTP requests to canvas server -- **Element Management**: Full CRUD operations -- **Batch Support**: Complex diagram creation +### **MCP Server** (`src/index.ts` → `dist/index.js`) +- **TypeScript MCP Protocol**: Type-safe Model Context Protocol implementation +- **Canvas Sync**: Strongly typed HTTP requests to canvas server +- **Element Management**: Full CRUD operations with comprehensive type checking +- **Batch Support**: Type-safe complex diagram creation + +### **Type System** (`src/types.ts`) +- **Excalidraw Element Types**: Complete type definitions for all element types +- **API Response Types**: Strongly typed REST API interfaces +- **WebSocket Message Types**: Type-safe real-time communication +- **Server Element Types**: Enhanced element types with metadata ## 🐛 Troubleshooting @@ -390,6 +406,8 @@ The canvas server provides these REST endpoints: - Delete `node_modules` and run `npm install` - Check Node.js version (requires 16+) - Ensure all dependencies are installed +- Run `npm run type-check` to identify TypeScript issues +- Verify `dist/` directory is created after `npm run build:server` ## 📋 Project Structure @@ -397,16 +415,23 @@ The canvas server provides these REST endpoints: mcp_excalidraw/ ├── frontend/ │ ├── src/ -│ │ ├── App.jsx # Main React component -│ │ └── main.jsx # React entry point +│ │ ├── App.tsx # Main React component (TypeScript) +│ │ └── main.tsx # React entry point (TypeScript) │ └── index.html # HTML template -├── src/ -│ ├── index.js # MCP server -│ ├── server.js # Canvas server (Express + WebSocket) -│ ├── types.js # Shared types and utilities +├── src/ (TypeScript Source) +│ ├── index.ts # MCP server (TypeScript) +│ ├── server.ts # Canvas server (Express + WebSocket, TypeScript) +│ ├── types.ts # Comprehensive type definitions │ └── utils/ -│ └── logger.js # Logging utility -├── dist/ # Built frontend (generated) +│ └── logger.ts # Logging utility (TypeScript) +├── dist/ (Compiled Output) +│ ├── index.js # Compiled MCP server +│ ├── server.js # Compiled Canvas server +│ ├── types.js # Compiled type definitions +│ ├── utils/ +│ │ └── logger.js # Compiled logging utility +│ └── frontend/ # Built React frontend +├── tsconfig.json # TypeScript configuration ├── vite.config.js # Vite build configuration ├── package.json # Dependencies and scripts └── README.md # This file @@ -414,10 +439,12 @@ mcp_excalidraw/ ## 🔮 Development Roadmap +- ✅ **TypeScript Migration**: Complete type safety for enhanced development experience - **NPM Package**: Resolving MCP tool registration issues - **Docker Deployment**: Improving canvas synchronization - **Enhanced Features**: Additional MCP tools and capabilities - **Performance Optimization**: Real-time sync improvements +- **Advanced TypeScript Features**: Stricter type checking and advanced type utilities ## 🤝 Contributing diff --git a/frontend/src/App.jsx b/frontend/src/App.tsx similarity index 66% rename from frontend/src/App.jsx rename to frontend/src/App.tsx index 9ab3423..e1423bf 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.tsx @@ -1,21 +1,86 @@ import React, { useState, useEffect, useRef } from 'react' -import { Excalidraw, convertToExcalidrawElements, CaptureUpdateAction } from '@excalidraw/excalidraw' +import { + Excalidraw, + convertToExcalidrawElements, + CaptureUpdateAction, + ExcalidrawAPIRefValue, + ExcalidrawElement +} from '@excalidraw/excalidraw' import '@excalidraw/excalidraw/index.css' +// Type definitions +interface ServerElement { + id: string; + type: string; + x: number; + y: number; + width?: number; + height?: number; + backgroundColor?: string; + strokeColor?: string; + strokeWidth?: number; + roughness?: number; + opacity?: number; + text?: string; + fontSize?: number; + fontFamily?: string | number; + label?: { + text: string; + }; + createdAt?: string; + updatedAt?: string; + version?: number; + syncedAt?: string; + source?: string; + syncTimestamp?: string; + boundElements?: any[] | null; + containerId?: string | null; + locked?: boolean; +} + +interface WebSocketMessage { + type: string; + element?: ServerElement; + elements?: ServerElement[]; + elementId?: string; + count?: number; + timestamp?: string; + source?: string; +} + +interface ApiResponse { + success: boolean; + elements?: ServerElement[]; + element?: ServerElement; + count?: number; + error?: string; + message?: string; +} + +interface ElementBinding { + id: string; + type: 'text' | 'arrow'; +} + +type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'; + // Helper function to clean elements for Excalidraw -const cleanElementForExcalidraw = (element) => { +const cleanElementForExcalidraw = (element: ServerElement): Partial => { const { createdAt, updatedAt, version, + syncedAt, + source, + syncTimestamp, ...cleanElement } = element; return cleanElement; } // Helper function to validate and fix element binding data -const validateAndFixBindings = (elements) => { - const elementMap = new Map(elements.map(el => [el.id, el])); +const validateAndFixBindings = (elements: Partial[]): Partial[] => { + const elementMap = new Map(elements.map(el => [el.id!, el])); return elements.map(element => { const fixedElement = { ...element }; @@ -23,7 +88,7 @@ const validateAndFixBindings = (elements) => { // Validate and fix boundElements if (fixedElement.boundElements) { if (Array.isArray(fixedElement.boundElements)) { - fixedElement.boundElements = fixedElement.boundElements.filter(binding => { + fixedElement.boundElements = fixedElement.boundElements.filter((binding: any) => { // Ensure binding has required properties if (!binding || typeof binding !== 'object') return false; if (!binding.id || !binding.type) return false; @@ -61,14 +126,14 @@ const validateAndFixBindings = (elements) => { }); } -function App() { - const [excalidrawAPI, setExcalidrawAPI] = useState(null) - const [isConnected, setIsConnected] = useState(false) - const websocketRef = useRef(null) +function App(): JSX.Element { + const [excalidrawAPI, setExcalidrawAPI] = useState(null) + const [isConnected, setIsConnected] = useState(false) + const websocketRef = useRef(null) // Sync state management - const [syncStatus, setSyncStatus] = useState('idle') // idle, syncing, success, error - const [lastSyncTime, setLastSyncTime] = useState(null) + const [syncStatus, setSyncStatus] = useState('idle') + const [lastSyncTime, setLastSyncTime] = useState(null) // WebSocket connection useEffect(() => { @@ -92,22 +157,22 @@ function App() { } }, [excalidrawAPI, isConnected]) - const loadExistingElements = async () => { + const loadExistingElements = async (): Promise => { try { const response = await fetch('/api/elements') - const result = await response.json() + const result: ApiResponse = await response.json() if (result.success && result.elements && result.elements.length > 0) { const cleanedElements = result.elements.map(cleanElementForExcalidraw) const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false }) - excalidrawAPI.updateScene({ elements: convertedElements }) + excalidrawAPI?.updateScene({ elements: convertedElements }) } } catch (error) { console.error('Error loading existing elements:', error) } } - const connectWebSocket = () => { + const connectWebSocket = (): void => { if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) { return } @@ -125,16 +190,16 @@ function App() { } } - websocketRef.current.onmessage = (event) => { + websocketRef.current.onmessage = (event: MessageEvent) => { try { - const data = JSON.parse(event.data) + const data: WebSocketMessage = JSON.parse(event.data) handleWebSocketMessage(data) } catch (error) { console.error('Error parsing WebSocket message:', error, event.data) } } - websocketRef.current.onclose = (event) => { + websocketRef.current.onclose = (event: CloseEvent) => { setIsConnected(false) // Reconnect after 3 seconds if not a clean close @@ -143,14 +208,13 @@ function App() { } } - websocketRef.current.onerror = (error) => { + websocketRef.current.onerror = (error: Event) => { console.error('WebSocket error:', error) setIsConnected(false) } } - - const handleWebSocketMessage = (data) => { + const handleWebSocketMessage = (data: WebSocketMessage): void => { if (!excalidrawAPI) { return } @@ -173,43 +237,51 @@ function App() { break case 'element_created': - const cleanedNewElement = cleanElementForExcalidraw(data.element) - const newElement = convertToExcalidrawElements([cleanedNewElement]) - const updatedElementsAfterCreate = [...currentElements, ...newElement] - excalidrawAPI.updateScene({ - elements: updatedElementsAfterCreate, - captureUpdate: CaptureUpdateAction.NEVER - }) + if (data.element) { + const cleanedNewElement = cleanElementForExcalidraw(data.element) + const newElement = convertToExcalidrawElements([cleanedNewElement]) + const updatedElementsAfterCreate = [...currentElements, ...newElement] + excalidrawAPI.updateScene({ + elements: updatedElementsAfterCreate, + captureUpdate: CaptureUpdateAction.NEVER + }) + } break case 'element_updated': - const cleanedUpdatedElement = cleanElementForExcalidraw(data.element) - const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement])[0] - const updatedElements = currentElements.map(el => - el.id === data.element.id ? convertedUpdatedElement : el - ) - excalidrawAPI.updateScene({ - elements: updatedElements, - captureUpdate: CaptureUpdateAction.NEVER - }) + if (data.element) { + const cleanedUpdatedElement = cleanElementForExcalidraw(data.element) + const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement])[0] + const updatedElements = currentElements.map(el => + el.id === data.element!.id ? convertedUpdatedElement : el + ) + excalidrawAPI.updateScene({ + elements: updatedElements, + captureUpdate: CaptureUpdateAction.NEVER + }) + } break case 'element_deleted': - const filteredElements = currentElements.filter(el => el.id !== data.elementId) - excalidrawAPI.updateScene({ - elements: filteredElements, - captureUpdate: CaptureUpdateAction.NEVER - }) + if (data.elementId) { + const filteredElements = currentElements.filter(el => el.id !== data.elementId) + excalidrawAPI.updateScene({ + elements: filteredElements, + captureUpdate: CaptureUpdateAction.NEVER + }) + } break case 'elements_batch_created': - const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw) - const batchElements = convertToExcalidrawElements(cleanedBatchElements) - const updatedElementsAfterBatch = [...currentElements, ...batchElements] - excalidrawAPI.updateScene({ - elements: updatedElementsAfterBatch, - captureUpdate: CaptureUpdateAction.NEVER - }) + if (data.elements) { + const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw) + const batchElements = convertToExcalidrawElements(cleanedBatchElements) + const updatedElementsAfterBatch = [...currentElements, ...batchElements] + excalidrawAPI.updateScene({ + elements: updatedElementsAfterBatch, + captureUpdate: CaptureUpdateAction.NEVER + }) + } break case 'elements_synced': @@ -218,7 +290,7 @@ function App() { break case 'sync_status': - console.log(`Server sync status: ${data.elementCount} elements`) + console.log(`Server sync status: ${data.count} elements`) break default: @@ -230,14 +302,14 @@ function App() { } // Data format conversion for backend - const convertToBackendFormat = (element) => { + const convertToBackendFormat = (element: ExcalidrawElement): ServerElement => { return { ...element - } + } as ServerElement } // Format sync time display - const formatSyncTime = (time) => { + const formatSyncTime = (time: Date | null): string => { if (!time) return '' return time.toLocaleTimeString('zh-CN', { hour: '2-digit', @@ -247,7 +319,7 @@ function App() { } // Main sync function - const syncToBackend = async () => { + const syncToBackend = async (): Promise => { if (!excalidrawAPI) { console.warn('Excalidraw API not available') return @@ -279,7 +351,7 @@ function App() { }) if (response.ok) { - const result = await response.json() + const result: ApiResponse = await response.json() setSyncStatus('success') setLastSyncTime(new Date()) console.log(`Sync successful: ${result.count} elements synced`) @@ -287,7 +359,7 @@ function App() { // Reset status after 2 seconds setTimeout(() => setSyncStatus('idle'), 2000) } else { - const error = await response.json() + const error: ApiResponse = await response.json() setSyncStatus('error') console.error('Sync failed:', error.error) } @@ -297,13 +369,12 @@ function App() { } } - - const clearCanvas = async () => { + const clearCanvas = async (): Promise => { if (excalidrawAPI) { try { // Get all current elements and delete them from backend const response = await fetch('/api/elements') - const result = await response.json() + const result: ApiResponse = await response.json() if (result.success && result.elements) { const deletePromises = result.elements.map(element => @@ -373,7 +444,7 @@ function App() { {/* Canvas Container */}
setExcalidrawAPI(api)} + excalidrawAPI={(api: ExcalidrawAPIRefValue) => setExcalidrawAPI(api)} initialData={{ elements: [], appState: { @@ -387,4 +458,4 @@ function App() { ) } -export default App \ No newline at end of file +export default App \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx deleted file mode 100644 index aad322f..0000000 --- a/frontend/src/main.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.jsx' - -ReactDOM.createRoot(document.getElementById('root')).render( - - - , -) \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..b25d27e --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error('Root element not found'); +} + +ReactDOM.createRoot(rootElement).render( + + + , +) \ No newline at end of file diff --git a/package.json b/package.json index 14207ee..2fa85df 100644 --- a/package.json +++ b/package.json @@ -2,21 +2,23 @@ "name": "mcp-excalidraw-server", "version": "1.0.2", "description": "Advanced MCP server for Excalidraw with real-time canvas, WebSocket sync, and comprehensive diagram management", - "main": "src/index.js", + "main": "dist/index.js", "type": "module", "bin": { - "mcp-excalidraw-server": "src/index.js" + "mcp-excalidraw-server": "dist/index.js" }, "scripts": { - "start": "node src/index.js", - "canvas": "node src/server.js", - "build": "npm run build:frontend && npm run build:types", + "start": "npm run build:server && node dist/index.js", + "canvas": "npm run build:server && node dist/server.js", + "build": "npm run build:frontend && npm run build:server", "build:frontend": "vite build", - "build:types": "npx tsc --emitDeclarationOnly", "build:server": "npx tsc", - "dev": "concurrently \"npm run canvas\" \"vite\"", + "build:types": "npx tsc --emitDeclarationOnly", + "dev": "concurrently \"npm run dev:server\" \"vite\"", + "dev:server": "npx tsc --watch", "production": "npm run build && npm run canvas", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "type-check": "npx tsc --noEmit" }, "dependencies": { "@excalidraw/excalidraw": "^0.18.0", @@ -33,7 +35,12 @@ "zod-to-json-schema": "^3.22.3" }, "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", "@types/node": "^20.19.7", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/ws": "^8.5.10", "@vitejs/plugin-react": "^4.6.0", "concurrently": "^9.2.0", "typescript": "^5.8.3", diff --git a/src/index.js b/src/index.ts old mode 100755 new mode 100644 similarity index 58% rename from src/index.js rename to src/index.ts index 15d2d15..2943e11 --- a/src/index.js +++ b/src/index.ts @@ -5,19 +5,23 @@ process.env.NODE_DISABLE_COLORS = '1'; process.env.NO_COLOR = '1'; import { fileURLToPath } from "url"; - import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, - ListToolsRequestSchema + ListToolsRequestSchema, + CallToolRequest, + Tool } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import dotenv from 'dotenv'; import logger from './utils/logger.js'; import { generateId, - EXCALIDRAW_ELEMENT_TYPES + EXCALIDRAW_ELEMENT_TYPES, + ServerElement, + ExcalidrawElementType, + validateElement } from './types.js'; import fetch from 'node-fetch'; @@ -28,15 +32,30 @@ dotenv.config(); const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || 'http://localhost:3000'; const ENABLE_CANVAS_SYNC = process.env.ENABLE_CANVAS_SYNC !== 'false'; // Default to true +// API Response types +interface ApiResponse { + success: boolean; + element?: ServerElement; + elements?: ServerElement[]; + message?: string; + count?: number; +} + +interface SyncResponse { + element?: ServerElement; + elements?: ServerElement[]; +} + // Helper functions to sync with Express server (canvas) -async function syncToCanvas(operation, data) { +async function syncToCanvas(operation: string, data: any): Promise { if (!ENABLE_CANVAS_SYNC) { logger.debug('Canvas sync disabled, skipping'); return null; } try { - let url, options; + let url: string; + let options: any; switch (operation) { case 'create': @@ -83,43 +102,50 @@ async function syncToCanvas(operation, data) { throw new Error(`Canvas sync failed: ${response.status} ${response.statusText}`); } - const result = await response.json(); + const result = await response.json() as ApiResponse; logger.debug(`Canvas sync successful: ${operation}`, result); - return result; + return result as SyncResponse; } catch (error) { - logger.warn(`Canvas sync failed for ${operation}:`, error.message); + logger.warn(`Canvas sync failed for ${operation}:`, (error as Error).message); // Don't throw - we want MCP operations to work even if canvas is unavailable return null; } } // Helper to sync element creation to canvas -async function createElementOnCanvas(elementData) { +async function createElementOnCanvas(elementData: ServerElement): Promise { const result = await syncToCanvas('create', elementData); return result?.element || elementData; } // Helper to sync element update to canvas -async function updateElementOnCanvas(elementData) { +async function updateElementOnCanvas(elementData: Partial & { id: string }): Promise { const result = await syncToCanvas('update', elementData); - return result?.element || elementData; + return result?.element || null; } // Helper to sync element deletion to canvas -async function deleteElementOnCanvas(elementId) { +async function deleteElementOnCanvas(elementId: string): Promise { const result = await syncToCanvas('delete', { id: elementId }); return result; } // Helper to sync batch creation to canvas -async function batchCreateElementsOnCanvas(elementsData) { +async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise { const result = await syncToCanvas('batch_create', elementsData); return result?.elements || elementsData; } // In-memory storage for scene state -const sceneState = { +interface SceneState { + theme: string; + viewport: { x: number; y: number; zoom: number }; + selectedElements: Set; + groups: Map; +} + +const sceneState: SceneState = { theme: 'light', viewport: { x: 0, y: 0, zoom: 1 }, selectedElements: new Set(), @@ -128,7 +154,7 @@ const sceneState = { // Schema definitions using zod const ElementSchema = z.object({ - type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)), + type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]), x: z.number(), y: z.number(), width: z.number().optional(), @@ -167,7 +193,7 @@ const DistributeElementsSchema = z.object({ }); const QuerySchema = z.object({ - type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)).optional(), + type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(), filter: z.record(z.any()).optional() }); @@ -175,19 +201,201 @@ const ResourceSchema = z.object({ resource: z.enum(['scene', 'library', 'theme', 'elements']) }); -// Initialize MCP server -const server = new Server( +// Tool definitions +const tools: Tool[] = [ { - name: "mcp-excalidraw-server", - version: "1.0.2", - description: "Advanced MCP server for Excalidraw with real-time canvas" + name: 'create_element', + description: 'Create a new Excalidraw element', + inputSchema: { + type: 'object', + properties: { + type: { + type: 'string', + enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) + }, + x: { type: 'number' }, + y: { type: 'number' }, + width: { type: 'number' }, + height: { type: 'number' }, + backgroundColor: { type: 'string' }, + strokeColor: { type: 'string' }, + strokeWidth: { type: 'number' }, + roughness: { type: 'number' }, + opacity: { type: 'number' }, + text: { type: 'string' }, + fontSize: { type: 'number' }, + fontFamily: { type: 'string' } + }, + required: ['type', 'x', 'y'] + } }, { - capabilities: { - tools: { - create_element: { - description: 'Create a new Excalidraw element', - inputSchema: { + name: 'update_element', + description: 'Update an existing Excalidraw element', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + type: { + type: 'string', + enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) + }, + x: { type: 'number' }, + y: { type: 'number' }, + width: { type: 'number' }, + height: { type: 'number' }, + backgroundColor: { type: 'string' }, + strokeColor: { type: 'string' }, + strokeWidth: { type: 'number' }, + roughness: { type: 'number' }, + opacity: { type: 'number' }, + text: { type: 'string' }, + fontSize: { type: 'number' }, + fontFamily: { type: 'string' } + }, + required: ['id'] + } + }, + { + name: 'delete_element', + description: 'Delete an Excalidraw element', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string' } + }, + required: ['id'] + } + }, + { + name: 'query_elements', + description: 'Query Excalidraw elements with optional filters', + inputSchema: { + type: 'object', + properties: { + type: { + type: 'string', + enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) + }, + filter: { + type: 'object', + additionalProperties: true + } + } + } + }, + { + name: 'get_resource', + description: 'Get an Excalidraw resource', + inputSchema: { + type: 'object', + properties: { + resource: { + type: 'string', + enum: ['scene', 'library', 'theme', 'elements'] + } + }, + required: ['resource'] + } + }, + { + name: 'group_elements', + description: 'Group multiple elements together', + inputSchema: { + type: 'object', + properties: { + elementIds: { + type: 'array', + items: { type: 'string' } + } + }, + required: ['elementIds'] + } + }, + { + name: 'ungroup_elements', + description: 'Ungroup a group of elements', + inputSchema: { + type: 'object', + properties: { + groupId: { type: 'string' } + }, + required: ['groupId'] + } + }, + { + name: 'align_elements', + description: 'Align elements to a specific position', + inputSchema: { + type: 'object', + properties: { + elementIds: { + type: 'array', + items: { type: 'string' } + }, + alignment: { + type: 'string', + enum: ['left', 'center', 'right', 'top', 'middle', 'bottom'] + } + }, + required: ['elementIds', 'alignment'] + } + }, + { + name: 'distribute_elements', + description: 'Distribute elements evenly', + inputSchema: { + type: 'object', + properties: { + elementIds: { + type: 'array', + items: { type: 'string' } + }, + direction: { + type: 'string', + enum: ['horizontal', 'vertical'] + } + }, + required: ['elementIds', 'direction'] + } + }, + { + name: 'lock_elements', + description: 'Lock elements to prevent modification', + inputSchema: { + type: 'object', + properties: { + elementIds: { + type: 'array', + items: { type: 'string' } + } + }, + required: ['elementIds'] + } + }, + { + name: 'unlock_elements', + description: 'Unlock elements to allow modification', + inputSchema: { + type: 'object', + properties: { + elementIds: { + type: 'array', + items: { type: 'string' } + } + }, + required: ['elementIds'] + } + }, + { + name: 'batch_create_elements', + description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams', + inputSchema: { + type: 'object', + properties: { + elements: { + type: 'array', + items: { type: 'object', properties: { type: { @@ -209,196 +417,32 @@ const server = new Server( }, required: ['type', 'x', 'y'] } - }, - update_element: { - description: 'Update an existing Excalidraw element', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string' }, - type: { - type: 'string', - enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) - }, - x: { type: 'number' }, - y: { type: 'number' }, - width: { type: 'number' }, - height: { type: 'number' }, - backgroundColor: { type: 'string' }, - strokeColor: { type: 'string' }, - strokeWidth: { type: 'number' }, - roughness: { type: 'number' }, - opacity: { type: 'number' }, - text: { type: 'string' }, - fontSize: { type: 'number' }, - fontFamily: { type: 'string' } - }, - required: ['id'] - } - }, - delete_element: { - description: 'Delete an Excalidraw element', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string' } - }, - required: ['id'] - } - }, - query_elements: { - description: 'Query Excalidraw elements with optional filters', - inputSchema: { - type: 'object', - properties: { - type: { - type: 'string', - enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) - }, - filter: { - type: 'object', - additionalProperties: true - } - } - } - }, - get_resource: { - description: 'Get an Excalidraw resource', - inputSchema: { - type: 'object', - properties: { - resource: { - type: 'string', - enum: ['scene', 'library', 'theme', 'elements'] - } - }, - required: ['resource'] - } - }, - group_elements: { - description: 'Group multiple elements together', - inputSchema: { - type: 'object', - properties: { - elementIds: { - type: 'array', - items: { type: 'string' } - } - }, - required: ['elementIds'] - } - }, - ungroup_elements: { - description: 'Ungroup a group of elements', - inputSchema: { - type: 'object', - properties: { - groupId: { type: 'string' } - }, - required: ['groupId'] - } - }, - align_elements: { - description: 'Align elements to a specific position', - inputSchema: { - type: 'object', - properties: { - elementIds: { - type: 'array', - items: { type: 'string' } - }, - alignment: { - type: 'string', - enum: ['left', 'center', 'right', 'top', 'middle', 'bottom'] - } - }, - required: ['elementIds', 'alignment'] - } - }, - distribute_elements: { - description: 'Distribute elements evenly', - inputSchema: { - type: 'object', - properties: { - elementIds: { - type: 'array', - items: { type: 'string' } - }, - direction: { - type: 'string', - enum: ['horizontal', 'vertical'] - } - }, - required: ['elementIds', 'direction'] - } - }, - lock_elements: { - description: 'Lock elements to prevent modification', - inputSchema: { - type: 'object', - properties: { - elementIds: { - type: 'array', - items: { type: 'string' } - } - }, - required: ['elementIds'] - } - }, - unlock_elements: { - description: 'Unlock elements to allow modification', - inputSchema: { - type: 'object', - properties: { - elementIds: { - type: 'array', - items: { type: 'string' } - } - }, - required: ['elementIds'] - } - }, - batch_create_elements: { - description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams', - inputSchema: { - type: 'object', - properties: { - elements: { - type: 'array', - items: { - type: 'object', - properties: { - type: { - type: 'string', - enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) - }, - x: { type: 'number' }, - y: { type: 'number' }, - width: { type: 'number' }, - height: { type: 'number' }, - backgroundColor: { type: 'string' }, - strokeColor: { type: 'string' }, - strokeWidth: { type: 'number' }, - roughness: { type: 'number' }, - opacity: { type: 'number' }, - text: { type: 'string' }, - fontSize: { type: 'number' }, - fontFamily: { type: 'string' } - }, - required: ['type', 'x', 'y'] - } - } - }, - required: ['elements'] - } - }, - } + } + }, + required: ['elements'] + } + } +]; + +// Initialize MCP server +const server = new Server( + { + name: "mcp-excalidraw-server", + version: "1.0.2", + description: "Advanced MCP server for Excalidraw with real-time canvas" + }, + { + capabilities: { + tools: Object.fromEntries(tools.map(tool => [tool.name, { + description: tool.description, + inputSchema: tool.inputSchema + }])) } } ); // Helper function to convert text property to label format for Excalidraw -function convertTextToLabel(element) { +function convertTextToLabel(element: ServerElement): ServerElement { const { text, ...rest } = element; if (text) { // For standalone text elements, keep text as direct property @@ -409,13 +453,13 @@ function convertTextToLabel(element) { return { ...rest, label: { text } - }; + } as ServerElement; } return element; } // Set up request handler for tool calls -server.setRequestHandler(CallToolRequestSchema, async (request) => { +server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { try { const { name, arguments: args } = request.params; logger.info(`Handling tool call: ${name}`); @@ -426,7 +470,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { logger.info('Creating element via MCP', { type: params.type }); const id = generateId(); - const element = { + const element: ServerElement = { id, ...params, createdAt: new Date().toISOString(), @@ -465,14 +509,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!id) throw new Error('Element ID is required'); // Build update payload with timestamp and version increment - const updatePayload = { + const updatePayload: Partial & { id: string } = { id, ...updates, updatedAt: new Date().toISOString() }; // Convert text to label format for Excalidraw - const excalidrawElement = convertTextToLabel(updatePayload); + const excalidrawElement = convertTextToLabel(updatePayload as ServerElement); // Update element directly on HTTP server (no local storage) const canvasElement = await updateElementOnCanvas(excalidrawElement); @@ -526,7 +570,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (type) queryParams.set('type', type); if (filter) { Object.entries(filter).forEach(([key, value]) => { - queryParams.set(key, value); + queryParams.set(key, String(value)); }); } @@ -538,14 +582,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw new Error(`HTTP server error: ${response.status} ${response.statusText}`); } - const data = await response.json(); + const data = await response.json() as ApiResponse; const results = data.elements || []; return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] }; } catch (error) { - throw new Error(`Failed to query elements: ${error.message}`); + throw new Error(`Failed to query elements: ${(error as Error).message}`); } } @@ -554,7 +598,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const { resource } = params; logger.info('Getting resource', { resource }); - let result; + let result: any; switch (resource) { case 'scene': result = { @@ -571,12 +615,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!response.ok) { throw new Error(`HTTP server error: ${response.status} ${response.statusText}`); } - const data = await response.json(); + const data = await response.json() as ApiResponse; result = { elements: data.elements || [] }; } catch (error) { - throw new Error(`Failed to get elements: ${error.message}`); + throw new Error(`Failed to get elements: ${(error as Error).message}`); } break; case 'theme': @@ -671,7 +715,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } catch (error) { - throw new Error(`Failed to lock elements: ${error.message}`); + throw new Error(`Failed to lock elements: ${(error as Error).message}`); } } @@ -697,7 +741,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } catch (error) { - throw new Error(`Failed to unlock elements: ${error.message}`); + throw new Error(`Failed to unlock elements: ${(error as Error).message}`); } } @@ -705,12 +749,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const params = z.object({ elements: z.array(ElementSchema) }).parse(args); logger.info('Batch creating elements via MCP', { count: params.elements.length }); - const createdElements = []; + const createdElements: ServerElement[] = []; // Create each element with unique ID for (const elementData of params.elements) { const id = generateId(); - const element = { + const element: ServerElement = { id, ...elementData, createdAt: new Date().toISOString(), @@ -754,9 +798,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw new Error(`Unknown tool: ${name}`); } } catch (error) { - logger.error(`Error handling tool call: ${error.message}`, { error }); + logger.error(`Error handling tool call: ${(error as Error).message}`, { error }); return { - content: [{ type: 'text', text: `Error: ${error.message}` }], + content: [{ type: 'text', text: `Error: ${(error as Error).message}` }], isError: true }; } @@ -765,234 +809,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // Set up request handler for listing available tools server.setRequestHandler(ListToolsRequestSchema, async () => { logger.info('Listing available tools'); - - const tools = [ - { - name: 'create_element', - description: 'Create a new Excalidraw element', - inputSchema: { - type: 'object', - properties: { - type: { - type: 'string', - enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) - }, - x: { type: 'number' }, - y: { type: 'number' }, - width: { type: 'number' }, - height: { type: 'number' }, - backgroundColor: { type: 'string' }, - strokeColor: { type: 'string' }, - strokeWidth: { type: 'number' }, - roughness: { type: 'number' }, - opacity: { type: 'number' }, - text: { type: 'string' }, - fontSize: { type: 'number' }, - fontFamily: { type: 'string' } - }, - required: ['type', 'x', 'y'] - } - }, - { - name: 'update_element', - description: 'Update an existing Excalidraw element', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string' }, - type: { - type: 'string', - enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) - }, - x: { type: 'number' }, - y: { type: 'number' }, - width: { type: 'number' }, - height: { type: 'number' }, - backgroundColor: { type: 'string' }, - strokeColor: { type: 'string' }, - strokeWidth: { type: 'number' }, - roughness: { type: 'number' }, - opacity: { type: 'number' }, - text: { type: 'string' }, - fontSize: { type: 'number' }, - fontFamily: { type: 'string' } - }, - required: ['id'] - } - }, - { - name: 'delete_element', - description: 'Delete an Excalidraw element', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string' } - }, - required: ['id'] - } - }, - { - name: 'query_elements', - description: 'Query Excalidraw elements with optional filters', - inputSchema: { - type: 'object', - properties: { - type: { - type: 'string', - enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) - }, - filter: { - type: 'object', - additionalProperties: true - } - } - } - }, - { - name: 'get_resource', - description: 'Get an Excalidraw resource', - inputSchema: { - type: 'object', - properties: { - resource: { - type: 'string', - enum: ['scene', 'library', 'theme', 'elements'] - } - }, - required: ['resource'] - } - }, - { - name: 'group_elements', - description: 'Group multiple elements together', - inputSchema: { - type: 'object', - properties: { - elementIds: { - type: 'array', - items: { type: 'string' } - } - }, - required: ['elementIds'] - } - }, - { - name: 'ungroup_elements', - description: 'Ungroup a group of elements', - inputSchema: { - type: 'object', - properties: { - groupId: { type: 'string' } - }, - required: ['groupId'] - } - }, - { - name: 'align_elements', - description: 'Align elements to a specific position', - inputSchema: { - type: 'object', - properties: { - elementIds: { - type: 'array', - items: { type: 'string' } - }, - alignment: { - type: 'string', - enum: ['left', 'center', 'right', 'top', 'middle', 'bottom'] - } - }, - required: ['elementIds', 'alignment'] - } - }, - { - name: 'distribute_elements', - description: 'Distribute elements evenly', - inputSchema: { - type: 'object', - properties: { - elementIds: { - type: 'array', - items: { type: 'string' } - }, - direction: { - type: 'string', - enum: ['horizontal', 'vertical'] - } - }, - required: ['elementIds', 'direction'] - } - }, - { - name: 'lock_elements', - description: 'Lock elements to prevent modification', - inputSchema: { - type: 'object', - properties: { - elementIds: { - type: 'array', - items: { type: 'string' } - } - }, - required: ['elementIds'] - } - }, - { - name: 'unlock_elements', - description: 'Unlock elements to allow modification', - inputSchema: { - type: 'object', - properties: { - elementIds: { - type: 'array', - items: { type: 'string' } - } - }, - required: ['elementIds'] - } - }, - { - name: 'batch_create_elements', - description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams', - inputSchema: { - type: 'object', - properties: { - elements: { - type: 'array', - items: { - type: 'object', - properties: { - type: { - type: 'string', - enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) - }, - x: { type: 'number' }, - y: { type: 'number' }, - width: { type: 'number' }, - height: { type: 'number' }, - backgroundColor: { type: 'string' }, - strokeColor: { type: 'string' }, - strokeWidth: { type: 'number' }, - roughness: { type: 'number' }, - opacity: { type: 'number' }, - text: { type: 'string' }, - fontSize: { type: 'number' }, - fontFamily: { type: 'string' } - }, - required: ['type', 'x', 'y'] - } - } - }, - required: ['elements'] - } - } - ]; - return { tools }; }); // Start server with transport based on mode -async function runServer() { +async function runServer(): Promise { try { logger.info('Starting Excalidraw MCP server...'); @@ -1022,19 +843,19 @@ async function runServer() { process.stdin.resume(); } catch (error) { logger.error('Error starting server:', error); - process.stderr.write(`Failed to start MCP server: ${error.message}\n${error.stack}\n`); + process.stderr.write(`Failed to start MCP server: ${(error as Error).message}\n${(error as Error).stack}\n`); process.exit(1); } } // Add global error handlers -process.on('uncaughtException', (error) => { +process.on('uncaughtException', (error: Error) => { logger.error('Uncaught exception:', error); process.stderr.write(`UNCAUGHT EXCEPTION: ${error.message}\n${error.stack}\n`); setTimeout(() => process.exit(1), 1000); }); -process.on('unhandledRejection', (reason, promise) => { +process.on('unhandledRejection', (reason: any, promise: Promise) => { logger.error('Unhandled promise rejection:', reason); process.stderr.write(`UNHANDLED REJECTION: ${reason}\n`); setTimeout(() => process.exit(1), 1000); @@ -1053,4 +874,4 @@ if (fileURLToPath(import.meta.url) === process.argv[1]) { }); } -export default runServer; +export default runServer; \ No newline at end of file diff --git a/src/server.js b/src/server.ts similarity index 76% rename from src/server.js rename to src/server.ts index 31d1f40..a42e53a 100644 --- a/src/server.js +++ b/src/server.ts @@ -1,4 +1,4 @@ -import express from 'express'; +import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import { WebSocketServer } from 'ws'; import { createServer } from 'http'; @@ -9,9 +9,19 @@ import logger from './utils/logger.js'; import { elements, generateId, - EXCALIDRAW_ELEMENT_TYPES + EXCALIDRAW_ELEMENT_TYPES, + ServerElement, + ExcalidrawElementType, + WebSocketMessage, + ElementCreatedMessage, + ElementUpdatedMessage, + ElementDeletedMessage, + BatchCreatedMessage, + SyncStatusMessage, + InitialElementsMessage } from './types.js'; import { z } from 'zod'; +import WebSocket from 'ws'; // Load environment variables dotenv.config(); @@ -34,35 +44,37 @@ app.use(express.static(staticDir)); app.use(express.static(path.join(__dirname, '../dist/frontend'))); // WebSocket connections -const clients = new Set(); +const clients = new Set(); // Broadcast to all connected clients -function broadcast(message) { +function broadcast(message: WebSocketMessage): void { const data = JSON.stringify(message); clients.forEach(client => { - if (client.readyState === client.OPEN) { + if (client.readyState === WebSocket.OPEN) { client.send(data); } }); } // WebSocket connection handling -wss.on('connection', (ws) => { +wss.on('connection', (ws: WebSocket) => { clients.add(ws); logger.info('New WebSocket connection established'); // Send current elements to new client - ws.send(JSON.stringify({ + const initialMessage: InitialElementsMessage = { type: 'initial_elements', elements: Array.from(elements.values()) - })); + }; + ws.send(JSON.stringify(initialMessage)); // Send sync status to new client - ws.send(JSON.stringify({ + const syncMessage: SyncStatusMessage = { type: 'sync_status', elementCount: elements.size, timestamp: new Date().toISOString() - })); + }; + ws.send(JSON.stringify(syncMessage)); ws.on('close', () => { clients.delete(ws); @@ -78,7 +90,7 @@ wss.on('connection', (ws) => { // Schema validation const CreateElementSchema = z.object({ id: z.string().optional(), // Allow passing ID for MCP sync - type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)), + type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]), x: z.number(), y: z.number(), width: z.number().optional(), @@ -98,7 +110,7 @@ const CreateElementSchema = z.object({ const UpdateElementSchema = z.object({ id: z.string(), - type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)).optional(), + type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), @@ -119,7 +131,7 @@ const UpdateElementSchema = z.object({ // API Routes // Get all elements -app.get('/api/elements', (req, res) => { +app.get('/api/elements', (req: Request, res: Response) => { try { const elementsArray = Array.from(elements.values()); res.json({ @@ -131,20 +143,20 @@ app.get('/api/elements', (req, res) => { logger.error('Error fetching elements:', error); res.status(500).json({ success: false, - error: error.message + error: (error as Error).message }); } }); // Create new element -app.post('/api/elements', (req, res) => { +app.post('/api/elements', (req: Request, res: Response) => { try { const params = CreateElementSchema.parse(req.body); logger.info('Creating element via API', { type: params.type }); // Prioritize passed ID (for MCP sync), otherwise generate new ID const id = params.id || generateId(); - const element = { + const element: ServerElement = { id, ...params, createdAt: new Date().toISOString(), @@ -155,10 +167,11 @@ app.post('/api/elements', (req, res) => { elements.set(id, element); // Broadcast to all connected clients - broadcast({ + const message: ElementCreatedMessage = { type: 'element_created', element: element - }); + }; + broadcast(message); res.json({ success: true, @@ -168,17 +181,24 @@ app.post('/api/elements', (req, res) => { logger.error('Error creating element:', error); res.status(400).json({ success: false, - error: error.message + error: (error as Error).message }); } }); // Update element -app.put('/api/elements/:id', (req, res) => { +app.put('/api/elements/:id', (req: Request, res: Response) => { try { const { id } = req.params; const updates = UpdateElementSchema.parse({ id, ...req.body }); + if (!id) { + return res.status(400).json({ + success: false, + error: 'Element ID is required' + }); + } + const existingElement = elements.get(id); if (!existingElement) { return res.status(404).json({ @@ -187,20 +207,21 @@ app.put('/api/elements/:id', (req, res) => { }); } - const updatedElement = { + const updatedElement: ServerElement = { ...existingElement, ...updates, updatedAt: new Date().toISOString(), - version: existingElement.version + 1 + version: (existingElement.version || 0) + 1 }; elements.set(id, updatedElement); // Broadcast to all connected clients - broadcast({ + const message: ElementUpdatedMessage = { type: 'element_updated', element: updatedElement - }); + }; + broadcast(message); res.json({ success: true, @@ -210,16 +231,23 @@ app.put('/api/elements/:id', (req, res) => { logger.error('Error updating element:', error); res.status(400).json({ success: false, - error: error.message + error: (error as Error).message }); } }); // Delete element -app.delete('/api/elements/:id', (req, res) => { +app.delete('/api/elements/:id', (req: Request, res: Response) => { try { const { id } = req.params; + if (!id) { + return res.status(400).json({ + success: false, + error: 'Element ID is required' + }); + } + if (!elements.has(id)) { return res.status(404).json({ success: false, @@ -230,10 +258,11 @@ app.delete('/api/elements/:id', (req, res) => { elements.delete(id); // Broadcast to all connected clients - broadcast({ + const message: ElementDeletedMessage = { type: 'element_deleted', - elementId: id - }); + elementId: id! + }; + broadcast(message); res.json({ success: true, @@ -243,19 +272,19 @@ app.delete('/api/elements/:id', (req, res) => { logger.error('Error deleting element:', error); res.status(500).json({ success: false, - error: error.message + error: (error as Error).message }); } }); // Query elements with filters -app.get('/api/elements/search', (req, res) => { +app.get('/api/elements/search', (req: Request, res: Response) => { try { const { type, ...filters } = req.query; let results = Array.from(elements.values()); // Filter by type if specified - if (type) { + if (type && typeof type === 'string') { results = results.filter(element => element.type === type); } @@ -263,7 +292,7 @@ app.get('/api/elements/search', (req, res) => { if (Object.keys(filters).length > 0) { results = results.filter(element => { return Object.entries(filters).every(([key, value]) => { - return element[key] === value; + return (element as any)[key] === value; }); }); } @@ -277,15 +306,23 @@ app.get('/api/elements/search', (req, res) => { logger.error('Error querying elements:', error); res.status(500).json({ success: false, - error: error.message + error: (error as Error).message }); } }); // Get element by ID -app.get('/api/elements/:id', (req, res) => { +app.get('/api/elements/:id', (req: Request, res: Response) => { try { const { id } = req.params; + + if (!id) { + return res.status(400).json({ + success: false, + error: 'Element ID is required' + }); + } + const element = elements.get(id); if (!element) { @@ -303,13 +340,13 @@ app.get('/api/elements/:id', (req, res) => { logger.error('Error fetching element:', error); res.status(500).json({ success: false, - error: error.message + error: (error as Error).message }); } }); // Batch create elements -app.post('/api/elements/batch', (req, res) => { +app.post('/api/elements/batch', (req: Request, res: Response) => { try { const { elements: elementsToCreate } = req.body; @@ -320,12 +357,12 @@ app.post('/api/elements/batch', (req, res) => { }); } - const createdElements = []; + const createdElements: ServerElement[] = []; elementsToCreate.forEach(elementData => { const params = CreateElementSchema.parse(elementData); const id = generateId(); - const element = { + const element: ServerElement = { id, ...params, createdAt: new Date().toISOString(), @@ -338,10 +375,11 @@ app.post('/api/elements/batch', (req, res) => { }); // Broadcast to all connected clients - broadcast({ + const message: BatchCreatedMessage = { type: 'elements_batch_created', elements: createdElements - }); + }; + broadcast(message); res.json({ success: true, @@ -352,13 +390,13 @@ app.post('/api/elements/batch', (req, res) => { logger.error('Error batch creating elements:', error); res.status(400).json({ success: false, - error: error.message + error: (error as Error).message }); } }); // Sync elements from frontend (overwrite sync) -app.post('/api/elements/sync', (req, res) => { +app.post('/api/elements/sync', (req: Request, res: Response) => { try { const { elements: frontendElements, timestamp } = req.body; @@ -384,15 +422,15 @@ app.post('/api/elements/sync', (req, res) => { // 2. Batch write new data let successCount = 0; - const processedElements = []; + const processedElements: ServerElement[] = []; - frontendElements.forEach((element, index) => { + frontendElements.forEach((element: any, index: number) => { try { // Ensure element has ID, generate one if missing const elementId = element.id || generateId(); // Add server metadata - const processedElement = { + const processedElement: ServerElement = { ...element, id: elementId, syncedAt: new Date().toISOString(), @@ -435,14 +473,14 @@ app.post('/api/elements/sync', (req, res) => { logger.error('Sync error:', error); res.status(500).json({ success: false, - error: error.message, + error: (error as Error).message, details: 'Internal server error during sync operation' }); } }); // Serve the frontend -app.get('/', (req, res) => { +app.get('/', (req: Request, res: Response) => { const htmlFile = path.join(__dirname, '../dist/frontend/index.html'); res.sendFile(htmlFile, (err) => { if (err) { @@ -453,7 +491,7 @@ app.get('/', (req, res) => { }); // Health check endpoint -app.get('/health', (req, res) => { +app.get('/health', (req: Request, res: Response) => { res.json({ status: 'healthy', timestamp: new Date().toISOString(), @@ -463,7 +501,7 @@ app.get('/health', (req, res) => { }); // Sync status endpoint -app.get('/api/sync/status', (req, res) => { +app.get('/api/sync/status', (req: Request, res: Response) => { res.json({ success: true, elementCount: elements.size, @@ -477,7 +515,7 @@ app.get('/api/sync/status', (req, res) => { }); // Error handling middleware -app.use((err, req, res, next) => { +app.use((err: Error, req: Request, res: Response, next: NextFunction) => { logger.error('Unhandled error:', err); res.status(500).json({ success: false, @@ -486,7 +524,7 @@ app.use((err, req, res, next) => { }); // Start server -const PORT = process.env.PORT || 3000; +const PORT = parseInt(process.env.PORT || '3000', 10); const HOST = process.env.HOST || 'localhost'; server.listen(PORT, HOST, () => { @@ -494,4 +532,4 @@ server.listen(PORT, HOST, () => { logger.info(`WebSocket server running on ws://${HOST}:${PORT}`); }); -export default app; \ No newline at end of file +export default app; \ No newline at end of file diff --git a/src/types.js b/src/types.js deleted file mode 100644 index 65b1f59..0000000 --- a/src/types.js +++ /dev/null @@ -1,35 +0,0 @@ -// Excalidraw element types -export const EXCALIDRAW_ELEMENT_TYPES = { - RECTANGLE: 'rectangle', - ELLIPSE: 'ellipse', - DIAMOND: 'diamond', - ARROW: 'arrow', - TEXT: 'text', - LABEL: 'label', - FREEDRAW: 'freedraw', - LINE: 'line' -}; - -// In-memory storage for Excalidraw elements -export const elements = new Map(); - -// Validation function for Excalidraw elements -export function validateElement(element) { - const requiredFields = ['type', 'x', 'y']; - const hasRequiredFields = requiredFields.every(field => field in element); - - if (!hasRequiredFields) { - throw new Error(`Missing required fields: ${requiredFields.join(', ')}`); - } - - if (!Object.values(EXCALIDRAW_ELEMENT_TYPES).includes(element.type)) { - throw new Error(`Invalid element type: ${element.type}`); - } - - return true; -} - -// Helper function to generate unique IDs -export function generateId() { - return Date.now().toString(36) + Math.random().toString(36).substr(2); -} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..94c039e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,233 @@ +export interface ExcalidrawElementBase { + id: string; + type: ExcalidrawElementType; + x: number; + y: number; + width?: number; + height?: number; + angle?: number; + strokeColor?: string; + backgroundColor?: string; + fillStyle?: string; + strokeWidth?: number; + strokeStyle?: string; + roughness?: number; + opacity?: number; + groupIds?: string[]; + frameId?: string | null; + roundness?: { + type: number; + value?: number; + } | null; + seed?: number; + versionNonce?: number; + isDeleted?: boolean; + locked?: boolean; + link?: string | null; + customData?: Record | null; + boundElements?: readonly ExcalidrawBoundElement[] | null; + updated?: number; + containerId?: string | null; +} + +export interface ExcalidrawTextElement extends ExcalidrawElementBase { + type: 'text'; + text: string; + fontSize?: number; + fontFamily?: number; + textAlign?: string; + verticalAlign?: string; + baseline?: number; + lineHeight?: number; +} + +export interface ExcalidrawRectangleElement extends ExcalidrawElementBase { + type: 'rectangle'; + width: number; + height: number; +} + +export interface ExcalidrawEllipseElement extends ExcalidrawElementBase { + type: 'ellipse'; + width: number; + height: number; +} + +export interface ExcalidrawDiamondElement extends ExcalidrawElementBase { + type: 'diamond'; + width: number; + height: number; +} + +export interface ExcalidrawArrowElement extends ExcalidrawElementBase { + type: 'arrow'; + points: readonly [number, number][]; + lastCommittedPoint?: readonly [number, number] | null; + startBinding?: ExcalidrawBinding | null; + endBinding?: ExcalidrawBinding | null; + startArrowhead?: string | null; + endArrowhead?: string | null; +} + +export interface ExcalidrawLineElement extends ExcalidrawElementBase { + type: 'line'; + points: readonly [number, number][]; + lastCommittedPoint?: readonly [number, number] | null; + startBinding?: ExcalidrawBinding | null; + endBinding?: ExcalidrawBinding | null; +} + +export interface ExcalidrawFreedrawElement extends ExcalidrawElementBase { + type: 'freedraw'; + points: readonly [number, number][]; + pressures?: readonly number[]; + simulatePressure?: boolean; + lastCommittedPoint?: readonly [number, number] | null; +} + +export type ExcalidrawElement = + | ExcalidrawTextElement + | ExcalidrawRectangleElement + | ExcalidrawEllipseElement + | ExcalidrawDiamondElement + | ExcalidrawArrowElement + | ExcalidrawLineElement + | ExcalidrawFreedrawElement; + +export interface ExcalidrawBoundElement { + id: string; + type: 'text' | 'arrow'; +} + +export interface ExcalidrawBinding { + elementId: string; + focus: number; + gap: number; + fixedPoint?: readonly [number, number] | null; +} + +export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw' | 'label'; + +// Excalidraw element types +export const EXCALIDRAW_ELEMENT_TYPES: Record = { + RECTANGLE: 'rectangle', + ELLIPSE: 'ellipse', + DIAMOND: 'diamond', + ARROW: 'arrow', + TEXT: 'text', + LABEL: 'label', + FREEDRAW: 'freedraw', + LINE: 'line' +} as const; + +// Server-side element with metadata +export interface ServerElement extends Omit { + id: string; + type: ExcalidrawElementType; + createdAt?: string; + updatedAt?: string; + version?: number; + syncedAt?: string; + source?: string; + syncTimestamp?: string; + text?: string; + fontSize?: number; + fontFamily?: string | number; + label?: { + text: string; + }; +} + +// API Response types +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +export interface ElementsResponse extends ApiResponse { + elements: ServerElement[]; + count: number; +} + +export interface ElementResponse extends ApiResponse { + element: ServerElement; +} + +export interface SyncResponse extends ApiResponse { + count: number; + syncedAt: string; + beforeCount: number; + afterCount: number; +} + +// WebSocket message types +export interface WebSocketMessage { + type: WebSocketMessageType; + [key: string]: any; +} + +export type WebSocketMessageType = + | 'initial_elements' + | 'element_created' + | 'element_updated' + | 'element_deleted' + | 'elements_batch_created' + | 'elements_synced' + | 'sync_status'; + +export interface InitialElementsMessage extends WebSocketMessage { + type: 'initial_elements'; + elements: ServerElement[]; +} + +export interface ElementCreatedMessage extends WebSocketMessage { + type: 'element_created'; + element: ServerElement; +} + +export interface ElementUpdatedMessage extends WebSocketMessage { + type: 'element_updated'; + element: ServerElement; +} + +export interface ElementDeletedMessage extends WebSocketMessage { + type: 'element_deleted'; + elementId: string; +} + +export interface BatchCreatedMessage extends WebSocketMessage { + type: 'elements_batch_created'; + elements: ServerElement[]; +} + +export interface SyncStatusMessage extends WebSocketMessage { + type: 'sync_status'; + elementCount: number; + timestamp: string; +} + +// In-memory storage for Excalidraw elements +export const elements = new Map(); + +// Validation function for Excalidraw elements +export function validateElement(element: Partial): element is ServerElement { + const requiredFields: (keyof ServerElement)[] = ['type', 'x', 'y']; + const hasRequiredFields = requiredFields.every(field => field in element); + + if (!hasRequiredFields) { + throw new Error(`Missing required fields: ${requiredFields.join(', ')}`); + } + + if (!Object.values(EXCALIDRAW_ELEMENT_TYPES).includes(element.type as ExcalidrawElementType)) { + throw new Error(`Invalid element type: ${element.type}`); + } + + return true; +} + +// Helper function to generate unique IDs +export function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substring(2); +} \ No newline at end of file diff --git a/src/utils/logger.js b/src/utils/logger.ts similarity index 88% rename from src/utils/logger.js rename to src/utils/logger.ts index 409f7ec..b5e6562 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.ts @@ -1,6 +1,6 @@ import winston from 'winston'; -const logger = winston.createLogger({ +const logger: winston.Logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( @@ -24,4 +24,4 @@ const logger = winston.createLogger({ ] }); -export default logger; +export default logger; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 7bcf949..41c25f4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,14 +3,24 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "node", - "allowJs": true, + "allowJs": false, "checkJs": false, "declaration": true, "declarationMap": true, "sourceMap": true, "outDir": "./dist", "rootDir": "./src", - "strict": false, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "noImplicitReturns": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, @@ -23,14 +33,16 @@ "types": ["node"] }, "include": [ - "src/**/*" + "src/**/*.ts" ], "exclude": [ "node_modules", "dist", "frontend", - "**/*.test.js", - "**/*.spec.js" + "**/*.test.ts", + "**/*.spec.ts", + "**/*.js", + "**/*.jsx" ], "ts-node": { "esm": true