@ordojs/cli
Version:
Command-line interface for OrdoJS framework
581 lines (511 loc) • 16.8 kB
text/typescript
/**
* @fileoverview OrdoJS CLI - Development Server
*
* Main development server implementation with lifecycle management.
*/
import { readdir } from 'fs/promises';
import http from 'http';
import type { AddressInfo } from 'net';
import path from 'path';
import { fileExists, readFile } from '../utils/fs.js';
import { logger } from '../utils/index.js';
import { PortManager } from './port-manager.js';
import { ProcessManager } from './process-manager.js';
/**
* Development server options
*/
export interface DevServerOptions {
/** Directory to serve */
dir: string;
/** Host to listen on */
host: string;
/** Port to listen on */
port: string | number;
/** Enable hot module replacement */
hmr: boolean;
}
/**
* Server status enum
*/
export enum ServerStatus {
STOPPED = 'stopped',
STARTING = 'starting',
RUNNING = 'running',
STOPPING = 'stopping',
ERROR = 'error',
RESTARTING = 'restarting'
}
/**
* Server state interface for preserving state during restarts
*/
export interface ServerState {
/** Connected clients */
connectedClients?: string[];
/** Active file watchers */
activeWatchers?: string[];
/** Compilation cache */
compilationCache?: Record<string, unknown>;
/** Custom state data */
[key: string]: unknown;
}
/**
* OrdoJSDevServer class for managing the development server lifecycle
*/
export class OrdoJSDevServer {
private options: DevServerOptions;
private server: http.Server | null;
private portManager: PortManager;
private processManager: ProcessManager;
private status: ServerStatus;
private actualPort: number;
private serverState: ServerState;
private hmrServer: any | null; // Placeholder for HMR server instance
private cssWatcher: any | null; // Placeholder for CSS watcher instance
/**
* Create a new OrdoJSDevServer instance
*
* @param options - Server options
*/
constructor(options: DevServerOptions) {
this.options = {
...options,
dir: options.dir || '.',
host: options.host || 'localhost',
port: options.port || 3000,
hmr: options.hmr !== false
};
this.server = null;
this.portManager = new PortManager(Number(this.options.port));
this.processManager = new ProcessManager();
this.status = ServerStatus.STOPPED;
this.actualPort = 0;
this.serverState = {};
this.hmrServer = null;
this.cssWatcher = null;
}
/**
* Get the current server status
*/
getStatus(): ServerStatus {
return this.status;
}
/**
* Get the actual port the server is running on
*/
getPort(): number {
return this.actualPort;
}
/**
* Get the current server state
*
* @returns The current server state
*/
getServerState(): ServerState {
return { ...this.serverState };
}
/**
* Start the development server
*/
async start(): Promise<void> {
if (this.status === ServerStatus.RUNNING) {
logger.info('Server is already running');
return;
}
if (this.status === ServerStatus.STARTING) {
logger.info('Server is already starting');
return;
}
this.status = ServerStatus.STARTING;
logger.info('Starting development server...');
try {
// Validate the directory exists
const dirExists = await fileExists(this.options.dir);
if (!dirExists) {
throw new Error(`Directory not found: ${this.options.dir}`);
}
// Allocate a port
this.actualPort = await this.portManager.allocatePort(
'dev-server',
Number(this.options.port)
);
// Create HTTP server
this.server = http.createServer(this.requestHandler.bind(this));
// Start the server
await this.startServer();
// Set up HMR if enabled
if (this.options.hmr) {
await this.setupHMR();
}
this.status = ServerStatus.RUNNING;
logger.success(`Development server running at http://${this.options.host}:${this.actualPort}`);
} catch (error) {
this.status = ServerStatus.ERROR;
logger.error(`Failed to start server: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
/**
* Stop the development server
*/
async stop(): Promise<void> {
if (this.status === ServerStatus.STOPPED) {
logger.info('Server is already stopped');
return;
}
if (this.status === ServerStatus.STOPPING) {
logger.info('Server is already stopping');
return;
}
this.status = ServerStatus.STOPPING;
logger.info('Stopping development server...');
try {
// Stop the HTTP server
if (this.server) {
await this.stopServer();
}
// Clean up resources
if (this.actualPort) {
this.portManager.releasePort(this.actualPort);
}
// Clean up child processes
await this.processManager.cleanup();
this.status = ServerStatus.STOPPED;
logger.success('Development server stopped');
} catch (error) {
this.status = ServerStatus.ERROR;
logger.error(`Failed to stop server: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
/**
* Restart the development server
*/
async restart(): Promise<void> {
if (this.status === ServerStatus.RESTARTING) {
logger.info('Server is already restarting');
return;
}
this.status = ServerStatus.RESTARTING;
logger.info('Restarting development server...');
try {
// Preserve the server state before stopping
const preservedState = { ...this.serverState };
// Stop the server but keep the state
await this.stop();
// Restore the preserved state
this.serverState = preservedState;
// Start the server with the preserved state
await this.start();
logger.success('Development server restarted successfully');
} catch (error) {
this.status = ServerStatus.ERROR;
logger.error(`Failed to restart server: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
/**
* Start the HTTP server
*/
private startServer(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!this.server) {
reject(new Error('Server not initialized'));
return;
}
// Handle server errors
this.server.once('error', (error) => {
const e = error as NodeJS.ErrnoException;
if (e.code === 'EADDRINUSE') {
reject(new Error(`Port ${this.actualPort} is already in use`));
} else {
reject(error);
}
});
// Start listening
this.server.listen(this.actualPort, this.options.host, () => {
const address = this.server?.address() as AddressInfo;
this.actualPort = address.port;
resolve();
});
});
}
/**
* Stop the HTTP server
*/
private stopServer(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!this.server) {
resolve();
return;
}
this.server.close((error) => {
if (error) {
reject(error);
} else {
this.server = null;
resolve();
}
});
});
}
/**
* HTTP request handler
*/
private async requestHandler(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const url = req.url || '/';
logger.debug(`${req.method} ${url}`);
try {
// Handle different file types
if (url.endsWith('.ordo')) {
await this.handleOrdoFile(req, res, url);
} else if (url === '/' || url === '/index.html') {
await this.handleIndex(req, res);
} else {
await this.handleStaticFile(req, res, url);
}
} catch (error) {
logger.error(`Request handler error: ${error instanceof Error ? error.message : String(error)}`);
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
}
/**
* Handle OrdoJS file compilation
*/
private async handleOrdoFile(req: http.IncomingMessage, res: http.ServerResponse, url: string): Promise<void> {
const filePath = path.join(this.options.dir, url);
try {
// Read the OrdoJS file
const source = await readFile(filePath);
// Compile the OrdoJS file to HTML/JS
const html = this.compileOrdoToHTML(source, path.basename(filePath, '.ordo'));
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
} catch (error) {
logger.error(`Failed to compile OrdoJS file: ${error instanceof Error ? error.message : String(error)}`);
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head><title>Compilation Error</title></head>
<body>
<h1>OrdoJS Compilation Error</h1>
<p>Failed to compile ${url}: ${error instanceof Error ? error.message : String(error)}</p>
</body>
</html>
`);
}
}
/**
* Handle index page
*/
private async handleIndex(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// First try to serve public/index.html
const indexHtmlPath = path.join(this.options.dir, 'public/index.html');
try {
const exists = await fileExists(indexHtmlPath);
if (exists) {
// Serve the index.html file
const content = await readFile(indexHtmlPath);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(content);
return;
}
} catch (error) {
// Continue to fallback
}
// Fallback: Look for app.ordo in the current directory
const appOrdoPath = path.join(this.options.dir, 'src/app.ordo');
try {
const exists = await fileExists(appOrdoPath);
if (exists) {
// Redirect to the app.ordo file
res.writeHead(302, { 'Location': '/src/app.ordo' });
res.end();
} else {
// Show directory listing or default page
await this.showDirectoryListing(req, res);
}
} catch (error) {
await this.showDirectoryListing(req, res);
}
}
/**
* Handle static files
*/
private async handleStaticFile(req: http.IncomingMessage, res: http.ServerResponse, url: string): Promise<void> {
const filePath = path.join(this.options.dir, url);
try {
const exists = await fileExists(filePath);
if (!exists) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('File not found');
return;
}
const content = await readFile(filePath);
const ext = path.extname(filePath);
let contentType = 'text/plain';
switch (ext) {
case '.html': contentType = 'text/html'; break;
case '.css': contentType = 'text/css'; break;
case '.js': contentType = 'application/javascript'; break;
case '.json': contentType = 'application/json'; break;
case '.png': contentType = 'image/png'; break;
case '.jpg':
case '.jpeg': contentType = 'image/jpeg'; break;
case '.gif': contentType = 'image/gif'; break;
case '.svg': contentType = 'image/svg+xml'; break;
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
} catch (error) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
}
/**
* Show directory listing
*/
private async showDirectoryListing(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
try {
const files = await readdir(this.options.dir);
const ordoFiles = files.filter(file => file.endsWith('.ordo'));
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>OrdoJS Development Server</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; }
h1 { color: #0066cc; }
.file-list { list-style: none; padding: 0; }
.file-list li { margin: 0.5rem 0; }
.file-list a { color: #0066cc; text-decoration: none; }
.file-list a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>OrdoJS Development Server</h1>
<p>Server is running at <code>http://${this.options.host}:${this.actualPort}</code></p>
<p>Serving directory: <code>${path.resolve(this.options.dir)}</code></p>
${ordoFiles.length > 0 ? `
<h2>Available OrdoJS Files:</h2>
<ul class="file-list">
${ordoFiles.map(file => `<li><a href="/${file}">${file}</a></li>`).join('')}
</ul>
` : '<p>No .ordo files found in the current directory.</p>'}
</body>
</html>
`);
} catch (error) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Failed to read directory');
}
}
/**
* Compile OrdoJS to HTML
*/
private compileOrdoToHTML(source: string, componentName: string): string {
try {
// Simple compilation for now - just wrap the content in HTML
// This is a basic implementation that will be enhanced
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${componentName} - OrdoJS</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 20px; }
.component { border: 1px solid #ddd; padding: 20px; margin: 20px 0; border-radius: 8px; }
.source { background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0; }
pre { margin: 0; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>${componentName}</h1>
<div class="component">
<h2>Compiled Component</h2>
<p>This is a placeholder for the compiled OrdoJS component.</p>
<p>The full compilation pipeline will be implemented in upcoming tasks.</p>
</div>
<div class="source">
<h3>Source Code:</h3>
<pre><code>${source.replace(/</g, '<').replace(/>/g, '>')}</code></pre>
</div>
<script>
console.log('OrdoJS component: ${componentName}');
console.log('Source:', \`${source.replace(/`/g, '\\`')}\`);
</script>
</body>
</html>
`;
} catch (error) {
return `
<!DOCTYPE html>
<html>
<head><title>Compilation Error</title></head>
<body>
<h1>OrdoJS Compilation Error</h1>
<p>Failed to compile component: ${error instanceof Error ? error.message : String(error)}</p>
</body>
</html>
`;
}
}
/**
* Set up Hot Module Replacement
*/
private async setupHMR(): Promise<void> {
try {
// Import HMR modules dynamically
const { HMRServer } = await import('./hmr.js');
this.hmrServer = new HMRServer({
port: this.actualPort + 1,
host: this.options.host
});
await this.hmrServer.start();
logger.info('HMR server started');
// Set up file watching for CSS files
await this.setupCSSWatching();
} catch (error) {
logger.warn(`HMR setup failed: ${error instanceof Error ? error.message : String(error)}`);
logger.info('Continuing without HMR...');
}
}
/**
* Set up CSS file watching for hot reload
*/
private async setupCSSWatching(): Promise<void> {
try {
const { FileWatcher } = await import('./file-watcher.js');
this.cssWatcher = new FileWatcher({
dir: this.options.dir,
patterns: ['**/*.css', '**/*.scss', '**/*.sass', '**/*.less'],
ignore: ['node_modules/**', 'dist/**', 'build/**']
});
this.cssWatcher.on('change', (event) => {
this.handleCSSChange(event);
});
await this.cssWatcher.start();
logger.info('CSS file watching enabled');
} catch (error) {
logger.warn(`CSS watching setup failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Handle CSS file changes for hot reload
*/
private handleCSSChange(event: { file: string; type: 'change' | 'add' | 'unlink' }): void {
if (!this.hmrServer) return;
logger.info(`CSS file changed: ${event.file}`);
// Send CSS update to connected clients
this.hmrServer.broadcast({
type: 'css-update',
file: event.file,
timestamp: Date.now()
});
}
}