293 lines
7.5 KiB
JavaScript
293 lines
7.5 KiB
JavaScript
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();
|
||
}
|