feat(server): extract standalone HTTP API from Electron

Create packages/server/src/http/ with platform-agnostic HTTP API:
- errors.ts: error codes and response helpers
- auth.ts: Bearer token auth using @openchatlab/config
- server.ts: Fastify factory with auth hook and error handler
- routes/system.ts: status and schema endpoints
- routes/sessions.ts: 7 session endpoints using DatabaseManager + core
- index.ts: server lifecycle (startHttpServer/stopHttpServer)

Add token field to @openchatlab/config apiConfigSchema.
Add fastify dependency to packages/server.
This commit is contained in:
digua committed 2026-05-11 20:28:26 +08:00
1 parent b66ac5d287
commit 9afabedca8
10 files changed
+623 -3

No files matched your search

+1
View File
@@ -20,6 +20,7 @@ export const dataConfigSchema = z.object({
export const apiConfigSchema = z.object({
port: z.number().int().min(1).max(65535).default(3210),
host: z.string().default('127.0.0.1'),
token: z.string().default(''),
})
export const localeConfigSchema = z.object({
+4 -3
View File
@@ -14,13 +14,14 @@
"rebuild-sqlite": "npm rebuild better-sqlite3"
},
"dependencies": {
"@openchatlab/core": "workspace:*",
"@modelcontextprotocol/sdk": "^1.12.1",
"@openchatlab/config": "workspace:*",
"@openchatlab/core": "workspace:*",
"@openchatlab/node-runtime": "workspace:*",
"@openchatlab/shared-types": "workspace:*",
"@modelcontextprotocol/sdk": "^1.12.1",
"@openchatlab/tools": "workspace:*",
"commander": "^13.1.0"
"commander": "^13.1.0",
"fastify": "^5.8.4"
},
"devDependencies": {
"tsx": "^4.21.0"
+44
View File
@@ -0,0 +1,44 @@
/**
* ChatLab HTTP API — Bearer Token authentication hook
*
* 从 electron/main/api/auth.ts 迁移,使用 @openchatlab/config 读取 token。
*/
import type { FastifyRequest, FastifyReply } from 'fastify'
import { timingSafeEqual } from 'crypto'
import { unauthorized, errorResponse } from './errors'
let cachedToken: string | null = null
/**
* 设置 auth hook 使用的 token(由 server 启动时注入)
*/
export function setAuthToken(token: string): void {
cachedToken = token
}
function safeTokenCompare(a: string, b: string): boolean {
const bufA = Buffer.from(a)
const bufB = Buffer.from(b)
if (bufA.length !== bufB.length) return false
return timingSafeEqual(bufA, bufB)
}
export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
if (!cachedToken) return
const authHeader = request.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
const err = unauthorized()
reply.code(err.statusCode).send(errorResponse(err))
return
}
const token = authHeader.slice(7)
if (!safeTokenCompare(token, cachedToken)) {
const err = unauthorized()
reply.code(err.statusCode).send(errorResponse(err))
return
}
}
+94
View File
@@ -0,0 +1,94 @@
/**
* ChatLab HTTP API — Error codes and factory functions
*
* 从 electron/main/api/errors.ts 迁移,完全平台无关。
*/
export enum ApiErrorCode {
UNAUTHORIZED = 'UNAUTHORIZED',
SESSION_NOT_FOUND = 'SESSION_NOT_FOUND',
INVALID_FORMAT = 'INVALID_FORMAT',
INVALID_PAYLOAD = 'INVALID_PAYLOAD',
SQL_READONLY_VIOLATION = 'SQL_READONLY_VIOLATION',
SQL_EXECUTION_ERROR = 'SQL_EXECUTION_ERROR',
EXPORT_TOO_LARGE = 'EXPORT_TOO_LARGE',
BODY_TOO_LARGE = 'BODY_TOO_LARGE',
SERVER_ERROR = 'SERVER_ERROR',
}
const HTTP_STATUS: Record<ApiErrorCode, number> = {
[ApiErrorCode.UNAUTHORIZED]: 401,
[ApiErrorCode.SESSION_NOT_FOUND]: 404,
[ApiErrorCode.INVALID_FORMAT]: 400,
[ApiErrorCode.INVALID_PAYLOAD]: 400,
[ApiErrorCode.SQL_READONLY_VIOLATION]: 400,
[ApiErrorCode.SQL_EXECUTION_ERROR]: 400,
[ApiErrorCode.EXPORT_TOO_LARGE]: 400,
[ApiErrorCode.BODY_TOO_LARGE]: 413,
[ApiErrorCode.SERVER_ERROR]: 500,
}
export class ApiError extends Error {
code: ApiErrorCode
statusCode: number
constructor(code: ApiErrorCode, message: string) {
super(message)
this.name = 'ApiError'
this.code = code
this.statusCode = HTTP_STATUS[code]
}
}
export function unauthorized(message = 'Invalid or missing token'): ApiError {
return new ApiError(ApiErrorCode.UNAUTHORIZED, message)
}
export function sessionNotFound(id: string): ApiError {
return new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `Session not found: ${id}`)
}
export function invalidPayload(message: string): ApiError {
return new ApiError(ApiErrorCode.INVALID_PAYLOAD, message)
}
export function sqlReadonlyViolation(): ApiError {
return new ApiError(ApiErrorCode.SQL_READONLY_VIOLATION, 'Only SELECT queries are allowed')
}
export function sqlExecutionError(message: string): ApiError {
return new ApiError(ApiErrorCode.SQL_EXECUTION_ERROR, message)
}
export function exportTooLarge(count: number, limit: number): ApiError {
return new ApiError(
ApiErrorCode.EXPORT_TOO_LARGE,
`Message count ${count} exceeds export limit ${limit}. Use paginated /messages API instead.`
)
}
export function serverError(message = 'Internal server error'): ApiError {
return new ApiError(ApiErrorCode.SERVER_ERROR, message)
}
export function successResponse<T>(data: T, meta?: Record<string, unknown>) {
return {
success: true as const,
data,
meta: {
timestamp: Math.floor(Date.now() / 1000),
version: '0.0.2',
...meta,
},
}
}
export function errorResponse(error: ApiError) {
return {
success: false as const,
error: {
code: error.code,
message: error.message,
},
}
}
+113
View File
@@ -0,0 +1,113 @@
/**
* ChatLab HTTP API — Server lifecycle manager
*
* 独立于 Electron 的 HTTP API 服务入口。
* 使用 DatabaseManager + @openchatlab/core 直接访问数据。
*/
import * as fs from 'fs'
import * as path from 'path'
import * as crypto from 'crypto'
import type { FastifyInstance } from 'fastify'
import { loadConfig, getConfigDir } from '@openchatlab/config'
import type { ChatLabConfig } from '@openchatlab/config'
import { NodePathProvider, DatabaseManager } from '@openchatlab/node-runtime'
import { createServer } from './server'
import { setAuthToken } from './auth'
import { registerSystemRoutes } from './routes/system'
import { registerSessionRoutes } from './routes/sessions'
let server: FastifyInstance | null = null
let dbManager: DatabaseManager | null = null
export interface HttpServerOptions {
port?: number
host?: string
token?: string
}
function resolveNativeBinding(): string | undefined {
if (process.versions.electron) return undefined
const nativePath = path.resolve(__dirname, '../../native/better_sqlite3.node')
if (fs.existsSync(nativePath)) return nativePath
return undefined
}
function ensureToken(config: ChatLabConfig): string {
if (config.api.token) return config.api.token
const token = `clb_${crypto.randomBytes(32).toString('hex')}`
const configDir = getConfigDir()
const configPath = path.join(configDir, 'config.toml')
try {
let content = ''
if (fs.existsSync(configPath)) {
content = fs.readFileSync(configPath, 'utf-8')
}
if (!content.includes('[api]')) {
content += `\n[api]\ntoken = "${token}"\n`
} else {
content = content.replace(/\[api\]/, `[api]\ntoken = "${token}"`)
}
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(configPath, content, 'utf-8')
} catch {
// 写入失败时仍使用生成的 token 运行本次会话
}
return token
}
/**
* 启动独立 HTTP API 服务
*/
export async function startHttpServer(options?: HttpServerOptions): Promise<{
port: number
host: string
token: string
}> {
if (server) {
throw new Error('HTTP server is already running')
}
const config = loadConfig()
const port = options?.port ?? config.api.port
const host = options?.host ?? config.api.host
const token = options?.token ?? ensureToken(config)
const dataDir = config.data.dir || undefined
const pathProvider = new NodePathProvider(dataDir)
pathProvider.ensureAllDirs()
const nativeBinding = resolveNativeBinding()
dbManager = new DatabaseManager(pathProvider, { nativeBinding })
setAuthToken(token)
server = createServer()
registerSystemRoutes(server, dbManager)
registerSessionRoutes(server, dbManager)
await server.listen({ port, host })
return { port, host, token }
}
/**
* 停止 HTTP API 服务
*/
export async function stopHttpServer(): Promise<void> {
if (!server) return
try {
await server.close()
} finally {
if (dbManager) {
dbManager.closeAll()
dbManager = null
}
server = null
}
}
export { createServer } from './server'
export { registerSystemRoutes } from './routes/system'
export { registerSessionRoutes } from './routes/sessions'
+232
View File
@@ -0,0 +1,232 @@
/**
* ChatLab HTTP API — Session routes
*
* 从 electron/main/api/routes/sessions.ts 迁移。
* 使用 DatabaseManager + @openchatlab/core 替代 workerManager。
*/
import type { FastifyInstance } from 'fastify'
import type { DatabaseManager } from '@openchatlab/node-runtime'
import {
getSessionMeta,
getSessionOverview,
queryMessages,
getMembers,
getMembersDetailed,
getMemberActivity,
getMessageTypeStats,
executeReadonlySql,
} from '@openchatlab/core'
import {
successResponse,
errorResponse,
sessionNotFound,
exportTooLarge,
sqlExecutionError,
ApiError,
} from '../errors'
const EXPORT_MESSAGE_LIMIT = 100_000
function ensureDb(dbManager: DatabaseManager, sessionId: string) {
const db = dbManager.open(sessionId)
if (!db) throw sessionNotFound(sessionId)
return db
}
export function registerSessionRoutes(server: FastifyInstance, dbManager: DatabaseManager): void {
// GET /api/v1/sessions — List all sessions
server.get('/api/v1/sessions', async () => {
const sessionIds = dbManager.listSessionIds()
const sessions = sessionIds
.map((id) => {
const db = dbManager.open(id)
if (!db) return null
const meta = getSessionMeta(db)
if (!meta) return null
const overview = getSessionOverview(db)
return {
id,
name: meta.name,
platform: meta.platform,
type: meta.type,
groupId: meta.groupId || undefined,
messageCount: overview.totalMessages,
memberCount: overview.totalMembers,
firstTimestamp: overview.firstMessageTs,
lastTimestamp: overview.lastMessageTs,
}
})
.filter(Boolean)
return successResponse(sessions)
})
// GET /api/v1/sessions/:id — Single session detail
server.get<{ Params: { id: string } }>('/api/v1/sessions/:id', async (request) => {
const db = ensureDb(dbManager, request.params.id)
const meta = getSessionMeta(db)
if (!meta) throw sessionNotFound(request.params.id)
const overview = getSessionOverview(db)
return successResponse({
id: request.params.id,
name: meta.name,
platform: meta.platform,
type: meta.type,
groupId: meta.groupId || undefined,
messageCount: overview.totalMessages,
memberCount: overview.totalMembers,
firstTimestamp: overview.firstMessageTs,
lastTimestamp: overview.lastMessageTs,
})
})
// GET /api/v1/sessions/:id/messages — Query messages (paginated)
server.get<{
Params: { id: string }
Querystring: {
page?: string
limit?: string
startTime?: string
endTime?: string
keyword?: string
senderId?: string
}
}>('/api/v1/sessions/:id/messages', async (request) => {
const { id } = request.params
const db = ensureDb(dbManager, id)
const page = Math.max(1, parseInt(request.query.page || '1', 10) || 1)
const limit = Math.min(1000, Math.max(1, parseInt(request.query.limit || '100', 10) || 100))
const offset = (page - 1) * limit
const { startTime, endTime, keyword, senderId } = request.query
const result = queryMessages(db, {
keyword: keyword || undefined,
startTs: startTime ? parseInt(startTime, 10) : undefined,
endTs: endTime ? parseInt(endTime, 10) : undefined,
senderId: senderId ? parseInt(senderId, 10) : undefined,
limit,
offset,
})
return successResponse({
messages: result.messages,
total: result.total,
page,
limit,
totalPages: result.totalPages,
})
})
// GET /api/v1/sessions/:id/members — Member list
server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/members', async (request) => {
const db = ensureDb(dbManager, request.params.id)
const members = getMembers(db)
return successResponse(members)
})
// GET /api/v1/sessions/:id/stats/overview — Overview statistics
server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/stats/overview', async (request) => {
const { id } = request.params
const db = ensureDb(dbManager, id)
const overview = getSessionOverview(db)
const memberActivity = getMemberActivity(db)
const typeDistribution = getMessageTypeStats(db)
const typeMap: Record<string, number> = {}
for (const item of typeDistribution) {
typeMap[String(item.type)] = item.count
}
const topMembers = memberActivity.slice(0, 10).map((m) => ({
platformId: m.platformId,
name: m.name,
messageCount: m.messageCount,
percentage: m.percentage,
}))
return successResponse({
messageCount: overview.totalMessages,
memberCount: overview.totalMembers,
timeRange: {
start: overview.firstMessageTs ?? 0,
end: overview.lastMessageTs ?? 0,
},
messageTypeDistribution: typeMap,
topMembers,
})
})
// POST /api/v1/sessions/:id/sql — Execute SQL (read-only)
server.post<{ Params: { id: string }; Body: { sql: string } }>('/api/v1/sessions/:id/sql', async (request, reply) => {
const { id } = request.params
const db = ensureDb(dbManager, id)
const { sql } = request.body || {}
if (!sql || typeof sql !== 'string') {
const err = sqlExecutionError('Missing sql parameter')
return reply.code(err.statusCode).send(errorResponse(err))
}
try {
const result = executeReadonlySql(db, sql)
return successResponse(result)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'SQL execution error'
if (message.includes('SELECT') || message.includes('只读') || message.includes('readonly')) {
const apiErr = new ApiError('SQL_READONLY_VIOLATION' as ApiError['code'], message)
apiErr.statusCode = 400
return reply.code(400).send(errorResponse(apiErr))
}
const apiErr = sqlExecutionError(message)
return reply.code(apiErr.statusCode).send(errorResponse(apiErr))
}
})
// GET /api/v1/sessions/:id/export — Export ChatLab Format JSON
server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/export', async (request, reply) => {
const { id } = request.params
const db = ensureDb(dbManager, id)
const meta = getSessionMeta(db)
if (!meta) throw sessionNotFound(id)
const overview = getSessionOverview(db)
if (overview.totalMessages > EXPORT_MESSAGE_LIMIT) {
const err = exportTooLarge(overview.totalMessages, EXPORT_MESSAGE_LIMIT)
return reply.code(err.statusCode).send(errorResponse(err))
}
const members = getMembersDetailed(db)
const allMessages = queryMessages(db, { limit: EXPORT_MESSAGE_LIMIT, offset: 0 })
const chatLabFormat = {
chatlab: {
version: '0.0.2',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'ChatLab API',
},
meta: {
name: meta.name,
platform: meta.platform,
type: meta.type,
groupId: meta.groupId || undefined,
},
members: members.map((m) => ({
platformId: m.platformId,
accountName: m.accountName || m.platformId,
groupNickname: m.groupNickname || undefined,
})),
messages: allMessages.messages.map((msg) => ({
sender: msg.senderPlatformId,
accountName: msg.senderName || undefined,
timestamp: msg.timestamp,
type: msg.type,
content: msg.content || null,
})),
}
return successResponse(chatLabFormat)
})
}
+92
View File
@@ -0,0 +1,92 @@
/**
* ChatLab HTTP API — System routes
*
* GET /api/v1/status Service status
* GET /api/v1/schema ChatLab Format JSON Schema
*/
import type { FastifyInstance } from 'fastify'
import type { DatabaseManager } from '@openchatlab/node-runtime'
import { successResponse } from '../errors'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require('../../../package.json') as { version: string }
export function registerSystemRoutes(server: FastifyInstance, dbManager: DatabaseManager): void {
server.get('/api/v1/status', async () => {
let sessionCount = 0
try {
sessionCount = dbManager.listSessionIds().length
} catch {
// ignore
}
return successResponse({
name: 'ChatLab API',
version: pkg.version,
uptime: Math.floor(process.uptime()),
sessionCount,
})
})
server.get('/api/v1/schema', async () => {
return successResponse({
format: 'ChatLab Format',
version: '0.0.2',
spec: {
chatlab: {
type: 'object',
required: ['version'],
properties: {
version: { type: 'string' },
exportedAt: { type: 'number' },
generator: { type: 'string' },
},
},
meta: {
type: 'object',
required: ['name', 'platform', 'type'],
properties: {
name: { type: 'string' },
platform: {
type: 'string',
enum: ['qq', 'wechat', 'telegram', 'discord', 'line', 'whatsapp', 'instagram', 'unknown'],
},
type: { type: 'string', enum: ['group', 'private'] },
groupId: { type: 'string' },
},
},
members: {
type: 'array',
items: {
type: 'object',
required: ['platformId', 'accountName'],
properties: {
platformId: { type: 'string' },
accountName: { type: 'string' },
groupNickname: { type: 'string' },
avatar: { type: 'string' },
},
},
},
messages: {
type: 'array',
items: {
type: 'object',
required: ['sender', 'timestamp', 'type'],
properties: {
platformMessageId: { type: 'string' },
sender: { type: 'string' },
accountName: { type: 'string' },
groupNickname: { type: 'string' },
timestamp: { type: 'number' },
type: { type: 'number' },
content: { type: ['string', 'null'] },
replyToMessageId: { type: 'string' },
},
},
},
},
})
})
}
+38
View File
@@ -0,0 +1,38 @@
/**
* ChatLab HTTP API — Fastify server factory
*
* 从 electron/main/api/server.ts 迁移,完全平台无关。
*/
import Fastify, { type FastifyInstance, type FastifyError } from 'fastify'
import { authHook } from './auth'
import { ApiError, ApiErrorCode, errorResponse, serverError } from './errors'
const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB
export function createServer(): FastifyInstance {
const server = Fastify({
logger: false,
bodyLimit: JSON_BODY_LIMIT,
})
server.addHook('onRequest', authHook)
server.setErrorHandler((error: FastifyError, _request, reply) => {
if (error instanceof ApiError) {
reply.code(error.statusCode).send(errorResponse(error))
return
}
if (error.statusCode === 413) {
const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit')
reply.code(413).send(errorResponse(bodyErr))
return
}
const err = serverError(error.message)
reply.code(err.statusCode).send(errorResponse(err))
})
return server
}
+1
View File
@@ -6,3 +6,4 @@
*/
export { run } from './cli'
export { startHttpServer, stopHttpServer } from './http'
+4
View File
@@ -182,6 +182,9 @@ importers:
commander:
specifier: ^13.1.0
version: 13.1.0
fastify:
specifier: ^5.8.4
version: 5.8.4
devDependencies:
tsx:
specifier: ^4.21.0
@@ -4373,6 +4376,7 @@ packages:
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
prelude-ls@1.2.1: