feat(cli): support Unix domain socket listener

This commit is contained in:
KokerZhou authored and digua committed 2026-08-28 10:21:39 +08:00
1 parent b7c38fb228
commit e3c2e8ec58
13 files changed
+149 -25

No files matched your search

+30 -14
View File
@@ -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 () => {
+20 -6
View File
@@ -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 }
}
/**
+33
View File
@@ -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 })
}
})
+33
View File
@@ -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}`))
})
})
}
+1 -1
View File
@@ -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()