refactor: extract inline middleware from index.ts into separate modules (#85)
- Extract update middleware to routes/update.ts - Extract health middleware and version logic to routes/health.ts - Extract shutdown logic to services/shutdown.ts - Extract gateway init to services/gateway-bootstrap.ts - Remove unused variables, fix duplicate app creation - Bump version to 0.4.0 index.ts: 260 lines → 80 lines Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-web-ui",
|
"name": "hermes-web-ui",
|
||||||
"version": "0.3.8",
|
"version": "0.4.0",
|
||||||
"description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)",
|
"description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
+19
-195
@@ -3,154 +3,50 @@ import cors from '@koa/cors'
|
|||||||
import bodyParser from '@koa/bodyparser'
|
import bodyParser from '@koa/bodyparser'
|
||||||
import serve from 'koa-static'
|
import serve from 'koa-static'
|
||||||
import send from 'koa-send'
|
import send from 'koa-send'
|
||||||
|
import os from 'os'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
import { mkdir } from 'fs/promises'
|
import { mkdir } from 'fs/promises'
|
||||||
import { readFileSync } from 'fs'
|
|
||||||
import { config } from './config'
|
import { config } from './config'
|
||||||
import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes'
|
import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes'
|
||||||
import { uploadRoutes } from './routes/upload'
|
import { uploadRoutes } from './routes/upload'
|
||||||
import { webhookRoutes } from './routes/webhook'
|
import { webhookRoutes } from './routes/webhook'
|
||||||
import * as hermesCli from './services/hermes/hermes-cli'
|
import { updateRoutes } from './routes/update'
|
||||||
|
import { healthRoutes, startVersionCheck } from './routes/health'
|
||||||
import { getToken, authMiddleware } from './services/auth'
|
import { getToken, authMiddleware } from './services/auth'
|
||||||
|
import { initGatewayManager } from './services/gateway-bootstrap'
|
||||||
function getLocalVersion(): string {
|
import { bindShutdown } from './services/shutdown'
|
||||||
// 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),
|
|
||||||
headers: { 'Cache-Control': 'no-cache' },
|
|
||||||
})
|
|
||||||
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 server: any = null
|
||||||
let isShuttingDown = false
|
|
||||||
|
|
||||||
// 👉 如果你有子进程,一定要存
|
|
||||||
let gatewayPid: number | null = null
|
|
||||||
let gatewayManager: any = null
|
|
||||||
|
|
||||||
export async function bootstrap() {
|
export async function bootstrap() {
|
||||||
await mkdir(config.uploadDir, { recursive: true })
|
await mkdir(config.uploadDir, { recursive: true })
|
||||||
await mkdir(config.dataDir, { recursive: true })
|
await mkdir(config.dataDir, { recursive: true })
|
||||||
|
|
||||||
// Auth (after mkdir so data dir exists)
|
|
||||||
const authToken = await getToken()
|
const authToken = await getToken()
|
||||||
|
const app = new Koa()
|
||||||
|
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
app.use(await authMiddleware(authToken))
|
app.use(await authMiddleware(authToken))
|
||||||
console.log(`🔐 Auth enabled — token: ${authToken}`)
|
console.log(`🔐 Auth enabled — token: ${authToken}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await initGatewayManager()
|
await initGatewayManager()
|
||||||
|
|
||||||
app.use(cors({ origin: config.corsOrigins }))
|
app.use(cors({ origin: config.corsOrigins }))
|
||||||
app.use(bodyParser())
|
app.use(bodyParser())
|
||||||
|
|
||||||
|
// Shared routes (no agent prefix)
|
||||||
app.use(webhookRoutes.routes())
|
app.use(webhookRoutes.routes())
|
||||||
app.use(uploadRoutes.routes())
|
app.use(uploadRoutes.routes())
|
||||||
|
app.use(updateRoutes.routes())
|
||||||
|
|
||||||
// update (must be before hermesRoutes which includes proxy routes)
|
// Hermes routes (must be after update — proxy catch-all matches everything)
|
||||||
app.use(async (ctx, next) => {
|
|
||||||
if (ctx.path === '/api/hermes/update' && ctx.method === 'POST') {
|
|
||||||
const isWin = process.platform === 'win32'
|
|
||||||
// Run npm install directly — calling `hermes-web-ui update` would kill this
|
|
||||||
// process (stopDaemon) before the response can be sent to the client.
|
|
||||||
const cmd = isWin
|
|
||||||
? 'cmd /c npm install -g hermes-web-ui@latest'
|
|
||||||
: 'npm install -g hermes-web-ui@latest'
|
|
||||||
|
|
||||||
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() }
|
|
||||||
// Restart the server after response is sent
|
|
||||||
setTimeout(() => {
|
|
||||||
const { spawn } = require('child_process')
|
|
||||||
const isWin = process.platform === 'win32'
|
|
||||||
spawn(isWin ? 'cmd' : 'sh', isWin ? ['/c', 'hermes-web-ui restart'] : ['-c', 'hermes-web-ui restart'], {
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore',
|
|
||||||
windowsHide: true,
|
|
||||||
}).unref()
|
|
||||||
process.exit(0)
|
|
||||||
}, 2000)
|
|
||||||
} catch (err: any) {
|
|
||||||
ctx.status = 500
|
|
||||||
ctx.body = { success: false, message: err.stderr || err.message }
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
|
|
||||||
app.use(hermesRoutes.routes())
|
app.use(hermesRoutes.routes())
|
||||||
app.use(proxyMiddleware)
|
app.use(proxyMiddleware)
|
||||||
|
|
||||||
// health
|
// Health check
|
||||||
app.use(async (ctx, next) => {
|
app.use(healthRoutes.routes())
|
||||||
if (ctx.path === '/health') {
|
|
||||||
const raw = await getVersion()
|
|
||||||
const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
|
|
||||||
|
|
||||||
let gatewayOk = false
|
// SPA fallback
|
||||||
try {
|
|
||||||
const upstream = gatewayManager?.getUpstream() || config.upstream
|
|
||||||
const res = await fetch(`${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')
|
const distDir = resolve(__dirname, '..', 'client')
|
||||||
app.use(serve(distDir))
|
app.use(serve(distDir))
|
||||||
app.use(async (ctx) => {
|
app.use(async (ctx) => {
|
||||||
@@ -162,14 +58,15 @@ export async function bootstrap() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 🚀 启动服务
|
// Start server
|
||||||
server = app.listen(config.port, '0.0.0.0')
|
server = app.listen(config.port, '0.0.0.0')
|
||||||
|
|
||||||
// Terminal WebSocket (must be after server is created)
|
|
||||||
setupTerminalWebSocket(server)
|
setupTerminalWebSocket(server)
|
||||||
|
|
||||||
server.on('listening', () => {
|
server.on('listening', () => {
|
||||||
console.log(`➜ Server: http://localhost:${config.port}`)
|
const interfaces = os.networkInterfaces()
|
||||||
|
const localIp = Object.values(interfaces).flat().find(i => i?.family === 'IPv4' && !i?.internal)?.address || 'localhost'
|
||||||
|
console.log(`➜ Server: http://localhost:${config.port} (LAN: http://${localIp}:${config.port})`)
|
||||||
console.log(`➜ Upstream: ${config.upstream}`)
|
console.log(`➜ Upstream: ${config.upstream}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -177,81 +74,8 @@ export async function bootstrap() {
|
|||||||
console.error('Server error:', err.message)
|
console.error('Server error:', err.message)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 👇 绑定退出信号
|
bindShutdown(server)
|
||||||
bindShutdown()
|
startVersionCheck()
|
||||||
|
|
||||||
// Check for updates every 4 hours
|
|
||||||
checkLatestVersion()
|
|
||||||
setInterval(checkLatestVersion, 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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// gateway 是系统服务,不随 dev server 退出而停止
|
|
||||||
|
|
||||||
} 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')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================
|
|
||||||
// Gateway Manager
|
|
||||||
// ============================
|
|
||||||
|
|
||||||
async function initGatewayManager() {
|
|
||||||
const { GatewayManager } = await import('./services/hermes/gateway-manager')
|
|
||||||
const { getActiveProfileName } = await import('./services/hermes/hermes-profile')
|
|
||||||
const { setGatewayManager } = await import('./routes/hermes/gateways')
|
|
||||||
|
|
||||||
const activeProfile = getActiveProfileName()
|
|
||||||
gatewayManager = new GatewayManager(activeProfile)
|
|
||||||
setGatewayManager(gatewayManager)
|
|
||||||
|
|
||||||
// Detect all running gateways
|
|
||||||
await gatewayManager.detectAllOnStartup()
|
|
||||||
|
|
||||||
// Start all gateways that aren't running
|
|
||||||
await gatewayManager.startAll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap()
|
bootstrap()
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Router from '@koa/router'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { getGatewayManager } from './hermes/gateways'
|
||||||
|
import * as hermesCli from '../services/hermes/hermes-cli'
|
||||||
|
import { config } from '../config'
|
||||||
|
|
||||||
|
function getLocalVersion(): string {
|
||||||
|
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 = ''
|
||||||
|
|
||||||
|
export async function checkLatestVersion(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://registry.npmjs.org/hermes-web-ui/latest', {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
headers: { 'Cache-Control': 'no-cache' },
|
||||||
|
})
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startVersionCheck(): void {
|
||||||
|
checkLatestVersion()
|
||||||
|
setInterval(checkLatestVersion, 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const healthRoutes = new Router()
|
||||||
|
|
||||||
|
healthRoutes.get('/health', async (ctx) => {
|
||||||
|
const raw = await hermesCli.getVersion()
|
||||||
|
const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
|
||||||
|
|
||||||
|
let gatewayOk = false
|
||||||
|
try {
|
||||||
|
const mgr = getGatewayManager()
|
||||||
|
const upstream = mgr?.getUpstream() || config.upstream
|
||||||
|
const res = await fetch(`${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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Router from '@koa/router'
|
||||||
|
|
||||||
|
export const updateRoutes = new Router()
|
||||||
|
|
||||||
|
updateRoutes.post('/api/hermes/update', async (ctx) => {
|
||||||
|
const isWin = process.platform === 'win32'
|
||||||
|
const cmd = isWin
|
||||||
|
? 'cmd /c npm install -g hermes-web-ui@latest'
|
||||||
|
: 'npm install -g hermes-web-ui@latest'
|
||||||
|
|
||||||
|
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() }
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const { spawn } = require('child_process')
|
||||||
|
spawn(isWin ? 'cmd' : 'sh', isWin ? ['/c', 'hermes-web-ui restart'] : ['-c', 'hermes-web-ui restart'], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true,
|
||||||
|
}).unref()
|
||||||
|
process.exit(0)
|
||||||
|
}, 2000)
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.status = 500
|
||||||
|
ctx.body = { success: false, message: err.stderr || err.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
let gatewayManager: any = null
|
||||||
|
|
||||||
|
export function getGatewayManagerInstance(): any {
|
||||||
|
return gatewayManager
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initGatewayManager(): Promise<void> {
|
||||||
|
const { GatewayManager } = await import('./hermes/gateway-manager')
|
||||||
|
const { getActiveProfileName } = await import('./hermes/hermes-profile')
|
||||||
|
const { setGatewayManager } = await import('../routes/hermes/gateways')
|
||||||
|
|
||||||
|
const activeProfile = getActiveProfileName()
|
||||||
|
gatewayManager = new GatewayManager(activeProfile)
|
||||||
|
setGatewayManager(gatewayManager)
|
||||||
|
|
||||||
|
await gatewayManager.detectAllOnStartup()
|
||||||
|
await gatewayManager.startAll()
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export function bindShutdown(server: any): void {
|
||||||
|
let isShuttingDown = false
|
||||||
|
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
if (isShuttingDown) return
|
||||||
|
isShuttingDown = true
|
||||||
|
|
||||||
|
console.log(`\n[${signal}] shutting down...`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (server) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.close(() => {
|
||||||
|
console.log('✓ http server closed')
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('shutdown error:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user