chia-agent
Version:
chia rpc/websocket client library
492 lines (491 loc) • 19.7 kB
JavaScript
"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);
}
}