Files
Genarrative/scripts/dev.mjs
Linghong 4b09ce3096 完成 Editor Agent Mock Agent P1 收尾
接入 Web Project 契约、SpacetimeDB 表与 api-server 控制面
新增 Mock Agent、静态构建 runner 与独立预览网关
补齐 /editor/agent 前端页面、服务客户端和 SSE 订阅
修复 sandbox 预览资源跨域加载并补充并发保护
接入本地 dev 预览端口漂移与服务身份初始化
更新 P1 技术方案、验收清单和 Hermes 共享记忆
2026-06-16 17:31:25 +08:00

2622 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
const webProjectRunnerBinPath = resolve(
serverRsDir,
'target/debug',
process.platform === 'win32' ? 'web-project-runner.exe' : 'web-project-runner',
);
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? '' : '/usr/bin/env';
const WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV =
'GENARRATIVE_SPACETIME_WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET';
const WEB_PROJECT_SERVICE_IDENTITY_CACHE_FILE = 'api-server-web-project-identity.json';
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
const SNAPSHOT_SERVICE_NAMES = [...SERVICE_NAMES, 'web-project-preview'];
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 端口
--web-project-preview-host <host> Web Project preview gateway 监听地址
--web-project-preview-port <port> Web Project preview gateway 端口
--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),
webProjectPreviewHost:
env.GENARRATIVE_WEB_PROJECT_PREVIEW_HOST || '127.0.0.1',
webProjectPreviewPort: normalizePort(
env.GENARRATIVE_WEB_PROJECT_PREVIEW_PORT,
3104,
),
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',
webProjectServiceBootstrapSecret: String(
env[WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV] ?? '',
).trim(),
webProjectServiceBootstrapSecretMode: String(
env[WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV] ?? '',
).trim()
? 'manual'
: '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 '--web-project-preview-host':
options.webProjectPreviewHost = readValue();
explicitOptions.add('webProjectPreviewHost');
break;
case '--web-project-preview-port':
options.webProjectPreviewPort = normalizePort(
readValue(),
options.webProjectPreviewPort,
);
explicitOptions.add('webProjectPreviewPort');
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 '--web-project-service-bootstrap-secret':
options.webProjectServiceBootstrapSecret = readValue();
options.webProjectServiceBootstrapSecretMode = 'manual';
break;
case '--no-web-project-service-bootstrap-secret':
options.webProjectServiceBootstrapSecret = '';
options.webProjectServiceBootstrapSecretMode = '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 buildWebProjectPreviewPublicBaseUrl({host, port}) {
return `http://${resolveClientHost(host)}:${port}`;
}
function resolveWebProjectPreviewFrameAncestors({baseEnv, webOrigin}) {
const explicitAncestors = String(
baseEnv.GENARRATIVE_WEB_PROJECT_PREVIEW_FRAME_ANCESTORS ?? '',
).trim();
if (explicitAncestors) {
const ancestors = uniqueNonEmpty([explicitAncestors, webOrigin]).join(' ');
return ancestors || webOrigin;
}
const webUrl = new URL(webOrigin);
const localhostOrigin = `${webUrl.protocol}//localhost:${webUrl.port}`;
return uniqueNonEmpty([webOrigin, localhostOrigin]).join(' ');
}
function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) {
const services = {};
for (const serviceName of SNAPSHOT_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/`,
};
case 'web-project-preview':
return {
host: options.webProjectPreviewHost,
port: options.webProjectPreviewPort,
url: state.webProjectPreviewPublicBaseUrl,
};
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(' ');
case 'web-project-preview':
return 'api-server embedded Web Project preview gateway';
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;
}
mergedEnv.RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
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}`,
webProjectPreviewPublicBaseUrl: buildWebProjectPreviewPublicBaseUrl({
host: options.webProjectPreviewHost,
port: options.webProjectPreviewPort,
}),
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;
}
if (!this.explicitOptions.has('webProjectPreviewPort')) {
this.options.webProjectPreviewPort = mappedPorts.webProjectPreviewPort;
}
this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${this.options.spacetimePort}`;
this.state.apiTarget = `http://${this.state.apiTargetHost}:${this.options.apiPort}`;
this.state.webProjectPreviewPublicBaseUrl = buildWebProjectPreviewPublicBaseUrl({
host: this.options.webProjectPreviewHost,
port: this.options.webProjectPreviewPort,
});
}
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,
};
portConfig.webProjectPreview = {
host: options.webProjectPreviewHost,
preferredPort: options.webProjectPreviewPort,
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;
}
if (resolvedPorts.webProjectPreview) {
options.webProjectPreviewPort = resolvedPorts.webProjectPreview;
}
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}`;
this.state.webProjectPreviewPublicBaseUrl = buildWebProjectPreviewPublicBaseUrl({
host: options.webProjectPreviewHost,
port: options.webProjectPreviewPort,
});
}
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] web project preview: ${state.webProjectPreviewPublicBaseUrl}`);
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);
this.prepareWebProjectServiceBootstrapSecret(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}`);
}
prepareWebProjectServiceBootstrapSecret(env) {
switch (this.options.webProjectServiceBootstrapSecretMode) {
case 'auto':
if (!this.options.webProjectServiceBootstrapSecret) {
this.options.webProjectServiceBootstrapSecret = randomHex(32);
}
break;
case 'manual':
if (this.options.webProjectServiceBootstrapSecret.length < 16) {
throw new Error('Web Project 服务身份引导密钥至少需要 16 个字符');
}
break;
case 'disabled':
delete env[WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV];
console.log('[dev:spacetime] 未启用 Web Project 服务身份引导密钥');
return;
default:
throw new Error(
`未知 Web Project 服务身份引导密钥模式: ${this.options.webProjectServiceBootstrapSecretMode}`,
);
}
env[WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV] =
this.options.webProjectServiceBootstrapSecret;
this.state.webProjectServiceBootstrapSecretPrepared = true;
console.log(
`[dev:spacetime] 已准备 Web Project 服务身份引导密钥: mode=${this.options.webProjectServiceBootstrapSecretMode}, length=${this.options.webProjectServiceBootstrapSecret.length}`,
);
}
async ensureApiServerSpacetimeToken() {
const existingToken = String(this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '').trim();
if (existingToken && shouldTrustExistingSpacetimeToken(existingToken, this.state.spacetimeServer)) {
return;
}
const cachedIdentity = readCachedSpacetimeIdentity(
this.options.spacetimeDataDir,
this.state.spacetimeServer,
);
if (cachedIdentity) {
this.baseEnv.GENARRATIVE_SPACETIME_TOKEN = cachedIdentity.token;
this.state.spacetimeIdentity = cachedIdentity.identity;
console.log(
`[dev:spacetime] 复用本地 api-server identity: ${cachedIdentity.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);
this.baseEnv.GENARRATIVE_SPACETIME_TOKEN = response.token;
this.state.spacetimeIdentity = response.identity;
cacheSpacetimeIdentity(
this.options.spacetimeDataDir,
this.state.spacetimeServer,
response,
);
console.log(
`[dev:spacetime] 已创建本地 Web identity: ${response.identity.slice(0, 12)}...`,
);
}
async startApiServer(service) {
await this.ensureApiServerSpacetimeToken();
await this.ensureWebProjectServiceIdentityAuthorized();
await this.ensureWebProjectRunnerBinary();
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 ensureWebProjectRunnerBinary() {
if (String(this.baseEnv.GENARRATIVE_WEB_PROJECT_RUNNER_BIN ?? '').trim()) {
return;
}
if (existsSync(webProjectRunnerBinPath)) {
return;
}
console.log('[dev:api-server] 构建 Web Project runner');
await runForeground(
'cargo',
['build', '-p', 'web-project-runner', '--manifest-path', 'server-rs/Cargo.toml'],
{
cwd: repoRoot,
env: buildLocalRustProcessEnv(this.baseEnv),
label: 'web-project-runner',
},
);
}
async ensureWebProjectServiceIdentityAuthorized() {
if (this.options.webProjectServiceBootstrapSecretMode === 'disabled') {
return;
}
const serviceIdentity = String(this.state.spacetimeIdentity ?? '').trim();
const token = String(this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '').trim();
if (!serviceIdentity || !token) {
console.warn('[dev:spacetime] 未能确认 api-server SpacetimeDB identity跳过 Web Project 自动授权');
return;
}
if (
this.options.webProjectServiceBootstrapSecretMode !== 'manual' &&
!this.state.webProjectServiceBootstrapSecretPrepared
) {
console.warn(
'[dev:spacetime] 本轮未发布带 Web Project 引导密钥的 spacetime-module跳过自动授权',
);
return;
}
const bootstrapSecret = String(
this.options.webProjectServiceBootstrapSecret ?? '',
).trim();
if (bootstrapSecret.length < 16) {
console.warn('[dev:spacetime] Web Project 服务身份引导密钥缺失,跳过自动授权');
return;
}
try {
const result = await authorizeWebProjectServiceIdentity({
serverUrl: this.state.spacetimeServer,
database: this.options.database,
token,
bootstrapSecret,
serviceIdentity,
});
console.log(
`[dev:spacetime] 已授权 Web Project 本地服务 identity: ${(result.service_identity_hex ?? serviceIdentity).slice(0, 12)}...`,
);
} catch (error) {
console.warn(
`[dev:spacetime] Web Project 本地服务 identity 自动授权失败:${error.message}`,
);
}
}
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 buildSpacetimeProcedureUrl(serverUrl, database, procedureName) {
if (!database) {
throw new Error('必须提供 SpacetimeDB 数据库名');
}
try {
const baseUrl = new URL(serverUrl).href.replace(/\/+$/u, '');
return `${baseUrl}/v1/database/${encodeURIComponent(database)}/call/${encodeURIComponent(procedureName)}`;
} catch {
throw new Error(`无法构造 SpacetimeDB procedure 地址: ${serverUrl}`);
}
}
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 readCachedSpacetimeIdentity(spacetimeDataDir, serverUrl) {
const cachePath = resolve(spacetimeDataDir, WEB_PROJECT_SERVICE_IDENTITY_CACHE_FILE);
if (!existsSync(cachePath)) {
return null;
}
try {
const payload = JSON.parse(readFileSync(cachePath, 'utf8'));
if (
payload?.serverUrl !== serverUrl ||
typeof payload.identity !== 'string' ||
typeof payload.token !== 'string' ||
!payload.identity.trim() ||
!payload.token.trim()
) {
return null;
}
return {
identity: payload.identity.trim(),
token: payload.token.trim(),
};
} catch (error) {
console.warn(`[dev:spacetime] 忽略无效本地 api-server identity 缓存: ${error.message}`);
return null;
}
}
function cacheSpacetimeIdentity(spacetimeDataDir, serverUrl, identityPayload) {
const cachePath = resolve(spacetimeDataDir, WEB_PROJECT_SERVICE_IDENTITY_CACHE_FILE);
ensureParentDir(cachePath);
writeFileSync(
cachePath,
`${JSON.stringify(
{
schemaVersion: 1,
serverUrl,
identity: identityPayload.identity,
token: identityPayload.token,
updatedAt: new Date().toISOString(),
},
null,
2,
)}\n`,
'utf8',
);
}
async function authorizeWebProjectServiceIdentity({
serverUrl,
database,
token,
bootstrapSecret,
serviceIdentity,
}) {
const url = buildSpacetimeProcedureUrl(
serverUrl,
database,
'authorize_web_project_service_identity',
);
const input = {
bootstrap_secret: bootstrapSecret,
service_identity_hex: serviceIdentity,
note: 'local api-server web project',
};
let response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify([input]),
});
} catch (error) {
throw new Error(
`SpacetimeDB Web Project 服务身份授权请求失败: ${url}; ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const text = await response.text();
if (!response.ok) {
throw new Error(`SpacetimeDB HTTP ${response.status}: ${trimPreview(text)}`);
}
const result = parseWebProjectServiceIdentityResult(text);
if (!result.ok) {
throw new Error(result.error_message ?? 'Web Project 服务身份授权失败');
}
return result;
}
function parseWebProjectServiceIdentityResult(output) {
const candidates = [];
const trimmed = output.trim();
if (trimmed) {
candidates.push(trimmed);
}
for (const line of output.split(/\r?\n/u)) {
const value = line.trim();
if (value.startsWith('{') || value.startsWith('[')) {
candidates.push(value);
}
}
for (const candidate of candidates) {
try {
return normalizeWebProjectServiceIdentityResult(JSON.parse(candidate));
} catch {
// SpacetimeDB 输出可能夹带说明文本,继续尝试后续候选。
}
}
throw new Error(`无法解析 Web Project 服务身份授权返回值: ${trimPreview(trimmed)}`);
}
function normalizeWebProjectServiceIdentityResult(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value;
}
if (Array.isArray(value) && value.length >= 3) {
return {
ok: normalizeSatsValue(value[0]),
service_identity_hex: normalizeSatsOption(value[1]),
error_message: normalizeSatsOption(value[2]),
};
}
throw new Error('Web Project 服务身份授权返回值不是合法对象。');
}
function normalizeSatsValue(value) {
if (Array.isArray(value)) {
return value.map((item) => normalizeSatsValue(item));
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, normalizeSatsValue(entry)]),
);
}
return value;
}
function normalizeSatsOption(value) {
if (Array.isArray(value)) {
if (value.length === 2 && value[0] === 0) {
return normalizeSatsValue(value[1]);
}
if (value.length === 0 || value[0] === 1) {
return null;
}
}
return normalizeSatsValue(value);
}
function shouldTrustExistingSpacetimeToken(existingToken, serverUrl) {
const shellToken = String(process.env.GENARRATIVE_SPACETIME_TOKEN ?? '').trim();
if (shellToken && shellToken === existingToken) {
return true;
}
return !isLoopbackSpacetimeServer(serverUrl);
}
function isLoopbackSpacetimeServer(serverUrl) {
try {
const url = new URL(serverUrl);
return ['127.0.0.1', 'localhost', '::1'].includes(url.hostname);
} catch {
return false;
}
}
function trimPreview(text, maxLength = 300) {
const normalized = String(text ?? '').replace(/\s+/gu, ' ').trim();
return normalized.length > maxLength
? `${normalized.slice(0, maxLength)}...`
: normalized;
}
function runForeground(command, args, {cwd, env, label}) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command, args, {
cwd,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('error', rejectRun);
child.on('exit', (code, signal) => {
if (signal) {
rejectRun(new Error(`[dev:${label}] 被信号终止: ${signal}`));
return;
}
if (code !== 0) {
rejectRun(new Error(`[dev:${label}] 退出码: ${code}`));
return;
}
resolveRun();
});
});
}
function randomHex(byteLength) {
return randomBytes(byteLength).toString('hex');
}
function sleep(ms) {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
function isCodeFile(path) {
const fileName = basename(path);
if (!fileName || fileName.startsWith('.')) {
return false;
}
return /\.(rs|toml|ts|tsx|js|jsx|mjs|json|css|html)$/u.test(fileName);
}
function normalizePath(path) {
return path.replace(/\\/gu, '/');
}
function normalizeDirectExecutionPath(path) {
return normalizePath(path).replace(/^\/([A-Za-z]:\/)/u, '$1');
}
function safeRealpath(pathValue) {
try {
return realpathSync(pathValue);
} catch {
return resolve(pathValue);
}
}
function isDirectModuleExecution(argv1, moduleUrl, resolvePath = safeRealpath) {
if (!argv1) {
return false;
}
try {
return (
normalizeDirectExecutionPath(resolvePath(argv1)) ===
normalizeDirectExecutionPath(resolvePath(fileURLToPath(moduleUrl)))
);
} catch {
return (
normalizeDirectExecutionPath(resolve(argv1)) ===
normalizeDirectExecutionPath(fileURLToPath(moduleUrl))
);
}
}
function buildSpacetimePublishArgs({database, server, preserveDatabase}) {
const args = [
'publish',
database,
'--server',
server,
'--module-path',
modulePath,
'--build-options=--debug',
];
if (!preserveDatabase) {
args.push('-c=on-conflict');
}
args.push('--yes', '--no-config');
return args;
}
function isSpacetimePublishPermissionError(error) {
const message = String(error?.message ?? error ?? '');
return (
message.includes('Pre-publish check failed with status 403 Forbidden') ||
message.includes('not authorized to perform action on database') ||
message.includes('is not authorized to perform action on database')
);
}
function buildApiServerProcessEnv({baseEnv, options, state}) {
return {
...baseEnv,
// 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true',
GENARRATIVE_API_HOST: options.apiHost,
GENARRATIVE_API_PORT: String(options.apiPort),
GENARRATIVE_API_LOG: options.apiLog,
GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer,
GENARRATIVE_SPACETIME_DATABASE: options.database,
GENARRATIVE_SPACETIME_TOKEN: baseEnv.GENARRATIVE_SPACETIME_TOKEN || '',
GENARRATIVE_WEB_PROJECT_RUNNER_BIN:
String(baseEnv.GENARRATIVE_WEB_PROJECT_RUNNER_BIN ?? '').trim() ||
webProjectRunnerBinPath,
GENARRATIVE_WEB_PROJECT_PREVIEW_HOST: options.webProjectPreviewHost,
GENARRATIVE_WEB_PROJECT_PREVIEW_PORT: String(options.webProjectPreviewPort),
GENARRATIVE_WEB_PROJECT_PREVIEW_PUBLIC_BASE_URL:
state.webProjectPreviewPublicBaseUrl ||
buildWebProjectPreviewPublicBaseUrl({
host: options.webProjectPreviewHost,
port: options.webProjectPreviewPort,
}),
GENARRATIVE_WEB_PROJECT_PREVIEW_FRAME_ANCESTORS:
resolveWebProjectPreviewFrameAncestors({
baseEnv,
webOrigin: `http://${resolveClientHost(options.webHost)}:${options.webPort}`,
}),
};
}
export {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
authorizeWebProjectServiceIdentity,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildLocalRustProcessEnv,
buildSpacetimeProcedureUrl,
buildSpacetimePublishArgs,
cacheSpacetimeIdentity,
createDevServerSpawnOptions,
createWatchConfigs,
DevRunner,
isDirectModuleExecution,
isSpacetimePublishPermissionError,
normalizeCargoVersionRequirement,
parseWebProjectServiceIdentityResult,
parseArgs,
parseSpacetimeToolVersion,
readCachedSpacetimeIdentity,
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();
}