Merge branch 'codex/backend-rewrite-spacetimedb' of http://82.157.175.59:3000/GenarrativeAI/Genarrative into codex/backend-rewrite-spacetimedb

This commit is contained in:
2026-04-22 20:37:56 +08:00
82 changed files with 26950 additions and 1312 deletions

View File

@@ -0,0 +1,538 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
用法:
npm run deploy:rust:remote
./scripts/deploy-rust-remote.sh --name 20260422-153000
说明:
1. 在仓库根目录创建 build/<当前时间>/ 作为 Ubuntu 发布包目录。
2. 使用 Vite 构建前端 release 到目标目录的 web/。
3. 构建 api-server 的 x86_64-unknown-linux-gnu release并复制到目标目录。
4. 构建 spacetime-module 的 wasm32-unknown-unknown release并复制 wasm 到目标目录。
5. 在目标目录生成 start.sh / stop.sh用于目标服务器启动静态网站、SpacetimeDB、发布 wasm、启动 api-server。
常用参数:
--name <folder-name> 指定 build 子目录名,默认使用当前时间 YYYYmmdd-HHMMSS
--database <database> SpacetimeDB database默认 genarrative-dev
--api-port <port> api-server 端口,默认 8082
--web-port <port> 静态网站端口,默认 3000
--spacetime-port <port> SpacetimeDB 端口,默认 3101
--skip-web-build 跳过 Vite 构建,仅用于调试
--skip-api-build 跳过 api-server 构建,仅用于调试
--skip-spacetime-build 跳过 wasm 构建,仅用于调试
目标服务器要求:
Ubuntu x86_64已安装 node、spacetime CLI并允许执行目标目录内的 start.sh / stop.sh。
如果在非 Linux 主机执行本脚本,需要本机 Rust 已配置 x86_64-unknown-linux-gnu 交叉编译工具链。
EOF
}
require_command() {
local command_name="$1"
if ! command -v "${command_name}" >/dev/null 2>&1; then
echo "[deploy:rust] 缺少命令: ${command_name}" >&2
exit 1
fi
}
copy_required_file() {
local source_path="$1"
local target_path="$2"
local label="$3"
if [[ ! -f "${source_path}" ]]; then
echo "[deploy:rust] 缺少 ${label}: ${source_path}" >&2
exit 1
fi
cp "${source_path}" "${target_path}"
}
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
BUILD_ROOT="${REPO_ROOT}/build"
BUILD_NAME="$(date +%Y%m%d-%H%M%S)"
DATABASE="genarrative-dev"
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"
SKIP_WEB_BUILD=0
SKIP_API_BUILD=0
SKIP_SPACETIME_BUILD=0
BUILD_COMPLETED=0
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--name)
BUILD_NAME="${2:?缺少 --name 的值}"
shift 2
;;
--database)
DATABASE="${2:?缺少 --database 的值}"
shift 2
;;
--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
;;
--skip-web-build)
SKIP_WEB_BUILD=1
shift
;;
--skip-api-build)
SKIP_API_BUILD=1
shift
;;
--skip-spacetime-build)
SKIP_SPACETIME_BUILD=1
shift
;;
*)
echo "[deploy:rust] 未知参数: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ ! "${BUILD_NAME}" =~ ^[0-9A-Za-z._-]+$ ]]; then
echo "[deploy:rust] --name 只能包含数字、字母、点、下划线和短横线。" >&2
exit 1
fi
TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}"
WEB_DIR="${TARGET_DIR}/web"
API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server"
WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm"
cleanup_partial_build() {
if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${TARGET_DIR:-}" && -d "${TARGET_DIR}" ]]; then
echo "[deploy:rust] 清理未完成发布包: ${TARGET_DIR}" >&2
rm -rf "${TARGET_DIR}"
fi
}
trap cleanup_partial_build EXIT
if [[ -e "${TARGET_DIR}" ]]; then
echo "[deploy:rust] 目标目录已存在: ${TARGET_DIR}" >&2
exit 1
fi
require_command node
require_command cargo
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
require_command npm
fi
mkdir -p "${WEB_DIR}"
echo "[deploy:rust] 发布包目录: ${TARGET_DIR}"
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
echo "[deploy:rust] 构建 Vite release -> ${WEB_DIR}"
(
cd "${REPO_ROOT}"
node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir
)
fi
if [[ "${SKIP_API_BUILD}" -ne 1 ]]; then
echo "[deploy:rust] 构建 api-server -> x86_64-unknown-linux-gnu"
(
cd "${SERVER_RS_DIR}"
cargo build \
-p api-server \
--release \
--target x86_64-unknown-linux-gnu \
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
)
fi
copy_required_file "${API_BINARY_SOURCE}" "${TARGET_DIR}/api-server" "api-server release binary"
chmod +x "${TARGET_DIR}/api-server"
if [[ "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then
echo "[deploy:rust] 构建 spacetime-module -> wasm32-unknown-unknown"
(
cd "${SERVER_RS_DIR}"
cargo build \
-p spacetime-module \
--release \
--target wasm32-unknown-unknown \
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
)
fi
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
cat >"${TARGET_DIR}/web-server.mjs" <<'WEB_SERVER'
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const releaseDir = path.dirname(fileURLToPath(import.meta.url));
const webRoot = path.join(releaseDir, 'web');
const webHost = process.env.GENARRATIVE_WEB_HOST || '0.0.0.0';
const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000');
const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082');
const indexPath = path.join(webRoot, 'index.html');
const proxyPrefixes = [
'/api/',
'/api',
'/generated-character-drafts',
'/generated-characters',
'/generated-animations',
'/generated-custom-world-scenes',
'/generated-custom-world-covers',
'/generated-qwen-sprites',
'/healthz',
];
function isProxyPath(pathname) {
return proxyPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
function contentTypeFor(filePath) {
const ext = path.extname(filePath).toLowerCase();
const typeMap = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
};
return typeMap[ext] || 'application/octet-stream';
}
function sendFile(response, filePath) {
fs.createReadStream(filePath)
.on('error', () => {
response.writeHead(500, {'content-type': 'text/plain; charset=utf-8'});
response.end('failed to read static file');
})
.pipe(response);
}
function serveStatic(request, response, pathname) {
const decodedPath = decodeURIComponent(pathname);
const relativePath = decodedPath === '/' ? '/index.html' : decodedPath;
const filePath = path.normalize(path.join(webRoot, relativePath));
const safeRelativePath = path.relative(webRoot, filePath);
if (safeRelativePath.startsWith('..') || path.isAbsolute(safeRelativePath)) {
response.writeHead(403, {'content-type': 'text/plain; charset=utf-8'});
response.end('forbidden');
return;
}
const resolvedFilePath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
? filePath
: indexPath;
response.writeHead(200, {'content-type': contentTypeFor(resolvedFilePath)});
sendFile(response, resolvedFilePath);
}
function proxyToApi(request, response) {
const targetUrl = new URL(request.url || '/', apiTarget);
const proxyRequest = http.request(
{
hostname: targetUrl.hostname,
method: request.method,
path: `${targetUrl.pathname}${targetUrl.search}`,
port: targetUrl.port || 80,
protocol: targetUrl.protocol,
headers: {
...request.headers,
host: apiTarget.host,
},
},
(proxyResponse) => {
response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers);
proxyResponse.pipe(response);
},
);
proxyRequest.on('error', (error) => {
response.writeHead(502, {'content-type': 'application/json; charset=utf-8'});
response.end(JSON.stringify({ok: false, error: {code: 'API_PROXY_FAILED', message: error.message}}));
});
request.pipe(proxyRequest);
}
const server = http.createServer((request, response) => {
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
if (isProxyPath(url.pathname)) {
proxyToApi(request, response);
return;
}
serveStatic(request, response, url.pathname);
});
server.listen(webPort, webHost, () => {
console.log(`[web] listening on http://${webHost}:${webPort}, api target ${apiTarget.href}`);
});
WEB_SERVER
cat >"${TARGET_DIR}/start.sh" <<'START_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PID_DIR="${SCRIPT_DIR}/run"
LOG_DIR="${SCRIPT_DIR}/logs"
SPACETIME_DATA_DIR="${GENARRATIVE_SPACETIME_DATA_DIR:-${SCRIPT_DIR}/spacetimedb-data}"
SPACETIME_HOST="${GENARRATIVE_SPACETIME_HOST:-127.0.0.1}"
SPACETIME_PORT="${GENARRATIVE_SPACETIME_PORT:-3101}"
SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_SERVER_URL:-http://${SPACETIME_HOST}:${SPACETIME_PORT}}"
SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_DATABASE:-genarrative-dev}"
API_HOST="${GENARRATIVE_API_HOST:-127.0.0.1}"
API_PORT="${GENARRATIVE_API_PORT:-8082}"
API_LOG="${GENARRATIVE_API_LOG:-info,tower_http=info}"
WEB_HOST="${GENARRATIVE_WEB_HOST:-0.0.0.0}"
WEB_PORT="${GENARRATIVE_WEB_PORT:-3000}"
CLEAR_DATABASE=0
cd "${SCRIPT_DIR}"
usage() {
cat <<'EOF'
用法:
./start.sh
./start.sh --clear-database
说明:
1. 启动当前发布包内的静态网站、SpacetimeDB 与 api-server。
2. 默认发布 spacetime_module.wasm 到 GENARRATIVE_SPACETIME_DATABASE但不清库。
3. 只有显式传入 --clear-database 时才允许清空数据库重发。
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--clear-database)
CLEAR_DATABASE=1
shift
;;
*)
echo "[start] 未知参数: $1" >&2
usage >&2
exit 1
;;
esac
done
require_command() {
local command_name="$1"
if ! command -v "${command_name}" >/dev/null 2>&1; then
echo "[start] 缺少命令: ${command_name}" >&2
exit 1
fi
}
wait_for_spacetime() {
local deadline=$((SECONDS + 60))
while ((SECONDS < deadline)); do
if spacetime server ping "${SPACETIME_SERVER_URL}" >/dev/null 2>&1; then
return
fi
sleep 0.5
done
echo "[start] 等待 SpacetimeDB 就绪超时: ${SPACETIME_SERVER_URL}" >&2
exit 1
}
start_process() {
local name="$1"
shift
local pid_file="${PID_DIR}/${name}.pid"
local log_file="${LOG_DIR}/${name}.log"
if [[ -f "${pid_file}" ]] && kill -0 "$(cat "${pid_file}")" 2>/dev/null; then
echo "[start] ${name} 已在运行: $(cat "${pid_file}")"
return
fi
echo "[start] 启动 ${name}"
nohup "$@" >"${log_file}" 2>&1 &
echo "$!" >"${pid_file}"
}
require_command node
require_command spacetime
mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_DATA_DIR}"
start_process spacetimedb \
spacetime \
start \
--data-dir "${SPACETIME_DATA_DIR}" \
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
--non-interactive
wait_for_spacetime
PUBLISH_ARGS=(
publish
"${SPACETIME_DATABASE}"
--server "${SPACETIME_SERVER_URL}"
--bin-path "${SCRIPT_DIR}/spacetime_module.wasm"
--yes
)
if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
PUBLISH_ARGS+=(--delete-data always)
fi
echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}"
spacetime "${PUBLISH_ARGS[@]}"
export GENARRATIVE_API_HOST="${API_HOST}"
export GENARRATIVE_API_PORT="${API_PORT}"
export GENARRATIVE_API_LOG="${API_LOG}"
export GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER_URL}"
export GENARRATIVE_SPACETIME_DATABASE="${SPACETIME_DATABASE}"
start_process api-server "${SCRIPT_DIR}/api-server"
export GENARRATIVE_WEB_HOST="${WEB_HOST}"
export GENARRATIVE_WEB_PORT="${WEB_PORT}"
export GENARRATIVE_API_TARGET="http://${API_HOST}:${API_PORT}"
start_process web node "${SCRIPT_DIR}/web-server.mjs"
echo "[start] 完成"
echo "[start] Web: http://${WEB_HOST}:${WEB_PORT}"
echo "[start] API: http://${API_HOST}:${API_PORT}"
echo "[start] SpacetimeDB: ${SPACETIME_SERVER_URL}"
START_SCRIPT
cat >"${TARGET_DIR}/stop.sh" <<'STOP_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PID_DIR="${SCRIPT_DIR}/run"
stop_process() {
local name="$1"
local pid_file="${PID_DIR}/${name}.pid"
if [[ ! -f "${pid_file}" ]]; then
echo "[stop] ${name} 未记录 pid"
return
fi
local pid
pid="$(cat "${pid_file}")"
if kill -0 "${pid}" 2>/dev/null; then
echo "[stop] 停止 ${name} (pid=${pid})"
kill "${pid}" 2>/dev/null || true
sleep 0.5
if kill -0 "${pid}" 2>/dev/null; then
kill -9 "${pid}" 2>/dev/null || true
fi
else
echo "[stop] ${name} 未运行"
fi
rm -f "${pid_file}"
}
stop_process web
stop_process api-server
stop_process spacetimedb
echo "[stop] 完成"
STOP_SCRIPT
chmod +x "${TARGET_DIR}/start.sh" "${TARGET_DIR}/stop.sh"
cat >"${TARGET_DIR}/README.md" <<EOF
# Genarrative Ubuntu Release
构建时间:\`${BUILD_NAME}\`
## 内容
- \`web/\`Vite release 静态资源
- \`api-server\`x86_64-unknown-linux-gnu release 可执行文件
- \`spacetime_module.wasm\`wasm32-unknown-unknown release 模块
- \`web-server.mjs\`:静态网站与 API 反代入口
- \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本
## 启动
\`\`\`bash
./start.sh
\`\`\`
默认不清空 SpacetimeDB。如需开发库清库重发
\`\`\`bash
./start.sh --clear-database
\`\`\`
## 环境变量
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
- \`GENARRATIVE_SPACETIME_HOST\` / \`GENARRATIVE_SPACETIME_PORT\`
- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\`
- \`GENARRATIVE_SPACETIME_DATA_DIR\`
- OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。
EOF
BUILD_COMPLETED=1
echo "[deploy:rust] 完成: ${TARGET_DIR}"

313
scripts/dev-rust-stack.ps1 Normal file
View File

@@ -0,0 +1,313 @@
[CmdletBinding()]
param(
[Alias("h")]
[switch]$Help,
[string]$ApiHost = "127.0.0.1",
[int]$ApiPort = 8082,
[string]$WebHost = "0.0.0.0",
[int]$WebPort = 3000,
[string]$SpacetimeHost = "127.0.0.1",
[int]$SpacetimePort = 3101,
[string]$SpacetimeRootDir = "",
[string]$Database = "genarrative-dev",
[string]$Log = "info,tower_http=info",
[int]$SpacetimeStartupTimeoutSeconds = 60,
[switch]$SkipSpacetime,
[switch]$SkipPublish,
[switch]$ClearDatabase
)
$ErrorActionPreference = "Stop"
function Write-Usage {
@(
'Usage:',
' npm run dev:rust',
' .\scripts\dev-rust-stack.ps1 -ApiPort 8090 -SpacetimePort 3110',
' .\scripts\dev-rust-stack.ps1 -SkipSpacetime -SkipPublish',
' .\scripts\dev-rust-stack.ps1 -ClearDatabase',
'',
'Notes:',
' 1. Start SpacetimeDB standalone, Rust api-server, and Vite web together.',
' 2. Publish server-rs/crates/spacetime-module by default, without clearing data.',
' 3. Only -ClearDatabase appends spacetime publish --clear-database.',
' 4. Web listens on 0.0.0.0:3000 by default; API listens on 127.0.0.1:8082.'
) -join [Environment]::NewLine
}
function Quote-ProcessArgument {
param([string]$Value)
if ($null -eq $Value) {
return '""'
}
if ($Value -notmatch '[\s"]') {
return $Value
}
return '"' + $Value.Replace('"', '\"') + '"'
}
function Join-ProcessArguments {
param([string[]]$Arguments)
return (($Arguments | ForEach-Object { Quote-ProcessArgument $_ }) -join " ")
}
function Resolve-ClientHost {
param([string]$HostName)
if ($HostName -eq "0.0.0.0" -or $HostName -eq "::") {
return "127.0.0.1"
}
return $HostName
}
function Start-StackProcess {
param(
[string]$Name,
[string]$FilePath,
[string[]]$Arguments,
[string]$WorkingDirectory,
[hashtable]$Environment
)
$argumentLine = Join-ProcessArguments -Arguments $Arguments
Write-Host "[dev:rust] start ${Name}: $FilePath $argumentLine"
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $FilePath
$startInfo.Arguments = $argumentLine
$startInfo.WorkingDirectory = $WorkingDirectory
$startInfo.UseShellExecute = $false
$startInfo.RedirectStandardOutput = $false
$startInfo.RedirectStandardError = $false
$startInfo.RedirectStandardInput = $false
foreach ($entry in $Environment.GetEnumerator()) {
$startInfo.EnvironmentVariables[$entry.Key] = [string]$entry.Value
}
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw "Failed to start process: $Name"
}
return [PSCustomObject]@{
Name = $Name
Process = $process
}
}
function Stop-StackProcesses {
param([System.Collections.Generic.List[object]]$Processes)
for ($index = $Processes.Count - 1; $index -ge 0; $index--) {
$item = $Processes[$index]
$process = $item.Process
if ($null -ne $process -and -not $process.HasExited) {
Write-Host "[dev:rust] stop $($item.Name) (pid=$($process.Id))"
$taskkillCommand = Get-Command taskkill.exe -ErrorAction SilentlyContinue
if ($null -ne $taskkillCommand) {
& $taskkillCommand.Source /PID $process.Id /T /F *> $null
}
else {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
}
}
}
function Wait-ForSpacetimeServer {
param(
[string]$CommandPath,
[string]$Server,
[int]$TimeoutSeconds,
$ProcessItem
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if ($null -ne $ProcessItem -and $ProcessItem.Process.HasExited) {
throw "SpacetimeDB exited before readiness, exit code: $($ProcessItem.Process.ExitCode)"
}
& $CommandPath server ping $Server *> $null
if ($LASTEXITCODE -eq 0) {
return
}
Start-Sleep -Milliseconds 500
}
throw "Timed out waiting for SpacetimeDB readiness: $Server"
}
if ($Help) {
Write-Usage
exit 0
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
$serverRsDir = Join-Path $repoRoot "server-rs"
$manifestPath = Join-Path $serverRsDir "Cargo.toml"
$modulePath = Join-Path $serverRsDir "crates\spacetime-module"
$viteCliPath = Join-Path $repoRoot "scripts\vite-cli.mjs"
if ([string]::IsNullOrWhiteSpace($SpacetimeRootDir)) {
$SpacetimeRootDir = Join-Path $serverRsDir ".spacetimedb\local"
}
if (-not (Test-Path $manifestPath)) {
throw "Missing server-rs/Cargo.toml, cannot start Rust local stack."
}
if (-not (Test-Path (Join-Path $modulePath "Cargo.toml"))) {
throw "Missing server-rs/crates/spacetime-module/Cargo.toml, cannot publish SpacetimeDB module."
}
if (-not (Test-Path $viteCliPath)) {
throw "Missing scripts/vite-cli.mjs, cannot start web frontend."
}
$cargoCommand = Get-Command cargo -ErrorAction SilentlyContinue
$nodeCommand = Get-Command node -ErrorAction SilentlyContinue
$spacetimeCommand = Get-Command spacetime -ErrorAction SilentlyContinue
if ($null -eq $cargoCommand) {
throw "Missing cargo. Install Rust toolchain first."
}
if ($null -eq $nodeCommand) {
throw "Missing node. Install Node.js or use the project bundled runtime first."
}
if (-not $SkipSpacetime -or -not $SkipPublish) {
if ($null -eq $spacetimeCommand) {
throw "Missing spacetime CLI. Install guide: https://spacetimedb.com/install"
}
}
$spacetimeServer = "http://$SpacetimeHost`:$SpacetimePort"
$apiTargetHost = Resolve-ClientHost -HostName $ApiHost
$rustServerTarget = "http://$apiTargetHost`:$ApiPort"
$stackProcesses = New-Object System.Collections.Generic.List[object]
$exitCode = 0
Write-Host "[dev:rust] repo: $repoRoot"
Write-Host "[dev:rust] web: http://127.0.0.1:$WebPort"
Write-Host "[dev:rust] rust api: $rustServerTarget"
Write-Host "[dev:rust] spacetime: $spacetimeServer"
Write-Host "[dev:rust] database: $Database"
try {
$spacetimeProcessItem = $null
if (-not $SkipSpacetime) {
New-Item -ItemType Directory -Force -Path $SpacetimeRootDir | Out-Null
$spacetimeProcessItem = Start-StackProcess `
-Name "spacetimedb" `
-FilePath $spacetimeCommand.Source `
-Arguments @(
"--root-dir", $SpacetimeRootDir,
"start",
"--edition", "standalone",
"--listen-addr", "$SpacetimeHost`:$SpacetimePort"
) `
-WorkingDirectory $serverRsDir `
-Environment @{}
$stackProcesses.Add($spacetimeProcessItem)
}
if (-not $SkipPublish) {
Write-Host "[dev:rust] wait for SpacetimeDB readiness"
Wait-ForSpacetimeServer `
-CommandPath $spacetimeCommand.Source `
-Server $spacetimeServer `
-TimeoutSeconds $SpacetimeStartupTimeoutSeconds `
-ProcessItem $spacetimeProcessItem
$publishArgs = @(
"publish",
$Database,
"--server", $spacetimeServer,
"--module-path", $modulePath
)
if ($ClearDatabase) {
$publishArgs += "--clear-database"
}
$publishArgs += "--yes"
Write-Host "[dev:rust] publish SpacetimeDB module: $Database"
& $spacetimeCommand.Source @publishArgs
if ($LASTEXITCODE -ne 0) {
throw "spacetime publish failed, exit code: $LASTEXITCODE"
}
}
$apiEnvironment = @{
GENARRATIVE_API_HOST = $ApiHost
GENARRATIVE_API_PORT = "$ApiPort"
GENARRATIVE_API_LOG = $Log
GENARRATIVE_SPACETIME_SERVER_URL = $spacetimeServer
GENARRATIVE_SPACETIME_DATABASE = $Database
}
$apiProcessItem = Start-StackProcess `
-Name "api-server" `
-FilePath $cargoCommand.Source `
-Arguments @("run", "-p", "api-server", "--manifest-path", $manifestPath) `
-WorkingDirectory $repoRoot `
-Environment $apiEnvironment
$stackProcesses.Add($apiProcessItem)
$webEnvironment = @{
GENARRATIVE_BACKEND_STACK = "rust"
RUST_SERVER_TARGET = $rustServerTarget
GENARRATIVE_RUNTIME_SERVER_TARGET = $rustServerTarget
VITE_DEV_HOST = $WebHost
}
$webProcessItem = Start-StackProcess `
-Name "vite" `
-FilePath $nodeCommand.Source `
-Arguments @($viteCliPath, "--port=$WebPort", "--host=$WebHost") `
-WorkingDirectory $repoRoot `
-Environment $webEnvironment
$stackProcesses.Add($webProcessItem)
Write-Host "[dev:rust] local Rust stack is running. Press Ctrl+C to stop all child processes."
while ($true) {
foreach ($item in $stackProcesses) {
if ($item.Process.HasExited) {
$exitCode = $item.Process.ExitCode
Write-Host "[dev:rust] $($item.Name) exited, code: $exitCode"
throw "Child process exited, shutting down Rust local stack."
}
}
Start-Sleep -Seconds 1
}
}
catch {
if ($exitCode -eq 0) {
$exitCode = 1
}
Write-Host "[dev:rust] $($_.Exception.Message)"
}
finally {
Stop-StackProcesses -Processes $stackProcesses
}
exit $exitCode

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

@@ -0,0 +1,280 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
用法:
npm run dev:rust:sh
./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110
./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish
./scripts/dev-rust-stack.sh --clear-database
说明:
1. 默认同时启动 SpacetimeDB standalone、Rust api-server 与 Vite 前端。
2. 默认会 publish server-rs/crates/spacetime-module但不会清空数据库。
3. 只有显式传入 --clear-database 时,才会追加 spacetime publish --clear-database。
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 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] SpacetimeDB 进程在就绪前退出。" >&2
exit 1
fi
if spacetime server ping "${server}" >/dev/null 2>&1; then
return
fi
sleep 0.5
done
echo "[dev:rust] 等待 SpacetimeDB 就绪超时: ${server}" >&2
exit 1
}
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="genarrative-dev"
API_LOG="info,tower_http=info"
SPACETIME_TIMEOUT_SECONDS="60"
SKIP_SPACETIME=0
SKIP_PUBLISH=0
CLEAR_DATABASE=0
PIDS=()
NAMES=()
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
;;
--skip-spacetime)
SKIP_SPACETIME=1
shift
;;
--skip-publish)
SKIP_PUBLISH=1
shift
;;
--clear-database)
CLEAR_DATABASE=1
shift
;;
*)
echo "[dev:rust] 未知参数: $1" >&2
usage >&2
exit 1
;;
esac
done
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}"
if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
mkdir -p "${SPACETIME_ROOT_DIR}"
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
if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
echo "[dev:rust] 等待 SpacetimeDB 就绪"
wait_for_spacetime "${SPACETIME_SERVER}" "${SPACETIME_TIMEOUT_SECONDS}" "${PIDS[0]:-}"
PUBLISH_ARGS=(
publish
"${DATABASE}"
--server "${SPACETIME_SERVER}"
--module-path "${MODULE_PATH}"
)
if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
PUBLISH_ARGS+=(--clear-database)
fi
PUBLISH_ARGS+=(--yes)
echo "[dev:rust] 发布 SpacetimeDB 模块: ${DATABASE}"
spacetime "${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}"
) &
PIDS+=("$!")
NAMES+=("api-server")
echo "[dev:rust] 启动 vite"
(
cd "${REPO_ROOT}"
GENARRATIVE_BACKEND_STACK="rust" \
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}"

170
scripts/m7-api-compare.ts Normal file
View File

@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
type HttpMethod = 'GET';
interface CompareCase {
method: HttpMethod;
path: string;
}
interface CompareResult {
path: string;
nodeStatus: number;
rustStatus: number;
matched: boolean;
reason?: string;
}
const DEFAULT_NODE_BASE_URL = 'http://127.0.0.1:8081';
const DEFAULT_RUST_BASE_URL = 'http://127.0.0.1:3000';
function readEnv(name: string, fallback: string): string {
const value = process.env[name]?.trim();
return value ? value : fallback;
}
function buildCases(): CompareCase[] {
const rawPaths = process.env.M7_COMPARE_PATHS?.trim();
const paths = rawPaths
? rawPaths.split(',').map((value) => value.trim()).filter(Boolean)
: ['/healthz', '/api/auth/login-options'];
return paths.map((path) => ({
method: 'GET',
path: path.startsWith('/') ? path : `/${path}`,
}));
}
async function fetchJson(baseUrl: string, testCase: CompareCase, requestId: string) {
const url = new URL(testCase.path, baseUrl);
const response = await fetch(url, {
method: testCase.method,
headers: {
'x-request-id': requestId,
'x-genarrative-response-envelope': '1',
},
});
const text = await response.text();
const json = text ? JSON.parse(text) : null;
return {
status: response.status,
json: normalizeVolatileJson(json),
};
}
function normalizeVolatileJson(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(normalizeVolatileJson);
}
if (!value || typeof value !== 'object') {
return value;
}
const record = value as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
for (const [key, child] of Object.entries(record)) {
if (['requestId', 'timestamp', 'latencyMs'].includes(key)) {
continue;
}
normalized[key] = normalizeVolatileJson(child);
}
return normalized;
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`;
}
if (!value || typeof value !== 'object') {
return JSON.stringify(value);
}
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`);
return `{${entries.join(',')}}`;
}
async function compareCase(
nodeBaseUrl: string,
rustBaseUrl: string,
testCase: CompareCase,
): Promise<CompareResult> {
const requestId = `m7-api-compare-${testCase.path.replaceAll('/', '-')}`;
const [nodeResponse, rustResponse] = await Promise.all([
fetchJson(nodeBaseUrl, testCase, requestId),
fetchJson(rustBaseUrl, testCase, requestId),
]);
if (nodeResponse.status !== rustResponse.status) {
return {
path: testCase.path,
nodeStatus: nodeResponse.status,
rustStatus: rustResponse.status,
matched: false,
reason: 'status 不一致',
};
}
const nodeBody = stableStringify(nodeResponse.json);
const rustBody = stableStringify(rustResponse.json);
if (nodeBody !== rustBody) {
return {
path: testCase.path,
nodeStatus: nodeResponse.status,
rustStatus: rustResponse.status,
matched: false,
reason: `body 不一致\nnode=${nodeBody}\nrust=${rustBody}`,
};
}
return {
path: testCase.path,
nodeStatus: nodeResponse.status,
rustStatus: rustResponse.status,
matched: true,
};
}
async function main() {
const nodeBaseUrl = readEnv('M7_NODE_BASE_URL', DEFAULT_NODE_BASE_URL);
const rustBaseUrl = readEnv('M7_RUST_BASE_URL', DEFAULT_RUST_BASE_URL);
const strict = process.env.M7_COMPARE_STRICT?.trim() !== 'false';
const cases = buildCases();
console.log(`[m7:api-compare] node=${nodeBaseUrl}`);
console.log(`[m7:api-compare] rust=${rustBaseUrl}`);
console.log(`[m7:api-compare] cases=${cases.map((item) => item.path).join(', ')}`);
const results = await Promise.all(
cases.map((testCase) => compareCase(nodeBaseUrl, rustBaseUrl, testCase)),
);
for (const result of results) {
const label = result.matched ? 'OK' : 'DIFF';
console.log(
`[m7:api-compare] ${label} ${result.path} node=${result.nodeStatus} rust=${result.rustStatus}`,
);
if (result.reason) {
console.log(result.reason);
}
}
const failures = results.filter((result) => !result.matched);
if (strict) {
assert.equal(failures.length, 0, '存在 Node/Rust API contract 差异');
}
}
main().catch((error) => {
console.error('[m7:api-compare] failed');
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,55 @@
import {existsSync} from 'node:fs';
import {spawn} from 'node:child_process';
const [, , scriptPath, ...scriptArgs] = process.argv;
if (!scriptPath) {
console.error('[run-bash-script] missing script path.');
process.exit(1);
}
function resolveBashCommand() {
if (process.env.GENARRATIVE_BASH) {
return process.env.GENARRATIVE_BASH;
}
if (process.platform !== 'win32') {
return 'bash';
}
const candidates = [
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
'C:\\msys64\\usr\\bin\\bash.exe',
];
const matched = candidates.find((candidate) => existsSync(candidate));
if (matched) {
return matched;
}
return 'bash';
}
const bashCommand = resolveBashCommand();
const child = spawn(bashCommand, [scriptPath, ...scriptArgs], {
cwd: process.cwd(),
env: process.env,
stdio: 'inherit',
shell: false,
});
child.on('error', (error) => {
console.error(`[run-bash-script] failed to start bash: ${error.message}`);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
console.error(`[run-bash-script] bash exited by signal: ${signal}`);
process.exit(1);
}
process.exit(code ?? 0);
});