transport.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import { once } from 'node:events'
  2. import { PassThrough, Writable } from 'node:stream'
  3. import { describe, expect, it } from 'vitest'
  4. import { JsonRpcLineTransport, JsonRpcResponseError } from '../src/index.ts'
  5. function transportPair() {
  6. const aToB = new PassThrough()
  7. const bToA = new PassThrough()
  8. const a = new JsonRpcLineTransport(bToA, aToB)
  9. const b = new JsonRpcLineTransport(aToB, bToA)
  10. return { a, b, aToB, bToA }
  11. }
  12. describe('JsonRpcLineTransport', () => {
  13. it('supports bidirectional requests and notifications over newline-delimited JSON-RPC', async () => {
  14. const { a, b } = transportPair()
  15. const notifications: Record<string, unknown>[] = []
  16. a.onRequest(async (method, params) => {
  17. expect(method).toBe('echo')
  18. return { echoed: params }
  19. })
  20. b.onNotification((method, params) => {
  21. notifications.push({ method, params })
  22. })
  23. a.start()
  24. b.start()
  25. const response = await b.request('echo', { value: 42 })
  26. expect(response).toEqual({ echoed: { value: 42 } })
  27. a.notify('session.status', { sessionId: 'main', status: 'idle' })
  28. a.notify('heartbeat')
  29. await new Promise(resolve => setTimeout(resolve, 10))
  30. expect(notifications).toEqual([
  31. { method: 'session.status', params: { sessionId: 'main', status: 'idle' } },
  32. { method: 'heartbeat', params: {} },
  33. ])
  34. a.close()
  35. b.close()
  36. })
  37. it('reports JSON-RPC request errors from the remote peer with their wire code', async () => {
  38. const { a, b } = transportPair()
  39. a.onRequest(async () => {
  40. throw new Error('handler boom')
  41. })
  42. a.start()
  43. b.start()
  44. const failure = await b.request('explode', {}).then(
  45. () => { throw new Error('request unexpectedly succeeded') },
  46. (error: unknown) => error,
  47. )
  48. expect(failure).toBeInstanceOf(JsonRpcResponseError)
  49. expect(failure).toMatchObject({ message: 'handler boom', code: -32603, data: undefined })
  50. a.close()
  51. b.close()
  52. })
  53. it('rejects immediately on a pre-aborted signal without registering pending state', async () => {
  54. const { b } = transportPair()
  55. b.start()
  56. const controller = new AbortController()
  57. controller.abort(new Error('already gone'))
  58. await expect(b.request('never-sent', {}, controller.signal)).rejects.toThrow('already gone')
  59. expect((b as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0)
  60. b.close()
  61. })
  62. it('abandons a pending request on abort, stringifying a non-Error reason', async () => {
  63. const { b } = transportPair()
  64. b.start()
  65. const controller = new AbortController()
  66. const pending = b.request('never-answered', {}, controller.signal)
  67. controller.abort('plain-string-reason')
  68. await expect(pending).rejects.toThrow('JSON-RPC request aborted: plain-string-reason')
  69. // The abandonment removed the pending entry — nothing is retained for a
  70. // response that may never come.
  71. expect((b as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0)
  72. b.close()
  73. })
  74. it('preserves structured error data from an error response frame', async () => {
  75. const { aToB, bToA, b } = transportPair()
  76. b.start()
  77. const pending = b.request('remote-error-data', {})
  78. const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string
  79. const request = JSON.parse(String(requestChunk)) as { id: string }
  80. aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: 7, message: 'structured', data: { detail: 'x' } } })}\n`)
  81. const failure = await pending.then(
  82. () => { throw new Error('request unexpectedly succeeded') },
  83. (error: unknown) => error,
  84. )
  85. expect(failure).toBeInstanceOf(JsonRpcResponseError)
  86. expect(failure).toMatchObject({ code: 7, message: 'structured', data: { detail: 'x' } })
  87. b.close()
  88. })
  89. it('stringifies non-Error request handler failures', async () => {
  90. const { a, b } = transportPair()
  91. a.onRequest(async () => {
  92. throw 'string boom'
  93. })
  94. a.start()
  95. b.start()
  96. await expect(b.request('explode-string', {})).rejects.toThrow('string boom')
  97. a.close()
  98. b.close()
  99. })
  100. it('reports method-not-found when no request handler is installed', async () => {
  101. const { a, b } = transportPair()
  102. a.start()
  103. b.start()
  104. await expect(b.request('missing', {})).rejects.toThrow('method not found: missing')
  105. a.close()
  106. b.close()
  107. })
  108. it('normalizes non-object request params and ignores notifications without a handler', async () => {
  109. const { aToB, bToA, b } = transportPair()
  110. const seen: Record<string, unknown>[] = []
  111. b.onRequest(async (method, params) => {
  112. seen.push({ method, params })
  113. return { ok: true }
  114. })
  115. b.start()
  116. aToB.write('{"jsonrpc":"2.0","method":"ignored"}\n')
  117. aToB.write('{"jsonrpc":"2.0","id":7,"method":"array-params","params":[]}\n')
  118. const chunk = (await once(bToA, 'data'))[0] as Buffer | string
  119. expect(seen).toEqual([{ method: 'array-params', params: {} }])
  120. expect(JSON.parse(String(chunk))).toEqual({ jsonrpc: '2.0', id: 7, result: { ok: true } })
  121. b.close()
  122. })
  123. it('ignores malformed frames and accepts notifications without params', async () => {
  124. const { aToB, b } = transportPair()
  125. const notifications: Record<string, unknown>[] = []
  126. b.onNotification((method, params) => {
  127. notifications.push({ method, params })
  128. })
  129. b.start()
  130. b.start()
  131. aToB.write('not json\n')
  132. aToB.write('\n')
  133. aToB.write('null\n')
  134. aToB.write('{"jsonrpc":"2.0","params":{}}\n')
  135. aToB.write('{"jsonrpc":"2.0","method":"tick"}\n')
  136. aToB.emit('data', '{"jsonrpc":"2.0","method":"string-chunk"}\n')
  137. await new Promise(resolve => setTimeout(resolve, 10))
  138. expect(notifications).toEqual([
  139. { method: 'tick', params: {} },
  140. { method: 'string-chunk', params: {} },
  141. ])
  142. b.close()
  143. })
  144. it('preserves multibyte UTF-8 characters split across Buffer chunks', async () => {
  145. const input = new PassThrough()
  146. const output = new PassThrough()
  147. const transport = new JsonRpcLineTransport(input, output)
  148. const notifications: Record<string, unknown>[] = []
  149. transport.onNotification((method, params) => { notifications.push({ method, params }) })
  150. transport.start()
  151. const frame = Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', method: 'message', params: { text: '你好' } })}\n`)
  152. const character = Buffer.from('你')
  153. const characterStart = frame.indexOf(character)
  154. expect(characterStart).toBeGreaterThanOrEqual(0)
  155. input.write(frame.subarray(0, characterStart + 1))
  156. input.write(frame.subarray(characterStart + 1))
  157. await new Promise(resolve => setTimeout(resolve, 10))
  158. expect(notifications).toEqual([{ method: 'message', params: { text: '你好' } }])
  159. transport.close()
  160. })
  161. it('flush waits for all earlier output writes', async () => {
  162. const events: string[] = []
  163. const output = new Writable({
  164. write(chunk: Buffer, _encoding, callback) {
  165. const label = chunk.length === 0 ? 'barrier' : 'frame'
  166. events.push(`start:${label}`)
  167. setTimeout(() => {
  168. events.push(`finish:${label}`)
  169. callback()
  170. }, 5)
  171. },
  172. })
  173. const transport = new JsonRpcLineTransport(new PassThrough(), output)
  174. transport.notify('tick')
  175. await transport.flush()
  176. expect(events).toEqual([
  177. 'start:frame',
  178. 'finish:frame',
  179. 'start:barrier',
  180. 'finish:barrier',
  181. ])
  182. transport.close()
  183. })
  184. it('reports an output callback failure from flush', async () => {
  185. const output = {
  186. write(_chunk: string, callback?: (error?: Error) => void) {
  187. callback?.(new Error('flush failed'))
  188. return true
  189. },
  190. }
  191. const transport = new JsonRpcLineTransport(new PassThrough(), output as never)
  192. await expect(transport.flush()).rejects.toThrow('flush failed')
  193. })
  194. it('rejects pending requests when the input closes', async () => {
  195. const { aToB, b } = transportPair()
  196. b.start()
  197. const pending = b.request('never-replies', {})
  198. aToB.end()
  199. await expect(pending).rejects.toThrow('JSON-RPC input closed')
  200. b.close()
  201. })
  202. it('rejects pending requests when the input errors', async () => {
  203. const { aToB, b } = transportPair()
  204. b.start()
  205. const pending = b.request('never-replies', {})
  206. aToB.emit('error', new Error('input broke'))
  207. await expect(pending).rejects.toThrow('input broke')
  208. b.close()
  209. })
  210. it('rejects pending requests when the transport closes', async () => {
  211. const { b } = transportPair()
  212. const pending = b.request('never-replies', {})
  213. b.close()
  214. await expect(pending).rejects.toThrow('JSON-RPC transport closed')
  215. })
  216. it('rejects a request when writing the frame throws', async () => {
  217. const input = new PassThrough()
  218. const output = {
  219. write() {
  220. throw new Error('write exploded')
  221. },
  222. }
  223. const transport = new JsonRpcLineTransport(input, output as never)
  224. await expect(transport.request('write-fails', {})).rejects.toThrow('write exploded')
  225. })
  226. it('stringifies non-Error write failures', async () => {
  227. const input = new PassThrough()
  228. const output = {
  229. write() {
  230. throw 'write string'
  231. },
  232. }
  233. const transport = new JsonRpcLineTransport(input, output as never)
  234. await expect(transport.request('write-fails', {})).rejects.toThrow('write string')
  235. })
  236. it('uses a fallback message for malformed JSON-RPC error responses', async () => {
  237. const { aToB, bToA, b } = transportPair()
  238. b.start()
  239. const pending = b.request('remote-error', {})
  240. const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string
  241. const request = JSON.parse(String(requestChunk)) as { id: string }
  242. aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: {} })}\n`)
  243. await expect(pending).rejects.toThrow('JSON-RPC error')
  244. b.close()
  245. })
  246. it('ignores responses that do not match a pending request', async () => {
  247. const { aToB, b } = transportPair()
  248. b.start()
  249. aToB.write('{"jsonrpc":"2.0","id":"unknown","result":{"ignored":true}}\n')
  250. await new Promise(resolve => setTimeout(resolve, 10))
  251. b.close()
  252. })
  253. })