UNPKG

@ordojs/cli

Version:

Command-line interface for OrdoJS framework

549 lines (470 loc) 14.8 kB
/** * @fileoverview OrdoJS CLI - Hot Module Replacement System * * Manages hot updates with WebSocket communication and state preservation. */ import { OrdoJSCompiler } from '@ordojs/core'; import { EventEmitter } from 'events'; import path from 'path'; import { WebSocket, WebSocketServer } from 'ws'; import { logger } from '../utils/index.js'; import type { FileChangeEvent } from './file-watcher.js'; /** * HMR update types */ export enum HMRUpdateType { COMPONENT_UPDATE = 'component-update', STYLE_UPDATE = 'style-update', ASSET_UPDATE = 'asset-update', FULL_RELOAD = 'full-reload', ERROR = 'error' } /** * HMR update message */ export interface HMRUpdate { type: HMRUpdateType; timestamp: number; file: string; componentName?: string; code?: string; css?: string; error?: string; affectedComponents?: string[]; preserveState?: boolean; } /** * Component state snapshot for preservation */ export interface ComponentStateSnapshot { componentId: string; componentName: string; state: Record<string, any>; props: Record<string, any>; timestamp: number; } /** * HMR client connection */ export interface HMRClient { id: string; socket: WebSocket; connectedAt: number; lastPing: number; userAgent?: string; } /** * HMR configuration options */ export interface HMROptions { /** Port for WebSocket server */ port: number; /** Enable state preservation during updates */ preserveState: boolean; /** Debounce delay for updates in milliseconds */ debounceMs: number; /** Maximum number of connected clients */ maxClients: number; /** Enable verbose logging */ verbose: boolean; } /** * OrdoJSHMR class for managing hot module replacement */ export class OrdoJSHMR extends EventEmitter { private options: HMROptions; private wsServer: WebSocketServer | null; private clients: Map<string, HMRClient>; private compiler: OrdoJSCompiler; private updateQueue: Map<string, HMRUpdate>; private debounceTimers: Map<string, NodeJS.Timeout>; private componentStates: Map<string, ComponentStateSnapshot>; private isRunning: boolean; /** * Create a new OrdoJSHMR instance */ constructor(options: Partial<HMROptions> = {}) { super(); this.options = { port: 24678, // Default HMR port preserveState: true, debounceMs: 100, maxClients: 50, verbose: false, ...options }; this.wsServer = null; this.clients = new Map(); this.updateQueue = new Map(); this.debounceTimers = new Map(); this.componentStates = new Map(); this.isRunning = false; // Initialize compiler with development settings this.compiler = new OrdoJSCompiler({ target: 'es2022', optimize: false, sourceMaps: true, minify: false }); } /** * Start the HMR system */ async start(): Promise<void> { if (this.isRunning) { logger.warn('HMR system is already running'); return; } logger.info(`Starting HMR system on port ${this.options.port}...`); try { // Create WebSocket server this.wsServer = new WebSocketServer({ port: this.options.port, perMessageDeflate: false }); // Set up WebSocket event handlers this.setupWebSocketHandlers(); this.isRunning = true; logger.success(`HMR system started on ws://localhost:${this.options.port}`); // Emit ready event this.emit('ready'); } catch (error) { logger.error(`Failed to start HMR system: ${error instanceof Error ? error.message : String(error)}`); throw error; } } /** * Stop the HMR system */ async stop(): Promise<void> { if (!this.isRunning) { logger.info('HMR system is not running'); return; } logger.info('Stopping HMR system...'); try { // Clear all debounce timers for (const timer of this.debounceTimers.values()) { clearTimeout(timer); } this.debounceTimers.clear(); // Close all client connections for (const client of this.clients.values()) { client.socket.close(1000, 'Server shutting down'); } this.clients.clear(); // Close WebSocket server if (this.wsServer) { await new Promise<void>((resolve, reject) => { this.wsServer!.close((error) => { if (error) { reject(error); } else { resolve(); } }); }); this.wsServer = null; } this.isRunning = false; logger.success('HMR system stopped'); // Emit stopped event this.emit('stopped'); } catch (error) { logger.error(`Error stopping HMR system: ${error instanceof Error ? error.message : String(error)}`); throw error; } } /** * Handle file change events from file watcher */ async handleFileChange(event: FileChangeEvent): Promise<void> { if (!this.isRunning) { return; } const { filePath, type: changeType } = event; const fileExtension = path.extname(filePath); if (this.options.verbose) { logger.debug(`HMR: Processing file change - ${changeType}: ${path.relative(process.cwd(), filePath)}`); } // Clear existing debounce timer for this file const existingTimer = this.debounceTimers.get(filePath); if (existingTimer) { clearTimeout(existingTimer); } // Set new debounce timer const timer = setTimeout(async () => { this.debounceTimers.delete(filePath); await this.processFileUpdate(filePath, changeType, fileExtension); }, this.options.debounceMs); this.debounceTimers.set(filePath, timer); } /** * Get the number of connected clients */ getClientCount(): number { return this.clients.size; } /** * Get HMR statistics */ getStats(): { isRunning: boolean; clientCount: number; port: number; updatesSent: number; componentsTracked: number; } { return { isRunning: this.isRunning, clientCount: this.clients.size, port: this.options.port, updatesSent: this.updateQueue.size, componentsTracked: this.componentStates.size }; } /** * Process file update and generate HMR patch */ private async processFileUpdate(filePath: string, changeType: string, fileExtension: string): Promise<void> { try { const relativePath = path.relative(process.cwd(), filePath); // Determine update type based on file extension let updateType: HMRUpdateType; if (fileExtension === '.ordo') { updateType = HMRUpdateType.COMPONENT_UPDATE; } else if (['.css', '.scss', '.sass', '.less'].includes(fileExtension)) { updateType = HMRUpdateType.STYLE_UPDATE; } else if (['.js', '.ts', '.json'].includes(fileExtension)) { updateType = HMRUpdateType.ASSET_UPDATE; } else { updateType = HMRUpdateType.FULL_RELOAD; } // Generate update patch const update = await this.generateUpdatePatch(filePath, updateType, changeType); if (update) { // Store update in queue this.updateQueue.set(filePath, update); // Send update to all connected clients await this.broadcastUpdate(update); logger.info(`HMR: Sent ${update.type} for ${relativePath}`); } } catch (error) { logger.error(`HMR: Error processing file update for ${filePath}: ${error instanceof Error ? error.message : String(error)}`); // Send error update to clients const errorUpdate: HMRUpdate = { type: HMRUpdateType.ERROR, timestamp: Date.now(), file: filePath, error: error instanceof Error ? error.message : String(error) }; await this.broadcastUpdate(errorUpdate); } } /** * Generate HMR update patch for a file */ private async generateUpdatePatch(filePath: string, updateType: HMRUpdateType, changeType: string): Promise<HMRUpdate | null> { const timestamp = Date.now(); switch (updateType) { case HMRUpdateType.COMPONENT_UPDATE: return await this.generateComponentUpdate(filePath, timestamp, changeType); case HMRUpdateType.STYLE_UPDATE: return await this.generateStyleUpdate(filePath, timestamp); case HMRUpdateType.ASSET_UPDATE: return { type: HMRUpdateType.ASSET_UPDATE, timestamp, file: filePath, preserveState: false }; case HMRUpdateType.FULL_RELOAD: return { type: HMRUpdateType.FULL_RELOAD, timestamp, file: filePath, preserveState: false }; default: return null; } } /** * Generate component update patch */ private async generateComponentUpdate(filePath: string, timestamp: number, changeType: string): Promise<HMRUpdate | null> { try { // Read and compile the component file const fs = await import('fs/promises'); const source = await fs.readFile(filePath, 'utf-8'); // Compile the component const result = this.compiler.compile(source); if (!result.success) { throw new Error(`Compilation failed: ${result.errors.join(', ')}`); } // Extract component name from file path const componentName = path.basename(filePath, '.ordo'); return { type: HMRUpdateType.COMPONENT_UPDATE, timestamp, file: filePath, componentName, code: result.output, preserveState: this.options.preserveState, affectedComponents: [componentName] }; } catch (error) { logger.error(`Failed to generate component update for ${filePath}: ${error instanceof Error ? error.message : String(error)}`); return null; } } /** * Generate style update patch */ private async generateStyleUpdate(filePath: string, timestamp: number): Promise<HMRUpdate | null> { try { const fs = await import('fs/promises'); const css = await fs.readFile(filePath, 'utf-8'); return { type: HMRUpdateType.STYLE_UPDATE, timestamp, file: filePath, css, preserveState: true }; } catch (error) { logger.error(`Failed to generate style update for ${filePath}: ${error instanceof Error ? error.message : String(error)}`); return null; } } /** * Broadcast update to all connected clients */ private async broadcastUpdate(update: HMRUpdate): Promise<void> { const message = JSON.stringify(update); const deadClients: string[] = []; for (const [clientId, client] of this.clients.entries()) { try { if (client.socket.readyState === WebSocket.OPEN) { client.socket.send(message); } else { deadClients.push(clientId); } } catch (error) { logger.debug(`Failed to send update to client ${clientId}: ${error instanceof Error ? error.message : String(error)}`); deadClients.push(clientId); } } // Clean up dead clients for (const clientId of deadClients) { this.clients.delete(clientId); } if (this.options.verbose && deadClients.length > 0) { logger.debug(`Cleaned up ${deadClients.length} dead client connections`); } } /** * Set up WebSocket server event handlers */ private setupWebSocketHandlers(): void { if (!this.wsServer) return; this.wsServer.on('connection', (socket, request) => { const clientId = this.generateClientId(); const userAgent = request.headers['user-agent']; // Check client limit if (this.clients.size >= this.options.maxClients) { socket.close(1013, 'Server at capacity'); return; } // Create client record const client: HMRClient = { id: clientId, socket, connectedAt: Date.now(), lastPing: Date.now(), userAgent }; this.clients.set(clientId, client); if (this.options.verbose) { logger.debug(`HMR: Client connected - ${clientId} (${this.clients.size} total)`); } // Set up client event handlers socket.on('message', (data) => { this.handleClientMessage(clientId, data); }); socket.on('close', (code, reason) => { this.clients.delete(clientId); if (this.options.verbose) { logger.debug(`HMR: Client disconnected - ${clientId} (${code}: ${reason})`); } }); socket.on('error', (error) => { logger.debug(`HMR: Client error - ${clientId}: ${error.message}`); this.clients.delete(clientId); }); // Send welcome message const welcomeMessage = { type: 'welcome', timestamp: Date.now(), clientId, options: { preserveState: this.options.preserveState } }; socket.send(JSON.stringify(welcomeMessage)); }); this.wsServer.on('error', (error) => { logger.error(`HMR WebSocket server error: ${error.message}`); this.emit('error', error); }); } /** * Handle messages from HMR clients */ private handleClientMessage(clientId: string, data: Buffer | string): void { try { const message = JSON.parse(data.toString()); const client = this.clients.get(clientId); if (!client) return; switch (message.type) { case 'ping': client.lastPing = Date.now(); client.socket.send(JSON.stringify({ type: 'pong', timestamp: Date.now() })); break; case 'state-snapshot': if (this.options.preserveState && message.snapshot) { this.storeComponentState(message.snapshot); } break; case 'error': logger.error(`HMR Client error from ${clientId}: ${message.error}`); break; default: if (this.options.verbose) { logger.debug(`HMR: Unknown message type from ${clientId}: ${message.type}`); } } } catch (error) { logger.debug(`HMR: Invalid message from client ${clientId}: ${error instanceof Error ? error.message : String(error)}`); } } /** * Store component state snapshot for preservation */ private storeComponentState(snapshot: ComponentStateSnapshot): void { const key = `${snapshot.componentName}_${snapshot.componentId}`; this.componentStates.set(key, { ...snapshot, timestamp: Date.now() }); if (this.options.verbose) { logger.debug(`HMR: Stored state for ${snapshot.componentName}`); } } /** * Generate unique client ID */ private generateClientId(): string { return `hmr_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`; } }