server.ts 5.1 KB

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