@ordojs/cli
Version:
Command-line interface for OrdoJS framework
549 lines (470 loc) • 14.8 kB
text/typescript
/**
* @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)}`;
}
}