diff --git a/.gitignore b/.gitignore index 44c2154..6f9ca41 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ npm-debug.log.* pnpm-debug.log pnpm-debug.log.* yarn-error.log +package-lock.json diff --git a/package.json b/package.json index e311fae..22b3364 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "email": "julien.fontanet@isonoe.net" }, "preferGlobal": false, - "main": "dist/", + "main": "dist/index.js", + "typings": "dist/index.d.ts", "bin": {}, "files": [ "dist/" @@ -30,40 +31,30 @@ "node": ">=4" }, "dependencies": { - "@babel/runtime": "7.0.0-rc.1", - "json-rpc-protocol": "^0.12.0", - "lodash": "^4.17.4" + "json-rpc-protocol": "^0.12.0" }, "devDependencies": { - "@babel/cli": "7.0.0-rc.1", - "@babel/core": "7.0.0-rc.1", - "@babel/plugin-transform-runtime": "7.0.0-rc.1", - "@babel/preset-env": "7.0.0-rc.1", - "babel-core": "^7.0.0-bridge.0", - "babel-eslint": "^8.0.3", - "babel-jest": "^23.0.1", - "babel-plugin-lodash": "^3.3.2", + "@types/node": "^10.3.1", "cross-env": "^5.1.1", - "eslint": "^5.3.0", - "eslint-config-standard": "^11.0.0-beta.0", - "eslint-plugin-import": "^2.8.0", - "eslint-plugin-node": "^7.0.1", - "eslint-plugin-promise": "^3.6.0", - "eslint-plugin-standard": "^3.0.1", "husky": "^0.14.3", "jest": "^23.1.0", - "rimraf": "^2.6.2" + "rimraf": "^2.6.2", + "ts-jest": "^22.4.6", + "tslint": "^5.10.0", + "tslint-config-standard": "^7.0.0", + "typescript": "^2.9.1" }, "scripts": { - "build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/", + "build": "cross-env NODE_ENV=production tsc", "clean": "rimraf dist/", "commitmsg": "npm test", - "dev": "cross-env NODE_ENV=development babel --watch --source-maps --out-dir=dist/ src/", + "dev": "cross-env NODE_ENV=development tsc --watch", "dev-test": "jest --bail --watch", "prebuild": "yarn run clean", "predev": "npm run prebuild", "prepublishOnly": "npm run build", - "pretest": "eslint --ignore-path .gitignore --fix .", + "pretest": "npm run lint:ts", + "lint:ts": "tslint --project tsconfig.json && tsc --noEmit", "test": "jest" }, "jest": { @@ -72,6 +63,13 @@ "roots": [ "/src" ], - "testRegex": "\\.spec\\.js$" + "transform": { + "^.+\\.ts?$": "ts-jest" + }, + "testRegex": "\\.spec\\.ts$", + "moduleFileExtensions": [ + "ts", + "js" + ] } } diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 580480c..0000000 --- a/src/index.js +++ /dev/null @@ -1,219 +0,0 @@ -import { EventEmitter } from 'events' -import { - forEach, - isArray, - map, -} from 'lodash' -import { - format, - JsonRpcError, - MethodNotFound, - parse, -} from 'json-rpc-protocol' - -// =================================================================== - -// Give access to low level interface. -export * from 'json-rpc-protocol' - -// =================================================================== - -function makeAsync (fn) { - return function () { - return new Promise(resolve => resolve(fn.apply(this, arguments))) - } -} - -const parseMessage = message => { - try { - return parse(message) - } catch (error) { - throw format.error(null, error) - } -} - -// Default onMessage implementation: -// -// - ignores notifications -// - throw MethodNotFound for all requests -function defaultOnMessage (message) { - if (message.type === 'request') { - throw new MethodNotFound(message.method) - } -} - -function noop () {} - -// Starts the autoincrement id with the JavaScript minimal safe integer to have -// more room before running out of integers (it's very far fetched but a very -// long running process with a LOT of messages could run out). -let nextRequestId = -9007199254740991 - -// =================================================================== - -export default class Peer extends EventEmitter { - constructor (onMessage = defaultOnMessage) { - super() - - this._asyncEmitError = process.nextTick.bind(process, this.emit.bind(this), 'error') - this._handle = makeAsync(onMessage) - this._deferreds = Object.create(null) - } - - _getDeferred (id) { - const deferred = this._deferreds[id] - delete this._deferreds[id] - return deferred - } - - async exec (message, data) { - message = parseMessage(message) - - if (isArray(message)) { - const results = [] - - // Only returns non empty results. - await Promise.all(map(message, message => { - return this.exec(message, data).then(result => { - if (result !== undefined) { - results.push(result) - } - }) - })) - - return results - } - - const {type} = message - - if (type === 'error') { - const {id} = message - - // Some errors do not have an identifier, simply discard them. - if (id === null) { - return - } - - const {error} = message - this._getDeferred(id).reject( - // TODO: it would be great if we could return an error with of - // a more specific type (and custom types with registration). - new JsonRpcError(error.message, error.code, error.data) - ) - } else if (type === 'response') { - this._getDeferred(message.id).resolve(message.result) - } else if (type === 'notification') { - this._handle(message, data).catch(noop) - } else { - return this._handle(message, data).then( - (result) => format.response(message.id, result === undefined ? null : result), - (error) => format.error( - message.id, - - // If the method name is not defined, default to the method passed - // in the request. - (error instanceof MethodNotFound && !error.data) - ? new MethodNotFound(message.method) - : error - ) - ) - } - } - - // Fails all pending requests. - failPendingRequests (reason) { - const {_deferreds: deferreds} = this - - forEach(deferreds, ({reject}, id) => { - reject(reason) - delete deferreds[id] - }) - } - - /** - * This function should be called to send a request to the other end. - * - * TODO: handle multi-requests. - */ - request (method, params) { - return new Promise((resolve, reject) => { - const requestId = nextRequestId++ - - this.push(format.request(requestId, method, params)) - - this._deferreds[requestId] = {resolve, reject} - }) - } - - /** - * This function should be called to send a notification to the other end. - * - * TODO: handle multi-notifications. - */ - async notify (method, params) { - this.push(format.notification(method, params)) - } - - // minimal stream interface - - end (data, encoding, cb) { - if (typeof data === 'function') { - process.nextTick(data) - } else { - if (typeof encoding === 'function') { - process.nextTick(encoding) - } else if (typeof cb === 'function') { - process.nextTick(cb) - } - - if (data !== undefined) { - this.write(data) - } - } - } - - pipe (writable) { - const listeners = { - data: data => writable.write(data), - end: () => { - writable.end() - clean() - }, - } - - const clean = () => forEach(listeners, (listener, event) => { - this.removeListener(event, listener) - }) - forEach(listeners, (listener, event) => { - this.on(event, listener) - }) - - return writable - } - - push (data) { - return data === null - ? this.emit('end') - : this.emit('data', data) - } - - write (message) { - let cb - const n = arguments.length - if (n > 1 && typeof (cb = arguments[n - 1]) === 'function') { - process.nextTick(cb) - } - - this.exec(String(message)).then( - response => { - if (response !== undefined) { - this.push(response) - } - }, - this._asyncEmitError - ) - - // indicates that other calls to `write` are allowed - return true - } -} diff --git a/src/index.spec.js b/src/index.spec.ts similarity index 59% rename from src/index.spec.js rename to src/index.spec.ts index fa0d95d..981f31c 100644 --- a/src/index.spec.js +++ b/src/index.spec.ts @@ -1,39 +1,53 @@ /* eslint-env jest */ -import { format } from 'json-rpc-protocol' - -import Peer, {MethodNotFound} from './' +import { + format, + JsonRpcParamsSchemaByPositional, + JsonRpcPayload, + JsonRpcPayloadRequest +} from 'json-rpc-protocol' + +import { + AnyFunction, + MethodNotFound, + Peer +} from './' // =================================================================== describe('Peer', () => { - let server, client - const messages = [] + let server: Peer + let client: Peer + + const messages: JsonRpcPayload[] = [] beforeAll(() => { - server = new Peer(message => { + server = new Peer((message) => { messages.push(message) if (message.type === 'notification') { return } - const {method} = message + const { method } = message as JsonRpcPayloadRequest if (method === 'circular value') { - const a = [] + const a: any[] = [] a.push(a) return a } + const requestPayload = message as JsonRpcPayloadRequest + const params = requestPayload.params as any as JsonRpcParamsSchemaByPositional + if (method === 'identity') { - return message.params[0] + return params[0] } if (method === 'wait') { - return new Promise(resolve => { - setTimeout(resolve, message.params[0]) + return new Promise((resolve) => { + setTimeout(resolve, params[0]) }) } @@ -55,7 +69,7 @@ describe('Peer', () => { client.notify('foo') expect(messages.length).toBe(1) - expect(messages[0].method).toBe('foo') + expect((messages[0] as any as JsonRpcPayloadRequest).method).toBe('foo') expect(messages[0].type).toBe('notification') }) @@ -63,11 +77,11 @@ describe('Peer', () => { const result = client.request('identity', [42]) expect(messages.length).toBe(1) - expect(messages[0].method).toBe('identity') + expect((messages[0] as any as JsonRpcPayloadRequest).method).toBe('identity') expect(messages[0].type).toBe('request') - return result.then(result => { - expect(result).toBe(42) + return result.then((ret) => { + expect(ret).toBe(42) }) }) @@ -76,29 +90,33 @@ describe('Peer', () => { () => { expect('should have been rejected').toBeFalsy() }, - error => { + (error) => { expect(error.code).toBe(-32601) expect(error.data).toBe('foo') } ) }) - it('#request() in parallel', function () { + it('#request() in parallel', () => { const start = Date.now() return Promise.all([ - client.request('wait', [25]), - client.request('wait', [25]), + client.request('wait', [100]), + client.request('wait', [100]), + client.request('wait', [100]) ]).then(() => { - expect(Date.now() - start).toBeLessThan(40) + expect(Date.now() - start).toBeLessThan(200) }) }) - describe('#write()', function () { - it('emits an error event if the response message cannot be formatted', function (done) { + describe('#write()', () => { + it('emits an error event if the response message cannot be formatted', (done: AnyFunction) => { server.on('error', () => done()) client.request('circular value') + .catch(() => { + // noop + }) }) }) @@ -108,6 +126,9 @@ describe('Peer', () => { const onMessage = jest.fn() const peer = new Peer(onMessage) peer.exec(format.notification('foo'), data) + .catch(() => { + // noop + }) expect(onMessage.mock.calls[0][1]).toBe(data) }) }) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..83e1980 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,283 @@ +import { EventEmitter } from 'events' + +import { + format, + JsonRpcError, + JsonRpcParamsSchema, + JsonRpcPayload, + JsonRpcPayloadError, + JsonRpcPayloadRequest, + JsonRpcPayloadResponse, + MethodNotFound, + parse +} from 'json-rpc-protocol' + +// =================================================================== + +// Give access to low level interface. +export * from 'json-rpc-protocol' + +// =================================================================== + +export type AnyFunction = (...args: any[]) => any + +function makeAsync (fn: AnyFunction): AnyFunction { + return function (this: any, ...args: any[]) { + return new Promise( + (resolve) => resolve( + fn.apply(this, args) + ) + ) + } +} + +const parseMessage = (message: string | object) => { + try { + return parse(message) + } catch (error) { + throw format.error(null, error) + } +} + +// Default onMessage implementation: +// +// - ignores notifications +// - throw MethodNotFound for all requests +function defaultOnMessage (message: JsonRpcPayload) { + if (message.type === 'request') { + throw new MethodNotFound(message.method) + } +} + +function noop () { + // noop +} + +// Starts the autoincrement id with the JavaScript minimal safe integer to have +// more room before running out of integers (it's very far fetched but a very +// long running process with a LOT of messages could run out). +let nextRequestId = -9007199254740991 + +// =================================================================== + +export class Peer extends EventEmitter implements NodeJS.WritableStream { + public writable = true + + private _asyncEmitError: (error: Error) => void + private _handle: (payload: JsonRpcPayload, data: any) => Promise + private _deferreds: { + [idx: string]: { + resolve: (...args: any[]) => any, + reject: (...args: any[]) => any + } + } + + constructor (onMessage = defaultOnMessage) { + super() + + this._asyncEmitError = process.nextTick.bind(process, this.emit.bind(this), 'error') + + this._handle = makeAsync(onMessage) + this._deferreds = Object.create(null) + } + + public async exec ( + message: string | object, + data?: any + ): Promise { + const messagePayload = parseMessage(message) + + if (Array.isArray(messagePayload)) { + // Only returns non empty results. + const results = ( + await Promise.all( + messagePayload.map( + (payload) => this.exec(payload, data) + ) + ) + ).filter((result) => result !== undefined) as JsonRpcPayload[] + + return results + } + + const { type } = messagePayload + + if (type === 'error') { + const { id } = messagePayload as JsonRpcPayloadError + + // Some errors do not have an identifier, simply discard them. + if (id === null) { + return undefined + } + + const { error } = messagePayload as JsonRpcPayloadError + this._getDeferred(id).reject( + // TODO: it would be great if we could return an error with of + // a more specific type (and custom types with registration). + new JsonRpcError(error.message, error.code, error.data) + ) + return undefined + + } else if (type === 'response') { + const responsePayload = messagePayload as JsonRpcPayloadResponse + this._getDeferred( + responsePayload.id + ).resolve(responsePayload.result) + return undefined + + } else if (type === 'notification') { + this._handle(messagePayload, data).catch(noop) + return undefined + + } else { // type === 'request' + const requestPayload = messagePayload as JsonRpcPayloadRequest + let result + try { + result = await this._handle(messagePayload, data) + } catch (error) { + return format.error( + requestPayload.id, + // If the method name is not defined, default to the method passed + // in the request. + (error instanceof MethodNotFound && !error.data) + ? new MethodNotFound(requestPayload.method) + : error + ) + } + return format.response(requestPayload.id, result === undefined ? null : result) + } + } + + // Fails all pending requests. + public failPendingRequests (reason?: string) { + const { _deferreds: deferreds } = this + + // https://stackoverflow.com/a/45959874/1123955 + for (const id of Object.keys(deferreds)) { + deferreds[id].reject(reason) + delete deferreds[id] + } + } + + /** + * This function should be called to send a request to the other end. + * + * TODO: handle multi-requests. + */ + public request (method: string, params?: JsonRpcParamsSchema): Promise { + return new Promise((resolve, reject) => { + const requestId = nextRequestId++ + + this._deferreds[requestId] = { resolve, reject } + + this.push(format.request(requestId, method, params)) + }) + } + + /** + * This function should be called to send a notification to the other end. + * + * TODO: handle multi-notifications. + */ + public notify (method: string, params?: JsonRpcParamsSchema) { + this.push(format.notification(method, params)) + } + + // minimal stream interface + + public pipe (writable: T): T { + let clean: () => void + + const listeners = { + data: (data: any) => { + if (writable instanceof Peer) { + writable.write(data) + } else { + // TypeScript bug? can not identify type at here, have to do a casting. + (writable as NodeJS.WritableStream).write(data) + } + }, + end: () => { + clean() + writable.end() + } + } as { + [event: string]: any + } + + clean = () => { + // forEach(listeners, (listener, event) => { + for (const event of Object.keys(listeners)) { + const listener = listeners[event] + this.removeListener(event, listener) + } + } + + for (const event of Object.keys(listeners)) { + const listener = listeners[event] + this.on(event, listener) + } + + return writable + } + + public push (chunk: any, encoding?: string) { + // TODO: does convert the chunk to a JsonRpcPayload is better? + return chunk === null + ? this.emit('end') + : this.emit('data', chunk, encoding) + } + + public write (buffer: string | Buffer, cb?: AnyFunction): boolean + public write (str: string, encoding?: string, cb?: AnyFunction): boolean + + public write (...args: any[]): boolean { + let cb + const n = args.length + if (n > 1 && typeof (cb = args[n - 1]) === 'function') { + process.nextTick(cb) + } + + this.exec(String(args[0])).then( + (response) => { + if (response !== undefined) { + this.push(response) + } + }, + this._asyncEmitError + ) + + // indicates that other calls to `write` are allowed + return true + } + + public end (cb?: (...args: any[]) => any): void + public end (buffer: string | Buffer, cb?: AnyFunction): void + public end (str: string, encoding?: string, cb?: AnyFunction): void + + // end (data, encoding, cb) { + public end (...args: any[]): void { + if (typeof args[0] === 'function') { + process.nextTick(args[0]) + } else { + if (typeof args[1] === 'function') { + process.nextTick(args[1]) + } else if (typeof args[2] === 'function') { + process.nextTick(args[2]) + } + + if (args[0] !== undefined) { + this.write(args[0]) + } + } + } + + private _getDeferred (id: number | string) { + const deferred = this._deferreds[id] + delete this._deferreds[id] + return deferred + } + +} + +export default Peer diff --git a/src/typings.d.ts b/src/typings.d.ts new file mode 100644 index 0000000..7edf0f2 --- /dev/null +++ b/src/typings.d.ts @@ -0,0 +1,6 @@ +declare const describe: any +declare const it: any +declare const expect: any +declare const beforeAll: any +declare const afterEach: any +declare const jest: any diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5e1ca80 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "alwaysStrict": true, + "declaration": true, + "esModuleInterop": true, + "module": "commonjs", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": "./dist", + "rootDir": "./src", + "sourceMap": true, + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "lib": [ + "esnext" + ], + "target": "es5" + } +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..b6293de --- /dev/null +++ b/tslint.json @@ -0,0 +1,6 @@ +{ + "extends": ["tslint:recommended", "tslint-config-standard"], + "rules": { + "interface-name": [true, "never-prefix"] + } +} diff --git a/yarn.lock b/yarn.lock index d865ffc..686892e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4544,3 +4544,4 @@ yargs@~3.10.0: cliui "^2.1.0" decamelize "^1.0.0" window-size "0.1.0" +