@wonderwhy-er/desktop-commander
Version:
MCP server for terminal operations and file editing
216 lines (215 loc) • 8.09 kB
JavaScript
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();