diff --git a/.eslintrc.js b/.eslintrc.js index 09761a93..869830a5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -38,12 +38,11 @@ module.exports = { format: ["snake_case"], }, ], - // Ignore type imports when counting dependencies. "import/max-dependencies": [ "error", { - max: 5, + max: 10, ignoreTypeImports: true, }, ], @@ -64,6 +63,8 @@ module.exports = { skipComments: true, }, ], + "max-statements": ["warn", 25], + "id-length": ["error", { exceptions: ["_"] }], // exception for lodash }, overrides: [ { diff --git a/src/client/backoff.ts b/src/client/backoff.ts index 22ef47de..29bda546 100644 --- a/src/client/backoff.ts +++ b/src/client/backoff.ts @@ -1,42 +1,69 @@ // Original code based on "backo" - https://github.com/segmentio/backo // MIT License - Copyright 2014 Segment.io -// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, +// modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +// is furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +// WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +interface ExponentialBackoffOptions { + // The min backoff duration. + min?: number; + // The max backoff duration. + max?: number; +} + +const DEFAULT_MIN = 100; +const DEFAULT_MAX = 1000; /** * A Back off strategy that increases exponentially. Useful with repeated * setTimeout calls over a network (where the destination may be down). */ -export class ExponentialBackoff { +export default class ExponentialBackoff { private readonly ms: number; private readonly max: number; private readonly factor: number = 2; - private readonly jitter: number = 0; - attempts = 0; + private numAttempts = 0; - constructor(opts: { min?: number; max?: number } = {}) { - this.ms = opts.min || 100; - this.max = opts.max || 10000; + /** + * Constructs an ExponentialBackoff object. + * + * @param opts - The options for the object. + */ + public constructor(opts: ExponentialBackoffOptions = {}) { + this.ms = opts.min ?? DEFAULT_MIN; + this.max = opts.max ?? DEFAULT_MAX; + } + + /** + * Number of attempts for backoff so far. + * + * @returns Number of attempts. + */ + public get attempts(): number { + return this.numAttempts; } /** * Return the backoff duration. + * + * @returns The backoff duration in milliseconds. */ - duration() { - let ms = this.ms * this.factor ** this.attempts++; - if (this.jitter) { - const rand = Math.random(); - const deviation = Math.floor(rand * this.jitter * ms); - ms = (Math.floor(rand * 10) & 1) == 0 ? ms - deviation : ms + deviation; - } - return Math.min(ms, this.max) | 0; + public duration(): number { + const ms = this.ms * this.factor ** this.numAttempts; + this.numAttempts += 1; + return Math.floor(Math.min(ms, this.max)); } /** * Reset the number of attempts. */ - reset() { - this.attempts = 0; + public reset(): void { + this.numAttempts = 0; } } diff --git a/src/client/broadcastClient.ts b/src/client/broadcastClient.ts index d4353248..afa70b51 100644 --- a/src/client/broadcastClient.ts +++ b/src/client/broadcastClient.ts @@ -1,10 +1,18 @@ import { Client, ClientOptions } from "."; -class BroadcastClient extends Client { - ledgerVersion: number | undefined = undefined; - private readonly _clients: Client[]; +/** + * Client that can rely on multiple different servers. + */ +export default class BroadcastClient extends Client { + private readonly clients: Client[]; - constructor(servers, options: ClientOptions = {}) { + /** + * Creates a new BroadcastClient. + * + * @param servers - An array of names of servers. + * @param options - Options for the clients. + */ + public constructor(servers: string[], options: ClientOptions = {}) { super(servers[0], options); const clients: Client[] = servers.map( @@ -12,33 +20,23 @@ class BroadcastClient extends Client { ); // exposed for testing - this._clients = clients; - this.getMethodNames().forEach((name) => { - this[name] = function () { - // eslint-disable-line no-loop-func - return Promise.race( - clients.map((client) => client[name](...arguments)) - ); - }; + this.clients = clients; + this.getMethodNames().forEach((name: string) => { + this[name] = async (...args): Promise => + // eslint-disable-next-line max-len -- Need a long comment, TODO: figure out how to avoid this weirdness + /* eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call -- Types are outlined in Client class */ + Promise.race(clients.map(async (client) => client[name](...args))); }); // connection methods must be overridden to apply to all client instances - this.connect = async function () { - await Promise.all(clients.map((client) => client.connect())); + this.connect = async (): Promise => { + await Promise.all(clients.map(async (client) => client.connect())); }; - this.disconnect = async function () { - await Promise.all(clients.map((client) => client.disconnect())); + this.disconnect = async (): Promise => { + await Promise.all(clients.map(async (client) => client.disconnect())); }; - this.isConnected = function () { - return clients.map((client) => client.isConnected()).every(Boolean); - }; - - // synchronous methods are all passed directly to the first client instance - const defaultClient = clients[0]; - const syncMethods = ["sign"]; - syncMethods.forEach((name) => { - this[name] = defaultClient[name].bind(defaultClient); - }); + this.isConnected = (): boolean => + clients.map((client) => client.isConnected()).every(Boolean); clients.forEach((client) => { client.on("error", (errorCode, errorMessage, data) => @@ -47,9 +45,14 @@ class BroadcastClient extends Client { }); } - getMethodNames() { + /** + * Gets the method names of all the methods of the client. + * + * @returns A list of the names of all the methods of the client. + */ + private getMethodNames(): string[] { const methodNames: string[] = []; - const firstClient = this._clients[0]; + const firstClient = this.clients[0]; const methods = Object.getOwnPropertyNames(firstClient); methods.push( ...Object.getOwnPropertyNames(Object.getPrototypeOf(firstClient)) @@ -62,5 +65,3 @@ class BroadcastClient extends Client { return methodNames; } } - -export { BroadcastClient }; diff --git a/src/client/connection.ts b/src/client/connection.ts index 539f4e5f..b8576dee 100644 --- a/src/client/connection.ts +++ b/src/client/connection.ts @@ -1,26 +1,32 @@ +/* eslint-disable max-lines -- Connection is a big class */ import { EventEmitter } from "events"; +import { Agent } from "http"; +// eslint-disable-next-line node/no-deprecated-api -- TODO: resolve this import { parse as parseURL } from "url"; import _ from "lodash"; import WebSocket from "ws"; import { - RippledError, DisconnectedError, NotConnectedError, - TimeoutError, - ResponseFormatError, ConnectionError, RippleError, } from "../common/errors"; -import { Response } from "../models/methods"; +import { BaseRequest } from "../models/methods/baseMethod"; -import { ExponentialBackoff } from "./backoff"; +import ExponentialBackoff from "./backoff"; +import ConnectionManager from "./connectionManager"; +import RequestManager from "./requestManager"; + +const SECONDS_PER_MINUTE = 60; +const TIMEOUT = 20; +const CONNECTION_TIMEOUT = 5; /** * ConnectionOptions is the configuration for the Connection class. */ -export interface ConnectionOptions { +interface ConnectionOptions { trace?: boolean | ((id: string, message: string) => void); proxy?: string; proxyAuthorization?: string; @@ -29,7 +35,8 @@ export interface ConnectionOptions { key?: string; passphrase?: string; certificate?: string; - timeout: number; // request timeout + // request timeout + timeout: number; connectionTimeout: number; } @@ -45,19 +52,13 @@ export type ConnectionUserOptions = Partial; // WebSocket spec allows 4xxx codes for app/library specific codes. // See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent // -const INTENTIONAL_DISCONNECT_CODE = 4000; +export const INTENTIONAL_DISCONNECT_CODE = 4000; -/** - * Create a new websocket given your URL and optional proxy/certificate - * configuration. - * - * @param url - * @param config - */ -function createWebSocket(url: string, config: ConnectionOptions): WebSocket { - const options: WebSocket.ClientOptions = {}; +type WebsocketState = 0 | 1 | 2 | 3; + +function getAgent(url: string, config: ConnectionOptions): Agent | undefined { + // TODO: replace deprecated method if (config.proxy != null) { - // TODO: replace deprecated method const parsedURL = parseURL(url); const parsedProxyURL = parseURL(config.proxy); const proxyOverrides = _.omitBy( @@ -75,12 +76,32 @@ function createWebSocket(url: string, config: ConnectionOptions): WebSocket { const proxyOptions = { ...parsedProxyURL, ...proxyOverrides }; let HttpsProxyAgent; try { + // eslint-disable-next-line max-len -- Long eslint-disable-next-line TODO: figure out how to make this nicer + // eslint-disable-next-line import/max-dependencies, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-require-imports, node/global-require, global-require, -- Necessary for the `require` HttpsProxyAgent = require("https-proxy-agent"); - } catch (error) { + } catch (_error) { throw new Error('"proxy" option is not supported in the browser'); } - options.agent = new HttpsProxyAgent(proxyOptions); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unsafe-call -- Necessary + return new HttpsProxyAgent(proxyOptions) as unknown as Agent; } + return undefined; +} + +/** + * Create a new websocket given your URL and optional proxy/certificate + * configuration. + * + * @param url - The URL to connect to. + * @param config - THe configuration options for the WebSocket. + * @returns A Websocket that fits the given configuration parameters. + */ +function createWebSocket( + url: string, + config: ConnectionOptions +): WebSocket | null { + const options: WebSocket.ClientOptions = {}; + options.agent = getAgent(url, config); if (config.authorization != null) { const base64 = Buffer.from(config.authorization).toString("base64"); options.headers = { Authorization: `Basic ${base64}` }; @@ -107,10 +128,14 @@ function createWebSocket(url: string, config: ConnectionOptions): WebSocket { /** * Ws.send(), but promisified. * - * @param ws - * @param message + * @param ws - Websocket to send with. + * @param message - Message to send. + * @returns When the message has been sent. */ -function websocketSendAsync(ws: WebSocket, message: string) { +async function websocketSendAsync( + ws: WebSocket, + message: string +): Promise { return new Promise((resolve, reject) => { ws.send(message, (error) => { if (error) { @@ -122,357 +147,106 @@ function websocketSendAsync(ws: WebSocket, message: string) { }); } -/** - * Manage all the requests made to the websocket, and their async responses - * that come in from the WebSocket. Because they come in over the WS connection - * after-the-fact. - */ -class ConnectionManager { - private promisesAwaitingConnection: Array<{ - resolve: Function; - reject: Function; - }> = []; - - resolveAllAwaiting() { - this.promisesAwaitingConnection.map(({ resolve }) => resolve()); - this.promisesAwaitingConnection = []; - } - - rejectAllAwaiting(error: Error) { - this.promisesAwaitingConnection.map(({ reject }) => reject(error)); - this.promisesAwaitingConnection = []; - } - - awaitConnection(): Promise { - return new Promise((resolve, reject) => { - this.promisesAwaitingConnection.push({ resolve, reject }); - }); - } -} - -/** - * Manage all the requests made to the websocket, and their async responses - * that come in from the WebSocket. Responses come in over the WS connection - * after-the-fact, so this manager will tie that response to resolve the - * original request. - */ -class RequestManager { - private nextId = 0; - private promisesAwaitingResponse: Array<{ - resolve: Function; - reject: Function; - timer: NodeJS.Timeout; - }> = []; - - cancel(id: number) { - const { timer } = this.promisesAwaitingResponse[id]; - clearTimeout(timer); - delete this.promisesAwaitingResponse[id]; - } - - resolve(id: string | number, data: Response) { - const { timer, resolve } = this.promisesAwaitingResponse[id]; - clearTimeout(timer); - resolve(data); - delete this.promisesAwaitingResponse[id]; - } - - reject(id: string | number, error: Error) { - const { timer, reject } = this.promisesAwaitingResponse[id]; - clearTimeout(timer); - reject(error); - delete this.promisesAwaitingResponse[id]; - } - - rejectAll(error: Error) { - this.promisesAwaitingResponse.forEach((_, id) => { - this.reject(id, error); - }); - } - - /** - * Creates a new WebSocket request. This sets up a timeout timer to catch - * hung responses, and a promise that will resolve with the response once - * the response is seen & handled. - * - * @param data - * @param timeout - */ - createRequest( - data: any, - timeout: number - ): [string | number, string, Promise] { - const newId = data.id ? data.id : this.nextId++; - const newData = JSON.stringify({ ...data, id: newId }); - const timer = setTimeout( - () => this.reject(newId, new TimeoutError()), - timeout - ); - // Node.js won't exit if a timer is still running, so we tell Node to ignore. - // (Node will still wait for the request to complete). - if (timer.unref) { - timer.unref(); - } - const newPromise = new Promise( - (resolve: (data: Response) => void, reject) => { - this.promisesAwaitingResponse[newId] = { resolve, reject, timer }; - } - ); - return [newId, newData, newPromise]; - } - - /** - * Handle a "response". Responses match to the earlier request handlers, - * and resolve/reject based on the data received. - * - * @param data - */ - handleResponse(data: Response) { - if (!Number.isInteger(data.id) || data.id < 0) { - throw new ResponseFormatError("valid id not found in response", data); - } - if (!this.promisesAwaitingResponse[data.id]) { - return; - } - if (data.status === "error") { - const error = new RippledError(data.error_message || data.error, data); - this.reject(data.id, error); - return; - } - if (data.status !== "success") { - const error = new ResponseFormatError( - `unrecognized response.status: ${data.status}`, - data - ); - this.reject(data.id, error); - return; - } - this.resolve(data.id, data); - } -} - /** * The main Connection class. Responsible for connecting to & managing * an active WebSocket connection to a XRPL node. - * - * @param errorOrCode */ export class Connection extends EventEmitter { - private readonly _url: string | undefined; - private _ws: null | WebSocket = null; - private _reconnectTimeoutID: null | NodeJS.Timeout = null; - private _heartbeatIntervalID: null | NodeJS.Timeout = null; - private readonly _retryConnectionBackoff = new ExponentialBackoff({ + private readonly url: string | undefined; + private ws: WebSocket | null = null; + private reconnectTimeoutID: null | NodeJS.Timeout = null; + private heartbeatIntervalID: null | NodeJS.Timeout = null; + private readonly retryConnectionBackoff = new ExponentialBackoff({ min: 100, - max: 60 * 1000, + max: SECONDS_PER_MINUTE * 1000, }); - private readonly _trace: (id: string, message: string) => void = () => {}; - private readonly _config: ConnectionOptions; - private readonly _requestManager = new RequestManager(); - private readonly _connectionManager = new ConnectionManager(); + private readonly config: ConnectionOptions; + private readonly requestManager = new RequestManager(); + private readonly connectionManager = new ConnectionManager(); - constructor(url?: string, options: ConnectionUserOptions = {}) { + /** + * Creates a new Connection object. + * + * @param url - URL to connect to. + * @param options - Options for the Connection object. + */ + public constructor(url?: string, options: ConnectionUserOptions = {}) { super(); this.setMaxListeners(Infinity); - this._url = url; - this._config = { - timeout: 20 * 1000, - connectionTimeout: 5 * 1000, + this.url = url; + this.config = { + timeout: TIMEOUT * 1000, + connectionTimeout: CONNECTION_TIMEOUT * 1000, ...options, }; if (typeof options.trace === "function") { - this._trace = options.trace; + this.trace = options.trace; } else if (options.trace) { - this._trace = console.log; + // eslint-disable-next-line no-console -- Used for tracing only + this.trace = console.log; } } - private _onMessage(message) { - this._trace("receive", message); - let data: any; - try { - data = JSON.parse(message); - } catch (error) { - this.emit("error", "badMessage", error.message, message); - return; - } - if (data.type == null && data.error) { - this.emit("error", data.error, data.error_message, data); // e.g. slowDown - return; - } - if (data.type) { - this.emit(data.type, data); - } - if (data.type === "response") { - try { - this._requestManager.handleResponse(data); - } catch (error) { - this.emit("error", "badMessage", error.message, message); - } - } - } - - private get _state() { - return this._ws ? this._ws.readyState : WebSocket.CLOSED; - } - - private get _shouldBeConnected() { - return this._ws !== null; - } - - private readonly _clearHeartbeatInterval = () => { - if (this._heartbeatIntervalID) { - clearInterval(this._heartbeatIntervalID); - } - }; - - private readonly _startHeartbeatInterval = () => { - this._clearHeartbeatInterval(); - this._heartbeatIntervalID = setInterval( - () => this._heartbeat(), - this._config.timeout - ); - }; - /** - * A heartbeat is just a "ping" command, sent on an interval. - * If this succeeds, we're good. If it fails, disconnect so that the consumer can reconnect, if desired. + * Returns whether the websocket is connected. + * + * @returns Whether the websocket connection is open. */ - private readonly _heartbeat = () => { - return this.request({ command: "ping" }).catch(() => { - return this.reconnect().catch((error) => { - this.emit("error", "reconnect", error.message, error); - }); - }); - }; - - private readonly _onConnectionFailed = ( - errorOrCode: Error | number | null - ) => { - if (this._ws) { - this._ws.removeAllListeners(); - this._ws.on("error", () => { - // Correctly listen for -- but ignore -- any future errors: If you - // don't have a listener on "error" node would log a warning on error. - }); - this._ws.close(); - this._ws = null; - } - if (typeof errorOrCode === "number") { - this._connectionManager.rejectAllAwaiting( - new NotConnectedError(`Connection failed with code ${errorOrCode}.`, { - code: errorOrCode, - }) - ); - } else if (errorOrCode && errorOrCode.message) { - this._connectionManager.rejectAllAwaiting( - new NotConnectedError(errorOrCode.message, errorOrCode) - ); - } else { - this._connectionManager.rejectAllAwaiting( - new NotConnectedError("Connection failed.") - ); - } - }; - - isConnected() { - return this._state === WebSocket.OPEN; + public isConnected(): boolean { + return this.state === WebSocket.OPEN; } - connect(): Promise { + /** + * Connects the websocket to the provided URL. + * + * @returns When the websocket is connected. + * @throws ConnectionError if there is a connection error, RippleError if there is already a WebSocket in existence. + */ + public async connect(): Promise { if (this.isConnected()) { return Promise.resolve(); } - if (this._state === WebSocket.CONNECTING) { - return this._connectionManager.awaitConnection(); + if (this.state === WebSocket.CONNECTING) { + return this.connectionManager.awaitConnection(); } - if (!this._url) { + if (!this.url) { return Promise.reject( new ConnectionError("Cannot connect because no server was specified") ); } - if (this._ws) { + if (this.ws != null) { return Promise.reject( new RippleError("Websocket connection never cleaned up.", { - state: this._state, + state: this.state, }) ); } // Create the connection timeout, in case the connection hangs longer than expected. const connectionTimeoutID = setTimeout(() => { - this._onConnectionFailed( + this.onConnectionFailed( new ConnectionError( - `Error: connect() timed out after ${this._config.connectionTimeout} ms. ` + + `Error: connect() timed out after ${this.config.connectionTimeout} ms. ` + `If your internet connection is working, the rippled server may be blocked or inaccessible. ` + `You can also try setting the 'connectionTimeout' option in the Client constructor.` ) ); - }, this._config.connectionTimeout); + }, this.config.connectionTimeout); // Connection listeners: these stay attached only until a connection is done/open. - this._ws = createWebSocket(this._url, this._config); + this.ws = createWebSocket(this.url, this.config); - if (this._ws == null) { + if (this.ws == null) { throw new Error("Connect: created null websocket"); } - this._ws.on("error", this._onConnectionFailed); - this._ws.on("error", () => clearTimeout(connectionTimeoutID)); - this._ws.on("close", this._onConnectionFailed); - this._ws.on("close", () => clearTimeout(connectionTimeoutID)); - this._ws.once("open", async () => { - if (this._ws == null) { - throw new Error("onceOpen: ws is null"); - } - - // Once the connection completes successfully, remove all old listeners - this._ws.removeAllListeners(); - clearTimeout(connectionTimeoutID); - // Add new, long-term connected listeners for messages and errors - this._ws.on("message", (message: string) => this._onMessage(message)); - this._ws.on("error", (error) => - this.emit("error", "websocket", error.message, error) - ); - // Handle a closed connection: reconnect if it was unexpected - this._ws.once("close", (code, reason) => { - if (this._ws == null) { - throw new Error("onceClose: ws is null"); - } - - this._clearHeartbeatInterval(); - this._requestManager.rejectAll( - new DisconnectedError(`websocket was closed, ${reason}`) - ); - this._ws.removeAllListeners(); - this._ws = null; - this.emit("disconnected", code); - // If this wasn't a manual disconnect, then lets reconnect ASAP. - if (code !== INTENTIONAL_DISCONNECT_CODE) { - const retryTimeout = this._retryConnectionBackoff.duration(); - this._trace("reconnect", `Retrying connection in ${retryTimeout}ms.`); - this.emit("reconnecting", this._retryConnectionBackoff.attempts); - // Start the reconnect timeout, but set it to `this._reconnectTimeoutID` - // so that we can cancel one in-progress on disconnect. - this._reconnectTimeoutID = setTimeout(() => { - this.reconnect().catch((error) => { - this.emit("error", "reconnect", error.message, error); - }); - }, retryTimeout); - } - }); - // Finalize the connection and resolve all awaiting connect() requests - try { - this._retryConnectionBackoff.reset(); - this._startHeartbeatInterval(); - this._connectionManager.resolveAllAwaiting(); - this.emit("connected"); - } catch (error) { - this._connectionManager.rejectAllAwaiting(error); - await this.disconnect().catch(() => {}); // Ignore this error, propagate the root cause. - } - }); - return this._connectionManager.awaitConnection(); + this.ws.on("error", (error) => this.onConnectionFailed(error)); + this.ws.on("error", () => clearTimeout(connectionTimeoutID)); + this.ws.on("close", (reason) => this.onConnectionFailed(reason)); + this.ws.on("close", () => clearTimeout(connectionTimeoutID)); + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- TODO: resolve this + this.ws.once("open", async () => this.onceOpen(connectionTimeoutID)); + return this.connectionManager.awaitConnection(); } /** @@ -481,30 +255,33 @@ export class Connection extends EventEmitter { * should still successfully close with the relevant error code returned. * See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent for the full list. * If no open websocket connection exists, resolve with no code (`undefined`). + * + * @returns A promise containing either `undefined` or a disconnected code, that resolves when the connection is destroyed. */ - disconnect(): Promise { - if (this._reconnectTimeoutID !== null) { - clearTimeout(this._reconnectTimeoutID); - this._reconnectTimeoutID = null; + public async disconnect(): Promise { + if (this.reconnectTimeoutID !== null) { + clearTimeout(this.reconnectTimeoutID); + this.reconnectTimeoutID = null; } - if (this._state === WebSocket.CLOSED) { + if (this.state === WebSocket.CLOSED) { return Promise.resolve(undefined); } - if (this._ws === null) { + if (this.ws == null) { return Promise.resolve(undefined); } return new Promise((resolve) => { - if (this._ws === null) { - return Promise.resolve(undefined); + if (this.ws == null) { + resolve(undefined); + } + if (this.ws != null) { + this.ws.once("close", (code) => resolve(code)); } - - this._ws.once("close", (code) => resolve(code)); // Connection already has a disconnect handler for the disconnect logic. // Just close the websocket manually (with our "intentional" code) to // trigger that. - if (this._ws != null && this._state !== WebSocket.CLOSING) { - this._ws.close(INTENTIONAL_DISCONNECT_CODE); + if (this.ws != null && this.state !== WebSocket.CLOSING) { + this.ws.close(INTENTIONAL_DISCONNECT_CODE); } }); } @@ -512,7 +289,7 @@ export class Connection extends EventEmitter { /** * Disconnect the websocket, then connect again. */ - async reconnect() { + public async reconnect(): Promise { // NOTE: We currently have a "reconnecting" event, but that only triggers // through an unexpected connection retry logic. // See: https://github.com/ripple/ripple-lib/pull/1101#issuecomment-565360423 @@ -521,20 +298,28 @@ export class Connection extends EventEmitter { await this.connect(); } - async request( + /** + * Sends a request to the rippled server. + * + * @param request - The request to send to the server. + * @param timeout - How long the Connection instance should wait before assuming that there will not be a response. + * @returns The response from the rippled server. + * @throws NotConnectedError if the Connection isn't connected to a server. + */ + public async request( request: T, timeout?: number - ): Promise { - if (!this._shouldBeConnected || this._ws == null) { + ): Promise { + if (!this.shouldBeConnected || this.ws == null) { throw new NotConnectedError(); } - const [id, message, responsePromise] = this._requestManager.createRequest( + const [id, message, responsePromise] = this.requestManager.createRequest( request, - timeout || this._config.timeout + timeout ?? this.config.timeout ); - this._trace("send", message); - websocketSendAsync(this._ws, message).catch((error) => { - this._requestManager.reject(id, error); + this.trace("send", message); + websocketSendAsync(this.ws, message).catch((error) => { + this.requestManager.reject(id, error); }); return responsePromise; @@ -545,7 +330,195 @@ export class Connection extends EventEmitter { * * @returns The Websocket connection URL. */ - getUrl(): string { - return this._url ?? ""; + public getUrl(): string { + return this.url ?? ""; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function -- Does nothing on default + private readonly trace: (id: string, message: string) => void = () => {}; + + /** + * Handler for when messages are received from the server. + * + * @param message - The message received from the server. + */ + private onMessage(message): void { + this.trace("receive", message); + let data: Record; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Must be a JSON dictionary + data = JSON.parse(message); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Errors have messages + this.emit("error", "badMessage", error.message, message); + return; + } + if (data.type == null && data.error) { + // e.g. slowDown + this.emit("error", data.error, data.error_message, data); + return; + } + if (data.type) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Should be true + this.emit(data.type as string, data); + } + if (data.type === "response") { + try { + this.requestManager.handleResponse(data); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Errors have messages + this.emit("error", "badMessage", error.message, message); + } + } + } + + /** + * Gets the state of the websocket. + * + * @returns The Websocket's ready state. + */ + private get state(): WebsocketState { + return this.ws ? this.ws.readyState : WebSocket.CLOSED; + } + + /** + * Returns whether the server should be connected. + * + * @returns Whether the server should be connected. + */ + private get shouldBeConnected(): boolean { + return this.ws !== null; + } + + /** + * Handler for what to do once the connection to the server is open. + * + * @param connectionTimeoutID - Timeout in case the connection hangs longer than expected. + * @returns A promise that resolves to void when the connection is fully established. + * @throws Error if the websocket initialized is somehow null. + */ + private async onceOpen(connectionTimeoutID: NodeJS.Timeout): Promise { + if (this.ws == null) { + throw new Error("onceOpen: ws is null"); + } + + // Once the connection completes successfully, remove all old listeners + this.ws.removeAllListeners(); + clearTimeout(connectionTimeoutID); + // Add new, long-term connected listeners for messages and errors + this.ws.on("message", (message: string) => this.onMessage(message)); + this.ws.on("error", (error) => + this.emit("error", "websocket", error.message, error) + ); + // Handle a closed connection: reconnect if it was unexpected + this.ws.once("close", (code, reason) => { + if (this.ws == null) { + throw new Error("onceClose: ws is null"); + } + + this.clearHeartbeatInterval(); + this.requestManager.rejectAll( + new DisconnectedError(`websocket was closed, ${reason}`) + ); + this.ws.removeAllListeners(); + this.ws = null; + this.emit("disconnected", code); + // If this wasn't a manual disconnect, then lets reconnect ASAP. + if (code !== INTENTIONAL_DISCONNECT_CODE) { + this.intentionalDisconnect(); + } + }); + // Finalize the connection and resolve all awaiting connect() requests + try { + this.retryConnectionBackoff.reset(); + this.startHeartbeatInterval(); + this.connectionManager.resolveAllAwaiting(); + this.emit("connected"); + } catch (error) { + this.connectionManager.rejectAllAwaiting(error); + // Ignore this error, propagate the root cause. + // eslint-disable-next-line @typescript-eslint/no-empty-function -- Need empty catch + await this.disconnect().catch(() => {}); + } + } + + private intentionalDisconnect(): void { + const retryTimeout = this.retryConnectionBackoff.duration(); + this.trace("reconnect", `Retrying connection in ${retryTimeout}ms.`); + this.emit("reconnecting", this.retryConnectionBackoff.attempts); + // Start the reconnect timeout, but set it to `this.reconnectTimeoutID` + // so that we can cancel one in-progress on disconnect. + this.reconnectTimeoutID = setTimeout(() => { + this.reconnect().catch((error: Error) => { + this.emit("error", "reconnect", error.message, error); + }); + }, retryTimeout); + } + + /** + * Clears the heartbeat connection interval. + */ + private clearHeartbeatInterval(): void { + if (this.heartbeatIntervalID) { + clearInterval(this.heartbeatIntervalID); + } + } + + /** + * Starts a heartbeat to check the connection with the server. + */ + private startHeartbeatInterval(): void { + this.clearHeartbeatInterval(); + this.heartbeatIntervalID = setInterval( + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- TODO: resolve this + async () => this.heartbeat(), + this.config.timeout + ); + } + + /** + * A heartbeat is just a "ping" command, sent on an interval. + * If this succeeds, we're good. If it fails, disconnect so that the consumer can reconnect, if desired. + * + * @returns A Promise that resolves to void when the heartbeat returns successfully. + */ + private async heartbeat(): Promise { + this.request({ command: "ping" }).catch(async () => { + return this.reconnect().catch((error: Error) => { + this.emit("error", "reconnect", error.message, error); + }); + }); + } + + /** + * Process a failed connection. + * + * @param errorOrCode - (Optional) Error or code for connection failure. + */ + private onConnectionFailed(errorOrCode: Error | number | null): void { + if (this.ws) { + this.ws.removeAllListeners(); + this.ws.on("error", () => { + // Correctly listen for -- but ignore -- any future errors: If you + // don't have a listener on "error" node would log a warning on error. + }); + this.ws.close(); + this.ws = null; + } + if (typeof errorOrCode === "number") { + this.connectionManager.rejectAllAwaiting( + new NotConnectedError(`Connection failed with code ${errorOrCode}.`, { + code: errorOrCode, + }) + ); + } else if (errorOrCode?.message) { + this.connectionManager.rejectAllAwaiting( + new NotConnectedError(errorOrCode.message, errorOrCode) + ); + } else { + this.connectionManager.rejectAllAwaiting( + new NotConnectedError("Connection failed.") + ); + } } } diff --git a/src/client/connectionManager.ts b/src/client/connectionManager.ts new file mode 100644 index 00000000..50136e8c --- /dev/null +++ b/src/client/connectionManager.ts @@ -0,0 +1,40 @@ +/** + * Manage all the requests made to the websocket, and their async responses + * that come in from the WebSocket. Because they come in over the WS connection + * after-the-fact. + */ +export default class ConnectionManager { + private promisesAwaitingConnection: Array<{ + resolve: (value?: void | PromiseLike) => void; + reject: (value?: Error) => void; + }> = []; + + /** + * Resolves all awaiting connections. + */ + public resolveAllAwaiting(): void { + this.promisesAwaitingConnection.map(({ resolve }) => resolve()); + this.promisesAwaitingConnection = []; + } + + /** + * Rejects all awaiting connections. + * + * @param error - Error to throw in the rejection. + */ + public rejectAllAwaiting(error: Error): void { + this.promisesAwaitingConnection.map(({ reject }) => reject(error)); + this.promisesAwaitingConnection = []; + } + + /** + * Await a new connection. + * + * @returns A promise for resolving the connection. + */ + public async awaitConnection(): Promise { + return new Promise((resolve, reject) => { + this.promisesAwaitingConnection.push({ resolve, reject }); + }); + } +} diff --git a/src/client/index.ts b/src/client/index.ts index bdf48854..e44d8c9a 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,3 +1,6 @@ +/* eslint-disable import/max-dependencies -- Client needs a lot of dependencies by definition */ +/* eslint-disable @typescript-eslint/member-ordering -- TODO: remove when instance methods aren't members */ +/* eslint-disable max-lines -- This might not be necessary later, but this file needs to be big right now */ import { EventEmitter } from "events"; import { @@ -18,7 +21,7 @@ import { } from "ripple-address-codec"; import { constants, errors, txFlags, ensureClassicAddress } from "../common"; -import { ValidationError } from "../common/errors"; +import { RippledError, ValidationError } from "../common/errors"; import { getFee } from "../common/fee"; import getBalances from "../ledger/balances"; import { getOrderbook, formatBidsAndAsks } from "../ledger/orderbook"; @@ -26,8 +29,6 @@ import getPaths from "../ledger/pathfind"; import getTrustlines from "../ledger/trustlines"; import { clamp } from "../ledger/utils"; import { - Request, - Response, // account methods AccountChannelsRequest, AccountChannelsResponse, @@ -94,6 +95,7 @@ import { RandomRequest, RandomResponse, } from "../models/methods"; +import { BaseRequest, BaseResponse } from "../models/methods/baseMethod"; import prepareCheckCancel from "../transaction/check-cancel"; import prepareCheckCash from "../transaction/check-cash"; import prepareCheckCreate from "../transaction/check-create"; @@ -116,7 +118,11 @@ import * as transactionUtils from "../transaction/utils"; import { deriveAddress, deriveXAddress } from "../utils/derive"; import generateFaucetWallet from "../wallet/generateFaucetWallet"; -import { Connection, ConnectionUserOptions } from "./connection"; +import { + Connection, + ConnectionUserOptions, + INTENTIONAL_DISCONNECT_CODE, +} from "./connection"; export interface ClientOptions extends ConnectionUserOptions { feeCushion?: number; @@ -130,7 +136,8 @@ export interface ClientOptions extends ConnectionUserOptions { * command. This varies from command to command, but we need to know it to * properly count across many requests. * - * @param command + * @param command - The rippled request command. + * @returns The property key corresponding to the command. */ function getCollectKeyFromCommand(command: string): string | null { switch (command) { @@ -152,44 +159,51 @@ function getCollectKeyFromCommand(command: string): string | null { } } -type MarkerRequest = - | AccountChannelsRequest - | AccountLinesRequest - | AccountObjectsRequest - | AccountOffersRequest - | AccountTxRequest - | LedgerDataRequest; +interface MarkerRequest extends BaseRequest { + limit?: number; + marker?: unknown; +} -type MarkerResponse = - | AccountChannelsResponse - | AccountLinesResponse - | AccountObjectsResponse - | AccountOffersResponse - | AccountTxResponse - | LedgerDataResponse; +interface MarkerResponse extends BaseResponse { + result: { + marker?: unknown; + }; +} + +const DEFAULT_FEE_CUSHION = 1.2; +const DEFAULT_MAX_FEE_XRP = "2"; + +const MIN_LIMIT = 10; +const MAX_LIMIT = 400; class Client extends EventEmitter { - // Factor to multiply estimated fee by to provide a cushion in case the - // required fee rises during submission of a transaction. Defaults to 1.2. - _feeCushion: number; - // Maximum fee to use with transactions, in XRP. Must be a string-encoded - // number. Defaults to '2'. - _maxFeeXRP: string; - // New in > 0.21.0 // non-validated ledger versions are allowed, and passed to rippled as-is. - connection: Connection; + public readonly connection: Connection; - constructor(server: string, options: ClientOptions = {}) { + // Factor to multiply estimated fee by to provide a cushion in case the + // required fee rises during submission of a transaction. Defaults to 1.2. + public readonly feeCushion: number; + // Maximum fee to use with transactions, in XRP. Must be a string-encoded + // number. Defaults to '2'. + public readonly maxFeeXRP: string; + + /** + * Creates a new Client with a websocket connection to a rippled server. + * + * @param server - URL of the server to connect to. + * @param options - Options for client settings. + */ + public constructor(server: string, options: ClientOptions = {}) { super(); - if (typeof server !== "string" || !/^(wss?|wss?\+unix):\/\//.exec(server)) { + if (typeof server !== "string" || !/wss?(?:\+unix)?:\/\//u.exec(server)) { throw new ValidationError( "server URI must start with `wss://`, `ws://`, `wss+unix://`, or `ws+unix://`." ); } - this._feeCushion = options.feeCushion || 1.2; - this._maxFeeXRP = options.maxFeeXRP || "2"; + this.feeCushion = options.feeCushion ?? DEFAULT_FEE_CUSHION; + this.maxFeeXRP = options.maxFeeXRP ?? DEFAULT_MAX_FEE_XRP; this.connection = new Connection(server, options); @@ -201,67 +215,17 @@ class Client extends EventEmitter { this.emit("connected"); }); - this.connection.on("disconnected", (code) => { + this.connection.on("disconnected", (code: number) => { let finalCode = code; // 4000: Connection uses a 4000 code internally to indicate a manual disconnect/close // Since 4000 is a normal disconnect reason, we convert this to the standard exit code 1000 - if (finalCode === 4000) { + if (finalCode === INTENTIONAL_DISCONNECT_CODE) { finalCode = 1000; } this.emit("disconnected", finalCode); }); } - /** - * Makes a request to the client with the given command and - * additional request body parameters. - */ - public request(r: AccountChannelsRequest): Promise; - public request( - r: AccountCurrenciesRequest - ): Promise; - public request(r: AccountInfoRequest): Promise; - public request(r: AccountLinesRequest): Promise; - public request(r: AccountObjectsRequest): Promise; - public request(r: AccountOffersRequest): Promise; - public request(r: AccountTxRequest): Promise; - public request(r: BookOffersRequest): Promise; - public request(r: ChannelVerifyRequest): Promise; - public request( - r: DepositAuthorizedRequest - ): Promise; - public request(r: FeeRequest): Promise; - public request(r: GatewayBalancesRequest): Promise; - public request(r: LedgerRequest): Promise; - public request(r: LedgerClosedRequest): Promise; - public request(r: LedgerCurrentRequest): Promise; - public request(r: LedgerDataRequest): Promise; - public request(r: LedgerEntryRequest): Promise; - public request(r: ManifestRequest): Promise; - public request(r: NoRippleCheckRequest): Promise; - public request(r: PathFindRequest): Promise; - public request(r: PingRequest): Promise; - public request(r: RandomRequest): Promise; - public request(r: RipplePathFindRequest): Promise; - public request(r: ServerInfoRequest): Promise; - public request(r: ServerStateRequest): Promise; - public request(r: SubmitRequest): Promise; - public request( - r: SubmitMultisignedRequest - ): Promise; - public request(r: TransactionEntryRequest): Promise; - public request(r: TxRequest): Promise; - public async request( - r: R - ): Promise { - // TODO: should this be typed with `extends BaseRequest/BaseResponse`? - return this.connection.request({ - ...r, - // @ts-expect-error - account: r.account ? ensureClassicAddress(r.account) : undefined, - }); - } - /** * Returns true if there are more pages of data. * @@ -270,47 +234,121 @@ class Client extends EventEmitter { * * See https://ripple.com/build/rippled-apis/#markers-and-pagination. * - * @param response + * @param response - Response to check for more pages on. + * @returns Whether the response has more pages of data. */ - hasNextPage(response: MarkerResponse): boolean { + public static hasNextPage(response: MarkerResponse): boolean { return Boolean(response.result.marker); } - async requestNextPage( + public async request( + r: AccountChannelsRequest + ): Promise; + public async request( + r: AccountCurrenciesRequest + ): Promise; + public async request(r: AccountInfoRequest): Promise; + public async request(r: AccountLinesRequest): Promise; + public async request( + r: AccountObjectsRequest + ): Promise; + public async request(r: AccountOffersRequest): Promise; + public async request(r: AccountTxRequest): Promise; + public async request(r: BookOffersRequest): Promise; + public async request(r: ChannelVerifyRequest): Promise; + public async request( + r: DepositAuthorizedRequest + ): Promise; + public async request(r: FeeRequest): Promise; + public async request( + r: GatewayBalancesRequest + ): Promise; + public async request(r: LedgerRequest): Promise; + public async request(r: LedgerClosedRequest): Promise; + public async request(r: LedgerCurrentRequest): Promise; + public async request(r: LedgerDataRequest): Promise; + public async request(r: LedgerEntryRequest): Promise; + public async request(r: ManifestRequest): Promise; + public async request(r: NoRippleCheckRequest): Promise; + public async request(r: PathFindRequest): Promise; + public async request(r: PingRequest): Promise; + public async request(r: RandomRequest): Promise; + public async request( + r: RipplePathFindRequest + ): Promise; + public async request(r: ServerInfoRequest): Promise; + public async request(r: ServerStateRequest): Promise; + public async request(r: SubmitRequest): Promise; + public async request( + r: SubmitMultisignedRequest + ): Promise; + public async request( + r: TransactionEntryRequest + ): Promise; + public async request(r: TxRequest): Promise; + /** + * Makes a request to the client with the given command and + * additional request body parameters. + * + * @param req - Request to send to the server. + * @returns The response from the server. + */ + public async request( + req: R + ): Promise { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Necessary for overloading + return this.connection.request({ + ...req, + account: req.account + ? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Must be string + ensureClassicAddress(req.account as string) + : undefined, + }) as unknown as T; + } + + public async requestNextPage( req: AccountChannelsRequest, resp: AccountChannelsResponse ): Promise; - async requestNextPage( + public async requestNextPage( req: AccountLinesRequest, resp: AccountLinesResponse ): Promise; - async requestNextPage( + public async requestNextPage( req: AccountObjectsRequest, resp: AccountObjectsResponse ): Promise; - async requestNextPage( + public async requestNextPage( req: AccountOffersRequest, resp: AccountOffersResponse ): Promise; - async requestNextPage( + public async requestNextPage( req: AccountTxRequest, resp: AccountTxResponse ): Promise; - async requestNextPage( + public async requestNextPage( req: LedgerDataRequest, resp: LedgerDataResponse ): Promise; - async requestNextPage( - req: T, - resp: U - ): Promise { + /** + * Requests the next page of data. + * + * @param req - Request to send. + * @param resp - Response with the marker to use in the request. + * @returns The response with the next page of data. + */ + public async requestNextPage< + T extends MarkerRequest, + U extends MarkerResponse + >(req: T, resp: U): Promise { if (!resp.result.marker) { return Promise.reject( new errors.NotFoundError("response does not have a next page") ); } const nextPageRequest = { ...req, marker: resp.result.marker }; - return this.connection.request(nextPageRequest); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Necessary for overloading + return this.connection.request(nextPageRequest) as unknown as U; } /** @@ -318,27 +356,36 @@ class Client extends EventEmitter { * * You can later submit the transaction with a `submit` request. * - * @param txJSON - * @param instructions + * @param txJSON - TODO: will be deleted. + * @param instructions - TODO: will be deleted. + * @returns TODO: will be deleted. */ - async prepareTransaction( + public async prepareTransaction( txJSON: TransactionJSON, instructions: Instructions = {} ): Promise { return transactionUtils.prepareTransaction(txJSON, this, instructions); } - /** - * Convert a string to hex. - * - * This can be used to generate `MemoData`, `MemoType`, and `MemoFormat`. - * - * @param string - String to convert to hex. - */ - convertStringToHex(string: string): string { - return transactionUtils.convertStringToHex(string); - } - + public async requestAll( + req: AccountChannelsRequest + ): Promise; + public async requestAll( + req: AccountLinesRequest + ): Promise; + public async requestAll( + req: AccountObjectsRequest + ): Promise; + public async requestAll( + req: AccountOffersRequest + ): Promise; + public async requestAll(req: AccountTxRequest): Promise; + public async requestAll( + req: BookOffersRequest + ): Promise; + public async requestAll( + req: LedgerDataRequest + ): Promise; /** * Makes multiple paged requests to the client to return a given number of * resources. Multiple paged requests will be made until the `limit` @@ -351,53 +398,52 @@ class Client extends EventEmitter { * NOTE: This command is used by existing methods and is not recommended for * general use. Instead, use rippled's built-in pagination and make multiple * requests as needed. + * + * @param request - The initial request to send to the server. + * @param collect - (Optional) the param to use to collect the array of resources (only needed if command is unknown). + * @returns The array of all responses. + * @throws ValidationError if there is no collection key (either from a known command or for the unknown command). */ - async requestAll( - req: AccountChannelsRequest - ): Promise; - async requestAll(req: AccountLinesRequest): Promise; - async requestAll( - req: AccountObjectsRequest - ): Promise; - async requestAll(req: AccountOffersRequest): Promise; - async requestAll(req: AccountTxRequest): Promise; - async requestAll(req: BookOffersRequest): Promise; - async requestAll(req: LedgerDataRequest): Promise; - async requestAll( + public async requestAll( request: T, - options: { collect?: string } = {} + collect?: string ): Promise { // The data under collection is keyed based on the command. Fail if command // not recognized and collection key not provided. - const collectKey = - options.collect || getCollectKeyFromCommand(request.command); + const collectKey = collect ?? getCollectKeyFromCommand(request.command); if (!collectKey) { - throw new errors.ValidationError( + throw new ValidationError( `no collect key for command ${request.command}` ); } // If limit is not provided, fetches all data over multiple requests. // NOTE: This may return much more than needed. Set limit when possible. - const countTo: number = request.limit != null ? request.limit : Infinity; + const countTo: number = request.limit == null ? Infinity : request.limit; let count = 0; - let marker = request.marker; + let marker: unknown = request.marker; let lastBatchLength: number; - const results: any[] = []; + const results: U[] = []; do { - const countRemaining = clamp(countTo - count, 10, 400); + const countRemaining = clamp(countTo - count, MIN_LIMIT, MAX_LIMIT); const repeatProps = { ...request, limit: countRemaining, marker, }; + // eslint-disable-next-line no-await-in-loop -- Necessary for this, it really has to wait const singleResponse = await this.connection.request(repeatProps); - const singleResult = singleResponse.result; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Should be true + const singleResult = (singleResponse as U).result; + if (!(collectKey in singleResult)) { + throw new RippledError(`${collectKey} not in result`); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Should be true const collectedData = singleResult[collectKey]; marker = singleResult.marker; - results.push(singleResponse); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Should be true + results.push(singleResponse as U); // Make sure we handle when no data (not even an empty array) is returned. - const isExpectedFormat = Array.isArray(collectedData); - if (isExpectedFormat) { + if (Array.isArray(collectedData)) { count += collectedData.length; lastBatchLength = collectedData.length; } else { @@ -407,78 +453,93 @@ class Client extends EventEmitter { return results; } - isConnected(): boolean { - return this.connection.isConnected(); - } - - async connect(): Promise { + /** + * Tells the Client instance to connect to its rippled server. + * + * @returns A promise that resolves with a void value when a connection is established. + */ + public async connect(): Promise { return this.connection.connect(); } - async disconnect(): Promise { + /** + * Tells the Client instance to disconnect from it's rippled server. + * + * @returns A promise that resolves with a void value when a connection is destroyed. + */ + public async disconnect(): Promise { // backwards compatibility: connection.disconnect() can return a number, but // this method returns nothing. SO we await but don't return any result. await this.connection.disconnect(); } - getFee = getFee; + /** + * Checks if the Client instance is connected to its rippled server. + * + * @returns Whether the client instance is connected. + */ + public isConnected(): boolean { + return this.connection.isConnected(); + } - getTrustlines = getTrustlines; - getBalances = getBalances; - getPaths = getPaths; - getOrderbook = getOrderbook; + public getFee = getFee; - preparePayment = preparePayment; - prepareTrustline = prepareTrustline; - prepareOrder = prepareOrder; - prepareOrderCancellation = prepareOrderCancellation; - prepareEscrowCreation = prepareEscrowCreation; - prepareEscrowExecution = prepareEscrowExecution; - prepareEscrowCancellation = prepareEscrowCancellation; - preparePaymentChannelCreate = preparePaymentChannelCreate; - preparePaymentChannelFund = preparePaymentChannelFund; - preparePaymentChannelClaim = preparePaymentChannelClaim; - prepareCheckCreate = prepareCheckCreate; - prepareCheckCash = prepareCheckCash; - prepareCheckCancel = prepareCheckCancel; - prepareTicketCreate = prepareTicketCreate; - prepareSettings = prepareSettings; - sign = sign; - combine = combine; + public getTrustlines = getTrustlines; + public getBalances = getBalances; + public getPaths = getPaths; + public getOrderbook = getOrderbook; - generateFaucetWallet = generateFaucetWallet; + public preparePayment = preparePayment; + public prepareTrustline = prepareTrustline; + public prepareOrder = prepareOrder; + public prepareOrderCancellation = prepareOrderCancellation; + public prepareEscrowCreation = prepareEscrowCreation; + public prepareEscrowExecution = prepareEscrowExecution; + public prepareEscrowCancellation = prepareEscrowCancellation; + public preparePaymentChannelCreate = preparePaymentChannelCreate; + public preparePaymentChannelFund = preparePaymentChannelFund; + public preparePaymentChannelClaim = preparePaymentChannelClaim; + public prepareCheckCreate = prepareCheckCreate; + public prepareCheckCash = prepareCheckCash; + public prepareCheckCancel = prepareCheckCancel; + public prepareTicketCreate = prepareTicketCreate; + public prepareSettings = prepareSettings; + public sign = sign; + public combine = combine; - errors = errors; + public generateFaucetWallet = generateFaucetWallet; - static deriveXAddress = deriveXAddress; + public errors = errors; + + public static deriveXAddress = deriveXAddress; // Client.deriveClassicAddress (static) is a new name for client.deriveAddress - static deriveClassicAddress = deriveAddress; + public static deriveClassicAddress = deriveAddress; - static formatBidsAndAsks = formatBidsAndAsks; + public static formatBidsAndAsks = formatBidsAndAsks; /** * Static methods to expose ripple-address-codec methods. */ - static classicAddressToXAddress = classicAddressToXAddress; - static xAddressToClassicAddress = xAddressToClassicAddress; - static isValidXAddress = isValidXAddress; - static isValidClassicAddress = isValidClassicAddress; - static encodeSeed = encodeSeed; - static decodeSeed = decodeSeed; - static encodeAccountID = encodeAccountID; - static decodeAccountID = decodeAccountID; - static encodeNodePublic = encodeNodePublic; - static decodeNodePublic = decodeNodePublic; - static encodeAccountPublic = encodeAccountPublic; - static decodeAccountPublic = decodeAccountPublic; - static encodeXAddress = encodeXAddress; - static decodeXAddress = decodeXAddress; + public static classicAddressToXAddress = classicAddressToXAddress; + public static xAddressToClassicAddress = xAddressToClassicAddress; + public static isValidXAddress = isValidXAddress; + public static isValidClassicAddress = isValidClassicAddress; + public static encodeSeed = encodeSeed; + public static decodeSeed = decodeSeed; + public static encodeAccountID = encodeAccountID; + public static decodeAccountID = decodeAccountID; + public static encodeNodePublic = encodeNodePublic; + public static decodeNodePublic = decodeNodePublic; + public static encodeAccountPublic = encodeAccountPublic; + public static decodeAccountPublic = decodeAccountPublic; + public static encodeXAddress = encodeXAddress; + public static decodeXAddress = decodeXAddress; - txFlags = txFlags; - static txFlags = txFlags; - accountSetFlags = constants.AccountSetFlags; - static accountSetFlags = constants.AccountSetFlags; + public txFlags = txFlags; + public static txFlags = txFlags; + public accountSetFlags = constants.AccountSetFlags; + public static accountSetFlags = constants.AccountSetFlags; } export { Client, Connection }; diff --git a/src/client/rangeSet.ts b/src/client/rangeSet.ts deleted file mode 100644 index 16b55426..00000000 --- a/src/client/rangeSet.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as assert from "assert"; - -import * as _ from "lodash"; - -type Interval = [number, number]; - -function mergeIntervals(intervals: Interval[]): Interval[] { - const stack: Interval[] = [[-Infinity, -Infinity]]; - _.sortBy(intervals, (x) => x[0]).forEach((interval) => { - const lastInterval: Interval = stack.pop()!; - if (interval[0] <= lastInterval[1] + 1) { - stack.push([lastInterval[0], Math.max(interval[1], lastInterval[1])]); - } else { - stack.push(lastInterval); - stack.push(interval); - } - }); - return stack.slice(1); -} - -class RangeSet { - ranges: Array<[number, number]> = []; - - constructor() { - this.reset(); - } - - reset() { - this.ranges = []; - } - - serialize() { - return this.ranges - .map((range) => `${range[0].toString()}-${range[1].toString()}`) - .join(","); - } - - addRange(start: number, end: number) { - assert.ok(start <= end, `invalid range ${start} <= ${end}`); - this.ranges = mergeIntervals(this.ranges.concat([[start, end]])); - } - - addValue(value: number) { - this.addRange(value, value); - } - - parseAndAddRanges(rangesString: string) { - const rangeStrings = rangesString.split(","); - rangeStrings.forEach((rangeString) => { - const range = rangeString.split("-").map(Number); - this.addRange(range[0], range.length === 1 ? range[0] : range[1]); - }); - } - - containsRange(start: number, end: number) { - return this.ranges.some((range) => range[0] <= start && range[1] >= end); - } - - containsValue(value: number) { - return this.containsRange(value, value); - } -} - -export default RangeSet; diff --git a/src/client/requestManager.ts b/src/client/requestManager.ts new file mode 100644 index 00000000..ffaa3151 --- /dev/null +++ b/src/client/requestManager.ts @@ -0,0 +1,171 @@ +import { + ResponseFormatError, + RippledError, + TimeoutError, +} from "../common/errors"; +import { Response } from "../models/methods"; +import { BaseRequest } from "../models/methods/baseMethod"; + +/** + * Manage all the requests made to the websocket, and their async responses + * that come in from the WebSocket. Responses come in over the WS connection + * after-the-fact, so this manager will tie that response to resolve the + * original request. + */ +export default class RequestManager { + private nextId = 0; + private readonly promisesAwaitingResponse = new Map< + string | number, + { + resolve: (value?: Response | PromiseLike) => void; + reject: (value?: Error) => void; + timer: NodeJS.Timeout; + } + >(); + + /** + * Cancels a request. + * + * @param id - ID of the request. + * @throws Error if no existing promise with the given ID. + */ + public cancel(id: string | number): void { + const promise = this.promisesAwaitingResponse.get(id); + if (promise == null) { + throw new Error(`No existing promise with id ${id}`); + } + clearTimeout(promise.timer); + this.deletePromise(id); + } + + /** + * Successfully resolves a request. + * + * @param id - ID of the request. + * @param response - Response to return. + * @throws Error if no existing promise with the given ID. + */ + public resolve(id: string | number, response: Response): void { + const promise = this.promisesAwaitingResponse.get(id); + if (promise == null) { + throw new Error(`No existing promise with id ${id}`); + } + clearTimeout(promise.timer); + promise.resolve(response); + this.deletePromise(id); + } + + /** + * Rejects a request. + * + * @param id - ID of the request. + * @param error - Error to throw with the reject. + * @throws Error if no existing promise with the given ID. + */ + public reject(id: string | number, error: Error): void { + const promise = this.promisesAwaitingResponse.get(id); + if (promise == null) { + throw new Error(`No existing promise with id ${id}`); + } + clearTimeout(promise.timer); + promise.reject(error); + this.deletePromise(id); + } + + /** + * Reject all pending requests. + * + * @param error - Error to throw with the reject. + */ + public rejectAll(error: Error): void { + this.promisesAwaitingResponse.forEach((_promise, id, _map) => { + this.reject(id, error); + }); + } + + /** + * Creates a new WebSocket request. This sets up a timeout timer to catch + * hung responses, and a promise that will resolve with the response once + * the response is seen & handled. + * + * @param request - Request to create. + * @param timeout - Timeout length to catch hung responses. + * @returns Request ID, new request form, and the promise for resolving the request. + */ + public createRequest( + request: T, + timeout: number + ): [string | number, string, Promise] { + const newId = request.id ? request.id : this.nextId; + this.nextId += 1; + const newRequest = JSON.stringify({ ...request, id: newId }); + const timer = setTimeout( + () => this.reject(newId, new TimeoutError()), + timeout + ); + // Node.js won't exit if a timer is still running, so we tell Node to ignore. + // (Node will still wait for the request to complete). + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Reason above. + if (timer.unref) { + timer.unref(); + } + const newPromise = new Promise( + (resolve: (value?: Response | PromiseLike) => void, reject) => { + this.promisesAwaitingResponse.set(newId, { resolve, reject, timer }); + } + ); + return [newId, newRequest, newPromise]; + } + + /** + * Handle a "response". Responses match to the earlier request handlers, + * and resolve/reject based on the data received. + * + * @param response - The response to handle. + * @throws ResponseFormatError if the response format is invalid, RippledError if rippled returns an error. + */ + public handleResponse(response: Partial): void { + if ( + response.id == null || + !Number.isInteger(response.id) || + response.id < 0 + ) { + throw new ResponseFormatError("valid id not found in response", response); + } + if (!this.promisesAwaitingResponse.has(response.id)) { + return; + } + if (response.status == null) { + const error = new ResponseFormatError("Response has no status"); + this.reject(response.id, error); + } + if (response.status === "error") { + const error = new RippledError( + response.error_message ?? response.error, + response + ); + this.reject(response.id, error); + return; + } + if (response.status !== "success") { + const error = new ResponseFormatError( + `unrecognized response.status: ${response.status ?? ""}`, + response + ); + this.reject(response.id, error); + return; + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Must be a valid Response here + this.resolve(response.id, response as unknown as Response); + } + + /** + * Delete a promise after it has been returned. + * + * @param id - ID of the request. + */ + private deletePromise(id: string | number): void { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Needs to delete promise after request has been fulfilled. + delete this.promisesAwaitingResponse[id]; + } +} diff --git a/src/client/wsWrapper.ts b/src/client/wsWrapper.ts index 684ae151..b40417a2 100644 --- a/src/client/wsWrapper.ts +++ b/src/client/wsWrapper.ts @@ -1,15 +1,27 @@ +/* eslint-disable import/no-unused-modules -- This is used by webpack */ +/* eslint-disable max-classes-per-file -- Needs to be a wrapper for ws */ import { EventEmitter } from "events"; // Define the global WebSocket class found on the native browser declare class WebSocket { - onclose?: Function; - onopen?: Function; - onerror?: Function; - onmessage?: Function; - readyState: number; - constructor(url: string); - close(); - send(message: string); + public onclose?: () => void; + public onopen?: () => void; + public onerror?: (error: Error) => void; + public onmessage?: (message: MessageEvent) => void; + public readyState: number; + public constructor(url: string); + public close(code?: number): void; + public send(message: string): void; +} + +interface WSWrapperOptions { + perMessageDeflate: boolean; + handshakeTimeout: number; + protocolVersion: number; + origin: string; + maxPayload: number; + followRedirects: boolean; + maxRedirects: number; } /** @@ -17,46 +29,71 @@ declare class WebSocket { * same, as `ws` package provides. */ export default class WSWrapper extends EventEmitter { - private readonly _ws: WebSocket; - static CONNECTING = 0; - static OPEN = 1; - static CLOSING = 2; - static CLOSED = 3; + public static CONNECTING = 0; + public static OPEN = 1; + public static CLOSING = 2; + // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- magic number is being defined here + public static CLOSED = 3; + private readonly ws: WebSocket; - constructor(url, _protocols: any, _websocketOptions: any) { + /** + * Constructs a browser-safe websocket. + * + * @param url - URL to connect to. + * @param _protocols - Not used. + * @param _websocketOptions - Not used. + */ + public constructor( + url: string, + _protocols: string | string[] | WSWrapperOptions | undefined, + _websocketOptions: WSWrapperOptions + ) { super(); this.setMaxListeners(Infinity); - this._ws = new WebSocket(url); + this.ws = new WebSocket(url); - this._ws.onclose = () => { + this.ws.onclose = (): void => { this.emit("close"); }; - this._ws.onopen = () => { + this.ws.onopen = (): void => { this.emit("open"); }; - this._ws.onerror = (error) => { + this.ws.onerror = (error): void => { this.emit("error", error); }; - this._ws.onmessage = (message) => { + this.ws.onmessage = (message: MessageEvent): void => { this.emit("message", message.data); }; } - close() { + /** + * Closes the websocket. + */ + public close(): void { if (this.readyState === 1) { - this._ws.close(); + this.ws.close(); } } - send(message) { - this._ws.send(message); + /** + * Sends a message over the Websocket connection. + * + * @param message - Message to send. + */ + public send(message: string): void { + this.ws.send(message); } - get readyState() { - return this._ws.readyState; + /** + * Get the ready state of the websocket. + * + * @returns The Websocket's ready state. + */ + public get readyState(): number { + return this.ws.readyState; } } diff --git a/src/common/fee.ts b/src/common/fee.ts index 847e8fe7..a3b92072 100644 --- a/src/common/fee.ts +++ b/src/common/fee.ts @@ -1,13 +1,13 @@ import BigNumber from "bignumber.js"; import _ from "lodash"; -import { Client } from ".."; +import type { Client } from ".."; // This is a public API that can be called directly. // This is not used by the `prepare*` methods. See `src/transaction/utils.ts` async function getFee(this: Client, cushion?: number): Promise { if (cushion == null) { - cushion = this._feeCushion; + cushion = this.feeCushion; } if (cushion == null) { cushion = 1.2; @@ -29,8 +29,8 @@ async function getFee(this: Client, cushion?: number): Promise { } let fee = baseFeeXrp.times(serverInfo.load_factor).times(cushion); - // Cap fee to `this._maxFeeXRP` - fee = BigNumber.min(fee, this._maxFeeXRP); + // Cap fee to `this.maxFeeXRP` + fee = BigNumber.min(fee, this.maxFeeXRP); // Round fee to 6 decimal places return new BigNumber(fee.toFixed(6)).toString(10); } diff --git a/src/index.ts b/src/index.ts index fc8eb966..c34a60d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ +// Broadcast client is experimental +import BroadcastClient from "./client/broadcastClient"; + export { Client } from "./client"; export * from "./transaction/types"; @@ -8,7 +11,6 @@ export * from "./models/methods"; export * from "./utils"; -// Broadcast client is experimental -export { BroadcastClient } from "./client/broadcastClient"; +export { BroadcastClient }; export { default as Wallet } from "./wallet"; diff --git a/src/ledger/balances.ts b/src/ledger/balances.ts index 909c9852..b4c27670 100644 --- a/src/ledger/balances.ts +++ b/src/ledger/balances.ts @@ -1,5 +1,4 @@ -import { Client } from ".."; -import { Connection } from "../client"; +import type { Client } from ".."; import { ensureClassicAddress } from "../common"; import { FormattedTrustline } from "../common/types/objects/trustlines"; @@ -44,13 +43,13 @@ function formatBalances( } async function getLedgerVersionHelper( - connection: Connection, + client: Client, optionValue?: number ): Promise { if (optionValue != null && optionValue !== null) { return Promise.resolve(optionValue); } - return connection + return client .request({ command: "ledger", ledger_index: "validated", @@ -71,7 +70,7 @@ async function getBalances( address = ensureClassicAddress(address); return Promise.all([ - getLedgerVersionHelper(this.connection, options.ledgerVersion).then( + getLedgerVersionHelper(this, options.ledgerVersion).then( async (ledgerVersion) => utils.getXRPBalance(this, address, ledgerVersion) ), this.getTrustlines(address, options), diff --git a/src/ledger/pathfind.ts b/src/ledger/pathfind.ts index a41f2922..afbf36c9 100644 --- a/src/ledger/pathfind.ts +++ b/src/ledger/pathfind.ts @@ -2,7 +2,7 @@ import BigNumber from "bignumber.js"; import _ from "lodash"; import type { Client } from ".."; -import { Connection } from "../client"; +import type { Connection } from "../client"; import { errors } from "../common"; import { RippledAmount, Amount } from "../common/types/objects"; import { RipplePathFindRequest } from "../models/methods"; diff --git a/src/ledger/utils.ts b/src/ledger/utils.ts index 038dead9..fb660a1d 100644 --- a/src/ledger/utils.ts +++ b/src/ledger/utils.ts @@ -2,12 +2,13 @@ import * as assert from "assert"; import _ from "lodash"; -import { Client, dropsToXrp } from ".."; -import { Connection } from "../client"; +import type { Client } from ".."; +import type { Connection } from "../client"; import * as common from "../common"; import { Issue } from "../common/types/objects"; import { AccountInfoRequest } from "../models/methods"; import { FormattedTransactionType } from "../transaction/types"; +import { dropsToXrp } from "../utils"; export interface RecursiveData { marker: string; diff --git a/src/models/methods/baseMethod.ts b/src/models/methods/baseMethod.ts index f7ce06d9..6e937b24 100644 --- a/src/models/methods/baseMethod.ts +++ b/src/models/methods/baseMethod.ts @@ -1,4 +1,5 @@ export interface BaseRequest { + [x: string]: unknown; id?: number | string; command: string; api_version?: number; diff --git a/src/transaction/check-cancel.ts b/src/transaction/check-cancel.ts index 9591160f..bac05091 100644 --- a/src/transaction/check-cancel.ts +++ b/src/transaction/check-cancel.ts @@ -1,4 +1,4 @@ -import { Client } from ".."; +import type { Client } from ".."; import { Instructions, Prepare, TransactionJSON } from "./types"; import { prepareTransaction } from "./utils"; diff --git a/src/transaction/check-cash.ts b/src/transaction/check-cash.ts index 1016d424..4cc51c0d 100644 --- a/src/transaction/check-cash.ts +++ b/src/transaction/check-cash.ts @@ -1,4 +1,4 @@ -import { Client } from ".."; +import type { Client } from ".."; import { ValidationError } from "../common/errors"; import { Amount } from "../common/types/objects"; import { toRippledAmount } from "../utils"; diff --git a/src/transaction/check-create.ts b/src/transaction/check-create.ts index 069560b7..4bb777f6 100644 --- a/src/transaction/check-create.ts +++ b/src/transaction/check-create.ts @@ -1,4 +1,4 @@ -import { Client } from ".."; +import type { Client } from ".."; import { Amount } from "../common/types/objects"; import { ISOTimeToRippleTime, toRippledAmount } from "../utils"; diff --git a/src/transaction/escrow-cancellation.ts b/src/transaction/escrow-cancellation.ts index 5e7bf0dd..3ecae7e6 100644 --- a/src/transaction/escrow-cancellation.ts +++ b/src/transaction/escrow-cancellation.ts @@ -1,4 +1,4 @@ -import { Client } from ".."; +import type { Client } from ".."; import { Memo } from "../common/types/objects"; import { Instructions, Prepare, TransactionJSON } from "./types"; diff --git a/src/transaction/escrow-creation.ts b/src/transaction/escrow-creation.ts index 65a71de7..454050d4 100644 --- a/src/transaction/escrow-creation.ts +++ b/src/transaction/escrow-creation.ts @@ -1,4 +1,4 @@ -import { Client } from ".."; +import type { Client } from ".."; import { Memo } from "../common/types/objects"; import { ISOTimeToRippleTime, xrpToDrops } from "../utils"; diff --git a/src/transaction/escrow-execution.ts b/src/transaction/escrow-execution.ts index db71452e..e39a0bff 100644 --- a/src/transaction/escrow-execution.ts +++ b/src/transaction/escrow-execution.ts @@ -1,4 +1,4 @@ -import { Client } from ".."; +import type { Client } from ".."; import { Memo } from "../common/types/objects"; import { Instructions, Prepare, TransactionJSON } from "./types"; diff --git a/src/transaction/order.ts b/src/transaction/order.ts index c6c155b3..a8abac39 100644 --- a/src/transaction/order.ts +++ b/src/transaction/order.ts @@ -1,4 +1,4 @@ -import { Client } from ".."; +import type { Client } from ".."; import { FormattedOrderSpecification } from "../common/types/objects/index"; import { ISOTimeToRippleTime, toRippledAmount } from "../utils"; diff --git a/src/transaction/ordercancellation.ts b/src/transaction/ordercancellation.ts index 2dce53bf..eda78c20 100644 --- a/src/transaction/ordercancellation.ts +++ b/src/transaction/ordercancellation.ts @@ -1,4 +1,4 @@ -import { Client } from ".."; +import type { Client } from ".."; import { Instructions, Prepare, TransactionJSON } from "./types"; import * as utils from "./utils"; diff --git a/src/transaction/payment-channel-claim.ts b/src/transaction/payment-channel-claim.ts index 3acfb434..420c5d7a 100644 --- a/src/transaction/payment-channel-claim.ts +++ b/src/transaction/payment-channel-claim.ts @@ -1,4 +1,4 @@ -import { Client } from ".."; +import type { Client } from ".."; import { xrpToDrops } from "../utils"; import { Instructions, Prepare, TransactionJSON } from "./types"; diff --git a/src/transaction/payment-channel-create.ts b/src/transaction/payment-channel-create.ts index fc384fef..d261a39f 100644 --- a/src/transaction/payment-channel-create.ts +++ b/src/transaction/payment-channel-create.ts @@ -1,4 +1,4 @@ -import { Client } from ".."; +import type { Client } from ".."; import { ISOTimeToRippleTime, xrpToDrops } from "../utils"; import { Instructions, Prepare, TransactionJSON } from "./types"; diff --git a/src/transaction/payment-channel-fund.ts b/src/transaction/payment-channel-fund.ts index f2798ef0..d3370e44 100644 --- a/src/transaction/payment-channel-fund.ts +++ b/src/transaction/payment-channel-fund.ts @@ -1,4 +1,4 @@ -import { Client } from ".."; +import type { Client } from ".."; import { ISOTimeToRippleTime, xrpToDrops } from "../utils"; import { Instructions, Prepare, TransactionJSON } from "./types"; diff --git a/src/transaction/settings.ts b/src/transaction/settings.ts index bec7aecd..332232f6 100644 --- a/src/transaction/settings.ts +++ b/src/transaction/settings.ts @@ -2,7 +2,7 @@ import * as assert from "assert"; import BigNumber from "bignumber.js"; -import { Client } from ".."; +import type { Client } from ".."; import { FormattedSettings, WeightedSigner } from "../common/types/objects"; import { diff --git a/src/transaction/sign.ts b/src/transaction/sign.ts index 245a01bc..21a77eb9 100644 --- a/src/transaction/sign.ts +++ b/src/transaction/sign.ts @@ -3,7 +3,7 @@ import _ from "lodash"; import binaryCodec from "ripple-binary-codec"; import keypairs from "ripple-keypairs"; -import { Client, Wallet } from ".."; +import type { Client, Wallet } from ".."; import { SignedTransaction } from "../common/types/objects"; import { xrpToDrops } from "../utils"; import { computeBinaryTransactionHash } from "../utils/hashes"; @@ -206,7 +206,7 @@ function checkTxSerialization(serialized: string, tx: TransactionJSON): void { */ function checkFee(client: Client, txFee: string): void { const fee = new BigNumber(txFee); - const maxFeeDrops = xrpToDrops(client._maxFeeXRP); + const maxFeeDrops = xrpToDrops(client.maxFeeXRP); if (fee.isGreaterThan(maxFeeDrops)) { throw new utils.common.errors.ValidationError( `"Fee" should not exceed "${maxFeeDrops}". ` + diff --git a/src/transaction/trustline.ts b/src/transaction/trustline.ts index 0ebb8bd1..b99fd1b7 100644 --- a/src/transaction/trustline.ts +++ b/src/transaction/trustline.ts @@ -1,6 +1,6 @@ import BigNumber from "bignumber.js"; -import { Client } from ".."; +import type { Client } from ".."; import { FormattedTrustlineSpecification } from "../common/types/objects/trustlines"; import { Instructions, Prepare, TransactionJSON } from "./types"; diff --git a/src/transaction/utils.ts b/src/transaction/utils.ts index 7c1c0fb8..02cb1dce 100644 --- a/src/transaction/utils.ts +++ b/src/transaction/utils.ts @@ -4,7 +4,7 @@ import { isValidXAddress, } from "ripple-address-codec"; -import { Client } from ".."; +import type { Client } from ".."; import * as common from "../common"; import { ValidationError } from "../common/errors"; import { Memo } from "../common/types/objects"; @@ -31,6 +31,8 @@ export interface ApiMemo { MemoFormat?: string; } +// TODO: move relevant methods from here to `src/utils` (such as `convertStringToHex`?) + function formatPrepareResponse(txJSON: any): Prepare { const instructions: any = { fee: dropsToXrp(txJSON.Fee), @@ -307,11 +309,11 @@ async function prepareTransaction( instructions.signersCount == null ? 1 : instructions.signersCount + 1; if (instructions.fee != null) { const fee = new BigNumber(instructions.fee); - if (fee.isGreaterThan(client._maxFeeXRP)) { + if (fee.isGreaterThan(client.maxFeeXRP)) { return Promise.reject( new ValidationError( `Fee of ${fee.toString(10)} XRP exceeds ` + - `max of ${client._maxFeeXRP} XRP. To use this fee, increase ` + + `max of ${client.maxFeeXRP} XRP. To use this fee, increase ` + "`maxFeeXRP` in the Client constructor." ) ); @@ -319,7 +321,7 @@ async function prepareTransaction( newTxJSON.Fee = scaleValue(xrpToDrops(instructions.fee), multiplier); return Promise.resolve(); } - const cushion = client._feeCushion; + const cushion = client.feeCushion; return client.getFee(cushion).then(async (fee) => { return client .request({ command: "fee" }) @@ -338,8 +340,8 @@ async function prepareTransaction( )); const feeDrops = xrpToDrops(fee); const maxFeeXRP = instructions.maxFee - ? BigNumber.min(client._maxFeeXRP, instructions.maxFee) - : client._maxFeeXRP; + ? BigNumber.min(client.maxFeeXRP, instructions.maxFee) + : client.maxFeeXRP; const maxFeeDrops = xrpToDrops(maxFeeXRP); const normalFee = scaleValue(feeDrops, multiplier, extraFee); newTxJSON.Fee = BigNumber.min(normalFee, maxFeeDrops).toString(10); diff --git a/src/wallet/generateFaucetWallet.ts b/src/wallet/generateFaucetWallet.ts index 4ed86b85..d90fda29 100644 --- a/src/wallet/generateFaucetWallet.ts +++ b/src/wallet/generateFaucetWallet.ts @@ -2,11 +2,13 @@ import https = require("https"); import { isValidClassicAddress } from "ripple-address-codec"; -import { Client, Wallet } from ".."; +import type { Client } from ".."; import { errors } from "../common"; import { RippledError } from "../common/errors"; import { GeneratedAddress } from "../utils/generateAddress"; +import Wallet from "."; + export interface FaucetWallet { account: GeneratedAddress; amount: number; @@ -21,14 +23,14 @@ export enum FaucetNetwork { const INTERVAL_SECONDS = 1; // Interval to check an account balance const MAX_ATTEMPTS = 20; // Maximum attempts to retrieve a balance -/** - * Generates a random wallet with some amount of XRP (usually 1000 XRP). - * - * @param client - Client. - * @param wallet - An existing XRPL Wallet to fund, if undefined, a new Wallet will be created. - * @returns A Wallet on the Testnet or Devnet that contains some amount of XRP. - * @throws When either Client isn't connected or unable to fund wallet address. - */ +// +// Generates a random wallet with some amount of XRP (usually 1000 XRP). +// +// @param client - Client. +// @param wallet - An existing XRPL Wallet to fund, if undefined, a new Wallet will be created. +// @returns A Wallet on the Testnet or Devnet that contains some amount of XRP. +// @throws When either Client isn't connected or unable to fund wallet address. +// z async function generateFaucetWallet( client: Client, wallet?: Wallet diff --git a/test/backoff.ts b/test/backoff.ts index ab0b4801..9d0d03c1 100644 --- a/test/backoff.ts +++ b/test/backoff.ts @@ -1,6 +1,6 @@ import { assert } from "chai"; -import { ExponentialBackoff } from "../src/client/backoff"; +import ExponentialBackoff from "../src/client/backoff"; describe("ExponentialBackoff", function () { it("duration() return value starts with the min value", function () { diff --git a/test/broadcastClient.ts b/test/broadcastClient.ts index 4cbac87b..2c3417e0 100644 --- a/test/broadcastClient.ts +++ b/test/broadcastClient.ts @@ -42,7 +42,7 @@ describe("BroadcastClient", function () { assert.strictEqual(info, "info"); done(); }); - this.client._clients[1].connection + this.client.clients[1].connection .request({ command: "echo", data, diff --git a/test/client.ts b/test/client.ts index 368eb962..13a00d72 100644 --- a/test/client.ts +++ b/test/client.ts @@ -33,7 +33,7 @@ describe("Client", function () { it("Client valid options", function () { const client = new Client("wss://s:1"); - const privateConnectionUrl = (client.connection as any)._url; + const privateConnectionUrl = (client.connection as any).url; assert.deepEqual(privateConnectionUrl, "wss://s:1"); }); diff --git a/test/client/constructor.ts b/test/client/constructor.ts index a5c171d4..6ccf1af0 100644 --- a/test/client/constructor.ts +++ b/test/client/constructor.ts @@ -14,7 +14,7 @@ describe("client constructor", function () { it("Client valid options", function () { const client = new Client("wss://s:1"); - const privateConnectionUrl = (client.connection as any)._url; + const privateConnectionUrl = (client.connection as any).url; assert.deepEqual(privateConnectionUrl, "wss://s:1"); }); diff --git a/test/client/getFee.ts b/test/client/getFee.ts index 1c2c8ee9..11f009de 100644 --- a/test/client/getFee.ts +++ b/test/client/getFee.ts @@ -18,7 +18,7 @@ describe("client.getFee", function () { it("getFee default", async function () { this.mockRippled.addResponse("server_info", rippled.server_info.normal); - this.client._feeCushion = undefined as unknown as number; + this.client.feeCushion = undefined as unknown as number; const fee = await this.client.getFee(); assert.strictEqual(fee, "0.000012"); }); @@ -39,14 +39,14 @@ describe("client.getFee", function () { ); // Ensure that overriding with high maxFeeXRP of '51540' causes no errors. // (fee will actually be 51539.607552) - this.client._maxFeeXRP = "51540"; + this.client.maxFeeXRP = "51540"; const fee = await this.client.getFee(); assert.strictEqual(fee, "51539.607552"); }); it("getFee custom cushion", async function () { this.mockRippled.addResponse("server_info", rippled.server_info.normal); - this.client._feeCushion = 1.4; + this.client.feeCushion = 1.4; const fee = await this.client.getFee(); assert.strictEqual(fee, "0.000014"); }); @@ -55,7 +55,7 @@ describe("client.getFee", function () { // less than the base fee. However, this test verifies the existing behavior. it("getFee cushion less than 1.0", async function () { this.mockRippled.addResponse("server_info", rippled.server_info.normal); - this.client._feeCushion = 0.9; + this.client.feeCushion = 0.9; const fee = await this.client.getFee(); assert.strictEqual(fee, "0.000009"); }); diff --git a/test/client/hasNextPage.ts b/test/client/hasNextPage.ts index d3d597bd..1eda18a0 100644 --- a/test/client/hasNextPage.ts +++ b/test/client/hasNextPage.ts @@ -1,5 +1,6 @@ import { assert } from "chai"; +import { Client } from "../../src"; import rippled from "../fixtures/rippled"; import setupClient from "../setupClient"; @@ -10,7 +11,7 @@ describe("client.hasNextPage", function () { it("returns true when there is another page", async function () { this.mockRippled.addResponse("ledger_data", rippled.ledger_data.first_page); const response = await this.client.request({ command: "ledger_data" }); - assert(this.client.hasNextPage(response)); + assert(Client.hasNextPage(response)); }); it("returns false when there are no more pages", async function () { @@ -26,6 +27,6 @@ describe("client.hasNextPage", function () { { command: "ledger_data" }, response ); - assert(!this.client.hasNextPage(responseNextPage)); + assert(!Client.hasNextPage(responseNextPage)); }); }); diff --git a/test/client/preparePayment.ts b/test/client/preparePayment.ts index c5f2aca8..0bd85e89 100644 --- a/test/client/preparePayment.ts +++ b/test/client/preparePayment.ts @@ -460,7 +460,7 @@ describe("client.preparePayment", function () { "account_info", rippled.account_info.normal ); - this.client._feeCushion = 1000000; + this.client.feeCushion = 1000000; const expectedResponse = { txJSON: '{"Flags":2147483648,"TransactionType":"Payment","Account":"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59","Destination":"rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo","Amount":{"value":"0.01","currency":"USD","issuer":"rMH4UxPrbuMa1spCBR98hLLyNJp4d8p4tM"},"SendMax":{"value":"0.01","currency":"USD","issuer":"rMH4UxPrbuMa1spCBR98hLLyNJp4d8p4tM"},"LastLedgerSequence":8820051,"Fee":"2000000","Sequence":23}', @@ -486,7 +486,7 @@ describe("client.preparePayment", function () { "account_info", rippled.account_info.normal ); - this.client._maxFeeXRP = "2.2"; + this.client.maxFeeXRP = "2.2"; const localInstructions = { ...instructionsWithMaxLedgerVersionOffset, fee: "2.1", @@ -516,7 +516,7 @@ describe("client.preparePayment", function () { "account_info", rippled.account_info.normal ); - this.client._feeCushion = 1000000; + this.client.feeCushion = 1000000; const expectedResponse = { txJSON: '{"Flags":2147483648,"TransactionType":"Payment","Account":"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59","Destination":"rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo","Amount":{"value":"0.01","currency":"USD","issuer":"rMH4UxPrbuMa1spCBR98hLLyNJp4d8p4tM"},"SendMax":{"value":"0.01","currency":"USD","issuer":"rMH4UxPrbuMa1spCBR98hLLyNJp4d8p4tM"},"LastLedgerSequence":8820051,"Fee":"2000000","Sequence":23}', @@ -542,8 +542,8 @@ describe("client.preparePayment", function () { "account_info", rippled.account_info.normal ); - this.client._feeCushion = 1000000; - this.client._maxFeeXRP = "3"; + this.client.feeCushion = 1000000; + this.client.maxFeeXRP = "3"; const localInstructions = { ...instructionsWithMaxLedgerVersionOffset, maxFee: "4", @@ -573,8 +573,8 @@ describe("client.preparePayment", function () { "account_info", rippled.account_info.normal ); - this.client._feeCushion = 1000000; - this.client._maxFeeXRP = "5"; + this.client.feeCushion = 1000000; + this.client.maxFeeXRP = "5"; const localInstructions = { ...instructionsWithMaxLedgerVersionOffset, maxFee: "4", diff --git a/test/client/prepareTransaction.ts b/test/client/prepareTransaction.ts index 58647cbe..72e02863 100644 --- a/test/client/prepareTransaction.ts +++ b/test/client/prepareTransaction.ts @@ -1005,7 +1005,7 @@ describe("client.prepareTransaction", function () { this.mockRippled.addResponse("fee", rippled.fee); this.mockRippled.addResponse("ledger_current", rippled.ledger_current); this.mockRippled.addResponse("account_info", rippled.account_info.normal); - this.client._feeCushion = 1000000; + this.client.feeCushion = 1000000; const txJSON = { Flags: 2147483648, @@ -1046,7 +1046,7 @@ describe("client.prepareTransaction", function () { this.mockRippled.addResponse("fee", rippled.fee); this.mockRippled.addResponse("ledger_current", rippled.ledger_current); this.mockRippled.addResponse("account_info", rippled.account_info.normal); - this.client._feeCushion = 1000000; + this.client.feeCushion = 1000000; const txJSON = { Flags: 2147483648, @@ -1092,8 +1092,8 @@ describe("client.prepareTransaction", function () { this.mockRippled.addResponse("fee", rippled.fee); this.mockRippled.addResponse("ledger_current", rippled.ledger_current); this.mockRippled.addResponse("account_info", rippled.account_info.normal); - this.client._feeCushion = 1000000; - this.client._maxFeeXRP = "3"; + this.client.feeCushion = 1000000; + this.client.maxFeeXRP = "3"; const localInstructions = { maxFee: "4", // We are testing that this does not matter; fee is still capped to maxFeeXRP }; @@ -1139,8 +1139,8 @@ describe("client.prepareTransaction", function () { this.mockRippled.addResponse("fee", rippled.fee); this.mockRippled.addResponse("ledger_current", rippled.ledger_current); this.mockRippled.addResponse("account_info", rippled.account_info.normal); - this.client._feeCushion = 1000000; - this.client._maxFeeXRP = "5"; + this.client.feeCushion = 1000000; + this.client.maxFeeXRP = "5"; const localInstructions = { maxFee: "4", // maxFeeXRP does not matter if maxFee is lower than maxFeeXRP }; diff --git a/test/client/requestNextPage.ts b/test/client/requestNextPage.ts index e995a7df..8128b9f6 100644 --- a/test/client/requestNextPage.ts +++ b/test/client/requestNextPage.ts @@ -1,5 +1,6 @@ import { assert } from "chai"; +import { Client } from "../../src"; import rippled from "../fixtures/rippled"; import setupClient from "../setupClient"; import { assertRejects } from "../testUtils"; @@ -34,7 +35,7 @@ describe("client.requestNextPage", function () { { command: "ledger_data" }, response ); - assert(!this.client.hasNextPage(responseNextPage)); + assert(!Client.hasNextPage(responseNextPage)); await assertRejects( this.client.requestNextPage({ command: "ledger_data" }, responseNextPage), Error, diff --git a/test/client/sign.ts b/test/client/sign.ts index c5372637..07e6e84c 100644 --- a/test/client/sign.ts +++ b/test/client/sign.ts @@ -199,7 +199,7 @@ describe("client.sign", function () { }); it("permits fee exceeding 2000000 drops when maxFeeXRP is higher than 2 XRP", async function () { - this.client._maxFeeXRP = "2.1"; + this.client.maxFeeXRP = "2.1"; const secret = "shsWGZcmZz6YsWWmcnpfr6fLTdtFV"; const request = { // TODO: This fails when address is X-address @@ -247,7 +247,7 @@ describe("client.sign", function () { }); it("throws when Fee exceeds maxFeeXRP (in drops) - custom maxFeeXRP", async function () { - this.client._maxFeeXRP = "1.9"; + this.client.maxFeeXRP = "1.9"; const secret = "shsWGZcmZz6YsWWmcnpfr6fLTdtFV"; const request = { txJSON: `{"Flags":2147483648,"TransactionType":"AccountSet","Account":"${test.address}","Domain":"6578616D706C652E636F6D","LastLedgerSequence":8820051,"Fee":"2010000","Sequence":23,"SigningPubKey":"02F89EAEC7667B30F33D0687BBA86C3FE2A08CCA40A9186C5BDE2DAA6FA97A37D8"}`, diff --git a/test/connection.ts b/test/connection.ts index fa1d4dc2..70b4d81e 100644 --- a/test/connection.ts +++ b/test/connection.ts @@ -42,9 +42,9 @@ describe("Connection", function () { it("default options", function () { const connection: any = new Connection("url"); - assert.strictEqual(connection._url, "url"); - assert(connection._config.proxy == null); - assert(connection._config.authorization == null); + assert.strictEqual(connection.url, "url"); + assert(connection.config.proxy == null); + assert(connection.config.authorization == null); }); describe("trace", function () { @@ -65,9 +65,9 @@ describe("Connection", function () { const messages: any[] = []; console.log = (id, message) => messages.push([id, message]); const connection: any = new Connection("url", { trace: false }); - connection._ws = { send() {} }; + connection.ws = { send() {} }; connection.request(mockedRequestData); - connection._onMessage(mockedResponse); + connection.onMessage(mockedResponse); assert.deepEqual(messages, []); }); @@ -75,9 +75,9 @@ describe("Connection", function () { const messages: any[] = []; console.log = (id, message) => messages.push([id, message]); const connection: any = new Connection("url", { trace: true }); - connection._ws = { send() {} }; + connection.ws = { send() {} }; connection.request(mockedRequestData); - connection._onMessage(mockedResponse); + connection.onMessage(mockedResponse); assert.deepEqual(messages, expectedMessages); }); @@ -86,9 +86,9 @@ describe("Connection", function () { const connection: any = new Connection("url", { trace: (id, message) => messages.push([id, message]), }); - connection._ws = { send() {} }; + connection.ws = { send() {} }; connection.request(mockedRequestData); - connection._onMessage(mockedResponse); + connection.onMessage(mockedResponse); assert.deepEqual(messages, expectedMessages); }); }); @@ -116,7 +116,7 @@ describe("Connection", function () { authorization: "authorization", trustedCertificates: ["path/to/pem"], }; - const connection = new Connection(this.client.connection._url, options); + const connection = new Connection(this.client.connection.url, options); connection.connect().catch((err) => { assert(err instanceof NotConnectedError); }); @@ -177,7 +177,7 @@ describe("Connection", function () { }); it("TimeoutError", function () { - this.client.connection._ws.send = function (_, callback) { + this.client.connection.ws.send = function (_, callback) { callback(null); }; const request = { command: "server_info" }; @@ -192,7 +192,7 @@ describe("Connection", function () { }); it("DisconnectedError on send", function () { - this.client.connection._ws.send = function (_, callback) { + this.client.connection.ws.send = function (_, callback) { callback({ message: "not connected" }); }; return this.client @@ -206,15 +206,15 @@ describe("Connection", function () { }); }); - it("DisconnectedError on initial _onOpen send", async function () { + it("DisconnectedError on initial onOpen send", async function () { // _onOpen previously could throw PromiseRejectionHandledWarning: Promise rejection was handled asynchronously // do not rely on the client.setup hook to test this as it bypasses the case, disconnect client connection first await this.client.disconnect(); // stub _onOpen to only run logic relevant to test case - this.client.connection._onOpen = () => { + this.client.connection.onOpen = () => { // overload websocket send on open when _ws exists - this.client.connection._ws.send = function (_0, _1, _2) { + this.client.connection.ws.send = function (_0, _1, _2) { // recent ws throws this error instead of calling back throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); }; @@ -225,7 +225,8 @@ describe("Connection", function () { try { await this.client.connect(); } catch (error) { - assert(error instanceof DisconnectedError); + console.log(error); + assert.instanceOf(error, DisconnectedError); assert.strictEqual( error.message, "WebSocket is not open: readyState 0 (CONNECTING)" @@ -252,7 +253,7 @@ describe("Connection", function () { done(); }); setTimeout(() => { - this.client.connection._ws.close(); + this.client.connection.ws.close(); }, 1); }); @@ -329,13 +330,13 @@ describe("Connection", function () { } } // Set the heartbeat to less than the 1 second ping response - this.client.connection._config.timeout = 500; + this.client.connection.config.timeout = 500; // Drop the test runner timeout, since this should be a quick test this.timeout(5000); // Hook up a listener for the reconnect event this.client.connection.on("reconnect", () => done()); // Trigger a heartbeat - this.client.connection._heartbeat().catch((error) => { + this.client.connection.heartbeat().catch((error) => { /* ignore - test expects heartbeat failure */ }); }); @@ -349,7 +350,7 @@ describe("Connection", function () { } } // Set the heartbeat to less than the 1 second ping response - this.client.connection._config.timeout = 500; + this.client.connection.config.timeout = 500; // Drop the test runner timeout, since this should be a quick test this.timeout(5000); // fail on reconnect/connection @@ -364,7 +365,7 @@ describe("Connection", function () { return done(new Error("Expected error on reconnect")); }); // Trigger a heartbeat - this.client.connection._heartbeat(); + this.client.connection.heartbeat(); }); it("should emit disconnected event with code 1000 (CLOSE_NORMAL)", function (done) { @@ -393,7 +394,7 @@ describe("Connection", function () { it("should emit connected event on after reconnect", function (done) { this.client.once("connected", done); - this.client.connection._ws.close(); + this.client.connection.ws.close(); }); it("Multiply connect calls", function () { @@ -450,17 +451,17 @@ describe("Connection", function () { done(); }); - this.client.connection._onMessage( + this.client.connection.onMessage( JSON.stringify({ type: "transaction", }) ); - this.client.connection._onMessage( + this.client.connection.onMessage( JSON.stringify({ type: "path_find", }) ); - this.client.connection._onMessage( + this.client.connection.onMessage( JSON.stringify({ type: "response", id: 1, @@ -475,7 +476,7 @@ describe("Connection", function () { assert.strictEqual(message, '{"type":"response","id":"must be integer"}'); done(); }); - this.client.connection._onMessage( + this.client.connection.onMessage( JSON.stringify({ type: "response", id: "must be integer", @@ -490,7 +491,7 @@ describe("Connection", function () { assert.deepEqual(data, { error: "slowDown", error_message: "slow down" }); done(); }); - this.client.connection._onMessage( + this.client.connection.onMessage( JSON.stringify({ error: "slowDown", error_message: "slow down", @@ -525,13 +526,13 @@ describe("Connection", function () { done(); }); - this.client.connection._onMessage(JSON.stringify({ type: "unknown" })); + this.client.connection.onMessage(JSON.stringify({ type: "unknown" })); }); // it('should clean up websocket connection if error after websocket is opened', async function () { // await this.client.disconnect() // // fail on connection - // this.client.connection._subscribeToLedger = async () => { + // this.client.connection.subscribeToLedger = async () => { // throw new Error('error on _subscribeToLedger') // } // try { @@ -541,7 +542,7 @@ describe("Connection", function () { // assert(err.message === 'error on _subscribeToLedger') // // _ws.close event listener should have cleaned up the socket when disconnect _ws.close is run on connection error // // do not fail on connection anymore - // this.client.connection._subscribeToLedger = async () => {} + // this.client.connection.subscribeToLedger = async () => {} // await this.client.connection.reconnect() // } // }) diff --git a/test/localRunner.html b/test/localRunner.html index b188111a..ed17852f 100644 --- a/test/localRunner.html +++ b/test/localRunner.html @@ -17,14 +17,12 @@ } mocha.ui('bdd') - + - - - + - - - +