UNPKG

@electrum-cash/network

Version:

@electrum-cash/network is a lightweight JavaScript library that lets you connect with one or more Electrum servers.

698 lines (690 loc) 26.3 kB
import debug from "@electrum-cash/debug-logs"; import { ElectrumWebSocket } from "@electrum-cash/web-socket"; import { EventEmitter } from "eventemitter3"; import { parse, parseNumberAndBigInt } from "lossless-json"; import { Mutex } from "async-mutex"; //#region source/electrum-protocol.ts /** * Grouping of utilities that simplifies implementation of the Electrum protocol. * * @ignore */ var ElectrumProtocol = class { /** * Helper function that builds an Electrum request object. * * @param method - method to call. * @param parameters - method parameters for the call. * @param requestId - unique string or number referencing this request. * * @returns a properly formatted Electrum request string. */ static buildRequestObject(method, parameters, requestId) { return JSON.stringify({ method, params: parameters, id: requestId }); } /** * Constant used to verify if a provided string is a valid version number. * * @returns a regular expression that matches valid version numbers. */ static get versionRegexp() { return /^\d+(\.\d+)+$/; } /** * Constant used to separate statements/messages in a stream of data. * * @returns the delimiter used by Electrum to separate statements. */ static get statementDelimiter() { return "\n"; } }; //#endregion //#region source/rpc-interfaces.ts const isRPCErrorResponse = function(message) { return "id" in message && "error" in message; }; const isRPCStatement = function(message) { return "id" in message && "result" in message; }; const isRPCNotification = function(message) { return !("id" in message) && "method" in message; }; const isRPCRequest = function(message) { return "id" in message && "method" in message; }; //#endregion //#region source/enums.ts /** * Enum that denotes the connection status of an ElectrumConnection. * @enum {number} * @property {0} DISCONNECTED The connection is disconnected. * @property {1} AVAILABLE The connection is connected. * @property {2} DISCONNECTING The connection is disconnecting. * @property {3} CONNECTING The connection is connecting. * @property {4} RECONNECTING The connection is restarting. */ let ConnectionStatus = /* @__PURE__ */ function(ConnectionStatus$1) { ConnectionStatus$1[ConnectionStatus$1["DISCONNECTED"] = 0] = "DISCONNECTED"; ConnectionStatus$1[ConnectionStatus$1["CONNECTED"] = 1] = "CONNECTED"; ConnectionStatus$1[ConnectionStatus$1["DISCONNECTING"] = 2] = "DISCONNECTING"; ConnectionStatus$1[ConnectionStatus$1["CONNECTING"] = 3] = "CONNECTING"; ConnectionStatus$1[ConnectionStatus$1["RECONNECTING"] = 4] = "RECONNECTING"; return ConnectionStatus$1; }({}); //#endregion //#region source/interfaces.ts /** * @ignore */ const isVersionRejected = function(object) { return "error" in object; }; /** * @ignore */ const isVersionNegotiated = function(object) { return "software" in object && "protocol" in object; }; //#endregion //#region source/electrum-connection.ts /** * Wrapper around TLS/WSS sockets that gracefully separates a network stream into Electrum protocol messages. */ var ElectrumConnection = class extends EventEmitter { status = ConnectionStatus.DISCONNECTED; lastReceivedTimestamp; socket; keepAliveTimer; reconnectTimer; verifications = []; messageBuffer = ""; /** * Sets up network configuration for an Electrum client connection. * * @param application - your application name, used to identify to the electrum host. * @param version - protocol version to use with the host. * @param socketOrHostname - pre-configured electrum socket or fully qualified domain name or IP number of the host * @param options - ... * * @throws {Error} if `version` is not a valid version string. */ constructor(application, version, socketOrHostname, options) { super(); this.application = application; this.version = version; this.socketOrHostname = socketOrHostname; this.options = options; if (!ElectrumProtocol.versionRegexp.test(version)) throw /* @__PURE__ */ new Error(`Provided version string (${version}) is not a valid protocol version number.`); if (typeof socketOrHostname === "string") this.socket = new ElectrumWebSocket(socketOrHostname); else this.socket = socketOrHostname; this.socket.on("connected", this.onSocketConnect.bind(this)); this.socket.on("disconnected", this.onSocketDisconnect.bind(this)); this.socket.on("data", this.parseMessageChunk.bind(this)); if (typeof document !== "undefined" && !this.options.disableBrowserVisibilityHandling) document.addEventListener("visibilitychange", this.handleVisibilityChange.bind(this)); if (typeof window !== "undefined" && !this.options.disableBrowserConnectivityHandling) { window.addEventListener("online", this.handleNetworkChange.bind(this)); window.addEventListener("offline", this.handleNetworkChange.bind(this)); } } get hostIdentifier() { return this.socket.hostIdentifier; } get encrypted() { return this.socket.encrypted; } /** * Assembles incoming data into statements and hands them off to the message parser. * * @param data - data to append to the current message buffer, as a string. * * @throws {SyntaxError} if the passed statement parts are not valid JSON. */ parseMessageChunk(data) { this.lastReceivedTimestamp = Date.now(); this.emit("received"); this.verifications.forEach((timer) => clearTimeout(timer)); this.verifications.length = 0; this.messageBuffer += data; while (this.messageBuffer.includes(ElectrumProtocol.statementDelimiter)) { const statementParts = this.messageBuffer.split(ElectrumProtocol.statementDelimiter); while (statementParts.length > 1) { let statementList = parse(String(statementParts.shift()), null, this.options.useBigInt ? parseNumberAndBigInt : parseFloat); if (!Array.isArray(statementList)) statementList = [statementList]; while (statementList.length > 0) { const currentStatement = statementList.shift(); if (isRPCNotification(currentStatement)) { this.emit("response", currentStatement); continue; } if (currentStatement.id === "versionNegotiation") { if (isRPCErrorResponse(currentStatement)) this.emit("version", { error: currentStatement.error }); else { const [software, protocol] = currentStatement.result; this.emit("version", { software, protocol }); } continue; } if (currentStatement.id === "keepAlive") continue; this.emit("response", currentStatement); } } this.messageBuffer = statementParts.shift() || ""; } } /** * Sends a keep-alive message to the host. * * @returns true if the ping message was fully flushed to the socket, false if * part of the message is queued in the user memory */ ping() { debug.ping(`Sending keep-alive ping to '${this.hostIdentifier}'`); const message = ElectrumProtocol.buildRequestObject("server.ping", [], "keepAlive"); return this.send(message); } /** * Initiates the network connection negotiates a protocol version. Also emits the 'connect' signal if successful. * * @throws {Error} if the socket connection fails. * @returns a promise resolving when the connection is established */ async connect() { if (this.status === ConnectionStatus.CONNECTED) return; this.status = ConnectionStatus.CONNECTING; this.emit("connecting"); const connectionResolver = (resolve, reject) => { const rejector = (error) => { this.status = ConnectionStatus.DISCONNECTED; this.emit("disconnected"); reject(error); }; this.socket.removeAllListeners("error"); this.socket.once("error", rejector); const versionNegotiator = () => { debug.network(`Requesting protocol version ${this.version} with '${this.hostIdentifier}'.`); this.socket.removeListener("error", rejector); const versionMessage = ElectrumProtocol.buildRequestObject("server.version", [this.application, this.version], "versionNegotiation"); const versionValidator = (version) => { if (isVersionRejected(version)) { this.disconnect(true); const errorMessage = "unsupported protocol version."; debug.errors(`Failed to connect with ${this.hostIdentifier} due to ${errorMessage}`); reject(errorMessage); } else if (version.protocol !== this.version && `${version.protocol}.0` !== this.version && `${version.protocol}.0.0` !== this.version) { this.disconnect(true); const errorMessage = `incompatible protocol version negotiated (${version.protocol} !== ${this.version}).`; debug.errors(`Failed to connect with ${this.hostIdentifier} due to ${errorMessage}`); reject(errorMessage); } else { debug.network(`Negotiated protocol version ${version.protocol} with '${this.hostIdentifier}', powered by ${version.software}.`); this.status = ConnectionStatus.CONNECTED; this.emit("connected"); resolve(); } }; this.once("version", versionValidator); this.send(versionMessage); }; this.socket.once("connected", versionNegotiator); this.socket.on("error", this.onSocketError.bind(this)); this.socket.connect(); }; await new Promise(connectionResolver); } /** * Restores the network connection. */ async reconnect() { await this.clearReconnectTimer(); debug.network(`Trying to reconnect to '${this.hostIdentifier}'..`); this.status = ConnectionStatus.RECONNECTING; this.emit("reconnecting"); this.socket.disconnect(); try { await this.connect(); } catch (_error) {} } /** * Removes the current reconnect timer. */ clearReconnectTimer() { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = void 0; } /** * Removes the current keep-alive timer. */ clearKeepAliveTimer() { if (this.keepAliveTimer) clearTimeout(this.keepAliveTimer); this.keepAliveTimer = void 0; } /** * Initializes the keep alive timer loop. */ setupKeepAliveTimer() { if (!this.keepAliveTimer) this.keepAliveTimer = setTimeout(this.ping.bind(this), this.options.sendKeepAliveIntervalInMilliSeconds); } /** * Tears down the current connection and removes all event listeners on disconnect. * * @param force - disconnect even if the connection has not been fully established yet. * @param intentional - update connection state if disconnect is intentional. * * @returns true if successfully disconnected, or false if there was no connection. */ async disconnect(force = false, intentional = true) { if (this.status === ConnectionStatus.DISCONNECTED && !force) return false; if (intentional) this.status = ConnectionStatus.DISCONNECTING; this.emit("disconnecting"); await this.clearKeepAliveTimer(); await this.clearReconnectTimer(); const disconnectResolver = (resolve) => { this.once("disconnected", () => resolve(true)); this.socket.disconnect(); }; return new Promise(disconnectResolver); } /** * Updates the connection state based on browser reported connectivity. * * Most modern browsers are able to provide information on the connection state * which allows for significantly faster response times to network changes compared * to waiting for network requests to fail. * * When available, we make use of this to fail early to provide a better user experience. */ async handleNetworkChange() { if (typeof window.navigator === "undefined") return; if (window.navigator.onLine === true) this.reconnect(); if (window.navigator.onLine !== true) this.disconnect(true, true); } /** * Updates connection state based on application visibility. * * Some browsers will disconnect network connections when the browser is out of focus, * which would normally cause our reconnect-on-timeout routines to trigger, but that * results in a poor user experience since the events are not handled consistently * and sometimes it can take some time after restoring focus to the browser. * * By manually disconnecting when this happens we prevent the default reconnection routines * and make the behavior consistent across browsers. */ async handleVisibilityChange() { if (document.visibilityState === "hidden") this.disconnect(true, true); if (document.visibilityState === "visible") this.reconnect(); } /** * Sends an arbitrary message to the server. * * @param message - json encoded request object to send to the server, as a string. * * @returns true if the message was fully flushed to the socket, false if part of the message * is queued in the user memory */ send(message) { this.clearKeepAliveTimer(); const currentTime = Date.now(); const verificationTimer = setTimeout(this.verifySend.bind(this, currentTime), this.socket.timeout); this.verifications.push(verificationTimer); this.setupKeepAliveTimer(); return this.socket.write(message + ElectrumProtocol.statementDelimiter); } /** * Marks the connection as timed out and schedules reconnection if we have not * received data within the expected time frame. */ verifySend(sentTimestamp) { if (Number(this.lastReceivedTimestamp) < sentTimestamp) { if (this.status === ConnectionStatus.DISCONNECTED || this.status === ConnectionStatus.DISCONNECTING) return; this.clearKeepAliveTimer(); debug.network(`Connection to '${this.hostIdentifier}' timed out.`); this.socket.disconnect(); } } /** * Updates the connection status when a connection is confirmed. */ onSocketConnect() { this.clearReconnectTimer(); this.lastReceivedTimestamp = Date.now(); this.setupKeepAliveTimer(); this.socket.removeAllListeners("error"); this.socket.on("error", this.onSocketError.bind(this)); } /** * Updates the connection status when a connection is ended. */ onSocketDisconnect() { this.clearKeepAliveTimer(); if (this.status === ConnectionStatus.DISCONNECTING) { this.status = ConnectionStatus.DISCONNECTED; this.emit("disconnected"); this.clearReconnectTimer(); this.removeAllListeners(); debug.network(`Disconnected from '${this.hostIdentifier}'.`); } else { if (this.status === ConnectionStatus.CONNECTED) debug.errors(`Connection with '${this.hostIdentifier}' was closed, trying to reconnect in ${this.options.reconnectAfterMilliSeconds / 1e3} seconds.`); this.status = ConnectionStatus.DISCONNECTED; this.emit("disconnected"); if (!this.reconnectTimer) this.reconnectTimer = setTimeout(this.reconnect.bind(this), this.options.reconnectAfterMilliSeconds); } } /** * Notify administrator of any unexpected errors. */ onSocketError(error) { if (typeof error === "undefined") return; debug.errors(`Network error ('${this.hostIdentifier}'): `, error); } }; //#endregion //#region source/constants.ts const MILLI_SECONDS_PER_SECOND = 1e3; /** * Configure default options. */ const defaultNetworkOptions = { useBigInt: false, sendKeepAliveIntervalInMilliSeconds: 1 * MILLI_SECONDS_PER_SECOND, reconnectAfterMilliSeconds: 5 * MILLI_SECONDS_PER_SECOND, verifyConnectionTimeoutInMilliSeconds: 5 * MILLI_SECONDS_PER_SECOND, disableBrowserVisibilityHandling: false, disableBrowserConnectivityHandling: false }; //#endregion //#region source/electrum-client.ts /** * High-level Electrum client that lets applications send requests and subscribe to notification events from a server. */ var ElectrumClient = class extends EventEmitter { /** * The name and version of the server software indexing the blockchain. */ software; /** * The genesis hash of the blockchain indexed by the server. * @remarks This is only available after a 'server.features' call. */ genesisHash; /** * The chain height of the blockchain indexed by the server. * @remarks This is only available after a 'blockchain.headers.subscribe' call. */ chainHeight; /** * Timestamp of when we last received data from the server indexing the blockchain. */ lastReceivedTimestamp; /** * Number corresponding to the underlying connection status. */ get status() { return this.connection.status; } connection; subscriptionMethods = {}; requestId = 0; requestResolvers = {}; connectionLock = new Mutex(); /** * Initializes an Electrum client. * * @param application - your application name, used to identify to the electrum host. * @param version - protocol version to use with the host. * @param socketOrHostname - pre-configured electrum socket or fully qualified domain name or IP number of the host * @param options - ... * * @throws {Error} if `version` is not a valid version string. */ constructor(application, version, socketOrHostname, options = {}) { super(); this.application = application; this.version = version; this.socketOrHostname = socketOrHostname; this.options = options; this.connection = new ElectrumConnection(application, version, socketOrHostname, { ...defaultNetworkOptions, ...options }); } get hostIdentifier() { return this.connection.hostIdentifier; } get encrypted() { return this.connection.encrypted; } /** * Connects to the remote server. * * @throws {Error} if the socket connection fails. * @returns a promise resolving when the connection is established. */ async connect() { const unlock = await this.connectionLock.acquire(); try { if (this.connection.status === ConnectionStatus.CONNECTED) return; this.connection.on("response", this.response.bind(this)); this.connection.on("connected", this.resubscribeOnConnect.bind(this)); this.connection.on("disconnected", this.onConnectionDisconnect.bind(this)); this.connection.on("connecting", this.handleConnectionStatusChanges.bind(this, "connecting")); this.connection.on("disconnecting", this.handleConnectionStatusChanges.bind(this, "disconnecting")); this.connection.on("reconnecting", this.handleConnectionStatusChanges.bind(this, "reconnecting")); this.connection.on("version", this.storeSoftwareVersion.bind(this)); this.connection.on("received", this.updateLastReceivedTimestamp.bind(this)); this.connection.on("error", this.emit.bind(this, "error")); await this.connection.connect(); } finally { unlock(); } } /** * Disconnects from the remote server and removes all event listeners/subscriptions and open requests. * * @param force - disconnect even if the connection has not been fully established yet. * @param retainSubscriptions - retain subscription data so they will be restored on reconnection. * * @returns true if successfully disconnected, or false if there was no connection. */ async disconnect(force = false, retainSubscriptions = false) { if (!retainSubscriptions) { this.removeAllListeners(); this.subscriptionMethods = {}; } return this.connection.disconnect(force); } /** * Calls a method on the remote server with the supplied parameters. * * @param method - name of the method to call. * @param parameters - one or more parameters for the method. * * @throws {Error} if the client is disconnected. * @returns a promise that resolves with the result of the method or an Error. */ async request(method, ...parameters) { if (this.connection.status !== ConnectionStatus.CONNECTED) throw /* @__PURE__ */ new Error(`Unable to send request to a disconnected server '${this.hostIdentifier}'.`); this.requestId += 1; const id = this.requestId; const message = ElectrumProtocol.buildRequestObject(method, parameters, id); const requestResolver = (resolve) => { this.requestResolvers[id] = (error, data) => { if (error) resolve(error); else resolve(data); }; this.connection.send(message); }; debug.network(`Sending request '${method}' to '${this.hostIdentifier}'`); return new Promise(requestResolver); } /** * Subscribes to the method and payload at the server. * * @remarks the response for the subscription request is issued as a notification event. * * @param method - one of the subscribable methods the server supports. * @param parameters - one or more parameters for the method. * * @throws {Error} if the client is disconnected. * @returns a promise resolving when the subscription is established. */ async subscribe(method, ...parameters) { if (!this.subscriptionMethods[method]) this.subscriptionMethods[method] = /* @__PURE__ */ new Set(); this.subscriptionMethods[method].add(JSON.stringify(parameters)); const requestData = await this.request(method, ...parameters); if (requestData instanceof Error) throw requestData; if (Array.isArray(requestData)) throw /* @__PURE__ */ new Error("Subscription request returned an more than one data point."); const notification = { jsonrpc: "2.0", method, params: [...parameters, requestData] }; this.emit("notification", notification); this.updateChainHeightFromHeadersNotifications(notification); } /** * Unsubscribes to the method at the server and removes any callback functions * when there are no more subscriptions for the method. * * @param method - a previously subscribed to method. * @param parameters - one or more parameters for the method. * * @throws {Error} if no subscriptions exist for the combination of the provided `method` and `parameters. * @throws {Error} if the client is disconnected. * @returns a promise resolving when the subscription is removed. */ async unsubscribe(method, ...parameters) { if (this.connection.status !== ConnectionStatus.CONNECTED) throw /* @__PURE__ */ new Error(`Unable to send unsubscribe request to a disconnected server '${this.hostIdentifier}'.`); if (!this.subscriptionMethods[method]) throw /* @__PURE__ */ new Error(`Cannot unsubscribe from '${method}' since the method has no subscriptions.`); const subscriptionParameters = JSON.stringify(parameters); if (!this.subscriptionMethods[method].has(subscriptionParameters)) throw /* @__PURE__ */ new Error(`Cannot unsubscribe from '${method}' since it has no subscription with the given parameters.`); this.subscriptionMethods[method].delete(subscriptionParameters); await this.request(method.replace(".subscribe", ".unsubscribe"), ...parameters); debug.client(`Unsubscribed from '${String(method)}' for the '${subscriptionParameters}' parameters.`); } /** * Restores existing subscriptions without updating status or triggering manual callbacks. * * @throws {Error} if subscription data cannot be found for all stored event names. * @throws {Error} if the client is disconnected. * @returns a promise resolving to true when the subscriptions are restored. * * @ignore */ async resubscribeOnConnect() { debug.client(`Connected to '${this.hostIdentifier}'.`); this.handleConnectionStatusChanges("connected"); const resubscriptionPromises = []; for (const method in this.subscriptionMethods) { for (const parameterJSON of this.subscriptionMethods[method].values()) { const parameters = JSON.parse(parameterJSON); resubscriptionPromises.push(this.subscribe(method, ...parameters)); } await Promise.all(resubscriptionPromises); } if (resubscriptionPromises.length > 0) debug.client(`Restored ${resubscriptionPromises.length} previous subscriptions for '${this.hostIdentifier}'`); } /** * Parser messages from the remote server to resolve request promises and emit subscription events. * * @param message - the response message * * @throws {Error} if the message ID does not match an existing request. * @ignore */ response(message) { if (isRPCNotification(message)) { debug.client(`Received notification for '${message.method}' from '${this.hostIdentifier}'`); this.emit("notification", message); this.updateChainHeightFromHeadersNotifications(message); return; } if (message.id === null) throw /* @__PURE__ */ new Error("Internal error: Received an RPC response with ID null."); const requestResolver = this.requestResolvers[message.id]; if (!requestResolver) { debug.warning(`Ignoring response #${message.id} as the request has already been rejected.`); return; } delete this.requestResolvers[message.id]; if (isRPCErrorResponse(message)) requestResolver(new Error(message.error.message)); else { requestResolver(void 0, message.result); this.storeGenesisHashFromFeaturesResponse(message); } } /** * Callback function that is called when connection to the Electrum server is lost. * Aborts all active requests with an error message indicating that connection was lost. * * @ignore */ async onConnectionDisconnect() { for (const resolverId in this.requestResolvers) { const requestResolver = this.requestResolvers[resolverId]; requestResolver(/* @__PURE__ */ new Error("Connection lost")); delete this.requestResolvers[resolverId]; } this.handleConnectionStatusChanges("disconnected"); } /** * Stores the server provider software version field on successful version negotiation. * * @ignore */ async storeSoftwareVersion(versionStatement) { if (versionStatement.error) return; this.software = versionStatement.software; } /** * Updates the last received timestamp. * * @ignore */ async updateLastReceivedTimestamp() { this.lastReceivedTimestamp = Date.now(); } /** * Checks if the provided message is a response to a headers subscription, * and if so updates the locally stored chain height value for this client. * * @ignore */ async updateChainHeightFromHeadersNotifications(message) { if (message.method === "blockchain.headers.subscribe") this.chainHeight = message.params[0].height; } /** * Checks if the provided message is a response to a server.features request, * and if so stores the genesis hash for this client locally. * * @ignore */ async storeGenesisHashFromFeaturesResponse(message) { try { if (typeof message.result.genesis_hash !== "undefined") this.genesisHash = message.result.genesis_hash; } catch (_ignored) {} } /** * Helper function to synchronize state and events with the underlying connection. */ async handleConnectionStatusChanges(eventName) { this.emit(eventName); } connecting; connected; disconnecting; disconnected; reconnecting; notification; error; }; var electrum_client_default = ElectrumClient; //#endregion export { ConnectionStatus, electrum_client_default as ElectrumClient, isRPCErrorResponse, isRPCNotification, isRPCRequest, isRPCStatement, isVersionNegotiated, isVersionRejected }; //# sourceMappingURL=index.mjs.map