web-app/src/modules/wallet/services/WalletWebSocketService.ts
padreug 15a7540515 Update WebSocket configuration and enhance connection handling in WalletWebSocketService
- Increased reconnect delay from 1 second to 2 seconds and reduced max reconnect attempts from 5 to 3 to minimize server load.
- Improved logging messages for connection status and abnormal closures, providing clearer insights during reconnection attempts.
- Added a ping mechanism to test connection stability and handle network connectivity issues more effectively.

These changes enhance the reliability and performance of the WebSocket service, contributing to a better user experience.
2025-09-18 10:30:33 +02:00

441 lines
No EOL
14 KiB
TypeScript

import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useToast } from '@/core/composables/useToast'
import { eventBus } from '@/core/event-bus'
import type { Ref } from 'vue'
import { ref } from 'vue'
interface WebSocketConfig {
enabled: boolean
reconnectDelay: number
maxReconnectAttempts: number
}
export class WalletWebSocketService extends BaseService {
protected readonly metadata = {
name: 'WalletWebSocketService',
version: '1.0.0',
dependencies: ['PaymentService', 'AuthService', 'WalletService']
}
private ws: WebSocket | null = null
private reconnectTimer: NodeJS.Timeout | null = null
private reconnectAttempts = 0
private config: WebSocketConfig = {
enabled: true,
reconnectDelay: 1000,
maxReconnectAttempts: 5
}
// Service dependencies (auto-injected by BaseService)
protected paymentService: any
protected walletService: any
// WebSocket state
public readonly isConnected: Ref<boolean> = ref(false)
public readonly connectionStatus: Ref<string> = ref('disconnected')
private toast = useToast()
protected async onInitialize(): Promise<void> {
console.log('WalletWebSocketService: Starting initialization...')
// Get services (already injected by BaseService)
this.paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
this.walletService = injectService(SERVICE_TOKENS.WALLET_SERVICE) as any
console.log('WalletWebSocketService: Services injected', {
paymentService: !!this.paymentService,
walletService: !!this.walletService
})
// Load config from app config if available
const appConfig = (window as any).appConfig
if (appConfig?.modules?.wallet?.config?.websocket) {
this.config = { ...this.config, ...appConfig.modules.wallet.config.websocket }
}
console.log('WalletWebSocketService: Config loaded', this.config)
// Only proceed if WebSocket is enabled
if (!this.config.enabled) {
console.log('WalletWebSocketService: WebSocket disabled in config')
return
}
// Register with VisibilityService for connection management
if (this.visibilityService) {
this.visibilityService.registerService(
this.metadata.name,
this.onResume.bind(this),
this.onPause.bind(this)
)
console.log('WalletWebSocketService: Registered with VisibilityService')
} else {
console.warn('WalletWebSocketService: VisibilityService not available')
}
// Listen for authentication events to connect when user logs in
eventBus.on('auth:login', () => {
console.log('WalletWebSocketService: Auth login detected, attempting connection...')
setTimeout(() => {
// Small delay to ensure PaymentService has processed the auth event
this.connectIfNeeded()
}, 500)
})
eventBus.on('auth:logout', () => {
console.log('WalletWebSocketService: Auth logout detected, disconnecting...')
this.disconnect()
})
console.log('WalletWebSocketService: Registered auth event listeners')
// Initial connection attempt (will fail if not authenticated, but that's OK)
console.log('WalletWebSocketService: Attempting initial connection...')
await this.connectIfNeeded()
}
/**
* Connect to LNbits WebSocket if we have wallet credentials
*/
private async connectIfNeeded(): Promise<void> {
console.log('WalletWebSocketService: Checking for wallet credentials...')
const wallet = this.paymentService?.getPreferredWallet?.()
console.log('WalletWebSocketService: Wallet check result', {
hasPaymentService: !!this.paymentService,
hasGetPreferredWallet: !!this.paymentService?.getPreferredWallet,
wallet: wallet ? { id: wallet.id, hasInkey: !!wallet.inkey } : null
})
if (wallet?.inkey) {
console.log('WalletWebSocketService: Connecting with wallet inkey...')
await this.connect(wallet.inkey)
} else {
console.log('WalletWebSocketService: No wallet available for WebSocket connection')
}
}
/**
* Establish WebSocket connection to LNbits
*/
private async connect(walletInkey: string): Promise<void> {
try {
console.log('WalletWebSocketService: Starting connection process...')
// Close existing connection if any
this.disconnect()
// Build WebSocket URL
const baseUrl = import.meta.env.VITE_LNBITS_BASE_URL || import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000'
const wsProtocol = baseUrl.startsWith('https') ? 'wss:' : 'ws:'
const host = baseUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')
const wsUrl = `${wsProtocol}//${host}/api/v1/ws/${walletInkey}`
console.log('WalletWebSocketService: Connection details', {
baseUrl,
wsProtocol,
host,
wsUrl,
walletInkey: walletInkey.substring(0, 8) + '...' // Only show first 8 chars for security
})
this.connectionStatus.value = 'connecting'
// Create WebSocket connection
this.ws = new WebSocket(wsUrl)
// Set up event handlers
this.ws.onopen = this.handleOpen.bind(this)
this.ws.onmessage = this.handleMessage.bind(this)
this.ws.onclose = this.handleClose.bind(this)
this.ws.onerror = this.handleWebSocketError.bind(this)
console.log('WalletWebSocketService: WebSocket created, waiting for connection...')
} catch (error) {
console.error('WalletWebSocketService: Failed to create WebSocket', error)
this.connectionStatus.value = 'error'
this.scheduleReconnect()
}
}
/**
* Handle WebSocket connection opened
*/
private handleOpen(_event: Event): void {
console.log('WalletWebSocketService: Connected successfully')
this.isConnected.value = true
this.connectionStatus.value = 'connected'
this.reconnectAttempts = 0
// Clear any pending reconnect timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
// Send a ping to test connection stability
this.sendPing()
}
/**
* Handle incoming WebSocket messages
*/
private handleMessage(event: MessageEvent): void {
try {
const data = JSON.parse(event.data)
console.log('WalletWebSocketService: Received message', data)
// Handle payment notification first (this updates transactions)
if (data.payment) {
this.handlePaymentNotification(data.payment)
}
// Handle wallet balance update
if (data.wallet_balance !== undefined) {
console.log('WalletWebSocketService: Processing balance update', {
newBalance: data.wallet_balance,
hasPayment: !!data.payment,
paymentAmount: data.payment?.amount
})
let finalBalance = data.wallet_balance
// For outgoing payments, LNbits sends pre-payment balance, so we need to adjust
// For incoming payments, LNbits sends post-payment balance, so use as-is
if (data.payment && data.payment.amount < 0) {
// Outgoing payment - subtract the payment amount from the balance
const paymentSats = Math.abs(data.payment.amount) / 1000
finalBalance = data.wallet_balance - paymentSats
console.log('WalletWebSocketService: Adjusting balance for outgoing payment', {
originalBalance: data.wallet_balance,
paymentSats: paymentSats,
finalBalance: finalBalance
})
} else if (data.payment && data.payment.amount > 0) {
// Incoming payment - use balance as-is (already post-payment)
console.log('WalletWebSocketService: Using balance as-is for incoming payment', {
balance: data.wallet_balance
})
} else {
// No payment in message - use balance as-is
console.log('WalletWebSocketService: Using balance as-is (no payment)', {
balance: data.wallet_balance
})
}
console.log('WalletWebSocketService: Updating balance to', finalBalance, 'sats')
this.updateWalletBalance(finalBalance)
}
} catch (error) {
console.error('WalletWebSocketService: Failed to parse message', error)
}
}
/**
* Update wallet balance via PaymentService
*/
private updateWalletBalance(balanceSats: number): void {
console.log('WalletWebSocketService: Updating balance to', balanceSats, 'sats')
// Convert sats to millisats for internal storage (LNbits WebSocket sends balance in sats)
const balanceMsat = balanceSats * 1000
// Get the wallet ID we're connected to
const wallet = this.paymentService?.getPreferredWallet?.()
const walletId = wallet?.id
// Update balance via PaymentService (which manages wallet state)
if (this.paymentService?.updateWalletBalance) {
this.paymentService.updateWalletBalance(balanceMsat, walletId)
}
}
/**
* Handle incoming payment notification
*/
private handlePaymentNotification(payment: any): void {
console.log('WalletWebSocketService: Payment notification', payment)
// Add transaction to wallet service
if (this.walletService?.addTransaction) {
this.walletService.addTransaction(payment)
}
// Show toast notification for incoming payments
if (payment.amount > 0 && !payment.pending) {
const amountSats = Math.abs(payment.amount / 1000)
if (payment.amount > 0) {
this.toast.success(`Received ${amountSats} sats!`)
} else {
this.toast.info(`Sent ${amountSats} sats`)
}
}
}
/**
* Handle WebSocket connection closed
*/
private handleClose(event: CloseEvent): void {
console.log('WalletWebSocketService: Connection closed', event.code, event.reason)
this.isConnected.value = false
this.connectionStatus.value = 'disconnected'
this.ws = null
// Handle specific close codes
if (event.code === 1006) {
console.warn('WalletWebSocketService: Abnormal closure detected - possible server issue')
// For code 1006, increase delay to avoid overwhelming server
this.scheduleReconnect(true)
} else if (event.code !== 1000) {
// Normal reconnection for other non-normal closures
this.scheduleReconnect(false)
}
// Code 1000 = normal closure, don't reconnect
}
/**
* Handle WebSocket error
*/
private handleWebSocketError(event: Event): void {
console.error('WalletWebSocketService: WebSocket error', event)
this.connectionStatus.value = 'error'
// Additional error details if available
if (this.ws) {
console.error('WalletWebSocketService: WebSocket state:', this.ws.readyState)
console.error('WalletWebSocketService: WebSocket URL:', this.ws.url)
}
// Check if this is a network connectivity issue
if (!navigator.onLine) {
console.log('WalletWebSocketService: Network appears to be offline')
this.connectionStatus.value = 'offline'
}
}
/**
* Schedule a reconnection attempt
*/
private scheduleReconnect(isAbnormalClosure = false): void {
// Don't reconnect if we've exceeded max attempts
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
console.log('WalletWebSocketService: Max reconnection attempts reached - disabling WebSocket')
this.connectionStatus.value = 'failed'
// Show user notification about WebSocket issues
if (this.toast) {
this.toast.info('Real-time balance updates temporarily unavailable', {
description: 'WebSocket connection failed. Balance will update on page refresh.'
})
}
return
}
// Clear any existing reconnect timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
// Calculate delay with exponential backoff
let delay = this.config.reconnectDelay * Math.pow(2, this.reconnectAttempts)
// For abnormal closures (1006), use longer delays to avoid overwhelming server
if (isAbnormalClosure) {
delay = Math.max(delay, 5000) // Minimum 5 second delay for 1006 errors
}
this.reconnectAttempts++
console.log(`WalletWebSocketService: Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts})${isAbnormalClosure ? ' [abnormal closure]' : ''}`)
this.connectionStatus.value = 'reconnecting'
this.reconnectTimer = setTimeout(() => {
this.connectIfNeeded()
}, delay)
}
/**
* Disconnect WebSocket connection
*/
public disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.ws) {
// Close with normal closure code
this.ws.close(1000, 'Client disconnect')
this.ws = null
}
this.isConnected.value = false
this.connectionStatus.value = 'disconnected'
}
/**
* Resume connection when app becomes visible
*/
private async onResume(): Promise<void> {
console.log('WalletWebSocketService: Resuming connection')
// Reconnect if not connected
if (!this.isConnected.value) {
this.reconnectAttempts = 0
await this.connectIfNeeded()
}
}
/**
* Pause connection when app loses visibility
*/
private async onPause(): Promise<void> {
console.log('WalletWebSocketService: Pausing connection')
// Disconnect to save battery
this.disconnect()
}
/**
* Send ping to test connection
*/
private sendPing(): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
// Send a ping frame (most WebSocket implementations support this)
this.ws.ping?.()
} catch (error) {
console.log('WalletWebSocketService: Ping not supported, connection seems stable')
}
}
}
/**
* Manual reconnection method
*/
public async reconnect(): Promise<void> {
console.log('WalletWebSocketService: Manual reconnection triggered')
this.reconnectAttempts = 0
// Disconnect current connection if any
this.disconnect()
// Wait a moment before reconnecting
setTimeout(() => {
this.connectIfNeeded()
}, 1000)
}
/**
* Clean up on service destruction
*/
public async cleanup(): Promise<void> {
this.disconnect()
}
}
export default WalletWebSocketService