feat(electron): add internal http server for renderer adapters

This commit is contained in:
digua committed 2026-06-02 15:41:21 +08:00
1 parent a8f0d5cb88
commit 96a9d143c4
10 files changed
+323 -6

No files matched your search

+1 -3
View File
@@ -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
}
+13
View File
@@ -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()
})
})
+187
View File
@@ -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
}
}
}
+17
View File
@@ -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'),
}
+12
View File
@@ -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,
}
+4
View File
@@ -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
}
+2 -1
View File
@@ -14,7 +14,8 @@
"../../packages/mcp-server/**/*",
"../../apps/cli/**/*",
"../../packages/tools/**/*",
"../../packages/sync/**/*"
"../../packages/sync/**/*",
"../../packages/http-routes/**/*"
],
"compilerOptions": {
"composite": true,
+8 -1
View File
@@ -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
+59
View File
@@ -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())
+20 -1
View File
@@ -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
}