UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

216 lines (215 loc) 8.09 kB
import fs from 'fs/promises'; import path from 'path'; import { existsSync } from 'fs'; import { CONFIG_FILE } from '../config.js'; import { logger } from './logger.js'; class FeatureFlagManager { constructor() { this.flags = {}; this.lastFetch = 0; this.cacheMaxAge = 30 * 60 * 1000; this.refreshInterval = null; // Track fresh fetch status for A/B tests that need network flags this.freshFetchPromise = null; this.resolveFreshFetch = null; this.loadedFromCache = false; const configDir = path.dirname(CONFIG_FILE); this.cachePath = path.join(configDir, 'feature-flags.json'); // Use production flags (v2 supports weighted variants) this.flagUrl = process.env.DC_FLAG_URL || 'https://desktopcommander.app/flags/v2/production.json'; // Set up promise for waiting on fresh fetch this.freshFetchPromise = new Promise((resolve) => { this.resolveFreshFetch = resolve; }); } /** * Initialize - load from cache and start background refresh */ async initialize() { try { // Load from cache immediately (non-blocking) await this.loadFromCache(); // Fetch in background (don't block startup) this.fetchFlags().then(() => { // Signal that fresh flags are now available if (this.resolveFreshFetch) { this.resolveFreshFetch(); } }).catch(err => { logger.debug('Initial flag fetch failed:', err.message); // Still resolve the promise so waiters don't hang forever if (this.resolveFreshFetch) { this.resolveFreshFetch(); } }); // Start periodic refresh every 5 minutes this.refreshInterval = setInterval(() => { this.fetchFlags().catch(err => { logger.debug('Periodic flag fetch failed:', err.message); }); }, this.cacheMaxAge); // Allow process to exit even if interval is pending // This is critical for proper cleanup when MCP client disconnects this.refreshInterval.unref(); logger.info(`Feature flags initialized (refresh every ${this.cacheMaxAge / 1000}s)`); } catch (error) { logger.warning('Failed to initialize feature flags:', error); } } /** * Get a flag value */ get(flagName, defaultValue = false) { return this.flags[flagName] !== undefined ? this.flags[flagName] : defaultValue; } /** * Get all flags for debugging */ getAll() { return { ...this.flags }; } /** * Manually refresh flags immediately (for testing) */ async refresh() { try { await this.fetchFlags(); return true; } catch (error) { logger.error('Manual refresh failed:', error); return false; } } /** * Check if flags were loaded from cache (vs fresh fetch) */ wasLoadedFromCache() { return this.loadedFromCache; } /** * Wait for fresh flags to be fetched from network. * Use this when you need to ensure flags are loaded before making decisions * (e.g., A/B test assignments for new users who don't have a cache yet) * * Has a hard timeout to prevent blocking MCP startup if the fetch hangs. * See: https://github.com/wonderwhy-er/DesktopCommanderMCP/issues/465 */ async waitForFreshFlags() { if (this.freshFetchPromise) { let safetyTimeoutHandle; try { const safetyTimeout = new Promise((resolve) => { safetyTimeoutHandle = setTimeout(resolve, 5000); }); await Promise.race([this.freshFetchPromise, safetyTimeout]); } finally { if (safetyTimeoutHandle) { clearTimeout(safetyTimeoutHandle); } } } } /** * Load flags from local cache */ async loadFromCache() { try { if (!existsSync(this.cachePath)) { logger.debug('No feature flag cache found'); this.loadedFromCache = false; return; } const data = await fs.readFile(this.cachePath, 'utf8'); const config = JSON.parse(data); if (config.flags) { this.flags = config.flags; this.lastFetch = Date.now(); this.loadedFromCache = true; logger.debug(`Loaded ${Object.keys(this.flags).length} feature flags from cache`); } } catch (error) { logger.warning('Failed to load feature flags from cache:', error); this.loadedFromCache = false; } } /** * Fetch flags from remote URL */ async fetchFlags() { const FETCH_TIMEOUT_MS = 3000; const controller = new AbortController(); const abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); let hardTimeoutHandle; try { // Don't log here - runs async and can interfere with MCP clients // Use Promise.race as a hard timeout safety net. // On some platforms (Windows + Node 24 / undici 7.x), AbortController.abort() // fails to interrupt an in-progress TCP connect — the fetch hangs until the // OS-level TCP timeout (~30s on Windows). Promise.race guarantees we reject // at the JS level regardless of AbortController behavior. // See: https://github.com/wonderwhy-er/DesktopCommanderMCP/issues/465 const fetchPromise = fetch(this.flagUrl, { signal: controller.signal, headers: { 'Cache-Control': 'no-cache', } }); const hardTimeout = new Promise((_, reject) => hardTimeoutHandle = setTimeout(() => reject(new Error('Feature flags fetch timed out')), FETCH_TIMEOUT_MS)); const response = await Promise.race([fetchPromise, hardTimeout]); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const config = await response.json(); // Update flags if (config.flags) { this.flags = config.flags; this.lastFetch = Date.now(); // Save to cache (silently - don't log during async operations // as it can interfere with MCP clients that close quickly) await this.saveToCache(config); } } catch (error) { logger.debug('Failed to fetch feature flags:', error.message); // Continue with cached values } finally { clearTimeout(abortTimeout); if (hardTimeoutHandle) { clearTimeout(hardTimeoutHandle); } } } /** * Save flags to local cache */ async saveToCache(config) { try { const configDir = path.dirname(this.cachePath); if (!existsSync(configDir)) { await fs.mkdir(configDir, { recursive: true }); } await fs.writeFile(this.cachePath, JSON.stringify(config, null, 2), 'utf8'); // Don't log here - this runs async and can cause issues with MCP clients } catch (error) { logger.warning('Failed to save feature flags to cache:', error); } } /** * Cleanup on shutdown */ destroy() { if (this.refreshInterval) { clearInterval(this.refreshInterval); this.refreshInterval = null; } } } // Export singleton instance export const featureFlagManager = new FeatureFlagManager();