minimal-xec-wallet 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/dist/minimal-xec-wallet.js +66268 -0
- package/dist/minimal-xec-wallet.min.js +55 -0
- package/examples/README.md +380 -0
- package/examples/advanced/browser-compatibility-test.js +263 -0
- package/examples/advanced/get-xec-price.js +149 -0
- package/examples/advanced/optimize-utxos.js +255 -0
- package/examples/advanced/send-op-return.js +216 -0
- package/examples/browser-test.html +350 -0
- package/examples/key-management/derive-addresses.js +191 -0
- package/examples/key-management/export-to-wif.js +114 -0
- package/examples/key-management/validate-address.js +214 -0
- package/examples/optimization/simple-consolidation-test.js +79 -0
- package/examples/optimization/test-utxo-consolidation.js +179 -0
- package/examples/test-examples.js +1204 -0
- package/examples/tokens/burn-tokens.js +293 -0
- package/examples/tokens/get-token-balance.js +169 -0
- package/examples/tokens/get-token-info.js +269 -0
- package/examples/tokens/list-all-tokens.js +162 -0
- package/examples/tokens/send-any-token.js +260 -0
- package/examples/tokens/test-main-wallet-integration.js +193 -0
- package/examples/transactions/send-all-xec.js +205 -0
- package/examples/transactions/send-to-multiple.js +217 -0
- package/examples/transactions/send-xec.js +191 -0
- package/examples/utils/show-qr.js +119 -0
- package/examples/utils/wallet-helper.js +176 -0
- package/examples/validation/comprehensive-infrastructure-test.js +210 -0
- package/examples/wallet-creation/create-new-wallet.js +67 -0
- package/examples/wallet-creation/import-from-wif.js +135 -0
- package/examples/wallet-creation/restore-from-mnemonic.js +100 -0
- package/examples/wallet-info/get-balance.js +99 -0
- package/examples/wallet-info/get-transactions.js +157 -0
- package/examples/wallet-info/get-utxos.js +145 -0
- package/examples/wallet.json +11 -0
- package/lib/adapters/robust-chronik-router.js +507 -0
- package/lib/adapters/router.js +651 -0
- package/lib/alp-token-handler.js +581 -0
- package/lib/browser-wasm-loader.js +271 -0
- package/lib/consolidate-utxos.js +338 -0
- package/lib/hybrid-token-manager.js +322 -0
- package/lib/key-derivation.js +466 -0
- package/lib/op-return.js +314 -0
- package/lib/security.js +270 -0
- package/lib/send-xec.js +396 -0
- package/lib/slp-token-handler.js +572 -0
- package/lib/token-protocol-detector.js +307 -0
- package/lib/utxos.js +303 -0
- package/package.json +125 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Get the UTXOs (Unspent Transaction Outputs) of your wallet.
|
|
3
|
+
This example shows how to view all spendable outputs in your wallet.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MinimalXECWallet = require('../../index')
|
|
7
|
+
const WalletHelper = require('../utils/wallet-helper')
|
|
8
|
+
|
|
9
|
+
async function getUtxos () {
|
|
10
|
+
try {
|
|
11
|
+
console.log('š Getting wallet UTXOs...\n')
|
|
12
|
+
|
|
13
|
+
// Load wallet from file
|
|
14
|
+
const walletData = WalletHelper.loadWallet()
|
|
15
|
+
if (!walletData) {
|
|
16
|
+
console.log(' Run: node examples/wallet-creation/create-new-wallet.js')
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Create wallet instance from saved data
|
|
21
|
+
const wallet = new MinimalXECWallet(walletData.mnemonic || walletData.privateKey)
|
|
22
|
+
await wallet.walletInfoPromise
|
|
23
|
+
|
|
24
|
+
console.log('š Fetching UTXOs from blockchain...')
|
|
25
|
+
|
|
26
|
+
// Get UTXOs for the wallet
|
|
27
|
+
const utxoData = await wallet.getUtxos()
|
|
28
|
+
const utxos = utxoData.utxos || []
|
|
29
|
+
|
|
30
|
+
console.log('\nš¦ UTXO Information:')
|
|
31
|
+
console.log('ā'.repeat(70))
|
|
32
|
+
console.log(`XEC Address: ${walletData.xecAddress}`)
|
|
33
|
+
console.log(`Total UTXOs: ${utxos.length}`)
|
|
34
|
+
console.log('ā'.repeat(70))
|
|
35
|
+
|
|
36
|
+
if (utxos.length === 0) {
|
|
37
|
+
console.log('\nšø No UTXOs found!')
|
|
38
|
+
console.log(' Your wallet has no spendable outputs.')
|
|
39
|
+
console.log(' This means either:')
|
|
40
|
+
console.log(' ⢠Your wallet is empty')
|
|
41
|
+
console.log(' ⢠All funds are in unconfirmed transactions')
|
|
42
|
+
console.log(' ⢠The address has never received any XEC')
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Group UTXOs by confirmation status
|
|
47
|
+
const confirmedUtxos = utxos.filter(utxo => utxo.blockHeight !== -1)
|
|
48
|
+
const unconfirmedUtxos = utxos.filter(utxo => utxo.blockHeight === -1)
|
|
49
|
+
|
|
50
|
+
// Calculate totals - handle sats field properly
|
|
51
|
+
const getUtxoValue = (utxo) => {
|
|
52
|
+
if (utxo.sats !== undefined) {
|
|
53
|
+
return typeof utxo.sats === 'bigint' ? Number(utxo.sats) : parseInt(utxo.sats)
|
|
54
|
+
}
|
|
55
|
+
if (utxo.value !== undefined) {
|
|
56
|
+
return typeof utxo.value === 'bigint' ? Number(utxo.value) : parseInt(utxo.value)
|
|
57
|
+
}
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const totalConfirmed = confirmedUtxos.reduce((sum, utxo) => sum + getUtxoValue(utxo), 0)
|
|
62
|
+
const totalUnconfirmed = unconfirmedUtxos.reduce((sum, utxo) => sum + getUtxoValue(utxo), 0)
|
|
63
|
+
const totalValue = totalConfirmed + totalUnconfirmed
|
|
64
|
+
|
|
65
|
+
console.log('\nš° Balance Summary:')
|
|
66
|
+
console.log(`Confirmed: ${(totalConfirmed / 100).toLocaleString()} XEC (${confirmedUtxos.length} UTXOs)`)
|
|
67
|
+
console.log(`Unconfirmed: ${(totalUnconfirmed / 100).toLocaleString()} XEC (${unconfirmedUtxos.length} UTXOs)`)
|
|
68
|
+
console.log(`Total: ${(totalValue / 100).toLocaleString()} XEC`)
|
|
69
|
+
|
|
70
|
+
// Show confirmed UTXOs
|
|
71
|
+
if (confirmedUtxos.length > 0) {
|
|
72
|
+
console.log('\nā
Confirmed UTXOs:')
|
|
73
|
+
console.log('ā'.repeat(70))
|
|
74
|
+
confirmedUtxos.forEach((utxo, index) => {
|
|
75
|
+
const satsValue = typeof utxo.sats === 'bigint' ? Number(utxo.sats) : parseInt(utxo.sats)
|
|
76
|
+
const xecValue = (satsValue / 100).toLocaleString()
|
|
77
|
+
const txidShort = `${utxo.outpoint.txid.substring(0, 8)}...${utxo.outpoint.txid.substring(56)}`
|
|
78
|
+
console.log(`${index + 1}. ${xecValue} XEC`)
|
|
79
|
+
console.log(` TXID: ${txidShort}:${utxo.outpoint.outIdx}`)
|
|
80
|
+
console.log(` Block: ${utxo.blockHeight}`)
|
|
81
|
+
console.log(` Coinbase: ${utxo.isCoinbase ? 'Yes' : 'No'}`)
|
|
82
|
+
console.log()
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Show unconfirmed UTXOs
|
|
87
|
+
if (unconfirmedUtxos.length > 0) {
|
|
88
|
+
console.log('ā³ Unconfirmed UTXOs:')
|
|
89
|
+
console.log('ā'.repeat(70))
|
|
90
|
+
unconfirmedUtxos.forEach((utxo, index) => {
|
|
91
|
+
const satsValue = typeof utxo.sats === 'bigint' ? Number(utxo.sats) : parseInt(utxo.sats)
|
|
92
|
+
const xecValue = (satsValue / 100).toLocaleString()
|
|
93
|
+
const txidShort = `${utxo.outpoint.txid.substring(0, 8)}...${utxo.outpoint.txid.substring(56)}`
|
|
94
|
+
console.log(`${index + 1}. ${xecValue} XEC (pending confirmation)`)
|
|
95
|
+
console.log(` TXID: ${txidShort}:${utxo.outpoint.outIdx}`)
|
|
96
|
+
console.log()
|
|
97
|
+
})
|
|
98
|
+
console.log('š Note: Unconfirmed UTXOs cannot be spent until they are confirmed.')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// UTXO analysis
|
|
102
|
+
console.log('\nš UTXO Analysis:')
|
|
103
|
+
console.log('ā'.repeat(70))
|
|
104
|
+
|
|
105
|
+
if (confirmedUtxos.length > 0) {
|
|
106
|
+
const values = confirmedUtxos.map(utxo => getUtxoValue(utxo) / 100)
|
|
107
|
+
const avgValue = values.reduce((a, b) => a + b, 0) / values.length
|
|
108
|
+
const maxValue = Math.max(...values)
|
|
109
|
+
const minValue = Math.min(...values)
|
|
110
|
+
|
|
111
|
+
console.log(`Average UTXO size: ${avgValue.toLocaleString()} XEC`)
|
|
112
|
+
console.log(`Largest UTXO: ${maxValue.toLocaleString()} XEC`)
|
|
113
|
+
console.log(`Smallest UTXO: ${minValue.toLocaleString()} XEC`)
|
|
114
|
+
|
|
115
|
+
// Dust analysis (UTXOs < 5.46 XEC are considered dust)
|
|
116
|
+
const dustUtxos = confirmedUtxos.filter(utxo => utxo.value < 546)
|
|
117
|
+
if (dustUtxos.length > 0) {
|
|
118
|
+
console.log(`Dust UTXOs: ${dustUtxos.length} (< 5.46 XEC)`)
|
|
119
|
+
console.log('š” Consider consolidating dust UTXOs to reduce transaction fees')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Suggest optimizations
|
|
124
|
+
if (confirmedUtxos.length > 20) {
|
|
125
|
+
console.log('\nā” Optimization Suggestion:')
|
|
126
|
+
console.log(` You have ${confirmedUtxos.length} UTXOs, which might increase transaction fees.`)
|
|
127
|
+
console.log(' Consider running: node examples/advanced/optimize-utxos.js')
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error('ā Failed to get UTXOs:', err.message)
|
|
131
|
+
|
|
132
|
+
// Provide helpful error context
|
|
133
|
+
if (err.message.includes('network') || err.message.includes('connection')) {
|
|
134
|
+
console.log('\nš Network Error:')
|
|
135
|
+
console.log(' ⢠Check your internet connection')
|
|
136
|
+
console.log(' ⢠Chronik indexer might be temporarily unavailable')
|
|
137
|
+
console.log(' ⢠Try again in a few moments')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
process.exit(1)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Run the example
|
|
145
|
+
getUtxos()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "New XEC Wallet",
|
|
3
|
+
"created": "2025-08-15T04:08:15.166Z",
|
|
4
|
+
"mnemonic": "bubble leader travel summer unit fault nation error firm mad leisure urge",
|
|
5
|
+
"xecAddress": "ecash:qpg562clu3350dnk3z3lenvxgyexyt7j6vnz4qg606",
|
|
6
|
+
"privateKey": "efa59377256849360662d4af7b39851f658a188b93862889ddf37b45e88c5784",
|
|
7
|
+
"publicKey": "031d85d1053f528a80b3d44fbef5ba081a4f6a9ca65982565cbfa92165decf8319",
|
|
8
|
+
"hdPath": "m/44'/899'/0'/0/0",
|
|
9
|
+
"fee": 1.2,
|
|
10
|
+
"enableDonations": false
|
|
11
|
+
}
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Robust Chronik Router with intelligent failover, health monitoring, and connection pooling.
|
|
3
|
+
Based on Bitcoin-ABC chronik-client patterns with enhanced reliability features.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { ChronikClient } = require('chronik-client')
|
|
7
|
+
|
|
8
|
+
// Connection error types for intelligent error handling
|
|
9
|
+
const ConnectionErrorType = {
|
|
10
|
+
NETWORK_TIMEOUT: 'network_timeout',
|
|
11
|
+
SERVER_UNAVAILABLE: 'server_unavailable',
|
|
12
|
+
SERVER_INDEXING: 'server_indexing',
|
|
13
|
+
PROTOCOL_ERROR: 'protocol_error',
|
|
14
|
+
RATE_LIMITED: 'rate_limited',
|
|
15
|
+
CONNECTION_REFUSED: 'connection_refused'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Connection strategies available
|
|
19
|
+
const RobustConnectionStrategy = {
|
|
20
|
+
CLOSEST_FIRST: 'ClosestFirst',
|
|
21
|
+
ROUND_ROBIN: 'RoundRobin',
|
|
22
|
+
PRIORITY_BASED: 'PriorityBased',
|
|
23
|
+
LOAD_BALANCED: 'LoadBalanced'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class EndpointHealthMonitor {
|
|
27
|
+
constructor (config = {}) {
|
|
28
|
+
this.healthCheckInterval = config.healthCheckInterval || 30000 // 30 seconds
|
|
29
|
+
this.maxLatencyHistory = config.maxLatencyHistory || 10
|
|
30
|
+
this.healthCheckTimeout = config.healthCheckTimeout || 5000
|
|
31
|
+
|
|
32
|
+
// Health tracking data
|
|
33
|
+
this.healthScores = new Map()
|
|
34
|
+
this.latencyHistory = new Map()
|
|
35
|
+
this.failureCount = new Map()
|
|
36
|
+
this.lastHealthCheck = new Map()
|
|
37
|
+
this.isMonitoring = false
|
|
38
|
+
this.healthCheckTimer = null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async startMonitoring (endpoints) {
|
|
42
|
+
if (this.isMonitoring) return
|
|
43
|
+
|
|
44
|
+
this.isMonitoring = true
|
|
45
|
+
|
|
46
|
+
// Initialize health data for all endpoints
|
|
47
|
+
for (const endpoint of endpoints) {
|
|
48
|
+
this.initializeEndpointHealth(endpoint.url)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Start periodic health checks
|
|
52
|
+
this.healthCheckTimer = setInterval(() => {
|
|
53
|
+
this._performHealthChecks(endpoints)
|
|
54
|
+
}, this.healthCheckInterval)
|
|
55
|
+
|
|
56
|
+
// Perform initial health check
|
|
57
|
+
await this._performHealthChecks(endpoints)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
stopMonitoring () {
|
|
61
|
+
this.isMonitoring = false
|
|
62
|
+
if (this.healthCheckTimer) {
|
|
63
|
+
clearInterval(this.healthCheckTimer)
|
|
64
|
+
this.healthCheckTimer = null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
initializeEndpointHealth (url) {
|
|
69
|
+
this.healthScores.set(url, 100) // Start with perfect health
|
|
70
|
+
this.latencyHistory.set(url, [])
|
|
71
|
+
this.failureCount.set(url, 0)
|
|
72
|
+
this.lastHealthCheck.set(url, 0)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async _performHealthChecks (endpoints) {
|
|
76
|
+
const healthCheckPromises = endpoints.map(endpoint =>
|
|
77
|
+
this._checkSingleEndpoint(endpoint).catch(err => {
|
|
78
|
+
console.warn(`Health check failed for ${endpoint.url}:`, err.message)
|
|
79
|
+
return false
|
|
80
|
+
})
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
await Promise.allSettled(healthCheckPromises)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async _checkSingleEndpoint (endpoint) {
|
|
87
|
+
const startTime = Date.now()
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Use chronik's blockchain info as a lightweight health check
|
|
91
|
+
const chronik = new ChronikClient(endpoint.url)
|
|
92
|
+
await Promise.race([
|
|
93
|
+
chronik.blockchainInfo(),
|
|
94
|
+
new Promise((resolve, reject) =>
|
|
95
|
+
setTimeout(() => reject(new Error('Health check timeout')), this.healthCheckTimeout)
|
|
96
|
+
)
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
const latency = Date.now() - startTime
|
|
100
|
+
this._recordSuccess(endpoint.url, latency)
|
|
101
|
+
return true
|
|
102
|
+
} catch (err) {
|
|
103
|
+
this._recordFailure(endpoint.url)
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
_recordSuccess (url, latency) {
|
|
109
|
+
// Update latency history
|
|
110
|
+
const history = this.latencyHistory.get(url) || []
|
|
111
|
+
history.push(latency)
|
|
112
|
+
if (history.length > this.maxLatencyHistory) {
|
|
113
|
+
history.shift()
|
|
114
|
+
}
|
|
115
|
+
this.latencyHistory.set(url, history)
|
|
116
|
+
|
|
117
|
+
// Reset failure count on success
|
|
118
|
+
this.failureCount.set(url, 0)
|
|
119
|
+
|
|
120
|
+
// Improve health score
|
|
121
|
+
const currentScore = this.healthScores.get(url) || 0
|
|
122
|
+
const newScore = Math.min(100, currentScore + 10)
|
|
123
|
+
this.healthScores.set(url, newScore)
|
|
124
|
+
|
|
125
|
+
this.lastHealthCheck.set(url, Date.now())
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_recordFailure (url) {
|
|
129
|
+
const failures = (this.failureCount.get(url) || 0) + 1
|
|
130
|
+
this.failureCount.set(url, failures)
|
|
131
|
+
|
|
132
|
+
// Reduce health score based on consecutive failures
|
|
133
|
+
const currentScore = this.healthScores.get(url) || 100
|
|
134
|
+
const penalty = Math.min(30, failures * 10)
|
|
135
|
+
const newScore = Math.max(0, currentScore - penalty)
|
|
136
|
+
this.healthScores.set(url, newScore)
|
|
137
|
+
|
|
138
|
+
this.lastHealthCheck.set(url, Date.now())
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getHealthScore (url) {
|
|
142
|
+
return this.healthScores.get(url) || 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
getAverageLatency (url) {
|
|
146
|
+
const history = this.latencyHistory.get(url) || []
|
|
147
|
+
if (history.length === 0) return Infinity
|
|
148
|
+
|
|
149
|
+
const sum = history.reduce((a, b) => a + b, 0)
|
|
150
|
+
return sum / history.length
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
getHealthiestEndpoints (endpoints) {
|
|
154
|
+
return endpoints
|
|
155
|
+
.filter(endpoint => this.getHealthScore(endpoint.url) > 20) // Only consider reasonably healthy endpoints
|
|
156
|
+
.sort((a, b) => {
|
|
157
|
+
const scoreA = this.getHealthScore(a.url)
|
|
158
|
+
const scoreB = this.getHealthScore(b.url)
|
|
159
|
+
const latencyA = this.getAverageLatency(a.url)
|
|
160
|
+
const latencyB = this.getAverageLatency(b.url)
|
|
161
|
+
|
|
162
|
+
// Prioritize health score, then latency
|
|
163
|
+
if (scoreA !== scoreB) {
|
|
164
|
+
return scoreB - scoreA // Higher score is better
|
|
165
|
+
}
|
|
166
|
+
return latencyA - latencyB // Lower latency is better
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
isEndpointHealthy (url, minScore = 30) {
|
|
171
|
+
const score = this.getHealthScore(url)
|
|
172
|
+
const failures = this.failureCount.get(url) || 0
|
|
173
|
+
return score >= minScore && failures < 3
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
class ConnectionErrorHandler {
|
|
178
|
+
constructor () {
|
|
179
|
+
this.maxRetries = 3
|
|
180
|
+
this.baseRetryDelay = 1000 // 1 second
|
|
181
|
+
this.maxRetryDelay = 30000 // 30 seconds
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
classifyError (error) {
|
|
185
|
+
const errorMsg = error.message?.toLowerCase() || ''
|
|
186
|
+
const errorCode = error.code?.toLowerCase() || ''
|
|
187
|
+
|
|
188
|
+
// Network-level errors
|
|
189
|
+
if (errorCode === 'econnrefused' || errorMsg.includes('connection refused')) {
|
|
190
|
+
return ConnectionErrorType.CONNECTION_REFUSED
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (errorCode === 'etimedout' || errorMsg.includes('timeout')) {
|
|
194
|
+
return ConnectionErrorType.NETWORK_TIMEOUT
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Server-level errors
|
|
198
|
+
if (errorMsg.includes('indexing') || errorMsg.includes('error state')) {
|
|
199
|
+
return ConnectionErrorType.SERVER_INDEXING
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (errorMsg.includes('rate limit') || errorMsg.includes('too many requests')) {
|
|
203
|
+
return ConnectionErrorType.RATE_LIMITED
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (errorMsg.includes('server unavailable') || errorMsg.includes('503')) {
|
|
207
|
+
return ConnectionErrorType.SERVER_UNAVAILABLE
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Default to protocol error for chronik-specific errors
|
|
211
|
+
return ConnectionErrorType.PROTOCOL_ERROR
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
shouldRetry (errorType, attemptNumber) {
|
|
215
|
+
if (attemptNumber >= this.maxRetries) return false
|
|
216
|
+
|
|
217
|
+
switch (errorType) {
|
|
218
|
+
case ConnectionErrorType.NETWORK_TIMEOUT:
|
|
219
|
+
case ConnectionErrorType.CONNECTION_REFUSED:
|
|
220
|
+
case ConnectionErrorType.SERVER_UNAVAILABLE:
|
|
221
|
+
return true
|
|
222
|
+
case ConnectionErrorType.SERVER_INDEXING:
|
|
223
|
+
return attemptNumber < 2 // Only retry once for indexing servers
|
|
224
|
+
case ConnectionErrorType.RATE_LIMITED:
|
|
225
|
+
return true
|
|
226
|
+
case ConnectionErrorType.PROTOCOL_ERROR:
|
|
227
|
+
return false // Don't retry protocol errors
|
|
228
|
+
default:
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
getRetryDelay (attemptNumber, errorType) {
|
|
234
|
+
const baseDelay = this.baseRetryDelay
|
|
235
|
+
|
|
236
|
+
// Adjust base delay based on error type
|
|
237
|
+
let multiplier = 1
|
|
238
|
+
switch (errorType) {
|
|
239
|
+
case ConnectionErrorType.RATE_LIMITED:
|
|
240
|
+
multiplier = 3 // Longer delay for rate limiting
|
|
241
|
+
break
|
|
242
|
+
case ConnectionErrorType.SERVER_INDEXING:
|
|
243
|
+
multiplier = 5 // Much longer delay for indexing servers
|
|
244
|
+
break
|
|
245
|
+
case ConnectionErrorType.NETWORK_TIMEOUT:
|
|
246
|
+
multiplier = 2
|
|
247
|
+
break
|
|
248
|
+
default:
|
|
249
|
+
multiplier = 1
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Exponential backoff with jitter
|
|
253
|
+
const exponentialDelay = baseDelay * multiplier * Math.pow(2, attemptNumber - 1)
|
|
254
|
+
const jitter = Math.random() * 0.1 * exponentialDelay // 10% jitter
|
|
255
|
+
const finalDelay = Math.min(exponentialDelay + jitter, this.maxRetryDelay)
|
|
256
|
+
|
|
257
|
+
return Math.floor(finalDelay)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
class RobustChronikRouter {
|
|
262
|
+
constructor (config = {}) {
|
|
263
|
+
this.strategy = config.strategy || RobustConnectionStrategy.CLOSEST_FIRST
|
|
264
|
+
this.endpoints = []
|
|
265
|
+
this.currentEndpointIndex = 0
|
|
266
|
+
this.healthMonitor = new EndpointHealthMonitor(config.healthMonitor || {})
|
|
267
|
+
this.errorHandler = new ConnectionErrorHandler()
|
|
268
|
+
this.connectionPool = new Map()
|
|
269
|
+
this.maxConnectionsPerEndpoint = config.maxConnectionsPerEndpoint || 3
|
|
270
|
+
this.connectionTimeout = config.connectionTimeout || 10000
|
|
271
|
+
this.isInitialized = false
|
|
272
|
+
|
|
273
|
+
// Statistics
|
|
274
|
+
this.stats = {
|
|
275
|
+
requestCount: 0,
|
|
276
|
+
failoverCount: 0,
|
|
277
|
+
totalLatency: 0,
|
|
278
|
+
errorsByType: new Map()
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async initialize (urls, strategyOptions = {}) {
|
|
283
|
+
try {
|
|
284
|
+
if (this.isInitialized) {
|
|
285
|
+
await this.cleanup()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Validate and prepare endpoints
|
|
289
|
+
this.endpoints = this._prepareEndpoints(urls)
|
|
290
|
+
|
|
291
|
+
// Initialize health monitoring
|
|
292
|
+
await this.healthMonitor.startMonitoring(this.endpoints)
|
|
293
|
+
|
|
294
|
+
// Try to establish initial connection using chosen strategy
|
|
295
|
+
await this._initializeWithStrategy(strategyOptions)
|
|
296
|
+
|
|
297
|
+
this.isInitialized = true
|
|
298
|
+
console.log(`RobustChronikRouter initialized with ${this.endpoints.length} endpoints using ${this.strategy} strategy`)
|
|
299
|
+
} catch (err) {
|
|
300
|
+
throw new Error(`Router initialization failed: ${err.message}`)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
_prepareEndpoints (urls) {
|
|
305
|
+
const urlArray = Array.isArray(urls) ? urls : [urls]
|
|
306
|
+
|
|
307
|
+
if (urlArray.length === 0) {
|
|
308
|
+
throw new Error('At least one endpoint URL is required')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return urlArray.map((url, index) => ({
|
|
312
|
+
url: this._normalizeUrl(url),
|
|
313
|
+
priority: index, // Lower index = higher priority
|
|
314
|
+
isActive: true,
|
|
315
|
+
createdAt: Date.now()
|
|
316
|
+
}))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
_normalizeUrl (url) {
|
|
320
|
+
if (!url || typeof url !== 'string') {
|
|
321
|
+
throw new Error('URL must be a non-empty string')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Remove trailing slash
|
|
325
|
+
const cleanUrl = url.replace(/\/$/, '')
|
|
326
|
+
|
|
327
|
+
// Validate URL format
|
|
328
|
+
if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) {
|
|
329
|
+
throw new Error(`Invalid URL format: ${url}`)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return cleanUrl
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async _initializeWithStrategy (options) {
|
|
336
|
+
switch (this.strategy) {
|
|
337
|
+
case RobustConnectionStrategy.CLOSEST_FIRST:
|
|
338
|
+
await this._initializeClosestFirst()
|
|
339
|
+
break
|
|
340
|
+
case RobustConnectionStrategy.PRIORITY_BASED:
|
|
341
|
+
this._initializePriorityBased()
|
|
342
|
+
break
|
|
343
|
+
case RobustConnectionStrategy.ROUND_ROBIN:
|
|
344
|
+
this._initializeRoundRobin()
|
|
345
|
+
break
|
|
346
|
+
default:
|
|
347
|
+
this._initializePriorityBased() // Default fallback
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async _initializeClosestFirst () {
|
|
352
|
+
// Wait for initial health checks to complete
|
|
353
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
354
|
+
|
|
355
|
+
// Sort endpoints by health and latency
|
|
356
|
+
const healthyEndpoints = this.healthMonitor.getHealthiestEndpoints(this.endpoints)
|
|
357
|
+
|
|
358
|
+
if (healthyEndpoints.length > 0) {
|
|
359
|
+
this.currentEndpointIndex = this.endpoints.indexOf(healthyEndpoints[0])
|
|
360
|
+
} else {
|
|
361
|
+
console.warn('No healthy endpoints found, using first endpoint')
|
|
362
|
+
this.currentEndpointIndex = 0
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
_initializePriorityBased () {
|
|
367
|
+
// Sort by priority (lower number = higher priority)
|
|
368
|
+
this.endpoints.sort((a, b) => a.priority - b.priority)
|
|
369
|
+
this.currentEndpointIndex = 0
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
_initializeRoundRobin () {
|
|
373
|
+
this.currentEndpointIndex = 0
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async executeWithFailover (operation, maxAttempts = null) {
|
|
377
|
+
const attempts = maxAttempts || this.endpoints.length * this.errorHandler.maxRetries
|
|
378
|
+
let lastError = null
|
|
379
|
+
|
|
380
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
381
|
+
const endpoint = this._getCurrentEndpoint()
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
this.stats.requestCount++
|
|
385
|
+
const startTime = Date.now()
|
|
386
|
+
|
|
387
|
+
const result = await this._executeWithTimeout(operation, endpoint)
|
|
388
|
+
|
|
389
|
+
// Record success metrics
|
|
390
|
+
this.stats.totalLatency += Date.now() - startTime
|
|
391
|
+
|
|
392
|
+
return result
|
|
393
|
+
} catch (err) {
|
|
394
|
+
lastError = err
|
|
395
|
+
const errorType = this.errorHandler.classifyError(err)
|
|
396
|
+
|
|
397
|
+
// Update error statistics
|
|
398
|
+
const errorCount = this.stats.errorsByType.get(errorType) || 0
|
|
399
|
+
this.stats.errorsByType.set(errorType, errorCount + 1)
|
|
400
|
+
|
|
401
|
+
// Check if we should retry
|
|
402
|
+
if (!this.errorHandler.shouldRetry(errorType, attempt) || attempt === attempts) {
|
|
403
|
+
throw err
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Move to next endpoint
|
|
407
|
+
await this._rotateToNextHealthyEndpoint()
|
|
408
|
+
this.stats.failoverCount++
|
|
409
|
+
|
|
410
|
+
// Wait before retry
|
|
411
|
+
const retryDelay = this.errorHandler.getRetryDelay(attempt, errorType)
|
|
412
|
+
console.warn(`Endpoint ${endpoint.url} failed (${errorType}), retrying in ${retryDelay}ms...`)
|
|
413
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay))
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
throw lastError || new Error('All failover attempts exhausted')
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async _executeWithTimeout (operation, endpoint) {
|
|
421
|
+
const timeoutPromise = new Promise((resolve, reject) =>
|
|
422
|
+
setTimeout(() => reject(new Error('Operation timeout')), this.connectionTimeout)
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return Promise.race([
|
|
426
|
+
operation(endpoint),
|
|
427
|
+
timeoutPromise
|
|
428
|
+
])
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
_getCurrentEndpoint () {
|
|
432
|
+
if (this.currentEndpointIndex >= this.endpoints.length) {
|
|
433
|
+
this.currentEndpointIndex = 0
|
|
434
|
+
}
|
|
435
|
+
return this.endpoints[this.currentEndpointIndex]
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async _rotateToNextHealthyEndpoint () {
|
|
439
|
+
const startIndex = this.currentEndpointIndex
|
|
440
|
+
let attempts = 0
|
|
441
|
+
|
|
442
|
+
do {
|
|
443
|
+
this.currentEndpointIndex = (this.currentEndpointIndex + 1) % this.endpoints.length
|
|
444
|
+
attempts++
|
|
445
|
+
|
|
446
|
+
const endpoint = this.endpoints[this.currentEndpointIndex]
|
|
447
|
+
|
|
448
|
+
// Check if this endpoint is healthy
|
|
449
|
+
if (this.healthMonitor.isEndpointHealthy(endpoint.url)) {
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
} while (this.currentEndpointIndex !== startIndex && attempts < this.endpoints.length)
|
|
453
|
+
|
|
454
|
+
// If no healthy endpoints found, use current anyway (last resort)
|
|
455
|
+
console.warn('No healthy endpoints available, continuing with current endpoint')
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async getConnection () {
|
|
459
|
+
if (!this.isInitialized) {
|
|
460
|
+
throw new Error('Router not initialized. Call initialize() first.')
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const endpoint = this._getCurrentEndpoint()
|
|
464
|
+
|
|
465
|
+
// Try to reuse existing connection from pool
|
|
466
|
+
const poolKey = endpoint.url
|
|
467
|
+
if (this.connectionPool.has(poolKey)) {
|
|
468
|
+
return this.connectionPool.get(poolKey)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Create new connection
|
|
472
|
+
const chronik = new ChronikClient(endpoint.url)
|
|
473
|
+
this.connectionPool.set(poolKey, chronik)
|
|
474
|
+
|
|
475
|
+
return chronik
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
getStats () {
|
|
479
|
+
const avgLatency = this.stats.requestCount > 0
|
|
480
|
+
? this.stats.totalLatency / this.stats.requestCount
|
|
481
|
+
: 0
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
...this.stats,
|
|
485
|
+
averageLatency: Math.round(avgLatency),
|
|
486
|
+
currentEndpoint: this._getCurrentEndpoint().url,
|
|
487
|
+
healthyEndpoints: this.endpoints.filter(ep =>
|
|
488
|
+
this.healthMonitor.isEndpointHealthy(ep.url)
|
|
489
|
+
).length,
|
|
490
|
+
totalEndpoints: this.endpoints.length
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async cleanup () {
|
|
495
|
+
this.healthMonitor.stopMonitoring()
|
|
496
|
+
this.connectionPool.clear()
|
|
497
|
+
this.isInitialized = false
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
module.exports = {
|
|
502
|
+
RobustChronikRouter,
|
|
503
|
+
EndpointHealthMonitor,
|
|
504
|
+
ConnectionErrorHandler,
|
|
505
|
+
RobustConnectionStrategy,
|
|
506
|
+
ConnectionErrorType
|
|
507
|
+
}
|