UNPKG

chia-agent

Version:
492 lines (491 loc) 19.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.getDaemon = getDaemon; const WS = require("ws"); const crypto_1 = require("crypto"); const logger_1 = require("../logger"); const connection_1 = require("./connection"); const index_1 = require("../config/index"); const DEFAULT_SERVICE_NAME = "wallet_ui"; const DEFAULT_AUTO_RECONNECT = true; const DEFAULT_TIMEOUT_MS = 30000; const DEFAULT_RETRY_OPTIONS = { maxAttempts: 5, initialDelay: 1000, maxDelay: 30000, backoffMultiplier: 1.5, }; let daemon = null; function getDaemon(serviceName) { if (daemon) { return daemon; } return (daemon = new Daemon(serviceName)); } // Gracefully disconnect from remote daemon server on Ctrl+C. const onProcessExit = () => { if (!daemon) { process.removeListener("SIGINT", onProcessExit); process.kill(process.pid, "SIGINT"); return; } setTimeout(async () => { try { if (daemon && daemon.connected && !daemon.closing) { (0, logger_1.getLogger)().debug(() => "Detected Ctrl+C. Initiating shutdown..."); await daemon.close(); process.removeListener("SIGINT", onProcessExit); process.kill(process.pid, "SIGINT"); } } catch (e) { process.stderr.write((0, logger_1.stringify)(e)); process.exit(1); } }, 67); }; process.addListener("SIGINT", onProcessExit); class Daemon { get connected() { return (Boolean(this._connectedUrl) && this._socket !== null && this._socket.readyState === WS.OPEN); } get closing() { return this._closing; } constructor(serviceName) { this._socket = null; this._connectedUrl = ""; this._responseQueue = {}; this._openEventListeners = []; this._messageEventListeners = []; this._errorEventListeners = []; this._closeEventListeners = []; this._messageListeners = {}; this._closing = false; this._subscriptions = []; this._serviceName = DEFAULT_SERVICE_NAME; this._autoReconnect = DEFAULT_AUTO_RECONNECT; this._retryOptions = DEFAULT_RETRY_OPTIONS; this._timeoutMs = DEFAULT_TIMEOUT_MS; this._reconnectAttempts = 0; this._reconnectTimer = null; this._lastConnectionUrl = ""; this._isReconnecting = false; this.onOpen = this.onOpen.bind(this); this.onError = this.onError.bind(this); this.onMessage = this.onMessage.bind(this); this.onClose = this.onClose.bind(this); this.onPing = this.onPing.bind(this); this.onPong = this.onPong.bind(this); this.onRejection = this.onRejection.bind(this); if (serviceName) { this._serviceName = serviceName; } } onRejection(e) { if (typeof e === "string") { (0, logger_1.getLogger)().error(`Error: ${e}`); } else if (e instanceof Error) { (0, logger_1.getLogger)().error(`Error ${e.name}: ${e.message}`); if (e.stack) { (0, logger_1.getLogger)().error(e.stack); } } else { try { (0, logger_1.getLogger)().error(() => `Error: ${(0, logger_1.stringify)(e)}`); } catch (_e) { (0, logger_1.getLogger)().error("Unknown error"); } } return null; } /** * Connect to local daemon via websocket. * @param daemonServerURL - The websocket URL to connect to. If not provided, uses config values. * @param options - Connection options including timeout, reconnect settings, and retry settings */ async connect(daemonServerURL, options) { if (!daemonServerURL) { const config = (0, index_1.getConfig)(); const daemonHost = config["/ui/daemon_host"]; const daemonPort = config["/ui/daemon_port"]; daemonServerURL = `wss://${daemonHost}:${daemonPort}`; } // Extract options with defaults const timeoutMs = options?.timeoutMs || this._timeoutMs; // Store timeout for reconnection attempts if (options?.timeoutMs !== undefined) { this._timeoutMs = options.timeoutMs; } // Update settings from options if (options?.autoReconnect !== undefined) { this._autoReconnect = options.autoReconnect; } if (options?.retryOptions !== undefined) { this._retryOptions = { ...DEFAULT_RETRY_OPTIONS, ...options.retryOptions, }; } if (this._connectedUrl === daemonServerURL) { return true; } else if (this._connectedUrl) { (0, logger_1.getLogger)().error("Connection is still active. Please close living connection first"); return false; } // Store URL for reconnection this._lastConnectionUrl = daemonServerURL; // Attempt connection with retry logic let lastError; for (let attempt = 1; attempt <= this._retryOptions.maxAttempts; attempt++) { (0, logger_1.getLogger)().debug(() => `Opening websocket connection to ${daemonServerURL} (attempt ${attempt}/${this._retryOptions.maxAttempts})`); const result = await (0, connection_1.open)(daemonServerURL, timeoutMs).catch((error) => { lastError = error; return null; }); if (result) { this._socket = result.ws; this._socket.on("error", this.onError); this._socket.addEventListener("message", this.onMessage); this._socket.on("close", this.onClose); this._socket.on("ping", this.onPing); this._socket.on("pong", this.onPong); // Call onOpen but don't check result (maintain original behavior) await this.onOpen(result.openEvent, daemonServerURL).catch(this.onRejection); return true; } // If not the last attempt, wait before retrying if (attempt < this._retryOptions.maxAttempts) { const delay = Math.min(this._retryOptions.initialDelay * Math.pow(this._retryOptions.backoffMultiplier, attempt - 1), this._retryOptions.maxDelay); (0, logger_1.getLogger)().info(`Connection attempt ${attempt} failed. Retrying in ${delay}ms...`); await new Promise((resolve) => setTimeout(resolve, delay)); } } // All attempts failed (0, logger_1.getLogger)().error(`Failed to connect after ${this._retryOptions.maxAttempts} attempts`); this.onRejection(lastError); return false; } async close() { return new Promise((resolve) => { if (this._closing || !this._socket) { return; } // Cancel any pending reconnection if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } // Disable reconnection for manual close this._autoReconnect = false; this._isReconnecting = false; (0, logger_1.getLogger)().debug(() => "Closing web socket connection"); this._socket.close(); this._closing = true; this._connectedUrl = ""; this._onClosePromise = resolve; // Resolved in onClose function. }); } async sendMessage(destination, command, data, timeoutMs = 30000) { return new Promise((resolve, reject) => { if (!this.connected || !this._socket) { (0, logger_1.getLogger)().error("Tried to send message without active connection"); reject("Not connected"); return; } const message = this.createMessageTemplate(command, destination, data || {}); const reqId = message.request_id; // Set up timeout const timeout = setTimeout(() => { const entry = this._responseQueue[reqId]; if (entry) { delete this._responseQueue[reqId]; entry.rejecter(new Error(`Message timeout after ${timeoutMs}ms. dest=${destination} command=${command} reqId=${reqId}`)); } }, timeoutMs); this._responseQueue[reqId] = { resolver: resolve, rejecter: reject, timeout, }; (0, logger_1.getLogger)().debug(() => `Sending Ws message. dest=${destination} command=${command} reqId=${reqId}`); const messageStr = JSON.stringify(message); this._socket.send(messageStr, (err) => { if (err) { (0, logger_1.getLogger)().error(`Error while sending message: ${messageStr}`); (0, logger_1.getLogger)().error(() => (0, logger_1.stringify)(err)); // Clean up on send error const entry = this._responseQueue[reqId]; if (entry) { clearTimeout(entry.timeout); delete this._responseQueue[reqId]; reject(err); } } }); }); } createMessageTemplate(command, destination, data) { return { command, data, ack: false, origin: this._serviceName, destination, request_id: (0, crypto_1.randomBytes)(32).toString("hex"), }; } async subscribe(service) { if (!this.connected || !this._socket) { (0, logger_1.getLogger)().error(`Tried to subscribe '${service}' without active connection`); throw new Error("Not connected"); } if (this._subscriptions.findIndex((s) => s === service) > -1) { return { command: "register_service", data: { success: true }, ack: true, origin: "daemon", destination: service, request_id: "", }; } let error; const result = await this.sendMessage("daemon", "register_service", { service, }).catch((e) => { error = e; return null; }); if (error || !result) { (0, logger_1.getLogger)().error("Failed to register agent service to daemon"); (0, logger_1.getLogger)().error(error instanceof Error ? `${error.name}: ${error.message}` : (0, logger_1.stringify)(error)); throw new Error("Subscribe failed"); } this._subscriptions.push(service); return result; } addEventListener(type, listener) { if (type === "open") { this._openEventListeners.push(listener); } else if (type === "message") { this._messageEventListeners.push(listener); } else if (type === "error") { this._errorEventListeners.push(listener); } else if (type === "close") { this._closeEventListeners.push(listener); } } removeEventListener(type, listener) { let listeners; if (type === "open") { listeners = this._openEventListeners; } else if (type === "message") { listeners = this._messageEventListeners; } else if (type === "error") { listeners = this._errorEventListeners; } else if (type === "close") { listeners = this._closeEventListeners; } else { return; } const index = listeners.findIndex((l) => l === listener); if (index > -1) { listeners.splice(index, 1); } } clearAllEventListeners() { this._openEventListeners = []; this._messageEventListeners = []; this._errorEventListeners = []; this._closeEventListeners = []; this._messageListeners = {}; } /** * Add listener for message * @param {string} origin - Can be chia_farmer, chia_full_node, chia_wallet, etc. * @param listener - Triggered when a message arrives. */ addMessageListener(origin, listener) { const o = origin || "all"; if (!this._messageListeners[o]) { this._messageListeners[o] = []; } this._messageListeners[o].push(listener); // Returns removeMessageListener function. return () => { this.removeMessageListener(o, listener); }; } removeMessageListener(origin, listener) { const listeners = this._messageListeners[origin]; if (!listeners) { return; } const index = listeners.findIndex((l) => l === listener); if (index > -1) { listeners.splice(index, 1); } } clearAllMessageListeners() { this._messageListeners = {}; } async onOpen(event, url) { (0, logger_1.getLogger)().info("ws connection opened"); this._connectedUrl = url; this._openEventListeners.forEach((l) => l(event)); return this.subscribe(this._serviceName); } onError(error) { (0, logger_1.getLogger)().error(`ws connection error: ${error.type} ${error.target} ${error.error} ${error.message}`); this._errorEventListeners.forEach((l) => l(error)); } onMessage(event) { let payload; let request_id; let origin; let command; try { payload = JSON.parse(event.data); ({ request_id, origin, command } = payload); } catch (err) { (0, logger_1.getLogger)().error(`Failed to parse ws message data: ${(0, logger_1.stringify)(err)}`); (0, logger_1.getLogger)().error(`ws payload: ${event.data}`); return; } const entry = this._responseQueue[request_id]; if (entry) { clearTimeout(entry.timeout); delete this._responseQueue[request_id]; (0, logger_1.getLogger)().debug(() => `Ws response received. origin=${origin} command=${command} reqId=${request_id}`); entry.resolver(payload); } else { (0, logger_1.getLogger)().debug(() => `Ws message arrived. origin=${origin} command=${command} reqId=${request_id}`); } (0, logger_1.getLogger)().trace(() => `Ws message: ${(0, logger_1.stringify)(payload)}`); this._messageEventListeners.forEach((l) => l(event)); for (const o in this._messageListeners) { if (!Object.prototype.hasOwnProperty.call(this._messageListeners, o)) { continue; } const listeners = this._messageListeners[o]; if (origin === o || o === "all") { listeners.forEach((l) => l(payload)); } } } onClose(event) { const previousSubscriptions = [...this._subscriptions]; if (this._socket) { this._socket.off("error", this.onError); this._socket.removeEventListener("message", this.onMessage); this._socket.off("close", this.onClose); this._socket.off("ping", this.onPing); this._socket.off("pong", this.onPong); this._socket = null; } this._closing = false; this._connectedUrl = ""; this._subscriptions = []; this._closeEventListeners.forEach((l) => l(event)); // Don't clear event listeners - preserve them for reconnection // this.clearAllEventListeners(); (0, logger_1.getLogger)().info(`Closed ws connection. code:${event.code} wasClean:${event.wasClean} reason:${event.reason}`); if (this._onClosePromise) { this._onClosePromise(); this._onClosePromise = undefined; } // Attempt reconnection if enabled and not manually closed if (this._autoReconnect && this._lastConnectionUrl && !this._isReconnecting && event.code !== 1000 // 1000 = normal closure ) { this._isReconnecting = true; this._attemptReconnection(previousSubscriptions); } } onPing() { (0, logger_1.getLogger)().debug(() => "Received ping"); } onPong() { (0, logger_1.getLogger)().debug(() => "Received pong"); } _attemptReconnection(previousSubscriptions) { if (this._reconnectAttempts >= this._retryOptions.maxAttempts) { (0, logger_1.getLogger)().error(`Max reconnection attempts (${this._retryOptions.maxAttempts}) reached. Giving up.`); this._isReconnecting = false; this._reconnectAttempts = 0; // Emit a custom event for max retries reached const errorEvent = { type: "error", message: "Max reconnection attempts reached", error: new Error("Max reconnection attempts reached"), target: this._socket, }; this._errorEventListeners.forEach((l) => l(errorEvent)); return; } const delay = Math.min(this._retryOptions.initialDelay * Math.pow(this._retryOptions.backoffMultiplier, this._reconnectAttempts), this._retryOptions.maxDelay); this._reconnectAttempts++; (0, logger_1.getLogger)().info(`Attempting reconnection ${this._reconnectAttempts}/${this._retryOptions.maxAttempts} in ${delay}ms...`); this._reconnectTimer = setTimeout(async () => { this._reconnectTimer = null; try { const connected = await this.connect(this._lastConnectionUrl, { timeoutMs: this._timeoutMs, autoReconnect: this._autoReconnect, retryOptions: this._retryOptions, }); if (connected) { (0, logger_1.getLogger)().info("Reconnection successful"); this._reconnectAttempts = 0; this._isReconnecting = false; // Re-establish previous subscriptions for (const service of previousSubscriptions) { try { await this.subscribe(service); (0, logger_1.getLogger)().debug(() => `Re-subscribed to ${service}`); } catch (e) { (0, logger_1.getLogger)().error(`Failed to re-subscribe to ${service}: ${e}`); } } // Emit successful reconnection event const reconnectedEvent = { type: "reconnected", target: this._socket, }; this._openEventListeners.forEach((l) => l(reconnectedEvent)); } else { // Connection failed, try again this._attemptReconnection(previousSubscriptions); } } catch (error) { (0, logger_1.getLogger)().error(`Reconnection attempt failed: ${error}`); this._attemptReconnection(previousSubscriptions); } }, delay); } }