Files
Genarrative/scripts/dev.mjs
T
JenkenB 7eeff10c67 新增编辑器生成规范、生成角色形象、生成图标素材等功能
新增编辑器生成规范、生成角色形象、生成图标素材等功能
2026-06-16 14:47:13 +08:00

2213 lines
62 KiB
JavaScript

import {spawn, spawnSync} from 'node:child_process';
import {randomBytes} from 'node:crypto';
import {
createWriteStream,
existsSync,
readdirSync,
readFileSync,
realpathSync,
statSync,
watch,
writeFileSync,
} from 'node:fs';
import {basename, join, relative, resolve} from 'node:path';
import {createInterface} from 'node:readline';
import {fileURLToPath} from 'node:url';
import {
formatPortDecision,
getLinuxDevPortRangeRegistryPaths,
getLinuxDevPortRangeUsername,
mapDevPortsToPortRange,
normalizePort,
reserveLinuxDevPortRange,
resolveDevStackPorts,
} from './dev-stack-port-utils.mjs';
import {
ensureParentDir,
mergeApiServerEnv,
resolveApiServerLogFile,
resolveClientHost,
} from './dev-utils.mjs';
const repoRoot = process.cwd();
const devStackStatePath = join(repoRoot, '.app/dev-stack.json');
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');
function resolveLocalDevRustcWrapperBypass() {
// Windows 下不能把 rustc 自身当成 Cargo wrapper;空值会覆盖仓库 .cargo/config.toml 中的 sccache。
return process.platform === 'win32' ? '' : '/usr/bin/env';
}
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 数据库名
--port-range <start-end> Linux 用户端口段,手动指定示例 10000-10099;默认自动从 10000-10099 起分配
--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(),
portRangeSpec: String(env.GENARRATIVE_DEV_PORT_RANGE ?? '').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 '--port-range':
options.portRangeSpec = readValue();
explicitOptions.add('portRangeSpec');
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 resolveDevStackStatePath(root = repoRoot) {
return join(root, '.app/dev-stack.json');
}
function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) {
const services = {};
for (const serviceName of SERVICE_NAMES) {
services[serviceName] = buildDevStackServiceSnapshot(
runner,
serviceName,
updatedAt,
);
}
return {
schemaVersion: 1,
command: runner.command ?? 'all',
repoRoot,
database: runner.options.database,
watch: Boolean(runner.options.watch),
updatedAt,
services,
};
}
function buildDevStackServiceSnapshot(runner, serviceName, updatedAt) {
const service = runner.services.get(serviceName);
const runtime = service?.runtime ?? {};
const endpoint = resolveDevStackServiceEndpoint(runner, serviceName);
const isReusedSpacetime =
serviceName === 'spacetime' && runner.state.spacetimeReused;
const runtimeStatus = runtime.status ?? 'idle';
const status =
runtimeStatus === 'idle' && isReusedSpacetime ? 'reused' : runtimeStatus;
const childPid =
service?.child && Number.isInteger(service.child.pid)
? service.child.pid
: null;
return {
status,
pid:
childPid ??
runtime.pid ??
(isReusedSpacetime ? (runner.state.spacetimePid ?? null) : null),
host: runtime.host ?? endpoint.host,
port: runtime.port ?? endpoint.port,
url: runtime.url ?? endpoint.url,
command: runtime.command ?? resolveDevStackServiceCommand(runner, serviceName),
startedAt: runtime.startedAt ?? null,
updatedAt: runtime.updatedAt ?? updatedAt,
exitCode: runtime.exitCode ?? null,
signal: runtime.signal ?? null,
};
}
function resolveDevStackServiceEndpoint(runner, serviceName) {
const {options, state} = runner;
switch (serviceName) {
case 'spacetime':
return {
host: options.spacetimeHost,
port: options.spacetimePort,
url: state.spacetimeServer,
};
case 'api-server':
return {
host: options.apiHost,
port: options.apiPort,
url: state.apiTarget,
};
case 'web':
return {
host: options.webHost,
port: options.webPort,
url: `http://${resolveClientHost(options.webHost)}:${options.webPort}`,
};
case 'admin-web':
return {
host: options.adminWebHost,
port: options.adminWebPort,
url: `http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`,
};
default:
return {
host: null,
port: null,
url: null,
};
}
}
function resolveDevStackServiceCommand(runner, serviceName) {
const {options, state} = runner;
switch (serviceName) {
case 'spacetime':
return state.spacetimeReused
? `reuse spacetime standalone ${state.spacetimeServer}`
: [
'spacetime',
'start',
'--data-dir',
options.spacetimeDataDir,
'--listen-addr',
`${options.spacetimeHost}:${options.spacetimePort}`,
'--non-interactive',
].join(' ');
case 'api-server':
return 'cargo run -p api-server --manifest-path server-rs/Cargo.toml';
case 'web':
return [
'node',
relative(repoRoot, viteCliPath),
`--port=${options.webPort}`,
`--host=${options.webHost}`,
'--strictPort',
].join(' ');
case 'admin-web':
return [
'node',
relative(adminWebDir, viteCliPath),
`--host=${options.adminWebHost}`,
`--port=${options.adminWebPort}`,
'--strictPort',
].join(' ');
default:
return null;
}
}
function requireCommand(command) {
const result = spawnSync(command, ['--version'], {
cwd: repoRoot,
encoding: 'utf8',
shell: process.platform === 'win32',
});
if (result.error) {
throw new Error(`缺少命令: ${command}`);
}
}
function isSccacheRustcWrapper(value) {
const wrapper = String(value ?? '').trim();
if (!wrapper) {
return false;
}
const command = wrapper.split(/[\\/]/).pop()?.toLowerCase();
return command === 'sccache' || command === 'sccache.exe';
}
function buildLocalRustProcessEnv(env, options = {}) {
const mergedEnv = {...env};
const wrappers = [
String(mergedEnv.RUSTC_WRAPPER ?? '').trim(),
String(mergedEnv.CARGO_BUILD_RUSTC_WRAPPER ?? '').trim(),
].filter(Boolean);
const customWrapper = wrappers.find((wrapper) => !isSccacheRustcWrapper(wrapper));
if (customWrapper) {
mergedEnv.RUSTC_WRAPPER = customWrapper;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = customWrapper;
return mergedEnv;
}
const rustcWrapperBypass = resolveLocalDevRustcWrapperBypass();
mergedEnv.RUSTC_WRAPPER = rustcWrapperBypass;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = rustcWrapperBypass;
if (options.log !== false) {
console.warn(
'[dev:rust] 本地 dev 构建绕过项目 sccache wrapper,避免缓存进程异常阻断启动。',
);
}
return mergedEnv;
}
function readWorkspaceSpacetimeVersion() {
const manifestText = readFileSync(manifestPath, 'utf8');
const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec(
manifestText,
);
const version = match?.[1] ?? match?.[2] ?? '';
if (!version) {
throw new Error('无法从 server-rs/Cargo.toml 读取 spacetimedb 版本');
}
return normalizeCargoVersionRequirement(version);
}
function normalizeCargoVersionRequirement(version) {
return version.replace(/^=/u, '');
}
function parseSpacetimeToolVersion(output) {
const match = /spacetimedb tool version\s+([0-9]+\.[0-9]+\.[0-9]+)/u.exec(output);
return match?.[1] ?? '';
}
function readSpacetimeToolVersion() {
const result = spawnSync('spacetime', ['--version'], {
cwd: repoRoot,
encoding: 'utf8',
shell: process.platform === 'win32',
});
if (result.error) {
throw new Error(`读取 spacetime 版本失败: ${result.error.message}`);
}
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
const version = parseSpacetimeToolVersion(output);
if (!version) {
throw new Error(`无法解析 spacetime 版本输出: ${trimPreview(output)}`);
}
return version;
}
function assertSpacetimeToolVersionMatchesWorkspace({
toolVersion,
workspaceVersion,
}) {
if (toolVersion === workspaceVersion) {
return;
}
throw new Error(
[
`本机 spacetime CLI/standalone 版本 ${toolVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
`请先直接升级并切换到锁定版本: spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion},然后重新运行本命令。`,
].join(''),
);
}
function assertReusableSpacetimeProcessVersionMatchesWorkspace({
dataDir,
serverUrl,
}) {
const recordedVersion = readRecordedSpacetimeToolVersion(dataDir);
const workspaceVersion = readWorkspaceSpacetimeVersion();
if (!recordedVersion) {
throw new Error(
[
`检测到正在运行的本地 SpacetimeDB: ${serverUrl},但缺少 SpacetimeDB 版本记录。`,
'为避免复用旧 standalone 导致 procedure 返回值 BSATN 反序列化失败和前端调用超时,请先停止该进程,再重新运行 npm run dev:spacetime。',
].join(''),
);
}
if (recordedVersion === workspaceVersion) {
return;
}
throw new Error(
[
`正在运行的本地 SpacetimeDB standalone 版本 ${recordedVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
'请停止当前 SpacetimeDB 进程,先直接升级并切换到锁定版本: spacetime version install ',
workspaceVersion,
' && spacetime version use ',
workspaceVersion,
',然后重新运行 npm run dev:spacetime。',
].join(''),
);
}
function ensureSpacetimeToolVersionMatchesWorkspace() {
assertSpacetimeToolVersionMatchesWorkspace({
toolVersion: readSpacetimeToolVersion(),
workspaceVersion: readWorkspaceSpacetimeVersion(),
});
}
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, onStateChange = null) {
this.name = name;
this.startFn = startFn;
this.onStateChange = onStateChange;
this.child = null;
this.children = [];
this.logStream = null;
this.stopping = false;
this.restartTimer = null;
this.runtime = {
status: 'idle',
pid: null,
host: null,
port: null,
url: null,
command: null,
startedAt: null,
updatedAt: null,
exitCode: null,
signal: null,
};
}
updateRuntimeState(patch) {
const updatedAt = new Date().toISOString();
this.runtime = {
...this.runtime,
...patch,
updatedAt,
};
this.onStateChange?.();
}
async start() {
if (this.child) {
return;
}
this.updateRuntimeState({status: 'starting', exitCode: null, signal: null});
try {
await this.startFn(this);
} catch (error) {
this.updateRuntimeState({status: 'failed', pid: null});
throw error;
}
}
registerChild(child) {
this.child = child;
this.updateRuntimeState({
status: 'running',
pid: Number.isInteger(child.pid) ? child.pid : null,
exitCode: null,
signal: null,
startedAt: this.runtime.startedAt ?? new Date().toISOString(),
});
child.on('exit', (code, signal) => {
if (this.logStream && !this.logStream.destroyed) {
this.logStream.end();
}
this.child = null;
this.updateRuntimeState({
status: this.stopping ? 'stopped' : code === 0 ? 'stopped' : 'failed',
pid: null,
exitCode: code ?? null,
signal: signal ?? 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;
if (processes.length > 0) {
this.updateRuntimeState({status: 'stopping'});
}
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;
if (processes.length > 0) {
this.updateRuntimeState({status: 'stopped', pid: null});
}
}
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}`,
portRange: null,
portRangeReservation: null,
};
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');
}
if (this.shouldValidateSpacetimeToolVersion(command)) {
ensureSpacetimeToolVersionMatchesWorkspace();
}
await this.prepareLinuxPortRange(command);
await this.tryReuseExistingSpacetime(command);
await this.resolvePorts(command);
this.registerServices();
this.printSummary(command);
this.writeDevStackState();
}
async prepareLinuxPortRange() {
if (process.platform !== 'linux') {
return;
}
const requestedRange = String(this.options.portRangeSpec ?? '').trim();
const allocation = await reserveLinuxDevPortRange({
env: {
...this.baseEnv,
GENARRATIVE_DEV_PORT_RANGE: requestedRange,
},
username: getLinuxDevPortRangeUsername(this.baseEnv),
});
if (!allocation) {
return;
}
this.state.portRangeReservation = allocation;
this.state.portRange = allocation.range;
this.baseEnv.GENARRATIVE_DEV_PORT_RANGE = allocation.range.label;
const mappedPorts = mapDevPortsToPortRange(allocation.range);
if (!mappedPorts) {
return;
}
if (!this.explicitOptions.has('webPort')) {
this.options.webPort = mappedPorts.webPort;
}
if (!this.explicitOptions.has('apiPort')) {
this.options.apiPort = mappedPorts.apiPort;
}
if (!this.explicitOptions.has('spacetimePort')) {
this.options.spacetimePort = mappedPorts.spacetimePort;
}
if (!this.explicitOptions.has('adminWebPort')) {
this.options.adminWebPort = mappedPorts.adminWebPort;
}
this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${this.options.spacetimePort}`;
this.state.apiTarget = `http://${this.state.apiTargetHost}:${this.options.apiPort}`;
}
shouldValidateSpacetimeToolVersion(command) {
if (command === 'spacetime') {
return true;
}
if (command === 'all') {
return !this.options.skipSpacetime || !this.options.skipPublish;
}
if (command === 'api-server') {
return isLoopbackSpacetimeServer(this.state.spacetimeServer);
}
return false;
}
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) {
if (!this.isCandidateSpacetimeWithinAssignedRange(candidate)) {
continue;
}
const pingUrl = buildUrl(candidate, '/v1/ping');
if (!pingUrl || !(await isHttpReady(pingUrl))) {
continue;
}
assertReusableSpacetimeProcessVersionMatchesWorkspace({
dataDir: this.options.spacetimeDataDir,
serverUrl: candidate,
});
const port = safeUrlPort(candidate);
if (Number.isInteger(port) && port > 0) {
this.options.spacetimePort = port;
}
this.state.spacetimeServer = candidate;
this.state.spacetimeReused = true;
this.state.spacetimePid = Number.isInteger(pidState.pid)
? pidState.pid
: null;
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(', ')}`,
);
}
isCandidateSpacetimeWithinAssignedRange(candidateUrl) {
const range = this.state.portRange;
if (!range) {
return true;
}
try {
const url = new URL(candidateUrl);
const port = Number(url.port);
return Number.isInteger(port) && port >= range.start && port <= range.end;
} catch {
return false;
}
}
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,
portRange: this.state.portRange,
};
}
}
if (command === 'all' || command === 'api-server') {
portConfig.api = {
host: options.apiHost,
preferredPort: options.apiPort,
portRange: this.state.portRange,
};
}
if (command === 'all' || command === 'web') {
portConfig.web = {
host: options.webHost,
preferredPort: options.webPort,
portRange: this.state.portRange,
};
}
if (command === 'all' || command === 'admin-web') {
portConfig.adminWeb = {
host: options.adminWebHost,
preferredPort: options.adminWebPort,
portRange: this.state.portRange,
};
}
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() {
const onStateChange = () => this.writeDevStackState();
this.services.set(
'spacetime',
new DevService(
'spacetime',
async (service) => this.startSpacetime(service),
onStateChange,
),
);
this.services.set(
'api-server',
new DevService(
'api-server',
async (service) => this.startApiServer(service),
onStateChange,
),
);
this.services.set(
'web',
new DevService('web', async (service) => this.startWeb(service), onStateChange),
);
this.services.set(
'admin-web',
new DevService(
'admin-web',
async (service) => this.startAdminWeb(service),
onStateChange,
),
);
if (this.state.spacetimeReused) {
const spacetimeService = this.services.get('spacetime');
const endpoint = resolveDevStackServiceEndpoint(this, 'spacetime');
spacetimeService?.updateRuntimeState({
status: 'reused',
pid: this.state.spacetimePid ?? null,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
command: resolveDevStackServiceCommand(this, 'spacetime'),
});
}
}
printSummary(command) {
const {options, state} = this;
console.log(`[dev] repo: ${repoRoot}`);
console.log(`[dev] command: ${command}`);
console.log(`[dev] watch: ${options.watch ? 'on' : 'off'}`);
if (state.portRange) {
const owner =
state.portRangeReservation?.username ||
getLinuxDevPortRangeUsername(this.baseEnv);
console.log(`[dev] port-range: ${state.portRange.label} (${owner})`);
console.log(
`[dev] port-range-registry: ${getLinuxDevPortRangeRegistryPaths(this.baseEnv).registryPath}`,
);
}
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}`);
}
writeDevStackState() {
try {
ensureParentDir(devStackStatePath);
const snapshot = buildDevStackSnapshot(this);
writeFileSync(devStackStatePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
} catch (error) {
console.warn(`[dev] 写入 ${devStackStatePath} 失败: ${error.message}`);
}
}
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) {
try {
await this.publishSpacetimeModule();
} catch (error) {
if (isSpacetimePublishPermissionError(error)) {
console.warn(
`[dev:spacetime] 本地发布被当前 identity 拒绝,保留已启动的 standalone: ${error.message}`,
);
} else {
throw error;
}
}
}
}
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;
const spacetimeToolVersion = readSpacetimeToolVersion();
assertSpacetimeToolVersionMatchesWorkspace({
toolVersion: spacetimeToolVersion,
workspaceVersion: readWorkspaceSpacetimeVersion(),
});
recordSpacetimeToolVersion(
options.spacetimeDataDir,
spacetimeToolVersion,
);
console.log(`[dev:spacetime] log: ${logFile}`);
service.updateRuntimeState({
status: 'starting',
pid: null,
host: options.spacetimeHost,
port: options.spacetimePort,
url: this.state.spacetimeServer,
command: resolveDevStackServiceCommand(this, 'spacetime'),
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
});
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);
this.services.get('spacetime')?.updateRuntimeState({
host: this.options.spacetimeHost,
port,
url: 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 = buildLocalRustProcessEnv(this.baseEnv);
this.prepareMigrationBootstrapSecret(env);
const args = buildSpacetimePublishArgs({
database: this.options.database,
preserveDatabase: this.options.preserveDatabase,
server: this.state.spacetimeServer,
});
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}`);
}
async ensureApiServerSpacetimeToken() {
const existingToken = String(this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '').trim();
if (existingToken && shouldTrustExistingSpacetimeToken(existingToken, this.state.spacetimeServer)) {
return;
}
const identityUrl = buildUrl(this.state.spacetimeServer, '/v1/identity');
if (!identityUrl) {
throw new Error(`无法构造 SpacetimeDB identity 地址: ${this.state.spacetimeServer}`);
}
const response = await fetchSpacetimeIdentity(identityUrl);
this.baseEnv.GENARRATIVE_SPACETIME_TOKEN = response.token;
this.state.spacetimeIdentity = response.identity;
console.log(
`[dev:spacetime] 已创建本地 Web identity: ${response.identity.slice(0, 12)}...`,
);
}
async startApiServer(service) {
await this.ensureApiServerSpacetimeToken();
const mergedEnv = buildApiServerProcessEnv({
baseEnv: buildLocalRustProcessEnv(this.baseEnv),
options: this.options,
state: this.state,
});
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}`,
);
service.updateRuntimeState({
status: 'starting',
pid: null,
host: this.options.apiHost,
port: this.options.apiPort,
url: this.state.apiTarget,
command: resolveDevStackServiceCommand(this, 'api-server'),
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
});
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 endpoint = resolveDevStackServiceEndpoint(this, 'web');
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}`);
service.updateRuntimeState({
status: 'starting',
pid: null,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
command: resolveDevStackServiceCommand(this, 'web'),
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
});
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 endpoint = resolveDevStackServiceEndpoint(this, 'admin-web');
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}`);
service.updateRuntimeState({
status: 'starting',
pid: null,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
command: resolveDevStackServiceCommand(this, 'admin-web'),
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
});
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 readRecordedSpacetimeToolVersion(dataDir) {
const versionPath = resolve(dataDir, 'dev-spacetime-tool-version');
if (!existsSync(versionPath)) {
return '';
}
return readFileSync(versionPath, 'utf8').split(/\r?\n/u)[0]?.trim() ?? '';
}
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 recordSpacetimeToolVersion(dataDir, version) {
const versionPath = resolve(dataDir, 'dev-spacetime-tool-version');
ensureParentDir(versionPath);
try {
writeFileSync(versionPath, `${version}\n`, 'utf8');
} catch (error) {
console.warn(`[dev:spacetime] 写入版本记录失败 ${versionPath}: ${error.message}`);
}
}
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);
}
}
async function fetchSpacetimeIdentity(url) {
let response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
} catch (error) {
throw new Error(
`SpacetimeDB identity 请求失败: ${url}; ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const text = await response.text();
if (!response.ok) {
throw new Error(`SpacetimeDB identity HTTP ${response.status}: ${trimPreview(text)}`);
}
let payload;
try {
payload = JSON.parse(text);
} catch (error) {
throw new Error(
`SpacetimeDB identity 响应不是合法 JSON: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const identity =
payload.identity ?? payload.Identity ?? payload.identity_hex ?? payload.identityHex;
const token = payload.token ?? payload.Token;
if (typeof identity !== 'string' || typeof token !== 'string') {
throw new Error(`SpacetimeDB identity 响应缺少 identity/token: ${trimPreview(text)}`);
}
return {identity, token};
}
function shouldTrustExistingSpacetimeToken(existingToken, serverUrl) {
const shellToken = String(process.env.GENARRATIVE_SPACETIME_TOKEN ?? '').trim();
if (shellToken && shellToken === existingToken) {
return true;
}
return !isLoopbackSpacetimeServer(serverUrl);
}
function isLoopbackSpacetimeServer(serverUrl) {
try {
const url = new URL(serverUrl);
return ['127.0.0.1', 'localhost', '::1'].includes(url.hostname);
} catch {
return false;
}
}
function trimPreview(text, maxLength = 300) {
const normalized = String(text ?? '').replace(/\s+/gu, ' ').trim();
return normalized.length > maxLength
? `${normalized.slice(0, maxLength)}...`
: normalized;
}
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, '/');
}
function normalizeDirectExecutionPath(path) {
return normalizePath(path).replace(/^\/([A-Za-z]:\/)/u, '$1');
}
function safeRealpath(pathValue) {
try {
return realpathSync(pathValue);
} catch {
return resolve(pathValue);
}
}
function isDirectModuleExecution(argv1, moduleUrl, resolvePath = safeRealpath) {
if (!argv1) {
return false;
}
try {
return (
normalizeDirectExecutionPath(resolvePath(argv1)) ===
normalizeDirectExecutionPath(resolvePath(fileURLToPath(moduleUrl)))
);
} catch {
return (
normalizeDirectExecutionPath(resolve(argv1)) ===
normalizeDirectExecutionPath(fileURLToPath(moduleUrl))
);
}
}
function buildSpacetimePublishArgs({database, server, preserveDatabase}) {
const args = [
'publish',
database,
'--server',
server,
'--module-path',
modulePath,
'--build-options=--debug',
];
if (!preserveDatabase) {
args.push('-c=on-conflict');
}
args.push('--yes', '--no-config');
return args;
}
function isSpacetimePublishPermissionError(error) {
const message = String(error?.message ?? error ?? '');
return (
message.includes('Pre-publish check failed with status 403 Forbidden') ||
message.includes('not authorized to perform action on database') ||
message.includes('is not authorized to perform action on database')
);
}
function buildApiServerProcessEnv({baseEnv, options, state}) {
return {
...baseEnv,
// 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true',
GENARRATIVE_API_HOST: options.apiHost,
GENARRATIVE_API_PORT: String(options.apiPort),
GENARRATIVE_API_LOG: options.apiLog,
GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer,
GENARRATIVE_SPACETIME_DATABASE: options.database,
GENARRATIVE_SPACETIME_TOKEN: baseEnv.GENARRATIVE_SPACETIME_TOKEN || '',
};
}
export {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildLocalRustProcessEnv,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
DevRunner,
isDirectModuleExecution,
isSpacetimePublishPermissionError,
normalizeCargoVersionRequirement,
parseArgs,
parseSpacetimeToolVersion,
resolveDevStackStatePath,
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 (isDirectModuleExecution(process.argv[1], import.meta.url)) {
void main();
}