mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(cli): support Unix domain socket listener
This commit is contained in:
1 parent
b7c38fb228
commit
e3c2e8ec58
13 files changed
+149
-25
No files matched your search
+30
-14
@@ -119,6 +119,7 @@ program
|
||||
.description('Start ChatLab (HTTP API + Web UI)')
|
||||
.option('--port <port>', 'Server port', String(DEFAULT_API_PORT))
|
||||
.option('--host <host>', 'Listen address', '127.0.0.1')
|
||||
.option('--socket <path>', 'Listen on a Unix domain socket instead of a TCP port')
|
||||
.option('--token <token>', 'Custom Bearer Token (reads from config or auto-generates if omitted)')
|
||||
.option('--headless', 'API-only mode, do not serve the Web UI')
|
||||
.option('--require-auth', 'Require Bearer token for all routes including /_web/*')
|
||||
@@ -127,6 +128,10 @@ program
|
||||
.action(async (options) => {
|
||||
// --daemon: install as system service and exit
|
||||
if (options.daemon) {
|
||||
if (options.socket) {
|
||||
console.error('Unix domain sockets are not yet supported with --daemon.')
|
||||
process.exit(1)
|
||||
}
|
||||
const { serviceInstall } = await import('./daemon/service')
|
||||
serviceInstall({
|
||||
port: parseInt(options.port, 10),
|
||||
@@ -155,7 +160,7 @@ program
|
||||
// 启动前预检端口,快速失败,避免无谓的初始化后再报 EADDRINUSE;
|
||||
// 置于 try 内确保非 EADDRINUSE 错误(EACCES/EADDRNOTAVAIL 等)
|
||||
// 也能走到统一的 Startup failed 错误处理路径。
|
||||
if (!(await isPortAvailable(port, options.host))) {
|
||||
if (!options.socket && !(await isPortAvailable(port, options.host))) {
|
||||
console.error(formatPortInUseError(port))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -163,6 +168,7 @@ program
|
||||
const info = await startHttpServer({
|
||||
port,
|
||||
host: options.host,
|
||||
socket: options.socket,
|
||||
token: options.token || undefined,
|
||||
webRoot,
|
||||
requireAuth: options.requireAuth || undefined,
|
||||
@@ -171,21 +177,31 @@ program
|
||||
const { startPeriodicUpdateCheck } = await import('./update-checker')
|
||||
startPeriodicUpdateCheck()
|
||||
|
||||
const displayHost = info.host === '0.0.0.0' ? '127.0.0.1' : info.host
|
||||
const url = `http://${displayHost}:${info.port}`
|
||||
|
||||
console.log(`\nChatLab v${getVersion()}`)
|
||||
if (webRoot) console.log(` Web UI: ${url}/`)
|
||||
console.log(` API: ${url}`)
|
||||
console.log(` Token: ${info.token}`)
|
||||
console.log(`\nExample:`)
|
||||
console.log(` curl -H "Authorization: Bearer ${info.token}" ${url}/api/v1/status`)
|
||||
|
||||
if (webRoot && options.open) {
|
||||
openBrowser(url)
|
||||
console.log(`\nBrowser opened. Press Ctrl+C to stop.\n`)
|
||||
} else {
|
||||
if (info.socket) {
|
||||
console.log(` Socket: ${info.socket}`)
|
||||
console.log(` Token: ${info.token}`)
|
||||
console.log(`\nExample:`)
|
||||
console.log(
|
||||
` curl --unix-socket "${info.socket}" -H "Authorization: Bearer ${info.token}" http://localhost/api/v1/status`
|
||||
)
|
||||
if (webRoot) console.log(`\nUse a reverse proxy to access the Web UI through a browser.`)
|
||||
console.log(`\nPress Ctrl+C to stop.\n`)
|
||||
} else {
|
||||
const displayHost = info.host === '0.0.0.0' ? '127.0.0.1' : info.host
|
||||
const url = `http://${displayHost}:${info.port}`
|
||||
if (webRoot) console.log(` Web UI: ${url}/`)
|
||||
console.log(` API: ${url}`)
|
||||
console.log(` Token: ${info.token}`)
|
||||
console.log(`\nExample:`)
|
||||
console.log(` curl -H "Authorization: Bearer ${info.token}" ${url}/api/v1/status`)
|
||||
|
||||
if (webRoot && options.open) {
|
||||
openBrowser(url)
|
||||
console.log(`\nBrowser opened. Press Ctrl+C to stop.\n`)
|
||||
} else {
|
||||
console.log(`\nPress Ctrl+C to stop.\n`)
|
||||
}
|
||||
}
|
||||
|
||||
const shutdown = async () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { initSync, cleanupSync } from '../sync'
|
||||
import { resolveCliPath } from '../paths'
|
||||
import { assertCliDataDirCompatible } from '../runtime-compat'
|
||||
import { resolveCliLocalEmbeddingRuntimeConfig } from '../semantic-index/local-runtime'
|
||||
import { prepareUnixSocket } from './port'
|
||||
|
||||
let server: FastifyInstance | null = null
|
||||
let dbManager: DatabaseManager | null = null
|
||||
@@ -50,6 +51,8 @@ let aiMemoryService: AIMemoryService | null = null
|
||||
export interface HttpServerOptions {
|
||||
port?: number
|
||||
host?: string
|
||||
/** Unix domain socket path. When set, takes precedence over port and host. */
|
||||
socket?: string
|
||||
token?: string
|
||||
/** dist-cli-web/ 目录路径,启用后托管 CLI Web SPA 静态资源 */
|
||||
webRoot?: string
|
||||
@@ -80,8 +83,9 @@ function ensureToken(config: ChatLabConfig): string {
|
||||
* 启动独立 HTTP API 服务
|
||||
*/
|
||||
export async function startHttpServer(options?: HttpServerOptions): Promise<{
|
||||
port: number
|
||||
host: string
|
||||
port?: number
|
||||
host?: string
|
||||
socket?: string
|
||||
token: string
|
||||
}> {
|
||||
if (server) {
|
||||
@@ -91,8 +95,11 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
|
||||
let config = loadConfig()
|
||||
const port = options?.port ?? config.api.port
|
||||
const host = options?.host ?? config.api.host
|
||||
const socket = options?.socket
|
||||
const token = options?.token ?? ensureToken(config)
|
||||
|
||||
if (socket) await prepareUnixSocket(socket)
|
||||
|
||||
const pendingMigration = applyPendingNodeDataDirMigrationIfNeeded()
|
||||
if (!pendingMigration.skipped) {
|
||||
if (pendingMigration.success) {
|
||||
@@ -178,7 +185,10 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
|
||||
initServerAiLogger(pathProvider.getLogsDir())
|
||||
appLogger.info('ai-config', 'Shared LLM configuration store initialized', { aiDataDir })
|
||||
appLogger.info('temp-workspace', 'Temporary workspace initialized', { root: pathProvider.getTempDir() })
|
||||
appLogger.info('server', `HTTP server starting on ${host}:${port}`)
|
||||
appLogger.info(
|
||||
'server',
|
||||
socket ? `HTTP server starting on Unix socket ${socket}` : `HTTP server starting on ${host}:${port}`
|
||||
)
|
||||
// 记录 Rust native parser 可用性(导入是否走 Rust 内核),便于按日志排查回退原因
|
||||
logNativeParserStatus()
|
||||
|
||||
@@ -237,7 +247,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
|
||||
},
|
||||
})
|
||||
|
||||
initSync(server, dbManager, pathProvider, { port, host, token })
|
||||
initSync(server, dbManager, pathProvider, { port, host, socket, token })
|
||||
|
||||
if (options?.webRoot && fs.existsSync(options.webRoot)) {
|
||||
// 注册反向代理:将 /_proxy/chatlab.fun/* 转发至 https://chatlab.fun,
|
||||
@@ -256,9 +266,13 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
|
||||
})
|
||||
}
|
||||
|
||||
await server.listen({ port, host })
|
||||
if (socket) {
|
||||
await server.listen({ path: socket })
|
||||
} else {
|
||||
await server.listen({ port, host })
|
||||
}
|
||||
|
||||
return { port, host, token }
|
||||
return socket ? { socket, token } : { port, host, token }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { prepareUnixSocket } from './port'
|
||||
|
||||
test('prepareUnixSocket leaves a live listener intact', { skip: process.platform === 'win32' }, async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), 'chatlab-socket-'))
|
||||
const socketPath = path.join(dir, 'live.sock')
|
||||
const server = net.createServer()
|
||||
await new Promise<void>((resolve) => server.listen(socketPath, resolve))
|
||||
|
||||
try {
|
||||
await assert.rejects(() => prepareUnixSocket(socketPath), /already in use/)
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())))
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('prepareUnixSocket never removes a non-socket entry', async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), 'chatlab-socket-'))
|
||||
const socketPath = path.join(dir, 'chatlab.sock')
|
||||
await writeFile(socketPath, 'keep me')
|
||||
|
||||
try {
|
||||
await assert.rejects(() => prepareUnixSocket(socketPath), /is not a socket/)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
* 端口可用性检测与错误文案
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import * as net from 'net'
|
||||
|
||||
/**
|
||||
@@ -37,3 +38,35 @@ export function formatPortInUseError(port: number): string {
|
||||
``,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Remove a stale Unix socket without replacing a live listener or a non-socket entry. */
|
||||
export async function prepareUnixSocket(socketPath: string): Promise<void> {
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.lstat(socketPath)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!stat.isSocket()) {
|
||||
throw new Error(`Socket path already exists and is not a socket: ${socketPath}`)
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const client = net.createConnection(socketPath)
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
if (err.code !== 'ECONNREFUSED') {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
fs.unlink(socketPath).then(resolve, reject)
|
||||
}
|
||||
client.once('error', onError)
|
||||
client.once('connect', () => {
|
||||
client.removeListener('error', onError)
|
||||
client.destroy()
|
||||
reject(new Error(`Unix socket is already in use: ${socketPath}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function initSync(
|
||||
server: FastifyInstance,
|
||||
dbManager: DatabaseManager,
|
||||
pathProvider: PathProvider,
|
||||
serverInfo: { port: number; host: string; token: string }
|
||||
serverInfo: { port: number; host: string; socket?: string; token: string }
|
||||
): void {
|
||||
const settingsDir = pathProvider.getSettingsDir()
|
||||
|
||||
|
||||
Reference in new issue
Block a user