#!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' 用法: npm run dev:rust ./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110 ./scripts/dev-rust-stack.sh --spacetime-data-dir server-rs/.spacetimedb/local/data ./scripts/dev-rust-stack.sh --admin-web-port 3102 ./scripts/dev-rust-stack.sh --api-timeout-seconds 600 ./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish ./scripts/dev-rust-stack.sh --preserve-database ./scripts/dev-rust-stack.sh --no-migration-bootstrap-secret npm run dev:rust:logs -- --follow 说明: 1. 默认同时启动 SpacetimeDB standalone、Rust api-server、主站 Vite 与后台 Vite。 2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict,在结构冲突时清理旧模块数据。 3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。 4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local/data 作为本地数据目录;需要新启动时会先检测端口并选择最近可用端口。 5. 默认在发布模块前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。 EOF } require_command() { local command_name="$1" if ! command -v "${command_name}" >/dev/null 2>&1; then echo "[dev:rust] 缺少命令: ${command_name}" >&2 exit 1 fi } resolve_client_host() { local host_name="$1" if [[ "${host_name}" == "0.0.0.0" || "${host_name}" == "::" ]]; then echo "127.0.0.1" return fi echo "${host_name}" } load_api_server_env_files() { local env_files=() local key local value [[ -f "${REPO_ROOT}/.env" ]] && env_files+=("${REPO_ROOT}/.env") [[ -f "${REPO_ROOT}/.env.local" ]] && env_files+=("${REPO_ROOT}/.env.local") [[ -f "${REPO_ROOT}/.env.secrets.local" ]] && env_files+=("${REPO_ROOT}/.env.secrets.local") if [[ "${#env_files[@]}" -eq 0 ]]; then return fi # Node 只负责按 dotenv 子集解析文本;通过 NUL 分隔返回,避免让 env 文件内容参与 shell 求值。 while IFS= read -r -d '' key && IFS= read -r -d '' value; do export "${key}=${value}" done < <( node - "${env_files[@]}" <<'NODE' const fs = require('fs'); const shellEnvKeys = new Set(Object.keys(process.env)); const values = new Map(); for (const filePath of process.argv.slice(2)) { if (!fs.existsSync(filePath)) { continue; } const rawText = fs.readFileSync(filePath, 'utf8'); for (const rawLine of rawText.split(/\r?\n/u)) { const line = rawLine.trim(); if (!line || line.startsWith('#')) { continue; } const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); if (!match) { continue; } const [, key, rawValue] = match; if (shellEnvKeys.has(key)) { continue; } values.set(key, rawValue.replace(/^['"]|['"]$/gu, '')); } } for (const [key, value] of values.entries()) { process.stdout.write(`${key}\0${value}\0`); } NODE ) } resolve_dev_stack_ports() { local key local value while IFS='=' read -r key value; do case "${key}" in SPACETIME_PORT|API_PORT|WEB_PORT|ADMIN_WEB_PORT) export "${key}=${value}" ;; esac done < <( node "${REPO_ROOT}/scripts/dev-stack-port-utils.mjs" resolve-dev-stack \ "spacetime:${SPACETIME_HOST}:${SPACETIME_PORT}" \ "api:${API_TARGET_HOST}:${API_PORT}" \ "web:${WEB_HOST}:${WEB_PORT}" \ "adminWeb:${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}" ) } cleanup() { local index for ((index = ${#PIDS[@]} - 1; index >= 0; index--)); do local pid="${PIDS[index]}" local name="${NAMES[index]}" if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then echo "[dev:rust] 停止 ${name} (pid=${pid})" if command -v pgrep >/dev/null 2>&1; then while read -r child_pid; do if [[ -n "${child_pid}" ]]; then kill "${child_pid}" 2>/dev/null || true fi done < <(pgrep -P "${pid}" 2>/dev/null || true) fi kill "${pid}" 2>/dev/null || true sleep 0.2 if kill -0 "${pid}" 2>/dev/null; then kill -9 "${pid}" 2>/dev/null || true fi fi done } wait_for_spacetime() { local server="$1" local timeout_seconds="$2" local data_dir="$3" local process_pid="${4:-}" local deadline=$((SECONDS + timeout_seconds)) while ((SECONDS < deadline)); do if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then echo "[dev:rust] SpacetimeDB 进程在就绪前退出。" >&2 print_spacetime_start_failure_diagnostics "${data_dir}" exit 1 fi if is_spacetime_ready "${server}"; then return fi sleep 0.5 done echo "[dev:rust] 等待 SpacetimeDB 就绪超时: ${server}" >&2 print_spacetime_start_failure_diagnostics "${data_dir}" exit 1 } wait_for_spacetime_listen_addr() { local log_file="$1" local timeout_seconds="$2" local process_pid="${3:-}" local deadline=$((SECONDS + timeout_seconds)) local listen_addr="" while ((SECONDS < deadline)); do if [[ -f "${log_file}" ]]; then listen_addr="$(sed -n 's/^.*Starting SpacetimeDB listening on \([^[:space:]]\+\).*$/\1/p' "${log_file}" | tail -n 1)" if [[ -n "${listen_addr}" ]]; then echo "${listen_addr}" return fi fi if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then echo "[dev:rust] SpacetimeDB 进程在输出监听地址前退出。" >&2 if [[ -f "${log_file}" ]]; then echo "[dev:rust] 最近 SpacetimeDB 启动日志: ${log_file}" >&2 tail -n 80 "${log_file}" >&2 || true fi exit 1 fi sleep 0.2 done echo "[dev:rust] 等待 SpacetimeDB 输出监听地址超时。" >&2 if [[ -f "${log_file}" ]]; then echo "[dev:rust] 最近 SpacetimeDB 启动日志: ${log_file}" >&2 tail -n 80 "${log_file}" >&2 || true fi exit 1 } port_from_listen_addr() { local listen_addr="$1" echo "${listen_addr##*:}" } read_digits_from_file() { local file_path="$1" node -e ' const fs = require("fs"); const filePath = process.argv[1]; const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); for (let attempt = 0; attempt < 5; attempt += 1) { try { const digits = fs.readFileSync(filePath, "utf8").replace(/\D/gu, "").slice(0, 20); if (digits) { process.stdout.write(digits); process.exit(0); } process.exit(1); } catch { sleep(100); } } process.exit(1); ' "${file_path}" 2>/dev/null } read_trimmed_first_line_from_file() { local file_path="$1" node -e ' const fs = require("fs"); const filePath = process.argv[1]; const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); for (let attempt = 0; attempt < 5; attempt += 1) { try { const firstLine = fs.readFileSync(filePath, "utf8").split(/\r?\n/u)[0]?.trim() ?? ""; if (firstLine) { process.stdout.write(firstLine); process.exit(0); } process.exit(1); } catch { sleep(100); } } process.exit(1); ' "${file_path}" 2>/dev/null } validate_tcp_port() { local port="$1" local label="$2" if ! [[ "${port}" =~ ^[0-9]+$ ]] || ((port < 1 || port > 65535)); then echo "[dev:rust] ${label} 端口无效: ${port}" >&2 exit 1 fi } is_tcp_port_available() { local host="$1" local port="$2" node -e ' const net = require("net"); const host = process.argv[1]; const port = Number(process.argv[2]); if (!Number.isInteger(port) || port < 1 || port > 65535) { process.exit(2); } const server = net.createServer(); server.once("error", () => process.exit(1)); server.once("listening", () => server.close(() => process.exit(0))); server.listen({ host, port, exclusive: true }); ' "${host}" "${port}" >/dev/null 2>&1 } describe_tcp_port_owner() { local port="$1" if command -v powershell.exe >/dev/null 2>&1; then PORT_FOR_POWERSHELL="${port}" powershell.exe -NoProfile -Command ' $port = [int]$env:PORT_FOR_POWERSHELL Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 5 | ForEach-Object { $process = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue $name = if ($process) { $process.ProcessName } else { "unknown" } "pid=$($_.OwningProcess) name=$name local=$($_.LocalAddress):$($_.LocalPort)" } ' 2>/dev/null || true return fi if command -v ss >/dev/null 2>&1; then ss -lntp 2>/dev/null | awk -v port=":${port}" 'NR == 1 || index($0, port) > 0 { print }' || true return fi if command -v netstat >/dev/null 2>&1; then netstat -lntp 2>/dev/null | awk -v port=":${port}" 'NR == 1 || index($0, port) > 0 { print }' || true fi } find_nearest_available_port() { local host="$1" local requested_port="$2" local label="$3" local max_probe_count="${4:-100}" validate_tcp_port "${requested_port}" "${label}" local port="${requested_port}" local probe_count=0 while ((port <= 65535 && probe_count < max_probe_count)); do if is_tcp_port_available "${host}" "${port}"; then if [[ "${port}" != "${requested_port}" ]]; then echo "[dev:rust] ${label} 端口 ${host}:${requested_port} 已占用,改用最近可用端口 ${host}:${port}" >&2 fi echo "${port}" return fi if ((probe_count == 0)); then echo "[dev:rust] ${label} 端口 ${host}:${requested_port} 已占用,开始查找最近可用端口。" >&2 describe_tcp_port_owner "${requested_port}" >&2 || true fi port=$((port + 1)) probe_count=$((probe_count + 1)) done echo "[dev:rust] 无法为 ${label} 在 ${host}:${requested_port} 起向后 ${max_probe_count} 个端口内找到可用端口。" >&2 exit 1 } spacetime_url_record_path() { local data_dir="$1" echo "${data_dir}/dev-rust-spacetime-url" } spacetime_start_log_path() { local data_dir="$1" echo "${data_dir}/logs/dev-rust-spacetime-start.log" } spacetime_standalone_log_path() { local data_dir="$1" echo "${data_dir}/logs/spacetime-standalone.log" } read_spacetime_pid() { local data_dir="$1" local pid_file="${data_dir}/spacetime.pid" if [[ ! -f "${pid_file}" ]]; then return 1 fi local pid if ! pid="$(read_digits_from_file "${pid_file}")"; then return 1 fi echo "${pid}" } try_reuse_existing_spacetime() { local data_dir="$1" local url_file url_file="$(spacetime_url_record_path "${data_dir}")" local existing_pid local recorded_url="" if [[ -f "${url_file}" ]]; then if ! recorded_url="$(read_trimmed_first_line_from_file "${url_file}")"; then recorded_url="" fi fi if [[ -z "${recorded_url}" ]]; then local start_log start_log="$(spacetime_start_log_path "${data_dir}")" if [[ -f "${start_log}" ]]; then local logged_addr logged_addr="$(sed -n 's/^.*Starting SpacetimeDB listening on \([^[:space:]]\+\).*$/\1/p' "${start_log}" | tail -n 1)" if [[ -n "${logged_addr}" ]]; then recorded_url="http://${logged_addr}" fi fi if [[ -z "${recorded_url}" ]]; then local standalone_log standalone_log="$(spacetime_standalone_log_path "${data_dir}")" if [[ -f "${standalone_log}" ]]; then local standalone_addr standalone_addr="$(sed -n 's/^.*Starting SpacetimeDB listening on \([^[:space:]]\+\).*$/\1/p' "${standalone_log}" | tail -n 1)" if [[ -n "${standalone_addr}" ]]; then recorded_url="http://${standalone_addr}" fi fi fi if [[ -z "${recorded_url}" ]]; then echo "[dev:rust] 未找到可复用的 SpacetimeDB URL 记录: ${url_file}。" return 1 fi fi if [[ -z "${recorded_url}" ]]; then echo "[dev:rust] SpacetimeDB URL 记录为空: ${url_file}。" return 1 fi if is_spacetime_ready "${recorded_url}"; then SPACETIME_SERVER="${recorded_url}" SPACETIME_PORT="$(port_from_listen_addr "${recorded_url}")" SPACETIME_REUSED_EXISTING=1 if existing_pid="$(read_spacetime_pid "${data_dir}")"; then echo "[dev:rust] 复用已启动 SpacetimeDB: ${SPACETIME_SERVER} (pid=${existing_pid})" else echo "[dev:rust] 复用已启动 SpacetimeDB: ${SPACETIME_SERVER} (pid 文件暂不可读)" fi return 0 fi if ! existing_pid="$(read_spacetime_pid "${data_dir}")"; then echo "[dev:rust] URL 不在线且 spacetime.pid 暂不可读: ${recorded_url},将检查 data-dir 占用。" return 1 fi if ! kill -0 "${existing_pid}" 2>/dev/null; then echo "[dev:rust] 发现过期 spacetime.pid: ${existing_pid},将重新启动 SpacetimeDB。" return 1 fi echo "[dev:rust] pid=${existing_pid} 存在,但 URL 不在线: ${recorded_url},将重新启动 SpacetimeDB。" return 1 } record_spacetime_server_url() { local data_dir="$1" local server="$2" local url_file url_file="$(spacetime_url_record_path "${data_dir}")" mkdir -p "${data_dir}" printf '%s\n' "${server}" >"${url_file}" echo "[dev:rust] 记录 SpacetimeDB URL: ${url_file} -> ${server}" } is_spacetime_ready() { local server="$1" local output if output="$(spacetime server ping "${server}" 2>&1)" && [[ "${output}" == *"Server is online:"* ]]; then return 0 fi # SpacetimeDB CLI 2.1.0 在 Windows 下可能对已监听的 standalone 返回 502; # 直接探测 HTTP 健康端点,避免 npm run dev:rust 卡在“等待 SpacetimeDB 就绪”。 node -e ' const target = new URL("/v1/ping", process.argv[1]); const client = target.protocol === "https:" ? require("https") : require("http"); const request = client.get(target, { timeout: 1000 }, (response) => { response.resume(); process.exit(response.statusCode >= 200 && response.statusCode < 300 ? 0 : 1); }); request.on("timeout", () => request.destroy(new Error("timeout"))); request.on("error", () => process.exit(1)); ' "${server}" >/dev/null 2>&1 } print_spacetime_start_failure_diagnostics() { local data_dir="$1" local log_file="${data_dir}/logs/spacetime-standalone.log" echo "[dev:rust] SpacetimeDB data-dir: ${data_dir}" >&2 if [[ ! -f "${log_file}" ]]; then echo "[dev:rust] 未找到 SpacetimeDB standalone 日志: ${log_file}" >&2 return fi echo "[dev:rust] 最近 SpacetimeDB standalone 日志: ${log_file}" >&2 tail -n 80 "${log_file}" >&2 || true if grep -q "mismatched database identity" "${log_file}" 2>/dev/null; then echo "[dev:rust] 检测到本地 replica 与当前数据库 identity 不一致。" >&2 echo "[dev:rust] 常见原因是同一个 data-dir 保留了旧库 replicas/1,但 control-db 已指向新库。" >&2 echo "[dev:rust] 若这是可丢弃的本地开发库,请先停止 SpacetimeDB,再备份或移走 ${data_dir} 后重新启动。" >&2 echo "[dev:rust] 若需要保留数据,不要清理目录;请改回创建旧库的 database/data-dir,或先走迁移导出。" >&2 fi } describe_spacetime_root_owner() { local data_dir="$1" local windows_data_dir="${data_dir}" if [[ "${windows_data_dir}" =~ ^/([a-zA-Z])/(.*)$ ]]; then windows_data_dir="${BASH_REMATCH[1]}:/${BASH_REMATCH[2]}" fi # Windows 本地开发最常见的失败是同一个 data-dir 下已有 standalone 持有 spacetime.pid; # 启动前先打印占用进程,避免用户只看到底层 os error 33 而不知道该停哪个实例。 # 只有 Windows/Git Bash 风格路径才交给 PowerShell 查 Windows 进程; # WSL/Linux 的 /tmp、/home 路径不能直接拿去匹配 Windows CommandLine,容易误命中无关 spacetime 进程。 if command -v powershell.exe >/dev/null 2>&1 && [[ "${data_dir}" =~ ^/([a-zA-Z])/ ]]; then DATA_DIR_FOR_POWERSHELL="${windows_data_dir}" powershell.exe -NoProfile -Command ' $dataDir = $env:DATA_DIR_FOR_POWERSHELL $normalized = $dataDir.Replace("/", "\") Get-CimInstance Win32_Process | Where-Object { $_.Name -match "spacetime" -and $_.CommandLine -and $_.CommandLine.Replace("/", "\") -like "*$normalized*" } | ForEach-Object { "pid=$($_.ProcessId) name=$($_.Name) command=$($_.CommandLine)" } ' 2>/dev/null || true return fi if command -v ps >/dev/null 2>&1; then ps -eo user=,pid=,ppid=,stat=,comm=,args= 2>/dev/null | awk -v data_dir="${data_dir}" ' { user = $1 pid = $2 ppid = $3 stat = $4 command = $5 args = $0 sub(/^[[:space:]]*[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]*/, "", args) name = command sub(/^.*\//, "", name) # 只认真实的 SpacetimeDB 启动进程,避免 .spacetimedb 路径让 grep/awk 自身误命中。 if ((name == "spacetime" || name == "spacetimedb-cli") && index(args, data_dir) > 0) { print user " " pid " " ppid " " stat " " name " " args } } ' || true fi } is_tcp_port_available() { local host="$1" local port="$2" # 中文注释:用真实 bind 探测端口,覆盖 127.0.0.1 与 0.0.0.0 互相占用的情况。 node - "${host}" "${port}" <<'NODE' const net = require('net'); const host = process.argv[2]; const port = Number(process.argv[3]); if (!Number.isInteger(port) || port <= 0 || port > 65535) { process.exit(1); } const server = net.createServer(); const timer = setTimeout(() => { server.close(); process.exit(1); }, 1000); server.once('error', () => { clearTimeout(timer); process.exit(1); }); server.once('listening', () => { clearTimeout(timer); server.close(() => process.exit(0)); }); server.listen({ host, port }); NODE } describe_tcp_port_owner() { local port="$1" # 中文注释:Windows 下直接读取监听端口对应进程,便于用户精确停止旧 dev 栈。 if command -v powershell.exe >/dev/null 2>&1; then GENARRATIVE_TCP_PORT="${port}" powershell.exe -NoProfile -Command ' $portNumber = [int]$env:GENARRATIVE_TCP_PORT $connections = Get-NetTCPConnection -State Listen -LocalPort $portNumber -ErrorAction SilentlyContinue $seen = @{} foreach ($connection in $connections) { $processId = [int]$connection.OwningProcess if ($seen.ContainsKey($processId)) { continue } $seen[$processId] = $true $process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue if ($process) { $commandLine = ($process.CommandLine -replace "\s+", " ").Trim() "pid=$processId name=$($process.Name) address=$($connection.LocalAddress):$($connection.LocalPort) command=$commandLine" } else { "pid=$processId address=$($connection.LocalAddress):$($connection.LocalPort)" } } ' 2>/dev/null || true return fi if command -v lsof >/dev/null 2>&1; then lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null | awk 'NR > 1 { print "pid=" $2 " name=" $1 " address=" $9 }' || true return fi if command -v ss >/dev/null 2>&1; then ss -ltnp "sport = :${port}" 2>/dev/null || true fi } ensure_tcp_port_available() { local label="$1" local host="$2" local port="$3" local owners # 中文注释:完整栈不复用旧 api-server / Vite,避免健康检查命中旧进程后继续误判。 if is_tcp_port_available "${host}" "${port}"; then return fi owners="$(describe_tcp_port_owner "${port}")" echo "[dev:rust] ${label} 端口已被占用,无法启动: ${host}:${port}" >&2 if [[ -n "${owners}" ]]; then echo "[dev:rust] 当前监听进程:" >&2 echo "${owners}" >&2 fi echo "[dev:rust] 请停止占用进程,或通过对应端口参数换端口后重试。" >&2 exit 1 } wait_for_api_server() { local health_url="$1" local timeout_seconds="$2" local process_pid="${3:-}" local deadline=$((SECONDS + timeout_seconds)) while ((SECONDS < deadline)); do if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then echo "[dev:rust] api-server 进程在就绪前退出。" >&2 exit 1 fi # 使用 Node 发起健康检查,避免要求 Windows 本地额外安装 curl/wget。 if node -e ' const target = process.argv[1]; const client = target.startsWith("https:") ? require("https") : require("http"); const request = client.get(target, { timeout: 1000 }, (response) => { response.resume(); process.exit(response.statusCode >= 200 && response.statusCode < 500 ? 0 : 1); }); request.on("timeout", () => request.destroy(new Error("timeout"))); request.on("error", () => process.exit(1)); ' "${health_url}" >/dev/null 2>&1; then return fi sleep 0.5 done echo "[dev:rust] 等待 api-server 就绪超时: ${health_url}" >&2 exit 1 } generate_migration_bootstrap_secret() { node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));' } prepare_migration_bootstrap_secret() { case "${MIGRATION_BOOTSTRAP_SECRET_MODE}" in auto) MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)" ;; manual) if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then echo "[dev:rust] 迁移引导密钥至少需要 16 个字符。" >&2 exit 1 fi ;; disabled) unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET echo "[dev:rust] 未启用迁移引导密钥。" return ;; *) echo "[dev:rust] 未知迁移引导密钥模式: ${MIGRATION_BOOTSTRAP_SECRET_MODE}" >&2 exit 1 ;; esac export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}" echo "[dev:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}" } SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" SERVER_RS_DIR="${REPO_ROOT}/server-rs" MANIFEST_PATH="${SERVER_RS_DIR}/Cargo.toml" MODULE_PATH="${SERVER_RS_DIR}/crates/spacetime-module" VITE_CLI_PATH="${REPO_ROOT}/scripts/vite-cli.mjs" ADMIN_WEB_DIR="${REPO_ROOT}/apps/admin-web" API_HOST="127.0.0.1" API_PORT="8082" WEB_HOST="0.0.0.0" WEB_PORT="3000" ADMIN_WEB_HOST="127.0.0.1" ADMIN_WEB_PORT="3102" SPACETIME_HOST="127.0.0.1" SPACETIME_PORT="3101" SPACETIME_ROOT_DIR="${SERVER_RS_DIR}/.spacetimedb/local" SPACETIME_DATA_DIR="${SPACETIME_ROOT_DIR}/data" DATABASE="" API_LOG="info,tower_http=info" SPACETIME_TIMEOUT_SECONDS="60" API_SERVER_TIMEOUT_SECONDS="600" SKIP_SPACETIME=0 SKIP_PUBLISH=0 PRESERVE_DATABASE=0 MIGRATION_BOOTSTRAP_SECRET="" MIGRATION_BOOTSTRAP_SECRET_MODE="auto" SPACETIME_REUSED_EXISTING=0 PIDS=() NAMES=() read_local_spacetime_database() { local config_path="${REPO_ROOT}/spacetime.local.json" if [[ ! -f "${config_path}" ]]; then return fi node -e ' const fs = require("fs"); const path = process.argv[1]; try { const value = JSON.parse(fs.readFileSync(path, "utf8")).database; if (typeof value === "string" && value.trim()) { process.stdout.write(value.trim()); } } catch (error) { process.stderr.write(`[dev:rust] ignore invalid spacetime.local.json: ${error.message}\n`); } ' "${config_path}" } while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage exit 0 ;; --api-host) API_HOST="${2:?缺少 --api-host 的值}" shift 2 ;; --api-port) API_PORT="${2:?缺少 --api-port 的值}" shift 2 ;; --web-host) WEB_HOST="${2:?缺少 --web-host 的值}" shift 2 ;; --web-port) WEB_PORT="${2:?缺少 --web-port 的值}" shift 2 ;; --admin-web-host) ADMIN_WEB_HOST="${2:?缺少 --admin-web-host 的值}" shift 2 ;; --admin-web-port) ADMIN_WEB_PORT="${2:?缺少 --admin-web-port 的值}" shift 2 ;; --spacetime-host) SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}" shift 2 ;; --spacetime-port) SPACETIME_PORT="${2:?缺少 --spacetime-port 的值}" shift 2 ;; --spacetime-root-dir) SPACETIME_ROOT_DIR="${2:?缺少 --spacetime-root-dir 的值}" SPACETIME_DATA_DIR="${SPACETIME_ROOT_DIR}/data" shift 2 ;; --spacetime-data-dir) SPACETIME_DATA_DIR="${2:?缺少 --spacetime-data-dir 的值}" shift 2 ;; --database) DATABASE="${2:?缺少 --database 的值}" shift 2 ;; --log) API_LOG="${2:?缺少 --log 的值}" shift 2 ;; --spacetime-timeout-seconds) SPACETIME_TIMEOUT_SECONDS="${2:?缺少 --spacetime-timeout-seconds 的值}" shift 2 ;; --api-timeout-seconds) API_SERVER_TIMEOUT_SECONDS="${2:?缺少 --api-timeout-seconds 的值}" shift 2 ;; --skip-spacetime) SKIP_SPACETIME=1 shift ;; --skip-publish) SKIP_PUBLISH=1 shift ;; --clear-database) PRESERVE_DATABASE=0 shift ;; --preserve-database) PRESERVE_DATABASE=1 shift ;; --migration-bootstrap-secret) MIGRATION_BOOTSTRAP_SECRET="${2:?缺少 --migration-bootstrap-secret 的值}" MIGRATION_BOOTSTRAP_SECRET_MODE="manual" shift 2 ;; --no-migration-bootstrap-secret) MIGRATION_BOOTSTRAP_SECRET="" MIGRATION_BOOTSTRAP_SECRET_MODE="disabled" shift ;; *) echo "[dev:rust] 未知参数: $1" >&2 usage >&2 exit 1 ;; esac done if [[ -z "${DATABASE//[[:space:]]/}" ]]; then DATABASE="$(read_local_spacetime_database)" fi if [[ -z "${DATABASE//[[:space:]]/}" ]]; then DATABASE="genarrative-dev" fi if [[ ! -f "${MANIFEST_PATH}" ]]; then echo "[dev:rust] 未找到 ${MANIFEST_PATH},无法启动 Rust 本地栈。" >&2 exit 1 fi if [[ ! -f "${MODULE_PATH}/Cargo.toml" ]]; then echo "[dev:rust] 未找到 ${MODULE_PATH}/Cargo.toml,无法发布 SpacetimeDB 模块。" >&2 exit 1 fi if [[ ! -f "${VITE_CLI_PATH}" ]]; then echo "[dev:rust] 未找到 ${VITE_CLI_PATH},无法启动 Web 前端。" >&2 exit 1 fi if [[ ! -f "${ADMIN_WEB_DIR}/package.json" ]]; then echo "[dev:rust] 未找到 ${ADMIN_WEB_DIR}/package.json,无法启动后台前端。" >&2 exit 1 fi require_command cargo require_command node if [[ "${SKIP_SPACETIME}" -ne 1 || "${SKIP_PUBLISH}" -ne 1 ]]; then require_command spacetime fi SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}" API_TARGET_HOST="$(resolve_client_host "${API_HOST}")" ADMIN_WEB_TARGET_HOST="$(resolve_client_host "${ADMIN_WEB_HOST}")" resolve_dev_stack_ports SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}" RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}" trap cleanup EXIT INT TERM echo "[dev:rust] repo: ${REPO_ROOT}" echo "[dev:rust] web: http://127.0.0.1:${WEB_PORT}" echo "[dev:rust] admin web: http://${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}" echo "[dev:rust] rust api: ${RUST_SERVER_TARGET}" echo "[dev:rust] spacetime: ${SPACETIME_SERVER}" echo "[dev:rust] database: ${DATABASE}" echo "[dev:rust] spacetime root: ${SPACETIME_ROOT_DIR}" echo "[dev:rust] spacetime data: ${SPACETIME_DATA_DIR}" echo "[dev:rust] api timeout: ${API_SERVER_TIMEOUT_SECONDS}s" ensure_tcp_port_available "Rust api-server" "${API_HOST}" "${API_PORT}" ensure_tcp_port_available "主站 Vite" "${WEB_HOST}" "${WEB_PORT}" ensure_tcp_port_available "后台 Vite" "${ADMIN_WEB_HOST}" "${ADMIN_WEB_PORT}" if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then mkdir -p "${SPACETIME_ROOT_DIR}" "${SPACETIME_DATA_DIR}" if ! try_reuse_existing_spacetime "${SPACETIME_DATA_DIR}"; then SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner "${SPACETIME_DATA_DIR}")" if [[ -n "${SPACETIME_ROOT_OWNER}" ]]; then echo "[dev:rust] 当前 data-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。" >&2 echo "[dev:rust] 如需复用,请确认 ${SPACETIME_DATA_DIR}/dev-rust-spacetime-url 记录了实例 URL,或传入占用实例实际端口并追加 --skip-spacetime;如需重启,请先停止下列进程。" >&2 echo "${SPACETIME_ROOT_OWNER}" >&2 exit 1 fi SPACETIME_START_LOG="${SPACETIME_DATA_DIR}/logs/dev-rust-spacetime-start.log" mkdir -p "$(dirname -- "${SPACETIME_START_LOG}")" : >"${SPACETIME_START_LOG}" echo "[dev:rust] 启动 spacetimedb" ( cd "${SERVER_RS_DIR}" # 当目标端口被占用时,SpacetimeDB 会询问是否使用最近的可用端口; # 这里直接发送回车接受默认建议,再从启动日志解析实际监听端口。 printf '\n' | spacetime \ start \ --data-dir "${SPACETIME_DATA_DIR}" \ --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \ --non-interactive ) 2>&1 | tee "${SPACETIME_START_LOG}" & PIDS+=("$!") NAMES+=("spacetimedb") SPACETIME_LISTEN_ADDR="$(wait_for_spacetime_listen_addr "${SPACETIME_START_LOG}" "${SPACETIME_TIMEOUT_SECONDS}" "${PIDS[0]:-}")" SPACETIME_PORT="$(port_from_listen_addr "${SPACETIME_LISTEN_ADDR}")" SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}" echo "[dev:rust] spacetime actual: ${SPACETIME_SERVER}" record_spacetime_server_url "${SPACETIME_DATA_DIR}" "${SPACETIME_SERVER}" fi fi if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then echo "[dev:rust] 等待 SpacetimeDB 就绪" wait_for_spacetime "${SPACETIME_SERVER}" "${SPACETIME_TIMEOUT_SECONDS}" "${SPACETIME_DATA_DIR}" "${PIDS[0]:-}" prepare_migration_bootstrap_secret PUBLISH_ARGS=( publish "${DATABASE}" --server "${SPACETIME_SERVER}" --module-path "${MODULE_PATH}" --build-options="--debug" ) if [[ "${PRESERVE_DATABASE}" -ne 1 ]]; then PUBLISH_ARGS+=(-c=on-conflict) fi PUBLISH_ARGS+=(--yes) echo "[dev:rust] 发布 SpacetimeDB 模块: ${DATABASE}" ( cd "${SERVER_RS_DIR}" # spacetime publish 会在内部调用 Cargo;从 server-rs 目录执行,确保读取 # server-rs/.cargo/config.toml 中的 sccache/linker 配置,并复用同一套 target 缓存。 spacetime "${PUBLISH_ARGS[@]}" ) fi echo "[dev:rust] 启动 api-server" load_api_server_env_files API_PORT="$(find_nearest_available_port "${API_HOST}" "${API_PORT}" "api-server")" API_TARGET_HOST="$(resolve_client_host "${API_HOST}")" # `.env.local` 可以给单独 `dev:web` 配置代理目标,但完整栈的前端必须跟随本次 `--api-port`。 RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}" echo "[dev:rust] api actual: ${RUST_SERVER_TARGET}" ( cd "${REPO_ROOT}" GENARRATIVE_API_HOST="${API_HOST}" \ GENARRATIVE_API_PORT="${API_PORT}" \ GENARRATIVE_API_LOG="${API_LOG}" \ GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER}" \ GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \ exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}" ) & API_PID="$!" PIDS+=("${API_PID}") NAMES+=("api-server") echo "[dev:rust] 等待 api-server 就绪" wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}" echo "[dev:rust] 启动 vite" ( cd "${REPO_ROOT}" RUST_SERVER_TARGET="${RUST_SERVER_TARGET}" \ GENARRATIVE_RUNTIME_SERVER_TARGET="${RUST_SERVER_TARGET}" \ ADMIN_WEB_TARGET="http://${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}" \ ADMIN_WEB_PORT="${ADMIN_WEB_PORT}" \ VITE_DEV_HOST="${WEB_HOST}" \ exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}" "--strictPort" ) & PIDS+=("$!") NAMES+=("vite") echo "[dev:rust] 启动 admin vite" ( cd "${ADMIN_WEB_DIR}" ADMIN_API_TARGET="${RUST_SERVER_TARGET}" \ GENARRATIVE_API_TARGET="${RUST_SERVER_TARGET}" \ GENARRATIVE_API_PORT="${API_PORT}" \ exec node "${VITE_CLI_PATH}" "--host=${ADMIN_WEB_HOST}" "--port=${ADMIN_WEB_PORT}" "--strictPort" ) & PIDS+=("$!") NAMES+=("admin-vite") echo "[dev:rust] 本地 Rust 栈已启动。按 Ctrl+C 停止全部子进程。" set +e wait -n "${PIDS[@]}" EXIT_CODE="$?" set -e echo "[dev:rust] 子进程已退出,开始回收本地 Rust 栈,退出码: ${EXIT_CODE}" exit "${EXIT_CODE}"