import Koa from 'koa' import cors from '@koa/cors' import bodyParser from '@koa/bodyparser' import serve from 'koa-static' import send from 'koa-send' import { resolve } from 'path' import { mkdir } from 'fs/promises' import { readFileSync } from 'fs' import { config } from './config' import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes' import { uploadRoutes } from './routes/upload' import { webhookRoutes } from './routes/webhook' import * as hermesCli from './services/hermes/hermes-cli' import { getToken, authMiddleware } from './services/auth' function getLocalVersion(): string { // production: dist/server → ../../package.json // dev: packages/server/src → ../../../package.json const candidates = [ resolve(__dirname, '../../package.json'), resolve(__dirname, '../../../package.json'), ] for (const p of candidates) { try { return JSON.parse(readFileSync(p, 'utf-8')).version } catch { } } return '0.0.0' } const LOCAL_VERSION = getLocalVersion() let cachedLatestVersion = '' async function checkLatestVersion(): Promise { try { const res = await fetch('https://registry.npmjs.org/hermes-web-ui/latest', { signal: AbortSignal.timeout(5000), }) if (res.ok) { const data = await res.json() const latest = data.version || '' if (latest && latest !== cachedLatestVersion) { cachedLatestVersion = latest if (latest !== LOCAL_VERSION) { console.log(`⬆ New version available: v${LOCAL_VERSION} → v${latest}`) } } } } catch { } } const app = new Koa() const { restartGateway, startGateway, startGatewayBackground, getVersion } = hermesCli let server: any = null let isShuttingDown = false // 👉 如果你有子进程,一定要存 let gatewayPid: number | null = null export async function bootstrap() { await mkdir(config.uploadDir, { recursive: true }) await mkdir(config.dataDir, { recursive: true }) // Auth (after mkdir so data dir exists) const authToken = await getToken() if (authToken) { app.use(await authMiddleware(authToken)) console.log(`🔐 Auth enabled — token: ${authToken}`) } await ensureApiServerConfig() await ensureGatewayRunning() app.use(cors({ origin: config.corsOrigins })) app.use(bodyParser()) app.use(webhookRoutes.routes()) app.use(uploadRoutes.routes()) // update (must be before hermesRoutes which includes proxy routes) app.use(async (ctx, next) => { if (ctx.path === '/api/hermes/update' && ctx.method === 'POST') { const isWin = process.platform === 'win32' const cmd = isWin ? 'cmd /c hermes-web-ui update' : 'hermes-web-ui update' try { const { execSync } = await import('child_process') const output = execSync(cmd, { encoding: 'utf-8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'], }) ctx.body = { success: true, message: output.trim() } } catch (err: any) { ctx.status = 500 ctx.body = { success: false, message: err.stderr || err.message } } return } await next() }) app.use(hermesRoutes.routes()) app.use(proxyMiddleware) // health app.use(async (ctx, next) => { if (ctx.path === '/health') { const raw = await getVersion() const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || '' let gatewayOk = false try { const res = await fetch(`${config.upstream.replace(/\/$/, '')}/health`, { signal: AbortSignal.timeout(5000), }) gatewayOk = res.ok } catch { } ctx.body = { status: gatewayOk ? 'ok' : 'error', platform: 'hermes-agent', version: hermesVersion, gateway: gatewayOk ? 'running' : 'stopped', webui_version: LOCAL_VERSION, webui_latest: cachedLatestVersion, webui_update_available: cachedLatestVersion && cachedLatestVersion !== LOCAL_VERSION, } return } await next() }) // SPA const distDir = resolve(__dirname, '..', 'client') app.use(serve(distDir)) app.use(async (ctx) => { if (!ctx.path.startsWith('/api') && ctx.path !== '/health' && ctx.path !== '/upload' && ctx.path !== '/webhook') { await send(ctx, 'index.html', { root: distDir }) } }) // 🚀 启动服务 server = app.listen(config.port, '0.0.0.0') // Terminal WebSocket (must be after server is created) setupTerminalWebSocket(server) server.on('listening', () => { console.log(`➜ Server: http://localhost:${config.port}`) console.log(`➜ Upstream: ${config.upstream}`) }) server.on('error', (err: any) => { console.error('Server error:', err.message) }) // 👇 绑定退出信号 bindShutdown() // Check for updates every 4 hours checkLatestVersion() setInterval(checkLatestVersion, 4 * 60 * 60 * 1000) } // ============================ // ✅ 统一关闭逻辑(核心) // ============================ function bindShutdown() { const shutdown = async (signal: string) => { if (isShuttingDown) return isShuttingDown = true console.log(`\n[${signal}] shutting down...`) try { // ✅ 1. 关闭 HTTP server if (server) { await new Promise((resolve) => { server.close(() => { console.log('✓ http server closed') resolve() }) }) } // ✅ 2. 关闭子进程(如果有) if (gatewayPid) { try { process.kill(gatewayPid) console.log(`✓ gateway process killed: ${gatewayPid}`) } catch { } } } catch (err) { console.error('shutdown error:', err) } process.exit(0) } // 👉 nodemon 专用(必须 once) process.once('SIGUSR2', shutdown) // 👉 正常退出 process.on('SIGINT', shutdown) process.on('SIGTERM', shutdown) // 👉 防止异常退出没处理 process.on('uncaughtException', (err) => { console.error('uncaughtException:', err) shutdown('uncaughtException') }) process.on('unhandledRejection', (err) => { console.error('unhandledRejection:', err) shutdown('unhandledRejection') }) } // ============================ // 你的原逻辑(基本不动) // ============================ async function ensureApiServerConfig() { const { readFileSync, writeFileSync, existsSync, copyFileSync } = await import('fs') const yaml = (await import('js-yaml')).default const { getActiveConfigPath } = await import('./services/hermes/hermes-profile') const configPath = getActiveConfigPath() const defaults: Record = { enabled: true, host: '127.0.0.1', port: 8642, key: '', cors_origins: '*', } try { if (!existsSync(configPath)) { console.log('✗ config.yaml not found') return } const content = readFileSync(configPath, 'utf-8') const cfg = yaml.load(content) as any || {} if (!cfg.platforms) cfg.platforms = {} if (!cfg.platforms.api_server) cfg.platforms.api_server = {} cfg.platforms.api_server = defaults copyFileSync(configPath, configPath + '.bak') writeFileSync(configPath, yaml.dump(cfg), 'utf-8') await restartGateway() } catch (err: any) { console.error('config error:', err.message) } } async function ensureGatewayRunning() { const upstream = config.upstream.replace(/\/$/, '') const waitForGatewayReady = async (timeoutMs: number = 15000) => { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { try { const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(2000) }) if (res.ok) return true } catch { } await new Promise(r => setTimeout(r, 300)) } return false } try { const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) }) if (res.ok) return } catch { } console.log('⚠ Gateway not running, starting...') try { // 👉 关键:保存 PID gatewayPid = await startGatewayBackground() if (await waitForGatewayReady()) { console.log(`✓ Gateway started (PID: ${gatewayPid})`) } else { console.error('gateway start failed: timed out waiting for health') } } catch (err: any) { console.error('gateway start failed:', err.message) } } bootstrap()