Refactor local dev stack scheduler
This commit is contained in:
@@ -1,292 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mergeApiServerEnv } from './api-server-dev.mjs';
|
||||
import {mergeApiServerEnv} from './dev-utils.mjs';
|
||||
|
||||
const REQUIRED_FOR_PUZZLE_GENERATION = [
|
||||
'VECTOR_ENGINE_BASE_URL',
|
||||
@@ -48,4 +48,4 @@ if (missing.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[api-server-env] 配置齐全。重启 npm run api-server 或 npm run dev 后生效。');
|
||||
console.log('[api-server-env] 配置齐全。重启 npm run dev:api-server 或 npm run dev 后生效。');
|
||||
|
||||
@@ -174,7 +174,7 @@ function buildReport({
|
||||
'',
|
||||
'本次实测:',
|
||||
'',
|
||||
'- `npm run api-server` 可启动 Rust `api-server`。',
|
||||
'- `npm run dev:api-server` 可启动 Rust `api-server`。',
|
||||
'- `GET http://127.0.0.1:3100/healthz` 返回 `200`,响应为 `{"ok":true,"service":"genarrative-api-server"}`。',
|
||||
'- `GET /api/runtime/visual-novel/gallery` 在当前本地环境返回超时 / `502`,日志显示 `api-server` 连接 `127.0.0.1:3101` SpacetimeDB 数据库 `xushi-p4wfr` 被拒绝;该项按本地 SpacetimeDB 未完整就绪记录为环境阻塞,不新增工程实现。',
|
||||
'',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,11 @@
|
||||
当前正式开发入口统一为:
|
||||
|
||||
- `npm run dev`
|
||||
- `scripts/dev-rust-stack.sh`
|
||||
- `npm run dev:spacetime`
|
||||
- `npm run dev:api-server`
|
||||
- `npm run dev:web`
|
||||
- `npm run dev:admin-web`
|
||||
- `scripts/dev.mjs`
|
||||
- `server-rs/crates/api-server/**`
|
||||
- `server-rs/crates/spacetime-module/**`
|
||||
|
||||
|
||||
108
scripts/dev-utils.mjs
Normal file
108
scripts/dev-utils.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import {existsSync, mkdirSync, readFileSync} from 'node:fs';
|
||||
import {dirname, isAbsolute, resolve} from 'node:path';
|
||||
|
||||
export const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local'];
|
||||
|
||||
export function buildProtectedEnvKeys(baseEnv) {
|
||||
return new Set(
|
||||
Object.entries(baseEnv)
|
||||
.filter(([, value]) => String(value ?? '').trim())
|
||||
.map(([key]) => key),
|
||||
);
|
||||
}
|
||||
|
||||
export function loadEnvFile(path, target, protectedKeys) {
|
||||
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;
|
||||
if (protectedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
target[key] = rawValue.replace(/^['"]|['"]$/gu, '');
|
||||
}
|
||||
}
|
||||
|
||||
export function loadApiServerEnv(repoRootPath, target, protectedKeys) {
|
||||
const resolvedProtectedKeys =
|
||||
protectedKeys ?? buildProtectedEnvKeys(process.env);
|
||||
|
||||
// shell > .env > .env.local > .env.secrets.local
|
||||
for (const fileName of LOCAL_ENV_FILES) {
|
||||
loadEnvFile(resolve(repoRootPath, fileName), target, resolvedProtectedKeys);
|
||||
}
|
||||
}
|
||||
|
||||
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`,
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureParentDir(filePath) {
|
||||
mkdirSync(dirname(filePath), {recursive: true});
|
||||
}
|
||||
|
||||
export function resolveClientHost(hostName) {
|
||||
if (hostName === '0.0.0.0' || hostName === '::') {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
|
||||
return hostName;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
formatApiServerLogTimestamp,
|
||||
mergeApiServerEnv,
|
||||
resolveApiServerLogFile,
|
||||
} from './api-server-dev.mjs';
|
||||
} from './dev-utils.mjs';
|
||||
|
||||
type EnvMap = Record<string, string>;
|
||||
|
||||
@@ -29,7 +29,7 @@ function withTempEnvFiles(
|
||||
}
|
||||
}
|
||||
|
||||
describe('api-server-dev env merge', () => {
|
||||
describe('dev utils env merge', () => {
|
||||
test('.env.local 和 .env.secrets.local 可以覆盖 .env 默认值', () => {
|
||||
withTempEnvFiles(
|
||||
{
|
||||
@@ -97,7 +97,7 @@ describe('api-server-dev env merge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('api-server-dev log file resolution', () => {
|
||||
describe('dev utils log file resolution', () => {
|
||||
const fixedDate = new Date(2026, 4, 15, 6, 7, 8);
|
||||
|
||||
test('默认写入 logs/api-server 的时间戳文件', () => {
|
||||
@@ -1,172 +0,0 @@
|
||||
import {spawn} from 'node:child_process';
|
||||
import {existsSync, readFileSync} from 'node:fs';
|
||||
import {resolve} from 'node:path';
|
||||
|
||||
import {
|
||||
findAvailablePort,
|
||||
formatPortDecision,
|
||||
normalizePort,
|
||||
} from './dev-stack-port-utils.mjs';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const shellEnvKeys = new Set(
|
||||
Object.entries(process.env)
|
||||
.filter(([, value]) => String(value ?? '').trim())
|
||||
.map(([key]) => key),
|
||||
);
|
||||
|
||||
function loadEnvFile(path, target) {
|
||||
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;
|
||||
// 中文注释:命令行显式传入的目标优先,`.env.local` 再覆盖 `.env`,与 api-server 启动脚本保持一致。
|
||||
if (shellEnvKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
target[key] = rawValue.replace(/^['"]|['"]$/gu, '');
|
||||
}
|
||||
}
|
||||
|
||||
const fileEnv = {...process.env};
|
||||
loadEnvFile(resolve(repoRoot, '.env'), fileEnv);
|
||||
loadEnvFile(resolve(repoRoot, '.env.local'), fileEnv);
|
||||
loadEnvFile(resolve(repoRoot, '.env.secrets.local'), fileEnv);
|
||||
|
||||
function buildTargetCandidates() {
|
||||
const candidates = [
|
||||
fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET,
|
||||
fileEnv.RUST_SERVER_TARGET,
|
||||
fileEnv.GENARRATIVE_API_TARGET,
|
||||
`http://127.0.0.1:${fileEnv.GENARRATIVE_API_PORT || '3100'}`,
|
||||
'http://127.0.0.1:8082',
|
||||
'http://127.0.0.1:3100',
|
||||
].filter(Boolean);
|
||||
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
async function isTargetReachable(target) {
|
||||
const healthUrl = new URL('/healthz', target);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1200);
|
||||
|
||||
try {
|
||||
const response = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRuntimeTarget() {
|
||||
const candidates = buildTargetCandidates();
|
||||
const reachableTargets = [];
|
||||
|
||||
for (const target of candidates) {
|
||||
if (await isTargetReachable(target)) {
|
||||
reachableTargets.push(target);
|
||||
if (
|
||||
target === fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET ||
|
||||
target === fileEnv.RUST_SERVER_TARGET ||
|
||||
target === fileEnv.GENARRATIVE_API_TARGET
|
||||
) {
|
||||
return {
|
||||
target,
|
||||
fallbackUsed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reachableTargets.length > 0) {
|
||||
return {
|
||||
target: reachableTargets[0],
|
||||
fallbackUsed: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
target:
|
||||
fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET ||
|
||||
fileEnv.RUST_SERVER_TARGET ||
|
||||
fileEnv.GENARRATIVE_API_TARGET ||
|
||||
`http://127.0.0.1:${fileEnv.GENARRATIVE_API_PORT || '3100'}`,
|
||||
fallbackUsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const runtimeTarget = await resolveRuntimeTarget();
|
||||
if (runtimeTarget.fallbackUsed) {
|
||||
console.warn(
|
||||
`[dev:web] 配置的 Rust target 不可用,已切换到 ${runtimeTarget.target}`,
|
||||
);
|
||||
}
|
||||
|
||||
const mergedEnv = {
|
||||
...fileEnv,
|
||||
RUST_SERVER_TARGET: runtimeTarget.target,
|
||||
GENARRATIVE_RUNTIME_SERVER_TARGET: runtimeTarget.target,
|
||||
};
|
||||
|
||||
console.log(`[dev:web] backend=rust target=${mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET}`);
|
||||
|
||||
const webHost = '0.0.0.0';
|
||||
const preferredWebPort = normalizePort(fileEnv.WEB_PORT, 3000);
|
||||
const webPort = await findAvailablePort({
|
||||
host: webHost,
|
||||
preferredPort: preferredWebPort,
|
||||
});
|
||||
console.log(
|
||||
formatPortDecision({
|
||||
name: 'web',
|
||||
host: webHost,
|
||||
preferredPort: preferredWebPort,
|
||||
resolvedPort: webPort,
|
||||
}),
|
||||
);
|
||||
|
||||
const child = spawn(
|
||||
'node',
|
||||
['scripts/vite-cli.mjs', `--port=${webPort}`, `--host=${webHost}`, '--strictPort'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`[dev:web] 启动 Vite 失败: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
console.error(`[dev:web] Vite 被信号终止: ${signal}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
1497
scripts/dev.mjs
Normal file
1497
scripts/dev.mjs
Normal file
File diff suppressed because it is too large
Load Diff
244
scripts/dev.test.ts
Normal file
244
scripts/dev.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
|
||||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
import {
|
||||
DevRunner,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
parseArgs,
|
||||
shouldAcceptWatchEvent,
|
||||
} from './dev.mjs';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('dev scheduler argument routing', () => {
|
||||
test('完整 dev 栈覆盖前端代理到本次解析出的 api-server 地址', () => {
|
||||
const {command, explicitOptions, options} = parseArgs([], {
|
||||
GENARRATIVE_API_PORT: '8090',
|
||||
GENARRATIVE_RUNTIME_SERVER_TARGET: 'http://127.0.0.1:3100',
|
||||
RUST_SERVER_TARGET: 'http://127.0.0.1:3100',
|
||||
GENARRATIVE_API_TARGET: 'http://127.0.0.1:3100',
|
||||
});
|
||||
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
runner.command = command;
|
||||
expect(runner.resolveFrontendApiTarget()).toBe('http://127.0.0.1:8090');
|
||||
});
|
||||
|
||||
test('单独 dev:web 未显式指定 api 参数时沿用已有 Rust target', () => {
|
||||
const testEnv = {
|
||||
RUST_SERVER_TARGET: 'http://127.0.0.1:3100',
|
||||
GENARRATIVE_API_PORT: '8082',
|
||||
};
|
||||
const {command, explicitOptions, options} = parseArgs(['web'], testEnv);
|
||||
|
||||
const runner = new DevRunner(options, testEnv, explicitOptions);
|
||||
runner.command = command;
|
||||
expect(runner.resolveFrontendApiTarget()).toBe('http://127.0.0.1:3100');
|
||||
});
|
||||
|
||||
test('单独 dev:web 显式指定 api-port 时覆盖代理目标', () => {
|
||||
const testEnv = {
|
||||
RUST_SERVER_TARGET: 'http://127.0.0.1:3100',
|
||||
GENARRATIVE_API_PORT: '8082',
|
||||
};
|
||||
const {command, explicitOptions, options} = parseArgs(
|
||||
['web', '--api-port', '9090'],
|
||||
testEnv,
|
||||
);
|
||||
|
||||
const runner = new DevRunner(options, testEnv, explicitOptions);
|
||||
runner.command = command;
|
||||
expect(runner.resolveFrontendApiTarget()).toBe('http://127.0.0.1:9090');
|
||||
});
|
||||
|
||||
test('单独 dev:admin-web 优先沿用 ADMIN_API_TARGET', () => {
|
||||
const testEnv = {
|
||||
ADMIN_API_TARGET: 'http://127.0.0.1:3100',
|
||||
RUST_SERVER_TARGET: 'http://127.0.0.1:8082',
|
||||
};
|
||||
const {command, explicitOptions, options} = parseArgs(['admin-web'], testEnv);
|
||||
|
||||
const runner = new DevRunner(options, testEnv, explicitOptions);
|
||||
runner.command = command;
|
||||
expect(runner.resolveFrontendApiTarget({admin: true})).toBe(
|
||||
'http://127.0.0.1:3100',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler spacetime reuse guard', () => {
|
||||
test('记录 URL 可 ping 但没有 spacetime.pid 时不复用宿主', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
|
||||
try {
|
||||
writeFileSync(join(tempDir, 'dev-spacetime-url'), 'http://127.0.0.1:3199\n', 'utf8');
|
||||
globalThis.fetch = vi.fn(async () => ({status: 200})) as unknown as typeof fetch;
|
||||
|
||||
const {command, explicitOptions, options} = parseArgs(
|
||||
['--spacetime-data-dir', tempDir],
|
||||
{},
|
||||
);
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
|
||||
await runner.tryReuseExistingSpacetime(command);
|
||||
|
||||
expect(runner.state.spacetimeReused).toBeUndefined();
|
||||
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:3101');
|
||||
} finally {
|
||||
rmSync(tempDir, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
|
||||
test('记录 URL 可 ping 且 spacetime.pid 存活时复用宿主', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
|
||||
try {
|
||||
writeFileSync(join(tempDir, 'dev-spacetime-url'), 'http://127.0.0.1:3199\n', 'utf8');
|
||||
writeFileSync(join(tempDir, 'spacetime.pid'), `${process.pid}\n`, 'utf8');
|
||||
globalThis.fetch = vi.fn(async () => ({status: 200})) as unknown as typeof fetch;
|
||||
|
||||
const {command, explicitOptions, options} = parseArgs(
|
||||
['--spacetime-data-dir', tempDir],
|
||||
{},
|
||||
);
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
|
||||
await runner.tryReuseExistingSpacetime(command);
|
||||
|
||||
expect(runner.state.spacetimeReused).toBe(true);
|
||||
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:3199');
|
||||
expect(runner.options.spacetimePort).toBe(3199);
|
||||
} finally {
|
||||
rmSync(tempDir, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
|
||||
test('没有 URL 记录但 spacetime.pid 存活时复用默认宿主', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
|
||||
try {
|
||||
writeFileSync(join(tempDir, 'spacetime.pid'), `${process.pid}\n`, 'utf8');
|
||||
globalThis.fetch = vi.fn(async () => ({status: 200})) as unknown as typeof fetch;
|
||||
|
||||
const {command, explicitOptions, options} = parseArgs(
|
||||
['--spacetime-data-dir', tempDir, '--spacetime-port', '3198'],
|
||||
{},
|
||||
);
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
|
||||
await runner.tryReuseExistingSpacetime(command);
|
||||
|
||||
expect(runner.state.spacetimeReused).toBe(true);
|
||||
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:3198');
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:3198/v1/ping',
|
||||
expect.any(Object),
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
|
||||
test('spacetime.pid 存活但候选地址不可访问时不继续启动第二个宿主', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
|
||||
try {
|
||||
writeFileSync(join(tempDir, 'spacetime.pid'), `${process.pid}\n`, 'utf8');
|
||||
globalThis.fetch = vi.fn(async () => ({status: 503})) as unknown as typeof fetch;
|
||||
|
||||
const {command, explicitOptions, options} = parseArgs(
|
||||
['--spacetime-data-dir', tempDir, '--spacetime-port', '3198'],
|
||||
{},
|
||||
);
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
|
||||
await expect(runner.tryReuseExistingSpacetime(command)).rejects.toThrow(
|
||||
'检测到 spacetime.pid',
|
||||
);
|
||||
expect(runner.state.spacetimeReused).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(tempDir, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler interactive input', () => {
|
||||
test('前端 dev server 不继承 stdin,避免吞掉 rs 重启命令', () => {
|
||||
const options = createDevServerSpawnOptions({cwd: repoRootForTest(), env: {A: 'B'}});
|
||||
|
||||
expect(options.stdio).toEqual(['ignore', 'pipe', 'pipe']);
|
||||
expect(options.env).toEqual({A: 'B'});
|
||||
});
|
||||
});
|
||||
|
||||
function repoRootForTest() {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
describe('dev scheduler watch routing', () => {
|
||||
test('watch 模式不重启 web/admin-web,交给 Vite 自身 watch', () => {
|
||||
const configs = createWatchConfigs();
|
||||
|
||||
expect(configs.web).toEqual([]);
|
||||
expect(configs['admin-web']).toEqual([]);
|
||||
});
|
||||
|
||||
test('watch 过滤依赖缓存和构建产物,避免自触发循环', () => {
|
||||
const config = {
|
||||
path: join(process.cwd(), 'apps/admin-web'),
|
||||
filter: () => true,
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldAcceptWatchEvent(config, join(process.cwd(), 'apps/admin-web/src/App.tsx')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAcceptWatchEvent(
|
||||
config,
|
||||
join(process.cwd(), 'apps/admin-web/node_modules/.vite/deps/_metadata.json'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAcceptWatchEvent(config, join(process.cwd(), 'apps/admin-web/dist/assets/app.js')),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler spacetime refresh', () => {
|
||||
test('手动刷新 spacetime 只重新发布模块,不重启 standalone 进程', async () => {
|
||||
const {explicitOptions, options} = parseArgs([], {});
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
const restart = vi.fn();
|
||||
|
||||
runner.services.set('spacetime', {restart});
|
||||
runner.waitForSpacetime = vi.fn(async () => {});
|
||||
runner.publishSpacetimeModule = vi.fn(async () => {});
|
||||
|
||||
await runner.restartService('spacetime');
|
||||
|
||||
expect(restart).not.toHaveBeenCalled();
|
||||
expect(runner.waitForSpacetime).toHaveBeenCalledTimes(1);
|
||||
expect(runner.publishSpacetimeModule).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('skip-publish 时 spacetime 刷新不会重启或发布', async () => {
|
||||
const {explicitOptions, options} = parseArgs(['--skip-publish'], {});
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
const restart = vi.fn();
|
||||
|
||||
runner.services.set('spacetime', {restart});
|
||||
runner.waitForSpacetime = vi.fn(async () => {});
|
||||
runner.publishSpacetimeModule = vi.fn(async () => {});
|
||||
|
||||
await runner.restartService('spacetime');
|
||||
|
||||
expect(restart).not.toHaveBeenCalled();
|
||||
expect(runner.waitForSpacetime).not.toHaveBeenCalled();
|
||||
expect(runner.publishSpacetimeModule).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,8 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
npm run dev:rust:logs
|
||||
npm run dev:rust:logs -- --follow
|
||||
npm run dev:spacetime:logs
|
||||
npm run dev:spacetime:logs -- --follow
|
||||
./scripts/spacetime-logs-local.sh --lines 500 --output logs/spacetime/local.log
|
||||
|
||||
说明:
|
||||
|
||||
Reference in New Issue
Block a user