This commit is contained in:
ian.he
2019-04-11 13:42:06 +12:00
commit 4e30fab9d6
688 changed files with 51707 additions and 0 deletions
@@ -0,0 +1,50 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { JsonRpcResponse } from '../types';
import Coder from '.';
describe('decodeResponse', () => {
let coder: Coder;
beforeEach(() => {
coder = new Coder();
});
it('expects a non-empty input object', () => {
expect(
() => coder.decodeResponse((undefined as any) as JsonRpcResponse)
).toThrow(/Empty response/);
});
it('expects a valid jsonrpc field', () => {
expect(
() => coder.decodeResponse({} as JsonRpcResponse)
).toThrow(/Invalid jsonrpc/);
});
it('expects a valid id field', () => {
expect(
() => coder.decodeResponse({ jsonrpc: '2.0' } as JsonRpcResponse)
).toThrow(/Invalid id/);
});
it('expects a valid result field', () => {
expect(
() => coder.decodeResponse({ id: 1, jsonrpc: '2.0' } as JsonRpcResponse)
).toThrow(/No result/);
});
it('throws any error found', () => {
expect(
() => coder.decodeResponse({ id: 1, jsonrpc: '2.0', error: { code: 123, message: 'test error' } } as JsonRpcResponse)
).toThrow(/123: test error/);
});
it('returns the result', () => {
expect(
coder.decodeResponse({ id: 1, jsonrpc: '2.0', result: 'some result' } as JsonRpcResponse)
).toEqual('some result');
});
});
@@ -0,0 +1,19 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import Coder from '.';
describe('encodeJson', () => {
let coder: Coder;
beforeEach(() => {
coder = new Coder();
});
it('encodes a valid JsonRPC JSON string', () => {
expect(
coder.encodeJson('method', 'params')
).toEqual('{"id":1,"jsonrpc":"2.0","method":"method","params":"params"}');
});
});
@@ -0,0 +1,29 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import Coder from '.';
describe('encodeObject', () => {
let coder: Coder;
beforeEach(() => {
coder = new Coder();
});
it('starts with id === 0 (nothing sent)', () => {
expect(coder.getId()).toEqual(0);
});
it('encodes a valid JsonRPC object', () => {
expect(
coder.encodeObject('method', 'params')
).toEqual({
id: 1,
jsonrpc: '2.0',
method: 'method',
params: 'params'
});
expect(coder.getId()).toEqual(1);
});
});
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { JsonRpcRequest, JsonRpcResponse, JsonRpcResponseBase$Error } from '../types';
import { assert, isUndefined, isNumber } from '@plugnet/util';
export default class RpcCoder {
private id: number = 0;
decodeResponse (response: JsonRpcResponse): any {
assert(response, 'Empty response object received');
assert(response.jsonrpc === '2.0', 'Invalid jsonrpc field in decoded object');
const isSubscription = !isUndefined(response.params) && !isUndefined(response.method);
assert(isNumber(response.id) || (isSubscription && isNumber(response.params.subscription)), 'Invalid id field in decoded object');
this.checkError(response.error);
assert(!isUndefined(response.result) || isSubscription, 'No result found in JsonRpc response');
if (isSubscription) {
this.checkError(response.params.error);
return response.params.result;
}
return response.result;
}
encodeJson (method: string, params: any | Array<any>): string {
return JSON.stringify(
this.encodeObject(method, params)
);
}
encodeObject (method: string, params: any | Array<any>): JsonRpcRequest {
return {
id: ++this.id,
jsonrpc: '2.0',
method,
params
};
}
getId (): number {
return this.id;
}
private checkError (error?: JsonRpcResponseBase$Error): void {
if (error) {
const { code, data, message } = error;
const _data = isUndefined(data)
? ''
: ' (' + `${data}`.substr(0, 10) + ')' ;
console.error(`${code}: ${message}${_data}`);
throw new Error(`${code}: ${message}${_data}`);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
const HTTP_URL = 'http://127.0.0.1:9933';
const WS_URL = 'ws://127.0.0.1:9944';
export default {
HTTP_URL,
WS_URL
};
@@ -0,0 +1,43 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { TEST_HTTP_URL } from '../../test/mockHttp';
import Http from '.';
describe('Http', () => {
let http;
beforeEach(() => {
http = new Http(TEST_HTTP_URL);
});
it('requires an http:// prefixed endpoint', () => {
expect(
() => new Http('ws://')
).toThrow(/with 'http/);
});
it('allows https:// endpoints', () => {
expect(
() => new Http('https://')
).not.toThrow();
});
it('always returns isConnected true', () => {
expect(http.isConnected()).toEqual(true);
});
it('does not (yet) support subscribe', () => {
return http.subscribe().catch((error) => {
expect(error.message).toMatch(/does not have subscriptions/);
});
});
it('does not (yet) support unsubscribe', () => {
return http.unsubscribe().catch((error) => {
expect(error.message).toMatch(/does not have subscriptions/);
});
});
});
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { ProviderInterface, ProviderInterface$Callback, ProviderInterface$Emitted, ProviderInterface$EmitCb } from '../types';
import './polyfill';
import { assert, logger } from '@plugnet/util';
import Coder from '../coder';
import defaults from '../defaults';
const ERROR_SUBSCRIBE = 'HTTP Provider does not have subscriptions, use WebSockets instead';
const l = logger('api-http');
/**
* # @polkadot/rpc-provider/https
*
* @name HttpProvider
*
* @description The HTTP Provider allows sending requests using HTTP to a HTTP RPC server TCP port. It does not support subscriptions so you won't be able to listen to events such as new blocks or balance changes. It is usually preferrable using the [[WsProvider]].
*
* @example
* <BR>
*
* ```javascript
* import Api from '@plugnet/api/promise';
* import HttpProvider from '@plugnet/rpc-provider/http';
*
* const provider = new HttpProvider('http://127.0.0.1:9933');
* const api = new Api(provider);
* ```
*
* @see [[WsProvider]]
*/
export default class HttpProvider implements ProviderInterface {
private coder: Coder;
private endpoint: string;
/**
* @param {string} endpoint The endpoint url starting with http://
*/
constructor (endpoint: string = defaults.HTTP_URL) {
assert(/^(https|http):\/\//.test(endpoint), `Endpoint should start with 'http://', received '${endpoint}'`);
this.coder = new Coder();
this.endpoint = endpoint;
}
/**
* @summary `true` when this provider supports subscriptions
*/
get hasSubscriptions (): boolean {
return false;
}
/**
* @description Returns a clone of the object
*/
clone (): HttpProvider {
throw new Error('Unimplemented');
}
/**
* @description Manually disconnect from the connection
*/
disconnect (): void {
// noop
}
/**
* @summary Whether the node is connected or not.
* @return {boolean} true if connected
*/
isConnected (): boolean {
return true;
}
/**
* @summary Events are not supported with the HttpProvider, see [[WsProvider]].
* @description HTTP Provider does not have 'on' emitters. WebSockets should be used instead.
*/
on (type: ProviderInterface$Emitted, sub: ProviderInterface$EmitCb): void {
l.error(`HTTP Provider does not have 'on' emitters, use WebSockets instead`);
}
/**
* @summary Send HTTP POST Request with Body to configured HTTP Endpoint.
*/
async send (method: string, params: Array<any>): Promise<any> {
const body = this.coder.encodeJson(method, params);
const response = await fetch(this.endpoint, {
body,
headers: {
'Accept': 'application/json',
'Content-Length': `${body.length}`,
'Content-Type': 'application/json'
},
method: 'POST'
});
assert(response.ok, `[${response.status}]: ${response.statusText}`);
const result = await response.json();
return this.coder.decodeResponse(result);
}
/**
* @summary Subscriptions are not supported with the HttpProvider, see [[WsProvider]].
*/
async subscribe (types: string, method: string, params: Array<any>, cb: ProviderInterface$Callback): Promise<number> {
l.error(ERROR_SUBSCRIBE);
throw new Error(ERROR_SUBSCRIBE);
}
/**
* @summary Subscriptions are not supported with the HttpProvider, see [[WsProvider]].
*/
async unsubscribe (type: string, method: string, id: number): Promise<boolean> {
l.error(ERROR_SUBSCRIBE);
throw new Error(ERROR_SUBSCRIBE);
}
}
@@ -0,0 +1,26 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
describe('http/polyfill', () => {
let origFetch;
beforeEach(() => {
origFetch = global.fetch;
global.fetch = null;
});
afterEach(() => {
global.fetch = origFetch;
});
it('polyfills with no exceptions (without fetch)', () => {
expect(require('./polyfill')).toBeDefined();
});
it('polyfills with no exceptions (with fetch)', () => {
global.fetch = () => true;
expect(require('./polyfill')).toBeDefined();
});
});
@@ -0,0 +1,8 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
// tslint:disable-next-line
if (typeof fetch === 'undefined') {
require('isomorphic-fetch');
}
@@ -0,0 +1,56 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { mockHttp, TEST_HTTP_URL } from '../../test/mockHttp';
import Http from '.';
describe('send', () => {
let http;
let mock;
beforeEach(() => {
http = new Http(TEST_HTTP_URL);
});
afterEach(() => {
if (mock) {
mock.done();
mock = null;
}
});
it('passes the body through correctly', () => {
mock = mockHttp([{
method: 'test_body',
reply: {
result: 'ok'
}
}]);
return http
.send('test_body', ['param'])
.then((result) => {
expect(mock.body['test_body']).toEqual({
id: 1,
jsonrpc: '2.0',
method: 'test_body',
params: ['param']
});
});
});
it('throws error when !response.ok', () => {
mock = mockHttp([{
code: 500,
method: 'test_error'
}]);
return http
.send('test_error', [])
.catch((error) => {
expect(error.message).toMatch(/\[500\]: Internal Server/);
});
});
});
+13
View File
@@ -0,0 +1,13 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { Logger } from '@plugnet/util/types';
import Coder from '../coder';
export type HttpState = {
coder: Coder,
endpoint: string,
l: Logger
};
+6
View File
@@ -0,0 +1,6 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
export { default as HttpProvider } from './http';
export { default as WsProvider } from './ws';
+206
View File
@@ -0,0 +1,206 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { ProviderInterface, ProviderInterface$Emitted, ProviderInterface$EmitCb } from '../types';
import { MockState$Subscriptions, MockState$Subscription$Callback, MockState$Db } from './types';
import BN from 'bn.js';
import EventEmitter from 'eventemitter3';
import interfaces from '@plugnet/jsonrpc';
import testKeyring from '@plugnet/keyring/testing';
import storage from '@plugnet/storage/static';
import { Codec } from '@plugnet/types/types';
import metadata from '@plugnet/types/Metadata/v0/static';
import { Header } from '@plugnet/types';
import { bnToU8a, logger, u8aToHex } from '@plugnet/util';
import { randomAsU8a } from '@plugnet/util-crypto';
const INTERVAL = 1000;
const SUBSCRIPTIONS: string[] = Array.prototype.concat.apply(
[], Object.values(interfaces).map((area) =>
Object
.values(area.methods)
.filter((method) =>
method.isSubscription
)
.map(({ method, section }) =>
`${section}_${method}`
)
)
);
const keyring = testKeyring({ type: 'ed25519' });
const l = logger('api-mock');
/**
* A mock provider mainly used for testing.
* @return {ProviderInterface} The mock provider
*/
export default class Mock implements ProviderInterface {
private db: MockState$Db = {};
private emitter = new EventEmitter();
public isUpdating: boolean = true;
private requests: { [index: string]: (...params: any[]) => string } = {
'chain_getBlockHash': (blockNumber: number): string => '0x1234',
'chain_getRuntimeVersion': (): string => '0x1234',
'state_getStorage': (storage: MockState$Db, params: Array<any>): string => {
return u8aToHex(
storage[(params[0] as string)]
);
},
'system_chain': (): string => 'mockChain',
'state_getMetadata': (): string => metadata,
'system_name': (): string => 'mockClient',
'system_version': (): string => '9.8.7'
};
public subscriptions: MockState$Subscriptions = SUBSCRIPTIONS.reduce((subs, name) => {
subs[name] = {
callbacks: {},
lastValue: null
};
return subs;
}, ({} as MockState$Subscriptions));
private subscriptionId: number = 0;
private subscriptionMap: {
[index: number]: string
} = {};
constructor () {
this.init();
}
get hasSubscriptions (): boolean {
return true;
}
clone (): Mock {
throw new Error('Unimplemented');
}
disconnect (): void {
// noop
}
isConnected (): boolean {
return true;
}
on (type: ProviderInterface$Emitted, sub: ProviderInterface$EmitCb): void {
this.emitter.on(type, sub);
}
async send (method: string, params: Array<any>): Promise<any> {
if (!this.requests[method]) {
throw new Error(`provider.send: Invalid method '${method}'`);
}
return this.requests[method](this.db, params);
}
async subscribe (type: string, method: string, ...params: Array<any>): Promise<number> {
l.debug(() => ['subscribe', method, params]);
if (this.subscriptions[method]) {
const callback: MockState$Subscription$Callback = params.pop();
const id = ++this.subscriptionId;
this.subscriptions[method].callbacks[id] = callback;
this.subscriptionMap[id] = method;
if (this.subscriptions[method].lastValue !== null) {
callback(null, this.subscriptions[method].lastValue);
}
return id;
}
throw new Error(`provider.subscribe: Invalid method '${method}'`);
}
async unsubscribe (type: string, method: string, id: number): Promise<boolean> {
const sub = this.subscriptionMap[id];
l.debug(() => ['unsubscribe', id, sub]);
if (!sub) {
throw new Error(`Unable to find subscription for ${id}`);
}
delete this.subscriptionMap[id];
delete this.subscriptions[sub].callbacks[id];
return true;
}
private init () {
const emitEvents: Array<ProviderInterface$Emitted> = ['connected', 'disconnected'];
let emitIndex = 0;
let newHead = this.makeBlockHeader(new BN(-1));
let counter = -1;
// Do something every 1 seconds
setInterval(() => {
if (!this.isUpdating) {
return;
}
// create a new header (next block)
newHead = this.makeBlockHeader(newHead.blockNumber.toBn());
// increment the balances and nonce for each account
keyring.getPairs().forEach(({ publicKey }, index) => {
this.setStateBn(storage.balances.freeBalance(publicKey()), newHead.blockNumber.muln(3).iaddn(index));
this.setStateBn(storage.system.accountNonce(publicKey()), newHead.blockNumber.addn(index));
});
// set the timestamp for the current block
this.setStateBn(storage.timestamp.now(), Math.floor(Date.now() / 1000));
this.updateSubs('chain_subscribeNewHead', newHead);
// We emit connected/disconnected at intervals
if (++counter % 2 === 1) {
if (++emitIndex === emitEvents.length) {
emitIndex = 0;
}
this.emitter.emit(emitEvents[emitIndex]);
}
}, INTERVAL);
}
private makeBlockHeader (prevNumber: BN): Header {
const blockNumber = prevNumber.addn(1);
return new Header({
digest: {
logs: []
},
extrinsicsRoot: randomAsU8a(),
number: blockNumber,
parentHash: blockNumber.isZero()
? new Uint8Array(32)
: bnToU8a(prevNumber, 256, false),
stateRoot: bnToU8a(blockNumber, 256, false)
});
}
private setStateBn (key: Uint8Array, value: BN | number): void {
this.db[u8aToHex(key)] = bnToU8a(value, 64, true);
}
private updateSubs (method: string, value: Codec) {
this.subscriptions[method].lastValue = value;
Object
.values(this.subscriptions[method].callbacks)
.forEach((cb) => {
try {
cb(null, value.toJSON());
} catch (error) {
console.error(`Error on '${method}' subscription`, error);
}
});
}
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { ProviderInterface$Emitted } from '../types';
import Mock from '.';
describe('on', () => {
let mock: Mock;
beforeEach(() => {
mock = new Mock();
});
it('it emits both connected and disconnected events', (done) => {
const events: { [index: string]: boolean } = { connected: false, disconnected: false };
const handler = (type: ProviderInterface$Emitted) => {
mock.on(type, () => {
events[type] = true;
if (Object.values(events).filter((value) => value).length === 2) {
done();
}
});
};
handler('connected');
handler('disconnected');
});
});
@@ -0,0 +1,29 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import Mock from '.';
describe('send', () => {
let mock: Mock;
beforeEach(() => {
mock = new Mock();
});
it('fails on non-supported methods', () => {
return mock
.send('something_invalid', [])
.catch((error) => {
expect(error.message).toMatch(/Invalid method/);
});
});
it('returns values for mocked requests', () => {
return mock
.send('system_name', [])
.then((result) => {
expect(result).toBe('mockClient');
});
});
});
@@ -0,0 +1,63 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import Mock from '.';
describe('subscribe', () => {
let mock: Mock;
beforeEach(() => {
mock = new Mock();
});
it('fails on unknown methods', () => {
return mock
.subscribe('test', 'test_notFound')
.catch((error) => {
expect(error.message).toMatch(/Invalid method 'test_notFound'/);
});
});
it('returns a subscription id', () => {
return mock
.subscribe('chain_newHead', 'chain_subscribeNewHead', () => void 0)
.then((id) => {
expect(id).toEqual(1);
});
});
it('calls back with the last known value', (done) => {
mock.isUpdating = false;
mock.subscriptions['chain_subscribeNewHead'].lastValue = 'testValue';
return mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, value: string) => {
expect(value).toEqual('testValue');
done();
});
});
it('calls back with new headers', (done) => {
return mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, header: any) => {
if (header.number === 4) {
done();
}
});
});
it('handles errors withing callbacks gracefully', (done) => {
let hasThrown: boolean = false;
return mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, header: any) => {
if (!hasThrown) {
hasThrown = true;
throw new Error('testing');
}
if (header.number === 3) {
done();
}
});
});
});
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
export type MockState$Subscription$Callback = (error: Error | null, value: any) => void;
export type MockState$Subscriptions = {
[index: string]: {
callbacks: {
[index: number]: MockState$Subscription$Callback
},
lastValue: any
}
};
export type MockState$Db = {
[index: string]: Uint8Array
};
export type MockState$Requests = {
[index: string]: (db: MockState$Db, params: Array<any>) => string
};
@@ -0,0 +1,40 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import Mock from '.';
describe('unsubscribe', () => {
let mock: Mock;
let id: number;
beforeEach(() => {
mock = new Mock();
return mock
.subscribe('chain_newHead', 'chain_subscribeNewHead', () => void 0)
.then((_id) => {
id = _id;
});
});
it('fails on unknown ids', () => {
return mock
.unsubscribe('chain_newHead', 'chain_subscribeNewHead', 5)
.catch((error) => {
expect(error.message).toMatch(/Unable to find/);
});
});
it('unsubscribes successfully', () => {
return mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id);
});
it('fails on double unsubscribe', () => {
return mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id)
.then(() => mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id))
.catch((error) => {
expect(error.message).toMatch(/Unable to find/);
});
});
});
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
export type JsonRpcObject = {
id: number;
jsonrpc: '2.0';
};
export type JsonRpcRequest = JsonRpcObject & {
method: string;
params: Array<any>;
};
export type JsonRpcResponseBase$Error = {
code: number,
data?: number | string,
message: string
};
type JsonRpcResponse$Single = {
error?: JsonRpcResponseBase$Error;
result?: any;
};
type JsonRpcResponse$Subscription = {
method?: string;
params: {
error?: JsonRpcResponseBase$Error;
result: any;
subscription: number;
}
};
export type JsonRpcResponseBase = JsonRpcResponse$Single & JsonRpcResponse$Subscription;
export type JsonRpcResponse = JsonRpcObject & JsonRpcResponseBase;
export type ProviderInterface$Callback = (result: any) => void;
export type ProviderInterface$Emitted = 'connected' | 'disconnected' | 'error';
export type ProviderInterface$EmitCb = (value?: any) => any;
export interface ProviderInterface {
readonly hasSubscriptions: boolean;
clone (): ProviderInterface;
disconnect (): void;
isConnected (): boolean;
on (type: ProviderInterface$Emitted, sub: ProviderInterface$EmitCb): void;
send (method: string, params: Array<any>): Promise<any>;
subscribe (type: string, method: string, params: Array<any>, cb: ProviderInterface$Callback): Promise<number>;
unsubscribe (type: string, method: string, id: number): Promise<boolean>;
}
+412
View File
@@ -0,0 +1,412 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { JsonRpcResponse, ProviderInterface, ProviderInterface$Callback, ProviderInterface$Emitted, ProviderInterface$EmitCb } from '../types';
import './polyfill';
import EventEmitter from 'eventemitter3';
import { assert, isNull, isUndefined, logger } from '@plugnet/util';
import Coder from '../coder';
import defaults from '../defaults';
type CallbackHandler = (error?: null | Error, value?: any) => void;
type SubscriptionHandler = {
callback: CallbackHandler,
type: string
};
type WsState$Awaiting = {
callback: CallbackHandler,
method: string,
params: Array<any>,
subscription?: SubscriptionHandler
};
type WsState$Subscription = SubscriptionHandler & {
method: string,
params: Array<any>
};
interface WSProviderInterface extends ProviderInterface {
connect (): void;
}
const ALIASSES: { [index: string]: string } = {
'chain_finalisedHead': 'chain_finalizedHead'
};
const l = logger('api-ws');
/**
* # @polkadot/rpc-provider/ws
*
* @name WsProvider
*
* @description The WebSocket Provider allows sending requests using WebSocket to a WebSocket RPC server TCP port. Unlike the [[HttpProvider]], it does support subscriptions and allows listening to events such as new blocks or balance changes.
*
* @example
* <BR>
*
* ```javascript
* import Api from '@plugnet/api/promise';
* import WsProvider from '@plugnet/rpc-provider/ws';
*
* const provider = new WsProvider('ws://127.0.0.1:9944');
* const api = new Api(provider);
* ```
*
* @see [[HttpProvider]]
*/
export default class WsProvider implements WSProviderInterface {
private _eventemitter: EventEmitter;
private _isConnected: boolean = false;
private autoConnect: boolean;
private coder: Coder;
private endpoint: string;
private handlers: {
[index: string]: WsState$Awaiting
};
private queued: {
[index: string]: string
};
private subscriptions: {
[index: string]: WsState$Subscription
};
private waitingForId: {
[index: string]: JsonRpcResponse
};
private websocket: WebSocket | null;
/**
* @param {string} endpoint The endpoint url. Usually `ws://ip:9944` or `wss://ip:9944`
* @param {boolean} autoConnect Whether to connect automatically or not.
*/
constructor (endpoint: string = defaults.WS_URL, autoConnect: boolean = true) {
assert(/^(wss|ws):\/\//.test(endpoint), `Endpoint should start with 'ws://', received '${endpoint}'`);
this._eventemitter = new EventEmitter();
this.autoConnect = autoConnect;
this.coder = new Coder();
this.endpoint = endpoint;
this.handlers = {};
this.queued = {};
this.subscriptions = {};
this.waitingForId = {};
this.websocket = null;
if (autoConnect) {
this.connect();
}
}
/**
* @summary `true` when this provider supports subscriptions
*/
get hasSubscriptions (): boolean {
return true;
}
/**
* @description Returns a clone of the object
*/
clone (): WsProvider {
return new WsProvider(this.endpoint);
}
/**
* @summary Manually connect
* @description The [[WsProvider]] connects automatically by default, however if you decided otherwise, you may
* connect manually using this method.
*/
connect (): void {
try {
this.websocket = new WebSocket(this.endpoint);
this.websocket.onclose = this.onSocketClose;
this.websocket.onerror = this.onSocketError;
this.websocket.onmessage = this.onSocketMessage;
this.websocket.onopen = this.onSocketOpen;
} catch (error) {
l.error(error);
}
}
/**
* @description Manually disconnect from the connection, clearing autoconnect logic
*/
disconnect (): void {
if (isNull(this.websocket)) {
throw new Error('Cannot disconnect on a non-open websocket');
}
// switch off autoConnect, we are in manual mode now
this.autoConnect = false;
// 1000 - Normal closure; the connection successfully completed
this.websocket.close(1000);
this.websocket = null;
}
/**
* @summary Whether the node is connected or not.
* @return {boolean} true if connected
*/
isConnected (): boolean {
return this._isConnected;
}
/**
* @summary Listens on events after having subscribed using the [[subscribe]] function.
* @param {ProviderInterface$Emitted} type Event
* @param {ProviderInterface$EmitCb} sub Callback
*/
on (type: ProviderInterface$Emitted, sub: ProviderInterface$EmitCb): void {
this._eventemitter.on(type, sub);
}
/**
* @summary Send JSON data using WebSockets to configured HTTP Endpoint or queue.
* @param method The RPC methods to execute
* @param params Encoded paramaters as appliucable for the method
* @param subscription Subscription details (internally used)
*/
async send (method: string, params: Array<any>, subscription?: SubscriptionHandler): Promise<any> {
return new Promise((resolve, reject): void => {
try {
const json = this.coder.encodeJson(method, params);
const id = this.coder.getId();
const callback = (error?: Error | null, result?: any) => {
error
? reject(error)
: resolve(result);
};
l.debug(() => ['calling', method, params, json, !!subscription]);
this.handlers[id] = {
callback,
method,
params,
subscription
};
if (this.isConnected() && !isNull(this.websocket)) {
this.websocket.send(json);
} else {
this.queued[id] = json;
}
} catch (error) {
reject(error);
}
});
}
/**
* @name subscribe
* @summary Allows subscribing to a specific event.
* @param {string} type Subscription type
* @param {string} method Subscription method
* @param {Array<any>} params Parameters
* @param {ProviderInterface$Callback} callback Callback
* @return {Promise<number>} Promise resolving to the dd of the subscription you can use with [[unsubscribe]].
*
* @example
* <BR>
*
* ```javascript
* const provider = new WsProvider('ws://127.0.0.1:9944');
* const rpc = new Rpc(provider);
*
* rpc.state.subscribeStorage([[storage.balances.freeBalance, <Address>]], (_, values) => {
* console.log(values)
* }).then((subscriptionId) => {
* console.log('balance changes subscription id: ', subscriptionId)
* })
* ```
*/
async subscribe (type: string, method: string, params: Array<any>, callback: ProviderInterface$Callback): Promise<number> {
const id = await this.send(method, params, { callback, type });
return id as number;
}
/**
* @summary Allows unsubscribing to subscriptions made with [[subscribe]].
*/
async unsubscribe (type: string, method: string, id: number): Promise<boolean> {
const subscription = `${type}::${id}`;
// FIXME This now could happen with re-subscriptions. The issue is that with a re-sub
// the assigned id now does not match what the API user originally received. It has
// a slight complication in solving - since we cannot rely on the send id, but rather
// need to find the actual subscription id to map it
if (isUndefined(this.subscriptions[subscription])) {
l.debug(() => `Unable to find active subscription=${subscription}`);
return false;
}
delete this.subscriptions[subscription];
const result = await this.send(method, [id]);
return result as boolean;
}
private emit (type: ProviderInterface$Emitted, ...args: Array<any>): void {
this._eventemitter.emit(type, ...args);
}
private onSocketClose = (event: CloseEvent): void => {
if (this.autoConnect) {
l.error(`disconnected from ${this.endpoint} code: '${event.code}' reason: '${event.reason}'`);
}
this._isConnected = false;
this.emit('disconnected');
if (this.autoConnect) {
setTimeout(() => {
this.connect();
}, 1000);
}
}
private onSocketError = (error: Event): void => {
l.debug(() => ['socket error', error]);
this.emit('error', error);
}
private onSocketMessage = (message: MessageEvent): void => {
l.debug(() => ['received', message.data]);
const response: JsonRpcResponse = JSON.parse(message.data as string);
return isUndefined(response.method)
? this.onSocketMessageResult(response)
: this.onSocketMessageSubscribe(response);
}
private onSocketMessageResult = (response: JsonRpcResponse): void => {
l.debug(() => ['handling: response =', response, 'id =', response.id]);
const handler = this.handlers[response.id];
if (!handler) {
l.debug(() => `Unable to find handler for id=${response.id}`);
return;
}
try {
const { method, params, subscription } = handler;
const result = this.coder.decodeResponse(response);
// first send the result - in case of subs, we may have an update
// immediately if we have some queued results already
handler.callback(null, result);
if (subscription) {
const subId = `${subscription.type}::${result}`;
this.subscriptions[subId] = {
...subscription,
method,
params
};
// if we have a result waiting for this subscription already
if (this.waitingForId[subId]) {
this.onSocketMessageSubscribe(this.waitingForId[subId]);
}
}
} catch (error) {
handler.callback(error, undefined);
}
delete this.handlers[response.id];
}
private onSocketMessageSubscribe = (response: JsonRpcResponse): void => {
const method = ALIASSES[response.method as string] || response.method;
const subId = `${method}::${response.params.subscription}`;
l.debug(() => ['handling: response =', response, 'subscription =', subId]);
const handler = this.subscriptions[subId];
if (!handler) {
// store the JSON, we could have out-of-order subid coming in
this.waitingForId[subId] = response;
l.debug(() => `Unable to find handler for subscription=${subId}`);
return;
}
// housekeeping
delete this.waitingForId[subId];
try {
const result = this.coder.decodeResponse(response);
handler.callback(null, result);
} catch (error) {
handler.callback(error, undefined);
}
}
private onSocketOpen = (): boolean => {
assert(!isNull(this.websocket), 'WebSocket cannot be null in onOpen');
l.debug(() => ['connected to', this.endpoint]);
this._isConnected = true;
this.emit('connected');
this.sendQueue();
this.resubscribe();
return true;
}
private resubscribe (): void {
const subscriptions = this.subscriptions;
this.subscriptions = {};
Object.keys(subscriptions).forEach(async (id) => {
const { callback, method, params, type } = subscriptions[id];
// only re-create subscriptions which are not in author (only area where
// transactions are created, i.e. submissions such as 'author_submitAndWatchExtrinsic'
// are not included (and will not be re-broadcast)
if (type.startsWith('author_')) {
return;
}
try {
await this.subscribe(type, method, params, callback);
} catch (error) {
l.error(error);
}
});
}
private sendQueue (): void {
Object.keys(this.queued).forEach((id) => {
try {
// @ts-ignore we have done the websocket check in onSocketOpen, if an issue, will catch it
this.websocket.send(
this.queued[id]
);
delete this.queued[id];
} catch (error) {
l.error(error);
}
});
}
}
@@ -0,0 +1,33 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { mockWs, TEST_WS_URL } from '../../test/mockWs';
import Ws from '.';
describe('onConnect', () => {
let mock;
beforeEach(() => {
mock = mockWs([]);
});
afterEach(() => {
if (mock) {
mock.done();
mock = null;
}
});
it('sets up the on* handlers', () => {
const ws = new Ws(TEST_WS_URL, false);
ws.connect();
expect(ws.websocket.onclose[0]).toBeDefined();
expect(ws.websocket.onerror[0]).toBeDefined();
expect(ws.websocket.onmessage[0]).toBeDefined();
expect(ws.websocket.onopen[0]).toBeDefined();
});
});
@@ -0,0 +1,32 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { mockWs, TEST_WS_URL } from '../../test/mockWs';
import Ws from '.';
let ws;
let mock;
function createWs (requests, autoConnect) {
mock = mockWs(requests);
ws = new Ws(TEST_WS_URL, autoConnect);
return ws;
}
describe('Ws', () => {
afterEach(() => {
if (mock) {
mock.done();
mock = null;
}
});
it('returns the connected state', () => {
expect(
createWs([]).isConnected()
).toEqual(false);
});
});
+7
View File
@@ -0,0 +1,7 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { default as WsProvider } from './Provider';
export default WsProvider;
@@ -0,0 +1,24 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import Ws from '.';
describe('onMessageResult', () => {
let ws;
beforeEach(() => {
ws = new Ws('ws://127.0.0.1:1234', false);
});
it('calls the handler when found', (done) => {
ws.handlers[5] = {
callback: (_, result) => {
expect(result).toEqual('test');
done();
}
};
ws.onSocketMessage({ data: '{"jsonrpc":"2.0","id":5,"result":"test"}' });
});
});
@@ -0,0 +1,47 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import Ws from '.';
describe('onMessageSubscribe', () => {
let ws;
beforeEach(() => {
ws = new Ws('ws://127.0.0.1:1234', false);
});
it('calls the subscriber with data', (done) => {
ws.handlers[11] = {
callback: (_, id) => {},
method: 'test',
subscription: {
callback: (_, result) => {
expect(result).toEqual('test');
done();
},
type: 'test'
}
};
ws.onSocketMessage({ data: '{"jsonrpc":"2.0","id":11,"result":22}' });
ws.onSocketMessage({ data: '{"jsonrpc":"2.0","method":"test","params":{"subscription":22,"result":"test"}}' });
});
it('calls the subscriber with error', (done) => {
ws.handlers[11] = {
callback: (_, id) => {},
method: 'test',
subscription: {
callback: (error) => {
expect(error.message).toMatch(/test/);
done();
},
type: 'test'
}
};
ws.onSocketMessage({ data: '{"jsonrpc":"2.0","id":11,"result":22}' });
ws.onSocketMessage({ data: '{"jsonrpc":"2.0","method":"test","params":{"subscription":22,"error":{"message":"test"}}}' });
});
});
@@ -0,0 +1,43 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { mockWs, TEST_WS_URL } from '../../test/mockWs';
import Ws from '.';
let ws;
let mock;
function createWs (requests, autoConnect) {
mock = mockWs(requests);
ws = new Ws(TEST_WS_URL, autoConnect);
return ws;
}
describe('onOpen', () => {
afterEach(() => {
if (mock) {
mock.done();
mock = null;
}
});
it('sends messages when connected', () => {
const ws = createWs([{
id: 1,
method: 'test_queue',
reply: {
result: 'ok'
}
}], false);
const sendPromise = ws.send('test_queue', []);
ws.connect();
return sendPromise.then((result) => {
expect(result).toEqual('ok');
});
});
});
@@ -0,0 +1,26 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
describe('ws/polyfill', () => {
let origWs;
beforeEach(() => {
origWs = global.WebSocket;
global.WebSocket = null;
});
afterEach(() => {
global.WebSocket = origWs;
});
it('polyfills with no exceptions (without WebSocket)', () => {
expect(require('./polyfill')).toBeDefined();
});
it('polyfills with no exceptions (with WebSocket)', () => {
global.WebSocket = () => true;
expect(require('./polyfill')).toBeDefined();
});
});
+9
View File
@@ -0,0 +1,9 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
// tslint:disable-next-line
if (typeof WebSocket === 'undefined') {
// @ts-ignore
global.WebSocket = require('websocket').w3cwebsocket;
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { mockWs, TEST_WS_URL } from '../../test/mockWs';
import Ws from '.';
let ws;
let mock;
function createMock (requests) {
mock = mockWs(requests);
}
function createWs (autoConnect) {
ws = new Ws(TEST_WS_URL, autoConnect);
return ws;
}
describe('send', () => {
let globalWs;
beforeEach(() => {
globalWs = global.WebSocket;
});
afterEach(() => {
global.WebSocket = globalWs;
if (mock) {
mock.done();
mock = null;
}
});
it('handles internal errors', () => {
createMock([]);
let websocket;
global.WebSocket = class {
constructor () {
websocket = this;
}
send () {
throw new Error('send error');
}
};
ws = createWs();
websocket.onopen();
return ws
.send('test_encoding', ['param'])
.catch((error) => {
expect(error.message).toEqual('send error');
});
});
it('passes the body through correctly', () => {
createMock([{
id: 1,
method: 'test_body',
reply: {
result: 'ok'
}
}]);
return createWs()
.send('test_body', ['param'])
.then((result) => {
expect(
mock.body['test_body']
).toEqual('{"id":1,"jsonrpc":"2.0","method":"test_body","params":["param"]}');
});
});
it('throws error when !response.ok', () => {
createMock([{
id: 1,
error: {
code: 666,
message: 'error'
}
}]);
return createWs()
.send('test_error', [])
.catch((error) => {
expect(error.message).toMatch(/666: error/);
});
});
it('adds subscriptions', () => {
createMock([{
id: 1,
method: 'test_sub',
reply: {
result: 1
}
}]);
return createWs()
.send('test_sub', [], () => {})
.then((id) => {
expect(id).toEqual(1);
});
});
});
@@ -0,0 +1,19 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import Ws from '.';
describe('state', () => {
it('requires an ws:// prefixed endpoint', () => {
expect(
() => new Ws('http://', false)
).toThrow(/with 'ws/);
});
it('allows wss:// endpoints', () => {
expect(
() => new Ws('wss://', false)
).not.toThrow();
});
});
@@ -0,0 +1,53 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { mockWs, TEST_WS_URL } from '../../test/mockWs';
import Ws from '.';
let ws;
let mock;
function createMock (requests) {
mock = mockWs(requests);
}
function createWs (autoConnect) {
ws = new Ws(TEST_WS_URL, autoConnect);
return ws;
}
describe('subscribe', () => {
let globalWs;
beforeEach(() => {
globalWs = global.WebSocket;
});
afterEach(() => {
global.WebSocket = globalWs;
if (mock) {
mock.done();
mock = null;
}
});
it('adds subscriptions', () => {
createMock([{
id: 1,
method: 'test_sub',
reply: {
result: 1
}
}]);
return createWs()
.subscribe('test_sub', [], () => {})
.then((id) => {
expect(id).toEqual(1);
});
});
});
@@ -0,0 +1,85 @@
// Copyright 2017-2019 @polkadot/rpc-provider authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { mockWs, TEST_WS_URL } from '../../test/mockWs';
import Ws from '.';
let ws;
let mock;
function createMock (requests) {
mock = mockWs(requests);
}
function createWs (autoConnect) {
ws = new Ws(TEST_WS_URL, autoConnect);
return ws;
}
describe('subscribe', () => {
let globalWs;
beforeEach(() => {
globalWs = global.WebSocket;
});
afterEach(() => {
global.WebSocket = globalWs;
if (mock) {
mock.done();
mock = null;
}
});
it('removes subscriptions', () => {
createMock([
{
id: 1,
method: 'subscribe_test',
reply: {
result: 1
}
},
{
id: 2,
method: 'unsubscribe_test',
reply: {
result: true
}
}
]);
const ws = createWs();
return ws
.subscribe('test', 'subscribe_test', [], () => {})
.then((id) => {
return ws.unsubscribe('test', 'subscribe_test', id);
});
});
it('fails when sub not found', () => {
createMock([{
id: 1,
method: 'subscribe_test',
reply: {
result: 1
}
}]);
const ws = createWs();
return ws
.subscribe('test', 'subscribe_test', [], () => {})
.then((id) => {
return ws.unsubscribe('test', 'subscribe_test', 111);
})
.then((result) => {
expect(result).toBe(false);
});
});
});