242 lines
6.9 KiB
JavaScript
242 lines
6.9 KiB
JavaScript
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 };
|