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:
538
scripts/deploy-rust-remote.sh
Normal file
538
scripts/deploy-rust-remote.sh
Normal 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
313
scripts/dev-rust-stack.ps1
Normal 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
280
scripts/dev-rust-stack.sh
Normal 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
170
scripts/m7-api-compare.ts
Normal 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;
|
||||
});
|
||||
55
scripts/run-bash-script.mjs
Normal file
55
scripts/run-bash-script.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user