import { spawn } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
agcVitePortEnvKey,
readAgcDevEndpoint,
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
} from './dev-port.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const defaultApiTarget =
process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
const backendDatabase = 'genarrative-game-creator-dev';
const backendSpacetimeDataDir = resolve(
repoRoot,
'server-rs/.spacetimedb/ai-game-creator/data',
);
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const childLifecycles = new WeakMap();
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 resolveBackendTargetsFromState(
state,
{
requireAgcBackend = false,
expectedDatabase = backendDatabase,
expectedSpacetimeDataDir = backendSpacetimeDataDir,
fallbackApiTarget = defaultApiTarget,
} = {},
) {
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 spacetimeDataDir =
typeof state?.spacetimeDataDir === 'string'
? resolve(state.spacetimeDataDir)
: '';
const hasMatchingDatabase = database === expectedDatabase;
const hasMatchingDataDir =
Boolean(spacetimeDataDir) &&
spacetimeDataDir === resolve(expectedSpacetimeDataDir);
const hasMatchingBackend = hasMatchingDatabase && hasMatchingDataDir;
const canReuseState = !requireAgcBackend || hasMatchingBackend;
const apiUrl =
canReuseState && isActive(apiServer) && apiServer.url
? apiServer.url
: requireAgcBackend
? ''
: fallbackApiTarget;
const spacetimeUrl =
canReuseState && isActive(spacetime) && spacetime.url
? spacetime.url
: requireAgcBackend
? ''
: 'http://127.0.0.1:3101';
return {
apiUrl,
spacetimeUrl,
database,
spacetimeDataDir,
hasMatchingDatabase,
hasMatchingDataDir,
hasMatchingBackend,
};
}
function readBackendTargets({ requireAgcBackend = false } = {}) {
return resolveBackendTargetsFromState(readJson(devStackStatePath), {
requireAgcBackend,
});
}
async function isBackendReady() {
const { apiUrl, spacetimeUrl, hasMatchingBackend } = readBackendTargets({
requireAgcBackend: true,
});
return (
hasMatchingBackend &&
Boolean(apiUrl) &&
Boolean(spacetimeUrl) &&
(await isHttpReady(`${apiUrl}/healthz`)) &&
(await isHttpReady(`${spacetimeUrl}/v1/ping`))
);
}
async function readExistingViteServer(endpoint = readAgcDevEndpoint()) {
return httpGetText(endpoint.url);
}
function isVitePortListening(endpoint = readAgcDevEndpoint()) {
return new Promise((resolveRequest) => {
const socket = net.connect({ host: endpoint.host, port: endpoint.port });
socket.once('connect', () => {
socket.destroy();
resolveRequest(true);
});
socket.once('error', () => resolveRequest(false));
socket.setTimeout(1000, () => {
socket.destroy();
resolveRequest(true);
});
});
}
function isAiGameCreatorServer(response) {
return (
response &&
response.statusCode >= 200 &&
response.statusCode < 500 &&
response.body.includes('
陶泥儿') &&
response.body.includes('/src/main.tsx')
);
}
async function readExistingViteMarker(endpoint = readAgcDevEndpoint()) {
const response = await httpGetText(endpoint.markerUrl, 2000);
if (!response || response.statusCode !== 200) {
return null;
}
try {
return JSON.parse(response.body);
} catch {
return null;
}
}
async function preflightExistingVite({
endpoint = readAgcDevEndpoint(),
readServer = readExistingViteServer,
portListening = isVitePortListening,
readMarker = readExistingViteMarker,
} = {}) {
const existing = await readServer(endpoint);
if (!existing) {
if (await portListening(endpoint)) {
throw new Error(
`${endpoint.url} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
);
}
return { status: 'available', apiTarget: '' };
}
if (!isAiGameCreatorServer(existing)) {
throw new Error(
`${endpoint.url} is already in use by another server. Stop it before starting Tauri dev.`,
);
}
const marker = await readMarker(endpoint);
const markerApiTarget =
marker?.schemaVersion === 1 &&
marker?.app === 'ai-game-creator-shell' &&
typeof marker?.apiTarget === 'string'
? marker.apiTarget
: '';
const actualTarget = markerApiTarget || 'unknown';
throw new Error(
`${endpoint.url} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`,
);
}
function spawnChild(command, args, options, spawnImpl = spawn) {
const isPosix = process.platform !== 'win32';
const useShell = options.shell ?? !isPosix;
const child = spawnImpl(command, args, {
...options,
shell: useShell,
// POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、
// Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。
detached: isPosix,
stdio: 'inherit',
});
const lifecycle = {
failure: null,
promise: null,
// detached 子进程在 POSIX 下以自身 PID 作为 PGID。leader 退出后
// child.pid 仍是清理其后代的唯一稳定句柄,必须随生命周期保留。
processGroupId: isPosix && Number.isInteger(child.pid) ? child.pid : null,
};
lifecycle.promise = new Promise((resolveLifecycle) => {
child.once('error', (error) => {
lifecycle.failure = { type: 'error', error };
resolveLifecycle(lifecycle.failure);
});
child.once('exit', (code, signal) => {
if (!lifecycle.failure) {
lifecycle.failure = { type: 'exit', code, signal };
}
resolveLifecycle(lifecycle.failure);
});
});
childLifecycles.set(child, lifecycle);
return child;
}
function readChildFailure(child) {
return childLifecycles.get(child)?.failure ?? null;
}
function waitForChildTermination(child) {
const lifecycle = childLifecycles.get(child);
if (!lifecycle) {
return Promise.resolve({
type: 'error',
error: new Error('子进程未注册生命周期监听'),
});
}
return lifecycle.promise;
}
function formatChildFailure(failure) {
if (failure?.type === 'error') {
return failure.error instanceof Error
? failure.error.message
: String(failure.error);
}
return failure?.signal
? `signal=${failure.signal}`
: `code=${failure?.code ?? 0}`;
}
function stopChild(child, signal = 'SIGTERM') {
if (!child) {
return;
}
if (process.platform !== 'win32') {
const processGroupId = childLifecycles.get(child)?.processGroupId;
if (Number.isInteger(processGroupId)) {
try {
process.kill(-processGroupId, signal);
return;
} catch (error) {
if (error?.code === 'ESRCH') {
return;
}
// leader 尚存活时保留 direct child fallback;leader 已退出则仍以
// 负 PGID kill 的失败为准,不能误以为 descendants 已清理。
if (child.exitCode != null || child.signalCode != null) {
return;
}
}
}
}
if (child.exitCode != null || child.signalCode != null) {
return;
}
try {
child.kill(signal);
} catch {
// ignore cleanup races
}
}
function readLinuxProcessGroupAlive(
processGroupId,
{ readdirImpl = readdirSync, readFileImpl = readFileSync } = {},
) {
let processIds;
try {
processIds = readdirImpl('/proc');
} catch {
return null;
}
for (const processId of processIds) {
if (!/^\d+$/.test(processId)) {
continue;
}
let stat;
try {
stat = readFileImpl(`/proc/${processId}/stat`, 'utf8');
} catch {
continue;
}
const commandEnd = stat.lastIndexOf(') ');
if (commandEnd < 0) {
continue;
}
const [state, , processGroup] = stat
.slice(commandEnd + 2)
.trim()
.split(/\s+/);
if (
Number(processGroup) === processGroupId &&
state !== 'Z' &&
state !== 'X'
) {
return true;
}
}
return false;
}
function isProcessGroupAlive(
processGroupId,
{
platform = process.platform,
killImpl = process.kill,
readLinuxGroupAlive = readLinuxProcessGroupAlive,
} = {},
) {
if (!Number.isInteger(processGroupId)) {
return false;
}
try {
killImpl(-processGroupId, 0);
} catch (error) {
return error?.code !== 'ESRCH';
}
if (platform === 'linux') {
const linuxGroupAlive = readLinuxGroupAlive(processGroupId);
if (typeof linuxGroupAlive === 'boolean') {
return linuxGroupAlive;
}
}
return true;
}
async function waitUntil(check, timeoutMs, pollIntervalMs = 25) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) {
return true;
}
await new Promise((resolveWait) => setTimeout(resolveWait, pollIntervalMs));
}
return check();
}
function runWindowsTaskkill(
processId,
{ spawnImpl = spawn, timeoutMs = 5000 } = {},
) {
return new Promise((resolveRequest) => {
const taskkill = spawnImpl(
'taskkill.exe',
['/PID', String(processId), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true,
},
);
let settled = false;
const timeout = setTimeout(() => {
try {
taskkill.kill('SIGKILL');
} catch {
// ignore taskkill timeout races
}
finish({ timedOut: true, code: null, error: null });
}, timeoutMs);
const finish = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolveRequest(result);
};
taskkill.once('error', (error) =>
finish({ timedOut: false, code: null, error }),
);
taskkill.once('exit', (code) =>
finish({ timedOut: false, code: code ?? 0, error: null }),
);
});
}
async function terminateChildTree(
child,
{
platform = process.platform,
gracefulTimeoutMs = 2500,
forceTimeoutMs = 2000,
killImpl = process.kill,
taskkillImpl = runWindowsTaskkill,
} = {},
) {
if (!child) {
return { stopped: true, forced: false };
}
if (platform === 'win32') {
if (!Number.isInteger(child.pid)) {
stopChild(child, 'SIGTERM');
return { stopped: true, forced: false };
}
const result = await taskkillImpl(child.pid);
return {
stopped:
!result?.timedOut &&
!result?.error &&
[0, 128].includes(result?.code ?? 0),
forced: true,
result,
};
}
const processGroupId = childLifecycles.get(child)?.processGroupId;
if (!Number.isInteger(processGroupId)) {
stopChild(child, 'SIGTERM');
const lifecycle = childLifecycles.get(child);
if (lifecycle) {
await Promise.race([
lifecycle.promise,
new Promise((resolveWait) =>
setTimeout(resolveWait, gracefulTimeoutMs),
),
]);
}
if (child.exitCode == null && child.signalCode == null) {
stopChild(child, 'SIGKILL');
return { stopped: false, forced: true };
}
return { stopped: true, forced: false };
}
stopChild(child, 'SIGTERM');
if (
await waitUntil(
() => !isProcessGroupAlive(processGroupId, { platform, killImpl }),
gracefulTimeoutMs,
)
) {
return { stopped: true, forced: false };
}
try {
killImpl(-processGroupId, 'SIGKILL');
} catch (error) {
if (error?.code !== 'ESRCH') {
return { stopped: false, forced: true, error };
}
}
const stopped = await waitUntil(
() => !isProcessGroupAlive(processGroupId, { platform, killImpl }),
forceTimeoutMs,
);
return { stopped, forced: true };
}
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isBackendReady()) {
return readBackendTargets();
}
const failure = readChildFailure(backendChild);
if (failure) {
throw new Error(`配套后端启动失败: ${formatChildFailure(failure)}`);
}
await Promise.race([
new Promise((resolveWait) => setTimeout(resolveWait, 1000)),
waitForChildTermination(backendChild),
]);
}
throw new Error('等待配套后端和数据库启动超时');
}
async function ensureBackend({
onBackendChild = () => {},
checkBackendReady = isBackendReady,
resolveTargets = readBackendTargets,
spawnBackend = () =>
spawnChild(
npm,
[
'--prefix',
'../..',
'run',
'agc:backend',
'--',
'--database',
backendDatabase,
'--spacetime-data-dir',
backendSpacetimeDataDir,
'--no-interactive',
],
{ cwd: appRoot },
),
waitUntilReady = waitForBackendReady,
} = {}) {
if (await checkBackendReady()) {
const targets = resolveTargets();
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 = spawnBackend();
try {
onBackendChild(backendChild);
const targets = await waitUntilReady(backendChild);
console.log(`[ai-game-creator-shell] backend ready ${targets.apiUrl}`);
return { backendChild, targets };
} catch (error) {
stopChild(backendChild);
throw error;
}
}
async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
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(endpoint);
if (existing) {
if (isAiGameCreatorServer(existing)) {
throw new Error(
`${endpoint.url} is already running and cannot be safely reused. Stop it before starting Tauri dev.`,
);
}
throw new Error(
`${endpoint.url} is already in use by another server. Stop it before starting Tauri dev.`,
);
}
return spawnChild(
npm,
[
'--prefix',
'../..',
'exec',
'vite',
'--',
'--config',
'vite.config.ts',
'--port',
String(endpoint.port),
],
{ cwd: appRoot, env: withAgcDevEndpointEnv(endpoint) },
);
}
async function main() {
let backendChild = null;
let viteChild = null;
let shutdownSignal = '';
const signalHandlers = new Map();
for (const signal of ['SIGINT', 'SIGTERM']) {
const handler = () => {
shutdownSignal = signal;
stopChild(viteChild, signal);
stopChild(backendChild, signal);
};
signalHandlers.set(signal, handler);
process.on(signal, handler);
}
try {
const endpoint = await resolveAgcDevEndpoint({ strictConfigured: true });
process.env[agcVitePortEnvKey] = String(endpoint.port);
await preflightExistingVite({ endpoint });
const backend = await ensureBackend({
onBackendChild(child) {
backendChild = child;
if (shutdownSignal) {
stopChild(child, shutdownSignal);
}
},
});
backendChild = backend.backendChild;
if (shutdownSignal) {
throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`);
}
viteChild = await startVite(backend.targets.apiUrl, endpoint);
if (shutdownSignal) {
stopChild(viteChild, shutdownSignal);
throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`);
}
const children = [backendChild, viteChild].filter(Boolean);
if (children.length === 0) {
return 0;
}
const failure = await Promise.race(
children.map((child) => waitForChildTermination(child)),
);
stopChild(viteChild);
stopChild(backendChild);
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
} catch (error) {
stopChild(viteChild);
stopChild(backendChild);
console.error(
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
} finally {
await Promise.all([
terminateChildTree(viteChild),
terminateChildTree(backendChild),
]);
for (const [signal, handler] of signalHandlers) {
process.off(signal, handler);
}
}
}
function isDirectModuleExecution() {
return Boolean(
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
);
}
export {
ensureBackend,
formatChildFailure,
isDirectModuleExecution,
isProcessGroupAlive,
preflightExistingVite,
readChildFailure,
readLinuxProcessGroupAlive,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
stopChild,
terminateChildTree,
waitForBackendReady,
waitForChildTermination,
};
if (isDirectModuleExecution()) {
process.exitCode = await main();
}