2026-04-11 21:33:04 +08:00
|
|
|
|
import Koa from 'koa'
|
|
|
|
|
|
import cors from '@koa/cors'
|
|
|
|
|
|
import bodyParser from '@koa/bodyparser'
|
|
|
|
|
|
import serve from 'koa-static'
|
|
|
|
|
|
import send from 'koa-send'
|
2026-04-20 20:37:32 +08:00
|
|
|
|
import os from 'os'
|
2026-04-11 21:33:04 +08:00
|
|
|
|
import { resolve } from 'path'
|
|
|
|
|
|
import { mkdir } from 'fs/promises'
|
2026-04-21 12:35:48 +08:00
|
|
|
|
import { readFileSync } from 'fs'
|
2026-04-11 21:33:04 +08:00
|
|
|
|
import { config } from './config'
|
2026-04-21 12:35:48 +08:00
|
|
|
|
import { getToken, requireAuth } from './services/auth'
|
2026-04-24 20:41:14 +08:00
|
|
|
|
import { initGatewayManager, getGatewayManagerInstance } from './services/gateway-bootstrap'
|
2026-04-20 20:37:32 +08:00
|
|
|
|
import { bindShutdown } from './services/shutdown'
|
2026-04-21 12:35:48 +08:00
|
|
|
|
import { setupTerminalWebSocket } from './routes/hermes/terminal'
|
|
|
|
|
|
import { startVersionCheck } from './routes/health'
|
|
|
|
|
|
import { registerRoutes } from './routes'
|
2026-04-24 20:41:14 +08:00
|
|
|
|
import { setGroupChatServer } from './routes/hermes/group-chat'
|
2026-04-29 16:26:24 +08:00
|
|
|
|
import { setChatRunServer } from './routes/hermes/chat-run'
|
2026-04-24 20:41:14 +08:00
|
|
|
|
import { GroupChatServer } from './services/hermes/group-chat'
|
2026-04-29 16:26:24 +08:00
|
|
|
|
import { ChatRunSocket } from './services/hermes/chat-run-socket'
|
2026-04-21 12:35:48 +08:00
|
|
|
|
import { logger } from './services/logger'
|
|
|
|
|
|
|
|
|
|
|
|
// 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__
|
2026-04-22 16:14:50 +08:00
|
|
|
|
: (() => { try { return JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')).version } catch { return 'dev' } })()
|
2026-04-21 12:35:48 +08:00
|
|
|
|
|
|
|
|
|
|
// Global error handlers
|
|
|
|
|
|
process.on('uncaughtException', (err) => {
|
|
|
|
|
|
logger.fatal(err, 'Uncaught exception')
|
|
|
|
|
|
process.exit(1)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
process.on('unhandledRejection', (reason) => {
|
|
|
|
|
|
logger.error(reason, 'Unhandled rejection')
|
|
|
|
|
|
})
|
2026-04-11 21:33:04 +08:00
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
let server: any = null
|
2026-05-07 19:11:32 +08:00
|
|
|
|
let servers: any[] = []
|
2026-05-05 13:03:14 +08:00
|
|
|
|
let chatRunServer: any = null
|
2026-04-14 20:17:12 +08:00
|
|
|
|
|
2026-05-07 19:11:32 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function probeIPv4(port: number): Promise<boolean> {
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
|
|
|
|
const req = require('http').get(`http://127.0.0.1:${port}/health`, (res: any) => {
|
|
|
|
|
|
res.resume()
|
|
|
|
|
|
resolve(true)
|
|
|
|
|
|
})
|
|
|
|
|
|
req.once('error', () => resolve(false))
|
|
|
|
|
|
req.setTimeout(1000, () => {
|
|
|
|
|
|
req.destroy()
|
|
|
|
|
|
resolve(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Try listening on IPv6 dual-stack (::) first. If IPv4 is not reachable through
|
|
|
|
|
|
* that socket, keep IPv6 and add a separate IPv4 listener. Fall back to IPv4
|
|
|
|
|
|
* only when IPv6 is unavailable. Skips fallback when BIND_HOST is explicitly set.
|
|
|
|
|
|
*
|
|
|
|
|
|
* On some systems (e.g. WSL2), binding to :: succeeds but the dual-stack
|
|
|
|
|
|
* doesn't actually accept IPv4 connections. We detect this by probing
|
|
|
|
|
|
* 127.0.0.1 after binding.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function listenWithFallback(app: Koa, port: number, host?: string): Promise<ListenResult> {
|
|
|
|
|
|
// Explicit host: use it directly.
|
|
|
|
|
|
if (host) {
|
|
|
|
|
|
console.log(`[bootstrap] listening on ${host}:${port}`)
|
|
|
|
|
|
const explicit = await listen(app, port, host)
|
|
|
|
|
|
return { primary: explicit, servers: [explicit] }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[bootstrap] trying IPv6 dual-stack on ::${port}`)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const s6 = await listen(app, port, '::')
|
|
|
|
|
|
if (await probeIPv4(port)) {
|
|
|
|
|
|
console.log(`[bootstrap] IPv6 dual-stack verified (IPv4 probe ok) on ::${port}`)
|
|
|
|
|
|
return { primary: s6, servers: [s6] }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[bootstrap] IPv6 listener is IPv6-only, adding IPv4 listener on 0.0.0.0')
|
|
|
|
|
|
try {
|
|
|
|
|
|
const s4 = await listen(app, port, '0.0.0.0')
|
|
|
|
|
|
console.log(`[bootstrap] listening on ::${port} and 0.0.0.0:${port}`)
|
|
|
|
|
|
return { primary: s6, servers: [s6, s4] }
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.log('[bootstrap] IPv4 listener failed; keeping IPv6 listener')
|
|
|
|
|
|
logger.warn({ err }, 'Could not add IPv4 listener after IPv6-only bind')
|
|
|
|
|
|
return { primary: s6, servers: [s6] }
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
if (err.code !== 'EADDRNOTAVAIL' && err.code !== 'EAFNOSUPPORT' && err.code !== 'EPROTONOSUPPORT') {
|
|
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[bootstrap] IPv6 not available (${err.code}), falling back to 0.0.0.0`)
|
|
|
|
|
|
const s4 = await listen(app, port, '0.0.0.0')
|
|
|
|
|
|
console.log(`[bootstrap] listening on 0.0.0.0:${port}`)
|
|
|
|
|
|
return { primary: s4, servers: [s4] }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 17:07:16 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 安全获取网络接口信息(兼容 Termux/proot 环境)
|
|
|
|
|
|
* 在 proot 环境中 os.networkInterfaces() 会抛出权限错误(errno 13)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function safeNetworkInterfaces() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return os.networkInterfaces()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 21:33:04 +08:00
|
|
|
|
export async function bootstrap() {
|
2026-04-21 12:35:48 +08:00
|
|
|
|
console.log(`hermes-web-ui v${APP_VERSION} starting...`)
|
2026-04-11 21:33:04 +08:00
|
|
|
|
await mkdir(config.uploadDir, { recursive: true })
|
|
|
|
|
|
await mkdir(config.dataDir, { recursive: true })
|
2026-04-14 21:48:53 +08:00
|
|
|
|
|
|
|
|
|
|
const authToken = await getToken()
|
2026-04-20 20:37:32 +08:00
|
|
|
|
const app = new Koa()
|
|
|
|
|
|
|
2026-04-18 13:07:12 +08:00
|
|
|
|
await initGatewayManager()
|
2026-04-21 12:35:48 +08:00
|
|
|
|
console.log('[bootstrap] gateway manager initialized')
|
2026-05-02 17:02:33 +08:00
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
2026-04-29 16:26:24 +08:00
|
|
|
|
// Initialize all web-ui SQLite tables
|
|
|
|
|
|
const { initAllStores } = await import('./db/hermes/init')
|
2026-05-02 17:02:33 +08:00
|
|
|
|
// Wait 1 second before initializing stores to ensure all resources are ready
|
2026-04-29 20:22:07 +08:00
|
|
|
|
initAllStores()
|
2026-05-02 17:02:33 +08:00
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
2026-04-29 16:26:24 +08:00
|
|
|
|
console.log('[bootstrap] all stores initialized')
|
|
|
|
|
|
|
|
|
|
|
|
// Sync Hermes sessions from all profiles (only if local DB is empty)
|
|
|
|
|
|
const { syncAllHermesSessionsOnStartup } = await import('./services/hermes/session-sync')
|
|
|
|
|
|
await syncAllHermesSessionsOnStartup()
|
|
|
|
|
|
console.log('[bootstrap] Hermes session sync completed')
|
2026-04-22 16:14:50 +08:00
|
|
|
|
|
2026-04-11 21:33:04 +08:00
|
|
|
|
app.use(cors({ origin: config.corsOrigins }))
|
|
|
|
|
|
app.use(bodyParser())
|
2026-04-21 12:35:48 +08:00
|
|
|
|
console.log('[bootstrap] cors + bodyParser registered')
|
2026-04-11 21:33:04 +08:00
|
|
|
|
|
2026-04-21 12:35:48 +08:00
|
|
|
|
// Register all routes (handles auth internally)
|
|
|
|
|
|
const proxyMiddleware = registerRoutes(app, requireAuth(authToken))
|
2026-04-16 08:38:18 +08:00
|
|
|
|
app.use(proxyMiddleware)
|
2026-04-21 12:35:48 +08:00
|
|
|
|
console.log('[bootstrap] routes registered')
|
2026-04-11 21:33:04 +08:00
|
|
|
|
|
2026-04-21 12:35:48 +08:00
|
|
|
|
if (authToken) {
|
|
|
|
|
|
console.log(`Auth enabled — token: ${authToken}`)
|
|
|
|
|
|
logger.info('Auth enabled — token: %s', authToken)
|
|
|
|
|
|
}
|
2026-04-13 20:08:32 +08:00
|
|
|
|
|
2026-04-20 20:37:32 +08:00
|
|
|
|
// SPA fallback
|
2026-04-16 08:38:18 +08:00
|
|
|
|
const distDir = resolve(__dirname, '..', 'client')
|
2026-04-11 21:33:04 +08:00
|
|
|
|
app.use(serve(distDir))
|
|
|
|
|
|
app.use(async (ctx) => {
|
2026-04-14 20:17:12 +08:00
|
|
|
|
if (!ctx.path.startsWith('/api') &&
|
|
|
|
|
|
ctx.path !== '/health' &&
|
|
|
|
|
|
ctx.path !== '/upload' &&
|
|
|
|
|
|
ctx.path !== '/webhook') {
|
2026-04-11 21:33:04 +08:00
|
|
|
|
await send(ctx, 'index.html', { root: distDir })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-04-21 12:35:48 +08:00
|
|
|
|
console.log('[bootstrap] SPA fallback registered')
|
2026-04-11 21:33:04 +08:00
|
|
|
|
|
2026-05-07 19:11:32 +08:00
|
|
|
|
// Start server — try IPv6 dual-stack first, fall back to IPv4
|
|
|
|
|
|
const listenResult = await listenWithFallback(app, config.port, config.host)
|
|
|
|
|
|
server = listenResult.primary
|
|
|
|
|
|
servers = listenResult.servers
|
2026-04-21 12:35:48 +08:00
|
|
|
|
console.log('[bootstrap] app.listen called')
|
2026-04-14 20:17:12 +08:00
|
|
|
|
|
2026-05-07 19:11:32 +08:00
|
|
|
|
setupTerminalWebSocket(servers)
|
2026-04-21 12:35:48 +08:00
|
|
|
|
console.log('[bootstrap] terminal websocket setup')
|
2026-04-15 16:36:04 +08:00
|
|
|
|
|
2026-04-24 20:41:14 +08:00
|
|
|
|
// Group chat Socket.IO (must be after server is created)
|
2026-05-07 19:11:32 +08:00
|
|
|
|
const groupChatServer = new GroupChatServer(servers)
|
2026-04-24 20:41:14 +08:00
|
|
|
|
setGroupChatServer(groupChatServer)
|
|
|
|
|
|
groupChatServer.setGatewayManager(getGatewayManagerInstance())
|
|
|
|
|
|
|
2026-04-29 16:26:24 +08:00
|
|
|
|
// Chat run Socket.IO — shares the same Server instance, just adds /chat-run namespace
|
2026-05-05 13:03:14 +08:00
|
|
|
|
chatRunServer = new ChatRunSocket(groupChatServer.getIO(), getGatewayManagerInstance())
|
2026-04-29 16:26:24 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-24 20:41:14 +08:00
|
|
|
|
// Catch-all: destroy upgrade requests not handled by terminal or Socket.IO
|
2026-05-07 19:11:32 +08:00
|
|
|
|
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.startsWith('/socket.io/')) {
|
|
|
|
|
|
socket.destroy()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-04-11 21:33:04 +08:00
|
|
|
|
})
|
2026-04-14 20:17:12 +08:00
|
|
|
|
|
2026-05-07 19:11:32 +08:00
|
|
|
|
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(`Upstream: ${config.upstream}`)
|
|
|
|
|
|
console.log(`Log: ~/.hermes-web-ui/logs/server.log`)
|
|
|
|
|
|
logger.info('Server: http://localhost:%d (LAN: http://%s:%d)', config.port, localIp, config.port)
|
|
|
|
|
|
logger.info('Upstream: %s', config.upstream)
|
|
|
|
|
|
|
|
|
|
|
|
// 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-04-14 20:17:12 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-07 19:11:32 +08:00
|
|
|
|
bindShutdown(servers, groupChatServer, chatRunServer)
|
2026-04-20 20:37:32 +08:00
|
|
|
|
startVersionCheck()
|
2026-04-13 20:08:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 03:13:24 +08:00
|
|
|
|
bootstrap()
|