const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const http = require('http'); const { app } = require('electron'); interface ServiceProcess { process: ReturnType | null; name: string; port: number; ready: boolean; restartCount: number; lastRestartTime: number; } const NODE_PORT = 14890; const LOCAL_NODE_URL = `http://127.0.0.1:${NODE_PORT}`; const MAX_RESTARTS = 4; const RESTART_WINDOW_MS = 2 * 60 * 1000; const services: { node: ServiceProcess } = { node: { process: null, name: 'Node Server', port: NODE_PORT, ready: false, restartCount: 0, lastRestartTime: 0, }, }; let logFilePath = ''; function getLogPath(): string { if (!logFilePath) { try { logFilePath = path.join(app.getPath('userData'), 'local-services.log'); } catch { logFilePath = path.join(process.cwd(), 'local-services.log'); } } return logFilePath; } function log(level: string, ...args: unknown[]): void { const line = `[${new Date().toISOString()}] [${level}] ${args.map((item) => typeof item === 'string' ? item : JSON.stringify(item)).join(' ')}`; if (level === 'ERROR') { console.error(line); } else { console.log(line); } try { fs.appendFileSync(getLogPath(), line + '\n', 'utf8'); } catch { // ignore logging failures } } function isPackaged(): boolean { return app.isPackaged; } function getServerDir(): string { if (isPackaged()) { return path.join((process as any).resourcesPath, 'server'); } return path.resolve(__dirname, '..', '..', 'server'); } function getPlaywrightBrowsersPath(): string | null { if (!isPackaged()) return null; const bundled = path.join((process as any).resourcesPath, 'playwright'); return fs.existsSync(bundled) ? bundled : null; } function findNodeRunner(): { cmd: string; args: string[]; useElectronAsNode?: boolean } { const serverDir = getServerDir(); const distEntry = path.join(serverDir, 'dist', 'app.js'); const srcEntry = path.join(serverDir, 'src', 'app.ts'); const isWin = process.platform === 'win32'; if (isPackaged()) { return { cmd: process.execPath, args: [distEntry], useElectronAsNode: true }; } if (fs.existsSync(distEntry)) { return { cmd: 'node', args: [distEntry] }; } const tsxBin = path.join(serverDir, 'node_modules', '.bin', isWin ? 'tsx.cmd' : 'tsx'); if (fs.existsSync(tsxBin)) { return { cmd: tsxBin, args: [srcEntry] }; } return { cmd: isWin ? 'npx.cmd' : 'npx', args: ['tsx', srcEntry] }; } function checkHealth(port: number): Promise { return new Promise((resolve) => { const req = http.get(`http://127.0.0.1:${port}/api/health`, { timeout: 2500 }, (res: any) => { res.resume(); resolve(res.statusCode >= 200 && res.statusCode < 500); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); } async function waitForService(port: number, timeoutMs = 45000): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { if (await checkHealth(port)) { return true; } await new Promise((resolve) => setTimeout(resolve, 1000)); } return false; } function shouldRestart(service: ServiceProcess): boolean { const now = Date.now(); if (now - service.lastRestartTime > RESTART_WINDOW_MS) { service.restartCount = 0; } service.lastRestartTime = now; service.restartCount += 1; return service.restartCount <= MAX_RESTARTS; } function startNodeServer(): void { const service = services.node; const serverDir = getServerDir(); const { cmd, args, useElectronAsNode } = findNodeRunner(); const env: Record = { ...(process.env as Record), PORT: String(NODE_PORT), HOST: '127.0.0.1', USE_REDIS_QUEUE: 'false', LOCAL_NODE_ONLY_AUTOMATION: 'true', NODE_OPTIONS: '--max-old-space-size=512', }; if (useElectronAsNode) { env.ELECTRON_RUN_AS_NODE = '1'; } if (isPackaged()) { const uploadsDir = path.join(app.getPath('userData'), 'uploads'); fs.mkdirSync(uploadsDir, { recursive: true }); env.UPLOAD_PATH = uploadsDir; } const playwrightPath = getPlaywrightBrowsersPath(); if (playwrightPath) { env.PLAYWRIGHT_BROWSERS_PATH = playwrightPath; } log('INFO', 'Starting node service', cmd, args.join(' ')); const child = spawn(cmd, args, { cwd: serverDir, env, stdio: ['ignore', 'pipe', 'pipe'], shell: false, windowsHide: true, }); service.process = child; service.ready = false; child.stdout?.on('data', (chunk: Buffer) => { const message = chunk.toString().trim(); if (!message) return; log('NODE', message); if (message.includes('Server running') || message.includes('listening')) { service.ready = true; } }); child.stderr?.on('data', (chunk: Buffer) => { const message = chunk.toString().trim(); if (message) log('NODE-ERR', message); }); child.on('error', (error: Error) => { log('ERROR', 'Node spawn error', error.message); }); child.on('exit', (code: number | null, signal: string | null) => { log('WARN', `Node service exited code=${code} signal=${signal}`); service.process = null; service.ready = false; if (signal === 'SIGTERM' || signal === 'SIGINT') { return; } if (!shouldRestart(service)) { log('ERROR', 'Node service restart budget exhausted'); return; } const backoffMs = Math.min(service.restartCount * 4000, 15000); log('INFO', `Restarting node service in ${backoffMs}ms`); setTimeout(() => startNodeServer(), backoffMs); }); } async function startLocalServices(): Promise<{ nodeOk: boolean }> { if (!services.node.process) { startNodeServer(); } const nodeOk = await waitForService(NODE_PORT); services.node.ready = nodeOk; return { nodeOk }; } function stopLocalServices(): void { const service = services.node; service.ready = false; if (!service.process) return; try { service.process.kill('SIGTERM'); } catch (error) { log('ERROR', 'Failed to stop node service', error); } service.process = null; } function getServiceStatus() { return { node: { ready: services.node.ready, port: services.node.port, restartCount: services.node.restartCount, }, }; } export { NODE_PORT, LOCAL_NODE_URL, startLocalServices, stopLocalServices, getServiceStatus, getLogPath, };