Refactor project structure and enhance TypeScript support

- Updated package.json to point to compiled TypeScript files in the dist directory.
- Improved TypeScript configuration with stricter type checks and removed JavaScript support.
- Migrated frontend entry point to TypeScript and added a new App component with enhanced functionality.
- Implemented a new server structure with TypeScript, including WebSocket support and improved element management.
- Updated README to reflect changes in architecture and usage instructions.
- Added comprehensive type definitions for Excalidraw elements and server responses.
This commit is contained in:
yctimlin
2025-08-20 15:31:42 +00:00
parent fcd3cc077a
commit 5043a67385
11 changed files with 841 additions and 662 deletions
+60 -33
View File
@@ -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
+104 -33
View File
@@ -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<ExcalidrawElement> => {
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<ExcalidrawElement>[]): Partial<ExcalidrawElement>[] => {
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<ExcalidrawAPIRefValue | null>(null)
const [isConnected, setIsConnected] = useState<boolean>(false)
const websocketRef = useRef<WebSocket | null>(null)
// Sync state management
const [syncStatus, setSyncStatus] = useState('idle') // idle, syncing, success, error
const [lastSyncTime, setLastSyncTime] = useState(null)
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle')
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null)
// WebSocket connection
useEffect(() => {
@@ -92,22 +157,22 @@ function App() {
}
}, [excalidrawAPI, isConnected])
const loadExistingElements = async () => {
const loadExistingElements = async (): Promise<void> => {
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,6 +237,7 @@ function App() {
break
case 'element_created':
if (data.element) {
const cleanedNewElement = cleanElementForExcalidraw(data.element)
const newElement = convertToExcalidrawElements([cleanedNewElement])
const updatedElementsAfterCreate = [...currentElements, ...newElement]
@@ -180,29 +245,35 @@ function App() {
elements: updatedElementsAfterCreate,
captureUpdate: CaptureUpdateAction.NEVER
})
}
break
case 'element_updated':
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
el.id === data.element!.id ? convertedUpdatedElement : el
)
excalidrawAPI.updateScene({
elements: updatedElements,
captureUpdate: CaptureUpdateAction.NEVER
})
}
break
case 'element_deleted':
if (data.elementId) {
const filteredElements = currentElements.filter(el => el.id !== data.elementId)
excalidrawAPI.updateScene({
elements: filteredElements,
captureUpdate: CaptureUpdateAction.NEVER
})
}
break
case 'elements_batch_created':
if (data.elements) {
const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw)
const batchElements = convertToExcalidrawElements(cleanedBatchElements)
const updatedElementsAfterBatch = [...currentElements, ...batchElements]
@@ -210,6 +281,7 @@ function App() {
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<void> => {
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<void> => {
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 */}
<div className="canvas-container">
<Excalidraw
excalidrawAPI={(api) => setExcalidrawAPI(api)}
excalidrawAPI={(api: ExcalidrawAPIRefValue) => setExcalidrawAPI(api)}
initialData={{
elements: [],
appState: {
-9
View File
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+14
View File
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+15 -8
View File
@@ -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",
Executable → Regular
+433 -612
View File
File diff suppressed because it is too large Load Diff
+90 -52
View File
@@ -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<WebSocket>();
// 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, () => {
-35
View File
@@ -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);
}
+233
View File
@@ -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<string, any> | 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<string, ExcalidrawElementType> = {
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<ExcalidrawElementBase, 'id'> {
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<T = any> {
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<string, ServerElement>();
// Validation function for Excalidraw elements
export function validateElement(element: Partial<ServerElement>): 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);
}
+1 -1
View File
@@ -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(
+17 -5
View File
@@ -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