55469a2198
Require an explicit red-box selection before extracting UI-design assets: add rectangular/ellipse/brush selection UI, render red-outline marks into a marked Data URL on the client, and update the extraction prompt to "仅提取被红色框框选的素材并整理成spritesheet". Add frontend overlay/raster models and tests to support the selection flow and adjust numerous image-editor tests and views to wire up the selection lifecycle. On the dev ops side, enhance scripts/dev to detect a local %LOCALAPPDATA%/Genarrative/ffmpeg/bin installation on Windows, prepend it to PATH and inject CHARACTER_ANIMATION_FFMPEG_PATH/FFPROBE_PATH (with tests), and export the helper. Server config now prefers GENARRATIVE_*-prefixed FFmpeg env vars and includes a Rust test to validate reading the prefixed variables. Update docs to describe the new UI extraction behavior and local FFmpeg handling.
2308 lines
65 KiB
JavaScript
2308 lines
65 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 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'],
|
|
['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,
|
|
platform = process.platform,
|
|
}) {
|
|
return applyLocalFfmpegEnv(
|
|
{
|
|
...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 || '',
|
|
},
|
|
platform,
|
|
);
|
|
}
|
|
|
|
export {
|
|
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
|
assertSpacetimeToolVersionMatchesWorkspace,
|
|
applyLocalFfmpegEnv,
|
|
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();
|
|
}
|