fix: default to 0.0.0.0 to fix WSL2 health check failure (#520)
PR #470 changed the default listen host to undefined, letting Node.js bind to IPv6 :: on systems that support it. This broke WSL2 where IPv6 dual-stack is unreliable — the server binds to :: but IPv4 127.0.0.1 connections fail, causing the health check to time out. Revert to 0.0.0.0 as the default. Users who need IPv6 can set BIND_HOST=:: explicitly. Fixes #518 Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -121,6 +121,13 @@ const terminalBg = computed(
|
|||||||
|
|
||||||
// ─── WebSocket ──────────────────────────────────────────────────
|
// ─── WebSocket ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatHostForPort(hostname: string, port: number): string {
|
||||||
|
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
||||||
|
return `${hostname}:${port}`;
|
||||||
|
}
|
||||||
|
return hostname.includes(":") ? `[${hostname}]:${port}` : `${hostname}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildWsUrl(): string {
|
function buildWsUrl(): string {
|
||||||
const token = getApiKey();
|
const token = getApiKey();
|
||||||
const base = getBaseUrlValue();
|
const base = getBaseUrlValue();
|
||||||
@@ -137,7 +144,7 @@ function buildWsUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const host = import.meta.env.DEV
|
const host = import.meta.env.DEV
|
||||||
? `${location.hostname}:8648`
|
? formatHostForPort(location.hostname, 8648)
|
||||||
: location.host;
|
: location.host;
|
||||||
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,6 +265,13 @@ const terminalBg = computed(
|
|||||||
|
|
||||||
// ─── WebSocket ──────────────────────────────────────────────────
|
// ─── WebSocket ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatHostForPort(hostname: string, port: number): string {
|
||||||
|
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
||||||
|
return `${hostname}:${port}`;
|
||||||
|
}
|
||||||
|
return hostname.includes(":") ? `[${hostname}]:${port}` : `${hostname}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildWsUrl(): string {
|
function buildWsUrl(): string {
|
||||||
const token = getApiKey();
|
const token = getApiKey();
|
||||||
const base = getBaseUrlValue();
|
const base = getBaseUrlValue();
|
||||||
@@ -282,7 +289,7 @@ function buildWsUrl(): string {
|
|||||||
|
|
||||||
// Dev mode: connect directly to backend port; Production: same host
|
// Dev mode: connect directly to backend port; Production: same host
|
||||||
const host = import.meta.env.DEV
|
const host = import.meta.env.DEV
|
||||||
? `${location.hostname}:8648`
|
? formatHostForPort(location.hostname, 8648)
|
||||||
: location.host;
|
: location.host;
|
||||||
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ export function getListenHost(env: Record<string, string | undefined> = process.
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
port: parseInt(process.env.PORT || '8648', 10),
|
port: parseInt(process.env.PORT || '8648', 10),
|
||||||
// Leave host undefined by default so Node binds to IPv6 when available,
|
// Default undefined: listenWithFallback tries :: first, falls back to 0.0.0.0
|
||||||
// falling back to IPv4 on systems without IPv6 support.
|
|
||||||
host: getListenHost(),
|
host: getListenHost(),
|
||||||
upstream: process.env.UPSTREAM || 'http://127.0.0.1:8642',
|
upstream: process.env.UPSTREAM || 'http://127.0.0.1:8642',
|
||||||
uploadDir: process.env.UPLOAD_DIR || resolve(homedir(), '.hermes-web-ui', 'upload'),
|
uploadDir: process.env.UPLOAD_DIR || resolve(homedir(), '.hermes-web-ui', 'upload'),
|
||||||
|
|||||||
+105
-28
@@ -37,8 +37,84 @@ process.on('unhandledRejection', (reason) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let server: any = null
|
let server: any = null
|
||||||
|
let servers: any[] = []
|
||||||
let chatRunServer: any = null
|
let chatRunServer: 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 安全获取网络接口信息(兼容 Termux/proot 环境)
|
* 安全获取网络接口信息(兼容 Termux/proot 环境)
|
||||||
* 在 proot 环境中 os.networkInterfaces() 会抛出权限错误(errno 13)
|
* 在 proot 环境中 os.networkInterfaces() 会抛出权限错误(errno 13)
|
||||||
@@ -101,18 +177,17 @@ export async function bootstrap() {
|
|||||||
})
|
})
|
||||||
console.log('[bootstrap] SPA fallback registered')
|
console.log('[bootstrap] SPA fallback registered')
|
||||||
|
|
||||||
// Start server
|
// Start server — try IPv6 dual-stack first, fall back to IPv4
|
||||||
console.log(`[bootstrap] listening on ${config.host || 'default host'}:${config.port}`)
|
const listenResult = await listenWithFallback(app, config.port, config.host)
|
||||||
server = config.host
|
server = listenResult.primary
|
||||||
? app.listen(config.port, config.host)
|
servers = listenResult.servers
|
||||||
: app.listen(config.port)
|
|
||||||
console.log('[bootstrap] app.listen called')
|
console.log('[bootstrap] app.listen called')
|
||||||
|
|
||||||
setupTerminalWebSocket(server)
|
setupTerminalWebSocket(servers)
|
||||||
console.log('[bootstrap] terminal websocket setup')
|
console.log('[bootstrap] terminal websocket setup')
|
||||||
|
|
||||||
// Group chat Socket.IO (must be after server is created)
|
// Group chat Socket.IO (must be after server is created)
|
||||||
const groupChatServer = new GroupChatServer(server)
|
const groupChatServer = new GroupChatServer(servers)
|
||||||
setGroupChatServer(groupChatServer)
|
setGroupChatServer(groupChatServer)
|
||||||
groupChatServer.setGatewayManager(getGatewayManagerInstance())
|
groupChatServer.setGatewayManager(getGatewayManagerInstance())
|
||||||
|
|
||||||
@@ -129,32 +204,34 @@ export async function bootstrap() {
|
|||||||
console.log('[bootstrap] session deleter started, profile=%s', activeProfile)
|
console.log('[bootstrap] session deleter started, profile=%s', activeProfile)
|
||||||
|
|
||||||
// Catch-all: destroy upgrade requests not handled by terminal or Socket.IO
|
// Catch-all: destroy upgrade requests not handled by terminal or Socket.IO
|
||||||
server.on('upgrade', (req: any, socket: any) => {
|
servers.forEach((httpServer) => {
|
||||||
const url = new URL(req.url || '', `http://${req.headers.host}`)
|
httpServer.on('upgrade', (req: any, socket: any) => {
|
||||||
if (url.pathname !== '/api/hermes/terminal' && !url.pathname.startsWith('/socket.io/')) {
|
const url = new URL(req.url || '', `http://${req.headers.host}`)
|
||||||
socket.destroy()
|
if (url.pathname !== '/api/hermes/terminal' && !url.pathname.startsWith('/socket.io/')) {
|
||||||
}
|
socket.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
server.on('listening', () => {
|
const interfaces = safeNetworkInterfaces()
|
||||||
const interfaces = safeNetworkInterfaces()
|
const localIp = Object.values(interfaces).flat().find(i => i?.family === 'IPv4' && !i?.internal)?.address || 'localhost'
|
||||||
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(`Server: http://localhost:${config.port} (LAN: http://${localIp}:${config.port})`)
|
console.log(`Upstream: ${config.upstream}`)
|
||||||
console.log(`Upstream: ${config.upstream}`)
|
console.log(`Log: ~/.hermes-web-ui/logs/server.log`)
|
||||||
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('Server: http://localhost:%d (LAN: http://%s:%d)', config.port, localIp, config.port)
|
logger.info('Upstream: %s', config.upstream)
|
||||||
logger.info('Upstream: %s', config.upstream)
|
|
||||||
|
|
||||||
// Restore group chat agents after server is ready
|
// Restore group chat agents after server is ready.
|
||||||
groupChatServer.restoreWhenReady()
|
groupChatServer.restoreWhenReady()
|
||||||
|
|
||||||
|
servers.forEach((httpServer) => {
|
||||||
|
httpServer.on('error', (err: any) => {
|
||||||
|
console.error('[bootstrap] server error:', err.code || err.message)
|
||||||
|
logger.error({ err }, 'Server error')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
server.on('error', (err: any) => {
|
bindShutdown(servers, groupChatServer, chatRunServer)
|
||||||
console.error('[bootstrap] server error:', err.code || err.message)
|
|
||||||
logger.error({ err }, 'Server error')
|
|
||||||
})
|
|
||||||
|
|
||||||
bindShutdown(server, groupChatServer, chatRunServer)
|
|
||||||
startVersionCheck()
|
startVersionCheck()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function createSession(shell: string): PtySession {
|
|||||||
|
|
||||||
// ─── WebSocket server setup ─────────────────────────────────────
|
// ─── WebSocket server setup ─────────────────────────────────────
|
||||||
|
|
||||||
export function setupTerminalWebSocket(httpServer: HttpServer) {
|
export function setupTerminalWebSocket(httpServers: HttpServer | HttpServer[]) {
|
||||||
if (!pty) {
|
if (!pty) {
|
||||||
logger.warn('node-pty not available, skipping terminal WebSocket setup')
|
logger.warn('node-pty not available, skipping terminal WebSocket setup')
|
||||||
return
|
return
|
||||||
@@ -118,26 +118,29 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
|||||||
|
|
||||||
const wss = new WebSocketServer({ noServer: true })
|
const wss = new WebSocketServer({ noServer: true })
|
||||||
const defaultShell = findShell()
|
const defaultShell = findShell()
|
||||||
|
const servers = Array.isArray(httpServers) ? httpServers : [httpServers]
|
||||||
|
|
||||||
httpServer.on('upgrade', async (req, socket, head) => {
|
servers.forEach((httpServer) => {
|
||||||
const url = new URL(req.url || '', `http://${req.headers.host}`)
|
httpServer.on('upgrade', async (req, socket, head) => {
|
||||||
if (url.pathname !== '/api/hermes/terminal') {
|
const url = new URL(req.url || '', `http://${req.headers.host}`)
|
||||||
return
|
if (url.pathname !== '/api/hermes/terminal') {
|
||||||
}
|
|
||||||
|
|
||||||
// Auth check
|
|
||||||
const authToken = await getToken()
|
|
||||||
if (authToken) {
|
|
||||||
const token = url.searchParams.get('token') || ''
|
|
||||||
if (token !== authToken) {
|
|
||||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
|
|
||||||
socket.destroy()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
// Auth check
|
||||||
wss.emit('connection', ws, req)
|
const authToken = await getToken()
|
||||||
|
if (authToken) {
|
||||||
|
const token = url.searchParams.get('token') || ''
|
||||||
|
if (token !== authToken) {
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
|
||||||
|
socket.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
wss.emit('connection', ws, req)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,15 @@ interface ManagedGateway {
|
|||||||
process?: ChildProcess
|
process?: ChildProcess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatHostForUrl(host: string): string {
|
||||||
|
if (host.startsWith('[') && host.endsWith(']')) return host
|
||||||
|
return host.includes(':') ? `[${host}]` : host
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHttpUrl(host: string, port: number): string {
|
||||||
|
return `http://${formatHostForUrl(host)}:${port}`
|
||||||
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// GatewayManager
|
// GatewayManager
|
||||||
// ============================
|
// ============================
|
||||||
@@ -360,7 +369,7 @@ export class GatewayManager {
|
|||||||
const gw = this.gateways.get(name)
|
const gw = this.gateways.get(name)
|
||||||
if (gw?.url) return gw.url
|
if (gw?.url) return gw.url
|
||||||
const { port, host } = this.readProfilePort(name)
|
const { port, host } = this.readProfilePort(name)
|
||||||
return `http://${host}:${port}`
|
return buildHttpUrl(host, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取 profile 的 API_SERVER_KEY(从 .env 文件) */
|
/** 读取 profile 的 API_SERVER_KEY(从 .env 文件) */
|
||||||
@@ -426,7 +435,7 @@ export class GatewayManager {
|
|||||||
async detectStatus(name: string): Promise<GatewayStatus> {
|
async detectStatus(name: string): Promise<GatewayStatus> {
|
||||||
const pid = this.readPidFile(name)
|
const pid = this.readPidFile(name)
|
||||||
const { port, host } = this.readProfilePort(name)
|
const { port, host } = this.readProfilePort(name)
|
||||||
const url = `http://${host}:${port}`
|
const url = buildHttpUrl(host, port)
|
||||||
|
|
||||||
if (pid && this.isProcessAlive(pid) && await this.checkHealth(url)) {
|
if (pid && this.isProcessAlive(pid) && await this.checkHealth(url)) {
|
||||||
this.gateways.set(name, { pid, port, host, url })
|
this.gateways.set(name, { pid, port, host, url })
|
||||||
@@ -456,7 +465,7 @@ export class GatewayManager {
|
|||||||
async start(name: string): Promise<GatewayStatus> {
|
async start(name: string): Promise<GatewayStatus> {
|
||||||
const { port, host } = await this.resolvePort(name)
|
const { port, host } = await this.resolvePort(name)
|
||||||
const hermesHome = this.profileDir(name)
|
const hermesHome = this.profileDir(name)
|
||||||
const url = `http://${host}:${port}`
|
const url = buildHttpUrl(host, port)
|
||||||
|
|
||||||
if (needsRunMode) {
|
if (needsRunMode) {
|
||||||
// WSL / Docker:无 systemd/launchd,用 "gateway run" 作为 detached 子进程
|
// WSL / Docker:无 systemd/launchd,用 "gateway run" 作为 detached 子进程
|
||||||
@@ -534,7 +543,7 @@ export class GatewayManager {
|
|||||||
const gw = this.gateways.get(name)
|
const gw = this.gateways.get(name)
|
||||||
const url = gw?.url || (() => {
|
const url = gw?.url || (() => {
|
||||||
const { port, host } = this.readProfilePort(name)
|
const { port, host } = this.readProfilePort(name)
|
||||||
return `http://${host}:${port}`
|
return buildHttpUrl(host, port)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
if (!needsRunMode) {
|
if (!needsRunMode) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { Server as HttpServer } from 'http'
|
|||||||
import { getToken } from '../../../services/auth'
|
import { getToken } from '../../../services/auth'
|
||||||
import { logger } from '../../../services/logger'
|
import { logger } from '../../../services/logger'
|
||||||
import { getDb } from '../../../db'
|
import { getDb } from '../../../db'
|
||||||
import { GC_ROOMS_TABLE, GC_MESSAGES_TABLE, GC_ROOM_AGENTS_TABLE, GC_CONTEXT_SNAPSHOTS_TABLE, GC_ROOM_MEMBERS_TABLE, GC_PENDING_SESSION_DELETES_TABLE, GC_SESSION_PROFILES_TABLE } from '../../../db/hermes/schemas'
|
|
||||||
import { AgentClients } from './agent-clients'
|
import { AgentClients } from './agent-clients'
|
||||||
import { ContextEngine } from '../context-engine/compressor'
|
import { ContextEngine } from '../context-engine/compressor'
|
||||||
import { SessionDeleter } from '../session-deleter'
|
import { SessionDeleter } from '../session-deleter'
|
||||||
@@ -39,74 +38,6 @@ interface Member {
|
|||||||
socketId: string
|
socketId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── SQLite Storage (global DB) ──────────────────────────────
|
|
||||||
|
|
||||||
const GC_PENDING_SESSION_DELETES_SCHEMA: Record<string, string> = {
|
|
||||||
session_id: 'TEXT PRIMARY KEY',
|
|
||||||
profile_name: 'TEXT NOT NULL',
|
|
||||||
status: "TEXT NOT NULL DEFAULT 'pending'",
|
|
||||||
attempt_count: 'INTEGER NOT NULL DEFAULT 0',
|
|
||||||
last_error: 'TEXT',
|
|
||||||
created_at: 'INTEGER NOT NULL',
|
|
||||||
updated_at: 'INTEGER NOT NULL',
|
|
||||||
next_attempt_at: 'INTEGER NOT NULL DEFAULT 0',
|
|
||||||
}
|
|
||||||
|
|
||||||
const GC_SESSION_PROFILES_SCHEMA: Record<string, string> = {
|
|
||||||
session_id: 'TEXT PRIMARY KEY',
|
|
||||||
room_id: 'TEXT NOT NULL',
|
|
||||||
agent_id: 'TEXT NOT NULL',
|
|
||||||
profile_name: 'TEXT NOT NULL',
|
|
||||||
created_at: 'INTEGER NOT NULL',
|
|
||||||
}
|
|
||||||
|
|
||||||
const GC_ROOMS_SCHEMA: Record<string, string> = {
|
|
||||||
id: 'TEXT PRIMARY KEY',
|
|
||||||
name: 'TEXT NOT NULL',
|
|
||||||
inviteCode: 'TEXT UNIQUE',
|
|
||||||
triggerTokens: 'INTEGER NOT NULL DEFAULT 100000',
|
|
||||||
maxHistoryTokens: 'INTEGER NOT NULL DEFAULT 32000',
|
|
||||||
tailMessageCount: 'INTEGER NOT NULL DEFAULT 20',
|
|
||||||
totalTokens: 'INTEGER NOT NULL DEFAULT 0',
|
|
||||||
}
|
|
||||||
|
|
||||||
const GC_MESSAGES_SCHEMA: Record<string, string> = {
|
|
||||||
id: 'TEXT PRIMARY KEY',
|
|
||||||
roomId: 'TEXT NOT NULL',
|
|
||||||
senderId: 'TEXT NOT NULL',
|
|
||||||
senderName: 'TEXT NOT NULL',
|
|
||||||
content: 'TEXT NOT NULL',
|
|
||||||
timestamp: 'INTEGER NOT NULL',
|
|
||||||
}
|
|
||||||
|
|
||||||
const GC_ROOM_AGENTS_SCHEMA: Record<string, string> = {
|
|
||||||
id: 'TEXT PRIMARY KEY',
|
|
||||||
roomId: 'TEXT NOT NULL',
|
|
||||||
agentId: 'TEXT NOT NULL',
|
|
||||||
profile: 'TEXT NOT NULL',
|
|
||||||
name: 'TEXT NOT NULL',
|
|
||||||
description: "TEXT NOT NULL DEFAULT ''",
|
|
||||||
invited: 'INTEGER NOT NULL DEFAULT 0',
|
|
||||||
}
|
|
||||||
|
|
||||||
const GC_CONTEXT_SNAPSHOTS_SCHEMA: Record<string, string> = {
|
|
||||||
roomId: 'TEXT PRIMARY KEY',
|
|
||||||
summary: 'TEXT NOT NULL DEFAULT \'\'',
|
|
||||||
lastMessageId: 'TEXT NOT NULL',
|
|
||||||
lastMessageTimestamp: 'INTEGER NOT NULL',
|
|
||||||
updatedAt: 'INTEGER NOT NULL',
|
|
||||||
}
|
|
||||||
|
|
||||||
const GC_ROOM_MEMBERS_SCHEMA: Record<string, string> = {
|
|
||||||
id: 'TEXT PRIMARY KEY',
|
|
||||||
roomId: 'TEXT NOT NULL',
|
|
||||||
userId: 'TEXT NOT NULL',
|
|
||||||
userName: 'TEXT NOT NULL',
|
|
||||||
description: "TEXT NOT NULL DEFAULT ''",
|
|
||||||
joinedAt: 'INTEGER NOT NULL',
|
|
||||||
updatedAt: 'INTEGER NOT NULL',
|
|
||||||
}
|
|
||||||
|
|
||||||
let _tablesEnsured = false
|
let _tablesEnsured = false
|
||||||
|
|
||||||
interface PendingSessionDelete {
|
interface PendingSessionDelete {
|
||||||
@@ -487,13 +418,15 @@ export class GroupChatServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(httpServer: HttpServer) {
|
constructor(httpServers: HttpServer | HttpServer[]) {
|
||||||
this.storage = new ChatStorage()
|
this.storage = new ChatStorage()
|
||||||
this.storage.init()
|
this.storage.init()
|
||||||
|
const servers = Array.isArray(httpServers) ? httpServers : [httpServers]
|
||||||
|
|
||||||
this.io = new Server(httpServer, {
|
this.io = new Server(servers[0], {
|
||||||
cors: { origin: '*' }
|
cors: { origin: '*' }
|
||||||
})
|
})
|
||||||
|
servers.slice(1).forEach((httpServer) => this.io.attach(httpServer))
|
||||||
this.nsp = this.io.of('/group-chat')
|
this.nsp = this.io.of('/group-chat')
|
||||||
this.nsp.use(this.authMiddleware.bind(this))
|
this.nsp.use(this.authMiddleware.bind(this))
|
||||||
this.nsp.on('connection', this.onConnection.bind(this))
|
this.nsp.on('connection', this.onConnection.bind(this))
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?:
|
|||||||
if (isShuttingDown) return
|
if (isShuttingDown) return
|
||||||
isShuttingDown = true
|
isShuttingDown = true
|
||||||
|
|
||||||
|
// Force exit after 3s no matter what
|
||||||
|
setTimeout(() => process.exit(0), 3000)
|
||||||
|
|
||||||
logger.info('Shutting down (%s)...', signal)
|
logger.info('Shutting down (%s)...', signal)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -24,13 +27,16 @@ export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?:
|
|||||||
logger.info('Socket.IO closed')
|
logger.info('Socket.IO closed')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (server) {
|
const servers = Array.isArray(server) ? server : [server].filter(Boolean)
|
||||||
await new Promise<void>((resolve) => {
|
if (servers.length) {
|
||||||
server.close(() => {
|
await Promise.all(servers.map((httpServer) => (
|
||||||
logger.info('HTTP server closed')
|
new Promise<void>((resolve) => {
|
||||||
resolve()
|
httpServer.close(() => {
|
||||||
|
logger.info('HTTP server closed')
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
)))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(err, 'Shutdown error')
|
logger.error(err, 'Shutdown error')
|
||||||
|
|||||||
Reference in New Issue
Block a user