| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 |
- import { once } from 'node:events'
- import { PassThrough, Writable } from 'node:stream'
- import { describe, expect, it } from 'vitest'
- import { JsonRpcLineTransport, JsonRpcResponseError } from '../src/index.ts'
- function transportPair() {
- const aToB = new PassThrough()
- const bToA = new PassThrough()
- const a = new JsonRpcLineTransport(bToA, aToB)
- const b = new JsonRpcLineTransport(aToB, bToA)
- return { a, b, aToB, bToA }
- }
- describe('JsonRpcLineTransport', () => {
- it('supports bidirectional requests and notifications over newline-delimited JSON-RPC', async () => {
- const { a, b } = transportPair()
- const notifications: Record<string, unknown>[] = []
- a.onRequest(async (method, params) => {
- expect(method).toBe('echo')
- return { echoed: params }
- })
- b.onNotification((method, params) => {
- notifications.push({ method, params })
- })
- a.start()
- b.start()
- const response = await b.request('echo', { value: 42 })
- expect(response).toEqual({ echoed: { value: 42 } })
- a.notify('session.status', { sessionId: 'main', status: 'idle' })
- a.notify('heartbeat')
- await new Promise(resolve => setTimeout(resolve, 10))
- expect(notifications).toEqual([
- { method: 'session.status', params: { sessionId: 'main', status: 'idle' } },
- { method: 'heartbeat', params: {} },
- ])
- a.close()
- b.close()
- })
- it('reports JSON-RPC request errors from the remote peer with their wire code', async () => {
- const { a, b } = transportPair()
- a.onRequest(async () => {
- throw new Error('handler boom')
- })
- a.start()
- b.start()
- const failure = await b.request('explode', {}).then(
- () => { throw new Error('request unexpectedly succeeded') },
- (error: unknown) => error,
- )
- expect(failure).toBeInstanceOf(JsonRpcResponseError)
- expect(failure).toMatchObject({ message: 'handler boom', code: -32603, data: undefined })
- a.close()
- b.close()
- })
- it('rejects immediately on a pre-aborted signal without registering pending state', async () => {
- const { b } = transportPair()
- b.start()
- const controller = new AbortController()
- controller.abort(new Error('already gone'))
- await expect(b.request('never-sent', {}, controller.signal)).rejects.toThrow('already gone')
- expect((b as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0)
- b.close()
- })
- it('abandons a pending request on abort, stringifying a non-Error reason', async () => {
- const { b } = transportPair()
- b.start()
- const controller = new AbortController()
- const pending = b.request('never-answered', {}, controller.signal)
- controller.abort('plain-string-reason')
- await expect(pending).rejects.toThrow('JSON-RPC request aborted: plain-string-reason')
- // The abandonment removed the pending entry — nothing is retained for a
- // response that may never come.
- expect((b as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0)
- b.close()
- })
- it('preserves structured error data from an error response frame', async () => {
- const { aToB, bToA, b } = transportPair()
- b.start()
- const pending = b.request('remote-error-data', {})
- const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string
- const request = JSON.parse(String(requestChunk)) as { id: string }
- aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: 7, message: 'structured', data: { detail: 'x' } } })}\n`)
- const failure = await pending.then(
- () => { throw new Error('request unexpectedly succeeded') },
- (error: unknown) => error,
- )
- expect(failure).toBeInstanceOf(JsonRpcResponseError)
- expect(failure).toMatchObject({ code: 7, message: 'structured', data: { detail: 'x' } })
- b.close()
- })
- it('stringifies non-Error request handler failures', async () => {
- const { a, b } = transportPair()
- a.onRequest(async () => {
- throw 'string boom'
- })
- a.start()
- b.start()
- await expect(b.request('explode-string', {})).rejects.toThrow('string boom')
- a.close()
- b.close()
- })
- it('reports method-not-found when no request handler is installed', async () => {
- const { a, b } = transportPair()
- a.start()
- b.start()
- await expect(b.request('missing', {})).rejects.toThrow('method not found: missing')
- a.close()
- b.close()
- })
- it('normalizes non-object request params and ignores notifications without a handler', async () => {
- const { aToB, bToA, b } = transportPair()
- const seen: Record<string, unknown>[] = []
- b.onRequest(async (method, params) => {
- seen.push({ method, params })
- return { ok: true }
- })
- b.start()
- aToB.write('{"jsonrpc":"2.0","method":"ignored"}\n')
- aToB.write('{"jsonrpc":"2.0","id":7,"method":"array-params","params":[]}\n')
- const chunk = (await once(bToA, 'data'))[0] as Buffer | string
- expect(seen).toEqual([{ method: 'array-params', params: {} }])
- expect(JSON.parse(String(chunk))).toEqual({ jsonrpc: '2.0', id: 7, result: { ok: true } })
- b.close()
- })
- it('ignores malformed frames and accepts notifications without params', async () => {
- const { aToB, b } = transportPair()
- const notifications: Record<string, unknown>[] = []
- b.onNotification((method, params) => {
- notifications.push({ method, params })
- })
- b.start()
- b.start()
- aToB.write('not json\n')
- aToB.write('\n')
- aToB.write('null\n')
- aToB.write('{"jsonrpc":"2.0","params":{}}\n')
- aToB.write('{"jsonrpc":"2.0","method":"tick"}\n')
- aToB.emit('data', '{"jsonrpc":"2.0","method":"string-chunk"}\n')
- await new Promise(resolve => setTimeout(resolve, 10))
- expect(notifications).toEqual([
- { method: 'tick', params: {} },
- { method: 'string-chunk', params: {} },
- ])
- b.close()
- })
- it('preserves multibyte UTF-8 characters split across Buffer chunks', async () => {
- const input = new PassThrough()
- const output = new PassThrough()
- const transport = new JsonRpcLineTransport(input, output)
- const notifications: Record<string, unknown>[] = []
- transport.onNotification((method, params) => { notifications.push({ method, params }) })
- transport.start()
- const frame = Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', method: 'message', params: { text: '你好' } })}\n`)
- const character = Buffer.from('你')
- const characterStart = frame.indexOf(character)
- expect(characterStart).toBeGreaterThanOrEqual(0)
- input.write(frame.subarray(0, characterStart + 1))
- input.write(frame.subarray(characterStart + 1))
- await new Promise(resolve => setTimeout(resolve, 10))
- expect(notifications).toEqual([{ method: 'message', params: { text: '你好' } }])
- transport.close()
- })
- it('flush waits for all earlier output writes', async () => {
- const events: string[] = []
- const output = new Writable({
- write(chunk: Buffer, _encoding, callback) {
- const label = chunk.length === 0 ? 'barrier' : 'frame'
- events.push(`start:${label}`)
- setTimeout(() => {
- events.push(`finish:${label}`)
- callback()
- }, 5)
- },
- })
- const transport = new JsonRpcLineTransport(new PassThrough(), output)
- transport.notify('tick')
- await transport.flush()
- expect(events).toEqual([
- 'start:frame',
- 'finish:frame',
- 'start:barrier',
- 'finish:barrier',
- ])
- transport.close()
- })
- it('reports an output callback failure from flush', async () => {
- const output = {
- write(_chunk: string, callback?: (error?: Error) => void) {
- callback?.(new Error('flush failed'))
- return true
- },
- }
- const transport = new JsonRpcLineTransport(new PassThrough(), output as never)
- await expect(transport.flush()).rejects.toThrow('flush failed')
- })
- it('rejects pending requests when the input closes', async () => {
- const { aToB, b } = transportPair()
- b.start()
- const pending = b.request('never-replies', {})
- aToB.end()
- await expect(pending).rejects.toThrow('JSON-RPC input closed')
- b.close()
- })
- it('rejects pending requests when the input errors', async () => {
- const { aToB, b } = transportPair()
- b.start()
- const pending = b.request('never-replies', {})
- aToB.emit('error', new Error('input broke'))
- await expect(pending).rejects.toThrow('input broke')
- b.close()
- })
- it('rejects pending requests when the transport closes', async () => {
- const { b } = transportPair()
- const pending = b.request('never-replies', {})
- b.close()
- await expect(pending).rejects.toThrow('JSON-RPC transport closed')
- })
- it('rejects a request when writing the frame throws', async () => {
- const input = new PassThrough()
- const output = {
- write() {
- throw new Error('write exploded')
- },
- }
- const transport = new JsonRpcLineTransport(input, output as never)
- await expect(transport.request('write-fails', {})).rejects.toThrow('write exploded')
- })
- it('stringifies non-Error write failures', async () => {
- const input = new PassThrough()
- const output = {
- write() {
- throw 'write string'
- },
- }
- const transport = new JsonRpcLineTransport(input, output as never)
- await expect(transport.request('write-fails', {})).rejects.toThrow('write string')
- })
- it('uses a fallback message for malformed JSON-RPC error responses', async () => {
- const { aToB, bToA, b } = transportPair()
- b.start()
- const pending = b.request('remote-error', {})
- const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string
- const request = JSON.parse(String(requestChunk)) as { id: string }
- aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: {} })}\n`)
- await expect(pending).rejects.toThrow('JSON-RPC error')
- b.close()
- })
- it('ignores responses that do not match a pending request', async () => {
- const { aToB, b } = transportPair()
- b.start()
- aToB.write('{"jsonrpc":"2.0","id":"unknown","result":{"ignored":true}}\n')
- await new Promise(resolve => setTimeout(resolve, 10))
- b.close()
- })
- })
|