| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192 |
- import fs from 'node:fs'
- import path from 'node:path'
- import { fileURLToPath } from 'node:url'
- import cookie from '@fastify/cookie'
- import multipart from '@fastify/multipart'
- import fastifyStatic from '@fastify/static'
- import Fastify, { type FastifyServerOptions } from 'fastify'
- import type { ApiErrorBody } from '@agent-server/shared'
- import type { App } from '../app'
- import { config } from '../config'
- import { AppError } from '../lib/errors'
- import adminRoutes from './routes/admin'
- import v1Routes from './routes/v1'
- import botRoutes from '../teams/routes'
- import { registerGateway } from '../mcp-gateway/server'
- import { registerMobileApp } from './mobile-app'
- export async function buildServer(app: App, opts: { logger: FastifyServerOptions['loggerInstance'] }) {
- const f = Fastify({ loggerInstance: opts.logger, trustProxy: config.trustProxy, bodyLimit: 2 * 1048576 })
- await f.register(cookie)
- await f.register(multipart)
- f.setErrorHandler((err, req, reply) => {
- if (err instanceof AppError) {
- const body: ApiErrorBody = { error: { code: err.code, message: err.message, details: err.details } }
- return reply.code(err.status).send(body)
- }
- const status = (err as { statusCode?: number }).statusCode ?? 500
- if (status >= 500) req.log.error({ err }, 'unhandled error')
- const body: ApiErrorBody = { error: { code: status >= 500 ? 'internal_error' : 'bad_request', message: status >= 500 ? '服务器内部错误' : (err as Error).message } }
- return reply.code(status).send(body)
- })
- // CORS:只对 /v1、/bot/v1 开放给设置中允许的业务平台来源(不带 Cookie,使用 Authorization 头);后台与网关不开放
- f.addHook('onRequest', async (req, reply) => {
- const origin = req.headers.origin
- if (!origin || !(req.url.startsWith('/v1/') || req.url.startsWith('/bot/v1/')) || !app.settings.get().security.corsOrigins.includes(origin)) return
- reply.header('access-control-allow-origin', origin)
- reply.header('vary', 'Origin')
- reply.header('access-control-expose-headers', 'content-disposition')
- if (req.method === 'OPTIONS') {
- reply.header('access-control-allow-methods', 'GET, POST, PATCH, DELETE')
- reply.header('access-control-allow-headers', 'authorization, content-type, last-event-id')
- reply.header('access-control-max-age', '600')
- return reply.code(204).send()
- }
- })
- f.get('/healthz', async () => ({ ok: true }))
- f.get('/readyz', async () => {
- app.db.$client.prepare('select 1').get()
- return { ok: true, runs: app.runs.stats() }
- })
- await f.register(v1Routes, { prefix: '/v1', app })
- await f.register(botRoutes, { prefix: '/bot/v1', app })
- registerGateway(f, app.gatewayTokens, { mcp: app.mcpServers, upstream: app.mcpUpstream, background: app.backgroundCalls, tasks: app.mcpTasks, elicitation: app.elicitation, content: app.mcpContent })
- await f.register(adminRoutes, { prefix: '/admin/v1', app })
- // 生产环境由服务托管后台静态资源,同源访问
- // 源码运行时位于 src/http,打包后位于 dist,两种情况都向上寻找 apps/admin/dist
- const here = path.dirname(fileURLToPath(import.meta.url))
- const adminDist = config.adminDist
- ?? [path.resolve(here, '../../../admin/dist'), path.resolve(here, '../../admin/dist')].find(p => fs.existsSync(path.join(p, 'index.html')))
- if (adminDist && fs.existsSync(path.join(adminDist, 'index.html'))) {
- await f.register(fastifyStatic, { root: adminDist, prefix: '/admin/', wildcard: false })
- f.get('/admin/*', (_req, reply) => reply.sendFile('index.html'))
- // 暂时屏蔽:根路径不再跳转到后台
- // f.get('/', (_req, reply) => reply.redirect('/admin/'))
- }
- // 业务平台使用的 SDK 包(scripts/build-sdk.mjs 生成,公开、不含密钥):生产在 app/sdk,开发在仓库的 dist-sdk
- const sdkDist = [config.sdkDist, adminDist && path.resolve(adminDist, '../sdk'), path.resolve(here, '../../../../dist-sdk'), path.resolve(here, '../../../dist-sdk')]
- .find((p): p is string => !!p && fs.existsSync(path.join(p, 'index.json')))
- if (sdkDist) {
- // 每次请求读取目录,重新部署 SDK 后无需重启
- f.get<{ Params: { file: string } }>('/sdk/:file', (req, reply) => {
- const file = req.params.file
- if (!/^[\w.-]+\.(tgz|json)$/.test(file) || !fs.existsSync(path.join(sdkDist, file))) return reply.code(404).send({ error: { code: 'not_found', message: '文件不存在' } })
- reply.header('Content-Type', file.endsWith('.json') ? 'application/json' : 'application/gzip')
- reply.header('Cache-Control', file.endsWith('.json') ? 'no-cache' : 'public, max-age=31536000, immutable')
- return reply.send(fs.createReadStream(path.join(sdkDist, file)))
- })
- }
- // Android App 下载页(deploy/release-apk.sh 发布):生产在 app/mobile,开发在仓库的 dist-mobile;目录不存在时页面提示还没有发布
- const mobileDirs = [config.mobileDist, adminDist && path.resolve(adminDist, '../mobile'), path.resolve(here, '../../../../dist-mobile'), path.resolve(here, '../../../dist-mobile')]
- registerMobileApp(f, () => mobileDirs.find((p): p is string => !!p && fs.existsSync(path.join(p, 'index.json'))))
- return f
- }
|