Files
Hermes-ui/packages/server/src/index.ts
T

294 lines
7.9 KiB
TypeScript
Raw Normal View History

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-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<void> {
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<void>((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-profile')
const configPath = getActiveConfigPath()
const defaults: Record<string, any> = {
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(/\/$/, '')
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()
await new Promise(r => setTimeout(r, 3000))
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
if (res.ok) {
console.log(`✓ Gateway started (PID: ${gatewayPid})`)
}
} catch (err: any) {
console.error('gateway start failed:', err.message)
}
}
bootstrap()