5a9bc10bb6
将本地发布身份改为按SpacetimeDB数据目录持久化并兼容迁移旧记录 为开发栈状态补充专用数据目录并拒绝复用旧共享后端 统一处理子进程启动错误、启动期信号和进程组清理 补充身份、后端复用与子进程生命周期回归测试及文档
3154 lines
86 KiB
JavaScript
3154 lines
86 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import {
|
|
chmodSync,
|
|
createWriteStream,
|
|
existsSync,
|
|
linkSync,
|
|
lstatSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
realpathSync,
|
|
renameSync,
|
|
rmSync,
|
|
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');
|
|
const BOOTSTRAP_SECRET_PATTERN = /^[0-9a-fA-F]{64}$/u;
|
|
|
|
function resolveLocalGenarrativeFfmpeg(
|
|
env = process.env,
|
|
platform = process.platform,
|
|
) {
|
|
if (platform !== 'win32') {
|
|
return null;
|
|
}
|
|
|
|
const localAppData =
|
|
String(env.LOCALAPPDATA ?? '').trim() ||
|
|
(String(env.USERPROFILE ?? '').trim()
|
|
? join(String(env.USERPROFILE).trim(), 'AppData', 'Local')
|
|
: '');
|
|
if (!localAppData) {
|
|
return null;
|
|
}
|
|
|
|
const binDir = join(localAppData, 'Genarrative', 'ffmpeg', 'bin');
|
|
const ffmpegPath = join(binDir, 'ffmpeg.exe');
|
|
const ffprobePath = join(binDir, 'ffprobe.exe');
|
|
if (!existsSync(ffmpegPath) || !existsSync(ffprobePath)) {
|
|
return null;
|
|
}
|
|
|
|
return { binDir, ffmpegPath, ffprobePath };
|
|
}
|
|
|
|
function resolvePathEnvKey(env, platform = process.platform) {
|
|
return (
|
|
Object.keys(env).find((key) => key.toLowerCase() === 'path') ||
|
|
(platform === 'win32' ? 'Path' : 'PATH')
|
|
);
|
|
}
|
|
|
|
function prependEnvPathEntry(env, entry, platform = process.platform) {
|
|
const pathKey = resolvePathEnvKey(env, platform);
|
|
const separator = platform === 'win32' ? ';' : ':';
|
|
const currentEntries = String(env[pathKey] ?? '')
|
|
.split(separator)
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
const normalize = (value) =>
|
|
platform === 'win32' ? value.toLowerCase() : value;
|
|
if (currentEntries.some((value) => normalize(value) === normalize(entry))) {
|
|
return;
|
|
}
|
|
|
|
env[pathKey] = [entry, ...currentEntries].join(separator);
|
|
}
|
|
|
|
function setDefaultEnvValue(env, key, value) {
|
|
if (!String(env[key] ?? '').trim()) {
|
|
env[key] = value;
|
|
}
|
|
}
|
|
|
|
function applyLocalFfmpegEnv(env, platform = process.platform) {
|
|
const localFfmpeg = resolveLocalGenarrativeFfmpeg(env, platform);
|
|
if (!localFfmpeg) {
|
|
return env;
|
|
}
|
|
|
|
// 本地 dev 进程常在安装 FFmpeg 前已经启动;这里用绝对路径兜底,避免只依赖旧 PATH。
|
|
prependEnvPathEntry(env, localFfmpeg.binDir, platform);
|
|
const ffmpegPath =
|
|
String(env.CHARACTER_ANIMATION_FFMPEG_PATH ?? '').trim() ||
|
|
String(env.GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH ?? '').trim() ||
|
|
localFfmpeg.ffmpegPath;
|
|
const ffprobePath =
|
|
String(env.CHARACTER_ANIMATION_FFPROBE_PATH ?? '').trim() ||
|
|
String(env.GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH ?? '').trim() ||
|
|
localFfmpeg.ffprobePath;
|
|
|
|
setDefaultEnvValue(env, 'CHARACTER_ANIMATION_FFMPEG_PATH', ffmpegPath);
|
|
setDefaultEnvValue(
|
|
env,
|
|
'GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH',
|
|
ffmpegPath,
|
|
);
|
|
setDefaultEnvValue(env, 'CHARACTER_ANIMATION_FFPROBE_PATH', ffprobePath);
|
|
setDefaultEnvValue(
|
|
env,
|
|
'GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH',
|
|
ffprobePath,
|
|
);
|
|
return env;
|
|
}
|
|
|
|
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'],
|
|
['backend', 'backend'],
|
|
['all', 'all'],
|
|
]);
|
|
|
|
function usage() {
|
|
console.log(`用法:
|
|
npm run dev [-- --watch] [-- --api-port 8090]
|
|
npm run dev backend [-- --watch]
|
|
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,
|
|
strictWebPort: false,
|
|
};
|
|
|
|
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 '--strict-web-port':
|
|
options.strictWebPort = true;
|
|
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' || name === 'backend' || 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: 2,
|
|
command: runner.command ?? 'all',
|
|
repoRoot,
|
|
database: runner.options.database,
|
|
spacetimeDataDir: resolve(runner.options.spacetimeDataDir),
|
|
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' ||
|
|
command === 'backend'
|
|
) {
|
|
requiredFiles.push([manifestPath, 'server-rs/Cargo.toml']);
|
|
}
|
|
|
|
if (command === 'spacetime' || command === 'all' || command === 'backend') {
|
|
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 parseProcessEnvBlock(rawEnv) {
|
|
return String(rawEnv ?? '')
|
|
.split('\0')
|
|
.filter(Boolean)
|
|
.reduce((env, entry) => {
|
|
const separatorIndex = entry.indexOf('=');
|
|
if (separatorIndex <= 0) {
|
|
return env;
|
|
}
|
|
env[entry.slice(0, separatorIndex)] = entry.slice(separatorIndex + 1);
|
|
return env;
|
|
}, {});
|
|
}
|
|
|
|
function normalizeProcessLinkTarget(path) {
|
|
return String(path ?? '').replace(/ \(deleted\)$/u, '');
|
|
}
|
|
|
|
function isSamePathForDevProcess(left, right) {
|
|
const normalizedLeft = normalizePath(normalizeProcessLinkTarget(left));
|
|
const normalizedRight = normalizePath(normalizeProcessLinkTarget(right));
|
|
return normalizedLeft === normalizedRight;
|
|
}
|
|
|
|
function isStaleExternalGenerationWorkerProcess({
|
|
cwd,
|
|
env,
|
|
exe,
|
|
expectedDatabase,
|
|
expectedExePath,
|
|
expectedRepoRoot,
|
|
expectedSpacetimeServer,
|
|
pid,
|
|
}) {
|
|
if (!Number.isInteger(pid) || pid === process.pid) {
|
|
return false;
|
|
}
|
|
if (env.GENARRATIVE_PROCESS_ROLE !== 'external-generation-worker') {
|
|
return false;
|
|
}
|
|
if (
|
|
String(env.GENARRATIVE_SPACETIME_SERVER_URL ?? '') !==
|
|
expectedSpacetimeServer
|
|
) {
|
|
return false;
|
|
}
|
|
if (String(env.GENARRATIVE_SPACETIME_DATABASE ?? '') !== expectedDatabase) {
|
|
return false;
|
|
}
|
|
if (!isSamePathForDevProcess(cwd, expectedRepoRoot)) {
|
|
return false;
|
|
}
|
|
return isSamePathForDevProcess(exe, expectedExePath);
|
|
}
|
|
|
|
function readLinuxApiServerProcessSnapshot(pid) {
|
|
try {
|
|
return {
|
|
cwd: readlinkSync(`/proc/${pid}/cwd`),
|
|
env: parseProcessEnvBlock(readFileSync(`/proc/${pid}/environ`, 'utf8')),
|
|
exe: readlinkSync(`/proc/${pid}/exe`),
|
|
pid,
|
|
};
|
|
} catch (error) {
|
|
if (
|
|
error?.code === 'ENOENT' ||
|
|
error?.code === 'EACCES' ||
|
|
error?.code === 'EPERM' ||
|
|
error?.code === 'ESRCH'
|
|
) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function listLinuxProcessIds(procDir = '/proc') {
|
|
try {
|
|
return readdirSync(procDir)
|
|
.map((name) => Number.parseInt(name, 10))
|
|
.filter(Number.isInteger);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function isLinuxProcessAlive(pid) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function waitForLinuxProcessExit(pid, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (!isLinuxProcessAlive(pid)) {
|
|
return true;
|
|
}
|
|
await sleep(100);
|
|
}
|
|
return !isLinuxProcessAlive(pid);
|
|
}
|
|
|
|
async function stopLinuxProcessId(pid, label) {
|
|
try {
|
|
process.kill(pid, 'SIGTERM');
|
|
} catch (error) {
|
|
if (error?.code === 'ESRCH') {
|
|
return true;
|
|
}
|
|
console.warn(`[dev:${label}] 停止旧进程失败 pid=${pid}: ${error.message}`);
|
|
return false;
|
|
}
|
|
|
|
if (await waitForLinuxProcessExit(pid, 5000)) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
process.kill(pid, 'SIGKILL');
|
|
} catch (error) {
|
|
if (error?.code === 'ESRCH') {
|
|
return true;
|
|
}
|
|
console.warn(
|
|
`[dev:${label}] 强制停止旧进程失败 pid=${pid}: ${error.message}`,
|
|
);
|
|
return false;
|
|
}
|
|
return waitForLinuxProcessExit(pid, 1000);
|
|
}
|
|
|
|
async function stopStaleLocalExternalGenerationWorkers({
|
|
database,
|
|
logStream = null,
|
|
repoRootPath = repoRoot,
|
|
processRole,
|
|
spacetimeServer,
|
|
}) {
|
|
if (process.platform !== 'linux') {
|
|
return [];
|
|
}
|
|
if (processRole !== 'all') {
|
|
return [];
|
|
}
|
|
|
|
const expectedExePath = resolve(
|
|
repoRootPath,
|
|
'server-rs/target/debug/api-server',
|
|
);
|
|
const stopped = [];
|
|
for (const pid of listLinuxProcessIds()) {
|
|
const snapshot = readLinuxApiServerProcessSnapshot(pid);
|
|
if (
|
|
!snapshot ||
|
|
!isStaleExternalGenerationWorkerProcess({
|
|
...snapshot,
|
|
expectedDatabase: database,
|
|
expectedExePath,
|
|
expectedRepoRoot: repoRootPath,
|
|
expectedSpacetimeServer: spacetimeServer,
|
|
})
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const label = snapshot.env.GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID
|
|
? `${pid}(${snapshot.env.GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID})`
|
|
: String(pid);
|
|
if (await stopLinuxProcessId(pid, 'api-server')) {
|
|
stopped.push(label);
|
|
}
|
|
}
|
|
|
|
if (stopped.length > 0) {
|
|
const line = `[dev:api-server] 已停止同库旧 external-generation-worker 进程: ${stopped.join(', ')}\n`;
|
|
process.stdout.write(line);
|
|
logStream?.write(line);
|
|
}
|
|
return stopped;
|
|
}
|
|
|
|
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.spacetimeApiToken = String(
|
|
this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '',
|
|
).trim();
|
|
delete this.baseEnv.GENARRATIVE_SPACETIME_TOKEN;
|
|
this.runtimeServiceBootstrapSecret = String(
|
|
this.baseEnv.GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET ?? '',
|
|
).trim();
|
|
this.explicitRuntimeServiceBootstrapSecret =
|
|
this.runtimeServiceBootstrapSecret;
|
|
delete this.baseEnv.GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET;
|
|
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' ||
|
|
command === 'backend'
|
|
) {
|
|
requireCommand('cargo');
|
|
}
|
|
if (
|
|
command === 'spacetime' ||
|
|
((command === 'all' || command === 'backend') &&
|
|
(!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 === 'backend') {
|
|
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 !== 'backend' &&
|
|
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 = {};
|
|
const portRangeFor = (optionName) =>
|
|
this.explicitOptions.has(optionName) ? null : this.state.portRange;
|
|
|
|
if (command === 'all' || command === 'backend' || command === 'spacetime') {
|
|
if (!options.skipSpacetime && !this.state.spacetimeReused) {
|
|
portConfig.spacetime = {
|
|
host: options.spacetimeHost,
|
|
preferredPort: options.spacetimePort,
|
|
portRange: portRangeFor('spacetimePort'),
|
|
};
|
|
}
|
|
}
|
|
|
|
if (
|
|
command === 'all' ||
|
|
command === 'backend' ||
|
|
command === 'api-server'
|
|
) {
|
|
portConfig.api = {
|
|
host: options.apiHost,
|
|
preferredPort: options.apiPort,
|
|
portRange: portRangeFor('apiPort'),
|
|
};
|
|
}
|
|
|
|
if (command === 'all' || command === 'web') {
|
|
portConfig.web = {
|
|
host: options.webHost,
|
|
preferredPort: options.webPort,
|
|
portRange: portRangeFor('webPort'),
|
|
strict: options.strictWebPort,
|
|
};
|
|
}
|
|
|
|
if (command === 'all' || command === 'admin-web') {
|
|
portConfig.adminWeb = {
|
|
host: options.adminWebHost,
|
|
preferredPort: options.adminWebPort,
|
|
portRange: portRangeFor('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 === 'backend' || 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 === 'backend') {
|
|
await this.startSpacetimeForFullStack();
|
|
await this.services.get('api-server').start();
|
|
await this.waitForApiServer();
|
|
this.startWatchers(['spacetime', 'api-server']);
|
|
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)) {
|
|
throw new Error(
|
|
`本地数据库不属于当前隔离 identity,已停止启动以避免 API 使用旧 schema 后持续重试订阅。请改用独立本地数据目录,或在确认无需保留旧开发数据后重建该目录。详情: ${error.message}`,
|
|
);
|
|
}
|
|
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 cliConfigPath = await this.prepareLocalSpacetimeCliIdentity(env);
|
|
|
|
const args = buildSpacetimePublishArgs({
|
|
cliConfigPath,
|
|
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',
|
|
});
|
|
}
|
|
|
|
async prepareLocalSpacetimeCliIdentity(env) {
|
|
if (!isLoopbackSpacetimeServer(this.state.spacetimeServer)) {
|
|
return '';
|
|
}
|
|
|
|
await this.ensureApiServerSpacetimeToken();
|
|
const cliConfigPath = resolve(
|
|
this.options.spacetimeDataDir,
|
|
'dev-cli',
|
|
'cli.toml',
|
|
);
|
|
ensureParentDir(cliConfigPath);
|
|
if (
|
|
existsSync(cliConfigPath) &&
|
|
resolveCurrentSpacetimeCliToken(cliConfigPath) === this.spacetimeApiToken
|
|
) {
|
|
chmodSync(cliConfigPath, 0o600);
|
|
console.log('[dev:spacetime] 已复用隔离的本地发布 identity');
|
|
return cliConfigPath;
|
|
}
|
|
await runForeground(
|
|
'spacetime',
|
|
[
|
|
'--config-path',
|
|
cliConfigPath,
|
|
'login',
|
|
'--token',
|
|
this.spacetimeApiToken,
|
|
],
|
|
{
|
|
cwd: serverRsDir,
|
|
env,
|
|
label: 'spacetime-login',
|
|
},
|
|
);
|
|
if (existsSync(cliConfigPath)) {
|
|
chmodSync(cliConfigPath, 0o600);
|
|
}
|
|
console.log('[dev:spacetime] 已配置隔离的本地发布 identity');
|
|
return cliConfigPath;
|
|
}
|
|
|
|
prepareMigrationBootstrapSecret(env) {
|
|
let runtimeServiceBootstrapSecret = '';
|
|
switch (this.options.migrationBootstrapSecretMode) {
|
|
case 'auto':
|
|
runtimeServiceBootstrapSecret =
|
|
this.resolveRuntimeServiceBootstrapSecret({ allowGenerate: true });
|
|
this.options.migrationBootstrapSecret = runtimeServiceBootstrapSecret;
|
|
break;
|
|
case 'manual':
|
|
if (
|
|
!BOOTSTRAP_SECRET_PATTERN.test(this.options.migrationBootstrapSecret)
|
|
) {
|
|
throw new Error('迁移引导密钥必须是 64 个十六进制字符');
|
|
}
|
|
runtimeServiceBootstrapSecret =
|
|
writeLocalSpacetimeRuntimeServiceBootstrapSecret({
|
|
dataDir: this.options.spacetimeDataDir,
|
|
serverUrl: this.state.spacetimeServer,
|
|
database: this.options.database,
|
|
secret: this.options.migrationBootstrapSecret,
|
|
replace: true,
|
|
});
|
|
break;
|
|
case 'disabled':
|
|
delete env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET;
|
|
delete env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256;
|
|
this.runtimeServiceBootstrapSecret = '';
|
|
console.log('[dev:spacetime] 未启用迁移引导密钥');
|
|
return;
|
|
default:
|
|
throw new Error(
|
|
`未知迁移引导密钥模式: ${this.options.migrationBootstrapSecretMode}`,
|
|
);
|
|
}
|
|
|
|
delete env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET;
|
|
env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256 = createHash(
|
|
'sha256',
|
|
)
|
|
.update(runtimeServiceBootstrapSecret)
|
|
.digest('hex');
|
|
this.runtimeServiceBootstrapSecret = runtimeServiceBootstrapSecret;
|
|
console.log('[dev:spacetime] 已启用迁移引导密钥');
|
|
}
|
|
|
|
resolveRuntimeServiceBootstrapSecret({ allowGenerate = false } = {}) {
|
|
const explicitSecret = this.explicitRuntimeServiceBootstrapSecret;
|
|
if (explicitSecret) {
|
|
if (!BOOTSTRAP_SECRET_PATTERN.test(explicitSecret)) {
|
|
throw new Error('运行服务 bootstrap secret 必须是 64 个十六进制字符');
|
|
}
|
|
return writeLocalSpacetimeRuntimeServiceBootstrapSecret({
|
|
dataDir: this.options.spacetimeDataDir,
|
|
serverUrl: this.state.spacetimeServer,
|
|
database: this.options.database,
|
|
secret: explicitSecret,
|
|
replace: true,
|
|
});
|
|
}
|
|
|
|
const persistedSecret = readLocalSpacetimeRuntimeServiceBootstrapSecret({
|
|
dataDir: this.options.spacetimeDataDir,
|
|
serverUrl: this.state.spacetimeServer,
|
|
database: this.options.database,
|
|
});
|
|
if (persistedSecret) {
|
|
console.log('[dev:spacetime] 已复用本地运行服务 bootstrap secret');
|
|
return persistedSecret;
|
|
}
|
|
|
|
if (!allowGenerate) {
|
|
return '';
|
|
}
|
|
|
|
const secret = writeLocalSpacetimeRuntimeServiceBootstrapSecret({
|
|
dataDir: this.options.spacetimeDataDir,
|
|
serverUrl: this.state.spacetimeServer,
|
|
database: this.options.database,
|
|
secret: randomHex(32),
|
|
});
|
|
console.log('[dev:spacetime] 已创建本地运行服务 bootstrap secret');
|
|
return secret;
|
|
}
|
|
|
|
async ensureApiServerSpacetimeToken() {
|
|
const existingToken = this.spacetimeApiToken;
|
|
if (
|
|
existingToken &&
|
|
shouldTrustExistingSpacetimeToken(
|
|
existingToken,
|
|
this.state.spacetimeServer,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const persistedIdentity = readLocalSpacetimeApiIdentity({
|
|
dataDir: this.options.spacetimeDataDir,
|
|
serverUrl: this.state.spacetimeServer,
|
|
});
|
|
if (persistedIdentity) {
|
|
this.spacetimeApiToken = persistedIdentity.token;
|
|
this.state.spacetimeIdentity = persistedIdentity.identity;
|
|
console.log(
|
|
`[dev:spacetime] 已复用本地 API identity: ${persistedIdentity.identity.slice(0, 12)}...`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const identityUrl = buildUrl(this.state.spacetimeServer, '/v1/identity');
|
|
if (!identityUrl) {
|
|
throw new Error(
|
|
`无法构造 SpacetimeDB identity 地址: ${this.state.spacetimeServer}`,
|
|
);
|
|
}
|
|
|
|
const response = await fetchSpacetimeIdentity(identityUrl);
|
|
if (isLoopbackSpacetimeServer(this.state.spacetimeServer)) {
|
|
writeLocalSpacetimeApiIdentity({
|
|
dataDir: this.options.spacetimeDataDir,
|
|
serverUrl: this.state.spacetimeServer,
|
|
identity: response.identity,
|
|
token: response.token,
|
|
});
|
|
}
|
|
this.spacetimeApiToken = response.token;
|
|
this.state.spacetimeIdentity = response.identity;
|
|
console.log(
|
|
`[dev:spacetime] 已创建本地 API identity: ${response.identity.slice(0, 12)}...`,
|
|
);
|
|
}
|
|
|
|
async startApiServer(service) {
|
|
await this.ensureApiServerSpacetimeToken();
|
|
if (
|
|
!this.runtimeServiceBootstrapSecret &&
|
|
this.options.migrationBootstrapSecretMode !== 'disabled'
|
|
) {
|
|
this.runtimeServiceBootstrapSecret =
|
|
this.resolveRuntimeServiceBootstrapSecret();
|
|
}
|
|
|
|
const mergedEnv = buildApiServerProcessEnv({
|
|
baseEnv: {
|
|
...buildLocalRustProcessEnv(this.baseEnv),
|
|
GENARRATIVE_SPACETIME_TOKEN: this.spacetimeApiToken,
|
|
},
|
|
options: this.options,
|
|
state: this.state,
|
|
});
|
|
if (this.runtimeServiceBootstrapSecret) {
|
|
mergedEnv.GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET =
|
|
this.runtimeServiceBootstrapSecret;
|
|
}
|
|
|
|
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);
|
|
await stopStaleLocalExternalGenerationWorkers({
|
|
database: this.options.database,
|
|
logStream,
|
|
processRole: mergedEnv.GENARRATIVE_PROCESS_ROLE,
|
|
spacetimeServer: this.state.spacetimeServer,
|
|
});
|
|
|
|
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 = buildFrontendProcessEnv(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 = buildFrontendProcessEnv(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 normalizeSpacetimeServerForIdentity(serverUrl) {
|
|
const url = new URL(serverUrl);
|
|
url.hash = '';
|
|
url.search = '';
|
|
url.pathname = url.pathname.replace(/\/+$/u, '') || '/';
|
|
return url.href.replace(/\/$/u, '');
|
|
}
|
|
|
|
function resolveLocalSpacetimeApiIdentityPath(dataDir) {
|
|
return resolve(dataDir, 'dev-api-identities', 'local-node.json');
|
|
}
|
|
|
|
function readLocalSpacetimeApiIdentityRecord(identityPath, expected = {}) {
|
|
const stat = lstatSync(identityPath);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
throw new Error('记录不是普通文件');
|
|
}
|
|
chmodSync(identityPath, 0o600);
|
|
const payload = JSON.parse(readFileSync(identityPath, 'utf8'));
|
|
const identity =
|
|
typeof payload.identity === 'string' ? payload.identity.trim() : '';
|
|
const token = typeof payload.token === 'string' ? payload.token.trim() : '';
|
|
if (!identity || !token) {
|
|
throw new Error('记录缺少 identity 或 token');
|
|
}
|
|
|
|
if (payload.schemaVersion === 2 && payload.scope === 'local-data-dir') {
|
|
return { identity, token };
|
|
}
|
|
if (
|
|
expected.allowLegacy &&
|
|
payload.schemaVersion === 1 &&
|
|
typeof payload.server === 'string' &&
|
|
isLoopbackSpacetimeServer(payload.server)
|
|
) {
|
|
return { identity, token };
|
|
}
|
|
throw new Error('记录格式或 data dir 作用域不匹配');
|
|
}
|
|
|
|
function migrateLegacyLocalSpacetimeApiIdentity(dataDir) {
|
|
const identityDir = resolve(dataDir, 'dev-api-identities');
|
|
if (!existsSync(identityDir)) {
|
|
return null;
|
|
}
|
|
|
|
const candidates = [];
|
|
for (const entry of readdirSync(identityDir, { withFileTypes: true })) {
|
|
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
|
continue;
|
|
}
|
|
const candidatePath = resolve(identityDir, entry.name);
|
|
if (candidatePath === resolveLocalSpacetimeApiIdentityPath(dataDir)) {
|
|
continue;
|
|
}
|
|
try {
|
|
candidates.push(
|
|
readLocalSpacetimeApiIdentityRecord(candidatePath, {
|
|
allowLegacy: true,
|
|
}),
|
|
);
|
|
} catch {
|
|
// 无效或非本地旧记录不参与迁移。
|
|
}
|
|
}
|
|
|
|
const uniqueCandidates = new Map(
|
|
candidates.map((candidate) => [
|
|
`${candidate.identity}\n${candidate.token}`,
|
|
candidate,
|
|
]),
|
|
);
|
|
if (uniqueCandidates.size === 0) {
|
|
return null;
|
|
}
|
|
if (uniqueCandidates.size > 1) {
|
|
throw new Error(
|
|
'同一 SpacetimeDB data dir 下发现多个旧 API identity,无法安全判断数据库 owner;请保留正确 owner 记录后重试',
|
|
);
|
|
}
|
|
|
|
const [identity] = uniqueCandidates.values();
|
|
writeLocalSpacetimeApiIdentity({ dataDir, ...identity });
|
|
console.log(
|
|
'[dev:spacetime] 已将旧端口作用域 API identity 迁移到 data dir 作用域',
|
|
);
|
|
return identity;
|
|
}
|
|
|
|
function resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath(
|
|
dataDir,
|
|
serverUrl,
|
|
database,
|
|
) {
|
|
const normalizedServer = normalizeSpacetimeServerForIdentity(serverUrl);
|
|
const normalizedDatabase = String(database ?? '').trim();
|
|
if (!normalizedDatabase) {
|
|
throw new Error('本地运行服务 bootstrap secret 缺少数据库名');
|
|
}
|
|
const scopeKey = createHash('sha256')
|
|
.update(`${normalizedServer}\n${normalizedDatabase}`)
|
|
.digest('hex');
|
|
return resolve(
|
|
dataDir,
|
|
'dev-runtime-service-bootstrap-secrets',
|
|
`${scopeKey}.json`,
|
|
);
|
|
}
|
|
|
|
function readLocalSpacetimeRuntimeServiceBootstrapSecret({
|
|
dataDir,
|
|
serverUrl,
|
|
database,
|
|
}) {
|
|
if (!isLoopbackSpacetimeServer(serverUrl)) {
|
|
return '';
|
|
}
|
|
|
|
const normalizedServer = normalizeSpacetimeServerForIdentity(serverUrl);
|
|
const normalizedDatabase = String(database ?? '').trim();
|
|
const secretPath = resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath(
|
|
dataDir,
|
|
normalizedServer,
|
|
normalizedDatabase,
|
|
);
|
|
if (!existsSync(secretPath)) {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
const stat = lstatSync(secretPath);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
throw new Error('记录不是普通文件');
|
|
}
|
|
if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) {
|
|
throw new Error('记录权限不是 0600');
|
|
}
|
|
|
|
const payload = JSON.parse(readFileSync(secretPath, 'utf8'));
|
|
const secret =
|
|
typeof payload.secret === 'string' ? payload.secret.trim() : '';
|
|
if (
|
|
payload.schemaVersion !== 1 ||
|
|
payload.server !== normalizedServer ||
|
|
payload.database !== normalizedDatabase ||
|
|
!BOOTSTRAP_SECRET_PATTERN.test(secret)
|
|
) {
|
|
throw new Error('记录格式或作用域不匹配');
|
|
}
|
|
|
|
return secret;
|
|
} catch (error) {
|
|
console.warn(
|
|
`[dev:spacetime] 本地运行服务 bootstrap secret 记录不可用,将重新生成: ${error.message}`,
|
|
);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function writeLocalSpacetimeRuntimeServiceBootstrapSecret({
|
|
dataDir,
|
|
serverUrl,
|
|
database,
|
|
secret,
|
|
replace = false,
|
|
}) {
|
|
const normalizedSecret = String(secret ?? '').trim();
|
|
if (!BOOTSTRAP_SECRET_PATTERN.test(normalizedSecret)) {
|
|
throw new Error('运行服务 bootstrap secret 必须是 64 个十六进制字符');
|
|
}
|
|
if (!isLoopbackSpacetimeServer(serverUrl)) {
|
|
return normalizedSecret;
|
|
}
|
|
|
|
const normalizedServer = normalizeSpacetimeServerForIdentity(serverUrl);
|
|
const normalizedDatabase = String(database ?? '').trim();
|
|
const secretPath = resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath(
|
|
dataDir,
|
|
normalizedServer,
|
|
normalizedDatabase,
|
|
);
|
|
const tempPath = `${secretPath}.${process.pid}.${randomHex(8)}.tmp`;
|
|
ensureParentDir(secretPath);
|
|
|
|
try {
|
|
writeFileSync(
|
|
tempPath,
|
|
`${JSON.stringify({
|
|
schemaVersion: 1,
|
|
server: normalizedServer,
|
|
database: normalizedDatabase,
|
|
secret: normalizedSecret,
|
|
})}\n`,
|
|
{
|
|
encoding: 'utf8',
|
|
flag: 'wx',
|
|
mode: 0o600,
|
|
},
|
|
);
|
|
chmodSync(tempPath, 0o600);
|
|
|
|
if (!replace) {
|
|
try {
|
|
linkSync(tempPath, secretPath);
|
|
return normalizedSecret;
|
|
} catch (error) {
|
|
if (error?.code !== 'EEXIST') {
|
|
throw error;
|
|
}
|
|
|
|
const persistedSecret = readLocalSpacetimeRuntimeServiceBootstrapSecret(
|
|
{
|
|
dataDir,
|
|
serverUrl: normalizedServer,
|
|
database: normalizedDatabase,
|
|
},
|
|
);
|
|
if (persistedSecret) {
|
|
return persistedSecret;
|
|
}
|
|
}
|
|
}
|
|
|
|
renameSync(tempPath, secretPath);
|
|
chmodSync(secretPath, 0o600);
|
|
return normalizedSecret;
|
|
} finally {
|
|
rmSync(tempPath, { force: true });
|
|
}
|
|
}
|
|
|
|
function readLocalSpacetimeApiIdentity({ dataDir, serverUrl }) {
|
|
if (!isLoopbackSpacetimeServer(serverUrl)) {
|
|
return null;
|
|
}
|
|
|
|
const identityPath = resolveLocalSpacetimeApiIdentityPath(dataDir);
|
|
if (!existsSync(identityPath)) {
|
|
return migrateLegacyLocalSpacetimeApiIdentity(dataDir);
|
|
}
|
|
|
|
try {
|
|
return readLocalSpacetimeApiIdentityRecord(identityPath);
|
|
} catch (error) {
|
|
console.warn(
|
|
`[dev:spacetime] 本地 API identity 记录不可用,将重新创建: ${error.message}`,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeLocalSpacetimeApiIdentity({ dataDir, identity, token }) {
|
|
const identityPath = resolveLocalSpacetimeApiIdentityPath(dataDir);
|
|
const tempPath = `${identityPath}.${process.pid}.${randomHex(8)}.tmp`;
|
|
ensureParentDir(identityPath);
|
|
|
|
try {
|
|
writeFileSync(
|
|
tempPath,
|
|
`${JSON.stringify({
|
|
schemaVersion: 2,
|
|
scope: 'local-data-dir',
|
|
identity,
|
|
token,
|
|
})}\n`,
|
|
{
|
|
encoding: 'utf8',
|
|
flag: 'wx',
|
|
mode: 0o600,
|
|
},
|
|
);
|
|
chmodSync(tempPath, 0o600);
|
|
renameSync(tempPath, identityPath);
|
|
chmodSync(identityPath, 0o600);
|
|
} finally {
|
|
rmSync(tempPath, { force: true });
|
|
}
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
return { identity, token };
|
|
}
|
|
|
|
function shouldTrustExistingSpacetimeToken(
|
|
existingToken,
|
|
serverUrl,
|
|
{ env = process.env, resolveCliToken = resolveCurrentSpacetimeCliToken } = {},
|
|
) {
|
|
const normalizedToken = String(existingToken ?? '').trim();
|
|
if (!normalizedToken) {
|
|
return false;
|
|
}
|
|
|
|
const shellToken = String(env.GENARRATIVE_SPACETIME_TOKEN ?? '').trim();
|
|
if (shellToken && shellToken === normalizedToken) {
|
|
return true;
|
|
}
|
|
|
|
if (!isLoopbackSpacetimeServer(serverUrl)) {
|
|
return true;
|
|
}
|
|
|
|
const cliToken = resolveCliToken();
|
|
return Boolean(cliToken && cliToken === normalizedToken);
|
|
}
|
|
|
|
function isLoopbackSpacetimeServer(serverUrl) {
|
|
try {
|
|
const url = new URL(serverUrl);
|
|
return ['127.0.0.1', 'localhost', '::1'].includes(url.hostname);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function resolveCurrentSpacetimeCliToken(cliConfigPath = '') {
|
|
const args = [
|
|
...(cliConfigPath ? ['--config-path', cliConfigPath] : []),
|
|
'login',
|
|
'show',
|
|
'--token',
|
|
];
|
|
const result = spawnSync('spacetime', args, {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
shell: process.platform === 'win32',
|
|
});
|
|
if (result.status !== 0) {
|
|
return '';
|
|
}
|
|
|
|
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
|
return /auth token\b.*?\bis\s+(\S+)/iu.exec(output)?.[1]?.trim() ?? '';
|
|
}
|
|
|
|
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) => {
|
|
let capturedOutput = '';
|
|
const capture = (chunk, target) => {
|
|
target.write(chunk);
|
|
capturedOutput = `${capturedOutput}${String(chunk)}`.slice(-32_768);
|
|
};
|
|
const child = spawn(command, args, {
|
|
cwd,
|
|
env,
|
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
shell: process.platform === 'win32',
|
|
});
|
|
|
|
child.stdout?.on('data', (chunk) => capture(chunk, process.stdout));
|
|
child.stderr?.on('data', (chunk) => capture(chunk, process.stderr));
|
|
|
|
child.on('error', rejectRun);
|
|
child.on('exit', (code, signal) => {
|
|
if (signal) {
|
|
rejectRun(new Error(`[dev:${label}] 被信号终止: ${signal}`));
|
|
return;
|
|
}
|
|
|
|
if (code !== 0) {
|
|
const detail = trimPreview(capturedOutput, 2_000);
|
|
rejectRun(
|
|
new Error(
|
|
`[dev:${label}] 退出码: ${code}${detail ? `: ${detail}` : ''}`,
|
|
),
|
|
);
|
|
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({
|
|
cliConfigPath = '',
|
|
database,
|
|
server,
|
|
preserveDatabase,
|
|
}) {
|
|
const args = [
|
|
...(cliConfigPath ? ['--config-path', cliConfigPath] : []),
|
|
'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,
|
|
platform = process.platform,
|
|
}) {
|
|
return applyLocalFfmpegEnv(
|
|
{
|
|
...baseEnv,
|
|
// 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。
|
|
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true',
|
|
GENARRATIVE_PROCESS_ROLE: baseEnv.GENARRATIVE_PROCESS_ROLE || 'all',
|
|
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 || '',
|
|
},
|
|
platform,
|
|
);
|
|
}
|
|
|
|
function buildFrontendProcessEnv(baseEnv, overrides = {}) {
|
|
const env = { ...baseEnv, ...overrides };
|
|
delete env.GENARRATIVE_SPACETIME_TOKEN;
|
|
delete env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET;
|
|
delete env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256;
|
|
delete env.GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET;
|
|
return env;
|
|
}
|
|
|
|
export {
|
|
applyLocalFfmpegEnv,
|
|
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
|
assertSpacetimeToolVersionMatchesWorkspace,
|
|
buildApiServerProcessEnv,
|
|
buildDevStackSnapshot,
|
|
buildFrontendProcessEnv,
|
|
buildLocalRustProcessEnv,
|
|
buildSpacetimePublishArgs,
|
|
createDevServerSpawnOptions,
|
|
createWatchConfigs,
|
|
DevRunner,
|
|
isDirectModuleExecution,
|
|
isSpacetimePublishPermissionError,
|
|
isStaleExternalGenerationWorkerProcess,
|
|
normalizeCargoVersionRequirement,
|
|
parseArgs,
|
|
parseProcessEnvBlock,
|
|
parseSpacetimeToolVersion,
|
|
resolveCurrentSpacetimeCliToken,
|
|
resolveDevStackStatePath,
|
|
resolveLocalSpacetimeApiIdentityPath,
|
|
resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath,
|
|
shouldAcceptWatchEvent,
|
|
shouldTrustExistingSpacetimeToken,
|
|
};
|
|
|
|
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();
|
|
}
|