1498 lines
40 KiB
JavaScript
1498 lines
40 KiB
JavaScript
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();
|
||
}
|