@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
JavaScript
/**
* 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