UNPKG

@pimzino/claude-code-spec-workflow

Version:

Automated workflows for Claude Code. Includes spec-driven development (Requirements → Design → Tasks → Implementation) with intelligent task execution, optional steering documents and streamlined bug fix workflow (Report → Analyze → Fix → Verify). We have

398 lines 16 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.TunnelManager = void 0; const events_1 = require("events"); const types_1 = require("./types"); const access_controller_1 = require("./access-controller"); const usage_tracker_1 = require("./usage-tracker"); const error_handler_1 = require("./error-handler"); const logger_1 = require("../logger"); const DEFAULT_CONFIG = { providers: {}, defaults: { provider: 'ngrok', ttl: 60, // 1 hour maxViewers: 10 }, security: { requirePassword: false, allowedOrigins: ['*'] } }; const DEFAULT_RECOVERY_OPTIONS = { maxRetries: 3, retryDelay: 2000, // 2 seconds healthCheckInterval: 30000, // 30 seconds enableAutoReconnect: true }; class TunnelManager extends events_1.EventEmitter { constructor(_server, config, recoveryOptions) { super(); this._server = _server; this.providers = new Map(); this.retryCount = 0; this.config = { ...DEFAULT_CONFIG, ...config }; this.recoveryOptions = { ...DEFAULT_RECOVERY_OPTIONS, ...recoveryOptions }; this.accessController = new access_controller_1.AccessController(); this.errorHandler = new error_handler_1.TunnelErrorHandler(); this.tunnelHealth = { status: 'unhealthy', lastCheck: new Date(), consecutiveFailures: 0, uptime: 0 }; } registerProvider(provider) { (0, logger_1.debug)(`Registering tunnel provider: ${provider.name}`); this.providers.set(provider.name.toLowerCase(), provider); } async startTunnel(options = {}) { (0, logger_1.debug)('Starting tunnel with options:', options); // Store options for potential reconnection this.lastTunnelOptions = options; this.retryCount = 0; return this.attemptTunnelStart(options); } async attemptTunnelStart(options, isRetry = false) { try { // Stop any existing tunnel if (this.activeTunnel && !isRetry) { await this.stopTunnel(); } const mergedOptions = { ...this.config.defaults, ...options }; // Get available providers const availableProviders = await this.getAvailableProviders(mergedOptions.provider); if (availableProviders.length === 0) { const error = new types_1.TunnelProviderError('all', 'NO_AVAILABLE_PROVIDERS', 'No tunnel providers are available. Please check your configuration.'); throw await this.errorHandler.handleError(error); } // Try providers with enhanced error handling let lastError; for (const provider of availableProviders) { try { (0, logger_1.debug)(`Attempting to create tunnel with provider: ${provider.name}`); const tunnelInfo = await this.createTunnelWithProvider(provider, mergedOptions); // Reset retry count on success this.retryCount = 0; // Start health monitoring this.startHealthMonitoring(); return tunnelInfo; } catch (error) { const tunnelError = error instanceof types_1.TunnelProviderError ? error : new types_1.TunnelProviderError(provider.name, 'UNKNOWN_ERROR', error instanceof Error ? error.message : String(error)); // Handle the error with recovery strategies lastError = await this.errorHandler.handleError(tunnelError); (0, logger_1.debug)(`Provider ${provider.name} failed:`, lastError.message); // Continue to next provider if (provider !== availableProviders[availableProviders.length - 1]) { continue; } } } // All providers failed const finalError = new types_1.TunnelProviderError('all', 'PROVIDER_FAILURES', `All tunnel providers failed. Last error: ${lastError?.message}`, 'Failed to create tunnel with any available provider.', ['Check your internet connection', 'Verify provider installations', 'Check provider configurations']); throw await this.errorHandler.handleError(finalError); } catch (error) { // If retry is enabled and we haven't exceeded max retries if (this.recoveryOptions.enableAutoReconnect && this.retryCount < (this.recoveryOptions.maxRetries || 3) && error instanceof types_1.TunnelProviderError) { this.retryCount++; (0, logger_1.debug)(`Retrying tunnel creation (attempt ${this.retryCount}/${this.recoveryOptions.maxRetries})`); // Wait before retry with exponential backoff const baseDelay = this.recoveryOptions.retryDelay || 2000; const delay = baseDelay * Math.pow(2, this.retryCount - 1); await new Promise(resolve => setTimeout(resolve, delay)); this.emit('tunnel:retry', { attempt: this.retryCount, error: error.message }); return this.attemptTunnelStart(options, true); } throw error; } } async createTunnelWithProvider(provider, options) { // Get the server port const address = this._server.server.address(); const port = (address && typeof address === 'object' && 'port' in address) ? address.port : 3000; // Create tunnel this.activeTunnel = await provider.createTunnel(port, { ttl: typeof options.ttl === 'number' ? options.ttl : undefined, metadata: { projectName: 'claude-code-spec-workflow', readOnly: 'true' } }); // Create tunnel info this.activeInfo = { url: this.activeTunnel.url, provider: provider.name, expiresAt: (typeof options.ttl === 'number') ? new Date(Date.now() + options.ttl * 60 * 1000) : undefined, passwordProtected: !!options.password }; // Generate tunnel ID and set password if provided this.tunnelId = this.generateTunnelId(); if (typeof options.password === 'string') { this.accessController.setPassword(this.tunnelId, options.password); } // Enable read-only mode for tunneled connections this.accessController = new access_controller_1.AccessController({ readOnlyMode: true, password: typeof options.password === 'string' ? options.password : undefined }); // Initialize usage tracker if analytics is enabled if (options.analytics !== false) { this.usageTracker = new usage_tracker_1.UsageTracker(); // Forward usage events this.usageTracker.on('visitor:new', (data) => { this.emit('tunnel:visitor:new', data); }); this.usageTracker.on('metrics:updated', (metrics) => { this.emit('tunnel:metrics:updated', metrics); }); (0, logger_1.debug)('Usage tracking enabled for tunnel'); } // Initialize tunnel health this.tunnelHealth = { status: 'healthy', lastCheck: new Date(), consecutiveFailures: 0, uptime: Date.now() }; // Emit tunnel started event this.emit('tunnel:started', this.activeInfo); (0, logger_1.debug)(`Tunnel created successfully: ${this.activeTunnel.url}`); return this.activeInfo; } async stopTunnel() { (0, logger_1.debug)('Stopping tunnel'); // Stop health monitoring this.stopHealthMonitoring(); if (!this.activeTunnel) { (0, logger_1.debug)('No active tunnel to stop'); return; } try { await this.activeTunnel.close(); // Emit tunnel stopped event with final metrics if (this.usageTracker) { const finalMetrics = this.usageTracker.getMetrics(); this.emit('tunnel:stopped', { info: this.activeInfo, metrics: finalMetrics }); } else { this.emit('tunnel:stopped', this.activeInfo); } this.activeTunnel = undefined; this.activeInfo = undefined; this.tunnelId = undefined; this.usageTracker = undefined; this.lastTunnelOptions = undefined; this.retryCount = 0; // Reset tunnel health this.tunnelHealth = { status: 'unhealthy', lastCheck: new Date(), consecutiveFailures: 0, uptime: 0 }; (0, logger_1.debug)('Tunnel stopped successfully'); } catch (error) { (0, logger_1.debug)('Error stopping tunnel:', error); throw error; } } getStatus() { if (!this.activeTunnel || !this.activeInfo) { return { active: false }; } const status = { active: true, info: this.activeInfo }; // Include viewer count if usage tracking is enabled if (this.usageTracker) { status.viewers = this.usageTracker.getActiveVisitorCount(); } // Include health status if (this.tunnelHealth.status !== 'healthy') { status.error = `Tunnel is ${this.tunnelHealth.status} (${this.tunnelHealth.consecutiveFailures} consecutive failures)`; } return status; } async getAvailableProviders(preferredProvider) { const providers = Array.from(this.providers.values()); if (!providers.length) { return []; } // Check provider availability const availabilityChecks = await Promise.all(providers.map(async (provider) => ({ provider, available: await provider.isAvailable().catch(() => false) }))); const availableProviders = availabilityChecks .filter(({ available }) => available) .map(({ provider }) => provider); // If specific provider requested, filter to just that one if (preferredProvider && preferredProvider !== 'auto') { const preferred = availableProviders.find(p => p.name.toLowerCase() === preferredProvider.toLowerCase()); return preferred ? [preferred] : []; } // Return all available providers for auto mode return availableProviders; } async checkTunnelHealth() { if (!this.activeTunnel) { return null; } try { const health = await this.activeTunnel.getHealth(); // Update tunnel health tracking this.updateTunnelHealth(health); // Emit health check event this.emit('tunnel:health', health); // If unhealthy and auto-reconnect is enabled, attempt recovery if (!health.healthy && this.recoveryOptions.enableAutoReconnect && this.lastTunnelOptions) { (0, logger_1.debug)('Tunnel unhealthy, attempting recovery'); // Only attempt recovery if we haven't exceeded consecutive failures threshold if (this.tunnelHealth.consecutiveFailures < (this.recoveryOptions.maxRetries || 3)) { this.emit('tunnel:recovery:start', { health, attempt: this.tunnelHealth.consecutiveFailures + 1 }); try { await this.stopTunnel(); await this.startTunnel(this.lastTunnelOptions); this.emit('tunnel:recovery:success'); } catch (error) { (0, logger_1.debug)('Tunnel recovery failed:', error); this.emit('tunnel:recovery:failed', { error: error instanceof Error ? error.message : String(error) }); } } else { (0, logger_1.debug)('Max recovery attempts exceeded, stopping tunnel'); this.emit('tunnel:recovery:exhausted'); await this.stopTunnel(); } } return health; } catch (error) { (0, logger_1.debug)('Health check failed:', error); const healthStatus = { healthy: false, error: error.message }; this.updateTunnelHealth(healthStatus); return healthStatus; } } updateTunnelHealth(health) { this.tunnelHealth.lastCheck = new Date(); if (health.healthy) { this.tunnelHealth.status = 'healthy'; this.tunnelHealth.consecutiveFailures = 0; } else { this.tunnelHealth.consecutiveFailures++; if (this.tunnelHealth.consecutiveFailures >= 3) { this.tunnelHealth.status = 'unhealthy'; } else { this.tunnelHealth.status = 'degraded'; } } } startHealthMonitoring() { if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); } const interval = this.recoveryOptions.healthCheckInterval || 30000; this.healthCheckTimer = setInterval(() => { this.checkTunnelHealth().catch(error => { (0, logger_1.debug)('Health check interval error:', error); }); }, interval); (0, logger_1.debug)(`Started health monitoring with ${interval}ms interval`); } stopHealthMonitoring() { if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); this.healthCheckTimer = undefined; (0, logger_1.debug)('Stopped health monitoring'); } } getTunnelHealth() { return { ...this.tunnelHealth }; } getRecoveryOptions() { return { ...this.recoveryOptions }; } updateRecoveryOptions(options) { this.recoveryOptions = { ...this.recoveryOptions, ...options }; // Restart health monitoring if interval changed if (options.healthCheckInterval && this.healthCheckTimer) { this.stopHealthMonitoring(); this.startHealthMonitoring(); } } generateTunnelId() { return `tunnel-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } getAccessController() { return this.accessController; } getTunnelId() { return this.tunnelId; } /** * Track an access event for analytics */ trackAccess(event) { if (!this.usageTracker) { return; // Analytics not enabled } this.usageTracker.trackAccess(event); } /** * Get current usage metrics */ getUsageMetrics() { if (!this.usageTracker) { return null; } return this.usageTracker.getMetrics(); } /** * Export usage metrics as JSON */ exportUsageMetrics() { if (!this.usageTracker) { return null; } return this.usageTracker.exportMetrics(); } /** * Clear usage metrics */ clearUsageMetrics() { if (this.usageTracker) { this.usageTracker.clearMetrics(); } } /** * Check if analytics is enabled */ isAnalyticsEnabled() { return !!this.usageTracker; } } exports.TunnelManager = TunnelManager; //# sourceMappingURL=manager.js.map