Files
Home/service-manager.js

185 lines
5.3 KiB
JavaScript

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 ||
this.credentialManager.getCredential('player', 'TOKEN_SECRET');
if (!tokenSecret || tokenSecret.length < 32) {
tokenSecret = require('crypto').randomBytes(32).toString('hex');
this.credentialManager.setCredential('player', 'TOKEN_SECRET', tokenSecret);
}
const defaultPorts = {
player: 3000,
frontend: 4890
};
const env = {
...process.env,
...(process.platform === 'darwin'
? { PATH: [process.env.PATH, '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean).join(path.delimiter) }
: {}),
PORT: (this.envConfig[name] && this.envConfig[name].PORT) || defaultPorts[name] || process.env.PORT || 3000,
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}`);
});
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)
));
}
stopAll() {
this.isStopping = true;
for (const name of Object.keys(this.stableTimers)) {
if (this.stableTimers[name]) {
clearTimeout(this.stableTimers[name]);
}
}
this.stableTimers = {};
for (const name of Object.keys(this.processes)) {
const procInfo = this.processes[name];
if (procInfo && procInfo.child) {
try {
procInfo.child.kill('SIGTERM');
} catch (e) { }
}
}
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;