Files
Genarrative/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
T
AIGameCreator App f9d8142068 接入客户端登录和配套后端启动
启动客户端时先检查平台登录态,未登录展示登录页,登录后进入启动器首页

独立客户端登录使用平台 auth 接口并对 refresh 做并发去重

Tauri 开发启动改为短命令 agc:serve,并先拉起配套 SpacetimeDB 与 api-server

AGC Vite 代理读取 dev-stack 状态并暴露本地 marker,复用时校验 API target

恢复启动器项目目录输入、最近项目刷新移除和非空文件夹提醒交互

补充启动器和登录 gate 测试,并更新 AI 游戏创作 App 开发文档
2026-07-08 02:25:31 +08:00

284 lines
7.7 KiB
JavaScript

import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import http from 'node:http';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const viteHost = '127.0.0.1';
const vitePort = 3080;
const viteUrl = `http://${viteHost}:${vitePort}/`;
const viteMarkerUrl = `${viteUrl}__agc_dev_server.json`;
const defaultApiTarget = process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
const backendDatabase = 'genarrative-game-creator-dev';
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
function readJson(path) {
if (!existsSync(path)) {
return null;
}
try {
return JSON.parse(readFileSync(path, 'utf8'));
} catch {
return null;
}
}
function httpGetText(url, timeout = 1000) {
return new Promise((resolveRequest) => {
const request = http.get(url, { timeout }, (response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
if (body.length < 4096) {
body += chunk;
}
});
response.on('end', () => {
resolveRequest({
statusCode: response.statusCode ?? 0,
body,
});
});
});
request.on('timeout', () => {
request.destroy();
resolveRequest(null);
});
request.on('error', () => resolveRequest(null));
});
}
async function isHttpReady(url) {
const response = await httpGetText(url);
return Boolean(response && response.statusCode >= 200 && response.statusCode < 300);
}
function readBackendTargets({ requireAgcDatabase = false } = {}) {
const state = readJson(devStackStatePath);
const apiServer = state?.services?.['api-server'];
const spacetime = state?.services?.spacetime;
const isActive = (service) =>
service && ['running', 'reused', 'starting'].includes(service.status ?? '');
const database = typeof state?.database === 'string' ? state.database : '';
const hasMatchingDatabase = database === backendDatabase;
const canReuseState = !requireAgcDatabase || hasMatchingDatabase;
const apiUrl =
canReuseState && isActive(apiServer) && apiServer.url
? apiServer.url
: requireAgcDatabase
? ''
: defaultApiTarget;
const spacetimeUrl =
canReuseState && isActive(spacetime) && spacetime.url
? spacetime.url
: requireAgcDatabase
? ''
: 'http://127.0.0.1:3101';
return {
apiUrl,
spacetimeUrl,
database,
hasMatchingDatabase,
};
}
async function isBackendReady() {
const { apiUrl, spacetimeUrl, hasMatchingDatabase } = readBackendTargets({
requireAgcDatabase: true,
});
return (
hasMatchingDatabase &&
Boolean(apiUrl) &&
Boolean(spacetimeUrl) &&
(await isHttpReady(`${apiUrl}/healthz`)) &&
(await isHttpReady(`${spacetimeUrl}/v1/ping`))
);
}
async function readExistingViteServer() {
return httpGetText(viteUrl);
}
function isAiGameCreatorServer(response) {
return (
response &&
response.statusCode >= 200 &&
response.statusCode < 500 &&
response.body.includes('<title>AI 游戏创作</title>') &&
response.body.includes('/src/main.tsx')
);
}
async function isExistingViteProxyReady() {
const response = await httpGetText(`${viteUrl}api/auth/me`, 2000);
return Boolean(
response &&
response.statusCode >= 200 &&
response.statusCode < 500 &&
!response.body.includes('<title>AI 游戏创作</title>') &&
!response.body.includes('/src/main.tsx'),
);
}
async function readExistingViteMarker() {
const response = await httpGetText(viteMarkerUrl, 2000);
if (!response || response.statusCode !== 200) {
return null;
}
try {
return JSON.parse(response.body);
} catch {
return null;
}
}
async function isExistingVitePairedWithBackend(apiTarget) {
const marker = await readExistingViteMarker();
return Boolean(
marker &&
marker.schemaVersion === 1 &&
marker.app === 'ai-game-creator-shell' &&
marker.apiTarget === apiTarget,
);
}
function spawnChild(command, args, options) {
return spawn(command, args, {
...options,
shell: true,
stdio: 'inherit',
});
}
function stopChild(child, signal = 'SIGTERM') {
if (!child || child.exitCode != null || child.signalCode != null) {
return;
}
try {
child.kill(signal);
} catch {
// ignore cleanup races
}
}
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
const startedAt = Date.now();
let backendExit = null;
backendChild?.on('exit', (code, signal) => {
backendExit = signal ? `signal=${signal}` : `code=${code ?? 0}`;
});
while (Date.now() - startedAt < timeoutMs) {
if (await isBackendReady()) {
return readBackendTargets();
}
if (backendExit) {
throw new Error(`配套后端启动失败: ${backendExit}`);
}
await new Promise((resolveWait) => setTimeout(resolveWait, 1000));
}
throw new Error('等待配套后端和数据库启动超时');
}
async function ensureBackend() {
if (await isBackendReady()) {
const targets = readBackendTargets();
console.log(`[ai-game-creator-shell] reuse backend ${targets.apiUrl}`);
return { backendChild: null, targets };
}
console.log('[ai-game-creator-shell] starting backend stack');
const backendChild = spawnChild(
npm,
[
'--prefix',
'../..',
'run',
'agc:backend',
'--',
'--database',
backendDatabase,
'--no-interactive',
],
{ cwd: appRoot },
);
const targets = await waitForBackendReady(backendChild);
console.log(`[ai-game-creator-shell] backend ready ${targets.apiUrl}`);
return { backendChild, targets };
}
async function startVite(apiTarget) {
const { apiUrl } = readBackendTargets();
if (apiUrl !== apiTarget) {
throw new Error(
`dev-stack state API target ${apiUrl} does not match paired backend ${apiTarget}.`,
);
}
const existing = await readExistingViteServer();
if (existing) {
if (
isAiGameCreatorServer(existing) &&
(await isExistingVitePairedWithBackend(apiTarget)) &&
(await isExistingViteProxyReady())
) {
console.log(`[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`);
return null;
}
if (isAiGameCreatorServer(existing)) {
throw new Error(
`${viteUrl} is already running, but its /api proxy is not connected to the paired backend. Stop it before starting Tauri dev.`,
);
}
throw new Error(
`${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
);
}
return spawnChild(
npm,
['--prefix', '../..', 'exec', 'vite', '--', '--config', 'vite.config.ts'],
{ cwd: appRoot },
);
}
let backendChild = null;
let viteChild = null;
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
stopChild(viteChild, signal);
stopChild(backendChild, signal);
});
}
try {
const backend = await ensureBackend();
backendChild = backend.backendChild;
viteChild = await startVite(backend.targets.apiUrl);
const children = [backendChild, viteChild].filter(Boolean);
if (children.length === 0) {
process.exit(0);
}
await new Promise((resolveExit) => {
for (const child of children) {
child.on('exit', (code, signal) => {
stopChild(viteChild);
stopChild(backendChild);
resolveExit(signal ? 1 : code ?? 0);
});
}
}).then((code) => process.exit(code));
} catch (error) {
stopChild(viteChild);
stopChild(backendChild);
console.error(
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}