Files
Genarrative/scripts/dev.mjs
2026-05-15 11:52:51 +08:00

1498 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {spawn, spawnSync} from 'node:child_process';
import {randomBytes} from 'node:crypto';
import {
createWriteStream,
existsSync,
readdirSync,
readFileSync,
statSync,
watch,
writeFileSync,
} from 'node:fs';
import {basename, relative, resolve} from 'node:path';
import {createInterface} from 'node:readline';
import {fileURLToPath} from 'node:url';
import {
formatPortDecision,
normalizePort,
resolveDevStackPorts,
} from './dev-stack-port-utils.mjs';
import {
ensureParentDir,
mergeApiServerEnv,
resolveApiServerLogFile,
resolveClientHost,
} from './dev-utils.mjs';
const repoRoot = process.cwd();
const serverRsDir = resolve(repoRoot, 'server-rs');
const manifestPath = resolve(serverRsDir, 'Cargo.toml');
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
const SERVICE_ALIASES = new Map([
['api', 'api-server'],
['admin', 'admin-web'],
['adminWeb', 'admin-web'],
['all', 'all'],
]);
function usage() {
console.log(`用法:
npm run dev [-- --watch] [-- --api-port 8090]
npm run dev:spacetime [-- --skip-publish]
npm run dev:api-server [-- --database genarrative-dev]
npm run dev:web [-- --api-port 8082]
npm run dev:admin-web [-- --api-port 8082]
常用参数:
--api-host <host> api-server 监听地址
--api-port <port> api-server 端口
--web-host <host> 主站 Vite 监听地址
--web-port <port> 主站 Vite 端口
--admin-web-host <host> 后台 Vite 监听地址
--admin-web-port <port> 后台 Vite 端口
--spacetime-host <host> SpacetimeDB 监听地址
--spacetime-port <port> SpacetimeDB 端口
--spacetime-data-dir <path> SpacetimeDB 本地数据目录
--database <name> SpacetimeDB 数据库名
--watch 文件改动后刷新/重启对应模块
--no-interactive 关闭交互式手动命令
交互命令:
rs spacetime 重新发布 spacetime-module不重启 standalone
rs api-server 重启 api-server
rs web 重启主站 Vite
rs admin-web 重启后台 Vite
rs all 重新发布 spacetime-module并重启其余模块
help
quit
`);
}
function readLocalSpacetimeDatabase() {
const configPath = resolve(repoRoot, 'spacetime.local.json');
if (!existsSync(configPath)) {
return '';
}
try {
const value = JSON.parse(readFileSync(configPath, 'utf8')).database;
return typeof value === 'string' ? value.trim() : '';
} catch (error) {
console.warn(`[dev] 忽略无效 spacetime.local.json: ${error.message}`);
return '';
}
}
function parseArgs(argv, baseEnv) {
const args = [...argv];
let command = 'all';
const explicitOptions = new Set();
if (args[0] && !args[0].startsWith('-')) {
command = normalizeServiceName(args.shift());
}
const env = baseEnv ?? process.env;
const options = {
apiHost: env.GENARRATIVE_API_HOST || '127.0.0.1',
apiPort: normalizePort(env.GENARRATIVE_API_PORT, 8082),
webHost: env.WEB_HOST || '0.0.0.0',
webPort: normalizePort(env.WEB_PORT, 3000),
adminWebHost: env.ADMIN_WEB_HOST || '127.0.0.1',
adminWebPort: normalizePort(env.ADMIN_WEB_PORT, 3102),
spacetimeHost: env.SPACETIME_HOST || '127.0.0.1',
spacetimePort: normalizePort(env.SPACETIME_PORT, 3101),
spacetimeDataDir: resolve(serverRsDir, '.spacetimedb/local/data'),
spacetimeServerUrl: String(env.GENARRATIVE_SPACETIME_SERVER_URL ?? '').trim(),
database:
readLocalSpacetimeDatabase() ||
String(env.GENARRATIVE_SPACETIME_DATABASE ?? '').trim() ||
'genarrative-dev',
apiLog: 'info,tower_http=info',
spacetimeTimeoutSeconds: 60,
apiTimeoutSeconds: 600,
skipSpacetime: false,
skipPublish: false,
preserveDatabase: false,
migrationBootstrapSecret: '',
migrationBootstrapSecretMode: 'auto',
watch: false,
interactive: true,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
const readValue = () => {
const value = args[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`缺少 ${arg} 的值`);
}
index += 1;
return value;
};
switch (arg) {
case '-h':
case '--help':
usage();
process.exit(0);
break;
case '--api-host':
options.apiHost = readValue();
explicitOptions.add('apiHost');
break;
case '--api-port':
options.apiPort = normalizePort(readValue(), options.apiPort);
explicitOptions.add('apiPort');
break;
case '--web-host':
options.webHost = readValue();
explicitOptions.add('webHost');
break;
case '--web-port':
options.webPort = normalizePort(readValue(), options.webPort);
explicitOptions.add('webPort');
break;
case '--admin-web-host':
options.adminWebHost = readValue();
explicitOptions.add('adminWebHost');
break;
case '--admin-web-port':
options.adminWebPort = normalizePort(readValue(), options.adminWebPort);
explicitOptions.add('adminWebPort');
break;
case '--spacetime-host':
options.spacetimeHost = readValue();
options.spacetimeServerUrl = '';
explicitOptions.add('spacetimeHost');
break;
case '--spacetime-port':
options.spacetimePort = normalizePort(readValue(), options.spacetimePort);
options.spacetimeServerUrl = '';
explicitOptions.add('spacetimePort');
break;
case '--spacetime-data-dir':
options.spacetimeDataDir = resolve(repoRoot, readValue());
explicitOptions.add('spacetimeDataDir');
break;
case '--database':
options.database = readValue();
explicitOptions.add('database');
break;
case '--log':
options.apiLog = readValue();
break;
case '--spacetime-timeout-seconds':
options.spacetimeTimeoutSeconds = Number(readValue());
break;
case '--api-timeout-seconds':
options.apiTimeoutSeconds = Number(readValue());
break;
case '--skip-spacetime':
options.skipSpacetime = true;
break;
case '--skip-publish':
options.skipPublish = true;
break;
case '--clear-database':
options.preserveDatabase = false;
break;
case '--preserve-database':
options.preserveDatabase = true;
break;
case '--migration-bootstrap-secret':
options.migrationBootstrapSecret = readValue();
options.migrationBootstrapSecretMode = 'manual';
break;
case '--no-migration-bootstrap-secret':
options.migrationBootstrapSecret = '';
options.migrationBootstrapSecretMode = 'disabled';
break;
case '--watch':
options.watch = true;
break;
case '--no-interactive':
options.interactive = false;
break;
default:
throw new Error(`未知参数: ${arg}`);
}
}
if (!Number.isFinite(options.spacetimeTimeoutSeconds)) {
throw new Error('--spacetime-timeout-seconds 必须是数字');
}
if (!Number.isFinite(options.apiTimeoutSeconds)) {
throw new Error('--api-timeout-seconds 必须是数字');
}
return {command, explicitOptions, options};
}
function normalizeServiceName(rawName) {
const alias = SERVICE_ALIASES.get(rawName);
const name = alias ?? rawName;
if (name === 'all' || SERVICE_NAMES.includes(name)) {
return name;
}
throw new Error(`未知模块: ${rawName}`);
}
function requireCommand(command) {
const result = spawnSync(command, ['--version'], {
cwd: repoRoot,
encoding: 'utf8',
shell: process.platform === 'win32',
});
if (result.error) {
throw new Error(`缺少命令: ${command}`);
}
}
function ensureRequiredFiles(command) {
const requiredFiles = [];
if (command === 'api-server' || command === 'spacetime' || command === 'all') {
requiredFiles.push([manifestPath, 'server-rs/Cargo.toml']);
}
if (command === 'spacetime' || command === 'all') {
requiredFiles.push([resolve(modulePath, 'Cargo.toml'), 'spacetime-module Cargo.toml']);
}
if (command === 'web' || command === 'admin-web' || command === 'all') {
requiredFiles.push([viteCliPath, 'scripts/vite-cli.mjs']);
}
if (command === 'admin-web' || command === 'all') {
requiredFiles.push([resolve(adminWebDir, 'package.json'), 'apps/admin-web/package.json']);
}
for (const [path, label] of requiredFiles) {
if (!existsSync(path)) {
throw new Error(`未找到 ${label}: ${path}`);
}
}
}
class DevService {
constructor(name, startFn) {
this.name = name;
this.startFn = startFn;
this.child = null;
this.children = [];
this.logStream = null;
this.stopping = false;
this.restartTimer = null;
}
async start() {
if (this.child) {
return;
}
await this.startFn(this);
}
registerChild(child) {
this.child = child;
child.on('exit', (code, signal) => {
if (this.logStream && !this.logStream.destroyed) {
this.logStream.end();
}
this.child = null;
if (this.stopping) {
this.stopping = false;
return;
}
const reason = signal ? `signal=${signal}` : `code=${code ?? 0}`;
console.error(`[dev:${this.name}] 子进程退出: ${reason}`);
});
}
async stop() {
if (this.restartTimer) {
clearTimeout(this.restartTimer);
this.restartTimer = null;
}
const processes = [this.child, ...this.children].filter(Boolean);
this.stopping = processes.length > 0;
this.child = null;
this.children = [];
for (const child of processes.reverse()) {
await stopProcess(child, this.name);
}
if (this.logStream && !this.logStream.destroyed) {
await new Promise((resolveEnd) => this.logStream.end(resolveEnd));
}
this.logStream = null;
this.stopping = false;
}
scheduleRestart(delayMs = 250, restartFn = null, actionLabel = '重启') {
if (this.restartTimer) {
clearTimeout(this.restartTimer);
}
this.restartTimer = setTimeout(async () => {
this.restartTimer = null;
try {
if (restartFn) {
await restartFn();
return;
}
await this.restart();
} catch (error) {
console.error(`[dev:${this.name}] ${actionLabel}失败: ${error.message}`);
}
}, delayMs);
}
async restart() {
console.log(`[dev] 重启 ${this.name}`);
await this.stop();
await this.start();
}
}
async function stopProcess(child, label) {
if (!child || child.exitCode != null || child.signalCode != null) {
return;
}
await new Promise((resolveStop) => {
const timer = setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
// ignore cleanup races
}
resolveStop();
}, 5000);
child.once('exit', () => {
clearTimeout(timer);
resolveStop();
});
try {
if (process.platform === 'win32') {
stopWindowsProcessTree(child.pid);
} else {
child.kill('SIGTERM');
}
} catch (error) {
clearTimeout(timer);
console.warn(`[dev:${label}] 停止进程失败: ${error.message}`);
resolveStop();
}
});
}
function stopWindowsProcessTree(pid) {
if (!pid) {
return;
}
spawnSync('powershell.exe', [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
[
'$ErrorActionPreference = "SilentlyContinue"',
'$root = [int]$env:GENARRATIVE_STOP_PID',
'$all = Get-CimInstance Win32_Process',
'$childrenByParent = @{}',
'foreach ($process in $all) {',
' $parent = [int]$process.ParentProcessId',
' if (-not $childrenByParent.ContainsKey($parent)) { $childrenByParent[$parent] = @() }',
' $childrenByParent[$parent] += [int]$process.ProcessId',
'}',
'$toStop = New-Object System.Collections.Generic.List[int]',
'$queue = New-Object System.Collections.Generic.Queue[int]',
'$queue.Enqueue($root)',
'while ($queue.Count -gt 0) {',
' $current = $queue.Dequeue()',
' $toStop.Add($current)',
' if ($childrenByParent.ContainsKey($current)) {',
' foreach ($child in $childrenByParent[$current]) { $queue.Enqueue($child) }',
' }',
'}',
'foreach ($id in ($toStop | Select-Object -Unique | Sort-Object -Descending)) {',
' Stop-Process -Id $id -Force -ErrorAction SilentlyContinue',
'}',
].join('\n'),
], {
env: {
...process.env,
GENARRATIVE_STOP_PID: String(pid),
},
stdio: 'ignore',
});
}
class DevRunner {
constructor(options, baseEnv = process.env, explicitOptions = new Set()) {
this.options = options;
this.baseEnv = {...baseEnv};
this.explicitOptions = explicitOptions;
const initialSpacetimeServer =
options.spacetimeServerUrl ||
`http://${options.spacetimeHost}:${options.spacetimePort}`;
this.state = {
apiTargetHost: resolveClientHost(options.apiHost),
adminWebTargetHost: resolveClientHost(options.adminWebHost),
spacetimeServer: initialSpacetimeServer,
apiTarget: `http://${resolveClientHost(options.apiHost)}:${options.apiPort}`,
};
this.services = new Map();
this.watchers = [];
this.shuttingDown = false;
}
async init(command) {
this.command = command;
ensureRequiredFiles(command);
requireCommand('node');
if (command === 'api-server' || command === 'all') {
requireCommand('cargo');
}
if (
command === 'spacetime' ||
(command === 'all' && (!this.options.skipSpacetime || !this.options.skipPublish))
) {
requireCommand('spacetime');
}
await this.tryReuseExistingSpacetime(command);
await this.resolvePorts(command);
this.registerServices();
this.printSummary(command);
}
async tryReuseExistingSpacetime(command) {
if (this.options.skipSpacetime) {
return;
}
if (
this.options.spacetimeServerUrl &&
command !== 'all' &&
command !== 'spacetime'
) {
return;
}
const pidState = readRecordedSpacetimePidState(this.options.spacetimeDataDir);
const recordedUrl = readRecordedSpacetimeUrl(this.options.spacetimeDataDir);
if (pidState.state === 'missing') {
if (recordedUrl) {
console.log(`[dev:spacetime] 记录的 URL 缺少 spacetime.pid跳过复用: ${recordedUrl}`);
}
return;
}
if (pidState.state === 'invalid' || pidState.state === 'dead' || pidState.state === 'unknown') {
console.log('[dev:spacetime] 检测到 spacetime.pid 但状态无效,跳过复用');
return;
}
const candidates = uniqueNonEmpty([
recordedUrl,
this.options.spacetimeServerUrl,
this.state.spacetimeServer,
`http://${this.options.spacetimeHost}:${this.options.spacetimePort}`,
]);
for (const candidate of candidates) {
const pingUrl = buildUrl(candidate, '/v1/ping');
if (!pingUrl || !(await isHttpReady(pingUrl))) {
continue;
}
const port = safeUrlPort(candidate);
if (Number.isInteger(port) && port > 0) {
this.options.spacetimePort = port;
}
this.state.spacetimeServer = candidate;
this.state.spacetimeReused = true;
const pidLabel = Number.isInteger(pidState.pid) ? ` pid=${pidState.pid}` : '';
console.log(`[dev:spacetime] 复用已启动实例${pidLabel}: ${candidate}`);
return;
}
const pidLabel = Number.isInteger(pidState.pid) ? ` pid=${pidState.pid}` : '';
throw new Error(
`检测到 spacetime.pid${pidLabel},但无法连接候选地址: ${candidates.join(', ')}`,
);
}
async resolvePorts(command) {
const {options} = this;
const portConfig = {};
if (command === 'all' || command === 'spacetime') {
if (!options.skipSpacetime && !this.state.spacetimeReused) {
portConfig.spacetime = {
host: options.spacetimeHost,
preferredPort: options.spacetimePort,
};
}
}
if (command === 'all' || command === 'api-server') {
portConfig.api = {
host: options.apiHost,
preferredPort: options.apiPort,
};
}
if (command === 'all' || command === 'web') {
portConfig.web = {
host: options.webHost,
preferredPort: options.webPort,
};
}
if (command === 'all' || command === 'admin-web') {
portConfig.adminWeb = {
host: options.adminWebHost,
preferredPort: options.adminWebPort,
};
}
if (Object.keys(portConfig).length === 0) {
return;
}
const resolvedPorts = await resolveDevStackPorts(portConfig);
for (const [name, resolvedPort] of Object.entries(resolvedPorts)) {
const config = portConfig[name];
console.error(
formatPortDecision({
name,
host: config.host,
preferredPort: config.preferredPort,
resolvedPort,
}),
);
}
if (resolvedPorts.spacetime) {
options.spacetimePort = resolvedPorts.spacetime;
}
if (resolvedPorts.api) {
options.apiPort = resolvedPorts.api;
}
if (resolvedPorts.web) {
options.webPort = resolvedPorts.web;
}
if (resolvedPorts.adminWeb) {
options.adminWebPort = resolvedPorts.adminWeb;
}
this.state.apiTargetHost = resolveClientHost(options.apiHost);
this.state.adminWebTargetHost = resolveClientHost(options.adminWebHost);
if (command === 'all' || command === 'spacetime') {
this.state.spacetimeServer = `http://${options.spacetimeHost}:${options.spacetimePort}`;
}
this.state.apiTarget = `http://${this.state.apiTargetHost}:${options.apiPort}`;
}
registerServices() {
this.services.set(
'spacetime',
new DevService('spacetime', async (service) => this.startSpacetime(service)),
);
this.services.set(
'api-server',
new DevService('api-server', async (service) => this.startApiServer(service)),
);
this.services.set('web', new DevService('web', async (service) => this.startWeb(service)));
this.services.set(
'admin-web',
new DevService('admin-web', async (service) => this.startAdminWeb(service)),
);
}
printSummary(command) {
const {options, state} = this;
console.log(`[dev] repo: ${repoRoot}`);
console.log(`[dev] command: ${command}`);
console.log(`[dev] watch: ${options.watch ? 'on' : 'off'}`);
console.log(`[dev] web: http://127.0.0.1:${options.webPort}`);
console.log(`[dev] admin web: http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`);
console.log(`[dev] api-server: ${state.apiTarget}`);
console.log(`[dev] spacetime: ${state.spacetimeServer}`);
console.log(`[dev] database: ${options.database}`);
}
async startCommand(command) {
if (command === 'all') {
await this.startSpacetimeForFullStack();
await this.services.get('api-server').start();
await this.waitForApiServer();
await this.services.get('web').start();
await this.services.get('admin-web').start();
this.startInteractiveInput();
this.startWatchers(['spacetime', 'api-server', 'web', 'admin-web']);
return;
}
if (command === 'spacetime') {
await this.startSpacetimeForFullStack();
} else {
await this.services.get(command).start();
}
this.startWatchers([command]);
}
async startSpacetimeForFullStack() {
if (!this.options.skipSpacetime && !this.state.spacetimeReused) {
await this.services.get('spacetime').start();
await this.waitForSpacetime();
}
if (this.state.spacetimeReused || this.options.skipSpacetime) {
await this.waitForSpacetime();
}
if (!this.options.skipPublish) {
await this.publishSpacetimeModule();
}
}
async refreshSpacetimeModule() {
if (this.options.skipPublish) {
console.log('[dev:spacetime] 已跳过发布,忽略重新发布请求');
return;
}
await this.waitForSpacetime();
await this.publishSpacetimeModule();
}
startSpacetime(service) {
const {options} = this;
ensureParentDir(resolve(options.spacetimeDataDir, 'logs/.keep'));
const logFile = resolve(options.spacetimeDataDir, 'logs/dev-spacetime-start.log');
const logStream = createWriteStream(logFile, {flags: 'a', encoding: 'utf8'});
service.logStream = logStream;
console.log(`[dev:spacetime] log: ${logFile}`);
const env = {
...this.baseEnv,
};
const child = spawn(
'spacetime',
[
'start',
'--data-dir',
options.spacetimeDataDir,
'--listen-addr',
`${options.spacetimeHost}:${options.spacetimePort}`,
'--non-interactive',
],
{
cwd: serverRsDir,
env,
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
},
);
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk);
logStream.write(chunk);
const listenAddr = parseListenAddr(String(chunk));
if (listenAddr) {
this.updateSpacetimeServerFromListenAddr(listenAddr);
}
});
child.stderr?.on('data', (chunk) => {
process.stderr.write(chunk);
logStream.write(chunk);
const listenAddr = parseListenAddr(String(chunk));
if (listenAddr) {
this.updateSpacetimeServerFromListenAddr(listenAddr);
}
});
child.on('error', (error) => {
console.error(`[dev:spacetime] 启动失败: ${error.message}`);
});
service.registerChild(child);
}
updateSpacetimeServerFromListenAddr(listenAddr) {
const port = Number(listenAddr.split(':').at(-1));
if (!Number.isInteger(port) || port <= 0) {
return;
}
this.options.spacetimePort = port;
this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${port}`;
recordSpacetimeUrl(this.options.spacetimeDataDir, this.state.spacetimeServer);
console.log(`[dev:spacetime] actual: ${this.state.spacetimeServer}`);
}
async waitForSpacetime() {
const deadline = Date.now() + this.options.spacetimeTimeoutSeconds * 1000;
while (Date.now() < deadline) {
if (await isHttpReady(new URL('/v1/ping', this.state.spacetimeServer).href)) {
return;
}
await sleep(500);
}
throw new Error(`等待 SpacetimeDB 就绪超时: ${this.state.spacetimeServer}`);
}
async publishSpacetimeModule() {
const env = {...this.baseEnv};
this.prepareMigrationBootstrapSecret(env);
const args = [
'publish',
this.options.database,
'--server',
this.state.spacetimeServer,
'--module-path',
modulePath,
'--build-options=--debug',
];
if (!this.options.preserveDatabase) {
args.push('-c=on-conflict');
}
args.push('--yes');
console.log(`[dev:spacetime] 发布模块: ${this.options.database}`);
await runForeground('spacetime', args, {
cwd: serverRsDir,
env,
label: 'spacetime',
});
}
prepareMigrationBootstrapSecret(env) {
switch (this.options.migrationBootstrapSecretMode) {
case 'auto':
this.options.migrationBootstrapSecret = randomHex(32);
break;
case 'manual':
if (this.options.migrationBootstrapSecret.length < 16) {
throw new Error('迁移引导密钥至少需要 16 个字符');
}
break;
case 'disabled':
delete env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET;
console.log('[dev:spacetime] 未启用迁移引导密钥');
return;
default:
throw new Error(`未知迁移引导密钥模式: ${this.options.migrationBootstrapSecretMode}`);
}
env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET =
this.options.migrationBootstrapSecret;
console.log(`[dev:spacetime] 迁移引导密钥: ${this.options.migrationBootstrapSecret}`);
}
startApiServer(service) {
const mergedEnv = {
...this.baseEnv,
GENARRATIVE_API_HOST: this.options.apiHost,
GENARRATIVE_API_PORT: String(this.options.apiPort),
GENARRATIVE_API_LOG: this.options.apiLog,
GENARRATIVE_SPACETIME_SERVER_URL: this.state.spacetimeServer,
GENARRATIVE_SPACETIME_DATABASE: this.options.database,
GENARRATIVE_SPACETIME_TOKEN:
this.baseEnv.GENARRATIVE_SPACETIME_TOKEN || '',
};
const logFile = resolveApiServerLogFile(repoRoot, mergedEnv);
ensureParentDir(logFile);
const logStream = createWriteStream(logFile, {flags: 'a', encoding: 'utf8'});
service.logStream = logStream;
mergedEnv.GENARRATIVE_API_SERVER_LOG_FILE = logFile;
stopExistingWindowsApiServer(logStream);
console.log(`[dev:api-server] log: ${logFile}`);
console.log(
`[dev:api-server] SpacetimeDB ${this.options.database} @ ${this.state.spacetimeServer}`,
);
const child = spawn(
'cargo',
['run', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'],
{
cwd: repoRoot,
env: mergedEnv,
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
},
);
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) => {
console.error(`[dev:api-server] 启动 cargo 失败: ${error.message}`);
});
service.registerChild(child);
}
async waitForApiServer() {
const healthUrl = `${this.state.apiTarget}/healthz`;
const deadline = Date.now() + this.options.apiTimeoutSeconds * 1000;
while (Date.now() < deadline) {
if (await isHttpReady(healthUrl, 500)) {
return;
}
await sleep(500);
}
throw new Error(`等待 api-server 就绪超时: ${healthUrl}`);
}
startWeb(service) {
const apiTarget = this.resolveFrontendApiTarget();
const env = {
...this.baseEnv,
RUST_SERVER_TARGET: apiTarget,
GENARRATIVE_RUNTIME_SERVER_TARGET: apiTarget,
ADMIN_WEB_TARGET: `http://${this.state.adminWebTargetHost}:${this.options.adminWebPort}`,
ADMIN_WEB_PORT: String(this.options.adminWebPort),
VITE_DEV_HOST: this.options.webHost,
};
console.log(`[dev:web] api target: ${apiTarget}`);
const child = spawn(
'node',
[
viteCliPath,
`--port=${this.options.webPort}`,
`--host=${this.options.webHost}`,
'--strictPort',
],
{
cwd: repoRoot,
env,
...createDevServerSpawnOptions(),
shell: process.platform === 'win32',
},
);
pipeChildOutput(child);
child.on('error', (error) => {
console.error(`[dev:web] 启动 Vite 失败: ${error.message}`);
});
service.registerChild(child);
}
startAdminWeb(service) {
const apiTarget = this.resolveFrontendApiTarget({admin: true});
const env = {
...this.baseEnv,
ADMIN_API_TARGET: apiTarget,
GENARRATIVE_API_TARGET: apiTarget,
GENARRATIVE_API_PORT: String(this.options.apiPort),
};
console.log(`[dev:admin-web] api target: ${apiTarget}`);
const child = spawn(
'node',
[
viteCliPath,
`--host=${this.options.adminWebHost}`,
`--port=${this.options.adminWebPort}`,
'--strictPort',
],
{
cwd: adminWebDir,
env,
...createDevServerSpawnOptions(),
shell: process.platform === 'win32',
},
);
pipeChildOutput(child);
child.on('error', (error) => {
console.error(`[dev:admin-web] 启动 Vite 失败: ${error.message}`);
});
service.registerChild(child);
}
resolveFrontendApiTarget({admin = false} = {}) {
if (
this.command === 'all' ||
this.explicitOptions.has('apiHost') ||
this.explicitOptions.has('apiPort')
) {
return this.state.apiTarget;
}
if (admin) {
const adminTarget = String(this.baseEnv.ADMIN_API_TARGET ?? '').trim();
if (adminTarget) {
return adminTarget;
}
}
return (
String(this.baseEnv.GENARRATIVE_RUNTIME_SERVER_TARGET ?? '').trim() ||
String(this.baseEnv.RUST_SERVER_TARGET ?? '').trim() ||
String(this.baseEnv.GENARRATIVE_API_TARGET ?? '').trim() ||
this.state.apiTarget
);
}
startWatchers(serviceNames) {
if (!this.options.watch) {
return;
}
const watchConfigs = createWatchConfigs();
for (const serviceName of serviceNames) {
const service = this.services.get(serviceName);
for (const config of watchConfigs[serviceName] ?? []) {
if (!existsSync(config.path)) {
continue;
}
this.watchers.push(
createServiceWatcher({
config,
service,
serviceName,
restartFn:
serviceName === 'spacetime'
? async () => this.refreshSpacetimeModule()
: async () => this.restartService(serviceName),
actionLabel: serviceName === 'spacetime' ? '重新发布' : '重启',
}),
);
}
}
}
startInteractiveInput() {
if (!this.options.interactive || !process.stdin.isTTY) {
return;
}
console.log('[dev] 输入 help 查看交互命令。');
const rl = createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
});
rl.on('line', async (line) => {
const raw = line.trim();
if (!raw) {
return;
}
try {
if (raw === 'help') {
console.log(
'可用命令: rs spacetime(重新发布) | rs api-server | rs web | rs admin-web | rs all | quit',
);
return;
}
if (raw === 'quit' || raw === 'exit') {
await this.shutdown(0);
return;
}
const match = raw.match(/^rs\s+(.+)$/u);
if (!match) {
console.warn(`[dev] 未知交互命令: ${raw}`);
return;
}
const target = normalizeServiceName(match[1]);
if (target === 'all') {
for (const serviceName of SERVICE_NAMES) {
await this.restartService(serviceName);
}
return;
}
await this.restartService(target);
} catch (error) {
console.error(`[dev] 交互命令失败: ${error.message}`);
}
});
}
async restartService(serviceName) {
if (serviceName === 'spacetime') {
await this.refreshSpacetimeModule();
return;
}
await this.services.get(serviceName).restart();
if (serviceName === 'api-server') {
await this.waitForApiServer();
}
}
async shutdown(code = 0) {
if (this.shuttingDown) {
return;
}
this.shuttingDown = true;
for (const watcher of this.watchers) {
watcher.close();
}
this.watchers = [];
for (const serviceName of [...SERVICE_NAMES].reverse()) {
await this.services.get(serviceName)?.stop();
}
process.exit(code);
}
}
function stopExistingWindowsApiServer(logStream) {
if (process.platform !== 'win32') {
return;
}
const apiServerExePath = resolve(repoRoot, 'server-rs/target/debug/api-server.exe');
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 "[dev:api-server] 忽略旧进程清理瞬时失败 pid=$($process.Id): $($_.Exception.Message)"',
' }',
'}',
'exit 0',
].join('\n');
const result = spawnSync(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
{
encoding: 'utf8',
env: {
...process.env,
GENARRATIVE_API_SERVER_EXE_TARGET: apiServerExePath,
},
},
);
if (result.error) {
throw result.error;
}
const output = String(result.stdout ?? '').trim();
if (output) {
const line = `[dev:api-server] 已停止旧 api-server 进程: ${output}\n`;
process.stdout.write(line);
logStream.write(line);
}
}
function parseListenAddr(text) {
const match = text.match(/Starting SpacetimeDB listening on ([^\s]+)/u);
return match?.[1] ?? '';
}
function createServiceWatcher({config, service, serviceName, restartFn, actionLabel = '重启'}) {
try {
const watcher = watch(config.path, {recursive: true}, (_event, fileName) => {
const filePath = fileName
? resolve(config.path, String(fileName))
: config.path;
notifyWatchedFile({
config,
filePath,
restartFn,
service,
serviceName,
actionLabel,
});
});
return {
close: () => watcher.close(),
};
} catch (error) {
console.warn(
`[dev:watch] ${serviceName} 使用轮询监听 ${relative(repoRoot, config.path)}: ${error.message}`,
);
return createPollingWatcher({config, restartFn, service, serviceName, actionLabel});
}
}
function createPollingWatcher({config, service, serviceName, restartFn, actionLabel = '重启'}) {
let snapshot = snapshotWatchedFiles(config.path, config.filter);
const timer = setInterval(() => {
const nextSnapshot = snapshotWatchedFiles(config.path, config.filter);
for (const [filePath, mtimeMs] of nextSnapshot.entries()) {
if (snapshot.get(filePath) !== mtimeMs) {
notifyWatchedFile({
config,
filePath,
restartFn,
service,
serviceName,
actionLabel,
});
break;
}
}
snapshot = nextSnapshot;
}, 1000);
return {
close: () => clearInterval(timer),
};
}
function notifyWatchedFile({config, filePath, restartFn, service, serviceName, actionLabel}) {
if (!shouldAcceptWatchEvent(config, filePath)) {
return;
}
console.log(`[dev:watch] ${serviceName}: ${relative(repoRoot, filePath)}`);
service.scheduleRestart(250, restartFn, actionLabel);
}
function snapshotWatchedFiles(rootPath, filter) {
const snapshot = new Map();
if (!existsSync(rootPath)) {
return snapshot;
}
const visit = (filePath) => {
let stats;
try {
stats = statSync(filePath);
} catch {
return;
}
if (stats.isDirectory()) {
if (shouldSkipDirectory(filePath)) {
return;
}
for (const entry of readdirSync(filePath)) {
visit(resolve(filePath, entry));
}
return;
}
if (stats.isFile() && filter(filePath)) {
snapshot.set(filePath, stats.mtimeMs);
}
};
visit(rootPath);
return snapshot;
}
function shouldSkipDirectory(filePath) {
const name = basename(filePath);
return (
name === 'node_modules' ||
name === '.vite' ||
name === 'target' ||
name === 'dist' ||
name === 'build' ||
name === '.git' ||
name === '.spacetimedb'
);
}
function shouldAcceptWatchEvent(config, filePath) {
if (hasSkippedPathSegment(filePath)) {
return false;
}
return config.filter(filePath);
}
function hasSkippedPathSegment(filePath) {
return normalizePath(filePath)
.split('/')
.some((segment) =>
[
'node_modules',
'.vite',
'target',
'dist',
'build',
'.git',
'.spacetimedb',
].includes(segment),
);
}
function createWatchConfigs() {
return {
spacetime: [
{
path: resolve(serverRsDir, 'crates/spacetime-module'),
filter: isCodeFile,
},
],
'api-server': [
{
path: serverRsDir,
filter: (path) =>
isCodeFile(path) &&
!normalizePath(path).includes('/crates/spacetime-module/'),
},
],
web: [],
'admin-web': [],
};
}
function createDevServerSpawnOptions(overrides = {}) {
return {
...overrides,
stdio: ['ignore', 'pipe', 'pipe'],
};
}
function pipeChildOutput(child) {
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk);
});
child.stderr?.on('data', (chunk) => {
process.stderr.write(chunk);
});
}
function uniqueNonEmpty(values) {
return [
...new Set(
values
.map((value) => String(value ?? '').trim())
.filter(Boolean),
),
];
}
function readRecordedSpacetimeUrl(dataDir) {
const candidates = [
resolve(dataDir, 'dev-spacetime-url'),
resolve(dataDir, 'dev-rust-spacetime-url'),
];
for (const candidate of candidates) {
if (!existsSync(candidate)) {
continue;
}
const value = readFileSync(candidate, 'utf8').split(/\r?\n/u)[0]?.trim();
if (value) {
return value;
}
}
return '';
}
function readRecordedSpacetimePidState(dataDir) {
const pidPath = resolve(dataDir, 'spacetime.pid');
if (!existsSync(pidPath)) {
return {state: 'missing', pid: 0};
}
try {
const rawPid = readFileSync(pidPath, 'utf8').split(/\r?\n/u)[0]?.trim() ?? '';
const pid = Number(rawPid);
if (!Number.isInteger(pid) || pid <= 0) {
return {state: 'invalid', pid: 0};
}
try {
process.kill(pid, 0);
return {state: 'alive', pid};
} catch (error) {
if (error?.code === 'EPERM') {
return {state: 'alive', pid};
}
return {state: 'dead', pid: 0};
}
} catch (error) {
if (error?.code === 'EBUSY' || error?.code === 'EPERM') {
return {state: 'locked', pid: 0};
}
console.warn(`[dev:spacetime] 读取 PID 记录失败 ${pidPath}: ${error.message}`);
return {state: 'unknown', pid: 0};
}
}
function recordSpacetimeUrl(dataDir, serverUrl) {
const targets = [
resolve(dataDir, 'dev-spacetime-url'),
resolve(dataDir, 'dev-rust-spacetime-url'),
];
for (const target of targets) {
ensureParentDir(target);
try {
writeFileSync(target, `${serverUrl}\n`, 'utf8');
} catch (error) {
console.warn(`[dev:spacetime] 写入 URL 记录失败 ${target}: ${error.message}`);
}
}
}
function buildUrl(baseUrl, path) {
try {
return new URL(path, baseUrl).href;
} catch {
return '';
}
}
function safeUrlPort(rawUrl) {
try {
return Number(new URL(rawUrl).port);
} catch {
return 0;
}
}
async function isHttpReady(url, timeoutMs = 1000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {signal: controller.signal});
return response.status >= 200 && response.status < 500;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
function runForeground(command, args, {cwd, env, label}) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command, args, {
cwd,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('error', rejectRun);
child.on('exit', (code, signal) => {
if (signal) {
rejectRun(new Error(`[dev:${label}] 被信号终止: ${signal}`));
return;
}
if (code !== 0) {
rejectRun(new Error(`[dev:${label}] 退出码: ${code}`));
return;
}
resolveRun();
});
});
}
function randomHex(byteLength) {
return randomBytes(byteLength).toString('hex');
}
function sleep(ms) {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
function isCodeFile(path) {
const fileName = basename(path);
if (!fileName || fileName.startsWith('.')) {
return false;
}
return /\.(rs|toml|ts|tsx|js|jsx|mjs|json|css|html)$/u.test(fileName);
}
function normalizePath(path) {
return path.replace(/\\/gu, '/');
}
export {
DevRunner,
createDevServerSpawnOptions,
createWatchConfigs,
parseArgs,
shouldAcceptWatchEvent,
};
async function main() {
let runner;
try {
const baseEnv = mergeApiServerEnv(repoRoot, process.env);
const {command, explicitOptions, options} = parseArgs(process.argv.slice(2), baseEnv);
runner = new DevRunner(options, baseEnv, explicitOptions);
await runner.init(command);
process.on('SIGINT', () => {
void runner.shutdown(130);
});
process.on('SIGTERM', () => {
void runner.shutdown(143);
});
await runner.startCommand(command);
} catch (error) {
console.error(`[dev] ${error.message}`);
if (runner) {
await runner.shutdown(1);
}
process.exit(1);
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
void main();
}