diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 6f6755be..de24c0ea 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -119,6 +119,7 @@ program .description('Start ChatLab (HTTP API + Web UI)') .option('--port ', 'Server port', String(DEFAULT_API_PORT)) .option('--host ', 'Listen address', '127.0.0.1') + .option('--socket ', 'Listen on a Unix domain socket instead of a TCP port') .option('--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 () => { diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index 2f1d8a52..425246ce 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -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 } } /** diff --git a/apps/cli/src/http/port.test.ts b/apps/cli/src/http/port.test.ts new file mode 100644 index 00000000..8dc01c4c --- /dev/null +++ b/apps/cli/src/http/port.test.ts @@ -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((resolve) => server.listen(socketPath, resolve)) + + try { + await assert.rejects(() => prepareUnixSocket(socketPath), /already in use/) + } finally { + await new Promise((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 }) + } +}) diff --git a/apps/cli/src/http/port.ts b/apps/cli/src/http/port.ts index 06005877..747ab692 100644 --- a/apps/cli/src/http/port.ts +++ b/apps/cli/src/http/port.ts @@ -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 { + 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((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}`)) + }) + }) +} diff --git a/apps/cli/src/sync/index.ts b/apps/cli/src/sync/index.ts index 806f8e6b..e4026e67 100644 --- a/apps/cli/src/sync/index.ts +++ b/apps/cli/src/sync/index.ts @@ -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() diff --git a/docs/cn/usage/installation.md b/docs/cn/usage/installation.md index 8dc1b4f8..1d47bb24 100644 --- a/docs/cn/usage/installation.md +++ b/docs/cn/usage/installation.md @@ -28,7 +28,7 @@ clb web --no-open # 启动 API + Web UI,但不自动打开浏览器 clb web --headless # 仅启动 API,不提供 Web UI(供脚本 / AI Agent 调用) ``` -常用选项:`--port <端口>`(默认 `3110`)、`--host <地址>`、`--token <令牌>`。 +常用选项:`--port <端口>`(默认 `3110`)、`--host <地址>`、`--token <令牌>`。在 macOS 和 Linux 上,可以使用 `--socket <路径>` 监听 Unix 域套接字而不占用 TCP 端口,例如 `clb web --socket /tmp/chatlab.sock --no-open`。可通过 `curl --unix-socket /tmp/chatlab.sock http://localhost/api/v1/status` 访问,或在其前面配置反向代理。 如果希望服务常驻后台,可以使用: diff --git a/docs/en/usage/installation.md b/docs/en/usage/installation.md index 1c1e75c9..02d007de 100644 --- a/docs/en/usage/installation.md +++ b/docs/en/usage/installation.md @@ -28,7 +28,7 @@ clb web --no-open # Start API + Web UI without opening a browser clb web --headless # Start the API without serving the Web UI (for scripts / AI Agents) ``` -Common options: `--port ` (default `3110`), `--host
`, and `--token `. +Common options: `--port ` (default `3110`), `--host
`, and `--token `. On macOS and Linux, use `--socket ` to listen on a Unix domain socket instead of a TCP port, for example `clb web --socket /tmp/chatlab.sock --no-open`. Access it with `curl --unix-socket /tmp/chatlab.sock http://localhost/api/v1/status` or place a reverse proxy in front of it. To keep ChatLab running as a background service: diff --git a/docs/tw/usage/installation.md b/docs/tw/usage/installation.md index 6507693b..13d73eb5 100644 --- a/docs/tw/usage/installation.md +++ b/docs/tw/usage/installation.md @@ -28,7 +28,7 @@ clb web --no-open # 啟動 API + Web UI,但不自動開啟瀏覽器 clb web --headless # 僅啟動 API,不提供 Web UI(供腳本 / AI Agent 呼叫) ``` -常用選項:`--port <連接埠>`(預設 `3110`)、`--host <位址>`、`--token <令牌>`。 +常用選項:`--port <連接埠>`(預設 `3110`)、`--host <位址>`、`--token <令牌>`。在 macOS 和 Linux 上,可以使用 `--socket <路徑>` 監聽 Unix 網域通訊端而不占用 TCP 連接埠,例如 `clb web --socket /tmp/chatlab.sock --no-open`。可透過 `curl --unix-socket /tmp/chatlab.sock http://localhost/api/v1/status` 存取,或在其前方設定反向代理。 若要讓服務常駐後台,可以使用: diff --git a/packages/http-routes/src/context/automation.ts b/packages/http-routes/src/context/automation.ts index 696c601e..87db7d78 100644 --- a/packages/http-routes/src/context/automation.ts +++ b/packages/http-routes/src/context/automation.ts @@ -34,7 +34,7 @@ export interface AutomationRouteContext { triggerPullAll: (sourceId: string) => Promise getProgress: () => unknown[] } - serverInfo?: { port: number; host: string; token: string } + serverInfo?: { port: number; host: string; socket?: string; token: string } deleteSessionData?: (sessionId: string) => void reloadTimer?: (sourceId: string, immediate?: boolean) => void stopTimer?: (sourceId: string) => void diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts index 9a63ec9c..f3b8e2d9 100644 --- a/packages/http-routes/src/routes/shared-routes.test.ts +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -580,6 +580,31 @@ describe('registerSharedRoutes smoke tests', () => { } }) + it('GET /_web/automation/config reports a Unix socket listener', async () => { + const ctx = createTestContext() + ctx.automation = { + serverInfo: { port: 3110, host: '127.0.0.1', socket: '/tmp/chatlab.sock', token: 'api-token' }, + dsManager: {} as NonNullable['dsManager'], + pullEngine: {} as NonNullable['pullEngine'], + } + const routeApp = Fastify() + registerAutomationRoutes(routeApp, ctx) + + try { + const resp = await routeApp.inject({ method: 'GET', url: '/_web/automation/config' }) + assert.equal(resp.statusCode, 200) + assert.deepEqual(resp.json(), { + enabled: true, + port: 3110, + host: '127.0.0.1', + socket: '/tmp/chatlab.sock', + token: 'api-token', + }) + } finally { + await routeApp.close() + } + }) + it('DELETE /_web/automation/data-sources/:id/sessions/:sessId deletes imported local session when deleteData=true', async () => { const ds = { id: 'source-1', diff --git a/packages/http-routes/src/routes/web/automation.ts b/packages/http-routes/src/routes/web/automation.ts index ad96ace5..e2080860 100644 --- a/packages/http-routes/src/routes/web/automation.ts +++ b/packages/http-routes/src/routes/web/automation.ts @@ -70,6 +70,7 @@ export function registerAutomationRoutes(server: FastifyInstance, ctx: Automatio port: automation.serverInfo.port, token: automation.serverInfo.token, host: automation.serverInfo.host, + socket: automation.serverInfo.socket, } }) diff --git a/src/components/common/Settings/ApiSettingsTab.vue b/src/components/common/Settings/ApiSettingsTab.vue index 37c3f490..2b609c3a 100644 --- a/src/components/common/Settings/ApiSettingsTab.vue +++ b/src/components/common/Settings/ApiSettingsTab.vue @@ -94,6 +94,7 @@ const statusColor = computed(() => { }) const apiBaseUrl = computed(() => { + if (config.value.socket) return `unix://${config.value.socket}` const port = status.value.port || config.value.port return `http://127.0.0.1:${port}/api/v1` }) diff --git a/src/stores/apiServer.ts b/src/stores/apiServer.ts index e56b42cb..db141f4a 100644 --- a/src/stores/apiServer.ts +++ b/src/stores/apiServer.ts @@ -18,6 +18,7 @@ import { createSyncResultPoller } from './syncResultPolling' export interface ApiServerConfig { enabled: boolean port: number + socket?: string token: string createdAt: number }