const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const CredentialManager = require('./credential-manager'); class ServiceManager { constructor(appDataPath, envConfig) { this.appDataPath = appDataPath; this.envConfig = envConfig; this.credentialManager = new CredentialManager(appDataPath); this.processes = {}; this.restartCounts = {}; this.stableTimers = {}; this.isStopping = false; this.logsDir = path.join(appDataPath, 'logs'); fs.mkdirSync(this.logsDir, { recursive: true }); } logToFile(name, line) { try { const logPath = path.join(this.logsDir, `${name}.log`); const timestamp = new Date().toISOString(); fs.appendFile(logPath, `[${timestamp}] ${line}\n`, 'utf8', () => { }); process.stdout.write(JSON.stringify({ event: "service-log", data: { service: name, message: line } }) + '\n'); } catch (e) { } } async startService(name, relativeDir, startScript = 'server.js') { if (this.processes[name]) { return; } const serviceDir = path.join(this.appDataPath, relativeDir); const scriptPath = path.join(serviceDir, startScript); if (!fs.existsSync(scriptPath)) { throw new Error(`Service script not found: ${scriptPath}`); } let tokenSecret = process.env.TOKEN_SECRET || process.env.STREAM_API_TOKEN_SECRET || this.credentialManager.getCredential('stream-api', 'TOKEN_SECRET'); if (!tokenSecret || tokenSecret.length < 32) { tokenSecret = require('crypto').randomBytes(32).toString('hex'); this.credentialManager.setCredential('stream-api', 'TOKEN_SECRET', tokenSecret); } const env = { ...process.env, TOKEN_SECRET: tokenSecret, STREAM_API_TOKEN_SECRET: tokenSecret, ...(this.envConfig[name] || {}), ...(this.credentialManager.getServiceCredentials(name) || {}) }; this.logToFile(name, `--- Starting ${name} in ${serviceDir} ---`); const nodeExecutable = process.env.NODE_EXE || 'node'; const child = spawn(nodeExecutable, [scriptPath], { cwd: serviceDir, env, stdio: ['ignore', 'pipe', 'pipe'], shell: false }); this.processes[name] = { child, relativeDir, startScript, status: 'starting' }; if (this.stableTimers[name]) { clearTimeout(this.stableTimers[name]); } this.stableTimers[name] = setTimeout(() => { this.restartCounts[name] = 0; }, 60000); child.stdout.on('data', (data) => { const text = data.toString().trim(); this.logToFile(name, `STDOUT: ${text}`); }); child.stderr.on('data', (data) => { const text = data.toString().trim(); this.logToFile(name, `STDERR: ${text}`); if (text.includes('EADDRINUSE')) { const match = text.match(/:(\d+)/); const port = match ? match[1] : null; if (port) { try { if (process.platform === 'win32') { require('child_process').spawnSync('powershell.exe', [ '-NoProfile', '-NonInteractive', '-Command', `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }` ], { stdio: 'ignore', windowsHide: true }); } else { require('child_process').spawnSync('fuser', ['-k', '-9', `${port}/tcp`], { stdio: 'ignore' }); } } catch (_) { } } } }); child.on('exit', (code, signal) => { this.logToFile(name, `EXITED: code=${code} signal=${signal}`); this.processes[name] = null; if (this.stableTimers[name]) { clearTimeout(this.stableTimers[name]); this.stableTimers[name] = null; } if (!this.isStopping) { this.restartCounts[name] = (this.restartCounts[name] || 0) + 1; if (this.restartCounts[name] > 5) { this.logToFile(name, `Crashed too many times, giving up.`); return; } setTimeout(() => { this.startService(name, relativeDir, startScript).catch(err => { this.logToFile(name, `Failed to restart: ${err.message}`); }); }, 1500); } }); } async startServices(services) { await Promise.all(services.map(({ name, relativeDir, startScript }) => this.startService(name, relativeDir, startScript) )); } async stopAll() { this.isStopping = true; for (const name of Object.keys(this.stableTimers)) { if (this.stableTimers[name]) { clearTimeout(this.stableTimers[name]); } } this.stableTimers = {}; const killPromises = Object.keys(this.processes).map((name) => { const procInfo = this.processes[name]; if (procInfo && procInfo.child) { this.logToFile(name, `Killing service`); return new Promise((resolve) => { procInfo.child.removeAllListeners('exit'); procInfo.child.on('exit', () => resolve()); procInfo.child.kill(); }); } return Promise.resolve(); }); await Promise.all(killPromises); this.processes = {}; } async waitForPorts(ports, timeoutMs = 30000) { await Promise.all(ports.map((port) => this.waitForPort(port, timeoutMs))); } async waitForPort(port, timeoutMs = 30000) { const start = Date.now(); const net = require('net'); while (Date.now() - start < timeoutMs) { if (this.isStopping) return false; const connected = await new Promise((resolve) => { const socket = new net.Socket(); socket.setTimeout(1000); socket.on('connect', () => { socket.destroy(); resolve(true); }); socket.on('error', () => { resolve(false); }); socket.on('timeout', () => { socket.destroy(); resolve(false); }); socket.connect(port, '127.0.0.1'); }); if (connected) return true; await new Promise(r => setTimeout(r, 40)); } throw new Error(`Timeout waiting for port ${port}`); } } module.exports = ServiceManager;