stream-server.host.spec.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import { once } from 'node:events'
  2. import { createServer, type Server } from 'node:http'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import WebSocket from 'ws'
  5. import {
  6. RemoteStreamMuxServer,
  7. type RemoteStreamFailureMapper,
  8. type RemoteStreamOpener,
  9. } from '../src/stream-server.ts'
  10. interface RunningMux {
  11. readonly http: Server
  12. readonly mux: RemoteStreamMuxServer
  13. readonly url: string
  14. }
  15. const running = new Set<RunningMux>()
  16. afterEach(async () => {
  17. await Promise.all([...running].map(async (entry) => {
  18. running.delete(entry)
  19. await entry.mux.close().catch(() => undefined)
  20. await closeHttp(entry.http)
  21. }))
  22. })
  23. describe('Remote stream mux server carrier lifecycle', () => {
  24. it('sends WebSocket Ping control frames without application messages', async () => {
  25. const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20)
  26. const client = await connect(entry.url)
  27. const serverSocket = acceptedSocket(entry.mux)
  28. const messages = vi.fn()
  29. client.on('message', messages)
  30. const ping = once(client, 'ping')
  31. const pong = once(serverSocket, 'pong')
  32. expect((await ping)[0]).toEqual(Buffer.alloc(0))
  33. expect((await pong)[0]).toEqual(Buffer.alloc(0))
  34. expect(messages).not.toHaveBeenCalled()
  35. const closingPing = vi.spyOn(serverSocket, 'ping')
  36. client.pause()
  37. serverSocket.close()
  38. expect(serverSocket.readyState).toBe(WebSocket.CLOSING)
  39. await new Promise<void>((resolve) => { setTimeout(resolve, 25) })
  40. expect(closingPing).not.toHaveBeenCalled()
  41. const closed = once(client, 'close')
  42. client.resume()
  43. await closed
  44. })
  45. it('rejects binary, malformed, and duplicate logical-stream messages', async () => {
  46. const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal))
  47. const binary = await connect(entry.url)
  48. const binaryClosed = once(binary, 'close')
  49. binary.send(Buffer.from('{}'))
  50. const binaryEvent = await binaryClosed
  51. expect(binaryEvent[0]).toBe(1003)
  52. const malformed = await connect(entry.url)
  53. const malformedClosed = once(malformed, 'close')
  54. malformed.send('not json')
  55. const malformedEvent = await malformedClosed
  56. expect(malformedEvent[0]).toBe(1008)
  57. expect(String(malformedEvent[1])).toBe('invalid Remote stream request')
  58. const duplicate = await connect(entry.url)
  59. const longId = 'same'.repeat(100)
  60. duplicate.send(openFrame(longId))
  61. duplicate.send(openFrame(longId))
  62. const duplicateEvent = await once(duplicate, 'close')
  63. expect(duplicateEvent[0]).toBe(1008)
  64. expect(String(duplicateEvent[1])).toBe('invalid Remote stream request')
  65. const noInput = await connect(entry.url)
  66. noInput.send(openFrame('no-input'))
  67. noInput.send(JSON.stringify({ type: 'input', streamId: 'no-input', value: 'unexpected' }))
  68. const noInputEvent = await once(noInput, 'close')
  69. expect(noInputEvent[0]).toBe(1008)
  70. expect(String(noInputEvent[1])).toBe('invalid Remote stream request')
  71. })
  72. it('accepts all ws text representations and terminates a carrier error', async () => {
  73. const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal))
  74. const client = await connect(entry.url)
  75. const serverSocket = acceptedSocket(entry.mux)
  76. const cancel = JSON.stringify({ type: 'cancel', streamId: 'absent' })
  77. serverSocket.emit('message', [Buffer.from(cancel)], false)
  78. serverSocket.emit('message', Uint8Array.from(Buffer.from(cancel)).buffer, false)
  79. const closed = once(client, 'close')
  80. serverSocket.emit('error', new Error('fixture carrier failure'))
  81. await closed
  82. })
  83. it('does not send an end frame after clean source cancellation', async () => {
  84. let opened!: () => void
  85. const didOpen = new Promise<void>((resolve) => { opened = resolve })
  86. let returned!: () => void
  87. const didReturn = new Promise<void>((resolve) => { returned = resolve })
  88. const entry = await startMux(async (_endpoint, _payload, signal) => {
  89. opened()
  90. return cleanlyCancelled(signal, returned)
  91. })
  92. const client = await connect(entry.url)
  93. const frames: unknown[] = []
  94. client.on('message', (data) => {
  95. if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame')
  96. frames.push(JSON.parse(data.toString('utf8')) as unknown)
  97. })
  98. client.send(openFrame('cancelled'))
  99. await didOpen
  100. client.send(JSON.stringify({ type: 'cancel', streamId: 'cancelled' }))
  101. await didReturn
  102. await new Promise<void>((resolve) => { setImmediate(resolve) })
  103. expect(frames).toEqual([])
  104. client.close()
  105. await once(client, 'close')
  106. })
  107. it('closes the carrier when ws reports an item write failure', async () => {
  108. let release!: () => void
  109. const released = new Promise<void>((resolve) => { release = resolve })
  110. let opened!: () => void
  111. const didOpen = new Promise<void>((resolve) => { opened = resolve })
  112. const entry = await startMux(async () => delayedItem(released, opened))
  113. const client = await connect(entry.url)
  114. client.send(openFrame('write-failure'))
  115. await didOpen
  116. const serverSocket = acceptedSocket(entry.mux)
  117. const mutable = serverSocket as unknown as {
  118. send(data: unknown, callback: (error?: Error) => void): void
  119. }
  120. mutable.send = (_data, callback): void => {
  121. callback(new Error('fixture ws write failure'))
  122. }
  123. const closed = once(client, 'close')
  124. release()
  125. const closeEvent = await closed
  126. expect(closeEvent[0]).toBe(1011)
  127. expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered')
  128. })
  129. it('contains an item produced after its socket closes', async () => {
  130. let release!: () => void
  131. const released = new Promise<void>((resolve) => { release = resolve })
  132. let opened!: () => void
  133. const didOpen = new Promise<void>((resolve) => { opened = resolve })
  134. let returned!: () => void
  135. const didReturn = new Promise<void>((resolve) => { returned = resolve })
  136. const entry = await startMux(async () => delayedItem(released, opened, returned))
  137. const client = await connect(entry.url)
  138. client.send(openFrame('late-item'))
  139. await didOpen
  140. const serverSocket = acceptedSocket(entry.mux)
  141. client.close()
  142. await once(client, 'close')
  143. await vi.waitFor(() => { expect(serverSocket.readyState).toBe(WebSocket.CLOSED) })
  144. release()
  145. await didReturn
  146. })
  147. it('terminates active sockets on close and reports a repeated close', async () => {
  148. let opened!: () => void
  149. const didOpen = new Promise<void>((resolve) => { opened = resolve })
  150. let returned!: () => void
  151. const didReturn = new Promise<void>((resolve) => { returned = resolve })
  152. const entry = await startMux(async (_endpoint, _payload, signal) => {
  153. opened()
  154. return cleanlyCancelled(signal, returned)
  155. })
  156. const client = await connect(entry.url)
  157. client.send(openFrame('active'))
  158. await didOpen
  159. const closed = once(client, 'close')
  160. await entry.mux.close()
  161. running.delete(entry)
  162. await closed
  163. await didReturn
  164. await expect(entry.mux.close()).rejects.toThrow()
  165. await closeHttp(entry.http)
  166. })
  167. })
  168. const mapFailure: RemoteStreamFailureMapper = error => ({
  169. code: 'internal',
  170. message: error instanceof Error ? error.message : String(error),
  171. details: {},
  172. })
  173. async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 30_000): Promise<RunningMux> {
  174. const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs)
  175. const http = createServer()
  176. http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) })
  177. await new Promise<void>((resolve, reject) => {
  178. http.once('error', reject)
  179. http.listen(0, '127.0.0.1', () => {
  180. http.off('error', reject)
  181. resolve()
  182. })
  183. })
  184. const address = http.address()
  185. if (address === null || typeof address === 'string') throw new Error('fixture HTTP server has no TCP port')
  186. const entry = { http, mux, url: `ws://127.0.0.1:${String(address.port)}` }
  187. running.add(entry)
  188. return entry
  189. }
  190. async function connect(url: string): Promise<WebSocket> {
  191. const socket = new WebSocket(url)
  192. await once(socket, 'open')
  193. return socket
  194. }
  195. function acceptedSocket(mux: RemoteStreamMuxServer): WebSocket {
  196. const exposed = mux as unknown as { server: { clients: Set<WebSocket> } }
  197. const socket = [...exposed.server.clients][0]
  198. if (socket === undefined) throw new Error('fixture mux has no accepted socket')
  199. return socket
  200. }
  201. function openFrame(streamId: string): string {
  202. return JSON.stringify({ type: 'open', streamId, endpoint: 'fixture/follow', payload: {} })
  203. }
  204. async function *waitForAbort(signal: AbortSignal): AsyncIterable<never> {
  205. await new Promise<void>((resolve) => {
  206. if (signal.aborted) resolve()
  207. else signal.addEventListener('abort', () => { resolve() }, { once: true })
  208. })
  209. }
  210. async function *cleanlyCancelled(signal: AbortSignal, returned: () => void): AsyncIterable<never> {
  211. try {
  212. await new Promise<void>((resolve) => {
  213. if (signal.aborted) resolve()
  214. else signal.addEventListener('abort', () => { resolve() }, { once: true })
  215. })
  216. } finally {
  217. returned()
  218. }
  219. }
  220. async function *delayedItem(
  221. released: Promise<void>,
  222. opened: () => void,
  223. returned: () => void = () => {},
  224. ): AsyncIterable<string> {
  225. try {
  226. opened()
  227. await released
  228. yield 'item'
  229. } finally {
  230. returned()
  231. }
  232. }
  233. async function closeHttp(server: Server): Promise<void> {
  234. if (!server.listening) return
  235. await new Promise<void>((resolve, reject) => {
  236. server.close((error) => {
  237. if (error === undefined) resolve()
  238. else reject(error)
  239. })
  240. })
  241. }