├── .editorconfig ├── .github ├── CODEOWNERS ├── dco.yml └── workflows │ └── ci.yml ├── .gitignore ├── .huskyrc.json ├── .lintstagedrc.json ├── .npmignore ├── .prettierignore ├── .prettierrc ├── CONTRIBUTING.txt ├── DCO.txt ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── src ├── TypesafeRequestDispatcher.test.ts ├── TypesafeRequestDispatcher.ts ├── __snapshots__ │ ├── decode.test.ts.snap │ └── protocol.test.ts.snap ├── decode.test.ts ├── decode.ts ├── index.integration.test.ts ├── index.ts ├── peer.test.ts ├── peer.ts ├── protocol.test.ts ├── protocol.ts ├── serialize.test.ts └── serialize.ts ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.module.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Fitbit/developer-tools 2 | -------------------------------------------------------------------------------- /.github/dco.yml: -------------------------------------------------------------------------------- 1 | require: 2 | members: false 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | build: 12 | strategy: 13 | matrix: 14 | os: [macos-latest, ubuntu-latest, windows-latest] 15 | node_version: [10, 12, 14] 16 | name: ${{ matrix.os }} / Node v${{ matrix.node_version }} 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - run: git config --global core.autocrlf false 20 | - uses: actions/checkout@v2 21 | - uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node_version }} 24 | - name: Get yarn cache directory path 25 | id: yarn-cache-dir-path 26 | run: echo "::set-output name=dir::$(yarn cache dir)" 27 | - name: Restore yarn cache 28 | uses: actions/cache@v1 29 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 30 | with: 31 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 32 | key: ${{ runner.os }}-node${{ matrix.node_version }}-yarn-${{ hashFiles('**/yarn.lock') }} 33 | restore-keys: | 34 | ${{ runner.os }}-node${{ matrix.node_version }}-yarn- 35 | - name: Install dependencies 36 | run: yarn --frozen-lockfile 37 | - name: Check code style (tslint) 38 | run: yarn lint 39 | - name: Check code style (Prettier) 40 | run: yarn checkstyle 41 | - name: Build 42 | run: yarn build 43 | - name: Run tests 44 | run: yarn test --coverage 45 | - name: Upload coverage 46 | uses: coverallsapp/github-action@v1.1.1 47 | with: 48 | github-token: ${{ secrets.GITHUB_TOKEN }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /lib-cov/ 3 | /node_modules/ 4 | yarn-error.log 5 | 6 | # OS metadata 7 | .DS_Store 8 | Thumbs.db 9 | 10 | # Ignore built ts files 11 | /lib/ 12 | /mod/ 13 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,jsx,ts,tsx,json}": ["prettier --write", "tslint --fix"] 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fitbit/jsonrpc-ts/3dfe68a14a0ebd704d0486621364fb4d9a184c28/.npmignore -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /lib/ 3 | /mod/ 4 | __test__/ 5 | __snapshots__/ 6 | src/**/__test__ 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "arrowParens": "always", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.txt: -------------------------------------------------------------------------------- 1 | The current version of our contributing guide can be found at https://dev.fitbit.com/community/contributing/. 2 | -------------------------------------------------------------------------------- /DCO.txt: -------------------------------------------------------------------------------- 1 | Developer Certificate of Origin 2 | Version 1.1 3 | 4 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 5 | 1 Letterman Drive 6 | Suite D4700 7 | San Francisco, CA, 94129 8 | 9 | Everyone is permitted to copy and distribute verbatim copies of this 10 | license document, but changing it is not allowed. 11 | 12 | Developer's Certificate of Origin 1.1 13 | 14 | By making a contribution to this project, I certify that: 15 | 16 | (a) The contribution was created in whole or in part by me and I 17 | have the right to submit it under the open source license 18 | indicated in the file; or 19 | 20 | (b) The contribution is based upon previous work that, to the best 21 | of my knowledge, is covered under an appropriate open source 22 | license and I have the right under that license to submit that 23 | work with modifications, whether created in whole or in part 24 | by me, under the same open source license (unless I am 25 | permitted to submit under a different license), as indicated 26 | in the file; or 27 | 28 | (c) The contribution was provided directly to me by some other 29 | person who certified (a), (b) or (c) and I have not modified 30 | it. 31 | 32 | (d) I understand and agree that this project and the contribution 33 | are public and that a record of the contribution (including all 34 | personal information I submit with it, including my sign-off) is 35 | maintained indefinitely and may be redistributed consistent with 36 | this project or the open source license(s) involved. 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Fitbit, Inc. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | @fitbit/jsonrpc-ts 2 | =============== 3 | 4 | [![Greenkeeper badge](https://badges.greenkeeper.io/Fitbit/jsonrpc-ts.svg)](https://greenkeeper.io/) 5 | 6 | [![Coverage Status](https://coveralls.io/repos/github/Fitbit/jsonrpc-ts/badge.svg?branch=master)](https://coveralls.io/github/Fitbit/jsonrpc-ts?branch=master) 7 | 8 | This package is a set of components which makes it easy to implement a 9 | JSON-RPC 2.0 client or server (or both) over any reliable transport. It 10 | is designed to be extremely customizable to fit nearly any use-case. 11 | If the implementation of a component from this library doesn't suit your 12 | needs, you can simply use your own implementation instead. Even the 13 | way that request ids are generated can be customized! 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.(ts|tsx)$': 'ts-jest', 4 | }, 5 | moduleFileExtensions: ['ts', 'tsx', 'js'], 6 | testRegex: '(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$', 7 | testPathIgnorePatterns: ['/node_modules', '/dist'], 8 | testEnvironment: 'node', 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fitbit/jsonrpc-ts", 3 | "version": "3.2.1", 4 | "description": "A very flexible library for building JSON-RPC 2.0 endpoints.", 5 | "files": [ 6 | "lib", 7 | "mod" 8 | ], 9 | "main": "lib/index.js", 10 | "module": "mod/index.js", 11 | "types": "lib/index.d.ts", 12 | "scripts": { 13 | "build": "rm -rf lib mod && tsc -p tsconfig.build.json && tsc -p tsconfig.module.json", 14 | "lint": "tslint -c tslint.json -p tsconfig.json --format code-frame", 15 | "checkstyle": "prettier --list-different \"**/*.{js,jsx,ts,tsx,json}\"", 16 | "test": "npm run lint && jest", 17 | "test:coveralls": "npm run lint && jest --coverage --coverageReporters=text-lcov | coveralls", 18 | "prepublishOnly": "npm run test && npm run build" 19 | }, 20 | "author": "Fitbit, Inc.", 21 | "license": "BSD-3-Clause", 22 | "repository": "github:Fitbit/jsonrpc-ts", 23 | "bugs": { 24 | "url": "https://github.com/Fitbit/jsonrpc-ts/issues" 25 | }, 26 | "homepage": "https://github.com/Fitbit/jsonrpc-ts#readme", 27 | "devDependencies": { 28 | "@types/jest": "^26.0.10", 29 | "@types/node": "^14.6.0", 30 | "coveralls": "^3.1.0", 31 | "husky": "^4.3.0", 32 | "jest": "^26.4.2", 33 | "lint-staged": "^10.5.1", 34 | "prettier": "^2.1.2", 35 | "ts-jest": "^26.2.0", 36 | "tslint": "^6.1.3", 37 | "tslint-config-airbnb": "^5.11.2", 38 | "tslint-config-prettier": "^1.18.0", 39 | "typescript": "^4.0.2" 40 | }, 41 | "dependencies": { 42 | "@types/error-subclass": "^2.2.0", 43 | "error-subclass": "^2.2.0", 44 | "fp-ts": "2.8.2", 45 | "io-ts": "2.2.10" 46 | }, 47 | "peerDependencies": { 48 | "fp-ts": "2.8.2", 49 | "io-ts": "2.2.10" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/TypesafeRequestDispatcher.test.ts: -------------------------------------------------------------------------------- 1 | import * as t from 'io-ts'; 2 | import { either } from 'fp-ts/lib/Either'; 3 | 4 | import * as peer from './peer'; 5 | import TypesafeRequestDispatcher from './TypesafeRequestDispatcher'; 6 | 7 | let dispatcher: TypesafeRequestDispatcher; 8 | 9 | beforeEach(() => { 10 | dispatcher = new TypesafeRequestDispatcher(); 11 | }); 12 | 13 | // Lifted from the io-ts README 14 | // tslint:disable-next-line:variable-name 15 | const DateFromString = new t.Type( 16 | 'DateFromString', 17 | (v): v is Date => v instanceof Date, 18 | (v, c) => 19 | either.chain(t.string.validate(v, c), (s) => { 20 | const d = new Date(s); 21 | return isNaN(d.getTime()) ? t.failure(s, c) : t.success(d); 22 | }), 23 | (a) => a.toISOString(), 24 | ); 25 | 26 | describe('swallows the notification', () => { 27 | test('when no notification handlers are registered', () => { 28 | const sentinel = jest.fn(); 29 | dispatcher.method('requestOnly', t.any, sentinel); 30 | dispatcher.onNotification('requestOnly', []); 31 | expect(sentinel).not.toBeCalled(); 32 | }); 33 | 34 | test('when no handler for the method matches the param types', () => { 35 | const sentinel = jest.fn(); 36 | dispatcher.notification('foo', t.tuple([t.number, t.string]), sentinel); 37 | dispatcher.notification('foo', t.interface({ bar: t.number }), sentinel); 38 | dispatcher.onNotification('foo', [3]); 39 | dispatcher.onNotification('foo', { baz: 'haha' }); 40 | expect(sentinel).not.toBeCalled(); 41 | }); 42 | }); 43 | 44 | describe('calls the default notification handler', () => { 45 | test('when no notification handlers are registered', () => { 46 | const handler = jest.fn(); 47 | dispatcher.defaultNotificationHandler = handler; 48 | dispatcher.onNotification('foo', [3, 1, 4]); 49 | expect(handler).toBeCalledWith('foo', [3, 1, 4]); 50 | }); 51 | 52 | test('when no handler for the method matches the param types', () => { 53 | const sentinel = jest.fn(); 54 | const handler = jest.fn(); 55 | dispatcher.defaultNotificationHandler = handler; 56 | dispatcher.notification('foo', t.tuple([t.number]), sentinel); 57 | dispatcher.onNotification('foo', ['hey']); 58 | expect(sentinel).not.toBeCalled(); 59 | expect(handler).toBeCalledWith('foo', ['hey']); 60 | }); 61 | }); 62 | 63 | describe('dispatches a notification', () => { 64 | test('when a request arrives', () => { 65 | const handler = jest.fn(); 66 | dispatcher.notification('asdf', t.any, handler); 67 | dispatcher.onNotification('asdf', {}); 68 | expect(handler).toBeCalled(); 69 | }); 70 | 71 | test('to the first matching handler', () => { 72 | const quux1 = jest.fn(); 73 | const quux2 = jest.fn(); 74 | const quux3 = jest.fn(); 75 | const abc = jest.fn(); 76 | dispatcher 77 | .notification('abc', t.any, abc) 78 | .notification('quux', t.tuple([t.string, t.number]), quux1) 79 | .notification('quux', t.tuple([t.number, t.number]), quux2) 80 | .notification('quux', t.tuple([t.number, t.number]), quux3); 81 | // Both quux2 and quux3 match the params so quux2 should be called 82 | // since it was registered first. 83 | dispatcher.onNotification('quux', [3, 4]); 84 | expect(quux1).not.toBeCalled(); 85 | expect(quux2).toBeCalledWith([3, 4]); 86 | expect(quux3).not.toBeCalled(); 87 | expect(abc).not.toBeCalled(); 88 | }); 89 | 90 | test('with transformed params', () => { 91 | const handler = jest.fn(); 92 | dispatcher.notification('date', t.tuple([DateFromString]), handler); 93 | const date = new Date(2017, 6, 1); 94 | dispatcher.onNotification('date', [date.toISOString()]); 95 | expect(handler).toBeCalledWith([date]); 96 | }); 97 | }); 98 | 99 | it('throws MethodNotFound when no request handler is registered', () => { 100 | const sentinel = jest.fn(); 101 | dispatcher.notification('foo', t.any, sentinel); 102 | expect(() => dispatcher.onRequest('foo', [])).toThrow(peer.MethodNotFound); 103 | expect(sentinel).not.toBeCalled(); 104 | }); 105 | 106 | it('throws InvalidParams when params match none of the registered handlers for a method', () => { 107 | const sentinel = jest.fn(); 108 | dispatcher 109 | .method('foo', t.tuple([t.string, t.number]), sentinel) 110 | .method('foo', t.tuple([t.number, t.boolean]), sentinel); 111 | expect(() => dispatcher.onRequest('foo', [true])).toThrow(peer.InvalidParams); 112 | expect(sentinel).not.toBeCalled(); 113 | }); 114 | 115 | describe('dispatches a request', () => { 116 | test('when a request arrives', () => { 117 | const handler = jest.fn(); 118 | dispatcher.method('foo', t.any, handler); 119 | dispatcher.onRequest('foo', [1, 2, 3]); 120 | expect(handler).toBeCalledWith([1, 2, 3]); 121 | }); 122 | 123 | test('to the first matching handler', () => { 124 | const quux1 = jest.fn(); 125 | const quux2 = jest.fn(); 126 | const quux3 = jest.fn(); 127 | const abc = jest.fn(); 128 | dispatcher 129 | .method('abc', t.any, abc) 130 | .method('quux', t.tuple([t.string, t.number]), quux1) 131 | .method('quux', t.tuple([t.number, t.number]), quux2) 132 | .method('quux', t.tuple([t.number, t.number]), quux3); 133 | // Both quux2 and quux3 match the params so quux2 should be called 134 | // since it was registered first. 135 | dispatcher.onRequest('quux', [3, 4]); 136 | expect(quux1).not.toBeCalled(); 137 | expect(quux2).toBeCalledWith([3, 4]); 138 | expect(quux3).not.toBeCalled(); 139 | expect(abc).not.toBeCalled(); 140 | }); 141 | 142 | test('with transformed params', () => { 143 | const handler = jest.fn(); 144 | dispatcher.method('date', t.tuple([DateFromString]), handler); 145 | const date = new Date(2017, 6, 1); 146 | dispatcher.onRequest('date', [date.toISOString()]); 147 | expect(handler).toBeCalledWith([date]); 148 | }); 149 | }); 150 | 151 | it("returns the request handler function's return value", () => { 152 | dispatcher.method('yarr', t.any, () => 'avast matey'); 153 | expect(dispatcher.onRequest('yarr', [])).toBe('avast matey'); 154 | }); 155 | 156 | it("disallows registering methods beginning with 'rpc.'", () => { 157 | const handler = jest.fn(); 158 | expect(() => dispatcher.method('rpc.foo', t.any, handler)).toThrow(TypeError); 159 | expect(() => dispatcher.notification('rpc.foo', t.any, handler)).toThrow( 160 | TypeError, 161 | ); 162 | }); 163 | -------------------------------------------------------------------------------- /src/TypesafeRequestDispatcher.ts: -------------------------------------------------------------------------------- 1 | /** Request and notificaiton dispatchers for JSON-RPC peers */ 2 | 3 | import * as t from 'io-ts'; 4 | import { PathReporter } from 'io-ts/lib/PathReporter'; 5 | import { isRight } from 'fp-ts/lib/Either'; 6 | 7 | import * as jrpc from './protocol'; 8 | import * as peer from './peer'; 9 | 10 | export interface TypedHandlerFn { 11 | fn: (params: t.TypeOf) => Promise | any; 12 | paramsType: T; 13 | } 14 | 15 | export type DefaultNotificationHandler = ( 16 | this: void, 17 | method: string, 18 | params: jrpc.RPCParams, 19 | ) => void; 20 | 21 | /** 22 | * A method dispatcher which performs run-time type checking to 23 | * verify that the type of the RPC request's arguments are compatible 24 | * with the RPC call handler's function signature. It also supports 25 | * function overloading like TypeScript. 26 | * 27 | * Unfortunately due to limitations of TypeScript's type system, RPC 28 | * calls with positional arguments will be passed to the method handler 29 | * functions as a single Array argument in order for the TypeScript 30 | * compiler to type-check the function signature. This limitation can 31 | * be lifted once the Variadic Kinds proposal 32 | * (https://github.com/Microsoft/TypeScript/issues/5453) is implemented 33 | * in TypeScript. 34 | * 35 | * Like TypeScript, the first method overload which passes type-checks 36 | * is invoked as the handler for a request. So make sure to register 37 | * method overloads in order of most specific to least specific. 38 | * 39 | * The method handler is called with the value output from the io-ts 40 | * validator, so runtime types can be used which deserialize or 41 | * otherwise transform the value when validating. 42 | */ 43 | export default class TypesafeRequestDispatcher { 44 | requestHandlers = new Map(); 45 | notificationHandlers = new Map(); 46 | 47 | /** 48 | * Override this property to receive notifications which could not be 49 | * handled by any registered notification handler function. 50 | */ 51 | defaultNotificationHandler: DefaultNotificationHandler = () => {}; 52 | 53 | private static register( 54 | collection: Map, 55 | name: string, 56 | paramsType: T, 57 | impl: (params: t.TypeOf) => Promise | any, 58 | ) { 59 | if (name.startsWith('rpc.')) { 60 | throw new TypeError('Method names beginning with "rpc." are reserved'); 61 | } 62 | const handlers = collection.get(name); 63 | if (handlers === undefined) { 64 | collection.set(name, [{ paramsType, fn: impl }]); 65 | } else { 66 | handlers.push({ paramsType, fn: impl }); 67 | } 68 | } 69 | 70 | /** 71 | * Register an RPC request handler function. 72 | * 73 | * @param name name of RPC method 74 | * @param paramsType io-ts type definition of the expected params 75 | * @param impl method implementation function 76 | */ 77 | method( 78 | name: string, 79 | paramsType: T, 80 | impl: (params: t.TypeOf) => Promise | any, 81 | ): this { 82 | TypesafeRequestDispatcher.register( 83 | this.requestHandlers, 84 | name, 85 | paramsType, 86 | impl, 87 | ); 88 | return this; 89 | } 90 | 91 | /** 92 | * Register an RPC notification handler function. 93 | * 94 | * @param name name of RPC method 95 | * @param paramsType io-ts type definition of the expected params 96 | * @param impl notification handler function 97 | */ 98 | notification( 99 | name: string, 100 | paramsType: T, 101 | impl: (params: t.TypeOf) => void, 102 | ): this { 103 | TypesafeRequestDispatcher.register( 104 | this.notificationHandlers, 105 | name, 106 | paramsType, 107 | impl, 108 | ); 109 | return this; 110 | } 111 | 112 | onRequest: peer.RequestHandler = (method: string, params: jrpc.RPCParams) => { 113 | const handlers = this.requestHandlers.get(method); 114 | if (handlers === undefined) { 115 | throw new peer.MethodNotFound(`No such method: '${method}'`); 116 | } else { 117 | const validationErrors: string[][] = []; 118 | for (const { fn, paramsType } of handlers) { 119 | const decoded = paramsType.decode(params); 120 | 121 | if (isRight(decoded)) { 122 | return fn(decoded.right); 123 | } 124 | validationErrors.push(PathReporter.report(decoded)); 125 | } 126 | // None of the implementations matched. 127 | throw new peer.InvalidParams( 128 | `Invalid parameters for method ${method}`, 129 | validationErrors, 130 | ); 131 | } 132 | }; 133 | 134 | onNotification: peer.NotificationHandler = ( 135 | method: string, 136 | params: jrpc.RPCParams, 137 | ) => { 138 | const handlers = this.notificationHandlers.get(method); 139 | if (handlers !== undefined) { 140 | for (const { fn, paramsType } of handlers) { 141 | const decoded = paramsType.decode(params); 142 | if (isRight(decoded)) { 143 | fn(decoded.right); 144 | return; 145 | } 146 | } 147 | } 148 | this.defaultNotificationHandler(method, params); 149 | }; 150 | } 151 | -------------------------------------------------------------------------------- /src/__snapshots__/decode.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`throws when decode fails 1`] = `"Invalid value \\"foo\\" supplied to : DateFromString"`; 4 | -------------------------------------------------------------------------------- /src/__snapshots__/protocol.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`error serializer serializes an error with a null id 1`] = ` 4 | Object { 5 | "error": Object { 6 | "code": 0, 7 | "data": undefined, 8 | "message": "", 9 | }, 10 | "id": null, 11 | "jsonrpc": "2.0", 12 | } 13 | `; 14 | 15 | exports[`error serializer serializes an error with a numeric id 1`] = ` 16 | Object { 17 | "error": Object { 18 | "code": 1, 19 | "data": undefined, 20 | "message": "", 21 | }, 22 | "id": 5, 23 | "jsonrpc": "2.0", 24 | } 25 | `; 26 | 27 | exports[`notification serializer serializes a notification with an array of params 1`] = ` 28 | Object { 29 | "jsonrpc": "2.0", 30 | "method": "asdffdsa", 31 | "params": Array [ 32 | "one", 33 | 2, 34 | "three", 35 | ], 36 | } 37 | `; 38 | 39 | exports[`notification serializer serializes a notification with no params 1`] = ` 40 | Object { 41 | "jsonrpc": "2.0", 42 | "method": "aNotification", 43 | "params": undefined, 44 | } 45 | `; 46 | 47 | exports[`request serializer serializes a request with a numeric id 1`] = ` 48 | Object { 49 | "id": -7, 50 | "jsonrpc": "2.0", 51 | "method": "foo", 52 | "params": undefined, 53 | } 54 | `; 55 | 56 | exports[`request serializer serializes a request with a string id 1`] = ` 57 | Object { 58 | "id": "foo", 59 | "jsonrpc": "2.0", 60 | "method": "method", 61 | "params": undefined, 62 | } 63 | `; 64 | 65 | exports[`request serializer serializes a request with an array of params 1`] = ` 66 | Object { 67 | "id": -803, 68 | "jsonrpc": "2.0", 69 | "method": "mmm", 70 | "params": Array [ 71 | 3, 72 | "seven", 73 | ], 74 | } 75 | `; 76 | 77 | exports[`response serializer serializes a response with a numeric id 1`] = ` 78 | Object { 79 | "id": 7654, 80 | "jsonrpc": "2.0", 81 | "result": null, 82 | } 83 | `; 84 | 85 | exports[`response serializer serializes a response with a string id 1`] = ` 86 | Object { 87 | "id": "ayedee", 88 | "jsonrpc": "2.0", 89 | "result": Object { 90 | "result": Object { 91 | "result": Array [ 92 | "result", 93 | ], 94 | }, 95 | }, 96 | } 97 | `; 98 | -------------------------------------------------------------------------------- /src/decode.test.ts: -------------------------------------------------------------------------------- 1 | import * as t from 'io-ts'; 2 | import { either } from 'fp-ts/lib/Either'; 3 | 4 | import { decode } from './decode'; 5 | 6 | // Lifted from the io-ts README 7 | // tslint:disable-next-line:variable-name 8 | const DateFromString = new t.Type( 9 | 'DateFromString', 10 | (m): m is Date => m instanceof Date, 11 | (m, c) => 12 | either.chain(t.string.validate(m, c), (s) => { 13 | const d = new Date(s); 14 | return isNaN(d.getTime()) ? t.failure(s, c) : t.success(d); 15 | }), 16 | (a) => a.toISOString(), 17 | ); 18 | 19 | // it returns a function of the right type 20 | const decodeDate: (value: t.mixed) => Date = decode(DateFromString); 21 | 22 | it('returns the decoded value', () => { 23 | const expectedDate = new Date(2018, 2, 16); 24 | expect(decodeDate(expectedDate.toISOString())).toEqual(expectedDate); 25 | }); 26 | 27 | it('throws when decode fails', () => { 28 | expect(() => decodeDate('foo')).toThrowErrorMatchingSnapshot(); 29 | }); 30 | -------------------------------------------------------------------------------- /src/decode.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:import-name 2 | import ErrorSubclass from 'error-subclass'; 3 | import * as t from 'io-ts'; 4 | import { failure } from 'io-ts/lib/PathReporter'; 5 | import { pipe } from 'fp-ts/lib/pipeable'; 6 | import { fold } from 'fp-ts/lib/Either'; 7 | 8 | export class DecodeError extends ErrorSubclass { 9 | static displayName = 'DecodeError'; 10 | } 11 | 12 | /** 13 | * Creates a decoder function for an io-ts type. 14 | * 15 | * The decoder function throws DecodeError if the input value is not of 16 | * the expected type. 17 | * 18 | * Decoder functions can be used to build wrappers around RPC method 19 | * calls which enforce type-safety of the return values. 20 | * 21 | * ```typescript 22 | * const myMethod = (args: MyMethodArgs) => 23 | * rpc.callMethod('myMethod', args).then(decode(MyMethodResult)); 24 | * // Return type of myMethod is Promise> 25 | * ``` 26 | * 27 | * @param type runtime type to decode 28 | */ 29 | export function decode(type: t.Type) { 30 | return (value: I): A => { 31 | return pipe( 32 | type.decode(value), 33 | fold( 34 | (errors) => { 35 | throw new DecodeError(failure(errors).join('\n')); 36 | }, 37 | (tag) => tag, 38 | ), 39 | ); 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/index.integration.test.ts: -------------------------------------------------------------------------------- 1 | import * as t from 'io-ts'; 2 | 3 | import { 4 | ParseJSON, 5 | Peer, 6 | StringifyJSON, 7 | TypesafeRequestDispatcher, 8 | } from './index'; 9 | 10 | let dispatcher: TypesafeRequestDispatcher; 11 | let handler: jest.Mock<{}>; 12 | let output: StringifyJSON; 13 | let input: ParseJSON; 14 | let rpc: Peer; 15 | 16 | beforeEach(() => { 17 | handler = jest.fn((p: [number, string]) => `${p[0]} ${p[1]}`); 18 | dispatcher = new TypesafeRequestDispatcher(); 19 | dispatcher.method('concat', t.tuple([t.number, t.string]), handler); 20 | output = new StringifyJSON(2); 21 | input = new ParseJSON(); 22 | rpc = new Peer(dispatcher); 23 | input.pipe(rpc).pipe(output); 24 | }); 25 | 26 | it('handles an incoming request and sends a response', (done) => { 27 | output.on('data', (chunk: Buffer) => { 28 | try { 29 | expect(handler).toBeCalledWith([123, 'hello']); 30 | expect(JSON.parse(chunk.toString())).toEqual({ 31 | jsonrpc: '2.0', 32 | id: 5, 33 | result: '123 hello', 34 | }); 35 | done(); 36 | } catch (e) { 37 | done(e); 38 | } 39 | }); 40 | input.write(`{ 41 | "jsonrpc": "2.0", 42 | "method": "concat", 43 | "id": 5, 44 | "params": [123, "hello"] 45 | }`); 46 | }); 47 | 48 | it('sends an outgoing request and resolves the response', () => { 49 | output.on('data', (chunk: Buffer) => { 50 | const request = JSON.parse(chunk.toString()); 51 | input.write(`{ 52 | "jsonrpc": "2.0", 53 | "id": ${request.id}, 54 | "result": "OK" 55 | }`); 56 | }); 57 | return expect(rpc.callMethod('foo', { hello: 'world' })).resolves.toBe('OK'); 58 | }); 59 | 60 | it('sends an error in response to malformed JSON', (done) => { 61 | output.on('data', (chunk: Buffer) => { 62 | expect(JSON.parse(chunk.toString())).toMatchObject({ 63 | jsonrpc: '2.0', 64 | id: null, 65 | error: { 66 | code: -32700, 67 | }, 68 | }); 69 | done(); 70 | }); 71 | input.write('}])'); 72 | }); 73 | 74 | it('rejects an in-progress method call when the input stream ends', () => { 75 | const call = rpc.callMethod('foo'); 76 | input.end(); 77 | return expect(call).rejects.toEqual(expect.any(Error)); 78 | }); 79 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './decode'; 2 | export * from './peer'; 3 | export * from './serialize'; 4 | export { default as TypesafeRequestDispatcher } from './TypesafeRequestDispatcher'; 5 | -------------------------------------------------------------------------------- /src/peer.test.ts: -------------------------------------------------------------------------------- 1 | import * as peer from './peer'; 2 | import * as jrpc from './protocol'; 3 | 4 | jest.useFakeTimers(); 5 | 6 | describe('RPCError', () => { 7 | it('defaults to code INTERNAL_ERROR and undefined data', () => { 8 | expect(new peer.RPCError('foo')).toEqual( 9 | expect.objectContaining({ 10 | message: 'foo', 11 | code: peer.ErrorCodes.INTERNAL_ERROR, 12 | data: undefined, 13 | }), 14 | ); 15 | }); 16 | 17 | it('allows the code to be overridden', () => { 18 | expect(new peer.RPCError('asdf', 12345)).toEqual( 19 | expect.objectContaining({ 20 | message: 'asdf', 21 | code: 12345, 22 | data: undefined, 23 | }), 24 | ); 25 | }); 26 | 27 | it('saves the data passed to the constructor', () => { 28 | const expectedData = { foo: 'bar' }; 29 | expect(new peer.RPCError('uh oh', 54321, expectedData)).toEqual( 30 | expect.objectContaining({ 31 | message: 'uh oh', 32 | code: 54321, 33 | data: expectedData, 34 | }), 35 | ); 36 | }); 37 | 38 | it('copies the info to an ErrorObject', () => { 39 | const err = new peer.RPCError('ohai', 42, 'data!'); 40 | expect(err.toErrorObject()).toEqual({ 41 | message: 'ohai', 42 | code: 42, 43 | data: 'data!', 44 | id: undefined, 45 | }); 46 | }); 47 | 48 | it('constructs objects which are instances of Error and RPCError', () => { 49 | const obj = new peer.RPCError('message', 31415, {}); 50 | expect(obj).toEqual(expect.any(Error)); 51 | expect(obj).toEqual(expect.any(peer.RPCError)); 52 | }); 53 | }); 54 | 55 | describe('MethodNotFound', () => { 56 | it('sets the error code to METHOD_NOT_FOUND', () => { 57 | expect(new peer.MethodNotFound('You are wrong', 'yes')).toEqual( 58 | expect.objectContaining({ 59 | message: 'You are wrong', 60 | code: peer.ErrorCodes.METHOD_NOT_FOUND, 61 | data: 'yes', 62 | }), 63 | ); 64 | }); 65 | 66 | it('constructs objects which are instances of MethodNotFound', () => { 67 | const obj = new peer.MethodNotFound('foo'); 68 | expect(obj).toEqual(expect.any(Error)); 69 | expect(obj).toEqual(expect.any(peer.RPCError)); 70 | expect(obj).toEqual(expect.any(peer.MethodNotFound)); 71 | }); 72 | }); 73 | 74 | describe('InvalidParams', () => { 75 | it('sets the error code to INVALID_PARAMS', () => { 76 | expect( 77 | new peer.InvalidParams('No bueno', 'what nonsense was that?'), 78 | ).toEqual( 79 | expect.objectContaining({ 80 | message: 'No bueno', 81 | code: peer.ErrorCodes.INVALID_PARAMS, 82 | data: 'what nonsense was that?', 83 | }), 84 | ); 85 | }); 86 | 87 | it('constructs objects which are instances of InvalidParams', () => { 88 | const obj = new peer.InvalidParams('foo', 'ha'); 89 | expect(obj).toEqual(expect.any(Error)); 90 | expect(obj).toEqual(expect.any(peer.RPCError)); 91 | expect(obj).toEqual(expect.any(peer.InvalidParams)); 92 | }); 93 | }); 94 | 95 | describe('MethodCallTimeout', () => { 96 | it('constructs objects which are instances of MethodCallTimeout', () => { 97 | const obj = new peer.MethodCallTimeout('foo'); 98 | expect(obj).toBeInstanceOf(Error); 99 | expect(obj).toBeInstanceOf(peer.MethodCallError); 100 | expect(obj).toBeInstanceOf(peer.MethodCallTimeout); 101 | }); 102 | 103 | it('sets the message and method', () => { 104 | const method = 'some.method.name'; 105 | expect(new peer.MethodCallTimeout(method)).toMatchObject({ 106 | method, 107 | message: `No response received for RPC call to '${method}'`, 108 | }); 109 | }); 110 | }); 111 | 112 | describe('RPCStreamClosed', () => { 113 | it('constructs objects which are instances of RPCStreamClosed', () => { 114 | const obj = new peer.RPCStreamClosed('foo'); 115 | expect(obj).toBeInstanceOf(Error); 116 | expect(obj).toBeInstanceOf(peer.MethodCallError); 117 | expect(obj).toBeInstanceOf(peer.RPCStreamClosed); 118 | }); 119 | 120 | it('sets the error message and method', () => { 121 | const method = 'some.method.name'; 122 | expect(new peer.RPCStreamClosed(method)).toMatchObject({ 123 | method, 124 | message: `RPC call to '${method}' could not be completed as the RPC stream is closed`, 125 | }); 126 | }); 127 | }); 128 | 129 | describe('UnexpectedResponse', () => { 130 | it('constructs objects which are instances of UnexpectedResponse', () => { 131 | const obj = new peer.UnexpectedResponse(0); 132 | expect(obj).toBeInstanceOf(Error); 133 | expect(obj).toBeInstanceOf(peer.UnexpectedResponse); 134 | }); 135 | 136 | it('sets the error message, kind and id', () => { 137 | expect(new peer.UnexpectedResponse('eye dee', 'error')).toMatchObject({ 138 | id: 'eye dee', 139 | kind: 'error', 140 | // tslint:disable-next-line:max-line-length 141 | message: 142 | 'Received error with id \'"eye dee"\', which does not correspond to any outstanding RPC call', 143 | }); 144 | }); 145 | 146 | it('defaults to kind "response"', () => { 147 | expect(new peer.UnexpectedResponse(0)).toHaveProperty('kind', 'response'); 148 | }); 149 | }); 150 | 151 | describe('numeric request id iterator', () => { 152 | it('does not repeat values', () => { 153 | // Not quite true; values will repeat when it wraps around. 154 | const uut = new peer.NumericIdIterator(); 155 | const previousValues = new Set(); 156 | for (let i = 0; i < 100; i += 1) { 157 | const output = uut.next(); 158 | expect(output.done).toBe(false); 159 | expect(previousValues.has(output.value)).toBe(false); 160 | previousValues.add(output.value); 161 | } 162 | }); 163 | 164 | it('wraps around when hitting max safe integer', () => { 165 | const uut = new peer.NumericIdIterator(Number.MAX_SAFE_INTEGER - 1); 166 | expect(uut.next().value).toBe(Number.MAX_SAFE_INTEGER - 1); 167 | expect(uut.next().value).toBe(Number.MAX_SAFE_INTEGER); 168 | expect(uut.next().value).toBe(Number.MIN_SAFE_INTEGER); 169 | expect(uut.next().value).toBe(Number.MIN_SAFE_INTEGER + 1); 170 | }); 171 | 172 | it('rejects non-integer starting values', () => { 173 | expect(() => new peer.NumericIdIterator(3.14)).toThrow(TypeError); 174 | }); 175 | 176 | it('rejects starting values larger than max safe integer', () => { 177 | expect(() => new peer.NumericIdIterator(2 ** 54)).toThrow(TypeError); 178 | }); 179 | 180 | it('rejects starting values smaller than min safe integer', () => { 181 | expect(() => new peer.NumericIdIterator(-(2 ** 54))).toThrow(TypeError); 182 | }); 183 | }); 184 | 185 | describe('Peer', () => { 186 | let uut: peer.Peer; 187 | 188 | beforeEach(() => { 189 | uut = new peer.Peer({}); 190 | }); 191 | 192 | describe('when no handlers are registered', () => { 193 | it('responds to an incoming request with an error response', (done) => { 194 | uut.once('data', (value: any) => { 195 | const message = jrpc.parse(value); 196 | expect(message).toEqual({ 197 | kind: 'error', 198 | id: 3, 199 | error: expect.objectContaining({ 200 | code: peer.ErrorCodes.METHOD_NOT_FOUND, 201 | }), 202 | }); 203 | done(); 204 | }); 205 | uut.write(jrpc.request(3, 'foo')); 206 | }); 207 | 208 | it('accepts an incoming notification', () => { 209 | uut.write(jrpc.notification('hello')); 210 | }); 211 | }); 212 | 213 | it('handles an unexpected response by emitting an UnexpectedResponse error', (done) => { 214 | uut.once('error', (err: Error) => { 215 | expect(err).toMatchObject({ 216 | message: expect.stringContaining("Received response with id '55'"), 217 | kind: 'response', 218 | id: 55, 219 | }); 220 | expect(err).toBeInstanceOf(peer.UnexpectedResponse); 221 | done(); 222 | }); 223 | uut.write(jrpc.response(55)); 224 | }); 225 | 226 | it('handles an error with no id by emitting an RPCError', (done) => { 227 | const errorContents = { 228 | message: 'I am error', 229 | code: 8675309, 230 | data: [1, 2, 3, 4, 5], 231 | }; 232 | uut.once('error', (err: Error) => { 233 | expect(err).toEqual(expect.any(peer.RPCError)); 234 | expect(err).toEqual(expect.objectContaining(errorContents)); 235 | done(); 236 | }); 237 | uut.write(jrpc.error(errorContents)); 238 | }); 239 | 240 | it(// tslint:disable-next-line:max-line-length 241 | 'handles an error with an id not matching any outstanding request by emitting an UnexpectedResponse error', (done) => { 242 | uut.once('error', (err: Error) => { 243 | expect(err).toMatchObject({ 244 | message: expect.stringContaining('Received error with id \'"yellow"\''), 245 | kind: 'error', 246 | id: 'yellow', 247 | }); 248 | expect(err).toBeInstanceOf(peer.UnexpectedResponse); 249 | done(); 250 | }); 251 | uut.write(jrpc.error({ id: 'yellow', message: '', code: 1 })); 252 | }); 253 | 254 | function expectInvalidRequest(done: jest.DoneCallback) { 255 | uut.once('data', (value: any) => { 256 | const message = jrpc.parse(value); 257 | expect(message).toEqual({ 258 | kind: 'error', 259 | id: null, 260 | error: expect.objectContaining({ 261 | code: peer.ErrorCodes.INVALID_REQUEST, 262 | }), 263 | }); 264 | done(); 265 | }); 266 | } 267 | 268 | it('sends an error message when a malformed object is received', (done) => { 269 | expectInvalidRequest(done); 270 | uut.write({ totally: 'bogus' }); 271 | }); 272 | 273 | it('sends an error message when very malformed JSON is received', (done) => { 274 | expectInvalidRequest(done); 275 | uut.write('A string is definitely not valid JSON-RPC'); 276 | }); 277 | 278 | it('sends a request to the remote peer and resolves the response', () => { 279 | uut.once('data', (value: any) => { 280 | const message = jrpc.parse(value); 281 | expect(message).toEqual( 282 | expect.objectContaining({ 283 | kind: 'request', 284 | method: 'myRpcCall', 285 | params: [true, 3, 'yes'], 286 | id: expect.anything(), 287 | }), 288 | ); 289 | if (message.kind === 'request') { 290 | uut.write(jrpc.response(message.id, 'this is a response')); 291 | } 292 | }); 293 | 294 | const call = uut.callMethod('myRpcCall', [true, 3, 'yes']); 295 | jest.runTimersToTime(24 * 60 * 60 * 1000); 296 | return expect(call).resolves.toBe('this is a response'); 297 | }); 298 | 299 | it('uses different request ids for multiple requests', () => { 300 | expect.assertions(6); 301 | const requestIds = new Set(); 302 | uut.on('data', (value: any) => { 303 | const message = jrpc.parse(value); 304 | expect(message.kind).toBe('request'); 305 | if (message.kind === 'request') { 306 | expect(requestIds.has(message.id)).toBe(false); 307 | requestIds.add(message.id); 308 | uut.write(jrpc.response(message.id)); 309 | } 310 | }); 311 | return Promise.all(['a', 'b', 'c'].map((method) => uut.callMethod(method))); 312 | }); 313 | 314 | it('resolves method calls regardless of the order responses are received', () => { 315 | const requests: jrpc.Request[] = []; 316 | uut.on('data', (value: any) => { 317 | const message = jrpc.parse(value); 318 | expect(message.kind).toBe('request'); 319 | if (message.kind === 'request') { 320 | requests.push(message); 321 | } 322 | if (requests.length === 2) { 323 | uut.write(jrpc.response(requests[1].id, requests[1].params)); 324 | } else if (requests.length === 3) { 325 | uut.write(jrpc.response(requests[0].id, requests[0].params)); 326 | uut.write(jrpc.response(requests[2].id, requests[2].params)); 327 | } else if (requests.length === 4) { 328 | uut.write(jrpc.response(requests[3].id, requests[3].params)); 329 | } 330 | }); 331 | 332 | const paramses = [['first', 1, 7], ['second'], ['third', 8], ['fourth', 0]]; 333 | return expect( 334 | Promise.all(paramses.map((p) => uut.callMethod('foo', p))), 335 | ).resolves.toEqual(paramses); 336 | }); 337 | 338 | it('rejects the method call promise when the remote peer sends an error response', () => { 339 | uut.once('data', (value: any) => { 340 | const message = jrpc.parse(value); 341 | if (message.kind === 'request') { 342 | uut.write(jrpc.error({ id: message.id, code: 3, message: 'fail!' })); 343 | } 344 | }); 345 | return expect(uut.callMethod('foo')).rejects.toEqual( 346 | expect.objectContaining({ 347 | message: 'fail!', 348 | code: 3, 349 | }), 350 | ); 351 | }); 352 | 353 | it('sends notifications', (done) => { 354 | uut.on('data', (value: any) => { 355 | const message = jrpc.parse(value); 356 | expect(message).toEqual({ 357 | kind: 'notification', 358 | method: 'fooNotification', 359 | params: { a: 'asdf' }, 360 | }); 361 | done(); 362 | }); 363 | uut.sendNotification('fooNotification', { a: 'asdf' }); 364 | }); 365 | 366 | it('calls the onNotification handler when a notification is received', (done) => { 367 | uut.onNotification = function (method: string, params: jrpc.RPCParams) { 368 | expect(this).toBe(undefined); 369 | expect(method).toBe('qwerty'); 370 | expect(params).toEqual({ foo: 'bar' }); 371 | done(); 372 | }; 373 | uut.write(jrpc.notification('qwerty', { foo: 'bar' })); 374 | }); 375 | 376 | it( 377 | 'calls the onRequest handler when a request is received ' + 378 | 'and sends a synchronous response', 379 | (done) => { 380 | uut.onRequest = function (method: string, params: jrpc.RPCParams) { 381 | expect(this).toBe(undefined); 382 | expect(method).toBe('add'); 383 | expect(params).toEqual(expect.any(Array)); 384 | return (params as number[]).reduce((a, b) => a + b, 0); 385 | }; 386 | const expectedResponses = [ 387 | { 388 | kind: 'response', 389 | id: 'asdf', 390 | result: 7, 391 | }, 392 | { 393 | kind: 'response', 394 | id: 'yes', 395 | result: 15, 396 | }, 397 | { 398 | kind: 'response', 399 | id: 123, 400 | result: 8, 401 | }, 402 | ]; 403 | uut.on('data', (value: any) => { 404 | try { 405 | const message = jrpc.parse(value); 406 | expect(message).toEqual(expectedResponses.shift()); 407 | if (expectedResponses.length === 0) { 408 | done(); 409 | } 410 | } catch (e) { 411 | done.fail(e); 412 | } 413 | }); 414 | 415 | uut.write(jrpc.request('asdf', 'add', [1, 4, 2])); 416 | uut.write(jrpc.request('yes', 'add', [5, 5, 5])); 417 | uut.write(jrpc.request(123, 'add', [4, -6, 10])); 418 | }, 419 | ); 420 | 421 | it('sends a response when the onRequest handler returns a promise', (done) => { 422 | uut.onRequest = (method: string, params: jrpc.RPCParams) => { 423 | return Promise.resolve(params); 424 | }; 425 | uut.on('data', (value: any) => { 426 | try { 427 | const message = jrpc.parse(value); 428 | expect(message).toEqual({ 429 | kind: 'response', 430 | id: 'foo', 431 | result: [5, 4, 3], 432 | }); 433 | done(); 434 | } catch (e) { 435 | done.fail(e); 436 | } 437 | }); 438 | uut.write(jrpc.request('foo', '', [5, 4, 3])); 439 | }); 440 | 441 | it('sends a response after the Writable side is closed', (done) => { 442 | uut.onRequest = () => { 443 | uut.end(); 444 | return new Promise((resolve) => setImmediate(resolve)); 445 | }; 446 | uut.on('data', (value) => { 447 | try { 448 | const message = jrpc.parse(value); 449 | expect(message).toMatchObject({ 450 | kind: 'response', 451 | id: 'bar', 452 | }); 453 | done(); 454 | } catch (e) { 455 | done.fail(e); 456 | } 457 | }); 458 | uut.write(jrpc.request('bar', '')); 459 | }); 460 | 461 | describe('sends an internal error response', () => { 462 | function testInternalError(onRequest: peer.RequestHandler) { 463 | uut.onRequest = onRequest; 464 | const promises = Promise.all([ 465 | new Promise((resolve: () => void) => { 466 | uut.on('data', (value: any) => { 467 | const message = jrpc.parse(value); 468 | expect(message).toEqual({ 469 | kind: 'error', 470 | id: 7654, 471 | error: expect.objectContaining({ 472 | code: peer.ErrorCodes.INTERNAL_ERROR, 473 | }), 474 | }); 475 | resolve(); 476 | }); 477 | }), 478 | new Promise((resolve: () => void) => { 479 | uut.on('error', (err: Error) => { 480 | expect(err.message).toBe('I died.'); 481 | resolve(); 482 | }); 483 | }), 484 | ]); 485 | uut.write(jrpc.request(7654, 'foo')); 486 | return promises; 487 | } 488 | 489 | test('when onRequest throws', () => { 490 | return testInternalError(() => { 491 | throw new Error('I died.'); 492 | }); 493 | }); 494 | 495 | test('when onRequest returns a promise which rejects', () => { 496 | return testInternalError(() => { 497 | return Promise.reject(new Error('I died.')); 498 | }); 499 | }); 500 | }); 501 | 502 | describe('sends an error response', () => { 503 | function testErrorResponse( 504 | done: jest.DoneCallback, 505 | onRequest: peer.RequestHandler, 506 | ) { 507 | uut.onRequest = onRequest; 508 | uut.on('error', done.fail); 509 | uut.on('data', (value: any) => { 510 | const message = jrpc.parse(value); 511 | expect(message).toEqual({ 512 | kind: 'error', 513 | id: 'foobar', 514 | error: { 515 | code: 5555, 516 | message: 'You dun goofed', 517 | data: undefined, 518 | }, 519 | }); 520 | done(); 521 | }); 522 | uut.write(jrpc.request('foobar', 'asdf')); 523 | } 524 | 525 | test('when onRequest throws an RPCError', (done) => { 526 | testErrorResponse(done, () => { 527 | throw new peer.RPCError('You dun goofed', 5555); 528 | }); 529 | }); 530 | 531 | test('when onRequest returns a promise which rejects', (done) => { 532 | testErrorResponse(done, () => { 533 | return Promise.reject(new peer.RPCError('You dun goofed', 5555)); 534 | }); 535 | }); 536 | 537 | test('after the Writable side is closed', (done) => { 538 | testErrorResponse(done, () => { 539 | uut.end(); 540 | return new Promise((resolve, reject) => { 541 | setImmediate(() => reject(new peer.RPCError('You dun goofed', 5555))); 542 | }); 543 | }); 544 | }); 545 | }); 546 | 547 | it('forwards a parse error from the deserializer to the remote peer', (done) => { 548 | const parseError = new peer.ParseError('That is not JSON'); 549 | const onProtocolError = jest.fn(); 550 | uut.on('protocolError', onProtocolError); 551 | uut.on('data', (value: any) => { 552 | const message = jrpc.parse(value); 553 | expect(message).toEqual({ 554 | kind: 'error', 555 | id: null, 556 | error: { 557 | code: peer.ErrorCodes.PARSE_ERROR, 558 | message: 'That is not JSON', 559 | data: undefined, 560 | }, 561 | }); 562 | expect(onProtocolError).toBeCalledWith(parseError); 563 | done(); 564 | }); 565 | uut.write(parseError); 566 | }); 567 | 568 | it('throws when request id repeats', () => { 569 | uut.requestIdIterator = [1, 2, 1, 2][Symbol.iterator](); 570 | uut.callMethod('foo'); 571 | uut.callMethod('bar'); 572 | expect(() => uut.callMethod('baz')).toThrow( 573 | /iterator yielded a value which was already used/, 574 | ); 575 | }); 576 | 577 | it('throws when the request id iterator is done', () => { 578 | uut.requestIdIterator = [1][Symbol.iterator](); 579 | uut.callMethod('foo'); 580 | expect(() => uut.callMethod('bar')).toThrow(/Out of Request IDs/); 581 | }); 582 | 583 | it('accepts a request id iterator in the constructor', () => { 584 | const iter = [][Symbol.iterator](); 585 | uut = new peer.Peer({}, { idIterator: iter }); 586 | expect(uut.requestIdIterator).toBe(iter); 587 | }); 588 | 589 | it('rejects all outstanding method calls when the stream ends', () => { 590 | const methodCalls = [uut.callMethod('foo'), uut.callMethod('bar')]; 591 | uut.end(); 592 | return Promise.all( 593 | methodCalls.map((call) => { 594 | return expect(call).rejects.toThrow(peer.RPCStreamClosed); 595 | }), 596 | ); 597 | }); 598 | 599 | describe('after the stream ends', () => { 600 | beforeEach(() => uut.end()); 601 | 602 | it('rejects when attempting to call a method', () => { 603 | return expect(uut.callMethod('foo')).rejects.toThrow( 604 | peer.RPCStreamClosed, 605 | ); 606 | }); 607 | 608 | it('does not throw when attempting to send a notification', () => { 609 | expect(() => uut.sendNotification('foo')).not.toThrow(); 610 | }); 611 | 612 | it('does not throw when attempting to push an error', () => { 613 | expect(() => uut.pushError({ code: 0, message: 'foo' })).not.toThrow(); 614 | }); 615 | }); 616 | 617 | describe('when calling a method with a timeout', () => { 618 | let id: jrpc.RPCID; 619 | let methodCall: Promise<{}>; 620 | 621 | beforeEach((done) => { 622 | uut.once('data', (value: any) => { 623 | const message = jrpc.parse(value); 624 | if (message.kind === 'request') { 625 | id = message.id; 626 | done(); 627 | } else { 628 | done.fail(`unexpected message kind ${message.kind}`); 629 | } 630 | }); 631 | 632 | methodCall = uut.callMethod('foo', undefined, { timeout: 1000 }); 633 | 634 | // Suppress unhandled-rejection warnings 635 | methodCall.catch(() => {}); 636 | }); 637 | 638 | describe('and there is a response', () => { 639 | beforeEach(() => { 640 | uut.write(jrpc.response(id)); 641 | }); 642 | 643 | it('resolves', () => expect(methodCall).resolves.toBeNull()); 644 | it('clears the timeout timer', () => expect(clearTimeout).toBeCalled()); 645 | }); 646 | 647 | describe('and there is no response within the timeout', () => { 648 | beforeEach(() => jest.runTimersToTime(1000)); 649 | 650 | it('rejects with a timeout error', () => { 651 | return expect(methodCall).rejects.toThrow(peer.MethodCallTimeout); 652 | }); 653 | 654 | describe('but a delayed response does arrive', () => { 655 | it('accepts the response without emitting an error', () => { 656 | uut.on('data', fail); 657 | uut.on('error', fail); 658 | uut.write(jrpc.response(id)); 659 | }); 660 | }); 661 | }); 662 | 663 | describe('and the stream ends', () => { 664 | beforeEach(() => uut.end()); 665 | 666 | it('rejects with an error', () => 667 | expect(methodCall).rejects.toThrow(peer.RPCStreamClosed)); 668 | it('clears the timeout timer', () => expect(clearTimeout).toBeCalled()); 669 | }); 670 | }); 671 | }); 672 | -------------------------------------------------------------------------------- /src/peer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A transport-agnostic JSON-RPC 2.0 peer. 3 | * 4 | * The peer is a Duplex stream which makes it easy to create a program 5 | * which talks JSON-RPC 2.0 over any reliable transport. 6 | */ 7 | 8 | import * as stream from 'stream'; 9 | 10 | // tslint:disable-next-line:import-name 11 | import ErrorSubclass from 'error-subclass'; 12 | 13 | import * as jrpc from './protocol'; 14 | 15 | export enum ErrorCodes { 16 | /** 17 | * Invalid JSON was received by the server, or an error occurred on 18 | * the server while parsing the JSON text. 19 | */ 20 | PARSE_ERROR = -32700, 21 | /** The JSON sent is not a valid Request object. */ 22 | INVALID_REQUEST = -32600, 23 | /** The method does not exist / is not available. */ 24 | METHOD_NOT_FOUND = -32601, 25 | /** Invalid method parameter(s). */ 26 | INVALID_PARAMS = -32602, 27 | /** Internal JSON-RPC error. */ 28 | INTERNAL_ERROR = -32603, 29 | } 30 | 31 | /** 32 | * An RPC-related error. 33 | * 34 | * Error objects received from the remote peer are wrapped in an 35 | * RPCError instance for local consumption. 36 | * 37 | * If an RPCError instance is thrown by an RPC method handler, the error 38 | * will be sent to the remote peer as an Error response. 39 | */ 40 | export class RPCError extends ErrorSubclass { 41 | static displayName = 'RPCError'; 42 | 43 | constructor( 44 | message: string, 45 | public readonly code: number = ErrorCodes.INTERNAL_ERROR, 46 | public readonly data?: any, 47 | ) { 48 | super(message); 49 | } 50 | 51 | toErrorObject(): jrpc.ErrorObject { 52 | return { 53 | code: this.code, 54 | message: this.message, 55 | data: this.data, 56 | }; 57 | } 58 | } 59 | 60 | class InvalidRequest extends RPCError { 61 | static displayName = 'InvalidRequest'; 62 | 63 | constructor(message: string, badObject: any) { 64 | super(message, ErrorCodes.INVALID_REQUEST, { badObject }); 65 | } 66 | } 67 | 68 | export class MethodNotFound extends RPCError { 69 | static displayName = 'MethodNotFound'; 70 | 71 | constructor(message: string, data?: any) { 72 | super(message, ErrorCodes.METHOD_NOT_FOUND, data); 73 | } 74 | } 75 | 76 | export class InvalidParams extends RPCError { 77 | static displayName = 'InvalidParams'; 78 | 79 | constructor(message: string, data?: any) { 80 | super(message, ErrorCodes.INVALID_PARAMS, data); 81 | } 82 | } 83 | 84 | /** 85 | * Error while parsing the JSON text of a message. 86 | * 87 | * A ParseError instance can be written to a Peer's writable stream to 88 | * inform the Peer that the remote peer has sent a message that could 89 | * not be parsed. 90 | */ 91 | export class ParseError extends RPCError { 92 | static displayName = 'ParseError'; 93 | 94 | constructor(message: string, data?: any) { 95 | super(message, ErrorCodes.PARSE_ERROR, data); 96 | } 97 | } 98 | 99 | /** 100 | * The method call could not be completed. 101 | */ 102 | export class MethodCallError extends ErrorSubclass { 103 | static displayName = 'MethodCallError'; 104 | 105 | constructor( 106 | public readonly method: string, 107 | message = `RPC call to '${method}' could not be completed`, 108 | ) { 109 | super(message); 110 | } 111 | } 112 | 113 | /** 114 | * No response to a method call was received in time. 115 | */ 116 | export class MethodCallTimeout extends MethodCallError { 117 | static displayName = 'MethodCallTimeout'; 118 | 119 | constructor(method: string) { 120 | super(method, `No response received for RPC call to '${method}'`); 121 | } 122 | } 123 | 124 | /** 125 | * The method call could not be completed as the Peer's writable stream 126 | * has been closed. 127 | */ 128 | export class RPCStreamClosed extends MethodCallError { 129 | static displayName = 'RPCStreamClosed'; 130 | 131 | constructor(method: string) { 132 | super( 133 | method, 134 | `RPC call to '${method}' could not be completed as the RPC stream is closed`, 135 | ); 136 | } 137 | } 138 | 139 | /** 140 | * An unexpected JSON-RPC Response has been received. 141 | */ 142 | export class UnexpectedResponse extends ErrorSubclass { 143 | static displayName = 'UnexpectedResponse'; 144 | 145 | constructor( 146 | public readonly id: jrpc.RPCID, 147 | public readonly kind = 'response', 148 | ) { 149 | // tslint:disable-next-line:max-line-length 150 | super( 151 | `Received ${kind} with id '${JSON.stringify( 152 | id, 153 | )}', which does not correspond to any outstanding RPC call`, 154 | ); 155 | } 156 | } 157 | 158 | /** 159 | * An infinite iterator which yields numeric Request IDs. 160 | */ 161 | export class NumericIdIterator implements Iterator { 162 | state: number; 163 | 164 | constructor(initialValue = 0) { 165 | if ( 166 | initialValue % 1 !== 0 || 167 | initialValue > Number.MAX_SAFE_INTEGER || 168 | initialValue < Number.MIN_SAFE_INTEGER 169 | ) { 170 | throw new TypeError('Initial value must be an integer'); 171 | } 172 | this.state = initialValue; 173 | } 174 | 175 | next() { 176 | const value = this.state; 177 | if (this.state === Number.MAX_SAFE_INTEGER) { 178 | this.state = Number.MIN_SAFE_INTEGER; 179 | } else { 180 | this.state += 1; 181 | } 182 | return { value, done: false }; 183 | } 184 | } 185 | 186 | /** 187 | * A function which handles RPC requests from the remote peer. 188 | * 189 | * The function should either return a value for a synchronous response, 190 | * or a Promise which will resolve to the value for an asynchronous 191 | * response. If the function throws, or if it returns a Promise which 192 | * rejects, an Error response will be sent to the remote peer. 193 | */ 194 | export type RequestHandler = ( 195 | this: void, 196 | method: string, 197 | params: jrpc.RPCParams, 198 | ) => Promise | any; 199 | /** 200 | * A function which handles RPC notifications from the remote peer. 201 | */ 202 | export type NotificationHandler = ( 203 | this: void, 204 | method: string, 205 | params: jrpc.RPCParams, 206 | ) => void; 207 | 208 | export interface PeerOptions { 209 | /** Custom iterator yielding request IDs. Must be infinite. */ 210 | idIterator?: Iterator; 211 | } 212 | 213 | interface PendingRequest { 214 | method: string; 215 | resolve: (value: any) => void; 216 | reject: (reason: Error) => void; 217 | } 218 | 219 | /** 220 | * A JSON-RPC Peer which reads and writes JSON-RPC objects as 221 | * JavaScript objects. 222 | * 223 | * For most applications, the read and write sides of this stream will 224 | * need to be piped through some Transform stream which serializes and 225 | * deserializes the objects, respectively, to a suitable format. This 226 | * gives the application developer complete control over how the 227 | * objects will be sent and received. 228 | * 229 | * A 'protocolError' event is emitted whenever a message is received 230 | * which is not a valid JSON-RPC 2.0 object. The parameter for the 231 | * event is an InvalidRequest instance containing the object which 232 | * failed validation. 233 | */ 234 | export class Peer extends stream.Duplex { 235 | onRequest?: RequestHandler; 236 | onNotification?: NotificationHandler; 237 | requestIdIterator: Iterator; 238 | 239 | private pendingRequests = new Map(); 240 | 241 | ended = false; 242 | 243 | constructor( 244 | handlers: { 245 | onRequest?: RequestHandler; 246 | onNotification?: NotificationHandler; 247 | }, 248 | options: PeerOptions = {}, 249 | ) { 250 | super({ 251 | readableObjectMode: true, 252 | writableObjectMode: true, 253 | allowHalfOpen: false, 254 | }); 255 | this.onRequest = handlers.onRequest; 256 | this.onNotification = handlers.onNotification; 257 | if (options.idIterator) { 258 | this.requestIdIterator = options.idIterator; 259 | } else { 260 | this.requestIdIterator = new NumericIdIterator(); 261 | } 262 | this.once('finish', () => this.onend()); 263 | } 264 | 265 | private onend() { 266 | this.ended = true; 267 | this.pendingRequests.forEach(({ method, reject }) => { 268 | reject(new RPCStreamClosed(method)); 269 | }); 270 | this.pendingRequests.clear(); 271 | } 272 | 273 | /** 274 | * Call an RPC method on the remote peer. 275 | * 276 | * A promise is returned which will resolve to the response value 277 | * returned by the peer. If the remote peer returns an error, the 278 | * promise will reject with an RPCError instance containing the error 279 | * response returned by the peer. 280 | */ 281 | callMethod( 282 | method: string, 283 | params?: jrpc.RPCParams, 284 | { timeout = undefined as number | undefined } = {}, 285 | ): Promise { 286 | if (this.ended) return Promise.reject(new RPCStreamClosed(method)); 287 | const idResult = this.requestIdIterator.next(); 288 | if (idResult.done) { 289 | throw new Error( 290 | 'Out of Request IDs! Request ID iterator is not infinite', 291 | ); 292 | } 293 | const id = idResult.value; 294 | if (this.pendingRequests.has(id)) { 295 | // We could try again with the next value from the iterator, but 296 | // that could result in an infinite loop if the iterator is badly 297 | // behaved. It would take 2^54 method calls before 298 | // NumericIdIterator would start repeating, and it is even less 299 | // likely for a request to be pending for that long without the 300 | // process running out of memory. Basically it's so unlikely for 301 | // this edge-case to happen with a well-behaved id iterator that 302 | // it's not worth trying to recover gracefully. 303 | throw new Error( 304 | 'Request ID iterator yielded a value which was already used in a pending request', 305 | ); 306 | } 307 | 308 | let timer: NodeJS.Timer | undefined; 309 | 310 | const promise = new Promise((resolve, reject) => { 311 | this.push(jrpc.request(id, method, params)); 312 | this.pendingRequests.set(id, { method, resolve, reject }); 313 | 314 | if (timeout !== undefined) { 315 | timer = setTimeout( 316 | () => reject(new MethodCallTimeout(method)), 317 | timeout, 318 | ); 319 | } 320 | }); 321 | 322 | if (timer !== undefined) { 323 | const timerRef = timer; 324 | return promise.then( 325 | (value: any) => { 326 | clearTimeout(timerRef); 327 | return value; 328 | }, 329 | (reason: any) => { 330 | clearTimeout(timerRef); 331 | return Promise.reject(reason); 332 | }, 333 | ); 334 | } 335 | 336 | return promise; 337 | } 338 | 339 | /** Send an RPC Notification object to the remote peer. */ 340 | sendNotification(method: string, params?: jrpc.RPCParams) { 341 | return this.push(jrpc.notification(method, params)); 342 | } 343 | 344 | /** Push an RPC Error object to the remote peer. */ 345 | pushError(error: jrpc.ErrorObject) { 346 | return this.push(jrpc.error(error)); 347 | } 348 | 349 | // tslint:disable-next-line:function-name 350 | _read() { 351 | // No-op; we'll push to the stream whenever we want. 352 | // Backpressure? We don't need no stinkin' backpressure! 353 | } 354 | 355 | // tslint:disable-next-line:function-name 356 | _write(chunk: any, encoding: string, callback: (err?: Error) => void) { 357 | if (chunk instanceof ParseError) { 358 | this.emit('protocolError', chunk); 359 | this.pushError(chunk.toErrorObject()); 360 | callback(); 361 | return; 362 | } 363 | 364 | let message: jrpc.Message; 365 | try { 366 | message = jrpc.parse(chunk); 367 | } catch (e) { 368 | // This peer could be used bidirectionally so we have to assume 369 | // that the malformed message was a request object and respond 370 | // appropriately. 371 | const error = new InvalidRequest('Not a valid JSON-RPC object', chunk); 372 | this.emit('protocolError', error); 373 | this.pushError(error.toErrorObject()); 374 | callback(); 375 | return; 376 | } 377 | 378 | try { 379 | switch (message.kind) { 380 | case 'request': 381 | this.handleRequest(message); 382 | break; 383 | case 'notification': 384 | this.handleNotification(message); 385 | break; 386 | case 'response': 387 | this.handleResponse(message); 388 | break; 389 | case 'error': 390 | this.handleError(message); 391 | break; 392 | } 393 | } catch (e) { 394 | // Invoking the callback with an error argument causes the 395 | // stream to emit an 'error' event. 396 | callback(e); 397 | return; 398 | } 399 | 400 | // We're ready to receive a new message. 401 | callback(); 402 | } 403 | 404 | /** Handle a Request object from the remote peer. */ 405 | handleRequest(request: jrpc.Request) { 406 | if (this.onRequest) { 407 | let promise: Promise; 408 | try { 409 | promise = Promise.resolve( 410 | this.onRequest.call(undefined, request.method, request.params), 411 | ); 412 | } catch (e) { 413 | promise = Promise.reject(e); 414 | } 415 | promise 416 | .then((value) => this.push(jrpc.response(request.id, value))) 417 | .catch((e) => { 418 | let rethrow = false; 419 | let error: jrpc.ErrorObject; 420 | if (e instanceof RPCError) { 421 | error = e.toErrorObject(); 422 | } else { 423 | error = new RPCError( 424 | 'Internal error while processing request', 425 | ).toErrorObject(); 426 | rethrow = true; 427 | } 428 | error.id = request.id; 429 | this.pushError(error); 430 | if (rethrow) { 431 | this.emit('error', e); 432 | } 433 | }); 434 | } else { 435 | this.pushError({ 436 | id: request.id, 437 | code: ErrorCodes.METHOD_NOT_FOUND, 438 | message: 'No request handler attached', 439 | }); 440 | } 441 | } 442 | 443 | /** Handle a Notification object from the remote peer. */ 444 | handleNotification(notification: jrpc.Notification) { 445 | if (this.onNotification) { 446 | this.onNotification.call( 447 | undefined, 448 | notification.method, 449 | notification.params, 450 | ); 451 | } 452 | } 453 | 454 | /** Handle a Response object from the remote peer. */ 455 | handleResponse(response: jrpc.Response) { 456 | const { id, result } = response; 457 | const rpcCall = this.pendingRequests.get(id); 458 | if (rpcCall) { 459 | this.pendingRequests.delete(id); 460 | rpcCall.resolve(result); 461 | } else { 462 | throw new UnexpectedResponse(id); 463 | } 464 | } 465 | 466 | /** 467 | * Handle an Error object from the remote peer. 468 | * 469 | * If the error corresponds to a pending request, the request's 470 | * promise is rejected. Otherwise the error is thrown as a JS 471 | * Error object. 472 | */ 473 | handleError(message: jrpc.Error) { 474 | const { id, error } = message; 475 | const rpcError = new RPCError(error.message, error.code, error.data); 476 | if (id !== null) { 477 | const rpcCall = this.pendingRequests.get(id); 478 | if (rpcCall) { 479 | this.pendingRequests.delete(id); 480 | rpcCall.reject(rpcError); 481 | } else { 482 | throw new UnexpectedResponse(id, 'error'); 483 | } 484 | } else { 485 | throw rpcError; 486 | } 487 | } 488 | } 489 | -------------------------------------------------------------------------------- /src/protocol.test.ts: -------------------------------------------------------------------------------- 1 | // null is not interchangeable with undefined in JSON-RPC. 2 | // tslint:disable:no-null-keyword 3 | 4 | import * as jrpc from './protocol'; 5 | 6 | declare class Object { 7 | static entries(object: object): [string, any][]; 8 | } 9 | 10 | describe('parse', () => { 11 | describe('a valid Request', () => { 12 | it.each<[string, Partial]>([ 13 | ['with a numeric id', { id: -7, method: 'foo.doBar/baz' }], 14 | ['with a string id', { id: 'three', method: 'hello' }], 15 | ['with params as empty array', { id: 55, method: 'foo', params: [] }], 16 | [ 17 | 'with params by-position', 18 | { 19 | id: 42, 20 | method: 'bar', 21 | params: [3, 'three', { three: 3 }, ['four', 'five']], 22 | }, 23 | ], 24 | [ 25 | 'with params by-name', 26 | { 27 | id: 8, 28 | method: 'baz', 29 | params: { foo: 'bar', quux: 'yes', 3: 5 } as object, 30 | }, 31 | ], 32 | ])('%s', (_, vector) => { 33 | const { id, method, params } = vector; 34 | // We can't reassemble the message from the spread object as any 35 | // properties that do not exist in the test vector would come into 36 | // existence on the reassembled message, with a value of 37 | // undefined. This would invalidate the tests as a property 38 | // that exists with the value undefined cannot be represented in 39 | // JSON. 40 | expect(jrpc.parse({ ...vector, jsonrpc: '2.0' })).toEqual({ 41 | id, 42 | method, 43 | params, 44 | kind: 'request', 45 | }); 46 | }); 47 | }); 48 | 49 | describe('a valid Notification', () => { 50 | it.each<[string, Partial]>([ 51 | ['with no params', { method: 'notifoo' }], 52 | ['with params as empty array', { method: 'blah', params: [] }], 53 | [ 54 | 'with params by-position', 55 | { 56 | method: 'a.method', 57 | params: [['hello', 'bonjour', -7.2], 'abc', { a: 3, b: 'four' }], 58 | }, 59 | ], 60 | [ 61 | 'with params by-name', 62 | { 63 | method: 'yo', 64 | params: { 65 | a: 1, 66 | b: 'two', 67 | c: [3, 'four', 5], 68 | d: { e: 'f' }, 69 | }, 70 | }, 71 | ], 72 | ])('%s', (_, vector) => { 73 | const { method, params } = vector; 74 | expect(jrpc.parse({ ...vector, jsonrpc: '2.0' })).toEqual({ 75 | method, 76 | params, 77 | kind: 'notification', 78 | }); 79 | }); 80 | }); 81 | 82 | describe('a valid Response', () => { 83 | it.each<[string, Partial]>([ 84 | ['with numeric id', { id: -7, result: null }], 85 | ['with string id', { id: 'abc', result: null }], 86 | ['with numeric result', { id: 5, result: 3.7 }], 87 | ['with string result', { id: 48, result: 'hello' }], 88 | ['with boolean result', { id: 1, result: true }], 89 | [ 90 | 'with array result', 91 | { 92 | id: 86, 93 | result: ['one', 2, { three: 3 }, [4]], 94 | }, 95 | ], 96 | ['with object result', { id: 104, result: { yes: 'yup' } }], 97 | ])('%s', (_, vector) => { 98 | const { id, result } = vector; 99 | expect(jrpc.parse({ ...vector, jsonrpc: '2.0' })).toEqual({ 100 | id, 101 | result, 102 | kind: 'response', 103 | }); 104 | }); 105 | }); 106 | 107 | describe('a valid Error', () => { 108 | it.each<[string, Partial]>([ 109 | [ 110 | 'with no id or data', 111 | { 112 | id: null, 113 | error: { code: -37000, message: 'you dun goofed' }, 114 | }, 115 | ], 116 | [ 117 | 'with numeric id, no data', 118 | { 119 | id: 7, 120 | error: { code: 42, message: 'everything' }, 121 | }, 122 | ], 123 | [ 124 | 'with string id, no data', 125 | { 126 | id: 'asdf', 127 | error: { code: 0, message: 'zero' }, 128 | }, 129 | ], 130 | [ 131 | 'with boolean data', 132 | { 133 | id: 2, 134 | error: { code: 33, message: 'm', data: false }, 135 | }, 136 | ], 137 | [ 138 | 'with numeric data', 139 | { 140 | id: 3, 141 | error: { code: 34, message: 'm', data: 8 }, 142 | }, 143 | ], 144 | [ 145 | 'with string data', 146 | { 147 | id: 4, 148 | error: { code: 35, message: 'q', data: 'nope' }, 149 | }, 150 | ], 151 | [ 152 | 'with null data', 153 | { 154 | id: 88, 155 | error: { code: 123, message: 'yes', data: null }, 156 | }, 157 | ], 158 | [ 159 | 'with array data', 160 | { 161 | id: 5, 162 | error: { code: 36, message: 'r', data: [1, 2, 'three'] }, 163 | }, 164 | ], 165 | [ 166 | 'with object data', 167 | { 168 | id: 6, 169 | error: { code: 37, message: 's', data: { foo: 'bar' } }, 170 | }, 171 | ], 172 | ])('%s', (_, vector) => { 173 | const { id, error } = vector; 174 | expect(jrpc.parse({ ...vector, jsonrpc: '2.0' })).toEqual({ 175 | id, 176 | error, 177 | kind: 'error', 178 | }); 179 | }); 180 | }); 181 | 182 | describe('rejects a malformed message', () => { 183 | const vectors: { [key: string]: any } = { 184 | 'numeric value': 3, 185 | 'boolean value': false, 186 | 'string value': 'hello', 187 | 'array of nonsense': [1, 2, 3], 188 | 189 | 'request without jsonrpc key': { 190 | id: 7, 191 | method: 'hello', 192 | params: { foo: 'bar', baz: 5 }, 193 | }, 194 | 'response without jsonrpc key': { 195 | id: 7, 196 | result: true, 197 | }, 198 | 'notification without jsonrpc key': { 199 | method: 'notify', 200 | params: [3, 4, 5], 201 | }, 202 | 'error without jsonrpc key': { 203 | id: 4, 204 | error: { code: 8, message: 'yo', data: 'yoyo' }, 205 | }, 206 | 207 | 'request with wrong jsonrpc type': { 208 | jsonrpc: 2, 209 | id: 1344, 210 | method: 'argh', 211 | }, 212 | 'request with wrong jsonrpc version': { 213 | jsonrpc: '3.0', 214 | id: -2, 215 | method: 'the future is now', 216 | }, 217 | 'notification with wrong jsonrpc version': { 218 | jsonrpc: '1.4', 219 | method: 'parallel universe', 220 | }, 221 | 'response with wrong jsonrpc version': { 222 | jsonrpc: '5', 223 | id: 66, 224 | }, 225 | 'error with wrong jsonrpc version': { 226 | jsonrpc: 'two point oh', 227 | id: null, 228 | error: { code: 1, message: 'words' }, 229 | }, 230 | 231 | 'request with fractional id': { 232 | jsonrpc: '2.0', 233 | id: 3.4, 234 | method: 'fractions!', 235 | }, 236 | 'request with structured id': { 237 | jsonrpc: '2.0', 238 | id: [3], 239 | method: 'nope', 240 | }, 241 | 'request with result key': { 242 | jsonrpc: '2.0', 243 | id: 8, 244 | method: 'hey', 245 | result: 'foo', 246 | }, 247 | 'request with params and result': { 248 | jsonrpc: '2.0', 249 | id: 115, 250 | method: 'uhoh', 251 | params: [1, 'two'], 252 | result: 'three', 253 | }, 254 | 'request with error key': { 255 | jsonrpc: '2.0', 256 | id: 3, 257 | method: 'woo', 258 | error: { code: 1, message: 'yeah' }, 259 | }, 260 | 'request with numeric method': { 261 | jsonrpc: '2.0', 262 | id: 5, 263 | method: 6, 264 | }, 265 | 'request with boolean method': { 266 | jsonrpc: '2.0', 267 | id: 7, 268 | method: true, 269 | }, 270 | 'request with array method': { 271 | jsonrpc: '2.0', 272 | id: 6543, 273 | method: ['do this', 'then that'], 274 | }, 275 | 'request with object method': { 276 | jsonrpc: '2.0', 277 | id: 432, 278 | method: { do: 'that' }, 279 | }, 280 | 281 | 'notification with result': { 282 | jsonrpc: '2.0', 283 | method: 'foo', 284 | result: 'wut', 285 | }, 286 | 'notification with params and result': { 287 | jsonrpc: '2.0', 288 | method: 'asdf', 289 | params: [1, 6], 290 | result: 'seven', 291 | }, 292 | 'notification with error key': { 293 | jsonrpc: '2.0', 294 | method: 'asdfasdf', 295 | error: { code: 33, message: 'yes' }, 296 | }, 297 | 298 | 'response with error key': { 299 | jsonrpc: '2.0', 300 | id: 89, 301 | result: 'yes', 302 | error: { code: 1, message: 'waitaminute' }, 303 | }, 304 | 'response with fractional id': { 305 | jsonrpc: '2.0', 306 | id: 6.4, 307 | result: 'foo', 308 | }, 309 | 310 | 'error with missing id': { 311 | jsonrpc: '2.0', 312 | error: { code: 2, message: 'hmm' }, 313 | }, 314 | 'error with fractional id': { 315 | jsonrpc: '2.0', 316 | id: 8.1, 317 | error: { code: 6, message: 'yo' }, 318 | }, 319 | 'error with missing code': { 320 | jsonrpc: '2.0', 321 | id: 7, 322 | error: { message: 'wut' }, 323 | }, 324 | 'error with missing message': { 325 | jsonrpc: '2.0', 326 | id: 654, 327 | error: { code: 34, data: 'yup' }, 328 | }, 329 | }; 330 | for (const [desc, vector] of Object.entries(vectors)) { 331 | test(desc, () => { 332 | expect(() => console.log(jrpc.parse(vector))).toThrow(); 333 | }); 334 | } 335 | }); 336 | }); 337 | 338 | describe('request serializer', () => { 339 | it('serializes a request with a numeric id', () => 340 | expect(jrpc.request(-7, 'foo')).toMatchSnapshot()); 341 | it('serializes a request with a string id', () => 342 | expect(jrpc.request('foo', 'method')).toMatchSnapshot()); 343 | it('serializes a request with an array of params', () => 344 | expect(jrpc.request(-803, 'mmm', [3, 'seven'])).toMatchSnapshot()); 345 | it('does not allow fractional IDs', () => 346 | expect(() => jrpc.request(5.1, 'woop')).toThrow(TypeError)); 347 | }); 348 | 349 | describe('notification serializer', () => { 350 | it('serializes a notification with no params', () => 351 | expect(jrpc.notification('aNotification')).toMatchSnapshot()); 352 | it('serializes a notification with an array of params', () => 353 | expect( 354 | jrpc.notification('asdffdsa', ['one', 2, 'three']), 355 | ).toMatchSnapshot()); 356 | }); 357 | 358 | describe('response serializer', () => { 359 | it('serializes a response with a numeric id', () => 360 | expect(jrpc.response(7654)).toMatchSnapshot()); 361 | it('serializes a response with a string id', () => 362 | expect( 363 | jrpc.response('ayedee', { result: { result: ['result'] } }), 364 | ).toMatchSnapshot()); 365 | it('does not allow fractional IDs', () => 366 | expect(() => jrpc.response(1.1)).toThrow(TypeError)); 367 | }); 368 | 369 | describe('error serializer', () => { 370 | it('does not allow fractional IDs', () => 371 | expect(() => jrpc.error({ id: 3.14, code: 1, message: '' })).toThrow( 372 | TypeError, 373 | )); 374 | it('does not allow fractional codes', () => 375 | expect(() => jrpc.error({ id: 3, code: 1.2, message: '' })).toThrow( 376 | TypeError, 377 | )); 378 | it('serializes an error with a numeric id', () => 379 | expect(jrpc.error({ id: 5, code: 1, message: '' })).toMatchSnapshot()); 380 | it('serializes an error with a null id', () => 381 | expect(jrpc.error({ id: null, code: 0, message: '' })).toMatchSnapshot()); 382 | it('converts an undefined id to null', () => 383 | expect(jrpc.error({ code: 1, message: '' }).id).toBeNull()); 384 | it('serializes error data', () => { 385 | const data = { foo: 'bar', baz: ['a', 'b', 'c'] }; 386 | expect(jrpc.error({ data, id: 3, code: 7, message: 'yarr' })).toMatchObject( 387 | { 388 | jsonrpc: '2.0', 389 | id: 3, 390 | error: { data, code: 7, message: 'yarr' }, 391 | }, 392 | ); 393 | }); 394 | }); 395 | -------------------------------------------------------------------------------- /src/protocol.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JSON-RPC protocol types, along with functions for constructing and 3 | * parsing/validating JSON-RPC data structures. 4 | * 5 | * The constructor and parser functions all operate on native JavaScript 6 | * data structures. De/serializing from/to JSON must be handled 7 | * externally. 8 | */ 9 | 10 | import * as t from 'io-ts'; 11 | 12 | // Runtime types are variables which are used like types, which is 13 | // reflected in their PascalCase naming scheme. 14 | /* tslint:disable:variable-name */ 15 | 16 | /** The JSON primitive types (string, number, boolean, null) */ 17 | export const Primitive = t.union([t.string, t.number, t.boolean, t.null]); 18 | export type Primitive = t.TypeOf; 19 | 20 | /** The JSON structured types (object, array) */ 21 | export const Structured = t.union([t.dictionary(t.string, t.any), t.Array]); 22 | export type Structured = t.TypeOf; 23 | 24 | /** Any of the JSON types; not undefined */ 25 | export const Some = t.union([Primitive, Structured]); 26 | export type Some = t.TypeOf; 27 | 28 | /** JSON-RPC Request or Response id */ 29 | export const RPCID = t.union([t.Integer, t.string]); 30 | export type RPCID = t.TypeOf; 31 | 32 | /** JSON-RPC Request params */ 33 | export const RPCParams = t.union([Structured, t.undefined]); 34 | export type RPCParams = t.TypeOf; 35 | 36 | /** `error` member of a JSON-RPC Error object */ 37 | export const RPCError = t.intersection([ 38 | t.interface({ 39 | code: t.Integer, 40 | message: t.string, 41 | }), 42 | t.partial({ 43 | data: Some, 44 | }), 45 | ]); 46 | export type RPCError = t.TypeOf; 47 | 48 | /** JSON-RPC Request object */ 49 | export const RequestJSON = t.intersection([ 50 | t.interface({ 51 | jsonrpc: t.literal('2.0'), 52 | method: t.string, 53 | id: RPCID, 54 | }), 55 | t.partial({ 56 | params: RPCParams, 57 | result: t.undefined, 58 | error: t.undefined, 59 | }), 60 | ]); 61 | export type RequestJSON = t.TypeOf; 62 | 63 | /** JSON-RPC Notification object */ 64 | export const NotificationJSON = t.intersection([ 65 | t.interface({ 66 | jsonrpc: t.literal('2.0'), 67 | method: t.string, 68 | }), 69 | t.partial({ 70 | params: RPCParams, 71 | id: t.undefined, 72 | result: t.undefined, 73 | error: t.undefined, 74 | }), 75 | ]); 76 | export type NotificationJSON = t.TypeOf; 77 | 78 | /** JSON-RPC Response object */ 79 | export const ResponseJSON = t.intersection([ 80 | t.interface({ 81 | jsonrpc: t.literal('2.0'), 82 | result: Some, 83 | id: t.union([t.Integer, t.string]), 84 | }), 85 | t.partial({ 86 | method: t.undefined, 87 | params: t.undefined, 88 | error: t.undefined, 89 | }), 90 | ]); 91 | export type ResponseJSON = t.TypeOf; 92 | 93 | /** JSON-RPC Error object */ 94 | export const ErrorJSON = t.intersection([ 95 | t.interface({ 96 | jsonrpc: t.literal('2.0'), 97 | error: RPCError, 98 | id: t.union([RPCID, t.null]), 99 | }), 100 | t.partial({ 101 | method: t.undefined, 102 | params: t.undefined, 103 | result: t.undefined, 104 | }), 105 | ]); 106 | export type ErrorJSON = t.TypeOf; 107 | 108 | /** Deserialized JSON-RPC Request */ 109 | export interface Request { 110 | kind: 'request'; 111 | method: string; 112 | params?: Structured; 113 | id: RPCID; 114 | } 115 | 116 | /** Deserialized JSON-RPC Notification */ 117 | export interface Notification { 118 | kind: 'notification'; 119 | method: string; 120 | params?: Structured; 121 | } 122 | 123 | /** Deserialized JSON-RPC Response */ 124 | export interface Response { 125 | kind: 'response'; 126 | result: Some; 127 | id: RPCID; 128 | } 129 | 130 | /** Deserialized JSON-RPC Error */ 131 | export interface Error { 132 | kind: 'error'; 133 | error: { 134 | code: number; 135 | message: string; 136 | data?: Some; 137 | }; 138 | id: RPCID | null; 139 | } 140 | 141 | /** Parsed and categorized JSON-RPC message */ 142 | export type Message = Request | Notification | Response | Error; 143 | 144 | /* tslint:enable:variable-name */ 145 | 146 | /** Parse an object as a JSON-RPC message. */ 147 | export function parse(obj: object): Message { 148 | if (RequestJSON.is(obj)) { 149 | return { 150 | kind: 'request', 151 | id: obj.id, 152 | method: obj.method, 153 | params: obj.params, 154 | }; 155 | } 156 | 157 | if (NotificationJSON.is(obj)) { 158 | return { 159 | kind: 'notification', 160 | method: obj.method, 161 | params: obj.params, 162 | }; 163 | } 164 | 165 | if (ResponseJSON.is(obj)) { 166 | return { 167 | kind: 'response', 168 | id: obj.id, 169 | result: obj.result, 170 | }; 171 | } 172 | 173 | if (ErrorJSON.is(obj)) { 174 | return { 175 | kind: 'error', 176 | id: obj.id, 177 | error: obj.error, 178 | }; 179 | } 180 | 181 | throw new TypeError('Not a valid JSON-RPC object'); 182 | } 183 | 184 | /** Construct a JSON-RPC Request object. */ 185 | export function request( 186 | id: RPCID, 187 | method: string, 188 | params?: RPCParams, 189 | ): RequestJSON { 190 | if (!RPCID.is(id)) { 191 | throw new TypeError('Request ID must be a string or integer'); 192 | } 193 | return { id, method, params, jsonrpc: '2.0' }; 194 | } 195 | 196 | /** Construct a JSON-RPC Notification object. */ 197 | export function notification( 198 | method: string, 199 | params?: RPCParams, 200 | ): NotificationJSON { 201 | return { method, params, jsonrpc: '2.0' }; 202 | } 203 | 204 | /** Construct a JSON-RPC Response. */ 205 | export function response(id: RPCID, result?: Some): ResponseJSON { 206 | if (!RPCID.is(id)) { 207 | throw new TypeError('Response ID must be a string or integer'); 208 | } 209 | // tslint:disable-next-line:no-null-keyword 210 | const nulledResult = result !== undefined ? result : null; 211 | return { id, jsonrpc: '2.0', result: nulledResult }; 212 | } 213 | 214 | /** Arguments for constructing a JSON-RPC Error object. */ 215 | export interface ErrorObject extends RPCError { 216 | id?: RPCID | null; 217 | } 218 | 219 | /** Construct a JSON-RPC Error object. */ 220 | export function error(error: ErrorObject): ErrorJSON { 221 | const { id, code, message, data } = error; 222 | // tslint:disable-next-line:no-null-keyword 223 | const nulledId = id !== undefined ? id : null; 224 | // tslint:disable-next-line:no-null-keyword 225 | if (nulledId !== null && !RPCID.is(id)) { 226 | throw new TypeError('Error ID must be string, integer, null or undefined'); 227 | } 228 | if (!t.Integer.is(code)) { 229 | throw new TypeError('Error code must be an integer'); 230 | } 231 | return { jsonrpc: '2.0', id: nulledId, error: { code, message, data } }; 232 | } 233 | -------------------------------------------------------------------------------- /src/serialize.test.ts: -------------------------------------------------------------------------------- 1 | import { ParseError } from './peer'; 2 | import * as serialize from './serialize'; 3 | 4 | describe('ParseJSON', () => { 5 | let parser: serialize.ParseJSON; 6 | 7 | beforeEach(() => { 8 | parser = new serialize.ParseJSON(); 9 | }); 10 | 11 | it('parses valid JSON text to a JS object', (done) => { 12 | parser.on('data', (chunk: any) => { 13 | expect(chunk).toEqual({ 14 | foo: 'bar', 15 | baz: 123, 16 | }); 17 | done(); 18 | }); 19 | parser.write('{"foo": "bar", "baz": 123}'); 20 | }); 21 | 22 | it('pushes a ParseError for invalid JSON text', (done) => { 23 | parser.on('data', (chunk: any) => { 24 | expect(chunk).toEqual(expect.any(ParseError)); 25 | done(); 26 | }); 27 | parser.write('}])'); 28 | }); 29 | 30 | it('handles unexpected errors gracefullly', (done) => { 31 | parser.on('error', () => done()); 32 | const buf = Buffer.from(''); 33 | buf.toString = () => { 34 | throw new Error('Unexpected error!'); 35 | }; 36 | parser.write(buf); 37 | }); 38 | }); 39 | 40 | describe('StringifyJSON', () => { 41 | it('stringifies arbitrary objects to JSON text', (done) => { 42 | const stringifier = new serialize.StringifyJSON(); 43 | stringifier.on('data', (chunk: any) => { 44 | expect(typeof chunk).toBe('string'); 45 | expect(chunk).toEqual('["one",2,true]'); 46 | done(); 47 | }); 48 | stringifier.write(['one', 2, true]); 49 | }); 50 | 51 | it('pretty-prints JSON text', (done) => { 52 | const vector = { 53 | foo: 'bar', 54 | baz: 567, 55 | quux: [1, 2, 3, 4, 5], 56 | xyzzy: true, 57 | }; 58 | const stringifier = new serialize.StringifyJSON(4); 59 | stringifier.on('data', (chunk: string) => { 60 | expect(chunk).toEqual(JSON.stringify(vector, undefined, 4)); 61 | done(); 62 | }); 63 | stringifier.write(vector); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/serialize.ts: -------------------------------------------------------------------------------- 1 | import * as stream from 'stream'; 2 | 3 | import { ParseError } from './peer'; 4 | 5 | /** 6 | * Parse each chunk as a JSON document. If the chunk is invalid JSON, 7 | * a peer.ParseError object is pushed instead. 8 | * 9 | * This stream is suitable for deserializing JSON-RPC messages for a 10 | * peer.Peer instance. 11 | */ 12 | export class ParseJSON extends stream.Transform { 13 | constructor() { 14 | super({ readableObjectMode: true }); 15 | } 16 | 17 | // tslint:disable-next-line:function-name 18 | _transform(chunk: Buffer, encoding: string, callback: (err?: Error) => void) { 19 | try { 20 | this.push(JSON.parse(chunk.toString())); 21 | } catch (e) { 22 | if (e instanceof SyntaxError) { 23 | this.push(new ParseError(e.message)); 24 | } else { 25 | callback(e); 26 | return; 27 | } 28 | } 29 | callback(); 30 | } 31 | } 32 | 33 | /** 34 | * Stringify each chunk to a JSON document. 35 | */ 36 | export class StringifyJSON extends stream.Transform { 37 | whitespace: string | number | undefined; 38 | 39 | /** 40 | * @param whitespace Adds indentation, white space, and line break 41 | * characters to the stringified JSON text to make it easier to read. 42 | */ 43 | constructor(whitespace?: string | number) { 44 | super({ writableObjectMode: true, encoding: 'utf8' }); 45 | this.whitespace = whitespace; 46 | } 47 | 48 | // tslint:disable-next-line:function-name 49 | _transform(chunk: object, encoding: string, callback: (err?: Error) => void) { 50 | this.push(JSON.stringify(chunk, undefined, this.whitespace)); 51 | callback(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/*.test.ts", "**/*.spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "lib": ["es6"], 6 | "strict": true, 7 | "moduleResolution": "node", 8 | "removeComments": true, 9 | "declaration": true, 10 | "outDir": "lib", 11 | "baseUrl": ".", 12 | "paths": { 13 | "*": ["src/types/*", "node_modules/*"] 14 | } 15 | }, 16 | "include": ["src/**/*"] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.module.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "module": "es6", 5 | "declaration": false, 6 | "outDir": "mod" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-config-airbnb", "tslint-config-prettier"], 3 | "rules": { 4 | "no-inferrable-types": true 5 | } 6 | } 7 | --------------------------------------------------------------------------------