This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortInUse(host: string, port: number): Promise<boolean> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function shutdown(reason: string, exitCode = 0): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user