|
| 1 | +import asyncio |
| 2 | +import json |
| 3 | +import logging |
| 4 | +import os |
| 5 | +import time |
| 6 | +from pathlib import Path |
| 7 | +from typing import Dict, List, Optional, Set |
| 8 | +from watchdog.events import FileSystemEventHandler, FileSystemEvent |
| 9 | +from watchdog.observers import Observer |
| 10 | + |
| 11 | +import uvicorn |
| 12 | +from fastapi import FastAPI, WebSocket, WebSocketDisconnect |
| 13 | +from fastapi.staticfiles import StaticFiles |
| 14 | +from fastapi.responses import FileResponse |
| 15 | + |
| 16 | +from .vite_server import ViteServer |
| 17 | + |
| 18 | +logger = logging.getLogger(__name__) |
| 19 | + |
| 20 | + |
| 21 | +class FileWatcher(FileSystemEventHandler): |
| 22 | + """File system watcher that tracks file changes.""" |
| 23 | + |
| 24 | + def __init__(self, websocket_manager): |
| 25 | + self.websocket_manager = websocket_manager |
| 26 | + self.ignored_patterns = { |
| 27 | + ".git", |
| 28 | + "__pycache__", |
| 29 | + ".pytest_cache", |
| 30 | + "node_modules", |
| 31 | + ".DS_Store", |
| 32 | + "*.pyc", |
| 33 | + "*.pyo", |
| 34 | + "*.pyd", |
| 35 | + ".coverage", |
| 36 | + "*.log", |
| 37 | + "*.tmp", |
| 38 | + "*.swp", |
| 39 | + "*.swo", |
| 40 | + "*~", |
| 41 | + } |
| 42 | + |
| 43 | + def should_ignore(self, path: str) -> bool: |
| 44 | + """Check if a path should be ignored.""" |
| 45 | + path_lower = path.lower() |
| 46 | + for pattern in self.ignored_patterns: |
| 47 | + if pattern.startswith("*"): |
| 48 | + if path_lower.endswith(pattern[1:]): |
| 49 | + return True |
| 50 | + elif pattern in path_lower: |
| 51 | + return True |
| 52 | + return False |
| 53 | + |
| 54 | + def on_created(self, event: FileSystemEvent): |
| 55 | + if not event.is_directory and not self.should_ignore(event.src_path): |
| 56 | + self.websocket_manager.broadcast_file_update("file_created", event.src_path) |
| 57 | + |
| 58 | + def on_modified(self, event: FileSystemEvent): |
| 59 | + if not event.is_directory and not self.should_ignore(event.src_path): |
| 60 | + self.websocket_manager.broadcast_file_update("file_changed", event.src_path) |
| 61 | + |
| 62 | + def on_deleted(self, event: FileSystemEvent): |
| 63 | + if not event.is_directory and not self.should_ignore(event.src_path): |
| 64 | + self.websocket_manager.broadcast_file_update("file_deleted", event.src_path) |
| 65 | + |
| 66 | + |
| 67 | +class WebSocketManager: |
| 68 | + """Manages WebSocket connections and broadcasts messages.""" |
| 69 | + |
| 70 | + def __init__(self): |
| 71 | + self.active_connections: List[WebSocket] = [] |
| 72 | + self._loop = None |
| 73 | + |
| 74 | + async def connect(self, websocket: WebSocket): |
| 75 | + await websocket.accept() |
| 76 | + self.active_connections.append(websocket) |
| 77 | + logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}") |
| 78 | + |
| 79 | + def disconnect(self, websocket: WebSocket): |
| 80 | + if websocket in self.active_connections: |
| 81 | + self.active_connections.remove(websocket) |
| 82 | + logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}") |
| 83 | + |
| 84 | + def broadcast_file_update(self, update_type: str, file_path: str): |
| 85 | + """Broadcast file update to all connected clients.""" |
| 86 | + message = {"type": update_type, "path": file_path, "timestamp": time.time()} |
| 87 | + json_message = json.dumps(message) |
| 88 | + |
| 89 | + # Broadcast to all active connections |
| 90 | + for connection in self.active_connections: |
| 91 | + try: |
| 92 | + asyncio.run_coroutine_threadsafe(connection.send_text(json_message), self._loop) |
| 93 | + except Exception as e: |
| 94 | + logger.error(f"Failed to send message to WebSocket: {e}") |
| 95 | + # Remove broken connection |
| 96 | + self.active_connections.remove(connection) |
| 97 | + |
| 98 | + |
| 99 | +class LogsServer(ViteServer): |
| 100 | + """ |
| 101 | + Enhanced server for serving Vite-built SPA with file watching and WebSocket support. |
| 102 | +
|
| 103 | + This server extends ViteServer to add: |
| 104 | + - File system watching |
| 105 | + - WebSocket connections for real-time updates |
| 106 | + - Live log streaming |
| 107 | + """ |
| 108 | + |
| 109 | + def __init__( |
| 110 | + self, |
| 111 | + build_dir: str = os.path.abspath( |
| 112 | + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "vite-app", "dist") |
| 113 | + ), |
| 114 | + host: str = "localhost", |
| 115 | + port: int = 4789, |
| 116 | + index_file: str = "index.html", |
| 117 | + watch_paths: Optional[List[str]] = None, |
| 118 | + ): |
| 119 | + super().__init__(build_dir, host, port, index_file) |
| 120 | + |
| 121 | + # Initialize WebSocket manager |
| 122 | + self.websocket_manager = WebSocketManager() |
| 123 | + |
| 124 | + # Set up file watching |
| 125 | + self.watch_paths = watch_paths or [os.getcwd()] |
| 126 | + self.observer = Observer() |
| 127 | + self.file_watcher = FileWatcher(self.websocket_manager) |
| 128 | + |
| 129 | + # Add WebSocket endpoint |
| 130 | + self._setup_websocket_routes() |
| 131 | + |
| 132 | + logger.info(f"LogsServer initialized on {host}:{port}") |
| 133 | + logger.info(f"Watching paths: {self.watch_paths}") |
| 134 | + |
| 135 | + def _setup_websocket_routes(self): |
| 136 | + """Set up WebSocket routes for real-time communication.""" |
| 137 | + |
| 138 | + @self.app.websocket("/ws") |
| 139 | + async def websocket_endpoint(websocket: WebSocket): |
| 140 | + await self.websocket_manager.connect(websocket) |
| 141 | + try: |
| 142 | + while True: |
| 143 | + # Keep connection alive |
| 144 | + await websocket.receive_text() |
| 145 | + except WebSocketDisconnect: |
| 146 | + self.websocket_manager.disconnect(websocket) |
| 147 | + except Exception as e: |
| 148 | + logger.error(f"WebSocket error: {e}") |
| 149 | + self.websocket_manager.disconnect(websocket) |
| 150 | + |
| 151 | + @self.app.get("/api/status") |
| 152 | + async def status(): |
| 153 | + """Get server status including active connections.""" |
| 154 | + return { |
| 155 | + "status": "ok", |
| 156 | + "build_dir": str(self.build_dir), |
| 157 | + "active_connections": len(self.websocket_manager.active_connections), |
| 158 | + "watch_paths": self.watch_paths, |
| 159 | + } |
| 160 | + |
| 161 | + def start_file_watching(self): |
| 162 | + """Start watching file system for changes.""" |
| 163 | + for path in self.watch_paths: |
| 164 | + if os.path.exists(path): |
| 165 | + self.observer.schedule(self.file_watcher, path, recursive=True) |
| 166 | + logger.info(f"Started watching: {path}") |
| 167 | + else: |
| 168 | + logger.warning(f"Watch path does not exist: {path}") |
| 169 | + |
| 170 | + self.observer.start() |
| 171 | + logger.info("File watching started") |
| 172 | + |
| 173 | + def stop_file_watching(self): |
| 174 | + """Stop watching file system.""" |
| 175 | + self.observer.stop() |
| 176 | + self.observer.join() |
| 177 | + logger.info("File watching stopped") |
| 178 | + |
| 179 | + async def run_async(self, reload: bool = False): |
| 180 | + """ |
| 181 | + Run the logs server asynchronously with file watching. |
| 182 | +
|
| 183 | + Args: |
| 184 | + reload: Whether to enable auto-reload (default: False) |
| 185 | + """ |
| 186 | + try: |
| 187 | + # Start file watching |
| 188 | + self.start_file_watching() |
| 189 | + |
| 190 | + logger.info(f"Starting LogsServer on {self.host}:{self.port}") |
| 191 | + logger.info(f"Serving files from: {self.build_dir}") |
| 192 | + logger.info("WebSocket endpoint available at /ws") |
| 193 | + |
| 194 | + # Store the event loop for WebSocket manager |
| 195 | + self.websocket_manager._loop = asyncio.get_running_loop() |
| 196 | + |
| 197 | + config = uvicorn.Config(self.app, host=self.host, port=self.port, reload=reload, log_level="info") |
| 198 | + server = uvicorn.Server(config) |
| 199 | + await server.serve() |
| 200 | + except KeyboardInterrupt: |
| 201 | + logger.info("Shutting down LogsServer...") |
| 202 | + finally: |
| 203 | + self.stop_file_watching() |
| 204 | + |
| 205 | + def run(self, reload: bool = False): |
| 206 | + """ |
| 207 | + Run the logs server with file watching. |
| 208 | +
|
| 209 | + Args: |
| 210 | + reload: Whether to enable auto-reload (default: False) |
| 211 | + """ |
| 212 | + asyncio.run(self.run_async(reload)) |
| 213 | + |
| 214 | + |
| 215 | +def serve_logs( |
| 216 | + host: str = "localhost", |
| 217 | + port: int = 4789, |
| 218 | + watch_paths: Optional[List[str]] = None, |
| 219 | + reload: bool = False, |
| 220 | +): |
| 221 | + """ |
| 222 | + Convenience function to create and run a LogsServer. |
| 223 | +
|
| 224 | + Args: |
| 225 | + build_dir: Path to the Vite build output directory |
| 226 | + host: Host to bind the server to |
| 227 | + port: Port to bind the server to (default: 4789 for logs) |
| 228 | + index_file: Name of the main index file |
| 229 | + watch_paths: List of paths to watch for file changes |
| 230 | + reload: Whether to enable auto-reload |
| 231 | + """ |
| 232 | + server = LogsServer(host=host, port=port, watch_paths=watch_paths) |
| 233 | + server.run(reload=reload) |
0 commit comments