import { spawn, type ChildProcess } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { access } from 'node:fs/promises'; import net from 'node:net'; import path from 'node:path'; import process from 'node:process'; interface ManagedProcess { readonly name: string; readonly child: ChildProcess; } const ROOT_DIR = process.cwd(); const AI_ENGINE_DIR = path.join(ROOT_DIR, 'ai-engine'); loadEnvFile(path.join(ROOT_DIR, '.env')); const DEFAULT_AI_URL = process.env.AI_ENGINE_URL ?? 'http://127.0.0.1:8000'; const DEFAULT_API_PORT = Number(process.env.PORT ?? '3005'); const AI_ENGINE_PORT = resolveAiPort(DEFAULT_AI_URL); const AI_START_TIMEOUT_MS = 120_000; const NEST_START_TIMEOUT_MS = 90_000; const HEALTH_POLL_INTERVAL_MS = 1_000; let nestProcess: ManagedProcess | null = null; let aiProcess: ManagedProcess | null = null; let shuttingDown = false; async function main(): Promise { ensureWindowsOrUnixShellAwareness(); const aiHealthUrl = `${DEFAULT_AI_URL}/health`; const aiHost = resolveHost(DEFAULT_AI_URL); const aiAlreadyHealthy = await isHealthy(aiHealthUrl); if (aiAlreadyHealthy) { log(`AI engine already running at ${aiHealthUrl}`); } else { const aiPortBusy = await isPortInUse(aiHost, AI_ENGINE_PORT); if (aiPortBusy) { throw new Error( `AI engine port ${AI_ENGINE_PORT} is already in use but ${aiHealthUrl} is not healthy`, ); } const pythonCommand = await resolvePythonCommand(); aiProcess = { name: 'ai-engine', child: spawn( pythonCommand.command, [ ...pythonCommand.args, '-m', 'uvicorn', 'main:app', '--host', '0.0.0.0', '--port', String(AI_ENGINE_PORT), ...resolveAiExtraArgs(), ], { cwd: AI_ENGINE_DIR, stdio: 'inherit', env: { ...process.env, PYTHONUNBUFFERED: '1', PORT: String(AI_ENGINE_PORT), }, }, ), }; attachExitHandlers(aiProcess); log(`Waiting for AI engine health at ${aiHealthUrl}`); await waitForHealth(aiHealthUrl, AI_START_TIMEOUT_MS); log('AI engine is ready'); } const nestHealthUrl = `http://127.0.0.1:${DEFAULT_API_PORT}/api/health/live`; const nestAlreadyHealthy = await isHealthy(nestHealthUrl); if (nestAlreadyHealthy) { log(`NestJS already running at ${nestHealthUrl}`); } else { const nestPortBusy = await isPortInUse('127.0.0.1', DEFAULT_API_PORT); if (nestPortBusy) { throw new Error( `NestJS port ${DEFAULT_API_PORT} is already in use but ${nestHealthUrl} is not healthy`, ); } nestProcess = { name: 'nest', child: spawnNestProcess(), }; attachExitHandlers(nestProcess); log(`Waiting for NestJS health at ${nestHealthUrl}`); await waitForHealth(nestHealthUrl, NEST_START_TIMEOUT_MS); log('NestJS is ready'); } log('Full stack is running'); } function ensureWindowsOrUnixShellAwareness(): void { process.on('SIGINT', () => { void shutdown('SIGINT'); }); process.on('SIGTERM', () => { void shutdown('SIGTERM'); }); process.on('uncaughtException', (error: Error) => { console.error('[full:run] Uncaught exception:', error); void shutdown('uncaughtException', 1); }); process.on('unhandledRejection', (reason: unknown) => { console.error('[full:run] Unhandled rejection:', reason); void shutdown('unhandledRejection', 1); }); } function resolveNestStartScript(): string { return process.env.FULL_RUN_NEST_SCRIPT ?? 'start:dev'; } function resolveAiExtraArgs(): string[] { return process.env.FULL_RUN_AI_RELOAD === 'true' ? ['--reload'] : []; } function spawnNestProcess(): ChildProcess { const nestScript = resolveNestStartScript(); if (process.platform === 'win32') { return spawn('cmd.exe', ['/d', '/s', '/c', `npm run ${nestScript}`], { cwd: ROOT_DIR, stdio: 'inherit', env: process.env, }); } return spawn('npm', ['run', nestScript], { cwd: ROOT_DIR, stdio: 'inherit', env: process.env, }); } async function resolvePythonCommand(): Promise<{ command: string; args: string[]; }> { const explicitPython = process.env.AI_ENGINE_PYTHON?.trim(); if (explicitPython) { return { command: explicitPython, args: [] }; } const localVenvPython = process.platform === 'win32' ? path.join(AI_ENGINE_DIR, 'venv', 'Scripts', 'python.exe') : path.join(AI_ENGINE_DIR, 'venv', 'bin', 'python'); if (await pathExists(localVenvPython)) { return { command: localVenvPython, args: [] }; } return { command: process.platform === 'win32' ? 'python' : 'python3', args: [] }; } function resolveAiPort(aiUrl: string): number { const parsedUrl = new URL(aiUrl); if (parsedUrl.port) { return Number(parsedUrl.port); } return parsedUrl.protocol === 'https:' ? 443 : 80; } async function pathExists(targetPath: string): Promise { try { await access(targetPath); return true; } catch { return false; } } function resolveHost(url: string): string { const parsedUrl = new URL(url); return parsedUrl.hostname; } function attachExitHandlers(managedProcess: ManagedProcess): void { managedProcess.child.on('exit', (code: number | null, signal: NodeJS.Signals | null) => { if (shuttingDown) { return; } const detail = signal !== null ? `signal=${signal}` : `code=${code ?? 'unknown'}`; console.error(`[full:run] ${managedProcess.name} exited unexpectedly (${detail})`); void shutdown(`${managedProcess.name}-exit`, code ?? 1); }); managedProcess.child.on('error', (error: Error) => { if (shuttingDown) { return; } console.error(`[full:run] Failed to start ${managedProcess.name}:`, error); void shutdown(`${managedProcess.name}-error`, 1); }); } async function waitForHealth(url: string, timeoutMs: number): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { try { const response = await fetch(url); if (response.ok) { return; } } catch { // Service is still booting. } await delay(HEALTH_POLL_INTERVAL_MS); } throw new Error(`Health check timed out: ${url}`); } async function isHealthy(url: string): Promise { try { const response = await fetch(url); return response.ok; } catch { return false; } } async function isPortInUse(host: string, port: number): Promise { return new Promise((resolve) => { const socket = net.createConnection({ host, port }); socket.once('connect', () => { socket.destroy(); resolve(true); }); socket.once('error', () => { resolve(false); }); }); } function delay(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } async function shutdown(reason: string, exitCode = 0): Promise { if (shuttingDown) { return; } shuttingDown = true; log(`Shutting down stack (${reason})`); await stopProcess(nestProcess); await stopProcess(aiProcess); process.exit(exitCode); } async function stopProcess(managedProcess: ManagedProcess | null): Promise { if (!managedProcess) { return; } const { child, name } = managedProcess; if (child.killed || child.exitCode !== null) { return; } child.kill('SIGTERM'); const stopped = await waitForProcessExit(child, 10_000); if (!stopped) { console.warn(`[full:run] ${name} did not stop gracefully, forcing termination`); child.kill('SIGKILL'); await waitForProcessExit(child, 5_000); } } function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promise { return new Promise((resolve) => { const timeout = setTimeout(() => { cleanup(); resolve(false); }, timeoutMs); const onExit = () => { cleanup(); resolve(true); }; const cleanup = () => { clearTimeout(timeout); child.off('exit', onExit); }; child.once('exit', onExit); }); } function log(message: string): void { console.log(`[full:run] ${message}`); } function loadEnvFile(envPath: string): void { try { const content = readFileSync(envPath, 'utf8'); const lines = content.split(/\r?\n/u); lines.forEach((line) => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) { return; } const separatorIndex = trimmed.indexOf('='); if (separatorIndex === -1) { return; } const key = trimmed.slice(0, separatorIndex).trim(); const rawValue = trimmed.slice(separatorIndex + 1).trim(); const normalizedValue = rawValue.replace(/^['"]|['"]$/gu, ''); if (!process.env[key]) { process.env[key] = normalizedValue; } }); } catch { // .env is optional for this script. } } void main().catch((error: Error) => { console.error('[full:run] Startup failed:', error); void shutdown('startup-failed', 1); });