realtimeApi.test.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. // @vitest-environment node
  2. import { createServer } from 'node:http'
  3. import { once } from 'node:events'
  4. import { mkdtempSync } from 'node:fs'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import type { AddressInfo } from 'node:net'
  8. import { WebSocketServer, type WebSocket } from 'ws'
  9. import { afterEach, describe, expect, it } from 'vitest'
  10. import { createAuthenticatedApplication } from './authenticatedApplication.js'
  11. import { createNodeServer } from '../../src/server/app.js'
  12. import { SSEParser } from '../../src/shared/sse.js'
  13. import type { ServerConfig } from '../../src/server/config.js'
  14. const closers: Array<() => Promise<void>> = []
  15. afterEach(async () => { for (const close of closers.splice(0).reverse()) await close() })
  16. async function setup() {
  17. const server = createServer()
  18. const wss = new WebSocketServer({ server })
  19. const peers = new Set<WebSocket>(), commands: any[] = []
  20. let connectionCount = 0, sequence = 0
  21. wss.on('connection', peer => {
  22. connectionCount++; peers.add(peer); peer.on('close', () => peers.delete(peer))
  23. peer.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { replay_epoch: 'fixture' } } }))
  24. peer.on('message', raw => {
  25. const command = JSON.parse(raw.toString()); commands.push(command)
  26. peer.send(JSON.stringify({ id: command.id, result: command.method === 'session.resume'
  27. ? { session_id: 'runtime', stored_session_id: 'stored', running: false } : { status: 'streaming' } }))
  28. })
  29. })
  30. server.listen(0, '127.0.0.1'); await once(server, 'listening')
  31. closers.push(async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise<void>(r => server.close(() => r())) })
  32. const home = mkdtempSync(join(tmpdir(), 'yaoyao-http-sse-api-'))
  33. const config: ServerConfig = { host: '127.0.0.1', port: 8800,
  34. upstream: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`), allowedHosts: new Set(),
  35. home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home, mediaOwner: 'test',
  36. allowInsecureLan: false, insecureLan: false, production: false }
  37. const runtime = createAuthenticatedApplication({ config, fetchImpl: (async input => {
  38. const path = new URL(String(input)).pathname
  39. if (path === '/api/auth/ws-ticket') return Response.json({ ticket: 'upstream-secret' })
  40. if (path === '/api/status') return Response.json({ auth_required: false })
  41. return Response.json({ user_id: 'service', profiles: [] })
  42. }) as typeof fetch })
  43. const node = createNodeServer(runtime)
  44. node.server.listen(0, '127.0.0.1'); await once(node.server, 'listening')
  45. config.port = (node.server.address() as AddressInfo).port
  46. closers.push(() => node.close())
  47. const origin = `http://127.0.0.1:${config.port}`
  48. const caps = await fetch(`${origin}/api/realtime/capabilities`)
  49. const token = await caps.json() as { csrfToken: string }
  50. const cookie = caps.headers.getSetCookie().map(c => c.split(';')[0]).join('; ')
  51. const headers = { Cookie: cookie, Origin: origin, 'X-CSRF-Token': token.csrfToken, 'Content-Type': 'application/json' }
  52. const create = async () => {
  53. const response = await fetch(`${origin}/api/realtime/channels`, { method: 'POST', headers, body: '{"channel":"chat"}' })
  54. expect(response.status).toBe(201)
  55. return (await response.json() as { id: string }).id
  56. }
  57. const command = async (channel: string, id: string, method: string, params: object) => {
  58. const response = await fetch(`${origin}/api/realtime/channels/${channel}/commands`, {
  59. method: 'POST', headers: { ...headers, 'Idempotency-Key': id }, body: JSON.stringify({ method, params }),
  60. })
  61. return { response, receipt: await response.json() as any }
  62. }
  63. const emit = (text: string) => {
  64. const payload = { text, emitted: performance.now() }
  65. for (const peer of peers) peer.send(JSON.stringify({ method: 'event', params: { type: 'message.delta', session_id: 'runtime', seq: ++sequence, payload } }))
  66. }
  67. return { origin, headers, create, command, emit, commands, config, count: () => connectionCount }
  68. }
  69. describe('HTTP/SSE realtime API', () => {
  70. it('enforces CSRF and returns upstream-confirmed receipts', async () => {
  71. const f = await setup()
  72. const invalid = await fetch(`${f.origin}/api/realtime/channels`, { method: 'POST', headers: { ...f.headers, 'X-CSRF-Token': 'bad' }, body: '{"channel":"chat"}' })
  73. expect(invalid.status).toBe(403)
  74. const c = await f.create()
  75. const result = await f.command(c, 'resume', 'session.resume', { session_id: 'stored', profile: 'default' })
  76. expect(result.receipt).toMatchObject({ state: 'confirmed', response: { result: { session_id: 'runtime' } } })
  77. const forbidden = await f.command(c, 'forbidden', 'terminal.resize', { session_id: 'runtime' })
  78. expect(forbidden.response.status).toBe(403)
  79. expect(f.commands).toHaveLength(1)
  80. })
  81. it('allows existing attachment sizes only on the dedicated command endpoint', async () => {
  82. const f = await setup(), c = await f.create()
  83. await f.command(c, 'resume', 'session.resume', { session_id: 'stored', profile: 'default' })
  84. const attached = await f.command(c, 'attachment', 'image.attach_bytes', { session_id: 'runtime', content_base64: 'A'.repeat(3 * 1024 * 1024), filename: 'fixture.png' })
  85. expect(attached.response.status).toBe(200)
  86. expect(attached.receipt.state).toBe('confirmed')
  87. const other = await fetch(`${f.origin}/api/app/sessions`, { method: 'POST', headers: f.headers, body: JSON.stringify({ data: 'A'.repeat(3 * 1024 * 1024) }) })
  88. expect(other.status).toBe(413)
  89. })
  90. it('shares Web/iOS subscriptions, replays after detach, and measures stream latency', async () => {
  91. const f = await setup(), a = await f.create(), b = await f.create()
  92. await f.command(a, 'resume-a', 'session.resume', { session_id: 'stored', profile: 'default' })
  93. await f.command(b, 'resume-b', 'session.resume', { session_id: 'stored', profile: 'default' })
  94. const controller = new AbortController()
  95. const response = await fetch(`${f.origin}/api/realtime/channels/${a}/events`, { headers: f.headers, signal: controller.signal })
  96. expect(response.headers.get('content-type')).toContain('text/event-stream')
  97. expect(response.headers.get('x-accel-buffering')).toBe('no')
  98. const reader = response.body!.getReader(), decoder = new TextDecoder(), parser = new SSEParser()
  99. const initial = []
  100. while (!initial.some(x => x.event === 'frame')) initial.push(...parser.feed(decoder.decode((await reader.read()).value, { stream: true })))
  101. const cursor = initial.find(x => x.event === 'frame')!.id!
  102. controller.abort(); await reader.cancel().catch(() => {})
  103. f.emit('while-offline')
  104. await new Promise(r => setTimeout(r, 30))
  105. const resumedController = new AbortController()
  106. const resumed = await fetch(`${f.origin}/api/realtime/channels/${a}/events`, { headers: { ...f.headers, 'Last-Event-ID': cursor }, signal: resumedController.signal })
  107. const resumedReader = resumed.body!.getReader(), resumedParser = new SSEParser(), resumedDecoder = new TextDecoder()
  108. const entries = []
  109. while (!entries.some(x => x.event === 'frame')) entries.push(...resumedParser.feed(resumedDecoder.decode((await resumedReader.read()).value, { stream: true })))
  110. expect(entries.filter(e => e.event === 'frame').map(e => JSON.parse(e.data).params.payload.text)).toEqual(['while-offline'])
  111. expect(f.count()).toBe(1)
  112. const latencies: number[] = []
  113. const before = process.memoryUsage().rss, cpu = process.cpuUsage()
  114. const consume = (async () => {
  115. while (latencies.length < 200) {
  116. const chunk = await resumedReader.read()
  117. for (const e of resumedParser.feed(resumedDecoder.decode(chunk.value, { stream: true }))) {
  118. if (e.event === 'frame') latencies.push(performance.now() - JSON.parse(e.data).params.payload.emitted)
  119. }
  120. }
  121. })()
  122. for (let i = 0; i < 200; i++) { f.emit(`token-${i}`); await new Promise<void>(r => setImmediate(r)) }
  123. await consume
  124. latencies.sort((x, y) => x - y)
  125. console.info('SSE fixture performance', JSON.stringify({ samples: latencies.length, p95Ms: latencies[189], p99Ms: latencies[197], rssDelta: process.memoryUsage().rss - before, cpuMicros: process.cpuUsage(cpu) }))
  126. expect(latencies).toHaveLength(200)
  127. resumedController.abort(); await resumedReader.cancel().catch(() => {})
  128. })
  129. it('keeps an admitted run connected after the only SSE client is offline for over twenty seconds', async () => {
  130. const f = await setup(), c = await f.create()
  131. await f.command(c, 'open', 'session.resume', { session_id: 'stored', profile: 'default' })
  132. await f.command(c, 'send', 'prompt.submit', { session_id: 'runtime', text: 'long-running fixture' })
  133. const controller = new AbortController()
  134. const response = await fetch(`${f.origin}/api/realtime/channels/${c}/events`, { headers: f.headers, signal: controller.signal })
  135. const reader = response.body!.getReader()
  136. await reader.read(); controller.abort(); await reader.cancel().catch(() => {})
  137. await new Promise(r => setTimeout(r, 21_000))
  138. expect(f.count()).toBe(1)
  139. f.emit('finished while offline')
  140. const reconnect = new AbortController()
  141. const again = await fetch(`${f.origin}/api/realtime/channels/${c}/events`, { headers: f.headers, signal: reconnect.signal })
  142. const input = again.body!.getReader(), decoder = new TextDecoder()
  143. let text = ''
  144. while (!text.includes('finished while offline')) text += decoder.decode((await input.read()).value, { stream: true })
  145. expect(text).toContain('finished while offline')
  146. reconnect.abort(); await input.cancel().catch(() => {})
  147. }, 30_000)
  148. })