const { spawn, execSync } = require('child_process'); const net = require('net'); const EventEmitter = require('events'); const processes = new Map(); const events = new EventEmitter(); function key(slug, type) { return `${slug}:${type}`; } function createEntry() { return { child: null, status: 'stopped', logs: [], port: null }; } function getStatus(slug) { const api = processes.get(key(slug, 'api')); const frontend = processes.get(key(slug, 'frontend')); return { api: api ? { status: api.status, pid: api.child?.pid || null, logs: api.logs } : { status: 'stopped', pid: null, logs: [] }, frontend: frontend ? { status: frontend.status, pid: frontend.child?.pid || null, logs: frontend.logs } : null, }; } function combinedStatus(slug, hasFrontend) { const s = getStatus(slug); if (!hasFrontend) return s.api.status; // Both must be running for "running"; if either is error, "error"; else worst state const apiSt = s.api.status; const feSt = s.frontend?.status || 'stopped'; if (apiSt === 'running' && feSt === 'running') return 'running'; if (apiSt === 'error' || feSt === 'error') return 'error'; if (apiSt === 'starting' || feSt === 'starting') return 'starting'; if (apiSt === 'stopping' || feSt === 'stopping') return 'stopping'; return 'stopped'; } function setStatus(entry, slug, subtype, status) { if (entry.status === status) return; entry.status = status; events.emit('status', slug, subtype, status); } function pushLog(entry, line) { entry.logs.push(line); if (entry.logs.length > 50) entry.logs.shift(); } function spawnProcess(slug, subtype, cmd, args, cwd) { const k = key(slug, subtype); const existing = processes.get(k); if (existing && (existing.status === 'running' || existing.status === 'starting')) { return existing; } const entry = createEntry(); processes.set(k, entry); const label = `[${slug}:${subtype}]`; console.log(`${label} Spawning: ${cmd} ${args.join(' ')} (cwd: ${cwd})`); const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true, }); entry.child = child; entry.status = 'starting'; events.emit('status', slug, subtype, 'starting'); child.stdout.on('data', (data) => { for (const line of data.toString().split('\n').filter(Boolean)) { pushLog(entry, `[out] ${line}`); } }); child.stderr.on('data', (data) => { for (const line of data.toString().split('\n').filter(Boolean)) { pushLog(entry, `[err] ${line}`); console.log(`${label} [err] ${line}`); } }); child.on('error', (err) => { console.error(`${label} Spawn error: ${err.message}`); setStatus(entry, slug, subtype, 'error'); pushLog(entry, `[sys] Error: ${err.message}`); }); child.on('exit', (code, signal) => { console.log(`${label} Exited code=${code} signal=${signal}`); setStatus(entry, slug, subtype, 'stopped'); pushLog(entry, `[sys] Exited code=${code} signal=${signal}`); entry.child = null; }); return entry; } function start(slug, projectPath, port, frontend) { // Start API const apiEntry = spawnProcess(slug, 'api', 'php', ['artisan', 'serve', `--port=${port}`, '--host=0.0.0.0'], projectPath ); apiEntry.port = port; healthCheck(slug, 'api', port, apiEntry); let feResult = null; // Start frontend if present if (frontend) { const isExisting = processes.get(key(slug, 'frontend')); if (isExisting && (isExisting.status === 'running' || isExisting.status === 'starting')) { feResult = { status: isExisting.status, message: 'Already running' }; } else { let cmd, args; if (frontend.packageManager === 'pnpm') { cmd = 'pnpm'; args = ['dev', '--port', String(frontend.port), '--host', '0.0.0.0']; } else { cmd = 'npm'; args = ['run', 'dev', '--', '--port', String(frontend.port), '--host', '0.0.0.0']; } const feEntry = spawnProcess(slug, 'frontend', cmd, args, frontend.fullPath); feEntry.port = frontend.port; healthCheck(slug, 'frontend', frontend.port, feEntry); feResult = { status: 'starting', pid: feEntry.child?.pid }; } } return { api: { status: apiEntry.status, pid: apiEntry.child?.pid }, frontend: feResult, }; } function healthCheck(slug, subtype, port, entry, attempt = 0) { const maxAttempts = subtype === 'frontend' ? 60 : 15; const interval = subtype === 'frontend' ? 3000 : 1000; if (attempt > maxAttempts) { if (entry.status === 'starting') setStatus(entry, slug, subtype, 'error'); pushLog(entry, '[sys] Health check timed out'); return; } setTimeout(() => { if (entry.status !== 'starting') return; // Use TCP connect check instead of HTTP — avoids socket pool exhaustion const sock = net.connect(port, '127.0.0.1'); sock.setTimeout(2000); sock.on('connect', () => { sock.destroy(); setStatus(entry, slug, subtype, 'running'); pushLog(entry, `[sys] Port ${port} responding`); }); sock.on('error', () => { sock.destroy(); healthCheck(slug, subtype, port, entry, attempt + 1); }); sock.on('timeout', () => { sock.destroy(); healthCheck(slug, subtype, port, entry, attempt + 1); }); }, interval); } function stopEntry(k, slug, subtype) { const entry = processes.get(k); if (!entry || !entry.child) { processes.delete(k); return Promise.resolve({ status: 'stopped' }); } return new Promise((resolve) => { const child = entry.child; setStatus(entry, slug, subtype, 'stopping'); child.on('exit', () => { setStatus(entry, slug, subtype, 'stopped'); entry.child = null; resolve({ status: 'stopped' }); }); try { process.kill(-child.pid, 'SIGTERM'); } catch {} setTimeout(() => { if (entry.child) { try { process.kill(-child.pid, 'SIGKILL'); } catch {} } }, 3000); }); } function stop(slug) { return Promise.all([ stopEntry(key(slug, 'api'), slug, 'api'), stopEntry(key(slug, 'frontend'), slug, 'frontend'), ]); } function stopAll() { const slugsSeen = new Set(); for (const [k] of processes) { const slug = k.split(':')[0]; slugsSeen.add(slug); } return Promise.all([...slugsSeen].map(s => stop(s))); } function getAllStatuses() { const result = {}; for (const [k] of processes) { const slug = k.split(':')[0]; if (!result[slug]) result[slug] = getStatus(slug); } return result; } function killAllSync() { for (const [, entry] of processes) { if (entry.child) { try { process.kill(-entry.child.pid, 'SIGTERM'); } catch {} } } // Brief wait then SIGKILL any survivors try { execSync('sleep 1'); } catch {} for (const [, entry] of processes) { if (entry.child) { try { process.kill(-entry.child.pid, 'SIGKILL'); } catch {} } } processes.clear(); } module.exports = { start, stop, stopAll, getStatus, combinedStatus, getAllStatuses, events, killAllSync };