@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.
615 lines • 26.4 kB
JavaScript
/**
* OATS Development Sync Engine
*
* Optimized file watching and synchronization system
*
* @module @oatsjs/core/dev-sync-optimized
*/
import { EventEmitter } from 'events';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join, basename } from 'path';
import chalk from 'chalk';
import { watch } from 'chokidar';
import debounce from 'lodash.debounce';
import ora from 'ora';
import { ApiSpecError, GeneratorError } from '../errors/index.js';
import { PlatformUtils } from '../utils/platform.js';
import { Logger } from '../utils/logger.js';
import { SwaggerChangeDetector } from './swagger-diff.js';
/**
* Development synchronization engine
*/
export class DevSyncEngine extends EventEmitter {
config;
watcher;
changeDetector;
debouncedSync;
isRunning = false;
lastSyncTime;
syncLock = false;
syncRetries = 0;
MAX_SYNC_RETRIES = 3;
pollingInterval;
lastGeneratedSpecHash;
logger;
constructor(config) {
super();
this.config = config;
this.changeDetector = new SwaggerChangeDetector();
this.logger = new Logger('DevSyncEngine');
// Setup debounced sync function with platform-specific timing
const debounceMs = this.config.sync.debounceMs ?? PlatformUtils.getFileWatcherDebounce();
this.debouncedSync = debounce(this.performSync.bind(this), debounceMs);
}
/**
* Start watching for changes
*/
async start() {
if (this.isRunning)
return;
const isRuntimeSpec = this.config.services.backend.apiSpec.path.startsWith('runtime:') ||
this.config.services.backend.apiSpec.path.startsWith('/');
if (isRuntimeSpec) {
// For runtime specs, use polling instead of file watching
if (!this.config.log?.quiet) {
this.logger.info(chalk.blue('👁️ Starting OpenAPI spec polling...'));
}
const pollIntervalMs = this.config.sync.pollingInterval ?? 5000; // Default 5 seconds
if (!this.config.log?.quiet) {
this.logger.debug(chalk.dim(`📊 Polling interval: ${pollIntervalMs}ms`));
}
// Start polling
this.pollingInterval = setInterval(() => {
this.debouncedSync();
}, pollIntervalMs);
this.isRunning = true;
if (!this.config.log?.quiet) {
ora().succeed('OpenAPI spec polling started');
}
}
else {
// For static specs, use file watching
if (!this.config.log?.quiet) {
this.logger.info(chalk.blue('👁️ Starting file watcher...'));
}
try {
const watchPaths = this.getWatchPaths();
if (!this.config.log?.quiet) {
this.logger.debug(chalk.dim('📂 Watching paths:'), watchPaths);
}
const ignored = this.config.sync.ignore || [
'**/node_modules/**',
'**/.git/**',
];
this.watcher = watch(watchPaths, {
ignored,
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 300,
pollInterval: 100,
},
});
this.watcher.on('change', this.handleFileChange.bind(this));
this.watcher.on('add', this.handleFileChange.bind(this));
this.watcher.on('error', this.handleWatchError.bind(this));
this.isRunning = true;
if (!this.config.log?.quiet) {
ora().succeed('File watcher started');
}
}
catch (error) {
this.logger.error(chalk.red('❌ Failed to start file watcher:'), error);
throw error;
}
}
// Run initial sync if configured
if (this.config.sync.runInitialGeneration) {
if (!this.config.log?.quiet) {
this.logger.info(chalk.blue('🔄 Running initial sync...'));
}
await this.performSync();
}
this.emit('started');
}
/**
* Stop watching for changes
*/
async stop() {
if (!this.isRunning)
return;
if (!this.config.log?.quiet) {
console.log(chalk.yellow('🔄 Stopping sync engine...'));
}
if (this.watcher) {
await this.watcher.close();
this.watcher = undefined;
}
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = undefined;
}
this.isRunning = false;
if (!this.config.log?.quiet) {
ora().succeed('Sync engine stopped');
}
this.emit('stopped');
}
/**
* Get current sync status
*/
getStatus() {
return {
isRunning: this.isRunning,
lastSyncTime: this.lastSyncTime,
watchedPaths: this.getWatchPaths(),
};
}
/**
* Handle file change events
*/
handleFileChange(filePath) {
this.logger.debug(chalk.gray(`📝 File changed: ${filePath}`));
// Check if this is an API spec file or related file
const isRelevant = this.isRelevantFile(filePath);
this.logger.debug(chalk.dim(` Is relevant: ${isRelevant}`));
if (isRelevant) {
this.logger.info(chalk.blue('🔄 API-related file changed, scheduling sync...'));
this.debouncedSync();
}
else {
this.logger.debug(chalk.dim(' Ignoring non-relevant file'));
}
}
/**
* Handle watch errors
*/
handleWatchError(error) {
this.logger.error(chalk.red('👁️ File watcher error:'), error);
this.emit('error', error);
}
/**
* Perform synchronization
*/
async performSync() {
// Prevent concurrent sync operations
if (this.syncLock) {
this.logger.debug(chalk.yellow('⏳ Sync already in progress, skipping...'));
return;
}
this.syncLock = true;
const showDurations = this.config.sync.showStepDurations ?? false;
const syncStartTime = Date.now();
try {
const event = {
type: 'generation-started',
timestamp: new Date(),
};
this.emit('sync-event', event);
this.logger.info(chalk.blue('🔄 Starting synchronization...'));
// Check if API spec has meaningful changes
const checkStartTime = Date.now();
const hasChanges = await this.checkForMeaningfulChanges();
if (showDurations) {
this.logger.debug(chalk.dim(` ⏱️ Change detection: ${Date.now() - checkStartTime}ms`));
}
if (!hasChanges && this.config.sync.strategy === 'smart') {
this.logger.debug(chalk.gray('📊 No meaningful API changes detected'));
return;
}
// Generate TypeScript client
const genStartTime = Date.now();
await this.generateClient();
if (showDurations) {
this.logger.debug(chalk.dim(` ⏱️ Client generation: ${Date.now() - genStartTime}ms`));
}
// Build client if needed
if (this.config.services.client.buildCommand) {
const buildStartTime = Date.now();
await this.buildClient();
if (showDurations) {
this.logger.debug(chalk.dim(` ⏱️ Client build: ${Date.now() - buildStartTime}ms`));
}
}
// Link packages if auto-link is enabled
if (this.config.sync.autoLink) {
const linkStartTime = Date.now();
await this.linkPackages();
if (showDurations) {
this.logger.debug(chalk.dim(` ⏱️ Package linking: ${Date.now() - linkStartTime}ms`));
}
}
this.lastSyncTime = new Date();
this.syncRetries = 0; // Reset retry count on success
const totalDuration = Date.now() - syncStartTime;
const endTime = new Date().toISOString();
if (showDurations) {
ora().succeed(`Synchronization completed in ${totalDuration}ms total`);
}
else {
ora().succeed(`Synchronization completed successfully at ${endTime}`);
}
const completedEvent = {
type: 'generation-completed',
timestamp: new Date(),
};
this.emit('sync-event', completedEvent);
}
catch (error) {
this.logger.error(chalk.red('❌ Synchronization failed:'), error);
const failedEvent = {
type: 'generation-failed',
timestamp: new Date(),
error: error,
};
this.emit('sync-event', failedEvent);
// Retry logic with exponential backoff
if (this.syncRetries < this.MAX_SYNC_RETRIES) {
this.syncRetries++;
const retryDelay = Math.pow(2, this.syncRetries) * 1000;
this.logger.warn(chalk.yellow(`🔄 Retrying sync in ${retryDelay}ms (attempt ${this.syncRetries}/${this.MAX_SYNC_RETRIES})...`));
setTimeout(() => {
this.syncLock = false;
this.performSync();
}, retryDelay);
}
else {
this.logger.error(chalk.red('❌ Max sync retries exceeded. Manual intervention required.'));
this.syncRetries = 0;
}
}
finally {
this.syncLock = false;
}
}
/**
* Check for meaningful changes in API spec
*/
async checkForMeaningfulChanges() {
// Handle runtime API specs (e.g., FastAPI)
const isRuntimeSpec = this.config.services.backend.apiSpec.path.startsWith('runtime:') ||
this.config.services.backend.apiSpec.path.startsWith('/');
if (isRuntimeSpec) {
// For runtime specs, fetch from endpoint and compare
const runtimePath = this.config.services.backend.apiSpec.path.replace('runtime:', '');
const apiUrl = `http://localhost:${this.config.services.backend.port}${runtimePath}`;
try {
const response = await fetch(apiUrl, {
signal: AbortSignal.timeout(5000), // 5 second timeout
});
if (!response.ok) {
this.logger.warn(chalk.yellow(`Failed to fetch spec for comparison: ${response.statusText}`));
// If we can't fetch the spec, assume it might have changed
return true;
}
const currentSpec = await response.json();
const hasChanges = this.changeDetector.hasSignificantChanges(currentSpec);
// Store the current spec hash if we're going to generate
if (hasChanges) {
this.lastGeneratedSpecHash =
this.changeDetector.getCurrentHash() || undefined;
}
return hasChanges;
}
catch (error) {
this.logger.warn(chalk.yellow(`Error fetching spec for comparison: ${error}`));
// If there's an error, assume changes to be safe
return true;
}
}
// For static specs, read from file system
const specPath = join(this.config.resolvedPaths.backend, this.config.services.backend.apiSpec.path);
if (!existsSync(specPath)) {
throw new ApiSpecError(`API spec file not found: ${specPath}`, specPath);
}
try {
const currentSpec = JSON.parse(readFileSync(specPath, 'utf-8'));
return this.changeDetector.hasSignificantChanges(currentSpec);
}
catch (error) {
throw new ApiSpecError(`Failed to parse API spec: ${error}`, specPath);
}
}
/**
* Generate TypeScript client
*/
async generateClient() {
const spinner = ora('Generating TypeScript client...').start();
const showDurations = this.config.sync.showStepDurations ?? false;
try {
// Extract filename from apiSpec path (e.g., '/openapi.json' -> 'openapi.json')
const specFilename = basename(this.config.services.backend.apiSpec.path) || 'openapi.json';
const clientSwaggerPath = join(this.config.resolvedPaths.client, specFilename);
// Check if we can skip generation based on spec hash
const specHashPath = join(this.config.resolvedPaths.client, '.openapi-hash');
try {
if (existsSync(specHashPath) && this.lastGeneratedSpecHash) {
const savedHash = readFileSync(specHashPath, 'utf-8').trim();
if (savedHash === this.lastGeneratedSpecHash) {
spinner.info('Client already up-to-date with current spec');
this.logger.debug(chalk.gray('📊 Client already up-to-date with current spec'));
return; // Skip generation entirely
}
}
}
catch (err) {
// Continue with generation
}
// Handle runtime API specs (e.g., FastAPI)
const isRuntimeSpec = this.config.services.backend.apiSpec.path.startsWith('runtime:') ||
this.config.services.backend.apiSpec.path.startsWith('/');
if (isRuntimeSpec) {
const runtimePath = this.config.services.backend.apiSpec.path.replace('runtime:', '');
const apiUrl = `http://localhost:${this.config.services.backend.port}${runtimePath}`;
spinner.text = `Fetching OpenAPI spec from ${apiUrl}...`;
this.logger.debug(chalk.dim(`Fetching OpenAPI spec from ${apiUrl}...`));
// Retry logic for runtime specs - backend might still be starting
const maxRetries = 5;
const retryDelay = 3000; // 3 seconds
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const attemptStartTime = Date.now();
const response = await fetch(apiUrl, {
signal: AbortSignal.timeout(10000), // 10 second timeout
});
if (!response.ok) {
throw new Error(`Failed to fetch OpenAPI spec: ${response.statusText}`);
}
const spec = await response.json();
const { writeFileSync } = await import('fs');
writeFileSync(clientSwaggerPath, JSON.stringify(spec, null, 2));
if (showDurations) {
this.logger.debug(chalk.dim(` ⏱️ Fetched OpenAPI spec: ${Date.now() - attemptStartTime}ms`));
}
else {
this.logger.debug(chalk.dim('Fetched and saved OpenAPI spec from runtime'));
}
// Success - break out of retry loop
break;
}
catch (error) {
this.logger.warn(chalk.yellow(`Attempt ${attempt}/${maxRetries} failed: ${error}`));
if (attempt < maxRetries) {
this.logger.debug(chalk.dim(`Waiting ${retryDelay}ms before retry...`));
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
else {
this.logger.error(chalk.red('Failed to fetch runtime OpenAPI spec after all retries:'), error);
throw new ApiSpecError(`Failed to fetch runtime API spec after ${maxRetries} attempts: ${error}`, apiUrl);
}
}
}
}
else {
// Copy static swagger.json to client directory
const specPath = join(this.config.resolvedPaths.backend, this.config.services.backend.apiSpec.path);
try {
const { copyFileSync } = await import('fs');
copyFileSync(specPath, clientSwaggerPath);
this.logger.debug(chalk.dim('Copied swagger.json to client directory'));
}
catch (error) {
this.logger.error(chalk.red('Failed to copy swagger.json:'), error);
}
}
// Clean generated files before regeneration to avoid caching issues
try {
const cleanStartTime = Date.now();
const srcPath = join(this.config.resolvedPaths.client, 'src');
const { rmSync } = await import('fs');
rmSync(srcPath, { recursive: true, force: true });
if (showDurations) {
this.logger.debug(chalk.dim(` ⏱️ Cleaned generated files: ${Date.now() - cleanStartTime}ms`));
}
else {
this.logger.debug(chalk.dim('Cleaned generated files'));
}
}
catch (err) {
// Ignore errors, src directory might not exist
}
// Implementation depends on generator type
const { generator, generateCommand } = this.config.services.client;
const genCommandStartTime = Date.now();
spinner.text = 'Running client generation command...';
if (generateCommand) {
// Use the specified generate command
await this.runCommand(generateCommand, this.config.resolvedPaths.client);
}
else if (generator === '@hey-api/openapi-ts') {
// Default command for @hey-api/openapi-ts
await this.runCommand('npx @hey-api/openapi-ts', this.config.resolvedPaths.client);
}
else {
throw new GeneratorError(`No generate command specified for generator ${generator}`, generator, 'generate');
}
if (showDurations) {
spinner.text = `TypeScript client generated (${Date.now() - genCommandStartTime}ms)`;
}
// Save the spec hash after successful generation
if (this.lastGeneratedSpecHash) {
try {
const { writeFileSync } = await import('fs');
writeFileSync(specHashPath, this.lastGeneratedSpecHash, 'utf-8');
}
catch (err) {
// Non-critical, continue
}
}
spinner.succeed('TypeScript client generated successfully');
}
catch (error) {
spinner.fail('Failed to generate TypeScript client');
throw error;
}
}
/**
* Build client package
*/
async buildClient() {
const { buildCommand } = this.config.services.client;
if (!buildCommand)
return;
const spinner = ora('Building client package...').start();
try {
// Check if fast build is available and we're in development
const packageJsonPath = join(this.config.resolvedPaths.client, 'package.json');
let useFastBuild = false;
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
if (packageJson.scripts && packageJson.scripts['build:fast']) {
useFastBuild = true;
}
}
catch (err) {
// Ignore, use regular build
}
const commandToRun = useFastBuild
? buildCommand.replace('build', 'build:fast')
: buildCommand;
spinner.text = useFastBuild
? 'Running fast build...'
: 'Running build command...';
await this.runCommand(commandToRun, this.config.resolvedPaths.client);
// Update package.json with OATS metadata after successful build
await this.updateClientVersionMetadata();
spinner.succeed('Client package built successfully');
}
catch (error) {
spinner.fail('Failed to build client package');
throw error;
}
}
/**
* Update client package.json with OATS metadata
*/
async updateClientVersionMetadata() {
const packageJsonPath = join(this.config.resolvedPaths.client, 'package.json');
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
// Add OATS metadata
packageJson._oats = {
generated: new Date().toISOString(),
apiSpecHash: this.lastGeneratedSpecHash || 'unknown',
version: packageJson.version || '1.0.0',
};
// Write back the updated package.json
writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
this.logger.debug(chalk.dim(`Updated client metadata with API spec hash`));
}
catch (error) {
this.logger.warn('Failed to update client version metadata:', error);
// Non-critical error, continue
}
}
/**
* Link packages for local development
*/
async linkPackages() {
const { linkCommand } = this.config.services.client;
if (!linkCommand)
return;
const spinner = ora('Linking packages...').start();
try {
// Link the client package
await this.runCommand(linkCommand, this.config.resolvedPaths.client);
// Link in frontend if configured
if (this.config.services.frontend) {
const frontendLinkCommand = this.config.services.frontend.packageLinkCommand ||
`${this.config.packageManager} link ${this.config.services.client.packageName}`;
await this.runCommand(frontendLinkCommand, this.config.resolvedPaths.frontend);
}
// Emit event to track linked packages
const linkedPaths = [this.config.resolvedPaths.client];
if (this.config.services.frontend) {
linkedPaths.push(this.config.resolvedPaths.frontend);
}
this.emit('packages-linked', {
clientPackage: this.config.services.client.packageName,
paths: linkedPaths,
});
// Touch a file in frontend to trigger HMR
if (this.config.services.frontend) {
try {
const touchFile = join(this.config.resolvedPaths.frontend, 'src', '.oats-sync');
await PlatformUtils.touchFile(touchFile);
this.logger.debug(chalk.dim('Triggered frontend HMR'));
}
catch (err) {
// Ignore errors, this is optional
}
}
spinner.succeed('Packages linked successfully');
}
catch (error) {
spinner.fail('Failed to link packages');
throw error;
}
}
/**
* Run a shell command
*/
async runCommand(command, cwd) {
const { execa } = await import('execa');
try {
const stdio = this.config.log?.quiet ? 'pipe' : 'inherit';
await execa(command, {
cwd,
shell: true,
stdio,
});
}
catch (error) {
throw new Error(`Command failed: ${command} - ${error}`);
}
}
/**
* Get paths to watch for changes
*/
getWatchPaths() {
const paths = [];
// Handle runtime API specs (e.g., FastAPI)
const isRuntimeSpec = this.config.services.backend.apiSpec.path.startsWith('runtime:') ||
this.config.services.backend.apiSpec.path.startsWith('/');
if (isRuntimeSpec) {
// For runtime specs, watch the entire backend directory
// The ignore patterns will be handled by chokidar's ignored option
paths.push(this.config.resolvedPaths.backend);
}
else {
// Watch static API spec file
const specPath = join(this.config.resolvedPaths.backend, this.config.services.backend.apiSpec.path);
paths.push(specPath);
}
// Watch additional paths if specified
if (this.config.services.backend.apiSpec.watch) {
for (const pattern of this.config.services.backend.apiSpec.watch) {
paths.push(join(this.config.resolvedPaths.backend, pattern));
}
}
return paths;
}
/**
* Check if file is relevant for synchronization
*/
isRelevantFile(filePath) {
// For runtime specs, any Python file change is relevant
const isRuntimeSpec = this.config.services.backend.apiSpec.path.startsWith('runtime:') ||
this.config.services.backend.apiSpec.path.startsWith('/');
if (isRuntimeSpec) {
// Check if it's a Python file and not in ignored directories
if (filePath.endsWith('.py') &&
!filePath.includes('__pycache__') &&
!filePath.includes('.venv') &&
!filePath.includes('venv/') &&
!filePath.includes('env/')) {
return true;
}
}
// Always relevant for API spec files
return (filePath.endsWith('.json') ||
filePath.endsWith('.yaml') ||
filePath.endsWith('.yml'));
}
}
//# sourceMappingURL=dev-sync-optimized.js.map