UNPKG

@tryloop/oats

Version:

🌾 OATS - OpenAPI TypeScript Sync. The missing link between your OpenAPI specs and TypeScript applications. Automatically watch, generate, and sync TypeScript clients from your API definitions.

393 lines • 16.6 kB
/** * OATS Development Orchestrator * * Manages multiple development services with cross-platform compatibility * and coordinates synchronization between backend API and TypeScript client */ import { EventEmitter } from 'events'; import { watch } from 'chokidar'; import chalk from 'chalk'; import ora from 'ora'; import { ProcessManager } from '../utils/process-manager.js'; import { PortManager } from '../utils/port-manager.js'; import { Logger } from '../utils/logger.js'; import { DebugManager } from '../utils/debug.js'; import { ShutdownManager } from '../utils/shutdown-manager.js'; import { linkManager } from '../utils/link-manager.js'; import { DevSyncEngine } from './dev-sync-optimized.js'; import { BaseService, ServiceState } from './services/base-service.js'; import { envManager } from './services/env-manager.js'; // Service implementations class BackendService extends BaseService { async waitForReady() { if (!this.config.port) return; const maxAttempts = 30; const checkInterval = 1000; const spinner = ora(`Waiting for backend service on port ${this.config.port}...`).start(); try { for (let i = 0; i < maxAttempts; i++) { const isReady = await PortManager.isPortInUse(this.config.port); if (isReady) { spinner.succeed(`Backend service ready on port ${this.config.port}`); this.logger.debug(`Backend service ready on port ${this.config.port}`); return; } spinner.text = `Waiting for backend service on port ${this.config.port}... (${i + 1}/${maxAttempts})`; await new Promise((resolve) => setTimeout(resolve, checkInterval)); } spinner.fail(`Backend service did not start within ${maxAttempts} seconds`); throw new Error(`Backend service failed to start on port ${this.config.port}`); } catch (error) { spinner.fail('Failed to start backend service'); throw error; } } async checkPort() { if (!this.config.port) return; const autoKill = this.runtimeConfig?.sync?.autoKillConflictingPorts ?? false; if (autoKill) { await PortManager.freePort(this.config.port, this.config.name); } } } class ClientService extends BaseService { async waitForReady() { // Client generation is typically quick const spinner = ora('Initializing client service...').start(); await new Promise((resolve) => setTimeout(resolve, 2000)); spinner.succeed('Client service ready'); } } class FrontendService extends BaseService { /** * Override start to inject OATS environment variables */ async start() { // Generate and merge OATS env vars const oatsEnvVars = envManager.generateFrontendEnvVars(this.config.path, this.runtimeConfig); // Merge with existing env config this.config.env = { ...this.config.env, ...oatsEnvVars, }; // Call parent start method await super.start(); } async waitForReady() { if (!this.config.port) return; const maxAttempts = 60; // Frontend can take longer const checkInterval = 1000; const spinner = ora(`Waiting for frontend service on port ${this.config.port}...`).start(); try { for (let i = 0; i < maxAttempts; i++) { const isReady = await PortManager.isPortInUse(this.config.port); if (isReady) { spinner.succeed(`Frontend service ready on port ${this.config.port}`); this.logger.debug(`Frontend service ready on port ${this.config.port}`); return; } spinner.text = `Waiting for frontend service on port ${this.config.port}... (${i + 1}/${maxAttempts})`; await new Promise((resolve) => setTimeout(resolve, checkInterval)); } spinner.fail(`Frontend service did not start within ${maxAttempts} seconds`); throw new Error(`Frontend service failed to start on port ${this.config.port}`); } catch (error) { spinner.fail('Failed to start frontend service'); throw error; } } async checkPort() { if (!this.config.port) return; const autoKill = this.runtimeConfig?.sync?.autoKillConflictingPorts ?? false; if (autoKill) { await PortManager.freePort(this.config.port, this.config.name); } } } export class DevSyncOrchestrator extends EventEmitter { config; logger; processManager; shutdownManager; services = new Map(); syncEngine; configWatcher; constructor(config) { super(); this.config = config; this.logger = new Logger('Orchestrator'); this.processManager = new ProcessManager(); this.shutdownManager = ShutdownManager.getInstance(); // Initialize logging const logLevel = config.log?.level ?? 'info'; Logger.setLogLevel(logLevel); Logger.setShowTimestamps(config.log?.timestamps ?? false); Logger.setUseColors(config.log?.colors ?? true); // Initialize debug mode DebugManager.init(logLevel === 'debug'); // Set log file if configured (only for debug level) if (config.log?.file && logLevel === 'debug') { Logger.setLogFile(config.log.file); } this.setupSignalHandlers(); this.createServices(); } /** * Create service instances */ createServices() { // Backend service const backendService = new BackendService({ name: 'backend', path: this.config.resolvedPaths.backend, command: this.config.services.backend.startCommand, port: this.config.services.backend.port, env: this.config.services.backend.env, }, this.processManager, this.config); this.services.set('backend', backendService); // Client service const clientService = new ClientService({ name: 'client', path: this.config.resolvedPaths.client, command: this.config.services.client.generateCommand ?? 'npm run generate', env: this.config.services.client.env || {}, }, this.processManager, this.config); this.services.set('client', clientService); // Frontend service (optional) if (this.config.services.frontend) { const frontendService = new FrontendService({ name: 'frontend', path: this.config.resolvedPaths.frontend, command: this.config.services.frontend.startCommand, port: this.config.services.frontend.port, env: this.config.services.frontend.env, }, this.processManager, this.config); this.services.set('frontend', frontendService); } // Set up service event handlers this.services.forEach((service, name) => { service.on('stateChange', ({ newState }) => { this.emit('serviceStateChange', { service: name, state: newState }); }); }); } /** * Start all services */ async start() { this.logger.info(chalk.blue.bold('\nšŸš€ Starting OATS Development Sync...\n')); try { // Link client package first await this.linkClientPackage(); // Start backend service DebugManager.section('Starting Backend Service'); const backendService = this.services.get('backend'); await backendService.start(); // Start client service (watch mode) DebugManager.section('Starting Client Service'); const clientService = this.services.get('client'); await clientService.start(); // Start frontend service if configured if (this.services.has('frontend')) { DebugManager.section('Starting Frontend Service'); const frontendService = this.services.get('frontend'); await frontendService.start(); } // Start sync engine DebugManager.section('Starting Sync Engine'); this.syncEngine = new DevSyncEngine(this.config); this.setupSyncHandlers(); await this.syncEngine.start(); // Show success message for info level and above this.logger.info(chalk.green.bold('\nāœ… All services started successfully!\n')); this.printServiceStatus(); // Set up signal handlers for graceful shutdown this.setupSignalHandlers(); // Watch config file for changes this.watchConfigFile(); } catch (error) { console.error(chalk.red.bold('\nāŒ Failed to start services\n')); // Only shutdown if this is the initial start, not a config reload if (!this.configWatcher) { await this.shutdown(); } throw error; } } /** * Shutdown all services */ async shutdown() { await this.shutdownManager.shutdown({ syncEngine: this.syncEngine, services: this.services, processManager: this.processManager, configWatcher: this.configWatcher, unlinkPackages: () => linkManager.unlinkAll(this.config), }); } /** * Stop all services without exiting the process */ async stop(keepConfigWatcher = false) { await this.shutdownManager.shutdown({ syncEngine: this.syncEngine, services: this.services, processManager: this.processManager, configWatcher: this.configWatcher, unlinkPackages: () => linkManager.unlinkAll(this.config), }, { keepConfigWatcher, exitProcess: false, }); // Clear service references after shutdown this.syncEngine = undefined; if (!keepConfigWatcher) { this.configWatcher = undefined; } } /** * Link client package to frontend */ async linkClientPackage() { if (!this.config.services.frontend) return; const clientPath = this.config.resolvedPaths.client; const clientName = this.config.services.client.packageName; const frontendPath = this.config.resolvedPaths.frontend; try { await linkManager.linkPackage(clientName, clientPath, frontendPath, this.config); } catch (error) { this.logger.error(`Failed to link ${clientName}:`, error); throw error; } } /** * Set up sync engine event handlers */ setupSyncHandlers() { if (!this.syncEngine) return; this.syncEngine.on('generation-completed', ({ linkedPaths }) => { ora().succeed('Client regeneration completed'); if (linkedPaths?.length) { console.log(chalk.dim('Updated paths:')); linkedPaths.forEach((path) => console.log(chalk.dim(` - ${path}`))); } }); this.syncEngine.on('generation-failed', ({ error }) => { console.error(chalk.red('āŒ Client regeneration failed:'), error); }); } /** * Set up signal handlers */ setupSignalHandlers() { this.shutdownManager.setupSignalHandlers(() => this.shutdown()); } /** * Watch config file for changes */ watchConfigFile() { // If config watcher already exists, don't create a new one if (this.configWatcher) { return; } // Watch all possible config files const configPatterns = [ 'oats.config.json', 'oats.config.js', 'oats.config.ts', ]; this.configWatcher = watch(configPatterns, { persistent: true, ignoreInitial: true, }); this.configWatcher.on('change', async (_changedPath) => { console.log(chalk.yellow('\nšŸ”„ Configuration changed, restarting...\n')); try { // Stop all services but keep the config watcher for restart await this.stop(true); // Re-read and validate the configuration using the config loader const { loadConfigFromFile, findConfigFile } = await import('../config/loader.js'); const { validateConfig, mergeWithDefaults } = await import('../config/schema.js'); const { dirname, resolve, join } = await import('path'); // Find the config file (it might be a different one than originally loaded) const configPath = findConfigFile(); if (!configPath) { console.error(chalk.red('āŒ Could not find configuration file')); return; } // Load the new configuration const loadedConfig = await loadConfigFromFile(configPath); // Validate configuration const validation = validateConfig(loadedConfig); if (!validation.valid) { console.error(chalk.red('\nāŒ Configuration validation failed:\n')); validation.errors.forEach((error) => { console.error(chalk.red(` • ${error.path}: ${error.message}`)); }); console.log(chalk.yellow('\nPlease fix these errors in the config file.')); return; } // Merge with defaults const newConfig = mergeWithDefaults(loadedConfig); // Create runtime config with resolved paths const runtimeConfig = newConfig; runtimeConfig.resolvedPaths = { backend: resolve(dirname(configPath), runtimeConfig.services.backend.path), client: resolve(dirname(configPath), runtimeConfig.services.client.path), frontend: runtimeConfig.services.frontend ? resolve(dirname(configPath), runtimeConfig.services.frontend.path) : undefined, apiSpec: runtimeConfig.services.backend.apiSpec.path.startsWith('runtime:') ? runtimeConfig.services.backend.apiSpec.path : join(resolve(dirname(configPath), runtimeConfig.services.backend.path), runtimeConfig.services.backend.apiSpec.path), }; // Update the config this.config = runtimeConfig; // Recreate services with new config this.services.clear(); this.createServices(); // Restart everything try { await this.start(); } catch (startError) { // Don't call shutdown() here as it will exit the process // Instead, log the error and keep the orchestrator running console.error(chalk.red('āŒ Failed to restart after config change:'), startError); console.log(chalk.yellow('\nServices are stopped. Fix the issue and save the config to retry.')); // Keep watching for config changes so user can fix and retry return; } } catch (error) { console.error(chalk.red('āŒ Failed to restart after config change:'), error); console.log(chalk.yellow('\nServices are stopped. Fix the issue and save the config to retry.')); } }); } /** * Print service status */ printServiceStatus() { console.log(chalk.blue('\nšŸ“Š Service Status:')); this.services.forEach((service) => { const info = service.getInfo(); const stateColor = info.state === ServiceState.RUNNING ? 'green' : 'yellow'; const portInfo = info.port ? ` (port ${info.port})` : ''; console.log(chalk[stateColor](` ${info.name}: ${info.state}${portInfo}`)); }); console.log(''); } } //# sourceMappingURL=orchestrator.js.map