UNPKG

xrpl

Version:

A TypeScript/JavaScript API for interacting with the XRP Ledger in Node.js and the browser

539 lines (499 loc) 17.5 kB
/* eslint-disable max-lines -- Connection is a large file w/ lots of imports/exports */ import type { Agent } from 'http' import { bytesToHex, hexToString } from '@xrplf/isomorphic/utils' import WebSocket, { ClientOptions } from '@xrplf/isomorphic/ws' import { EventEmitter } from 'eventemitter3' import { DisconnectedError, NotConnectedError, ConnectionError, XrplError, } from '../errors' import type { APIVersion, RequestResponseMap } from '../models' import { BaseRequest } from '../models/methods/baseMethod' import ConnectionManager from './ConnectionManager' import ExponentialBackoff from './ExponentialBackoff' import RequestManager from './RequestManager' const SECONDS_PER_MINUTE = 60 const TIMEOUT = 20 const CONNECTION_TIMEOUT = 5 /** * ConnectionOptions is the configuration for the Connection class. */ interface ConnectionOptions { trace?: boolean | ((id: string, message: string) => void) headers?: { [key: string]: string } agent?: Agent authorization?: string connectionTimeout: number timeout: number } /** * ConnectionUserOptions is the user-provided configuration object. All configuration * is optional, so any ConnectionOptions configuration that has a default value is * still optional at the point that the user provides it. */ export type ConnectionUserOptions = Partial<ConnectionOptions> /** * Represents an intentionally triggered web-socket disconnect code. * WebSocket spec allows 4xxx codes for app/library specific codes. * See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent */ export const INTENTIONAL_DISCONNECT_CODE = 4000 type WebsocketState = 0 | 1 | 2 | 3 /** * 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: ClientOptions = { agent: config.agent, } if (config.headers) { options.headers = config.headers } if (config.authorization != null) { options.headers = { ...options.headers, Authorization: `Basic ${btoa(config.authorization)}`, } } const websocketOptions = { ...options } return new WebSocket(url, websocketOptions) } /** * Ws.send(), but promisified. * * @param ws - Websocket to send with. * @param message - Message to send. * @returns When the message has been sent. */ async function websocketSendAsync( ws: WebSocket, message: string, ): Promise<void> { return new Promise<void>((resolve, reject) => { ws.send(message, (error) => { if (error) { reject(new DisconnectedError(error.message, error)) } else { resolve() } }) }) } /** * The main Connection class. Responsible for connecting to & managing * an active WebSocket connection to a XRPL node. */ export class Connection extends EventEmitter { private readonly url: string | undefined private ws: WebSocket | null = null // Typing necessary for Jest tests running in browser private reconnectTimeoutID: null | ReturnType<typeof setTimeout> = null // Typing necessary for Jest tests running in browser private heartbeatIntervalID: null | ReturnType<typeof setTimeout> = null private readonly retryConnectionBackoff = new ExponentialBackoff({ min: 100, max: SECONDS_PER_MINUTE * 1000, }) private readonly config: ConnectionOptions private readonly requestManager = new RequestManager() private readonly connectionManager = new ConnectionManager() /** * 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.url = url this.config = { timeout: TIMEOUT * 1000, connectionTimeout: CONNECTION_TIMEOUT * 1000, ...options, } if (typeof options.trace === 'function') { this.trace = options.trace } else if (options.trace) { // eslint-disable-next-line no-console -- Used for tracing only this.trace = console.log } } /** * 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 } /** * Returns whether the websocket is connected. * * @returns Whether the websocket connection is open. */ public isConnected(): boolean { return this.state === WebSocket.OPEN } /** * 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. */ // eslint-disable-next-line max-lines-per-function -- Necessary public async connect(): Promise<void> { if (this.isConnected()) { return Promise.resolve() } if (this.state === WebSocket.CONNECTING) { return this.connectionManager.awaitConnection() } if (!this.url) { return Promise.reject( new ConnectionError('Cannot connect because no server was specified'), ) } if (this.ws != null) { return Promise.reject( new XrplError('Websocket connection never cleaned up.', { state: this.state, }), ) } // Create the connection timeout, in case the connection hangs longer than expected. const connectionTimeoutID: ReturnType<typeof setTimeout> = setTimeout( () => { this.onConnectionFailed( new ConnectionError( `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, ) // Connection listeners: these stay attached only until a connection is done/open. this.ws = createWebSocket(this.url, this.config) if (this.ws == null) { throw new XrplError('Connect: created null websocket') } 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)) this.ws.once('open', () => { void this.onceOpen(connectionTimeoutID) }) return this.connectionManager.awaitConnection() } /** * Disconnect the websocket connection. * We never expect this method to reject. Even on "bad" disconnects, the websocket * 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. */ public async disconnect(): Promise<number | undefined> { this.clearHeartbeatInterval() if (this.reconnectTimeoutID !== null) { clearTimeout(this.reconnectTimeoutID) this.reconnectTimeoutID = null } if (this.state === WebSocket.CLOSED) { return Promise.resolve(undefined) } if (this.ws == null) { return Promise.resolve(undefined) } return new Promise((resolve) => { if (this.ws == null) { resolve(undefined) } if (this.ws != null) { 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) } }) } /** * Disconnect the websocket, then connect again. * */ public async reconnect(): Promise<void> { /* * NOTE: We currently have a "reconnecting" event, but that only triggers * through an unexpected connection retry logic. * See: https://github.com/XRPLF/xrpl.js/pull/1101#issuecomment-565360423 */ this.emit('reconnect') await this.disconnect() await this.connect() } /** * 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< R extends BaseRequest, T = RequestResponseMap<R, APIVersion>, >(request: R, timeout?: number): Promise<T> { if (!this.shouldBeConnected || this.ws == null) { throw new NotConnectedError(JSON.stringify(request), request) } const [id, message, responsePromise] = this.requestManager.createRequest< R, T >(request, timeout ?? this.config.timeout) this.trace('send', message) websocketSendAsync(this.ws, message).catch((error) => { try { this.requestManager.reject(id, error) } catch (err) { if (err instanceof XrplError) { this.trace( 'send', `send errored after connection was closed: ${err.toString()}`, ) } else { this.trace('send', String(err)) } } }) return responsePromise } /** * Get the Websocket connection URL. * * @returns The Websocket connection URL. */ public getUrl(): string { return this.url ?? '' } // eslint-disable-next-line @typescript-eslint/no-empty-function, class-methods-use-this -- Does nothing on default public 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<string, unknown> try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Must be a JSON dictionary data = JSON.parse(message) } catch (error) { if (error instanceof Error) { 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) { if (error instanceof Error) { this.emit('error', 'badMessage', error.message, message) } else { this.emit('error', 'badMessage', error, error) } } } } /** * 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. */ // eslint-disable-next-line max-lines-per-function -- Many error code conditionals to check. private async onceOpen( connectionTimeoutID: ReturnType<typeof setTimeout>, ): Promise<void> { if (this.ws == null) { throw new XrplError('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?: number, reason?: Uint8Array) => { if (this.ws == null) { throw new XrplError('onceClose: ws is null') } this.clearHeartbeatInterval() this.requestManager.rejectAll( new DisconnectedError( `websocket was closed, ${ reason ? hexToString(bytesToHex(reason)) : '' }`, ), ) this.ws.removeAllListeners() this.ws = null if (code === undefined) { // Useful to keep this code for debugging purposes. // const reasonText = reason ? reason.toString() : 'undefined' // // eslint-disable-next-line no-console -- The error is helpful for debugging. // console.error( // `Disconnected but the disconnect code was undefined (The given reason was ${reasonText}).` + // `This could be caused by an exception being thrown during a 'connect' callback. ` + // `Disconnecting with code 1011 to indicate an internal error has occurred.`, // ) /* * Error code 1011 represents an Internal Error according to * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code */ const internalErrorCode = 1011 this.emit('disconnected', internalErrorCode) } else { this.emit('disconnected', code) } /* * If this wasn't a manual disconnect, then lets reconnect ASAP. * Code can be undefined if there's an exception while connecting. */ if (code !== INTENTIONAL_DISCONNECT_CODE && code !== undefined) { 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) { if (error instanceof 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(() => { void 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<void> { 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.'), ) } } }