mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(electron): add internal http server for renderer adapters
This commit is contained in:
1 parent
a8f0d5cb88
commit
96a9d143c4
10 files changed
+323
-6
No files matched your search
@@ -373,9 +373,7 @@ async function handleLegacyImport(request: FastifyRequest, reply: FastifyReply,
|
||||
if (sessionId) {
|
||||
const session = await worker.getSession(sessionId)
|
||||
if (!session) {
|
||||
// Legacy route requires session to exist
|
||||
const { sessionNotFound } = await import('../errors')
|
||||
const err = sessionNotFound(sessionId)
|
||||
const err = new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `Session not found: ${sessionId}`)
|
||||
reply.code(err.statusCode).send(errorResponse(err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { join } from 'path'
|
||||
import { optimizer, is, platform } from '@electron-toolkit/utils'
|
||||
import { checkUpdate } from './update'
|
||||
import mainIpcMain, { cleanup } from './ipcMain'
|
||||
import { startInternalServer, stopInternalServer, registerInternalApiIpc } from './internal-api'
|
||||
import { getPathProvider } from './path-context'
|
||||
import { initAnalytics, trackDailyActive } from './analytics'
|
||||
import { initProxy } from './network/proxy'
|
||||
import {
|
||||
@@ -270,6 +272,16 @@ class MainProcess {
|
||||
// 记录日活(用于统计操作系统版本、客户端版本,便于更好的适配客户端)
|
||||
trackDailyActive()
|
||||
|
||||
// 启动 Internal API Server(在 createWindow 之前,确保 Renderer 加载时 endpoint 可用)
|
||||
try {
|
||||
await startInternalServer(getPathProvider())
|
||||
registerInternalApiIpc()
|
||||
console.log('[Main] Internal API Server ready')
|
||||
} catch (err) {
|
||||
console.error('[Main] Internal API Server failed to start (falling back to IPC):', err)
|
||||
registerInternalApiIpc()
|
||||
}
|
||||
|
||||
// 创建主窗口
|
||||
console.log('[Main] Creating window...')
|
||||
await this.createWindow()
|
||||
@@ -329,6 +341,7 @@ class MainProcess {
|
||||
|
||||
// 退出前清理资源
|
||||
app.on('will-quit', () => {
|
||||
stopInternalServer().catch(() => {})
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Electron Internal API Server
|
||||
*
|
||||
* Provides HTTP-based business communication for the Renderer process,
|
||||
* reusing @openchatlab/http-routes shared routes with ephemeral auth.
|
||||
*
|
||||
* Completely isolated from the user-facing External API Server
|
||||
* (apps/desktop/main/api/). Different port, different token, different lifecycle.
|
||||
*/
|
||||
|
||||
import { randomBytes, createHmac, timingSafeEqual } from 'crypto'
|
||||
import { ipcMain } from 'electron'
|
||||
import Fastify, { type FastifyInstance, type FastifyError, type FastifyRequest, type FastifyReply } from 'fastify'
|
||||
import type { PathProvider } from '@openchatlab/core'
|
||||
import { DatabaseManager, createDatabaseManagerAdapter } from '@openchatlab/node-runtime'
|
||||
import { registerSharedRoutes, ApiError, ApiErrorCode, errorResponse, serverError } from '@openchatlab/http-routes'
|
||||
import type { HttpRouteContext } from '@openchatlab/http-routes'
|
||||
|
||||
export interface InternalEndpoint {
|
||||
baseUrl: string
|
||||
token: string
|
||||
}
|
||||
|
||||
let server: FastifyInstance | null = null
|
||||
let endpoint: InternalEndpoint | null = null
|
||||
let dbManager: DatabaseManager | null = null
|
||||
|
||||
const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50 MB
|
||||
|
||||
/**
|
||||
* Start the Internal API Server.
|
||||
* Must be called before createWindow() so the Renderer can retrieve the endpoint.
|
||||
*/
|
||||
export async function startInternalServer(pathProvider: PathProvider): Promise<InternalEndpoint> {
|
||||
if (server) return endpoint!
|
||||
|
||||
let newServer: FastifyInstance | null = null
|
||||
let newDbManager: DatabaseManager | null = null
|
||||
|
||||
try {
|
||||
const token = `int_${randomBytes(32).toString('hex')}`
|
||||
|
||||
newDbManager = new DatabaseManager(pathProvider)
|
||||
const sessionAdapter = createDatabaseManagerAdapter(newDbManager)
|
||||
|
||||
const { app } = await import('electron')
|
||||
|
||||
const ctx: HttpRouteContext = {
|
||||
dbManager: newDbManager,
|
||||
sessionAdapter,
|
||||
pathProvider,
|
||||
getVersion: () => app.getVersion(),
|
||||
}
|
||||
|
||||
newServer = Fastify({ logger: false, bodyLimit: JSON_BODY_LIMIT })
|
||||
|
||||
// CORS: dev allows the Vite dev server origin; prod allows Electron app origins only
|
||||
const isDev = !app.isPackaged
|
||||
const devOrigin = process.env.ELECTRON_RENDERER_URL || 'http://localhost:13100'
|
||||
newServer.addHook('onRequest', (request, reply, done) => {
|
||||
const origin = request.headers.origin
|
||||
if (!origin) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
if (origin === devOrigin || origin.startsWith('http://localhost:')) {
|
||||
reply.header('Access-Control-Allow-Origin', origin)
|
||||
}
|
||||
} else if (origin === 'file://' || origin === 'app://' || origin === 'null') {
|
||||
reply.header('Access-Control-Allow-Origin', origin)
|
||||
}
|
||||
|
||||
reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
|
||||
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
reply.code(204).send()
|
||||
return
|
||||
}
|
||||
done()
|
||||
})
|
||||
|
||||
newServer.addHook('onRequest', createInternalAuthHook(token))
|
||||
|
||||
newServer.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 statusCode = (error as any).statusCode
|
||||
if (statusCode && statusCode >= 400 && statusCode < 600) {
|
||||
reply.code(statusCode).send({ success: false, error: { code: 'CLIENT_ERROR', message: error.message } })
|
||||
return
|
||||
}
|
||||
const err = serverError(error.message)
|
||||
reply.code(err.statusCode).send(errorResponse(err))
|
||||
})
|
||||
|
||||
registerSharedRoutes(newServer, ctx)
|
||||
|
||||
await newServer.listen({ port: 0, host: '127.0.0.1' })
|
||||
|
||||
const address = newServer.server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
|
||||
server = newServer
|
||||
dbManager = newDbManager
|
||||
endpoint = { baseUrl: `http://127.0.0.1:${port}`, token }
|
||||
console.log(`[InternalAPI] Server started on port ${port}`)
|
||||
|
||||
return endpoint
|
||||
} catch (err) {
|
||||
// Cleanup partial initialization to avoid leaked resources
|
||||
try { await newServer?.close() } catch { /* best-effort */ }
|
||||
try { newDbManager?.closeAll() } catch { /* best-effort */ }
|
||||
server = null
|
||||
dbManager = null
|
||||
endpoint = null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function getInternalEndpoint(): InternalEndpoint | null {
|
||||
return endpoint
|
||||
}
|
||||
|
||||
export async function stopInternalServer(): Promise<void> {
|
||||
if (!server) return
|
||||
try {
|
||||
await server.close()
|
||||
} catch (err) {
|
||||
console.error('[InternalAPI] Error closing server:', err)
|
||||
} finally {
|
||||
try { dbManager?.closeAll() } catch { /* best-effort */ }
|
||||
server = null
|
||||
endpoint = null
|
||||
dbManager = null
|
||||
console.log('[InternalAPI] Server stopped')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handler so the Renderer can retrieve the endpoint via preload.
|
||||
* Must be called before createWindow().
|
||||
*/
|
||||
export function registerInternalApiIpc(): void {
|
||||
ipcMain.handle('internal-api:getEndpoint', () => getInternalEndpoint())
|
||||
}
|
||||
|
||||
// ==================== Auth Hook (Internal Only) ====================
|
||||
|
||||
const hmacKey = randomBytes(32)
|
||||
|
||||
function safeTokenCompare(a: string, b: string): boolean {
|
||||
const hashA = createHmac('sha256', hmacKey).update(a).digest()
|
||||
const hashB = createHmac('sha256', hmacKey).update(b).digest()
|
||||
return timingSafeEqual(hashA, hashB)
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth hook for the Internal Server. ALL routes require Bearer token, no exceptions.
|
||||
* Independent from @openchatlab/http-routes auth (which uses global state for External Server).
|
||||
*/
|
||||
function createInternalAuthHook(token: string) {
|
||||
return async function internalAuthHook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
if (request.method === 'OPTIONS') return
|
||||
|
||||
const authHeader = request.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
reply.code(401).send({ success: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } })
|
||||
return
|
||||
}
|
||||
|
||||
const provided = authHeader.slice(7)
|
||||
if (!safeTokenCompare(provided, token)) {
|
||||
reply.code(401).send({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Internal API Server endpoint — Preload API
|
||||
*
|
||||
* Exposes the Internal Server's baseUrl and ephemeral token to the Renderer.
|
||||
* Token is held in memory only (never persisted to localStorage/sessionStorage).
|
||||
*/
|
||||
|
||||
import { ipcRenderer } from 'electron'
|
||||
|
||||
export interface InternalEndpoint {
|
||||
baseUrl: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export const internalApi = {
|
||||
getEndpoint: (): Promise<InternalEndpoint | null> => ipcRenderer.invoke('internal-api:getEndpoint'),
|
||||
}
|
||||
Vendored
+12
@@ -1206,9 +1206,19 @@ declare global {
|
||||
nlpApi: NlpApi
|
||||
apiServerApi: ApiServerApi
|
||||
preferencesApi: PreferencesApi
|
||||
internalApi: InternalApi
|
||||
}
|
||||
}
|
||||
|
||||
interface InternalEndpoint {
|
||||
baseUrl: string
|
||||
token: string
|
||||
}
|
||||
|
||||
interface InternalApi {
|
||||
getEndpoint: () => Promise<InternalEndpoint | null>
|
||||
}
|
||||
|
||||
export {
|
||||
ChatApi,
|
||||
Api,
|
||||
@@ -1269,4 +1279,6 @@ export {
|
||||
DataSource,
|
||||
ImportSession,
|
||||
PreferencesApi,
|
||||
InternalApi,
|
||||
InternalEndpoint,
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { aiApi, llmApi, agentApi, assistantApi, skillApi } from './apis/ai'
|
||||
import { nlpApi, networkApi, cacheApi, sessionApi } from './apis/utils'
|
||||
import { apiServerApi } from './apis/api-server'
|
||||
import { preferencesApi } from './apis/preferences'
|
||||
import { internalApi } from './apis/internal-api'
|
||||
|
||||
// 为渲染进程提供统一的类型入口,避免 type-only import 指向无导出的运行时代码。
|
||||
export type { PreprocessConfig } from './apis/ai'
|
||||
@@ -48,6 +49,7 @@ if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld('nlpApi', nlpApi)
|
||||
contextBridge.exposeInMainWorld('apiServerApi', apiServerApi)
|
||||
contextBridge.exposeInMainWorld('preferencesApi', preferencesApi)
|
||||
contextBridge.exposeInMainWorld('internalApi', internalApi)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -82,4 +84,6 @@ if (process.contextIsolated) {
|
||||
window.apiServerApi = apiServerApi
|
||||
// @ts-ignore (define in dts)
|
||||
window.preferencesApi = preferencesApi
|
||||
// @ts-ignore (define in dts)
|
||||
window.internalApi = internalApi
|
||||
}
|
||||
@@ -14,7 +14,8 @@
|
||||
"../../packages/mcp-server/**/*",
|
||||
"../../apps/cli/**/*",
|
||||
"../../packages/tools/**/*",
|
||||
"../../packages/sync/**/*"
|
||||
"../../packages/sync/**/*",
|
||||
"../../packages/http-routes/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
+8
-1
@@ -88,7 +88,14 @@ onMounted(async () => {
|
||||
document.documentElement.classList.add('platform-linux')
|
||||
}
|
||||
|
||||
if (!IS_ELECTRON) {
|
||||
if (IS_ELECTRON) {
|
||||
// Electron: get Internal API Server endpoint from preload
|
||||
const ep = await window.internalApi?.getEndpoint()
|
||||
if (ep) {
|
||||
configureHttpClient({ baseUrl: `${ep.baseUrl}/_web`, token: ep.token })
|
||||
}
|
||||
} else {
|
||||
// CLI Web: use relative paths + dynamic token from auth store
|
||||
let redirectingTo401 = false
|
||||
const on401 = () => {
|
||||
if (redirectingTo401 || router.currentRoute.value.name === 'login') return
|
||||
|
||||
@@ -60,7 +60,66 @@ export async function initServices(): Promise<void> {
|
||||
_initialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Internal HTTP Server is available.
|
||||
* When available, business adapters use Fetch instead of IPC.
|
||||
*/
|
||||
function isInternalHttpAvailable(): boolean {
|
||||
try {
|
||||
return typeof window !== 'undefined' && typeof (window as any).internalApi?.getEndpoint === 'function'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function initElectronAdapters(): Promise<void> {
|
||||
// Check if Internal API Server endpoint was configured in App.vue
|
||||
const { getBaseUrl } = await import('./utils/http')
|
||||
const useHttp = isInternalHttpAvailable() && getBaseUrl().startsWith('http')
|
||||
|
||||
if (useHttp) {
|
||||
await initElectronHttpAdapters()
|
||||
} else {
|
||||
await initElectronIpcAdapters()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP mode: business adapters use Fetch (via Internal API Server).
|
||||
* Only platform + import + ai remain on IPC (routes not yet available).
|
||||
*/
|
||||
async function initElectronHttpAdapters(): Promise<void> {
|
||||
const { FetchDataAdapter } = await import('./data/fetch')
|
||||
registerAdapter('data', new FetchDataAdapter())
|
||||
|
||||
// Import stays on IPC: routes depend on CLI-specific modules
|
||||
const { ElectronImportAdapter } = await import('./import/electron')
|
||||
registerAdapter('import', new ElectronImportAdapter())
|
||||
|
||||
// Session-index stays on IPC: summaries routes (generateSummary, checkCanGenerateSummary)
|
||||
// depend on LLM config and are not registered in Internal Server's registerSharedRoutes()
|
||||
const { ElectronSessionIndexAdapter } = await import('./session-index/electron')
|
||||
registerAdapter('session-index', new ElectronSessionIndexAdapter())
|
||||
|
||||
const { FetchMessageAdapter } = await import('./message/fetch')
|
||||
registerAdapter('message', new FetchMessageAdapter())
|
||||
|
||||
// Platform always uses IPC (native capabilities)
|
||||
const { ElectronPlatformAdapter } = await import('./platform/electron')
|
||||
registerAdapter('platform', new ElectronPlatformAdapter())
|
||||
|
||||
// AI stays on IPC: routes depend on CLI-specific modules
|
||||
const { ElectronAIAdapter } = await import('./ai/electron')
|
||||
registerAdapter('ai', new ElectronAIAdapter())
|
||||
|
||||
const { FetchPreferencesAdapter } = await import('./preferences/fetch')
|
||||
registerAdapter('preferences', new FetchPreferencesAdapter())
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC fallback mode: all adapters use Electron IPC (original behavior).
|
||||
*/
|
||||
async function initElectronIpcAdapters(): Promise<void> {
|
||||
const { ElectronDataAdapter } = await import('./data/electron')
|
||||
registerAdapter('data', new ElectronDataAdapter())
|
||||
|
||||
|
||||
@@ -36,9 +36,27 @@ export function getBaseUrl(): string {
|
||||
return _baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a URL path against the configured base.
|
||||
* When baseUrl is absolute (http://...), relative paths like "/_web/..."
|
||||
* are rewritten to the Internal Server origin. Otherwise returns as-is.
|
||||
*/
|
||||
function resolveFullUrl(url: string): string {
|
||||
if (!_baseUrl.startsWith('http://') && !_baseUrl.startsWith('https://')) return url
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||||
try {
|
||||
const origin = new URL(_baseUrl).origin
|
||||
return `${origin}${url}`
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated fetch wrapper — same API as native fetch,
|
||||
* but auto-injects Authorization header and handles 401.
|
||||
* Paths starting with / are resolved via resolveFullUrl so that
|
||||
* Electron Internal Server mode works with absolute base URLs.
|
||||
*/
|
||||
export async function fetchWithAuth(url: string, init?: RequestInit): Promise<Response> {
|
||||
const headers = new Headers(init?.headers)
|
||||
@@ -46,7 +64,8 @@ export async function fetchWithAuth(url: string, init?: RequestInit): Promise<Re
|
||||
if (token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
const resp = await fetch(url, { ...init, headers })
|
||||
const resolvedUrl = resolveFullUrl(url)
|
||||
const resp = await fetch(resolvedUrl, { ...init, headers })
|
||||
if (resp.status === 401 && _on401) _on401()
|
||||
return resp
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user