diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e73d741 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + deno: + - v1.x + - canary + os: + - ubuntu-22.04 + - windows-2022 + - macOS-12 + + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + submodules: true + + - name: Set up Deno + uses: denoland/setup-deno@v1 + with: + deno-version: ${{ matrix.deno }} + + - name: Run tests canary + run: deno task test + + - name: Generate lcov + run: deno task cov:gen + + - name: Upload coverage + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + name: ${{ matrix.os }}-${{ matrix.deno }} + files: cov.lcov + + lint: + runs-on: ubuntu-22.04 + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + submodules: false + persist-credentials: false + + - name: Set up Deno + uses: denoland/setup-deno@v1 + with: + deno-version: canary + + - name: Format + run: deno fmt --check + + - name: Lint + run: deno task lint diff --git a/.gitignore b/.gitignore index 77f12ae..06eb9de 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ docs/ +coverage/ +cov.lcov diff --git a/README.md b/README.md index 3f434c8..350f611 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # json-rpc-ts [![deno.land/x](https://shield.deno.dev/x/json_rpc_ts)](https://deno.land/x/json_rpc_ts) +[![ci](https://github.com/yieldray/json-rpc-ts/actions/workflows/ci.yml/badge.svg)](https://github.com/yieldray/json-rpc-ts/actions/workflows/ci.yml) -A strictly typed json-rpc(2.0) implemention, zero dependency, minimal abstraction, with simple api +A strictly typed json-rpc(2.0) implementation, zero dependency, minimal abstraction, with simple api > Specification @@ -14,19 +15,29 @@ const methodSet = { minus: ([a, b]: [number, number]) => a - b, } +// initialize all methods with the constructor const server = new JSONRPCServer(methodSet) +// or add methods manually +const server = new JSONRPCServer() +server.setMethod('upper', methodSet.upper) +server.setMethod('lower', methodSet.lower) +server.setMethod('plus', methodSet.plus) +server.setMethod('minus', methodSet.minus) + +// (optional) provide a generic parameter to enable ts check const client = new JSONRPCClient((json) => server.process(json) ) +// request, Notification and batch are always async assertEquals(await client.request('upper', 'hello'), 'HELLO') assertEquals( await client.batch( client.createRequest('upper', 'nihao'), - // notifaction does not have response, even when response errors - client.createNotifaction('upper'), + // Notification does not have response, even when response errors + client.createNotification('upper'), client.createRequest('upper', 'shijie'), client.createRequest('plus', [1, 1]), ), @@ -85,3 +96,13 @@ const httpServer = Deno.serve( }, ) ``` + +# build for JavaScript + +To use this library without typescript, you have to build it to javascript. + +```sh +git clone https://github.com/YieldRay/json-rpc-ts.git +cd json-rpc-ts +esbuild --bundle src/index.ts --outdir=dist --format=esm +``` diff --git a/deno.json b/deno.json index 6b380b5..92aa364 100644 --- a/deno.json +++ b/deno.json @@ -1,9 +1,20 @@ { + "compilerOptions": { + "strict": true, + "useUnknownInCatchVariables": true, + "noImplicitOverride": true + }, "imports": { "std/": "https://deno.land/std@0.209.0/" }, "tasks": { - "docs": "deno doc --html --name=json-rpc-ts --output=./docs/ ./src/index.ts" + "lint": "deno lint", + "fmt": "deno fmt", + "docs": "deno doc --html --name=json-rpc-ts --output=./docs/ ./mod.ts", + "test": "deno test --parallel --coverage --trace-ops", + "cov:gen": "deno coverage coverage --lcov --output=cov.lcov", + "cov:view": "deno coverage --html coverage", + "cov:clean": "rm -rf ./coverage/ cov.lcov" }, "fmt": { "lineWidth": 80, @@ -11,7 +22,17 @@ "indentWidth": 4, "singleQuote": true, "proseWrap": "preserve", - "include": ["*"], - "exclude": [] - } + "include": [ + "src", + ".github", + "deno.json", + "README.md", + "mod.ts" + ] + }, + "exclude": [ + ".git", + "docs", + "coverage" + ] } diff --git a/mod.ts b/mod.ts index 6042c3b..92d4272 100644 --- a/mod.ts +++ b/mod.ts @@ -1 +1 @@ -export * from "./src/index.ts" \ No newline at end of file +export * from './src/index.ts' diff --git a/src/client.test.ts b/src/client.test.ts new file mode 100644 index 0000000..a40158d --- /dev/null +++ b/src/client.test.ts @@ -0,0 +1,262 @@ +import { + assertEquals, + assertInstanceOf, + assertObjectMatch, +} from 'std/assert/mod.ts' +import { JSONRPCClient, JSONRPCClientParseError } from './client.ts' +import { JSONRPCFulfilledResult } from './types.ts' +import { JSONRPCRequest } from './dto/request.ts' +import { JSONRPCErrorResponse, JSONRPCSuccessResponse } from './dto/response.ts' +import { JSONRPCError } from './dto/errors.ts' + +Deno.test('client', async () => { + const client = new JSONRPCClient((json) => + new JSONRPCSuccessResponse({ + id: JSON.parse(json).id, + result: 'bar', + }).toString() + ) + assertEquals(await client.request('foo'), 'bar') + assertEquals(await client.notify('foo'), undefined) +}) + +Deno.test('client/batch', async () => { + let client: JSONRPCClient + + client = new JSONRPCClient((json) => + JSON.stringify( + JSON.parse(json).map(({ id }: JSONRPCRequest) => + new JSONRPCSuccessResponse({ id, result: 'bar' }) + ), + ) + ) + + assertEquals(await client.batch(client.createRequest('foo')), [{ + status: 'fulfilled', + value: 'bar', + }]) + + assertEquals( + await client.batch( + client.createRequest('foo'), + ), + [{ + status: 'fulfilled', + value: 'bar', + }], + ) + + assertEquals( + await client.batch( + client.createRequest('foo1'), + client.createRequest('foo2'), + client.createRequest('foo3'), + ), + [{ + status: 'fulfilled', + value: 'bar', + }, { + status: 'fulfilled', + value: 'bar', + }, { + status: 'fulfilled', + value: 'bar', + }], + ) + + assertEquals( + await client.batch( + client.createNotification('foo'), + ), + [], + ) + + assertEquals( + await client.batch( + client.createNotification('foo1'), + client.createNotification('foo2'), + client.createNotification('foo3'), + ), + [], + ) + + client = new JSONRPCClient(() => + `${new JSONRPCErrorResponse({ + id: null, + error: { + code: 666, + message: '666', + }, + })}` + ) + + assertInstanceOf( + await client.batch( + client.createRequest('foo1'), + client.createRequest('foo2'), + client.createRequest('foo3'), + ).catch((e) => e), + JSONRPCError, + ) + + client = new JSONRPCClient((json) => + JSON.stringify( + JSON.parse(json).map(() => + new JSONRPCErrorResponse({ + id: null, + error: { + code: 666, + message: '666', + }, + }) + ), + ) + ) + + assertEquals( + await client.batch( + client.createRequest('foo1'), + client.createRequest('foo2'), + client.createRequest('foo3'), + ), + [{ + status: 'rejected', + reason: { + code: 666, + message: '666', + }, + }, { + status: 'rejected', + reason: { + code: 666, + message: '666', + }, + }, { + status: 'rejected', + reason: { + code: 666, + message: '666', + }, + }], + ) +}) + +Deno.test('client/batch', async () => { +}) + +Deno.test('client/JSONRPCClientParseError', async () => { + let client: JSONRPCClient + + client = new JSONRPCClient(() => `malformed json`) + + assertInstanceOf( + await client.request('foo').catch(( + e, + ) => e), + JSONRPCClientParseError, + ) + + assertInstanceOf( + await client.batch(client.createRequest('foo')).catch(( + e, + ) => e), + JSONRPCClientParseError, + ) + + assertInstanceOf( + await new JSONRPCClient(() => `{"incorrect": "response object"}`) + .request('foo').catch(( + e, + ) => e), + JSONRPCClientParseError, + ) + + assertInstanceOf( + await new JSONRPCClient(() => + `${new JSONRPCSuccessResponse({ + id: Math.random.toString().slice(2), + result: 6, + })}` + ) + .request('foo').catch(( + e, + ) => e), + JSONRPCClientParseError, + ) + + client = new JSONRPCClient(() => + JSON.stringify(new Array(10).fill( + new JSONRPCSuccessResponse({ + id: Math.random.toString().slice(2), + result: 'bar', + }), + )) + ) + + assertInstanceOf( + await client.batch( + client.createRequest('foo'), + ).catch((e) => e), + JSONRPCClientParseError, + ) + + assertInstanceOf( + await client.batch( + client.createRequest('foo1'), + client.createRequest('foo2'), + client.createRequest('foo3'), + ).catch((e) => e), + JSONRPCClientParseError, + ) + + client = new JSONRPCClient((json) => + JSON.stringify( + JSON.parse(json).map(() => ({ 'malformed': 'response' })), + ) + ) + + assertInstanceOf( + await client.batch( + client.createRequest('foo1'), + client.createRequest('foo2'), + client.createRequest('foo3'), + ).catch((e) => e), + JSONRPCClientParseError, + ) +}) + +Deno.test({ + name: 'client/aria2', + // also need to run `aria2c --enable-rpc` + ignore: Deno.permissions.querySync({ name: 'net', host: 'localhost:6800' }) + .state !== + 'granted', + fn: async () => { + const ok = await fetch('http://localhost:6800/jsonrpc', { + signal: AbortSignal.timeout(500), + }).then((res) => res.ok).catch(() => false) + if (!ok) { + // skip when no aria2c jsonrpc is running + return + } + + const client = new JSONRPCClient((json) => + fetch('http://localhost:6800/jsonrpc', { + method: 'POST', + body: json, + }).then((res) => res.text()) + ) + + assertInstanceOf(await client.request('system.listMethods'), Array) + + assertEquals(await client.notify('system.listMethods'), undefined) + + const [r1, r2] = await client.batch( + client.createRequest('system.listMethods'), + client.createRequest('system.listMethods'), + ) as JSONRPCFulfilledResult[] + + assertObjectMatch(r1, { status: 'fulfilled' }) + assertObjectMatch(r2, { status: 'fulfilled' }) + }, +}) diff --git a/src/client.ts b/src/client.ts index 606abe3..df167ea 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,6 +2,7 @@ import type { JSONRPCMethodSet, JSONRPCSettledResult } from './types.ts' import { JSONRPCNotification, JSONRPCRequest } from './dto/request.ts' import { JSONRPCErrorResponse, JSONRPCSuccessResponse } from './dto/response.ts' import { isJSONRPCResponse, JSONRPCResponse } from './dto/response.ts' +import { JSONRPCError } from './dto/errors.ts' import { getIDFromGenerator, type IDGenerator, @@ -17,7 +18,7 @@ type JSONRPCAnyRequest = * The client cannot parse the server response */ export class JSONRPCClientParseError extends Error { - name = 'JSONRPCClientParseError' + override name = 'JSONRPCClientParseError' request: JSONRPCAnyRequest constructor(message: string, request: JSONRPCAnyRequest) { super(message) @@ -51,7 +52,9 @@ function parseJSON( * The constructor optionally accept a customized id generator, otherwise it use a * self added number */ -export class JSONRPCClient { +export class JSONRPCClient< + MethodSet extends JSONRPCMethodSet = JSONRPCMethodSet, +> { /** * MUST be an infinite iterator */ @@ -59,10 +62,10 @@ export class JSONRPCClient { /** * The extern function to request the server for response */ - private processor: (input: string) => Promise + private processor: (input: string) => string | Promise constructor( - processor: (input: string) => Promise, + processor: (input: string) => string | Promise, idGenerator?: IDGenerator, ) { this.processor = processor @@ -82,7 +85,7 @@ export class JSONRPCClient { return request } - public createNotifaction( + public createNotification( method: T extends string ? T : never, params?: Parameters[0], ): JSONRPCNotification { @@ -101,7 +104,7 @@ export class JSONRPCClient { params?: Parameters[0], ): Promise> { const request = this.createRequest(method, params) - // responsed json string + // responded json string const jsonString = await this.processor(JSON.stringify(request)) const jsonValue = parseJSON(jsonString, request) @@ -118,6 +121,14 @@ export class JSONRPCClient { if ('error' in response) { throw response.error } else { + if (request.id !== response.id) { + // according the spec, response id MUST as same as the request id + throw new JSONRPCClientParseError( + `The server sent an valid response but id is not matched`, + request, + ) + } + // response.result is now JSONRPCValue return response.result as ReturnType } @@ -131,12 +142,12 @@ export class JSONRPCClient { method: T extends string ? T : never, params?: Parameters[0], ): Promise { - const notification = this.createNotifaction(method, params) + const notification = this.createNotification(method, params) await this.processor(JSON.stringify(notification)) } /** - * You should use the `createRequest()` or `createNotifaction()` method to + * You should use the `createRequest()` or `createNotification()` method to * create the requests array. Response order is always matched by id. * * Throws `JSONRPCClientParseError` if server response cannot be parsed, @@ -156,15 +167,18 @@ export class JSONRPCClient { * }, * ] * ``` + * @throws `JSONRPCError` - when server return single JSONRPCErrorResponse + * @throws `JSONRPCClientParseError` - when server response cannot be parsed */ async batch( ...requests: Array - ): Promise { - // responsed json string + ): Promise { + // responded json string const jsonString = await this.processor(JSON.stringify(requests)) const requestCount = requests.filter((r) => 'id' in r).length if (requestCount === 0) { // all the requests are notification + // note that the server should return nothing, so we ignore any response return [] } // parsed response @@ -172,12 +186,9 @@ export class JSONRPCClient { if (!Array.isArray(jsonValue)) { if (isJSONRPCResponse(jsonValue) && 'error' in jsonValue) { - // If the batch rpc call itself fails to be recognized as an valid JSON or as an Array with at least one value, + // if the batch rpc call itself fails to be recognized as an valid JSON or as an Array with at least one value, // the response from the Server MUST be a single Response object. - return { - status: 'rejected', - reason: jsonValue.error, - } + throw new JSONRPCError(jsonValue.error) } // requests contains request, so response must be an array @@ -225,7 +236,7 @@ export class JSONRPCClient { (response) => 'error' in response && response.id === null, ) - // this implemention expect that all the JSONRPCErrorResponse are ordered + // this implementation expect that all the JSONRPCErrorResponse are ordered responses.push({ status: 'rejected', reason: (unorderedResponses[ diff --git a/src/dto/errors.test.ts b/src/dto/errors.test.ts index ce6ff0f..3d809fa 100644 --- a/src/dto/errors.test.ts +++ b/src/dto/errors.test.ts @@ -1,5 +1,21 @@ -import { assertEquals } from 'std/assert/mod.ts' -import { isJSONRPCError } from './errors.ts' +import { assertEquals, assertInstanceOf } from 'std/assert/mod.ts' +import { + isJSONRPCError, + JSONRPCError, + JSONRPCInternalError, + JSONRPCInvalidParamsError, + JSONRPCInvalidRequestError, + JSONRPCMethodNotFoundError, + JSONRPCParseError, +} from './errors.ts' + +Deno.test('error', () => { + assertInstanceOf(new JSONRPCParseError(), JSONRPCError) + assertInstanceOf(new JSONRPCInvalidRequestError(), JSONRPCError) + assertInstanceOf(new JSONRPCMethodNotFoundError(), JSONRPCError) + assertInstanceOf(new JSONRPCInvalidParamsError(), JSONRPCError) + assertInstanceOf(new JSONRPCInternalError(), JSONRPCError) +}) Deno.test('isJSONRPCError', () => { assertEquals( diff --git a/src/dto/errors.ts b/src/dto/errors.ts index 2536c0c..15b81bf 100644 --- a/src/dto/errors.ts +++ b/src/dto/errors.ts @@ -24,7 +24,7 @@ export class JSONRPCError extends Error implements JSONRPCErrorInterface { * The error codes from and including -32768 to -32000 are reserved for pre-defined errors. Any code within this range, but not defined explicitly below is reserved for future use. The error codes are nearly the same as those suggested for XML-RPC at the following url: http://xmlrpc-epi.sourceforge.net/specs/rfc.fault_codes.php */ public code: number - public message: string + public override message: string public data?: JSONRPCValue public constructor(object: JSONRPCErrorInterface) { diff --git a/src/dto/request.test.ts b/src/dto/request.test.ts index 496047e..384c39d 100644 --- a/src/dto/request.test.ts +++ b/src/dto/request.test.ts @@ -1,5 +1,26 @@ import { assertEquals } from 'std/assert/mod.ts' -import { isJSONRPCRequest } from './request.ts' +import { + isJSONRPCRequest, + JSONRPCNotification, + JSONRPCRequest, +} from './request.ts' + +Deno.test('request', () => { + assertEquals( + new JSONRPCNotification({ + method: 'foo', + }).toString(), + /*json*/ `{"jsonrpc":"2.0","method":"foo"}`, + ) + + assertEquals( + new JSONRPCRequest({ + id: 1, + method: 'foo', + }).toString(), + /*json*/ `{"jsonrpc":"2.0","method":"foo","id":1}`, + ) +}) Deno.test('isJSONRPCRequest', () => { assertEquals( @@ -27,4 +48,9 @@ Deno.test('isJSONRPCRequest', () => { }), false, ) + + assertEquals( + isJSONRPCRequest(null), + false, + ) }) diff --git a/src/dto/request.ts b/src/dto/request.ts index 1affe44..712ba59 100644 --- a/src/dto/request.ts +++ b/src/dto/request.ts @@ -49,7 +49,7 @@ export class JSONRPCRequest extends JSONRPCNotification { this.id = object.id } - public toString() { + public override toString() { return JSON.stringify({ jsonrpc: this.jsonrpc, method: this.method, diff --git a/src/dto/response.test.ts b/src/dto/response.test.ts index aef345c..4601c67 100644 --- a/src/dto/response.test.ts +++ b/src/dto/response.test.ts @@ -28,4 +28,9 @@ Deno.test('isJSONRPCResponse', () => { }), false, ) + + assertEquals( + isJSONRPCResponse(null), + false, + ) }) diff --git a/src/dto/response.ts b/src/dto/response.ts index a05ba4f..cc1c165 100644 --- a/src/dto/response.ts +++ b/src/dto/response.ts @@ -45,7 +45,11 @@ export class JSONRPCErrorResponse { return JSON.stringify({ jsonrpc: this.jsonrpc, id: this.id, - error: this.error, + error: { + code: this.error.code, + data: this.error.data, + message: this.error.message, + }, }) } } diff --git a/src/id.test.ts b/src/id.test.ts index d21fe37..993f0b7 100644 --- a/src/id.test.ts +++ b/src/id.test.ts @@ -1,13 +1,23 @@ import { assertEquals } from 'std/assert/mod.ts' -import { isJSONRPCID } from './id.ts' +import { getIDFromGenerator, isJSONRPCID } from './id.ts' Deno.test('assertEquals', () => { assertEquals(isJSONRPCID(null), true) assertEquals(isJSONRPCID(111), true) assertEquals(isJSONRPCID('foo'), true) - assertEquals(isJSONRPCID(false), false) assertEquals(isJSONRPCID(undefined), false) assertEquals(isJSONRPCID([]), false) assertEquals(isJSONRPCID({}), false) + + const gen = (function* () { + for (;;) { + yield 6 + } + })() + assertEquals(getIDFromGenerator(gen), 6) + assertEquals(getIDFromGenerator(gen), 6) + assertEquals(getIDFromGenerator(gen), 6) + + assertEquals(getIDFromGenerator(() => 6), 6) }) diff --git a/src/index.test.ts b/src/index.test.ts index 19a6763..112d029 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,13 +1,5 @@ -import { - assertEquals, - assertInstanceOf, - assertObjectMatch, -} from 'std/assert/mod.ts' -import { - JSONRPCClient, - JSONRPCFulfilledResult, - JSONRPCServer, -} from './index.ts' +import { assertEquals, assertObjectMatch } from 'std/assert/mod.ts' +import { JSONRPCClient, JSONRPCServer } from './index.ts' Deno.test('JSONRPCClient/JSONRPCServer', async () => { const methodSet = { @@ -24,12 +16,12 @@ Deno.test('JSONRPCClient/JSONRPCServer', async () => { ) assertEquals(await client.request('upper', 'hello'), 'HELLO') - assertEquals(await client.request('lower', 'WORLD'), 'world') assertObjectMatch( await client.request( 'plus', + // deno-lint-ignore no-explicit-any { error_test: 'not an array, so it should throw' } as any, ).catch((e) => e), { @@ -40,6 +32,7 @@ Deno.test('JSONRPCClient/JSONRPCServer', async () => { assertObjectMatch( await client.request( + // deno-lint-ignore no-explicit-any 'no_such_method' as any, ).catch((e) => e), { @@ -51,7 +44,7 @@ Deno.test('JSONRPCClient/JSONRPCServer', async () => { assertEquals( await client.batch( client.createRequest('upper', 'nihao'), - client.createNotifaction('lower', 'anything'), + client.createNotification('lower', 'anything'), client.createRequest('upper', 'shijie'), client.createRequest('plus', [1, 2]), client.createRequest('minus', [1, 2]), @@ -72,42 +65,6 @@ Deno.test('JSONRPCClient/JSONRPCServer', async () => { ) }) -Deno.test({ - name: 'JSONRPCClient/aria2', - // also need to run `aria2c --enable-rpc` - ignore: Deno.permissions.querySync({ name: 'net', host: 'localhost:6800' }) - .state !== - 'granted', - fn: async () => { - const ok = await fetch('http://localhost:6800/jsonrpc', { - signal: AbortSignal.timeout(500), - }).then((res) => res.ok).catch(() => false) - if (!ok) { - // skip when no aria2c jsonrpc is running - return - } - - const client = new JSONRPCClient((json) => - fetch('http://localhost:6800/jsonrpc', { - method: 'POST', - body: json, - }).then((res) => res.text()) - ) - - assertInstanceOf(await client.request('system.listMethods'), Array) - - assertEquals(await client.notify('system.listMethods'), undefined) - - const [r1, r2] = await client.batch( - client.createRequest('system.listMethods'), - client.createRequest('system.listMethods'), - ) as JSONRPCFulfilledResult[] - - assertObjectMatch(r1, { status: 'fulfilled' }) - assertObjectMatch(r2, { status: 'fulfilled' }) - }, -}) - Deno.test({ name: 'JSONRPCServer/Deno.serve()', ignore: Deno.permissions.querySync({ name: 'net' }) @@ -137,9 +94,10 @@ Deno.test({ ) const client = new JSONRPCClient((json) => - fetch('http://localhost:8888/jsonrpc', { + fetch('http://127.0.0.1:8888/jsonrpc', { method: 'POST', body: json, + signal: AbortSignal.timeout(5000), }).then((res) => res.text()) ) diff --git a/src/server.test.ts b/src/server.test.ts new file mode 100644 index 0000000..c9b48a4 --- /dev/null +++ b/src/server.test.ts @@ -0,0 +1,115 @@ +import { + assertEquals, + assertInstanceOf, + assertObjectMatch, +} from 'std/assert/mod.ts' +import { JSONRPCServer } from './server.ts' +import { isJSONRPCError } from './dto/errors.ts' + +Deno.test( + 'server', + async () => { + const server = new JSONRPCServer() + + const trim = (str: string) => str.trim() + const trimStart = (str: string) => str.trimStart() + const trimEnd = (str: string) => str.trimEnd() + + server.setMethod('trim', trim) + .setMethod('trimStart', trimStart) + .setMethod('trimEnd', trimEnd) + + assertInstanceOf(server.getMethod('trim'), Function) + assertEquals(server.getMethod('not exist'), undefined) + + assertEquals( + isJSONRPCError( + JSON.parse( + await server.process(JSON.stringify({ + id: 1, + params: ' trim ', + })), + ).error, + ), + true, + ) + + assertEquals( + isJSONRPCError( + JSON.parse(await server.process('malformed json')).error, + ), + true, + ) + + assertObjectMatch( + JSON.parse( + await server.process('[]'), + ), + { + id: null, + error: {}, + }, + ) + + assertEquals( + await server.process(JSON.stringify([{ + jsonrpc: '2.0', + method: 'trim', + }])), + '', + ) + + assertObjectMatch( + JSON.parse( + await server.process(JSON.stringify([{ + jsonrpc: '2.0', + id: 1, + params: ' trim ', + }])), + )[0], + { + id: 1, + error: { + code: -32600, + message: 'Invalid Request', + }, + }, + ) + + assertObjectMatch( + JSON.parse( + await server.process(JSON.stringify([{ + id: null, + params: ' trim ', + }])), + )[0], + { + id: null, + error: {}, + }, + ) + + assertObjectMatch( + JSON.parse( + await server.process(JSON.stringify([{ + jsonrpc: '2.0', + id: 1, + method: 'trim', + params: ' trim ', + }, { + id: 2, + method: 'trim', + params: ' trim ', + }])), + ), + { + 0: { jsonrpc: '2.0', id: 1, result: 'trim' }, + 1: { + jsonrpc: '2.0', + id: 2, + error: { code: -32600, message: 'Invalid Request' }, + }, + }, + ) + }, +) diff --git a/src/server.ts b/src/server.ts index c011909..29a44a3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,6 +24,8 @@ import type { JSONRPCMethodSet, JSONRPCValue } from './types.ts' * that handle the rpc calls. * * See `JSONRPCMethodSet` for how method in method set should be. + * + * Note: avoid using `this` in method! */ export class JSONRPCServer< MethodSet extends JSONRPCMethodSet = JSONRPCMethodSet, @@ -41,7 +43,7 @@ export class JSONRPCServer< * when method is not in methodSet. * * You can also use this method to handle dynamic - * method name if you like, but make sure you manully + * method name if you like, but make sure you manually * throw `JSONRPCMethodNotFoundError` when required */ public methodNotFound(): JSONRPCValue { @@ -79,12 +81,17 @@ export class JSONRPCServer< */ public async process(input: string): Promise { const resp = await this.processAnyRequest(input) - const output = JSON.stringify(resp) - return output || '' + if (!resp) { + return '' + } + if (Array.isArray(resp)) { + return `[${resp.map((r) => r.toString()).join(',')}]` + } + return resp.toString() } /** - * Process request or batch request, return coresponding value + * Process request or batch request, return corresponding value * @returns corresponding response object or array or some other thing * @noexcept */ @@ -145,7 +152,7 @@ export class JSONRPCServer< /** * @param jsonValue the parse json value, can be any unknown javascript value - * @returns `JSONRPCResponse` if is request, or `undefined` if is notifaction + * @returns `JSONRPCResponse` if is request, or `undefined` if is Notification * @noexcept */ private async processOneJsonValue( diff --git a/src/types.ts b/src/types.ts index db96d44..05ffe1f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,12 +22,20 @@ export type ArrayValue = Array * Note that client is allowed to send no params, meaning that params can also be `undefined` */ export interface JSONRPCMethodSet { - [method: string]: ( - // deno-lint-ignore no-explicit-any - params: any, //! client may send any params to server - ) => Promise | JSONRPCValue + [method: string]: JSONRPCMethod } +/** + * Represent any json rpc method, any detailed method should extend it + * + * Note that for a request, `params` MUST be `JSONRPCValue`, + * however here `params` is `any` (just for simplicity) + */ +export type JSONRPCMethod = ( + // deno-lint-ignore no-explicit-any + params: any, //! client may send any params to server +) => Promise | JSONRPCValue + export type WithOptionalJSONRPCVersion = & Omit & {