app.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. import { hermesBotRelay } from './hermesBotRelay.js'
  2. import { remoteAgentExports } from './workspaceRemoteAgents.js'
  3. import { randomBytes, createHash } from 'node:crypto'
  4. import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
  5. import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'
  6. import { createServer as createHttpsServer } from 'node:https'
  7. import { join } from 'node:path'
  8. import { bodyParser } from '@koa/bodyparser'
  9. import Koa from 'koa'
  10. import { RealtimeAPI } from './realtimeApi.js'
  11. import type { ServerConfig } from './config.js'
  12. import { loadServerConfig } from './config.js'
  13. import { errorMessage, HttpError } from './errors.js'
  14. import { NodePairingStore } from './pairing.js'
  15. import { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
  16. import { AccountLoginPairingStore } from './accountPairing.js'
  17. import { UpstreamProfileIdentityService } from './profileIdentities.js'
  18. import { createApiRouter } from './routes.js'
  19. import {
  20. applySecurityHeaders,
  21. CsrfProtection,
  22. isAllowedHostHeader,
  23. isExactOrigin,
  24. } from './security.js'
  25. import { UpstreamHttpError, UpstreamClient } from './upstream.js'
  26. import { UploadStore } from './uploads.js'
  27. import { WorkspaceStore } from './workspaceStore.js'
  28. import { WorkspaceNodes } from './workspaceGateway.js'
  29. import { WorkspaceRuntime } from './workspaceRuntime.js'
  30. import { WorkspaceAssets } from './workspaceAssets.js'
  31. import { workspaceRouter } from './workspaceRoutes.js'
  32. import { SystemUpdateManager } from './updateManager.js'
  33. import { PushCoordinator } from './pushCoordinator.js'
  34. import {
  35. APNsConfigurationManager,
  36. type APNsConfigurationSnapshot,
  37. } from './apnsConfiguration.js'
  38. import {
  39. FCMConfigurationManager,
  40. type FCMConfigurationSnapshot,
  41. } from './fcmConfiguration.js'
  42. import { PushCoordinatorEventAdapter } from './pushEventAdapter.js'
  43. import {
  44. AllowedHostsConfigurationManager,
  45. type AllowedHostsConfigurationSnapshot,
  46. } from './allowedHostsConfiguration.js'
  47. import {
  48. ChatPushJobManager,
  49. HermesChatNotificationResolver,
  50. } from './pushEvents.js'
  51. import { ChatCacheCoordinator, ChatCacheStore } from './chatCache.js'
  52. export interface ApplicationOptions {
  53. config?: ServerConfig
  54. fetchImpl?: typeof fetch
  55. csrfSecret?: Buffer
  56. pairings?: NodePairingStore
  57. uploads?: UploadStore
  58. updates?: SystemUpdateManager
  59. auth?: LocalAuthStore
  60. upstreamSession?: UpstreamServiceSession
  61. accountPairings?: AccountLoginPairingStore
  62. profileIdentities?: UpstreamProfileIdentityService
  63. push?: PushCoordinator
  64. apnsConfiguration?: APNsConfigurationManager
  65. fcmConfiguration?: FCMConfigurationManager
  66. allowedHostsConfiguration?: AllowedHostsConfigurationManager
  67. chatCache?: ChatCacheCoordinator
  68. }
  69. export interface ApplicationRuntime {
  70. workspace: WorkspaceStore
  71. workspaceRuntime: WorkspaceRuntime
  72. realtime: RealtimeAPI
  73. app: Koa
  74. config: ServerConfig
  75. csrf: CsrfProtection
  76. pairings: NodePairingStore
  77. upstream: UpstreamClient
  78. uploads: UploadStore
  79. updates: SystemUpdateManager
  80. auth: LocalAuthStore
  81. upstreamSession: UpstreamServiceSession
  82. accountPairings: AccountLoginPairingStore
  83. profileIdentities: UpstreamProfileIdentityService
  84. push: PushCoordinator
  85. apnsConfiguration: APNsConfigurationManager
  86. fcmConfiguration: FCMConfigurationManager
  87. allowedHostsConfiguration: AllowedHostsConfigurationManager
  88. pushEventCoordinator: PushCoordinatorEventAdapter
  89. chatPushJobs: ChatPushJobManager
  90. chatCache?: ChatCacheCoordinator
  91. close(): void
  92. }
  93. function isMutation(method: string): boolean {
  94. return !['GET', 'HEAD', 'OPTIONS'].includes(method)
  95. }
  96. function persistentCsrfSecret(home: string, override?: Buffer): Buffer {
  97. if (override) return override
  98. const path = join(home, 'csrf-secret.bin')
  99. try {
  100. const secret = readFileSync(path)
  101. if (secret.byteLength === 32) return secret
  102. throw new Error('CSRF secret has an invalid length')
  103. } catch (error) {
  104. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  105. }
  106. mkdirSync(home, { recursive: true, mode: 0o700 })
  107. const generated = randomBytes(32)
  108. try {
  109. writeFileSync(path, generated, { mode: 0o600, flag: 'wx' })
  110. return generated
  111. } catch (error) {
  112. // Another 15300 process can finish first during launchd hand-off. Never
  113. // rotate its secret; use the one it safely wrote instead.
  114. if ((error as NodeJS.ErrnoException).code === 'EEXIST') return readFileSync(path)
  115. throw error
  116. }
  117. }
  118. export function createApplication(options: ApplicationOptions = {}): ApplicationRuntime {
  119. const config = options.config ?? loadServerConfig()
  120. const runtimeAllowedHosts = new Set(config.allowedHosts)
  121. config.allowedHosts = runtimeAllowedHosts
  122. const app = new Koa()
  123. app.proxy = false
  124. const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
  125. const pairings = options.pairings ?? new NodePairingStore(config.home)
  126. const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
  127. const uploads = options.uploads ?? new UploadStore(config.home)
  128. const updates = options.updates ?? new SystemUpdateManager(config)
  129. const auth = options.auth ?? new LocalAuthStore(config.home, Boolean(config.tlsCert))
  130. const configuredUpstreamCredentials = config.upstreamUsername && config.upstreamPassword
  131. ? { username: config.upstreamUsername, password: config.upstreamPassword }
  132. : undefined
  133. const upstreamSession = options.upstreamSession ?? new UpstreamServiceSession(
  134. upstream,
  135. () => auth.upstreamCredentials(configuredUpstreamCredentials),
  136. )
  137. let chatCache = options.chatCache
  138. if (!chatCache) {
  139. chatCache = new ChatCacheCoordinator(
  140. new ChatCacheStore(
  141. config.home,
  142. auth.soleUserIDForLegacyMigration(),
  143. ),
  144. upstreamSession,
  145. config.chatCacheMode ?? 'prefer-local',
  146. )
  147. }
  148. const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
  149. const profileIdentities = options.profileIdentities
  150. ?? new UpstreamProfileIdentityService(config, upstreamSession)
  151. const initialAPNsSettings: APNsConfigurationSnapshot = config.apnsSettings ?? {
  152. source: config.apns || config.apnsConfigurationError ? 'environment' : 'none',
  153. editable: !(config.apns || config.apnsConfigurationError),
  154. ...(config.apns ? {
  155. input: {
  156. ...config.apns,
  157. environments: [...(config.apns.environments ?? ['development', 'production'])],
  158. },
  159. config: config.apns,
  160. } : {}),
  161. warnings: [],
  162. ...(config.apnsConfigurationError ? { configurationError: config.apnsConfigurationError } : {}),
  163. }
  164. const apnsConfiguration = options.apnsConfiguration
  165. ?? new APNsConfigurationManager(config.home, initialAPNsSettings)
  166. const initialFCMSettings: FCMConfigurationSnapshot = config.fcmSettings ?? {
  167. source: config.fcm || config.fcmConfigurationError ? 'environment' : 'none',
  168. editable: !(config.fcm || config.fcmConfigurationError),
  169. ...(config.fcm ? { input: { ...config.fcm }, config: config.fcm } : {}),
  170. warnings: [],
  171. ...(config.fcmConfigurationError ? { configurationError: config.fcmConfigurationError } : {}),
  172. }
  173. const fcmConfiguration = options.fcmConfiguration
  174. ?? new FCMConfigurationManager(config.home, initialFCMSettings)
  175. const initialAllowedHostsSettings: AllowedHostsConfigurationSnapshot = config.allowedHostsSettings ?? {
  176. source: runtimeAllowedHosts.size ? 'environment' : 'none',
  177. hosts: [...runtimeAllowedHosts],
  178. editableHosts: [],
  179. environmentHosts: [...runtimeAllowedHosts],
  180. }
  181. const allowedHostsConfiguration = options.allowedHostsConfiguration
  182. ?? new AllowedHostsConfigurationManager(config.home, initialAllowedHostsSettings, runtimeAllowedHosts)
  183. const push = options.push ?? new PushCoordinator({
  184. home: config.home,
  185. apns: config.apns,
  186. apnsConfigurationError: config.apnsConfigurationError,
  187. fcm: config.fcm,
  188. fcmConfigurationError: config.fcmConfigurationError,
  189. isUserActive: userID => auth.isUserActive(userID),
  190. userAuthorizationVersion: userID => auth.pushAuthorizationVersion(userID),
  191. })
  192. const pushEventCoordinator = new PushCoordinatorEventAdapter(push)
  193. const chatPushJobs = new ChatPushJobManager(config, upstreamSession, pushEventCoordinator, {
  194. resolver: new HermesChatNotificationResolver(
  195. upstreamSession,
  196. (localUserID, prompt) => push.promptDigest(localUserID, prompt),
  197. ),
  198. })
  199. // The application owns group events. No Dashboard plugin is queried.
  200. const workspace = new WorkspaceStore(config.home)
  201. const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession }, pairings.nodeID)
  202. workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)
  203. const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner))
  204. const workspaceAssets = new WorkspaceAssets(workspace, workspaceNodes, config.home)
  205. workspaceRuntime.onMessage = (owner, message) => workspaceAssets.archive(owner, message)
  206. workspaceRuntime.onNotify = (owner, c, run, message, interaction) => {
  207. if (c.kind === 'group' && !push.isGroupSubscribed(owner,c.id)) return
  208. push.enqueueNotification({ kind: interaction ? interaction.kind === 'approval' ? 'chat.approval.requested' : 'chat.clarification.requested' : run.status === 'failed' ? 'chat.failed' : 'chat.completed', eventID: `workspace:${interaction?.id ?? run.id}`, localUserID: owner, title: c.name, body: (interaction?.message || message?.content || run.error || '回复完成').slice(0,180), collapseID: `workspace:${c.id}:${run.conversationTaskId ?? 'direct'}`, data: { conversationId:c.id, ...(run.conversationTaskId ? { conversationTaskId: run.conversationTaskId } : {}), workspace:'1' }, ...(c.kind === 'group' ? {roomID:c.id} : {}) })
  209. }
  210. const realtime = new RealtimeAPI(config, auth, csrf, pairings, upstream, upstreamSession, {
  211. coordinator: pushEventCoordinator,
  212. resolver: new HermesChatNotificationResolver(upstreamSession, (user, prompt) => push.promptDigest(user, prompt)),
  213. }, (owner, profile, sessionID) =>
  214. chatCache?.store.ownsSession(owner, profile, sessionID) ?? false)
  215. realtime.broker.protectedSession = id => workspace.ownsUpstream(id)
  216. realtime.broker.onNativeEvent = (owner, profile, storedId, frame) => {
  217. chatCache?.observe(owner, profile, storedId, frame)
  218. if (owner.startsWith('device:')) return
  219. if (!['message.complete', 'tool.complete', 'tool.completed', 'attachment.staged'].includes(frame.type)) return
  220. const data = frame.payload ?? {}, text = JSON.stringify(data)
  221. const messageId = String(data.row_id ?? data.message_id ?? data.id ?? createHash('sha256').update(text).digest('hex'))
  222. void workspaceAssets.archiveText(owner, text, 'local', profile, storedId, messageId, frame.type === 'attachment.staged' ? 'user' : 'agent').catch(() => {})
  223. }
  224. realtime.broker.onNativeGlobalEvent = (owner, type) => chatCache?.observeGlobal(owner, type)
  225. realtime.broker.onNativeCommand = (owner, profile, storedId, method, params) => {
  226. chatCache?.command(owner, profile, storedId, method, params)
  227. }
  228. realtime.broker.onNativeRoute = (owner, profile, storedId, runtimeId) => {
  229. chatCache?.route(owner, profile, storedId, runtimeId)
  230. }
  231. chatPushJobs.setTransportFactory(realtime.recoveryTransport, job => realtime.ownsPushJob(job.id))
  232. try {
  233. uploads.cleanupUncommitted()
  234. } catch {
  235. // A locked cleanup should not prevent startup; upload operations will
  236. // still surface the underlying storage error when used.
  237. }
  238. app.use(async (ctx, next) => {
  239. try {
  240. await next()
  241. } catch (error) {
  242. if (error instanceof UpstreamHttpError) {
  243. ctx.status = error.response.status
  244. ctx.type = 'application/json; charset=utf-8'
  245. ctx.body = { error: `Hermes returned HTTP ${error.response.status}`, code: error.code }
  246. return
  247. }
  248. const status = error instanceof HttpError
  249. ? error.status
  250. : typeof (error as { status?: unknown })?.status === 'number'
  251. ? Number((error as { status: number }).status)
  252. : 500
  253. const expose = status < 500 || error instanceof HttpError
  254. ctx.status = status
  255. ctx.type = 'application/json; charset=utf-8'
  256. ctx.body = {
  257. error: expose ? errorMessage(error) : 'Internal server error',
  258. code: error instanceof HttpError ? error.code : 'internal_error',
  259. }
  260. if (status >= 500 && !config.production) ctx.app.emit('error', error, ctx)
  261. }
  262. })
  263. app.use(async (ctx, next) => {
  264. applySecurityHeaders(ctx, Boolean(config.tlsCert), config.allowedHosts)
  265. if (!isAllowedHostHeader(ctx.get('host'), config)) {
  266. throw new HttpError(421, 'Request Host is not allowed', 'invalid_host')
  267. }
  268. await next()
  269. })
  270. app.use(async (ctx, next) => {
  271. if (ctx.path.startsWith('/api/app/') && isMutation(ctx.method)) {
  272. const secure = ctx.secure || Boolean(config.tlsCert)
  273. if (!isExactOrigin(ctx.get('origin'), ctx.get('host'), secure, config.allowedHosts)) {
  274. throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
  275. }
  276. if (!csrf.verify(ctx.get('cookie'), ctx.get('x-csrf-token'))) {
  277. throw new HttpError(403, 'CSRF token is invalid or missing', 'invalid_csrf')
  278. }
  279. }
  280. await next()
  281. })
  282. app.use(async (ctx, next) => {
  283. const user = auth.current(ctx)
  284. if (user && user.role !== 'admin' && (ctx.path.startsWith('/api/') || ctx.path.startsWith('/node/') || ctx.path.startsWith('/ws/') || ctx.path.startsWith('/Users/') || ctx.path.startsWith('/attachments/'))) {
  285. const path = ctx.path
  286. const publicPath = ['/api/status', '/api/auth/providers', '/api/auth/me', '/api/profiles', '/api/app/bootstrap', '/api/app/login', '/api/app/logout'].includes(path)
  287. const accountPath = ['/api/account/credentials', '/api/app/account/credentials', '/api/app/account/avatar'].includes(path)
  288. const workspacePath = /^\/api\/app\/(?:capabilities|agents|conversations|runs|interactions|events|uploads|files|message-files)(?:\/|$)/.test(path)
  289. && path !== '/api/app/agents/remote'
  290. const pushPath = /^\/api\/(?:app\/)?push\/v1\//.test(path)
  291. if (!publicPath && !accountPath && !workspacePath && !pushPath)
  292. throw new HttpError(403, '子账号只能使用 Bot 模式', 'bot_mode_required')
  293. }
  294. await next()
  295. })
  296. app.use(hermesBotRelay(workspaceNodes, auth, csrf))
  297. app.use(remoteAgentExports(workspace,workspaceNodes,pairings,auth))
  298. app.use(realtime.middleware())
  299. const parseBody = (limit: string) => bodyParser({
  300. encoding: 'utf-8',
  301. enableTypes: ['json'],
  302. parsedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
  303. jsonLimit: limit,
  304. onError: (error, ctx) => {
  305. throw new HttpError(ctx.status === 413 || (error as { status?: number }).status === 413 ? 413 : 400, 'Invalid JSON request body', 'invalid_json_body')
  306. },
  307. })
  308. const ordinaryBody = parseBody('2mb'), workspaceBody = parseBody('4mb')
  309. app.use((ctx,next) => (/^\/api\/app\/(agents|conversations)(\/|$)/.test(ctx.path) || ctx.path === '/api/app/account/avatar' ? workspaceBody : ordinaryBody)(ctx,next))
  310. app.use(async (ctx, next) => {
  311. ctx.state.localAuth = auth
  312. ctx.state.upstreamSession = upstreamSession
  313. const anonymous = ctx.path === '/api/app/bootstrap'
  314. || ctx.path === '/api/app/setup'
  315. || ctx.path === '/api/app/login'
  316. || ctx.path === '/api/status'
  317. || ctx.path === '/api/auth/providers'
  318. || ctx.path === '/auth/password-login'
  319. || ctx.path.startsWith('/api/pair/v1/')
  320. || ctx.path.startsWith('/api/account-pair/v1/')
  321. || ctx.path === '/healthz'
  322. || ctx.path === '/readyz'
  323. if (ctx.path.startsWith('/api/app/') && !anonymous) {
  324. const allowPasswordChange = ctx.path === '/api/app/account/credentials'
  325. || ctx.path === '/api/app/logout'
  326. ctx.state.localUser = auth.require(ctx, allowPasswordChange)
  327. const adminOnly = ctx.path.startsWith('/api/app/admin/')
  328. || ctx.path.startsWith('/api/app/system/')
  329. || ctx.path.startsWith('/api/app/plugins/')
  330. || ctx.path.startsWith('/api/app/pairings')
  331. || ctx.path.startsWith('/api/app/paired-devices')
  332. || ctx.path.startsWith('/api/app/groups/nodes')
  333. if (adminOnly) ctx.state.localUser = auth.requireAdmin(ctx)
  334. }
  335. const gatewayCompatibility = (ctx.path.startsWith('/api/') && !ctx.path.startsWith('/api/app/')
  336. && !ctx.path.startsWith('/api/pair/'))
  337. || ctx.path === '/auth/logout'
  338. if (gatewayCompatibility && !anonymous) {
  339. const allowPasswordChange = ctx.path === '/api/auth/me' || ctx.path === '/api/profiles'
  340. || ctx.path === '/api/account/credentials'
  341. ctx.state.localUser = auth.require(ctx, allowPasswordChange)
  342. if (isMutation(ctx.method)) {
  343. const origin = ctx.get('origin')
  344. if (origin && !isExactOrigin(origin, ctx.get('host'), ctx.secure || Boolean(config.tlsCert), config.allowedHosts)) {
  345. throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
  346. }
  347. }
  348. }
  349. const user = ctx.state.localUser as { id: string } | undefined
  350. if (user) {
  351. await upstream.withReadScope(`user:${user.id}:${auth.pushAuthorizationVersion(user.id)}`, ctx.get('x-yaoyao-cache') === 'bypass', next)
  352. } else await next()
  353. })
  354. const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push)
  355. app.use(applicationRouter.routes())
  356. app.use(async (ctx, next) => {
  357. if (ctx.path.includes('/plugins/yaoyao') || ctx.path.startsWith('/api/app/groups')) throw new HttpError(410, '请使用新版聊天接口', 'retired_plugin_api')
  358. const id = /\/sessions\/([^/]+)/.exec(ctx.path)?.[1]
  359. if (id && workspace.ownsUpstream(decodeURIComponent(id))) throw new HttpError(404, '会话不存在', 'session_not_found')
  360. await next()
  361. // Native history is a separate surface, including search and profile projections.
  362. if (ctx.path.includes('/sessions') || ctx.path.endsWith('/profiles')) {
  363. let value: any = ctx.body
  364. const buffered = Buffer.isBuffer(value)
  365. try {
  366. if (buffered) value = JSON.parse(value.toString())
  367. const filter = (v: any): any => {
  368. if (Array.isArray(v)) return v.filter(x => !x || typeof x !== 'object' || (x.source !== 'yaoyao_workspace' && !workspace.ownsUpstream(String(x.id ?? x.session_id ?? x.sessionId ?? '')))).map(filter)
  369. if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k,x]) => [k, filter(x)]))
  370. return v
  371. }
  372. ctx.body = buffered ? Buffer.from(JSON.stringify(filter(value))) : filter(value)
  373. } catch { /* Preserve non-JSON error responses. */ }
  374. }
  375. })
  376. const router = createApiRouter({
  377. onUserAccessChanged: async owner => {
  378. for (const c of workspace.list<{ id: string }>(owner, 'conversation')) await workspaceRuntime.stopConversation(owner, c.id)
  379. },
  380. workspace,
  381. config,
  382. csrf,
  383. upstream,
  384. pairings,
  385. uploads,
  386. updates,
  387. auth,
  388. upstreamSession,
  389. accountPairings,
  390. profileIdentities,
  391. push,
  392. apnsConfiguration,
  393. fcmConfiguration,
  394. allowedHostsConfiguration,
  395. chatCache,
  396. })
  397. app.use(router.routes())
  398. app.use(router.allowedMethods({ throw: false }))
  399. app.use(async (ctx, next) => {
  400. if (ctx.path === '/api' || ctx.path.startsWith('/api/') || ctx.path.startsWith('/ws/')) {
  401. ctx.status = 404
  402. ctx.type = 'application/json; charset=utf-8'
  403. ctx.body = { error: 'Route not found', code: 'not_found' }
  404. return
  405. }
  406. await next()
  407. })
  408. return {
  409. workspace,
  410. workspaceRuntime,
  411. realtime,
  412. app,
  413. config,
  414. csrf,
  415. pairings,
  416. upstream,
  417. uploads,
  418. updates,
  419. auth,
  420. upstreamSession,
  421. accountPairings,
  422. profileIdentities,
  423. push,
  424. apnsConfiguration,
  425. fcmConfiguration,
  426. allowedHostsConfiguration,
  427. pushEventCoordinator,
  428. chatPushJobs,
  429. chatCache,
  430. close: () => {
  431. workspaceAssets.close()
  432. workspaceRuntime.close()
  433. workspaceNodes.close()
  434. upstream.close()
  435. realtime.close()
  436. chatPushJobs.stop()
  437. push.close()
  438. uploads.close()
  439. workspace.close()
  440. chatCache?.store.close()
  441. },
  442. }
  443. }
  444. export interface NodeServerRuntime {
  445. server: HttpServer
  446. close(): Promise<void>
  447. }
  448. export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime {
  449. const { config } = runtime
  450. const server: HttpServer = config.tlsCert && config.tlsKey
  451. ? createHttpsServer({
  452. cert: readFileSync(config.tlsCert),
  453. key: readFileSync(config.tlsKey),
  454. }, runtime.app.callback())
  455. : createHttpServer(runtime.app.callback())
  456. const synchronizePushObservers = (enabled = runtime.push.isAnyProviderEnabled()) => {
  457. if (enabled) {
  458. runtime.chatPushJobs.start()
  459. } else {
  460. runtime.chatPushJobs.stop()
  461. }
  462. }
  463. runtime.workspaceRuntime.start()
  464. synchronizePushObservers()
  465. const removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
  466. const removeWebSockets = runtime.realtime.rejectLegacyUpgrades(server)
  467. return {
  468. server,
  469. close: async () => {
  470. removePushConfigurationListener()
  471. removeWebSockets()
  472. runtime.realtime.close()
  473. await new Promise<void>((resolve, reject) => {
  474. if (!server.listening) {
  475. resolve()
  476. return
  477. }
  478. server.close((error) => error ? reject(error) : resolve())
  479. })
  480. runtime.close()
  481. },
  482. }
  483. }