Files
Genarrative/scripts/dev-rust-stack.sh
历冰郁-hermes版 f74717c415
Some checks failed
CI / verify (push) Has been cancelled
fix(dev): resolve local stack ports before startup
2026-05-10 20:00:42 +08:00

1039 lines
31 KiB
Bash
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.
#!/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}"