import { execFileSync, spawn } from 'node:child_process'; import { createWriteStream, existsSync, mkdirSync, readFileSync, } from 'node:fs'; import { dirname, isAbsolute, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = process.cwd(); const apiServerExePath = resolve( repoRoot, 'server-rs/target/debug/api-server.exe', ); const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local']; function buildProtectedEnvKeys(baseEnv) { return new Set( Object.entries(baseEnv) .filter(([, value]) => String(value ?? '').trim()) .map(([key]) => key), ); } const shellEnvKeys = buildProtectedEnvKeys(process.env); function loadEnvFile(path, target, protectedKeys = shellEnvKeys) { if (!existsSync(path)) { return; } const rawText = readFileSync(path, 'utf8'); for (const rawLine of rawText.split(/\r?\n/u)) { const line = rawLine.trim(); if (!line || line.startsWith('#')) { continue; } const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); if (!match) { continue; } const [, key, rawValue] = match; // 只保留启动命令行和外层 shell 已显式传入的环境变量优先级; // `.env.local` 与 `.env.secrets.local` 需要能覆盖 `.env`, // 否则本地短信登录或私密模型密钥会被默认空值压住。 if (protectedKeys.has(key)) { continue; } target[key] = rawValue.replace(/^['"]|['"]$/gu, ''); } } export function loadApiServerEnv( repoRootPath, target, protectedKeys = shellEnvKeys, ) { // 保持与 dev-web-rust.mjs / dev-rust-stack.sh 一致: // shell > .env > .env.local > .env.secrets.local。 for (const fileName of LOCAL_ENV_FILES) { loadEnvFile(resolve(repoRootPath, fileName), target, protectedKeys); } } export function mergeApiServerEnv(repoRootPath, baseEnv = process.env) { const mergedEnv = { ...baseEnv }; loadApiServerEnv(repoRootPath, mergedEnv, buildProtectedEnvKeys(baseEnv)); return mergedEnv; } export function formatApiServerLogTimestamp(date = new Date()) { const pad = (value) => String(value).padStart(2, '0'); return [ date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate()), '-', pad(date.getHours()), pad(date.getMinutes()), pad(date.getSeconds()), ].join(''); } export function resolveApiServerLogFile( repoRootPath, env = process.env, now = new Date(), ) { const explicitLogFile = String( env.GENARRATIVE_API_SERVER_LOG_FILE ?? '', ).trim(); if (explicitLogFile) { return isAbsolute(explicitLogFile) ? explicitLogFile : resolve(repoRootPath, explicitLogFile); } const logDir = String(env.GENARRATIVE_API_SERVER_LOG_DIR ?? '').trim() || 'logs/api-server'; const resolvedLogDir = isAbsolute(logDir) ? logDir : resolve(repoRootPath, logDir); return resolve( resolvedLogDir, `api-server-${formatApiServerLogTimestamp(now)}.log`, ); } function createApiServerLogStream(logFilePath) { mkdirSync(dirname(logFilePath), { recursive: true }); const logStream = createWriteStream(logFilePath, { flags: 'a', encoding: 'utf8', }); logStream.on('error', (error) => { console.error(`[api-server] 写入日志失败: ${error.message}`); }); return logStream; } function writeLauncherLog(logStream, message, stream = process.stdout) { const line = `${message}\n`; stream.write(line); if (!logStream.destroyed) { logStream.write(line); } } function stopExistingWindowsApiServer(logStream) { if (process.platform !== 'win32') { return; } // Windows 下 cargo 重新编译时无法覆盖仍在运行的 exe,只清理本仓库 target 内的旧进程。 const command = [ '$ErrorActionPreference = "Continue"', '$target = [System.IO.Path]::GetFullPath($env:GENARRATIVE_API_SERVER_EXE_TARGET)', '$processes = Get-Process -Name api-server -ErrorAction SilentlyContinue | Where-Object {', ' $_.Path -and ([System.IO.Path]::GetFullPath($_.Path) -ieq $target)', '}', 'foreach ($process in $processes) {', ' try {', ' Stop-Process -Id $process.Id -Force -ErrorAction Stop', ' Wait-Process -Id $process.Id -Timeout 5 -ErrorAction SilentlyContinue', ' Write-Output $process.Id', ' } catch {', ' Write-Error "[api-server] 忽略旧进程清理瞬时失败 pid=$($process.Id): $($_.Exception.Message)"', ' }', '}', 'exit 0', ].join('\n'); const output = execFileSync( 'powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], { encoding: 'utf8', env: { ...process.env, GENARRATIVE_API_SERVER_EXE_TARGET: apiServerExePath, }, }, ).trim(); if (output) { writeLauncherLog(logStream, `[api-server] 已停止旧 api-server 进程: ${output}`); } } function main() { const mergedEnv = mergeApiServerEnv(repoRoot); mergedEnv.GENARRATIVE_API_HOST = mergedEnv.GENARRATIVE_API_HOST || '127.0.0.1'; mergedEnv.GENARRATIVE_API_PORT = mergedEnv.GENARRATIVE_API_PORT || '3100'; mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL = mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || 'http://127.0.0.1:3101'; mergedEnv.GENARRATIVE_SPACETIME_DATABASE = mergedEnv.GENARRATIVE_SPACETIME_DATABASE || ''; mergedEnv.GENARRATIVE_SPACETIME_TOKEN = mergedEnv.GENARRATIVE_SPACETIME_TOKEN || ''; const logFilePath = resolveApiServerLogFile(repoRoot, mergedEnv); const logStream = createApiServerLogStream(logFilePath); mergedEnv.GENARRATIVE_API_SERVER_LOG_FILE = logFilePath; let didExit = false; const exitAfterLogFlush = (code) => { const finish = () => { if (didExit) { return; } didExit = true; process.exit(code); }; if (logStream.destroyed) { finish(); return; } logStream.end(finish); setTimeout(finish, 1000).unref(); }; writeLauncherLog(logStream, `[api-server] 日志: ${logFilePath}`); if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) { writeLauncherLog( logStream, '[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。', process.stderr, ); exitAfterLogFlush(1); return; } try { stopExistingWindowsApiServer(logStream); } catch (error) { writeLauncherLog( logStream, `[api-server] 清理旧 api-server 进程失败: ${error.message}`, process.stderr, ); exitAfterLogFlush(1); return; } writeLauncherLog( logStream, `[api-server] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`, ); const child = spawn( 'cargo', ['run', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'], { cwd: repoRoot, env: mergedEnv, stdio: ['inherit', 'pipe', 'pipe'], }, ); child.stdout?.on('data', (chunk) => { process.stdout.write(chunk); logStream.write(chunk); }); child.stderr?.on('data', (chunk) => { process.stderr.write(chunk); logStream.write(chunk); }); child.on('error', (error) => { writeLauncherLog( logStream, `[api-server] 启动 cargo 失败: ${error.message}`, process.stderr, ); exitAfterLogFlush(1); }); child.on('close', (code, signal) => { if (signal) { writeLauncherLog( logStream, `[api-server] api-server 被信号终止: ${signal}`, process.stderr, ); exitAfterLogFlush(1); return; } exitAfterLogFlush(code ?? 0); }); } if ( process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { main(); }