This commit is contained in:
2026-05-15 02:41:43 +08:00
39 changed files with 2539 additions and 200 deletions

View File

@@ -1,6 +1,11 @@
import { execFileSync, spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
createWriteStream,
existsSync,
mkdirSync,
readFileSync,
} from 'node:fs';
import { dirname, isAbsolute, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = process.cwd();
@@ -67,7 +72,69 @@ export function mergeApiServerEnv(repoRootPath, baseEnv = process.env) {
return mergedEnv;
}
function stopExistingWindowsApiServer() {
export function formatApiServerLogTimestamp(date = new Date()) {
const pad = (value) => String(value).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
'-',
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
export function resolveApiServerLogFile(
repoRootPath,
env = process.env,
now = new Date(),
) {
const explicitLogFile = String(
env.GENARRATIVE_API_SERVER_LOG_FILE ?? '',
).trim();
if (explicitLogFile) {
return isAbsolute(explicitLogFile)
? explicitLogFile
: resolve(repoRootPath, explicitLogFile);
}
const logDir =
String(env.GENARRATIVE_API_SERVER_LOG_DIR ?? '').trim() ||
'logs/api-server';
const resolvedLogDir = isAbsolute(logDir)
? logDir
: resolve(repoRootPath, logDir);
return resolve(
resolvedLogDir,
`api-server-${formatApiServerLogTimestamp(now)}.log`,
);
}
function createApiServerLogStream(logFilePath) {
mkdirSync(dirname(logFilePath), { recursive: true });
const logStream = createWriteStream(logFilePath, {
flags: 'a',
encoding: 'utf8',
});
logStream.on('error', (error) => {
console.error(`[api-server] 写入日志失败: ${error.message}`);
});
return logStream;
}
function writeLauncherLog(logStream, message, stream = process.stdout) {
const line = `${message}\n`;
stream.write(line);
if (!logStream.destroyed) {
logStream.write(line);
}
}
function stopExistingWindowsApiServer(logStream) {
if (process.platform !== 'win32') {
return;
}
@@ -104,7 +171,7 @@ function stopExistingWindowsApiServer() {
).trim();
if (output) {
console.log(`[api-server] 已停止旧 api-server 进程: ${output}`);
writeLauncherLog(logStream, `[api-server] 已停止旧 api-server 进程: ${output}`);
}
}
@@ -121,19 +188,55 @@ function main() {
mergedEnv.GENARRATIVE_SPACETIME_TOKEN =
mergedEnv.GENARRATIVE_SPACETIME_TOKEN || '';
const logFilePath = resolveApiServerLogFile(repoRoot, mergedEnv);
const logStream = createApiServerLogStream(logFilePath);
mergedEnv.GENARRATIVE_API_SERVER_LOG_FILE = logFilePath;
let didExit = false;
const exitAfterLogFlush = (code) => {
const finish = () => {
if (didExit) {
return;
}
didExit = true;
process.exit(code);
};
if (logStream.destroyed) {
finish();
return;
}
logStream.end(finish);
setTimeout(finish, 1000).unref();
};
writeLauncherLog(logStream, `[api-server] 日志: ${logFilePath}`);
if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) {
console.error('[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。');
process.exit(1);
writeLauncherLog(
logStream,
'[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。',
process.stderr,
);
exitAfterLogFlush(1);
return;
}
try {
stopExistingWindowsApiServer();
stopExistingWindowsApiServer(logStream);
} catch (error) {
console.error(`[api-server] 清理旧 api-server 进程失败: ${error.message}`);
process.exit(1);
writeLauncherLog(
logStream,
`[api-server] 清理旧 api-server 进程失败: ${error.message}`,
process.stderr,
);
exitAfterLogFlush(1);
return;
}
console.log(
writeLauncherLog(
logStream,
`[api-server] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`,
);
@@ -143,22 +246,41 @@ function main() {
{
cwd: repoRoot,
env: mergedEnv,
stdio: 'inherit',
stdio: ['inherit', 'pipe', 'pipe'],
},
);
child.on('error', (error) => {
console.error(`[api-server] 启动 cargo 失败: ${error.message}`);
process.exit(1);
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk);
logStream.write(chunk);
});
child.on('exit', (code, signal) => {
child.stderr?.on('data', (chunk) => {
process.stderr.write(chunk);
logStream.write(chunk);
});
child.on('error', (error) => {
writeLauncherLog(
logStream,
`[api-server] 启动 cargo 失败: ${error.message}`,
process.stderr,
);
exitAfterLogFlush(1);
});
child.on('close', (code, signal) => {
if (signal) {
console.error(`[api-server] api-server 被信号终止: ${signal}`);
process.exit(1);
writeLauncherLog(
logStream,
`[api-server] api-server 被信号终止: ${signal}`,
process.stderr,
);
exitAfterLogFlush(1);
return;
}
process.exit(code ?? 0);
exitAfterLogFlush(code ?? 0);
});
}

View File

@@ -4,7 +4,11 @@ import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
import { mergeApiServerEnv } from './api-server-dev.mjs';
import {
formatApiServerLogTimestamp,
mergeApiServerEnv,
resolveApiServerLogFile,
} from './api-server-dev.mjs';
type EnvMap = Record<string, string>;
@@ -92,3 +96,39 @@ describe('api-server-dev env merge', () => {
);
});
});
describe('api-server-dev log file resolution', () => {
const fixedDate = new Date(2026, 4, 15, 6, 7, 8);
test('默认写入 logs/api-server 的时间戳文件', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-log-'));
try {
expect(formatApiServerLogTimestamp(fixedDate)).toBe('20260515-060708');
expect(resolveApiServerLogFile(tempDir, {}, fixedDate)).toBe(
join(tempDir, 'logs/api-server/api-server-20260515-060708.log'),
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('GENARRATIVE_API_SERVER_LOG_FILE 优先于日志目录默认值', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-log-'));
try {
expect(
resolveApiServerLogFile(
tempDir,
{
GENARRATIVE_API_SERVER_LOG_DIR: 'logs/ignored',
GENARRATIVE_API_SERVER_LOG_FILE: 'logs/custom/api.log',
},
fixedDate,
),
).toBe(join(tempDir, 'logs/custom/api.log'));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,647 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(scriptDir, '..');
const moduleSrcRoot = 'server-rs/crates/spacetime-module/src';
const migrationPath = `${moduleSrcRoot}/migration.rs`;
const tableCatalogPath = 'docs/technical/SPACETIMEDB_TABLE_CATALOG.md';
const bindingsRoot = 'server-rs/crates/spacetime-client/src/module_bindings/';
const allowBreaking = process.env.SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING === '1';
function normalizePath(path) {
return path.replace(/\\/gu, '/');
}
function runGit(args, options = {}) {
return execFileSync('git', args, {
cwd: repoRoot,
encoding: 'utf8',
stdio: options.quiet ? ['ignore', 'pipe', 'ignore'] : ['ignore', 'pipe', 'pipe'],
maxBuffer: 32 * 1024 * 1024,
}).trim();
}
function tryGit(args) {
try {
return runGit(args, { quiet: true });
} catch {
return null;
}
}
function resolveBaseRef() {
const explicitArgIndex = process.argv.indexOf('--base-ref');
if (explicitArgIndex >= 0 && process.argv[explicitArgIndex + 1]) {
return process.argv[explicitArgIndex + 1];
}
if (process.env.SPACETIME_SCHEMA_BASE_REF) {
return process.env.SPACETIME_SCHEMA_BASE_REF;
}
const mergeBase = tryGit(['merge-base', 'HEAD', 'origin/master']);
if (mergeBase) {
return mergeBase;
}
const originMaster = tryGit(['rev-parse', '--verify', 'origin/master']);
if (originMaster) {
return originMaster;
}
return 'HEAD';
}
function listCurrentRustFiles(dir) {
const files = [];
function walk(currentDir) {
if (!existsSync(currentDir)) {
return;
}
for (const name of readdirSync(currentDir)) {
const fullPath = join(currentDir, name);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
walk(fullPath);
continue;
}
if (name.endsWith('.rs')) {
files.push(normalizePath(relative(repoRoot, fullPath)));
}
}
}
walk(join(repoRoot, dir));
return files.sort();
}
function listBaseRustFiles(baseRef) {
const output = tryGit(['ls-tree', '-r', '--name-only', baseRef, '--', moduleSrcRoot]);
if (!output) {
return [];
}
return output
.split(/\r?\n/u)
.map(normalizePath)
.filter((path) => path.endsWith('.rs'))
.sort();
}
function readCurrentFile(path) {
return readFileSync(join(repoRoot, path), 'utf8');
}
function readBaseFile(baseRef, path) {
const text = tryGit(['show', `${baseRef}:${path}`]);
return text ?? '';
}
function lineNumberAt(text, index) {
let line = 1;
for (let i = 0; i < index; i += 1) {
if (text[i] === '\n') {
line += 1;
}
}
return line;
}
function findClosingBracket(text, start) {
let depth = 0;
let quote = null;
let escaped = false;
let lineComment = false;
let blockCommentDepth = 0;
for (let i = start; i < text.length; i += 1) {
const char = text[i];
const next = text[i + 1];
if (lineComment) {
if (char === '\n') {
lineComment = false;
}
continue;
}
if (blockCommentDepth > 0) {
if (char === '/' && next === '*') {
blockCommentDepth += 1;
i += 1;
} else if (char === '*' && next === '/') {
blockCommentDepth -= 1;
i += 1;
}
continue;
}
if (quote) {
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === quote) {
quote = null;
}
continue;
}
if (char === '/' && next === '/') {
lineComment = true;
i += 1;
continue;
}
if (char === '/' && next === '*') {
blockCommentDepth = 1;
i += 1;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '[') {
depth += 1;
} else if (char === ']') {
depth -= 1;
if (depth === 0) {
return i + 1;
}
}
}
return -1;
}
function findClosingBrace(text, start) {
let depth = 0;
let quote = null;
let escaped = false;
let lineComment = false;
let blockCommentDepth = 0;
for (let i = start; i < text.length; i += 1) {
const char = text[i];
const next = text[i + 1];
if (lineComment) {
if (char === '\n') {
lineComment = false;
}
continue;
}
if (blockCommentDepth > 0) {
if (char === '/' && next === '*') {
blockCommentDepth += 1;
i += 1;
} else if (char === '*' && next === '/') {
blockCommentDepth -= 1;
i += 1;
}
continue;
}
if (quote) {
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === quote) {
quote = null;
}
continue;
}
if (char === '/' && next === '/') {
lineComment = true;
i += 1;
continue;
}
if (char === '/' && next === '*') {
blockCommentDepth = 1;
i += 1;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
return i;
}
}
}
return -1;
}
function splitTopLevelSegments(text) {
const segments = [];
let start = 0;
let parenDepth = 0;
let bracketDepth = 0;
let braceDepth = 0;
let angleDepth = 0;
let quote = null;
let escaped = false;
let lineComment = false;
let blockCommentDepth = 0;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
const next = text[i + 1];
if (lineComment) {
if (char === '\n') {
lineComment = false;
}
continue;
}
if (blockCommentDepth > 0) {
if (char === '/' && next === '*') {
blockCommentDepth += 1;
i += 1;
} else if (char === '*' && next === '/') {
blockCommentDepth -= 1;
i += 1;
}
continue;
}
if (quote) {
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === quote) {
quote = null;
}
continue;
}
if (char === '/' && next === '/') {
lineComment = true;
i += 1;
continue;
}
if (char === '/' && next === '*') {
blockCommentDepth = 1;
i += 1;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') {
parenDepth += 1;
} else if (char === ')') {
parenDepth = Math.max(0, parenDepth - 1);
} else if (char === '[') {
bracketDepth += 1;
} else if (char === ']') {
bracketDepth = Math.max(0, bracketDepth - 1);
} else if (char === '{') {
braceDepth += 1;
} else if (char === '}') {
braceDepth = Math.max(0, braceDepth - 1);
} else if (char === '<') {
angleDepth += 1;
} else if (char === '>') {
angleDepth = Math.max(0, angleDepth - 1);
} else if (
char === ',' &&
parenDepth === 0 &&
bracketDepth === 0 &&
braceDepth === 0 &&
angleDepth === 0
) {
segments.push({ text: text.slice(start, i), start });
start = i + 1;
}
}
segments.push({ text: text.slice(start), start });
return segments;
}
function normalizeRustText(text) {
return text.replace(/\s+/gu, ' ').trim();
}
function parseField(segment, fileText, bodyStartIndex) {
const withoutLineComments = segment.text.replace(/\/\/.*$/gmu, '').trim();
if (!withoutLineComments) {
return null;
}
const attrs = [...withoutLineComments.matchAll(/#\[[\s\S]*?\]/gu)].map((match) =>
normalizeRustText(match[0]),
);
const fieldText = withoutLineComments.replace(/#\[[\s\S]*?\]\s*/gu, '').trim();
const fieldMatch =
/^(?:pub(?:\([^)]*\))?\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/u.exec(
fieldText,
);
if (!fieldMatch) {
return null;
}
return {
name: fieldMatch[1],
type: normalizeRustText(fieldMatch[2]),
attrs,
hasDefault: attrs.some((attr) => /^#\[\s*default\b/u.test(attr)),
line: lineNumberAt(fileText, bodyStartIndex + segment.start),
};
}
function parseFields(body, fileText, bodyStartIndex) {
return splitTopLevelSegments(body)
.map((segment) => parseField(segment, fileText, bodyStartIndex))
.filter(Boolean);
}
function parseTablesFromFile(path, text) {
const tables = [];
const tableAttrPattern = /#\[\s*(?:spacetimedb::)?table\s*\(/gu;
let match;
while ((match = tableAttrPattern.exec(text)) !== null) {
const attrStart = match.index;
const attrEnd = findClosingBracket(text, attrStart);
if (attrEnd < 0) {
continue;
}
const attrText = text.slice(attrStart, attrEnd);
const accessorMatch = /accessor\s*=\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/u.exec(
attrText,
);
const accessor = accessorMatch?.[1] ?? accessorMatch?.[2];
if (!accessor) {
continue;
}
const afterAttr = text.slice(attrEnd, attrEnd + 4000);
const structMatch =
/(?:#\[[\s\S]*?\]\s*)*(?:pub(?:\([^)]*\))?\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/u.exec(
afterAttr,
);
if (!structMatch) {
continue;
}
const structStart = attrEnd + structMatch.index;
const structOpenBrace = structStart + structMatch[0].lastIndexOf('{');
const structCloseBrace = findClosingBrace(text, structOpenBrace);
if (structCloseBrace < 0) {
continue;
}
const bodyStartIndex = structOpenBrace + 1;
const body = text.slice(bodyStartIndex, structCloseBrace);
tables.push({
accessor,
structName: structMatch[1],
path,
line: lineNumberAt(text, structStart),
fields: parseFields(body, text, bodyStartIndex),
});
tableAttrPattern.lastIndex = structCloseBrace + 1;
}
return tables;
}
function collectTablesFromSources(sources) {
const tables = new Map();
const failures = [];
for (const source of sources) {
for (const table of parseTablesFromFile(source.path, source.text)) {
const previous = tables.get(table.accessor);
if (previous) {
failures.push(
`${table.path}:${table.line}: SpacetimeDB table accessor ${table.accessor} 重复定义,首次定义在 ${previous.path}:${previous.line}`,
);
continue;
}
tables.set(table.accessor, table);
}
}
return { tables, failures };
}
function loadCurrentSources() {
return listCurrentRustFiles(moduleSrcRoot).map((path) => ({
path,
text: readCurrentFile(path),
}));
}
function loadBaseSources(baseRef) {
return listBaseRustFiles(baseRef).map((path) => ({
path,
text: readBaseFile(baseRef, path),
}));
}
function getChangedFiles(baseRef) {
const diffOutput = tryGit(['diff', '--name-only', baseRef, '--']) ?? '';
const untrackedOutput =
tryGit(['ls-files', '--others', '--exclude-standard', moduleSrcRoot]) ?? '';
return new Set(
[...diffOutput.split(/\r?\n/u), ...untrackedOutput.split(/\r?\n/u)]
.map(normalizePath)
.filter(Boolean),
);
}
function sameFieldSchema(left, right) {
return (
left.name === right.name &&
left.type === right.type &&
left.attrs.join('\n') === right.attrs.join('\n')
);
}
function fieldDescription(field) {
return `${field.name}: ${field.type}`;
}
function compareTables(baseTables, currentTables) {
const failures = [];
let schemaChanged = false;
let breakingChanged = false;
for (const [accessor, baseTable] of baseTables) {
const currentTable = currentTables.get(accessor);
if (!currentTable) {
schemaChanged = true;
breakingChanged = true;
failures.push(
`${baseTable.path}:${baseTable.line}: SpacetimeDB 表 ${accessor} 被删除或改名。表删除/改名必须先询问用户并确认迁移计划。`,
);
continue;
}
const currentFieldNames = new Set(currentTable.fields.map((field) => field.name));
if (currentTable.fields.length < baseTable.fields.length) {
schemaChanged = true;
breakingChanged = true;
failures.push(
`${currentTable.path}:${currentTable.line}: SpacetimeDB 表 ${accessor} 字段数量减少。删除或改名字段必须先询问用户并确认迁移计划。`,
);
}
for (let index = 0; index < baseTable.fields.length; index += 1) {
const baseField = baseTable.fields[index];
const currentField = currentTable.fields[index];
if (!currentField) {
continue;
}
if (sameFieldSchema(baseField, currentField)) {
continue;
}
schemaChanged = true;
breakingChanged = true;
if (baseField.name !== currentField.name) {
const baseFieldStillExists = currentFieldNames.has(baseField.name);
const reason = baseFieldStillExists ? '字段顺序被调整' : '字段被删除或改名';
failures.push(
`${currentTable.path}:${currentField.line}: SpacetimeDB 表 ${accessor} 的第 ${
index + 1
} 个字段从 ${baseField.name} 变为 ${currentField.name},疑似${reason}。只能在结构体最后追加新字段;改名必须先询问用户并确认迁移计划。`,
);
continue;
}
failures.push(
`${currentTable.path}:${currentField.line}: SpacetimeDB 表 ${accessor}.${currentField.name} 的 schema 从 ${fieldDescription(
baseField,
)} 变为 ${fieldDescription(currentField)}。修改已有字段类型或属性必须先询问用户并确认迁移计划。`,
);
}
if (currentTable.fields.length > baseTable.fields.length) {
schemaChanged = true;
if (!breakingChanged) {
const addedFields = currentTable.fields.slice(baseTable.fields.length);
for (const field of addedFields) {
if (!field.hasDefault) {
failures.push(
`${currentTable.path}:${field.line}: SpacetimeDB 表 ${accessor} 新增字段 ${field.name} 必须放在结构体最后并添加 #[default(...)]。当前字段位于末尾但缺少默认值。`,
);
}
}
}
}
}
for (const [accessor, table] of currentTables) {
if (!baseTables.has(accessor)) {
schemaChanged = true;
failures.push(
`${table.path}:${table.line}: 新增 SpacetimeDB 表 ${accessor}。请同步 migration.rs、表目录和生成绑定。`,
);
}
}
return { failures, schemaChanged, breakingChanged };
}
function checkSchemaSidecars(changedFiles, schemaChanged) {
if (!schemaChanged) {
return [];
}
const failures = [];
if (!changedFiles.has(migrationPath)) {
failures.push(
`SpacetimeDB schema 已变化,但 ${migrationPath} 没有同步变更。row shape 或表变化必须同步迁移导入导出口径。`,
);
}
if (!changedFiles.has(tableCatalogPath)) {
failures.push(
`SpacetimeDB schema 已变化,但 ${tableCatalogPath} 没有同步变更。表结构目录必须跟源码一致。`,
);
}
const bindingsChanged = [...changedFiles].some((path) => path.startsWith(bindingsRoot));
if (!bindingsChanged) {
failures.push(
`SpacetimeDB schema 已变化,但 ${bindingsRoot} 下没有生成绑定变更。请重新生成并提交绑定。`,
);
}
return failures;
}
function main() {
const baseRef = resolveBaseRef();
const currentSources = loadCurrentSources();
const baseSources = loadBaseSources(baseRef);
const currentResult = collectTablesFromSources(currentSources);
const baseResult = collectTablesFromSources(baseSources);
const compareResult = compareTables(baseResult.tables, currentResult.tables);
const changedFiles = getChangedFiles(baseRef);
const sidecarFailures = checkSchemaSidecars(changedFiles, compareResult.schemaChanged);
const failures = [
...currentResult.failures,
...baseResult.failures,
...compareResult.failures,
...sidecarFailures,
];
if (compareResult.breakingChanged && !allowBreaking) {
failures.push(
'检测到 SpacetimeDB 字段删除、改名、重排、类型或属性修改。请先询问用户并确认迁移计划;确认后如确需继续,可在人工确认的迁移提交中设置 SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1 运行本检查。',
);
}
if (failures.length > 0) {
console.error(`SpacetimeDB schema guard failed against ${baseRef}:`);
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log(
`SpacetimeDB schema guard passed for ${currentResult.tables.size} table(s) against ${baseRef}.`,
);
}
main();

View File

@@ -658,11 +658,13 @@ wait_for_api_server() {
local health_url="$1"
local timeout_seconds="$2"
local process_pid="${3:-}"
local log_file="${4:-${API_SERVER_LOG_FILE:-}}"
local deadline=$((SECONDS + timeout_seconds))
while ((SECONDS < deadline)); do
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
echo "[dev:rust] api-server 进程在就绪前退出。" >&2
print_api_server_log_tail "${log_file}"
exit 1
fi
@@ -684,9 +686,58 @@ request.on("error", () => process.exit(1));
done
echo "[dev:rust] 等待 api-server 就绪超时: ${health_url}" >&2
print_api_server_log_tail "${log_file}"
exit 1
}
format_api_server_log_timestamp() {
date +%Y%m%d-%H%M%S
}
normalize_api_server_log_path() {
local path_value="$1"
if [[ "${path_value}" == *\\* ]]; then
path_value="${path_value//\\//}"
fi
echo "${path_value}"
}
resolve_api_server_log_file() {
local explicit_log_file="${GENARRATIVE_API_SERVER_LOG_FILE:-}"
local log_dir="${GENARRATIVE_API_SERVER_LOG_DIR:-${REPO_ROOT}/logs/api-server}"
if [[ -n "${explicit_log_file//[[:space:]]/}" ]]; then
explicit_log_file="$(normalize_api_server_log_path "${explicit_log_file}")"
if [[ "${explicit_log_file}" = /* || "${explicit_log_file}" =~ ^[A-Za-z]:[\\/] ]]; then
echo "${explicit_log_file}"
return
fi
echo "${REPO_ROOT}/${explicit_log_file}"
return
fi
log_dir="$(normalize_api_server_log_path "${log_dir}")"
if [[ ! "${log_dir}" = /* && ! "${log_dir}" =~ ^[A-Za-z]:[\\/] ]]; then
log_dir="${REPO_ROOT}/${log_dir}"
fi
echo "${log_dir}/api-server-dev-rust-$(format_api_server_log_timestamp).log"
}
print_api_server_log_tail() {
local log_file="${1:-}"
if [[ -z "${log_file}" || ! -f "${log_file}" ]]; then
return
fi
echo "[dev:rust] api-server 最近日志: ${log_file}" >&2
tail -n 80 "${log_file}" >&2 || true
}
generate_migration_bootstrap_secret() {
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
@@ -995,22 +1046,26 @@ API_PORT="$(find_nearest_available_port "${API_HOST}" "${API_PORT}" "api-server"
API_TARGET_HOST="$(resolve_client_host "${API_HOST}")"
# `.env.local` 可以给单独 `dev:web` 配置代理目标,但完整栈的前端必须跟随本次 `--api-port`。
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
API_SERVER_LOG_FILE="$(resolve_api_server_log_file)"
mkdir -p "$(dirname -- "${API_SERVER_LOG_FILE}")"
echo "[dev:rust] api actual: ${RUST_SERVER_TARGET}"
echo "[dev:rust] api-server log: ${API_SERVER_LOG_FILE}"
(
cd "${REPO_ROOT}"
GENARRATIVE_API_HOST="${API_HOST}" \
GENARRATIVE_API_PORT="${API_PORT}" \
GENARRATIVE_API_LOG="${API_LOG}" \
GENARRATIVE_API_SERVER_LOG_FILE="${API_SERVER_LOG_FILE}" \
GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER}" \
GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \
exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}"
) &
) > >(tee -a "${API_SERVER_LOG_FILE}") 2>&1 &
API_PID="$!"
PIDS+=("${API_PID}")
NAMES+=("api-server")
echo "[dev:rust] 等待 api-server 就绪"
wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}"
wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}" "${API_SERVER_LOG_FILE}"
echo "[dev:rust] 启动 vite"
(