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('AI 游戏创作') && 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('AI 游戏创作') && !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); }