init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

451
scripts/dev-rust-stack.sh Normal file
View File

@@ -0,0 +1,451 @@
#!/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 --api-timeout-seconds 600
./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish
./scripts/dev-rust-stack.sh --preserve-database
npm run dev:rust:logs -- --follow
说明:
1. 默认同时启动 SpacetimeDB standalone、Rust api-server 与 Vite 前端。
2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict 在结构冲突时清理旧模块数据。
3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。
4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local 作为本地数据与日志目录。
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}"
}
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 root_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
exit 1
fi
if spacetime --root-dir="${root_dir}" server ping "${server}" >/dev/null 2>&1; then
return
fi
sleep 0.5
done
echo "[dev:rust] 等待 SpacetimeDB 就绪超时: ${server}" >&2
exit 1
}
is_spacetime_ready() {
local server="$1"
local root_dir="$2"
spacetime --root-dir="${root_dir}" server ping "${server}" >/dev/null 2>&1
}
describe_spacetime_root_owner() {
local root_dir="$1"
local windows_root_dir="${root_dir}"
if [[ "${windows_root_dir}" =~ ^/([a-zA-Z])/(.*)$ ]]; then
windows_root_dir="${BASH_REMATCH[1]}:/${BASH_REMATCH[2]}"
fi
# Windows 本地开发最常见的失败是同一个 root-dir 下已有 standalone 持有 spacetime.pid
# 启动前先打印占用进程,避免用户只看到底层 os error 33 而不知道该停哪个实例。
if command -v powershell.exe >/dev/null 2>&1; then
ROOT_DIR_FOR_POWERSHELL="${windows_root_dir}" powershell.exe -NoProfile -Command '
$rootDir = $env:ROOT_DIR_FOR_POWERSHELL
$normalized = $rootDir.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 -ef 2>/dev/null | grep '[s]pacetime' | grep -F "${root_dir}" || true
fi
}
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
}
sync_local_spacetime_install() {
local root_dir="$1"
# SpacetimeDB standalone 会在 --root-dir 下回调 bin/current/spacetimedb-cli.exe
# Windows 本地开发使用工程内 root-dir 时,需要把用户级安装目录同步进来。
if [[ "${OSTYPE:-}" != msys* && "${OSTYPE:-}" != cygwin* ]]; then
return
fi
local target_cli="${root_dir}/bin/current/spacetimedb-cli.exe"
if [[ -f "${target_cli}" ]]; then
return
fi
local spacetime_command
spacetime_command="$(command -v spacetime || true)"
if [[ -z "${spacetime_command}" ]]; then
return
fi
local install_dir
install_dir="$(cd -- "$(dirname -- "${spacetime_command}")" && pwd)"
if [[ ! -d "${install_dir}/bin" ]]; then
return
fi
echo "[dev:rust] 同步本机 SpacetimeDB 安装到 ${root_dir}"
mkdir -p "${root_dir}"
cp -a "${install_dir}/bin" "${root_dir}/"
if [[ -f "${install_dir}/spacetime.exe" ]]; then
cp -f "${install_dir}/spacetime.exe" "${root_dir}/spacetime.exe"
fi
# Git Bash 复制 Windows junction 时可能不会生成可执行的 current 目录;
# 若 current 缺失,则用最新版本目录复制出一个真实目录,满足 standalone 回调路径。
if [[ ! -f "${target_cli}" ]]; then
local version_dir
version_dir="$(find "${root_dir}/bin" -mindepth 1 -maxdepth 1 -type d ! -name current | sort -V | tail -n 1)"
if [[ -n "${version_dir}" && -f "${version_dir}/spacetimedb-cli.exe" ]]; then
rm -rf "${root_dir}/bin/current"
cp -a "${version_dir}" "${root_dir}/bin/current"
fi
fi
}
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"
API_HOST="127.0.0.1"
API_PORT="8082"
WEB_HOST="0.0.0.0"
WEB_PORT="3000"
SPACETIME_HOST="127.0.0.1"
SPACETIME_PORT="3101"
SPACETIME_ROOT_DIR="${SERVER_RS_DIR}/.spacetimedb/local"
DATABASE=""
API_LOG="info,tower_http=info"
SPACETIME_TIMEOUT_SECONDS="60"
API_SERVER_TIMEOUT_SECONDS="300"
SKIP_SPACETIME=0
SKIP_PUBLISH=0
PRESERVE_DATABASE=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
;;
--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 的值}"
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
;;
*)
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
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}")"
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] 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] api timeout: ${API_SERVER_TIMEOUT_SECONDS}s"
if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
mkdir -p "${SPACETIME_ROOT_DIR}"
sync_local_spacetime_install "${SPACETIME_ROOT_DIR}"
if is_spacetime_ready "${SPACETIME_SERVER}" "${SPACETIME_ROOT_DIR}"; then
echo "[dev:rust] 复用已运行的 SpacetimeDB: ${SPACETIME_SERVER}"
else
SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner "${SPACETIME_ROOT_DIR}")"
if [[ -n "${SPACETIME_ROOT_OWNER}" ]]; then
echo "[dev:rust] 当前 root-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。" >&2
echo "[dev:rust] 目标地址未就绪: ${SPACETIME_SERVER}" >&2
echo "[dev:rust] 如需复用,请传入占用实例实际端口,例如 --spacetime-port 3199如需重启请先停止下列进程。" >&2
echo "${SPACETIME_ROOT_OWNER}" >&2
exit 1
fi
echo "[dev:rust] 启动 spacetimedb"
(
cd "${SERVER_RS_DIR}"
exec spacetime \
--root-dir="${SPACETIME_ROOT_DIR}" \
start \
--edition standalone \
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"
) &
PIDS+=("$!")
NAMES+=("spacetimedb")
fi
fi
if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
echo "[dev:rust] 等待 SpacetimeDB 就绪"
wait_for_spacetime "${SPACETIME_SERVER}" "${SPACETIME_TIMEOUT_SECONDS}" "${SPACETIME_ROOT_DIR}" "${PIDS[0]:-}"
PUBLISH_ARGS=(
publish
"${DATABASE}"
--server "${SPACETIME_SERVER}"
--module-path "${MODULE_PATH}"
)
if [[ "${PRESERVE_DATABASE}" -ne 1 ]]; then
PUBLISH_ARGS+=(-c=on-conflict)
fi
PUBLISH_ARGS+=(--yes)
echo "[dev:rust] 发布 SpacetimeDB 模块: ${DATABASE}"
spacetime --root-dir="${SPACETIME_ROOT_DIR}" "${PUBLISH_ARGS[@]}"
fi
echo "[dev:rust] 启动 api-server"
(
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}" \
VITE_DEV_HOST="${WEB_HOST}" \
exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}"
) &
PIDS+=("$!")
NAMES+=("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}"