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
text/typescript
/* 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.'),
)
}
}
}