Prune obsolete docs and update navigation
This commit is contained in:
@@ -1,127 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const baselinePath = path.join(repoRoot, 'scripts', 'server-node-freeze-baseline.json');
|
||||
const needle = 'server-node';
|
||||
|
||||
const ignoredDirectories = new Set([
|
||||
'.git',
|
||||
'.codex',
|
||||
'.codex-temp',
|
||||
'.idea',
|
||||
'node_modules',
|
||||
'dist',
|
||||
'build',
|
||||
'coverage',
|
||||
'target',
|
||||
'logs',
|
||||
]);
|
||||
|
||||
const ignoredFiles = new Set([
|
||||
'scripts/check-server-node-freeze.mjs',
|
||||
'scripts/server-node-freeze-baseline.json',
|
||||
'scripts/server-node-frozen.mjs',
|
||||
'docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md',
|
||||
]);
|
||||
|
||||
const allowedExtensions = new Set([
|
||||
'.cjs',
|
||||
'.js',
|
||||
'.json',
|
||||
'.md',
|
||||
'.mjs',
|
||||
'.ps1',
|
||||
'.rs',
|
||||
'.toml',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
]);
|
||||
|
||||
function toRepoPath(absolutePath) {
|
||||
return path.relative(repoRoot, absolutePath).replaceAll(path.sep, '/');
|
||||
}
|
||||
|
||||
function hashLine(line) {
|
||||
return createHash('sha256').update(line.trim()).digest('hex');
|
||||
}
|
||||
|
||||
function walk(directory, output) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignoredDirectories.has(entry.name)) {
|
||||
walk(path.join(directory, entry.name), output);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
const repoPath = toRepoPath(absolutePath);
|
||||
if (ignoredFiles.has(repoPath)) {
|
||||
continue;
|
||||
}
|
||||
if (!allowedExtensions.has(path.extname(entry.name).toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
output.push(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
function collectReferences() {
|
||||
const files = [];
|
||||
walk(repoRoot, files);
|
||||
|
||||
const references = new Map();
|
||||
for (const file of files) {
|
||||
const repoPath = toRepoPath(file);
|
||||
const content = readFileSync(file, 'utf8');
|
||||
const lines = content.split(/\r?\n/u);
|
||||
for (const line of lines) {
|
||||
if (!line.toLowerCase().includes(needle)) {
|
||||
continue;
|
||||
}
|
||||
const key = `${repoPath}\u0000${hashLine(line)}`;
|
||||
references.set(key, (references.get(key) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
function parseBaseline() {
|
||||
if (!existsSync(baselinePath)) {
|
||||
return new Map();
|
||||
}
|
||||
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
|
||||
return new Map(Object.entries(baseline.references || {}));
|
||||
}
|
||||
|
||||
const currentReferences = collectReferences();
|
||||
const baselineReferences = parseBaseline();
|
||||
const newReferences = [];
|
||||
|
||||
for (const [key, count] of currentReferences.entries()) {
|
||||
const allowedCount = baselineReferences.get(key) || 0;
|
||||
if (count > allowedCount) {
|
||||
const [repoPath] = key.split('\u0000');
|
||||
newReferences.push({ repoPath, count: count - allowedCount });
|
||||
}
|
||||
}
|
||||
|
||||
if (newReferences.length > 0) {
|
||||
console.error('检测到冻结后新增的 server-node 引用,请迁移到 server-rs 或更新废弃审计后再处理:');
|
||||
for (const reference of newReferences.slice(0, 50)) {
|
||||
console.error(`- ${reference.repoPath} (+${reference.count})`);
|
||||
}
|
||||
if (newReferences.length > 50) {
|
||||
console.error(`... 另有 ${newReferences.length - 50} 处`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('server-node freeze guard passed: 未发现冻结后新增引用。');
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/deploy.sh <backend_dir>
|
||||
|
||||
示例:
|
||||
./scripts/deploy.sh /work/server-node
|
||||
|
||||
说明:
|
||||
1. 进入指定后端目录
|
||||
2. 构建后端
|
||||
3. 重启已有的 genarrative-server
|
||||
4. 如果 PM2 进程不存在,则使用 ecosystem.config.cjs 创建
|
||||
|
||||
注意:
|
||||
- 不会执行 git pull
|
||||
- 不会同步文件
|
||||
- 不会构建前端
|
||||
EOF
|
||||
}
|
||||
|
||||
require_command() {
|
||||
local command_name="$1"
|
||||
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
echo "[deploy] 缺少命令: $command_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
BACKEND_DIR="${1:-}"
|
||||
|
||||
if [[ -z "${BACKEND_DIR}" || "${BACKEND_DIR}" == "-h" || "${BACKEND_DIR}" == "--help" ]]; then
|
||||
usage
|
||||
if [[ -z "${BACKEND_DIR}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
require_command npm
|
||||
require_command pm2
|
||||
|
||||
if [[ ! -d "${BACKEND_DIR}" ]]; then
|
||||
echo "[deploy] 后端目录不存在: ${BACKEND_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${BACKEND_DIR}/ecosystem.config.cjs" ]]; then
|
||||
echo "[deploy] 缺少 PM2 配置文件: ${BACKEND_DIR}/ecosystem.config.cjs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[deploy] 后端目录: ${BACKEND_DIR}"
|
||||
|
||||
cd "${BACKEND_DIR}"
|
||||
|
||||
# 重新构建后端产物。
|
||||
echo "[deploy] 构建后端"
|
||||
npm run build
|
||||
|
||||
# 优先重启;如果进程还不存在,就直接创建。
|
||||
echo "[deploy] 重启或创建 PM2 服务"
|
||||
pm2 restart genarrative-server --update-env \
|
||||
|| pm2 start ecosystem.config.cjs
|
||||
|
||||
echo "[deploy] 完成"
|
||||
@@ -1,548 +0,0 @@
|
||||
import {spawn, spawnSync} from 'node:child_process';
|
||||
import {existsSync, readFileSync} from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath, pathToFileURL} from 'node:url';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
|
||||
const serverRoot = fileURLToPath(new URL('../server-node/', import.meta.url));
|
||||
const serverRsRoot = fileURLToPath(new URL('../server-rs/', import.meta.url));
|
||||
const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url));
|
||||
const serverTsxCliPath = fileURLToPath(
|
||||
new URL('../server-node/node_modules/tsx/dist/cli.mjs', import.meta.url),
|
||||
);
|
||||
const serverTsxLoaderPath = fileURLToPath(
|
||||
new URL('../server-node/node_modules/tsx/dist/loader.mjs', import.meta.url),
|
||||
);
|
||||
const serverTsxLoaderUrl = pathToFileURL(serverTsxLoaderPath).href;
|
||||
const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url));
|
||||
const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url));
|
||||
const spacetimeConfigPath = fileURLToPath(new URL('../spacetime.json', import.meta.url));
|
||||
const spacetimeLocalConfigPath = fileURLToPath(new URL('../spacetime.local.json', import.meta.url));
|
||||
const bundledNodePath = fileURLToPath(
|
||||
new URL('../.tools/node-v22.22.2-win-x64/node.exe', import.meta.url),
|
||||
);
|
||||
const bundledNpmCliPath = fileURLToPath(
|
||||
new URL('../.tools/node-v22.22.2-win-x64/node_modules/npm/bin/npm-cli.js', import.meta.url),
|
||||
);
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const DEFAULT_DEV_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/genarrative';
|
||||
const DEV_MEMORY_DATABASE_URL = 'pg-mem://genarrative-dev';
|
||||
const DEFAULT_RUST_API_HOST = '127.0.0.1';
|
||||
const DEFAULT_RUST_API_PORT = '3100';
|
||||
const DEFAULT_SPACETIME_SERVER_URL = 'http://127.0.0.1:3001';
|
||||
const DEFAULT_SPACETIME_DATABASE = 'genarrative-dev';
|
||||
const DEFAULT_INTERNAL_API_SECRET = 'genarrative-dev-internal-bridge';
|
||||
const spacetimeModulePath = path.join(serverRsRoot, 'crates', 'spacetime-module');
|
||||
const spacetimeRustBindingsOutDir = path.join(
|
||||
serverRsRoot,
|
||||
'crates',
|
||||
'spacetime-client',
|
||||
'src',
|
||||
'module_bindings',
|
||||
);
|
||||
|
||||
function parseEnvContents(contents) {
|
||||
return contents
|
||||
.split(/\r?\n/u)
|
||||
.reduce((envMap, rawLine) => {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex < 0) {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
let value = line.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
envMap[key] = value;
|
||||
return envMap;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return parseEnvContents(readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDatabaseProbeTarget(databaseUrl) {
|
||||
const trimmed = databaseUrl.trim();
|
||||
if (!trimmed || !/^postgres(?:ql)?:\/\//u.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return {
|
||||
host: url.hostname === '0.0.0.0' ? '127.0.0.1' : url.hostname,
|
||||
port: Number(url.port || 5432),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function checkTcpReachable(target, timeoutMs = 1500) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection(target);
|
||||
let settled = false;
|
||||
|
||||
const finish = (result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('timeout', () => finish(false));
|
||||
socket.once('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
function resolveServerTarget(serverAddr) {
|
||||
const trimmed = serverAddr.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return 'http://127.0.0.1:8081';
|
||||
}
|
||||
|
||||
if (/^https?:\/\//u.test(trimmed)) {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.hostname === '0.0.0.0') {
|
||||
url.hostname = '127.0.0.1';
|
||||
}
|
||||
return url.toString().replace(/\/$/u, '');
|
||||
} catch {
|
||||
return trimmed.replace(/\/$/u, '');
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith(':')) {
|
||||
return `http://127.0.0.1${trimmed}`;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('0.0.0.0:')) {
|
||||
return `http://127.0.0.1:${trimmed.slice('0.0.0.0:'.length)}`;
|
||||
}
|
||||
|
||||
return `http://${trimmed}`;
|
||||
}
|
||||
|
||||
function redactDatabaseUrl(databaseUrl) {
|
||||
const trimmed = `${databaseUrl || ''}`.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return '[missing]';
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('pg-mem://')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const databaseName = url.pathname.replace(/^\/+/u, '') || 'postgres';
|
||||
const portSuffix = url.port ? `:${url.port}` : '';
|
||||
return `${url.protocol}//${url.hostname}${portSuffix}/${databaseName}`;
|
||||
} catch {
|
||||
return '[configured]';
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePathEnvKey(envMap) {
|
||||
return Object.keys(envMap).find((key) => key.toLowerCase() === 'path') || 'PATH';
|
||||
}
|
||||
|
||||
function prependEnvPath(envMap, nextEntry) {
|
||||
const pathKey = resolvePathEnvKey(envMap);
|
||||
const currentValue = envMap[pathKey] || '';
|
||||
const normalizedEntry = path.resolve(nextEntry);
|
||||
const segments = currentValue
|
||||
.split(path.delimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.filter((entry) => {
|
||||
try {
|
||||
return path.resolve(entry) !== normalizedEntry;
|
||||
} catch {
|
||||
return entry !== nextEntry;
|
||||
}
|
||||
});
|
||||
|
||||
envMap[pathKey] = [nextEntry, ...segments].join(path.delimiter);
|
||||
}
|
||||
|
||||
function resolveSpacetimeCommand() {
|
||||
const command = process.platform === 'win32' ? 'where' : 'which';
|
||||
const result = spawnSync(command, ['spacetime'], {
|
||||
cwd: repoRoot,
|
||||
env: mergedEnv,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstLine = `${result.stdout || ''}`
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
|
||||
return firstLine || 'spacetime';
|
||||
}
|
||||
|
||||
function runRequiredCommand(command, args, label) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.error(`[dev:node] ${label} failed with exit code ${result.status ?? 1}`);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSpacetimeSchemaReady() {
|
||||
const spacetimeCommand = resolveSpacetimeCommand();
|
||||
if (!spacetimeCommand) {
|
||||
console.error(
|
||||
'[dev:node] Missing `spacetime` CLI. Install or expose it in PATH before starting local dev.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const spacetimeServerUrl = `${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || ''}`.trim();
|
||||
const spacetimeDatabase = `${mergedEnv.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim();
|
||||
|
||||
if (!spacetimeServerUrl || !spacetimeDatabase) {
|
||||
console.error(
|
||||
'[dev:node] Missing GENARRATIVE_SPACETIME_SERVER_URL or GENARRATIVE_SPACETIME_DATABASE, cannot publish local schema.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[dev:node] Publishing spacetime-module to ${spacetimeDatabase} (${spacetimeServerUrl}) before Rust api-server starts...`,
|
||||
);
|
||||
runRequiredCommand(
|
||||
spacetimeCommand,
|
||||
[
|
||||
'publish',
|
||||
spacetimeDatabase,
|
||||
'--server',
|
||||
spacetimeServerUrl,
|
||||
'--module-path',
|
||||
spacetimeModulePath,
|
||||
'--yes',
|
||||
...(mergedEnv.GENARRATIVE_SPACETIME_DELETE_DATA_ON_CONFLICT === '1'
|
||||
? ['--delete-data=on-conflict']
|
||||
: []),
|
||||
],
|
||||
'spacetime publish',
|
||||
);
|
||||
|
||||
console.log('[dev:node] Generating Rust Spacetime bindings before Rust api-server starts...');
|
||||
runRequiredCommand(
|
||||
spacetimeCommand,
|
||||
[
|
||||
'generate',
|
||||
'--no-config',
|
||||
'--lang',
|
||||
'rust',
|
||||
'--out-dir',
|
||||
spacetimeRustBindingsOutDir,
|
||||
'--module-path',
|
||||
spacetimeModulePath,
|
||||
'--include-private',
|
||||
'--yes',
|
||||
],
|
||||
'spacetime generate (rust)',
|
||||
);
|
||||
}
|
||||
|
||||
const exampleEnv = readEnvFile(envExamplePath);
|
||||
const localEnv = readEnvFile(envLocalPath);
|
||||
const spacetimeConfig = readJsonFile(spacetimeConfigPath);
|
||||
const spacetimeLocalConfig = readJsonFile(spacetimeLocalConfigPath);
|
||||
|
||||
const mergedEnv = {
|
||||
...exampleEnv,
|
||||
...localEnv,
|
||||
...process.env,
|
||||
};
|
||||
|
||||
const runtimeNodePath = existsSync(bundledNodePath)
|
||||
? bundledNodePath
|
||||
: process.execPath;
|
||||
const runtimeNpmCliPath = existsSync(bundledNpmCliPath)
|
||||
? bundledNpmCliPath
|
||||
: '';
|
||||
const runtimeNodeDir = path.dirname(runtimeNodePath);
|
||||
|
||||
mergedEnv.PROJECT_ROOT = mergedEnv.PROJECT_ROOT || repoRoot;
|
||||
mergedEnv.NODE_SERVER_ADDR = mergedEnv.NODE_SERVER_ADDR || ':8081';
|
||||
mergedEnv.NODE_SERVER_TARGET =
|
||||
mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR);
|
||||
mergedEnv.GENARRATIVE_API_HOST =
|
||||
mergedEnv.GENARRATIVE_API_HOST || DEFAULT_RUST_API_HOST;
|
||||
mergedEnv.GENARRATIVE_API_PORT =
|
||||
mergedEnv.GENARRATIVE_API_PORT || DEFAULT_RUST_API_PORT;
|
||||
mergedEnv.GENARRATIVE_API_TARGET =
|
||||
mergedEnv.GENARRATIVE_API_TARGET ||
|
||||
`http://${mergedEnv.GENARRATIVE_API_HOST}:${mergedEnv.GENARRATIVE_API_PORT}`;
|
||||
mergedEnv.GENARRATIVE_INTERNAL_API_SECRET =
|
||||
mergedEnv.GENARRATIVE_INTERNAL_API_SECRET || DEFAULT_INTERNAL_API_SECRET;
|
||||
mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL =
|
||||
mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || DEFAULT_SPACETIME_SERVER_URL;
|
||||
mergedEnv.GENARRATIVE_SPACETIME_DATABASE =
|
||||
mergedEnv.GENARRATIVE_SPACETIME_DATABASE ||
|
||||
spacetimeLocalConfig?.database ||
|
||||
spacetimeConfig?.database ||
|
||||
DEFAULT_SPACETIME_DATABASE;
|
||||
mergedEnv.DATABASE_URL =
|
||||
mergedEnv.DATABASE_URL || DEFAULT_DEV_DATABASE_URL;
|
||||
mergedEnv.VITE_DEV_HOST = mergedEnv.VITE_DEV_HOST || '127.0.0.1';
|
||||
prependEnvPath(mergedEnv, runtimeNodeDir);
|
||||
mergedEnv.npm_config_scripts_prepend_node_path = 'true';
|
||||
|
||||
const exampleDatabaseUrl = `${exampleEnv.DATABASE_URL || ''}`.trim();
|
||||
const localDatabaseUrl = `${localEnv.DATABASE_URL || ''}`.trim();
|
||||
const processDatabaseUrl = `${process.env.DATABASE_URL || ''}`.trim();
|
||||
const exampleSpacetimeDatabase = `${exampleEnv.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim();
|
||||
const localSpacetimeDatabase = `${localEnv.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim();
|
||||
const processSpacetimeDatabase = `${process.env.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim();
|
||||
const hasExplicitDatabaseUrl =
|
||||
Boolean(processDatabaseUrl) ||
|
||||
(Boolean(localDatabaseUrl) && localDatabaseUrl !== exampleDatabaseUrl);
|
||||
const hasExplicitSpacetimeDatabase =
|
||||
Boolean(processSpacetimeDatabase) ||
|
||||
(Boolean(localSpacetimeDatabase) && localSpacetimeDatabase !== exampleSpacetimeDatabase);
|
||||
|
||||
// 本地开发默认跟随仓库当前的 Spacetime 数据库名,只有显式覆盖时才尊重环境变量。
|
||||
if (!hasExplicitSpacetimeDatabase) {
|
||||
mergedEnv.GENARRATIVE_SPACETIME_DATABASE =
|
||||
spacetimeLocalConfig?.database ||
|
||||
spacetimeConfig?.database ||
|
||||
DEFAULT_SPACETIME_DATABASE;
|
||||
}
|
||||
|
||||
if (!hasExplicitDatabaseUrl) {
|
||||
const databaseProbeTarget = resolveDatabaseProbeTarget(mergedEnv.DATABASE_URL);
|
||||
if (databaseProbeTarget) {
|
||||
const isReachable = await checkTcpReachable(databaseProbeTarget);
|
||||
if (!isReachable) {
|
||||
console.warn(
|
||||
`[dev:node] PostgreSQL unavailable at ${databaseProbeTarget.host}:${databaseProbeTarget.port}; falling back to ${DEV_MEMORY_DATABASE_URL} for local dev.`,
|
||||
);
|
||||
console.warn(
|
||||
'[dev:node] Current session will use in-memory persistence only. Set DATABASE_URL in .env.local to restore PostgreSQL-backed runtime data.',
|
||||
);
|
||||
mergedEnv.DATABASE_URL = DEV_MEMORY_DATABASE_URL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[dev:node] PROJECT_ROOT=${mergedEnv.PROJECT_ROOT}`);
|
||||
console.log(`[dev:node] NODE_SERVER_ADDR=${mergedEnv.NODE_SERVER_ADDR}`);
|
||||
console.log(`[dev:node] NODE_SERVER_TARGET=${mergedEnv.NODE_SERVER_TARGET}`);
|
||||
console.log(`[dev:node] GENARRATIVE_API_TARGET=${mergedEnv.GENARRATIVE_API_TARGET}`);
|
||||
console.log('[dev:node] GENARRATIVE_INTERNAL_API_SECRET=[configured]');
|
||||
console.log(
|
||||
`[dev:node] GENARRATIVE_SPACETIME_SERVER_URL=${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`,
|
||||
);
|
||||
console.log(
|
||||
`[dev:node] GENARRATIVE_SPACETIME_DATABASE=${mergedEnv.GENARRATIVE_SPACETIME_DATABASE}`,
|
||||
);
|
||||
console.log(`[dev:node] DATABASE_URL=${redactDatabaseUrl(mergedEnv.DATABASE_URL)}`);
|
||||
console.log(`[dev:node] VITE_DEV_HOST=${mergedEnv.VITE_DEV_HOST}`);
|
||||
console.log(`[dev:node] NODE_RUNTIME=${runtimeNodePath}`);
|
||||
|
||||
ensureSpacetimeSchemaReady();
|
||||
|
||||
const children = new Set();
|
||||
let shuttingDown = false;
|
||||
let pendingExitCode = 0;
|
||||
|
||||
function stopChild(child) {
|
||||
if (!child || child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill('SIGTERM');
|
||||
|
||||
setTimeout(() => {
|
||||
if (child.exitCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}, 2000).unref();
|
||||
}
|
||||
|
||||
function stopAllChildren() {
|
||||
for (const child of children) {
|
||||
stopChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeExit(code = 0) {
|
||||
pendingExitCode = code;
|
||||
if (children.size === 0) {
|
||||
process.exit(pendingExitCode);
|
||||
}
|
||||
}
|
||||
|
||||
function requestShutdown(code = 0) {
|
||||
if (!shuttingDown) {
|
||||
shuttingDown = true;
|
||||
pendingExitCode = code;
|
||||
stopAllChildren();
|
||||
}
|
||||
|
||||
finalizeExit(pendingExitCode);
|
||||
}
|
||||
|
||||
function registerChild(name, child, siblingProvider) {
|
||||
children.add(child);
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`[dev:node] ${name} failed to start`, error);
|
||||
requestShutdown(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
children.delete(child);
|
||||
|
||||
if (!shuttingDown) {
|
||||
const resolvedExitCode = code ?? 1;
|
||||
const signalSuffix = signal ? ` (${signal})` : '';
|
||||
console.error(
|
||||
`[dev:node] ${name} exited with code ${resolvedExitCode}${signalSuffix}`,
|
||||
);
|
||||
|
||||
const sibling = siblingProvider();
|
||||
if (sibling) {
|
||||
stopChild(sibling);
|
||||
}
|
||||
|
||||
requestShutdown(resolvedExitCode);
|
||||
return;
|
||||
}
|
||||
|
||||
finalizeExit(pendingExitCode);
|
||||
});
|
||||
}
|
||||
|
||||
const serverProcess = existsSync(serverTsxLoaderPath)
|
||||
? spawn(runtimeNodePath, ['--watch', '--import', serverTsxLoaderUrl, 'src/server.ts'], {
|
||||
cwd: serverRoot,
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
: existsSync(serverTsxCliPath)
|
||||
? spawn(runtimeNodePath, [serverTsxCliPath, 'watch', 'src/server.ts'], {
|
||||
cwd: serverRoot,
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
: runtimeNpmCliPath
|
||||
? spawn(runtimeNodePath, [runtimeNpmCliPath, 'run', 'dev'], {
|
||||
cwd: serverRoot,
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
: spawn(npmCommand, ['run', 'dev'], {
|
||||
cwd: serverRoot,
|
||||
env: mergedEnv,
|
||||
shell: process.platform === 'win32',
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
const rustApiProcess = process.platform === 'win32'
|
||||
? spawn(
|
||||
'powershell',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
path.join(serverRsRoot, 'scripts', 'dev.ps1'),
|
||||
'-ApiHost',
|
||||
mergedEnv.GENARRATIVE_API_HOST,
|
||||
'-Port',
|
||||
mergedEnv.GENARRATIVE_API_PORT,
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
)
|
||||
: spawn(
|
||||
'bash',
|
||||
[
|
||||
path.join(serverRsRoot, 'scripts', 'dev.sh'),
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
|
||||
const viteProcess = spawn(
|
||||
runtimeNodePath,
|
||||
[viteCliPath, '--port=3000', `--host=${mergedEnv.VITE_DEV_HOST}`],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
|
||||
registerChild('node server', serverProcess, () => viteProcess);
|
||||
registerChild('rust api-server', rustApiProcess, () => viteProcess);
|
||||
registerChild('vite dev server', viteProcess, () => serverProcess);
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('[dev:node] received SIGINT, shutting down...');
|
||||
requestShutdown(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[dev:node] received SIGTERM, shutting down...');
|
||||
requestShutdown(0);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ usage() {
|
||||
用法:
|
||||
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
|
||||
@@ -183,6 +184,7 @@ 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
|
||||
@@ -256,6 +258,10 @@ while [[ $# -gt 0 ]]; do
|
||||
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
|
||||
@@ -322,6 +328,7 @@ 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}"
|
||||
@@ -375,12 +382,11 @@ PIDS+=("${API_PID}")
|
||||
NAMES+=("api-server")
|
||||
|
||||
echo "[dev:rust] 等待 api-server 就绪"
|
||||
wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${SPACETIME_TIMEOUT_SECONDS}" "${API_PID}"
|
||||
wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}"
|
||||
|
||||
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}" \
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
当前正式开发入口统一为:
|
||||
|
||||
- `node scripts/dev-node.mjs`
|
||||
- `server-node/src/modules/editor/**`
|
||||
- `server-node/src/modules/assets/**`
|
||||
- `src/editor/shared/editorApiClient.ts`
|
||||
- `npm run dev`
|
||||
- `scripts/dev-rust-stack.sh`
|
||||
- `server-rs/crates/api-server/**`
|
||||
- `server-rs/crates/spacetime-module/**`
|
||||
|
||||
该目录只保留本说明文件,作为迁移结果标记。
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import {spawn} from 'node:child_process';
|
||||
|
||||
const mergedEnv = {
|
||||
...process.env,
|
||||
GENARRATIVE_BACKEND_STACK: process.env.GENARRATIVE_BACKEND_STACK || 'rust',
|
||||
RUST_SERVER_TARGET:
|
||||
process.env.RUST_SERVER_TARGET ||
|
||||
process.env.GENARRATIVE_API_TARGET ||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
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;
|
||||
});
|
||||
@@ -53,9 +53,10 @@ function normalizePathForCaddy(filePath) {
|
||||
|
||||
function resolveApiUpstream(env) {
|
||||
return (
|
||||
env.CADDY_API_UPSTREAM
|
||||
|| env.NODE_SERVER_TARGET
|
||||
|| 'http://127.0.0.1:8081'
|
||||
env.CADDY_API_UPSTREAM ||
|
||||
env.GENARRATIVE_API_TARGET ||
|
||||
env.RUST_SERVER_TARGET ||
|
||||
'http://127.0.0.1:3100'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const command = process.env.npm_lifecycle_event || 'server-node:*';
|
||||
|
||||
console.error(`[server-node frozen] ${command} 已冻结。`);
|
||||
console.error('后端主线已切换到 server-rs(Rust + SpacetimeDB),禁止继续运行或扩展 server-node。');
|
||||
console.error('如需开发后端能力,请使用 npm run dev:rust 或 server-rs/scripts/*。');
|
||||
process.exit(1);
|
||||
@@ -1,436 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { httpRequest } from '../server-node/src/testHttp.ts';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const repoRoot = path.resolve(path.dirname(scriptPath), '..');
|
||||
const bundledNodePath = path.join(
|
||||
repoRoot,
|
||||
'.tools',
|
||||
'node-v22.22.2-win-x64',
|
||||
process.platform === 'win32' ? 'node.exe' : 'bin/node',
|
||||
);
|
||||
const runtimeNodePath = fs.existsSync(bundledNodePath)
|
||||
? bundledNodePath
|
||||
: process.execPath;
|
||||
const serverBuildPath = path.join(repoRoot, 'server-node', 'dist', 'server.cjs');
|
||||
const webBuildPath = path.join(repoRoot, 'dist', 'index.html');
|
||||
const publicRoot = path.join(repoRoot, 'public');
|
||||
const proxyPort = 18080;
|
||||
const nodePort = 18081;
|
||||
const proxyBaseUrl = `http://127.0.0.1:${proxyPort}`;
|
||||
const nodeBaseUrl = `http://127.0.0.1:${nodePort}`;
|
||||
|
||||
type ManagedChild = {
|
||||
name: string;
|
||||
process: ChildProcess;
|
||||
};
|
||||
|
||||
function assertBuildArtifacts() {
|
||||
if (!fs.existsSync(serverBuildPath)) {
|
||||
throw new Error(
|
||||
'server-node/dist/server.cjs 不存在,请先运行 npm run server-node:build',
|
||||
);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(webBuildPath)) {
|
||||
throw new Error('dist/index.html 不存在,请先运行 npm run build');
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForReady(
|
||||
label: string,
|
||||
url: string,
|
||||
validate: (bodyText: string, status: number) => void,
|
||||
timeoutMs = 20000,
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const response = await httpRequest(url);
|
||||
const bodyText = await response.text();
|
||||
validate(bodyText, response.status);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[smoke:proxy] ${label} 未在 ${timeoutMs}ms 内就绪: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function spawnManagedChild(
|
||||
name: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): ManagedChild {
|
||||
const child = spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`[smoke:proxy] ${name} 启动失败`, error);
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
process: child,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopChild(child: ManagedChild | null) {
|
||||
if (!child || child.process.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.process.kill('SIGTERM');
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
child.process.once('exit', () => resolve());
|
||||
}),
|
||||
sleep(2000),
|
||||
]);
|
||||
|
||||
if (child.process.exitCode === null) {
|
||||
child.process.kill('SIGKILL');
|
||||
await new Promise<void>((resolve) => {
|
||||
child.process.once('exit', () => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function contentTypeFor(filePath: string) {
|
||||
if (filePath.endsWith('.html')) {
|
||||
return 'text/html; charset=utf-8';
|
||||
}
|
||||
if (filePath.endsWith('.js')) {
|
||||
return 'text/javascript; charset=utf-8';
|
||||
}
|
||||
if (filePath.endsWith('.css')) {
|
||||
return 'text/css; charset=utf-8';
|
||||
}
|
||||
if (filePath.endsWith('.json')) {
|
||||
return 'application/json; charset=utf-8';
|
||||
}
|
||||
if (filePath.endsWith('.png')) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (filePath.endsWith('.jpg') || filePath.endsWith('.jpeg')) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (filePath.endsWith('.webp')) {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (filePath.endsWith('.svg')) {
|
||||
return 'image/svg+xml; charset=utf-8';
|
||||
}
|
||||
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function resolveStaticFile(urlPath: string) {
|
||||
const cleanPath = decodeURIComponent(urlPath.split('?')[0] || '/');
|
||||
const normalizedPath = cleanPath === '/' ? '/index.html' : cleanPath;
|
||||
const trimmedRelativePath = normalizedPath.replace(/^\/+/u, '');
|
||||
const distRoot = path.resolve(repoRoot, 'dist');
|
||||
const publicCandidatePath = path.resolve(publicRoot, trimmedRelativePath);
|
||||
const distCandidatePath = path.resolve(distRoot, trimmedRelativePath);
|
||||
|
||||
if (
|
||||
publicCandidatePath.startsWith(publicRoot) &&
|
||||
fs.existsSync(publicCandidatePath) &&
|
||||
fs.statSync(publicCandidatePath).isFile()
|
||||
) {
|
||||
return publicCandidatePath;
|
||||
}
|
||||
|
||||
if (
|
||||
distCandidatePath.startsWith(distRoot) &&
|
||||
fs.existsSync(distCandidatePath) &&
|
||||
fs.statSync(distCandidatePath).isFile()
|
||||
) {
|
||||
return distCandidatePath;
|
||||
}
|
||||
|
||||
return webBuildPath;
|
||||
}
|
||||
|
||||
async function startSameOriginProxy() {
|
||||
const server = http.createServer((request, response) => {
|
||||
const requestUrl = request.url || '/';
|
||||
|
||||
if (requestUrl === '/healthz') {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
});
|
||||
response.end('ok');
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.startsWith('/api/')) {
|
||||
const upstream = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: nodePort,
|
||||
path: requestUrl,
|
||||
method: request.method,
|
||||
headers: {
|
||||
...request.headers,
|
||||
host: `127.0.0.1:${nodePort}`,
|
||||
},
|
||||
},
|
||||
(upstreamResponse) => {
|
||||
response.writeHead(
|
||||
upstreamResponse.statusCode ?? 502,
|
||||
upstreamResponse.headers,
|
||||
);
|
||||
upstreamResponse.pipe(response);
|
||||
},
|
||||
);
|
||||
|
||||
upstream.on('error', (error) => {
|
||||
response.writeHead(502, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
});
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
error instanceof Error ? error.message : 'proxy upstream failed',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
request.pipe(upstream);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolveStaticFile(requestUrl);
|
||||
response.writeHead(200, {
|
||||
'Content-Type': contentTypeFor(filePath),
|
||||
});
|
||||
fs.createReadStream(filePath).pipe(response);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(proxyPort, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async function stopProxyServer(server: http.Server | null) {
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function authEntry(baseUrl: string) {
|
||||
const requestId = 'proxy-smoke-auth-entry';
|
||||
const username = `proxy_${Date.now().toString(36)}`;
|
||||
const response = await httpRequest(`${baseUrl}/api/auth/entry`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-Id': requestId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password: 'proxy-secret-123',
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
token: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get('x-request-id'), requestId);
|
||||
assert.equal(payload.user.username, username);
|
||||
assert.ok(payload.token);
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertBuildArtifacts();
|
||||
|
||||
let serverChild: ManagedChild | null = null;
|
||||
let proxyServer: http.Server | null = null;
|
||||
|
||||
try {
|
||||
console.log('[smoke:proxy] starting built node server');
|
||||
serverChild = spawnManagedChild(
|
||||
'server-node',
|
||||
runtimeNodePath,
|
||||
[serverBuildPath],
|
||||
{
|
||||
...process.env,
|
||||
PROJECT_ROOT: repoRoot,
|
||||
NODE_ENV: 'test',
|
||||
NODE_SERVER_ADDR: `:${nodePort}`,
|
||||
DATABASE_URL: 'pg-mem://genarrative-proxy-smoke',
|
||||
LOG_LEVEL: 'silent',
|
||||
JWT_SECRET: 'proxy-smoke-secret',
|
||||
JWT_ISSUER: 'genarrative-proxy-smoke',
|
||||
LLM_API_KEY: '',
|
||||
DASHSCOPE_API_KEY: '',
|
||||
},
|
||||
);
|
||||
|
||||
await waitForReady(
|
||||
'node server',
|
||||
`${nodeBaseUrl}/healthz`,
|
||||
(bodyText, status) => {
|
||||
assert.equal(status, 200);
|
||||
const payload = JSON.parse(bodyText) as {
|
||||
ok: boolean;
|
||||
service: string;
|
||||
};
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.service, 'genarrative-node-server');
|
||||
},
|
||||
);
|
||||
console.log('[smoke:proxy] node server ready');
|
||||
|
||||
console.log('[smoke:proxy] starting same-origin reverse proxy harness');
|
||||
proxyServer = await startSameOriginProxy();
|
||||
|
||||
await waitForReady(
|
||||
'reverse proxy',
|
||||
`${proxyBaseUrl}/healthz`,
|
||||
(bodyText, status) => {
|
||||
assert.equal(status, 200);
|
||||
assert.equal(bodyText.trim(), 'ok');
|
||||
},
|
||||
);
|
||||
console.log('[smoke:proxy] reverse proxy ready');
|
||||
|
||||
const homeResponse = await httpRequest(`${proxyBaseUrl}/`);
|
||||
const homeHtml = await homeResponse.text();
|
||||
assert.equal(homeResponse.status, 200);
|
||||
assert.match(homeHtml, /<div id="root"><\/div>/u);
|
||||
console.log('[smoke:proxy] static web entry ok');
|
||||
|
||||
const entry = await authEntry(proxyBaseUrl);
|
||||
console.log('[smoke:proxy] proxied auth entry ok');
|
||||
|
||||
const meResponse = await httpRequest(`${proxyBaseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
});
|
||||
const mePayload = (await meResponse.json()) as {
|
||||
user: {
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
assert.equal(meResponse.status, 200);
|
||||
assert.equal(mePayload.user.username, entry.user.username);
|
||||
console.log('[smoke:proxy] proxied auth me ok');
|
||||
|
||||
const saveResponse = await httpRequest(
|
||||
`${proxyBaseUrl}/api/runtime/save/snapshot`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Genarrative-Response-Envelope': 'v1',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
gameState: {
|
||||
worldType: 'WUXIA',
|
||||
chapter: 2,
|
||||
},
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: 'proxy smoke story',
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const savePayload = (await saveResponse.json()) as {
|
||||
ok: true;
|
||||
data: {
|
||||
gameState: {
|
||||
chapter: number;
|
||||
};
|
||||
};
|
||||
meta: {
|
||||
requestId: string;
|
||||
operation: string;
|
||||
};
|
||||
};
|
||||
assert.equal(saveResponse.status, 200);
|
||||
assert.equal(savePayload.ok, true);
|
||||
assert.equal(savePayload.data.gameState.chapter, 2);
|
||||
assert.equal(savePayload.meta.operation, 'runtime.snapshot.put');
|
||||
assert.ok(savePayload.meta.requestId);
|
||||
console.log('[smoke:proxy] proxied runtime save ok');
|
||||
|
||||
const getResponse = await httpRequest(
|
||||
`${proxyBaseUrl}/api/runtime/save/snapshot`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const getPayload = (await getResponse.json()) as {
|
||||
gameState: {
|
||||
chapter: number;
|
||||
};
|
||||
bottomTab: string;
|
||||
};
|
||||
assert.equal(getResponse.status, 200);
|
||||
assert.equal(getPayload.gameState.chapter, 2);
|
||||
assert.equal(getPayload.bottomTab, 'adventure');
|
||||
console.log('[smoke:proxy] proxied runtime snapshot read ok');
|
||||
|
||||
console.log('[smoke:proxy] all checks passed');
|
||||
} finally {
|
||||
await stopProxyServer(proxyServer);
|
||||
await stopChild(serverChild);
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error('[smoke:proxy] failed');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,406 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createApp } from '../server-node/src/app.ts';
|
||||
import type { AppConfig } from '../server-node/src/config.ts';
|
||||
import { createAppContext } from '../server-node/src/server.ts';
|
||||
import { httpRequest, type TestRequestInit } from '../server-node/src/testHttp.ts';
|
||||
|
||||
function createSmokeConfig(): AppConfig {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-server-node-smoke-'),
|
||||
);
|
||||
|
||||
return {
|
||||
nodeEnv: 'test',
|
||||
projectRoot: tempRoot,
|
||||
publicDir: path.join(tempRoot, 'public'),
|
||||
logsDir: path.join(tempRoot, 'logs'),
|
||||
dataDir: path.join(tempRoot, 'data'),
|
||||
rawEnv: {},
|
||||
databaseUrl: 'pg-mem://genarrative-smoke',
|
||||
serverAddr: ':0',
|
||||
logLevel: 'silent',
|
||||
editorApiEnabled: true,
|
||||
assetsApiEnabled: true,
|
||||
jwtSecret: 'test-secret',
|
||||
jwtExpiresIn: '7d',
|
||||
jwtIssuer: 'genarrative-server-node-smoke',
|
||||
llm: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
model: 'test-model',
|
||||
},
|
||||
dashScope: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
imageModel: 'test-image-model',
|
||||
requestTimeoutMs: 1000,
|
||||
},
|
||||
smsAuth: {
|
||||
enabled: true,
|
||||
provider: 'mock',
|
||||
endpoint: 'dypnsapi.aliyuncs.com',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
signName: 'Test Sign',
|
||||
templateCode: '100001',
|
||||
templateParamKey: 'code',
|
||||
countryCode: '86',
|
||||
schemeName: '',
|
||||
codeLength: 6,
|
||||
codeType: 1,
|
||||
validTimeSeconds: 300,
|
||||
intervalSeconds: 60,
|
||||
duplicatePolicy: 1,
|
||||
caseAuthPolicy: 1,
|
||||
returnVerifyCode: false,
|
||||
mockVerifyCode: '123456',
|
||||
maxSendPerPhonePerDay: 20,
|
||||
maxSendPerIpPerHour: 30,
|
||||
maxVerifyFailuresPerPhonePerHour: 12,
|
||||
maxVerifyFailuresPerIpPerHour: 24,
|
||||
captchaTtlSeconds: 180,
|
||||
captchaTriggerVerifyFailuresPerPhone: 3,
|
||||
captchaTriggerVerifyFailuresPerIp: 5,
|
||||
blockPhoneFailureThreshold: 6,
|
||||
blockIpFailureThreshold: 10,
|
||||
blockPhoneDurationMinutes: 30,
|
||||
blockIpDurationMinutes: 30,
|
||||
},
|
||||
wechatAuth: {
|
||||
enabled: true,
|
||||
provider: 'mock',
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect',
|
||||
accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token',
|
||||
userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo',
|
||||
callbackPath: '/api/auth/wechat/callback',
|
||||
defaultRedirectPath: '/',
|
||||
mockUserId: 'mock_wechat_user',
|
||||
mockUnionId: 'mock_wechat_union',
|
||||
mockDisplayName: '微信旅人',
|
||||
mockAvatarUrl: '',
|
||||
},
|
||||
authSession: {
|
||||
refreshCookieName: 'genarrative_refresh_session',
|
||||
refreshSessionTtlDays: 30,
|
||||
refreshCookieSecure: false,
|
||||
refreshCookieSameSite: 'Lax',
|
||||
refreshCookiePath: '/api/auth',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withSmokeServer<T>(
|
||||
run: (options: { baseUrl: string }) => Promise<T>,
|
||||
) {
|
||||
const context = await createAppContext(createSmokeConfig());
|
||||
const app = createApp(context);
|
||||
const server = await new Promise<import('node:http').Server>((resolve) => {
|
||||
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address() as AddressInfo;
|
||||
return await run({
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await context.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function withBearer(token: string, init: TestRequestInit = {}) {
|
||||
return {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
} satisfies TestRequestInit;
|
||||
}
|
||||
|
||||
async function authEntry(baseUrl: string) {
|
||||
const username = `smoke_${Date.now().toString(36)}`;
|
||||
const response = await httpRequest(`${baseUrl}/api/auth/entry`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password: 'smoke-secret-123',
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
token: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(payload.token);
|
||||
assert.equal(payload.user.username, username);
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function sendPhoneCode(baseUrl: string, phone: string) {
|
||||
const response = await httpRequest(`${baseUrl}/api/auth/phone/send-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
phone,
|
||||
scene: 'login',
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.cooldownSeconds, 60);
|
||||
assert.equal(payload.expiresInSeconds, 300);
|
||||
}
|
||||
|
||||
async function phoneAuthEntry(baseUrl: string) {
|
||||
const phone = '13800138000';
|
||||
|
||||
await sendPhoneCode(baseUrl, phone);
|
||||
|
||||
const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
phone,
|
||||
code: '123456',
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
token: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
loginMethod: string;
|
||||
phoneNumberMasked: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(payload.token);
|
||||
assert.equal(payload.user.loginMethod, 'phone');
|
||||
assert.equal(payload.user.phoneNumberMasked, '138****8000');
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('[server-node:smoke] booting ephemeral Express server');
|
||||
|
||||
await withSmokeServer(async ({ baseUrl }) => {
|
||||
const healthzRequestId = 'smoke-healthz-request';
|
||||
const healthzResponse = await httpRequest(`${baseUrl}/healthz`, {
|
||||
headers: {
|
||||
'X-Request-Id': healthzRequestId,
|
||||
},
|
||||
});
|
||||
const healthzPayload = (await healthzResponse.json()) as {
|
||||
ok: boolean;
|
||||
service: string;
|
||||
};
|
||||
|
||||
assert.equal(healthzResponse.status, 200);
|
||||
assert.equal(healthzResponse.headers.get('x-request-id'), healthzRequestId);
|
||||
assert.equal(healthzPayload.ok, true);
|
||||
assert.equal(healthzPayload.service, 'genarrative-node-server');
|
||||
console.log('[server-node:smoke] healthz ok');
|
||||
|
||||
const entry = await authEntry(baseUrl);
|
||||
console.log('[server-node:smoke] password auth entry ok');
|
||||
|
||||
const phoneEntry = await phoneAuthEntry(baseUrl);
|
||||
console.log('[server-node:smoke] phone auth entry ok');
|
||||
|
||||
const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${phoneEntry.token}`,
|
||||
},
|
||||
});
|
||||
const mePayload = (await meResponse.json()) as {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
loginMethod: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(meResponse.status, 200);
|
||||
assert.equal(mePayload.user.username, phoneEntry.user.username);
|
||||
assert.equal(mePayload.user.loginMethod, 'phone');
|
||||
console.log('[server-node:smoke] auth me ok');
|
||||
|
||||
const putSnapshotResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(phoneEntry.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
gameState: {
|
||||
worldType: 'WUXIA',
|
||||
chapter: 1,
|
||||
},
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: 'smoke story',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const putSnapshotPayload = (await putSnapshotResponse.json()) as {
|
||||
version: number;
|
||||
bottomTab: string;
|
||||
gameState: {
|
||||
chapter: number;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(putSnapshotResponse.status, 200);
|
||||
assert.equal(putSnapshotPayload.version, 2);
|
||||
assert.equal(putSnapshotPayload.bottomTab, 'adventure');
|
||||
assert.equal(putSnapshotPayload.gameState.chapter, 1);
|
||||
|
||||
const getSnapshotResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${phoneEntry.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const getSnapshotPayload = (await getSnapshotResponse.json()) as {
|
||||
bottomTab: string;
|
||||
gameState: {
|
||||
chapter: number;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(getSnapshotResponse.status, 200);
|
||||
assert.equal(getSnapshotPayload.bottomTab, 'adventure');
|
||||
assert.equal(getSnapshotPayload.gameState.chapter, 1);
|
||||
console.log('[server-node:smoke] runtime snapshot roundtrip ok');
|
||||
|
||||
const putSettingsResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/settings`,
|
||||
withBearer(phoneEntry.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
musicVolume: 0.3,
|
||||
platformTheme: 'light',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const putSettingsPayload = (await putSettingsResponse.json()) as {
|
||||
musicVolume: number;
|
||||
platformTheme: string;
|
||||
};
|
||||
|
||||
assert.equal(putSettingsResponse.status, 200);
|
||||
assert.equal(putSettingsPayload.musicVolume, 0.3);
|
||||
assert.equal(putSettingsPayload.platformTheme, 'light');
|
||||
|
||||
const getSettingsResponse = await httpRequest(`${baseUrl}/api/runtime/settings`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${phoneEntry.token}`,
|
||||
},
|
||||
});
|
||||
const getSettingsPayload = (await getSettingsResponse.json()) as {
|
||||
musicVolume: number;
|
||||
platformTheme: string;
|
||||
};
|
||||
|
||||
assert.equal(getSettingsResponse.status, 200);
|
||||
assert.equal(getSettingsPayload.musicVolume, 0.3);
|
||||
assert.equal(getSettingsPayload.platformTheme, 'light');
|
||||
console.log('[server-node:smoke] runtime settings roundtrip ok');
|
||||
|
||||
const deleteSnapshotResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(phoneEntry.token, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
);
|
||||
const deleteSnapshotPayload = (await deleteSnapshotResponse.json()) as {
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
assert.equal(deleteSnapshotResponse.status, 200);
|
||||
assert.equal(deleteSnapshotPayload.ok, true);
|
||||
|
||||
const emptySnapshotResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${phoneEntry.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const emptySnapshotPayload = await emptySnapshotResponse.json();
|
||||
|
||||
assert.equal(emptySnapshotResponse.status, 200);
|
||||
assert.equal(emptySnapshotPayload, null);
|
||||
console.log('[server-node:smoke] runtime snapshot delete ok');
|
||||
|
||||
const logoutResponse = await httpRequest(
|
||||
`${baseUrl}/api/auth/logout`,
|
||||
withBearer(phoneEntry.token, {
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
const logoutPayload = (await logoutResponse.json()) as {
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
assert.equal(logoutResponse.status, 200);
|
||||
assert.equal(logoutPayload.ok, true);
|
||||
|
||||
const expiredTokenResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${phoneEntry.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(expiredTokenResponse.status, 401);
|
||||
console.log('[server-node:smoke] logout invalidation ok');
|
||||
});
|
||||
|
||||
console.log('[server-node:smoke] all checks passed');
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error('[server-node:smoke] failed');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/update.sh
|
||||
|
||||
说明:
|
||||
1. 对当前仓库执行 git pull
|
||||
2. 只构建前端
|
||||
3. 固定同步前端 dist 到 /work/dist
|
||||
4. 固定同步 server-node 到 /work/server-node
|
||||
|
||||
注意:
|
||||
- server-node 同步时会排除 dist 和 node_modules
|
||||
- 不会构建后端
|
||||
- 不会执行 npm ci
|
||||
- 不会重启 PM2
|
||||
EOF
|
||||
}
|
||||
|
||||
require_command() {
|
||||
local command_name="$1"
|
||||
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
echo "[update] 缺少命令: $command_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
require_command git
|
||||
require_command npm
|
||||
require_command rsync
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
CLIENT_TARGET_DIR="/work/dist"
|
||||
SERVER_TARGET_DIR="/work/server-node"
|
||||
|
||||
echo "[update] 仓库目录: ${REPO_ROOT}"
|
||||
echo "[update] 前端目标目录: ${CLIENT_TARGET_DIR}"
|
||||
echo "[update] 后端目标目录: ${SERVER_TARGET_DIR}"
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# 先拉取当前分支的最新代码。
|
||||
echo "[update] 拉取当前分支最新代码"
|
||||
git pull
|
||||
|
||||
# 只构建前端,不处理后端构建。
|
||||
echo "[update] 构建前端"
|
||||
npm run build
|
||||
|
||||
# 固定创建 /work 下的目标目录。
|
||||
echo "[update] 创建目标目录"
|
||||
mkdir -p "${CLIENT_TARGET_DIR}" "${SERVER_TARGET_DIR}"
|
||||
|
||||
# 同步前端构建产物。
|
||||
echo "[update] 同步前端 dist -> ${CLIENT_TARGET_DIR}"
|
||||
rsync -a --delete "${REPO_ROOT}/dist/" "${CLIENT_TARGET_DIR}/"
|
||||
|
||||
# 同步 server-node 源码和配置,但保留目标目录自己的 dist 和 node_modules。
|
||||
echo "[update] 同步 server-node -> ${SERVER_TARGET_DIR}"
|
||||
rsync -a --delete \
|
||||
--exclude 'dist/' \
|
||||
--exclude 'node_modules/' \
|
||||
"${REPO_ROOT}/server-node/" "${SERVER_TARGET_DIR}/"
|
||||
|
||||
echo "[update] 完成"
|
||||
Reference in New Issue
Block a user