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

219 lines
8.1 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 os from 'os'
import { resolve } from 'path'
import { mkdir } from 'fs/promises'
import { readFileSync } from 'fs'
import { config } from './config'
import { initLoginLimiter } from './services/login-limiter'
import { bindShutdown } from './services/shutdown'
import { setupTerminalWebSocket } from './routes/hermes/terminal'
import { setupKanbanEventsWebSocket } from './routes/hermes/kanban-events'
import { startVersionCheck } from './routes/health'
import { registerRoutes } from './routes'
import { setGroupChatServer } from './routes/hermes/group-chat'
import { setChatRunServer } from './routes/hermes/chat-run'
import { GroupChatServer } from './services/hermes/group-chat'
2026-05-15 10:08:52 +08:00
import { ChatRunSocket } from './services/hermes/run-chat'
2026-05-14 09:03:57 +08:00
import { startAgentBridgeManager } from './services/hermes/agent-bridge'
import { HermesSkillInjector } from './services/hermes/skill-injector'
import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart'
import { logger } from './services/logger'
2026-05-23 18:44:53 +08:00
import { requireUserJwt, resolveUserProfile } from './middleware/user-auth'
// Injected by esbuild at build time; fallback to reading package.json in dev mode
declare const __APP_VERSION__: string
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined'
? __APP_VERSION__
: (() => { try { return JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')).version } catch { return 'dev' } })()
// Global error handlers
process.on('uncaughtException', (err) => {
console.error('FATAL: Uncaught exception')
console.error(err)
logger.fatal(err, 'Uncaught exception')
process.exit(1)
})
process.on('unhandledRejection', (reason) => {
2026-05-20 10:02:15 +08:00
console.error('Unhandled rejection')
console.error(reason)
logger.error(reason, 'Unhandled rejection')
})
let server: any = null
let servers: any[] = []
let chatRunServer: any = null
2026-05-14 09:03:57 +08:00
let agentBridgeManager: any = null
interface ListenResult {
primary: any
servers: any[]
}
function listen(app: Koa, port: number, host: string): Promise<any> {
return new Promise((resolve, reject) => {
const s = app.listen(port, host)
s.once('listening', () => resolve(s))
s.once('error', reject)
})
}
async function listenWithFallback(app: Koa, port: number, host?: string): Promise<ListenResult> {
2026-05-08 15:47:03 +08:00
const bindHost = host || '0.0.0.0'
console.log(`[bootstrap] listening on ${bindHost}:${port}`)
const primary = await listen(app, port, bindHost)
return { primary, servers: [primary] }
}
/**
* 安全获取网络接口信息(兼容 Termux/proot 环境)
* 在 proot 环境中 os.networkInterfaces() 会抛出权限错误(errno 13
*/
function safeNetworkInterfaces() {
try {
return os.networkInterfaces()
} catch {
return {}
}
}
export async function bootstrap() {
console.log(`hermes-web-ui v${APP_VERSION} starting...`)
await mkdir(config.uploadDir, { recursive: true })
await mkdir(config.dataDir, { recursive: true })
await initLoginLimiter()
try {
const skillInjector = new HermesSkillInjector()
const injectionResult = await skillInjector.injectMissingSkills()
if (injectionResult.injected.length > 0) {
logger.info({
injected: [...new Set(injectionResult.injected)],
targetCount: injectionResult.targets.length,
}, '[bootstrap] bundled skills injected')
}
if (injectionResult.updated.length > 0) {
logger.info({
updated: [...new Set(injectionResult.updated)],
targetCount: injectionResult.targets.length,
}, '[bootstrap] bundled skills updated')
}
} catch (err) {
logger.warn(err, '[bootstrap] failed to inject bundled skills')
console.warn('[bootstrap] failed to inject bundled skills:', err instanceof Error ? err.message : err)
}
try {
await ensureProfileGatewaysRunning()
console.log('[bootstrap] profile gateways checked')
} catch (err) {
logger.warn(err, '[bootstrap] failed to ensure profile gateways')
console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err)
}
const app = new Koa()
2026-05-14 09:03:57 +08:00
try {
agentBridgeManager = await startAgentBridgeManager()
console.log('[bootstrap] agent bridge started')
} catch (err) {
logger.warn(err, '[bootstrap] agent bridge failed to start')
console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err)
}
await new Promise(resolve => setTimeout(resolve, 1000))
// Initialize all web-ui SQLite tables
const { initAllStores } = await import('./db/hermes/init')
// Wait 1 second before initializing stores to ensure all resources are ready
initAllStores()
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('[bootstrap] all stores initialized')
app.use(cors({ origin: config.corsOrigins }))
app.use(bodyParser())
console.log('[bootstrap] cors + bodyParser registered')
// Register all routes (handles auth internally)
2026-05-23 18:44:53 +08:00
const proxyMiddleware = registerRoutes(app, [requireUserJwt, resolveUserProfile])
app.use(proxyMiddleware)
console.log('[bootstrap] routes registered')
// SPA fallback
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 })
}
})
console.log('[bootstrap] SPA fallback registered')
2026-05-08 15:47:03 +08:00
// Start server using the configured bind host. Default is IPv4 for WSL stability.
const listenResult = await listenWithFallback(app, config.port, config.host)
server = listenResult.primary
servers = listenResult.servers
console.log('[bootstrap] app.listen called')
setupTerminalWebSocket(servers)
setupKanbanEventsWebSocket(servers)
console.log('[bootstrap] terminal + kanban websocket setup')
// Group chat Socket.IO (must be after server is created)
const groupChatServer = new GroupChatServer(servers)
setGroupChatServer(groupChatServer)
// Chat run Socket.IO — shares the same Server instance, just adds /chat-run namespace
chatRunServer = new ChatRunSocket(groupChatServer.getIO())
setChatRunServer(chatRunServer)
chatRunServer.init()
// Session deleter — periodically drain pending session deletes
const { SessionDeleter } = await import('./services/hermes/session-deleter')
const sessionDeleter = SessionDeleter.getInstance()
const activeProfile = process.env.PROFILE || 'default'
sessionDeleter.start(activeProfile)
console.log('[bootstrap] session deleter started, profile=%s', activeProfile)
// Catch-all: destroy upgrade requests not handled by terminal or Socket.IO
servers.forEach((httpServer) => {
httpServer.on('upgrade', (req: any, socket: any) => {
const url = new URL(req.url || '', `http://${req.headers.host}`)
if (url.pathname !== '/api/hermes/terminal' && url.pathname !== '/api/hermes/kanban/events' && !url.pathname.startsWith('/socket.io/')) {
socket.destroy()
}
})
})
const interfaces = safeNetworkInterfaces()
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(`Log: ${config.appHome}/logs/server.log`)
logger.info('Server: http://localhost:%d (LAN: http://%s:%d)', config.port, localIp, config.port)
// Restore group chat agents after server is ready.
groupChatServer.restoreWhenReady()
servers.forEach((httpServer) => {
httpServer.on('error', (err: any) => {
console.error('[bootstrap] server error:', err.code || err.message)
logger.error({ err }, 'Server error')
})
})
2026-05-14 09:03:57 +08:00
bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
startVersionCheck()
}
bootstrap().catch((error) => {
console.error('FATAL: Failed to start Hermes Web UI')
console.error(error)
logger.fatal(error, 'Fatal error during bootstrap')
process.exit(1)
})