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, formatApiServerLogTimestamp, 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', 'bgfilter-worker', 'web', 'admin-web', ]; const SERVICE_ALIASES = new Map([ ['api', 'api-server'], ['bgfilter', 'bgfilter-worker'], ['bgfilterWorker', 'bgfilter-worker'], ['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:bgfilter-worker [-- --database genarrative-dev] npm run dev:web [-- --api-port 8082] npm run dev:admin-web [-- --api-port 8082] 常用参数: --api-host api-server 监听地址 --api-port api-server 端口 --bgfilter-worker-host BgFilter worker 内部监听地址 --bgfilter-worker-port BgFilter worker 内部端口 --web-host 主站 Vite 监听地址 --web-port 主站 Vite 端口 --admin-web-host 后台 Vite 监听地址 --admin-web-port 后台 Vite 端口 --spacetime-host SpacetimeDB 监听地址 --spacetime-port SpacetimeDB 端口 --spacetime-data-dir SpacetimeDB 本地数据目录 --database SpacetimeDB 数据库名 --port-range Linux 用户端口段,手动指定示例 10000-10099;默认自动从 10000-10099 起分配 --watch 文件改动后刷新/重启对应模块 --no-interactive 关闭交互式手动命令 交互命令: rs spacetime 重新发布 spacetime-module,不重启 standalone rs api-server 重启 api-server rs bgfilter-worker 重启 BgFilter worker 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), bgfilterWorkerHost: env.GENARRATIVE_BGFILTER_WORKER_HOST || '127.0.0.1', bgfilterWorkerPort: normalizePort( env.GENARRATIVE_BGFILTER_WORKER_PORT, 8083, ), 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 '--bgfilter-worker-host': options.bgfilterWorkerHost = readValue(); explicitOptions.add('bgfilterWorkerHost'); break; case '--bgfilter-worker-port': options.bgfilterWorkerPort = normalizePort( readValue(), options.bgfilterWorkerPort, ); explicitOptions.add('bgfilterWorkerPort'); 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' || 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 'bgfilter-worker': return { host: options.bgfilterWorkerHost, port: options.bgfilterWorkerPort, url: state.bgfilterWorkerTarget, }; 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 'bgfilter-worker': 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 parseSpacetimeToolCommit(output) { const match = /^Commit:\s*([0-9a-f]{40})\s*$/imu.exec(output); return match?.[1]?.toLowerCase() ?? ''; } function expectedSpacetimeToolCommit(workspaceVersion) { if (workspaceVersion === '2.7.0') { return 'd220349adb7af7eefa810eb08a185609356b83f6'; } return ''; } function buildSpacetimeUpgradeInstruction(workspaceVersion) { const expectedCommit = expectedSpacetimeToolCommit(workspaceVersion); if (expectedCommit) { return `请安装官方 v${workspaceVersion}-hotfix3 发行包并确认 spacetime --version 的 Commit 为 ${expectedCommit},然后重新运行本命令。`; } return `请先直接升级并切换到锁定版本: spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion},然后重新运行本命令。`; } function readSpacetimeToolProvenance() { 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, commit: parseSpacetimeToolCommit(output) }; } function assertSpacetimeToolVersionMatchesWorkspace({ toolVersion, toolCommit = '', workspaceVersion, }) { if (toolVersion !== workspaceVersion) { throw new Error( [ `本机 spacetime CLI/standalone 版本 ${toolVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`, '版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。', buildSpacetimeUpgradeInstruction(workspaceVersion), ].join(''), ); } const expectedCommit = expectedSpacetimeToolCommit(workspaceVersion); if (expectedCommit && toolCommit.toLowerCase() !== expectedCommit) { throw new Error( [ `本机 spacetime CLI/standalone 虽报告 ${toolVersion},但构建 commit ${toolCommit || 'unknown'} 不是项目锁定的 ${expectedCommit}。`, '同版本号的裸 tag 缺少后续 hotfix,不能用于本地 SpacetimeDB。', buildSpacetimeUpgradeInstruction(workspaceVersion), ].join(''), ); } } function assertReusableSpacetimeProcessVersionMatchesWorkspace({ dataDir, serverUrl, }) { const recordedProvenance = readRecordedSpacetimeToolProvenance(dataDir); const workspaceVersion = readWorkspaceSpacetimeVersion(); if (!recordedProvenance.version) { throw new Error( [ `检测到正在运行的本地 SpacetimeDB: ${serverUrl},但缺少 SpacetimeDB 版本记录。`, '为避免复用旧 standalone 导致 procedure 返回值 BSATN 反序列化失败和前端调用超时,请先停止该进程,再重新运行 npm run dev:spacetime。', ].join(''), ); } try { assertSpacetimeToolVersionMatchesWorkspace({ toolVersion: recordedProvenance.version, toolCommit: recordedProvenance.commit, workspaceVersion, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error( `正在运行的本地 SpacetimeDB standalone provenance 不符合项目锁定要求: ${message}请停止当前 SpacetimeDB 进程后重新运行 npm run dev:spacetime。`, ); } } function ensureSpacetimeToolVersionMatchesWorkspace() { const toolProvenance = readSpacetimeToolProvenance(); assertSpacetimeToolVersionMatchesWorkspace({ toolVersion: toolProvenance.version, toolCommit: toolProvenance.commit, workspaceVersion: readWorkspaceSpacetimeVersion(), }); } function ensureRequiredFiles(command) { const requiredFiles = []; if ( command === 'api-server' || command === 'bgfilter-worker' || 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 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.bgfilterInternalToken = String(this.baseEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN ?? '').trim() || randomBytes(32).toString('hex'); delete this.baseEnv.GENARRATIVE_BGFILTER_INTERNAL_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), bgfilterWorkerTargetHost: resolveClientHost(options.bgfilterWorkerHost), adminWebTargetHost: resolveClientHost(options.adminWebHost), spacetimeServer: initialSpacetimeServer, apiTarget: `http://${resolveClientHost(options.apiHost)}:${options.apiPort}`, bgfilterWorkerTarget: `http://${resolveClientHost(options.bgfilterWorkerHost)}:${options.bgfilterWorkerPort}`, portRange: null, portRangeReservation: null, }; this.services = new Map(); this.watchers = []; this.shuttingDown = false; this.windowsApiServerCleanupCompleted = false; } async init(command) { this.command = command; ensureRequiredFiles(command); requireCommand('node'); if ( command === 'api-server' || command === 'bgfilter-worker' || 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; } if (!this.explicitOptions.has('bgfilterWorkerPort')) { this.options.bgfilterWorkerPort = mappedPorts.bgfilterWorkerPort; } this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${this.options.spacetimePort}`; this.state.apiTarget = `http://${this.state.apiTargetHost}:${this.options.apiPort}`; this.state.bgfilterWorkerTarget = `http://${this.state.bgfilterWorkerTargetHost}:${this.options.bgfilterWorkerPort}`; } shouldValidateSpacetimeToolVersion(command) { if (command === 'spacetime') { return true; } if (command === 'all') { return !this.options.skipSpacetime || !this.options.skipPublish; } if (command === 'api-server' || command === 'bgfilter-worker') { 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 = {}; const portRangeFor = (optionName) => this.explicitOptions.has(optionName) ? null : this.state.portRange; if (command === 'all' || command === 'spacetime') { if (!options.skipSpacetime && !this.state.spacetimeReused) { portConfig.spacetime = { host: options.spacetimeHost, preferredPort: options.spacetimePort, portRange: portRangeFor('spacetimePort'), }; } } if (command === 'all' || 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 ( command === 'all' || command === 'api-server' || command === 'bgfilter-worker' ) { portConfig.bgfilterWorker = { host: options.bgfilterWorkerHost, preferredPort: options.bgfilterWorkerPort, portRange: portRangeFor('bgfilterWorkerPort'), }; } 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; } if (resolvedPorts.bgfilterWorker) { options.bgfilterWorkerPort = resolvedPorts.bgfilterWorker; } this.state.apiTargetHost = resolveClientHost(options.apiHost); this.state.adminWebTargetHost = resolveClientHost(options.adminWebHost); this.state.bgfilterWorkerTargetHost = resolveClientHost( options.bgfilterWorkerHost, ); if (command === 'all' || command === 'spacetime') { this.state.spacetimeServer = `http://${options.spacetimeHost}:${options.spacetimePort}`; } this.state.apiTarget = `http://${this.state.apiTargetHost}:${options.apiPort}`; this.state.bgfilterWorkerTarget = `http://${this.state.bgfilterWorkerTargetHost}:${options.bgfilterWorkerPort}`; } 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( 'bgfilter-worker', new DevService( 'bgfilter-worker', async (service) => this.startBgfilterWorker(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] bgfilter-worker: ${state.bgfilterWorkerTarget}`); 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.startRustServicePair(); await this.services.get('web').start(); await this.services.get('admin-web').start(); this.startInteractiveInput(); this.startWatchers([ 'spacetime', 'api-server', 'bgfilter-worker', 'web', 'admin-web', ]); return; } if (command === 'api-server') { await this.startRustServicePair(); this.startWatchers(['api-server', 'bgfilter-worker']); return; } if (command === 'spacetime') { await this.startSpacetimeForFullStack(); } else { await this.services.get(command).start(); } this.startWatchers([command]); } async startRustServicePair() { if (!this.windowsApiServerCleanupCompleted) { stopExistingWindowsApiServer(); this.windowsApiServerCleanupCompleted = true; } await this.services.get('bgfilter-worker').start(); await this.waitForBgfilterWorker(); await this.services.get('api-server').start(); await this.waitForApiServer(); } async restartRustServicePair() { await this.services.get('api-server').stop(); await this.services.get('bgfilter-worker').stop(); await this.startRustServicePair(); } 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 spacetimeToolProvenance = readSpacetimeToolProvenance(); assertSpacetimeToolVersionMatchesWorkspace({ toolVersion: spacetimeToolProvenance.version, toolCommit: spacetimeToolProvenance.commit, workspaceVersion: readWorkspaceSpacetimeVersion(), }); recordSpacetimeToolVersion( options.spacetimeDataDir, spacetimeToolProvenance, ); 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) { 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, bgfilterInternalToken: this.bgfilterInternalToken, processRole: this.command === 'all' ? 'all' : undefined, }); 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; 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 startBgfilterWorker(service) { await this.ensureApiServerSpacetimeToken(); if ( !this.runtimeServiceBootstrapSecret && this.options.migrationBootstrapSecretMode !== 'disabled' ) { this.runtimeServiceBootstrapSecret = this.resolveRuntimeServiceBootstrapSecret(); } const mergedEnv = buildBgfilterWorkerProcessEnv({ baseEnv: { ...buildLocalRustProcessEnv(this.baseEnv), GENARRATIVE_SPACETIME_TOKEN: this.spacetimeApiToken, }, options: this.options, state: this.state, bgfilterInternalToken: this.bgfilterInternalToken, }); if (this.runtimeServiceBootstrapSecret) { mergedEnv.GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET = this.runtimeServiceBootstrapSecret; } const logFile = resolveBgfilterWorkerLogFile(repoRoot, mergedEnv); ensureParentDir(logFile); const logStream = createWriteStream(logFile, { flags: 'a', encoding: 'utf8', }); service.logStream = logStream; mergedEnv.GENARRATIVE_API_SERVER_LOG_FILE = logFile; console.log(`[dev:bgfilter-worker] log: ${logFile}`); console.log( `[dev:bgfilter-worker] SpacetimeDB ${this.options.database} @ ${this.state.spacetimeServer}`, ); service.updateRuntimeState({ status: 'starting', pid: null, host: this.options.bgfilterWorkerHost, port: this.options.bgfilterWorkerPort, url: this.state.bgfilterWorkerTarget, command: resolveDevStackServiceCommand(this, 'bgfilter-worker'), 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:bgfilter-worker] 启动 cargo 失败: ${error.message}`); service.updateRuntimeState({ status: 'failed', pid: null }); }); 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}`); } async waitForBgfilterWorker() { const readinessUrl = `${this.state.bgfilterWorkerTarget}/readyz`; const deadline = Date.now() + this.options.apiTimeoutSeconds * 1000; while (Date.now() < deadline) { if (await isHttpReady(readinessUrl, 500)) { return; } const runtimeStatus = this.services.get('bgfilter-worker')?.runtime ?.status; if (runtimeStatus === 'failed' || runtimeStatus === 'stopped') { throw new Error( `bgfilter-worker 在 readiness 前退出,请检查 logs/bgfilter-worker/: ${readinessUrl}`, ); } await sleep(500); } throw new Error(`等待 bgfilter-worker 就绪超时: ${readinessUrl}`); } 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(); const pendingServiceNames = [...serviceNames]; const managesRustPair = this.command === 'all' || this.command === 'api-server'; if ( managesRustPair && pendingServiceNames.some((serviceName) => ['api-server', 'bgfilter-worker'].includes(serviceName), ) ) { const service = this.services.get('api-server'); for (const config of watchConfigs['api-server'] ?? []) { if (!existsSync(config.path)) { continue; } this.watchers.push( createServiceWatcher({ config, service, serviceName: 'rust-services', restartFn: async () => this.restartRustServicePair(), actionLabel: '组合重启', }), ); } } for (const serviceName of pendingServiceNames) { if ( managesRustPair && ['api-server', 'bgfilter-worker'].includes(serviceName) ) { continue; } 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 bgfilter-worker | 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') { await this.refreshSpacetimeModule(); await this.restartRustServicePair(); await this.services.get('web').restart(); await this.services.get('admin-web').restart(); return; } await this.restartService(target); } catch (error) { console.error(`[dev] 交互命令失败: ${error.message}`); } }); } async restartService(serviceName) { if (serviceName === 'spacetime') { await this.refreshSpacetimeModule(); return; } if ( (this.command === 'all' || this.command === 'api-server') && ['api-server', 'bgfilter-worker'].includes(serviceName) ) { await this.restartRustServicePair(); return; } await this.services.get(serviceName).restart(); if (serviceName === 'api-server') { await this.waitForApiServer(); } if (serviceName === 'bgfilter-worker') { await this.waitForBgfilterWorker(); } } 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() { const rustApiConfig = { path: serverRsDir, filter: (path) => isCodeFile(path) && !normalizePath(path).includes('/crates/spacetime-module/'), }; return { spacetime: [ { path: resolve(serverRsDir, 'crates/spacetime-module'), filter: isCodeFile, }, ], 'api-server': [rustApiConfig], 'bgfilter-worker': [rustApiConfig], 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 readRecordedSpacetimeToolProvenance(dataDir) { const versionPath = resolve(dataDir, 'dev-spacetime-tool-version'); if (!existsSync(versionPath)) { return { version: '', commit: '' }; } const [version = '', commit = ''] = readFileSync(versionPath, 'utf8').split( /\r?\n/u, ); return { version: version.trim(), commit: commit.trim().toLowerCase() }; } 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, serverUrl) { const normalizedServer = normalizeSpacetimeServerForIdentity(serverUrl); const serverKey = createHash('sha256').update(normalizedServer).digest('hex'); return resolve(dataDir, 'dev-api-identities', `${serverKey}.json`); } 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 normalizedServer = normalizeSpacetimeServerForIdentity(serverUrl); const identityPath = resolveLocalSpacetimeApiIdentityPath( dataDir, normalizedServer, ); if (!existsSync(identityPath)) { return null; } try { const stat = lstatSync(identityPath); if (!stat.isFile() || stat.isSymbolicLink()) { throw new Error('记录不是普通文件'); } chmodSync(identityPath, 0o600); const payload = JSON.parse(readFileSync(identityPath, 'utf8')); if ( payload.schemaVersion !== 1 || payload.server !== normalizedServer || typeof payload.identity !== 'string' || !payload.identity.trim() || typeof payload.token !== 'string' || !payload.token.trim() ) { throw new Error('记录格式或 server 绑定不匹配'); } return { identity: payload.identity.trim(), token: payload.token.trim(), }; } catch (error) { console.warn( `[dev:spacetime] 本地 API identity 记录不可用,将重新创建: ${error.message}`, ); return null; } } function writeLocalSpacetimeApiIdentity({ dataDir, serverUrl, identity, token, }) { const normalizedServer = normalizeSpacetimeServerForIdentity(serverUrl); const identityPath = resolveLocalSpacetimeApiIdentityPath( dataDir, normalizedServer, ); const tempPath = `${identityPath}.${process.pid}.${randomHex(8)}.tmp`; ensureParentDir(identityPath); try { writeFileSync( tempPath, `${JSON.stringify({ schemaVersion: 1, server: normalizedServer, 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, provenance) { const versionPath = resolve(dataDir, 'dev-spacetime-tool-version'); ensureParentDir(versionPath); try { writeFileSync( versionPath, `${provenance.version}\n${provenance.commit}\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() { const result = spawnSync('spacetime', ['login', 'show', '--token'], { 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) => { 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, bgfilterInternalToken = baseEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN || '', processRole = baseEnv.GENARRATIVE_PROCESS_ROLE || 'all', platform = process.platform, }) { return applyLocalFfmpegEnv( { ...baseEnv, // 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。 GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true', GENARRATIVE_PROCESS_ROLE: processRole, GENARRATIVE_API_HOST: options.apiHost, GENARRATIVE_API_PORT: String(options.apiPort), GENARRATIVE_API_LOG: options.apiLog, GENARRATIVE_BGFILTER_WORKER_BASE_URL: state.bgfilterWorkerTarget, GENARRATIVE_BGFILTER_INTERNAL_TOKEN: bgfilterInternalToken, GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer, GENARRATIVE_SPACETIME_DATABASE: options.database, GENARRATIVE_SPACETIME_TOKEN: baseEnv.GENARRATIVE_SPACETIME_TOKEN || '', }, platform, ); } function buildBgfilterWorkerProcessEnv({ baseEnv, options, state, bgfilterInternalToken, platform = process.platform, }) { return applyLocalFfmpegEnv( { ...baseEnv, GENARRATIVE_PROCESS_ROLE: 'bgfilter-worker', GENARRATIVE_BGFILTER_WORKER_HOST: options.bgfilterWorkerHost, GENARRATIVE_BGFILTER_WORKER_PORT: String(options.bgfilterWorkerPort), GENARRATIVE_BGFILTER_WORKER_BASE_URL: state.bgfilterWorkerTarget, GENARRATIVE_BGFILTER_INTERNAL_TOKEN: bgfilterInternalToken, GENARRATIVE_BGFILTER_WORKER_CONCURRENCY: String( baseEnv.GENARRATIVE_BGFILTER_WORKER_CONCURRENCY ?? '', ).trim() || '16', GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS: String( baseEnv.GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS ?? '', ).trim() || '5000', GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS: String( baseEnv.GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS ?? '', ).trim() || '2048', 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 resolveBgfilterWorkerLogFile( repoRootPath, env = process.env, now = new Date(), ) { const explicitLogFile = String( env.GENARRATIVE_BGFILTER_WORKER_LOG_FILE ?? '', ).trim(); if (explicitLogFile) { return resolve(repoRootPath, explicitLogFile); } const logDir = String(env.GENARRATIVE_BGFILTER_WORKER_LOG_DIR ?? '').trim() || 'logs/bgfilter-worker'; return resolve( repoRootPath, logDir, `bgfilter-worker-${formatApiServerLogTimestamp(now)}.log`, ); } 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; delete env.GENARRATIVE_BGFILTER_INTERNAL_TOKEN; delete env.GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE; return env; } export { applyLocalFfmpegEnv, assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, buildApiServerProcessEnv, buildBgfilterWorkerProcessEnv, buildDevStackSnapshot, buildFrontendProcessEnv, buildLocalRustProcessEnv, buildSpacetimePublishArgs, createDevServerSpawnOptions, createWatchConfigs, DevRunner, isDirectModuleExecution, isSpacetimePublishPermissionError, isStaleExternalGenerationWorkerProcess, normalizeCargoVersionRequirement, parseArgs, parseProcessEnvBlock, parseSpacetimeToolCommit, parseSpacetimeToolVersion, resolveBgfilterWorkerLogFile, 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(); }