diff --git a/src/rpc/client.js b/src/rpc/client.js index 87624cc..ac0dce3 100644 --- a/src/rpc/client.js +++ b/src/rpc/client.js @@ -4,8 +4,14 @@ * Provides HTTP client infrastructure for Salvium daemon and wallet RPC. * Supports JSON-RPC 2.0 protocol with authentication, retries, and error handling. * Works in both browser and Node.js environments. + * + * Supports multiple server URLs with latency-based selection via ConnectionManager. + * Single URL = direct connection (no overhead). + * Multiple URLs = race on connect, pick fastest, failover on error. */ +import { ConnectionManager, SEED_NODES } from './connection-manager.js'; + /** * @typedef {Object} RPCClientOptions * @property {string} url - RPC server URL (e.g., 'http://localhost:19081') @@ -67,11 +73,28 @@ export class RPCClient { * @param {RPCClientOptions} options - Client configuration */ constructor(options = {}) { - if (!options.url) { - throw new Error('RPC client requires a URL'); + // Resolve URLs: explicit urls array, network seed nodes, or single url + let urls = options.urls || null; + if (!urls && options.network && SEED_NODES[options.network]) { + urls = SEED_NODES[options.network]; + } + + if (urls && urls.length > 0) { + this.url = urls[0].replace(/\/+$/, ''); + this._connectionManager = new ConnectionManager({ + urls: urls.map(u => u.replace(/\/+$/, '')), + raceTimeout: options.raceTimeout || 5000, + degradationFactor: options.degradationFactor || 2, + raceInterval: options.raceInterval || 0, + onSwitch: options.onSwitch || null, + }); + } else if (options.url) { + this.url = options.url.replace(/\/+$/, ''); + this._connectionManager = null; + } else { + throw new Error('RPC client requires a url, urls array, or network name'); } - this.url = options.url.replace(/\/+$/, ''); // Remove trailing slashes this.username = options.username || null; this.password = options.password || null; this.timeout = options.timeout || 30000; @@ -81,6 +104,31 @@ export class RPCClient { this._requestId = 0; } + /** + * Get the currently active URL (may change with ConnectionManager) + * @returns {string} + */ + getActiveUrl() { + if (this._connectionManager) { + return this._connectionManager.activeUrl; + } + return this.url; + } + + /** + * Race all configured servers and pick the fastest. + * No-op if only one server is configured. + * @returns {Promise} The active URL after racing + */ + async race() { + if (this._connectionManager) { + const url = await this._connectionManager.race(); + this.url = url; + return url; + } + return this.url; + } + /** * Generate a unique request ID * @returns {number} Request ID @@ -148,6 +196,47 @@ export class RPCClient { * @param {Object} [params={}] - Method parameters * @returns {Promise} */ + /** + * Get the current URL, ensuring connection manager has raced if needed. + * @returns {Promise} + * @private + */ + async _resolveUrl() { + if (this._connectionManager) { + await this._connectionManager.ensureConnected(); + this.url = this._connectionManager.activeUrl; + } + return this.url; + } + + /** + * Record latency and handle failover via connection manager. + * @param {number} latencyMs - Response latency + * @private + */ + _recordLatency(latencyMs) { + if (this._connectionManager) { + this._connectionManager.recordLatency(latencyMs); + this.url = this._connectionManager.activeUrl; + } + } + + /** + * Handle network failure — failover to next server if available. + * @returns {Promise} true if a new server is available to retry + * @private + */ + async _handleNetworkFailure() { + if (this._connectionManager && this._connectionManager.isMultiServer) { + const newUrl = await this._connectionManager.handleFailure(); + if (newUrl) { + this.url = newUrl; + return true; + } + } + return false; + } + async call(method, params = {}) { const payload = { jsonrpc: '2.0', @@ -156,10 +245,13 @@ export class RPCClient { params }; + await this._resolveUrl(); + let lastError = null; const attempts = this.retries + 1; for (let attempt = 1; attempt <= attempts; attempt++) { + const start = Date.now(); try { const response = await this._fetchWithTimeout(`${this.url}/json_rpc`, { method: 'POST', @@ -167,6 +259,8 @@ export class RPCClient { body: JSON.stringify(payload) }); + this._recordLatency(Date.now() - start); + if (!response.ok) { if (response.status === 401) { return { @@ -225,6 +319,11 @@ export class RPCClient { }; } + // Try failover to another server before exhausting retries + if (await this._handleNetworkFailure()) { + continue; // Retry on the new server + } + if (attempt < attempts) { await this._sleep(this.retryDelay); } @@ -244,10 +343,13 @@ export class RPCClient { * @returns {Promise} */ async post(endpoint, data = {}) { + await this._resolveUrl(); + let lastError = null; const attempts = this.retries + 1; for (let attempt = 1; attempt <= attempts; attempt++) { + const start = Date.now(); try { const url = endpoint.startsWith('/') ? `${this.url}${endpoint}` @@ -259,6 +361,8 @@ export class RPCClient { body: JSON.stringify(data) }); + this._recordLatency(Date.now() - start); + if (!response.ok) { if (response.status === 401) { return { @@ -301,6 +405,10 @@ export class RPCClient { }; } + if (await this._handleNetworkFailure()) { + continue; + } + if (attempt < attempts) { await this._sleep(this.retryDelay); } @@ -320,10 +428,13 @@ export class RPCClient { * @returns {Promise} */ async postBinary(endpoint, body) { + await this._resolveUrl(); + let lastError = null; const attempts = this.retries + 1; for (let attempt = 1; attempt <= attempts; attempt++) { + const start = Date.now(); try { const url = endpoint.startsWith('/') ? `${this.url}${endpoint}` @@ -338,6 +449,8 @@ export class RPCClient { body }); + this._recordLatency(Date.now() - start); + if (!response.ok) { if (response.status === 401) { return { @@ -370,6 +483,10 @@ export class RPCClient { }; } + if (await this._handleNetworkFailure()) { + continue; + } + if (attempt < attempts) { await this._sleep(this.retryDelay); } @@ -389,10 +506,13 @@ export class RPCClient { * @returns {Promise} */ async get(endpoint, params = {}) { + await this._resolveUrl(); + let lastError = null; const attempts = this.retries + 1; for (let attempt = 1; attempt <= attempts; attempt++) { + const start = Date.now(); try { let url = endpoint.startsWith('/') ? `${this.url}${endpoint}` @@ -415,6 +535,8 @@ export class RPCClient { headers: this._buildHeaders() }); + this._recordLatency(Date.now() - start); + if (!response.ok) { if (response.status === 401) { return { @@ -448,6 +570,10 @@ export class RPCClient { }; } + if (await this._handleNetworkFailure()) { + continue; + } + if (attempt < attempts) { await this._sleep(this.retryDelay); } diff --git a/src/rpc/connection-manager.js b/src/rpc/connection-manager.js new file mode 100644 index 0000000..916a054 --- /dev/null +++ b/src/rpc/connection-manager.js @@ -0,0 +1,219 @@ +/** + * Connection Manager — Latency-Based Server Selection + * + * Manages multiple RPC server URLs. Races them on connect, picks the fastest, + * monitors latency, and re-races when performance degrades or a server fails. + * + * Single URL = no overhead, just uses it directly. + * Multiple URLs = race, pick fastest, failover on error, re-race on degradation. + */ + +/** + * Default seed nodes for each Salvium network. + * Same hostnames, different ports per network. + */ +export const SEED_NODES = { + mainnet: [ + 'http://seed01.salvium.io:19081', + 'http://seed02.salvium.io:19081', + 'http://seed03.salvium.io:19081', + ], + testnet: [ + 'http://seed01.salvium.io:29081', + 'http://seed02.salvium.io:29081', + 'http://seed03.salvium.io:29081', + ], + stagenet: [ + 'http://seed01.salvium.io:39081', + 'http://seed02.salvium.io:39081', + 'http://seed03.salvium.io:39081', + ], +}; + +/** + * @typedef {Object} ConnectionManagerOptions + * @property {string[]} urls - Server URLs to race + * @property {number} [raceTimeout=5000] - Timeout per server during race (ms) + * @property {number} [degradationFactor=2] - Re-race when latency > baseline * factor + * @property {number} [raceInterval=0] - Periodic re-race interval (ms), 0 = disabled + * @property {function} [onSwitch] - Callback when active server changes (oldUrl, newUrl) + */ + +export class ConnectionManager { + /** + * @param {ConnectionManagerOptions} options + */ + constructor(options = {}) { + this.urls = options.urls || []; + this.raceTimeout = options.raceTimeout || 5000; + this.degradationFactor = options.degradationFactor || 2; + this.raceInterval = options.raceInterval || 0; + this.onSwitch = options.onSwitch || null; + + this.activeUrl = this.urls[0] || null; + this.baselineLatency = null; + this._latencies = new Map(); // url -> last latency ms + this._ranked = [...this.urls]; // sorted by latency after each race + this._racing = false; + this._raceTimer = null; + this._initialized = false; + } + + /** + * Whether this manager has multiple servers to race. + */ + get isMultiServer() { + return this.urls.length > 1; + } + + /** + * Race all servers, pick the fastest responding one. + * Sets activeUrl and baselineLatency. + * @returns {Promise} The winning URL + */ + async race() { + if (this._racing) return this.activeUrl; + if (this.urls.length === 0) return this.activeUrl; + if (this.urls.length === 1) { + this.activeUrl = this.urls[0]; + this._initialized = true; + return this.activeUrl; + } + + this._racing = true; + + try { + const results = await Promise.allSettled( + this.urls.map(async (url) => { + const start = Date.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.raceTimeout); + + try { + const resp = await fetch(`${url.replace(/\/+$/, '')}/get_info`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + signal: controller.signal, + }); + clearTimeout(timer); + + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + await resp.json(); // consume body + + const latency = Date.now() - start; + this._latencies.set(url, latency); + return { url, latency }; + } catch (e) { + clearTimeout(timer); + this._latencies.set(url, Infinity); + throw e; + } + }) + ); + + // Pick the fastest successful result + let best = null; + for (const result of results) { + if (result.status === 'fulfilled') { + if (!best || result.value.latency < best.latency) { + best = result.value; + } + } + } + + // Sort URLs by latency (fastest first) for failover ordering + this._ranked = [...this.urls].sort((a, b) => { + const la = this._latencies.get(a) ?? Infinity; + const lb = this._latencies.get(b) ?? Infinity; + return la - lb; + }); + + if (best) { + const oldUrl = this.activeUrl; + this.activeUrl = best.url; + this.baselineLatency = best.latency; + this._initialized = true; + + if (oldUrl && oldUrl !== best.url && this.onSwitch) { + this.onSwitch(oldUrl, best.url); + } + } + + // Set up periodic re-race + if (this.raceInterval > 0 && !this._raceTimer) { + this._raceTimer = setInterval(() => this.race(), this.raceInterval); + // Don't keep process alive just for re-racing + if (this._raceTimer.unref) this._raceTimer.unref(); + } + } finally { + this._racing = false; + } + + return this.activeUrl; + } + + /** + * Ensure connection manager is initialized (first race completed). + * @returns {Promise} Active URL + */ + async ensureConnected() { + if (!this._initialized) { + return this.race(); + } + return this.activeUrl; + } + + /** + * Record a successful response latency. Triggers re-race if degraded. + * @param {number} latencyMs - Response time in milliseconds + */ + recordLatency(latencyMs) { + if (!this.isMultiServer || !this.baselineLatency) return; + + this._latencies.set(this.activeUrl, latencyMs); + + // Check for degradation + if (latencyMs > this.baselineLatency * this.degradationFactor) { + // Fire re-race in background (don't await) + this.race(); + } + } + + /** + * Report a failure on the active server. Switch to next best, or re-race. + * @returns {Promise} New active URL, or null if all failed + */ + async handleFailure() { + if (!this.isMultiServer) return this.activeUrl; + + // Mark current as failed + this._latencies.set(this.activeUrl, Infinity); + + // Walk the ranked list (sorted by latency at last race) for next best + const ranked = this._ranked || this.urls; + for (const url of ranked) { + if (url === this.activeUrl) continue; + const lat = this._latencies.get(url) ?? 0; + if (lat < Infinity) { + const oldUrl = this.activeUrl; + this.activeUrl = url; + if (this.onSwitch) this.onSwitch(oldUrl, url); + return url; + } + } + + // All servers have failed — re-race + return this.race(); + } + + /** + * Stop periodic re-racing and clean up. + */ + destroy() { + if (this._raceTimer) { + clearInterval(this._raceTimer); + this._raceTimer = null; + } + } +} diff --git a/src/rpc/index.js b/src/rpc/index.js index ddd1e3e..d8b116e 100644 --- a/src/rpc/index.js +++ b/src/rpc/index.js @@ -65,6 +65,12 @@ export { STAGENET_URL as WALLET_STAGENET_URL } from './wallet.js'; +// Connection manager +export { + ConnectionManager, + SEED_NODES +} from './connection-manager.js'; + // Default export with all components import { RPCClient, createClient, RPC_ERROR_CODES, RPC_STATUS } from './client.js'; import { @@ -74,6 +80,7 @@ import { RESTRICTED_MAINNET_URL as DAEMON_RESTRICTED_MAINNET, RESTRICTED_TESTNET_URL as DAEMON_RESTRICTED_TESTNET, RESTRICTED_STAGENET_URL as DAEMON_RESTRICTED_STAGENET } from './daemon.js'; import { WalletRPC, createWalletRPC, PRIORITY, TRANSFER_TYPE, MAINNET_URL as WALLET_MAINNET, TESTNET_URL as WALLET_TESTNET, STAGENET_URL as WALLET_STAGENET } from './wallet.js'; +import { ConnectionManager, SEED_NODES } from './connection-manager.js'; export default { // Base client @@ -82,6 +89,10 @@ export default { RPC_ERROR_CODES, RPC_STATUS, + // Connection manager + ConnectionManager, + SEED_NODES, + // Daemon RPC DaemonRPC, createDaemonRPC,