- Update RelayHubStatus.vue to display both local and global subscription counts, improving user visibility into active subscriptions. - Add totalSubscriptionCount computed property in useRelayHub.ts to track the total number of subscriptions. - Implement totalSubscriptionCount getter in relayHub.ts to return the size of the subscriptions map. - Include debug logging in relayHub.ts for connected relay counts and statuses to aid in troubleshooting.
495 lines
14 KiB
TypeScript
495 lines
14 KiB
TypeScript
import { SimplePool, type Filter, type Event, type Relay } from 'nostr-tools'
|
|
|
|
// Simple EventEmitter implementation for browser compatibility
|
|
class EventEmitter {
|
|
private events: { [key: string]: Function[] } = {}
|
|
|
|
on(event: string, listener: Function): void {
|
|
if (!this.events[event]) {
|
|
this.events[event] = []
|
|
}
|
|
this.events[event].push(listener)
|
|
}
|
|
|
|
off(event: string, listener: Function): void {
|
|
if (!this.events[event]) return
|
|
const index = this.events[event].indexOf(listener)
|
|
if (index > -1) {
|
|
this.events[event].splice(index, 1)
|
|
}
|
|
}
|
|
|
|
emit(event: string, ...args: any[]): void {
|
|
if (!this.events[event]) return
|
|
this.events[event].forEach(listener => listener(...args))
|
|
}
|
|
|
|
removeAllListeners(event?: string): void {
|
|
if (event) {
|
|
delete this.events[event]
|
|
} else {
|
|
this.events = {}
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface RelayConfig {
|
|
url: string
|
|
read: boolean
|
|
write: boolean
|
|
priority?: number // Lower number = higher priority
|
|
}
|
|
|
|
export interface SubscriptionConfig {
|
|
id: string
|
|
filters: Filter[]
|
|
relays?: string[] // If not specified, uses all connected relays
|
|
onEvent?: (event: Event) => void
|
|
onEose?: () => void
|
|
onClose?: () => void
|
|
}
|
|
|
|
export interface RelayStatus {
|
|
url: string
|
|
connected: boolean
|
|
lastSeen: number
|
|
error?: string
|
|
latency?: number
|
|
}
|
|
|
|
export class RelayHub extends EventEmitter {
|
|
private pool: SimplePool
|
|
private relayConfigs: Map<string, RelayConfig> = new Map()
|
|
private connectedRelays: Map<string, Relay> = new Map()
|
|
private subscriptions: Map<string, any> = new Map()
|
|
public isInitialized = false
|
|
private reconnectInterval?: NodeJS.Timeout
|
|
private healthCheckInterval?: NodeJS.Timeout
|
|
private mobileVisibilityHandler?: () => void
|
|
|
|
// Connection state
|
|
private _isConnected = false
|
|
private _connectionAttempts = 0
|
|
private readonly maxReconnectAttempts = 5
|
|
private readonly reconnectDelay = 5000 // 5 seconds
|
|
private readonly healthCheckIntervalMs = 30000 // 30 seconds
|
|
|
|
constructor() {
|
|
super()
|
|
this.pool = new SimplePool()
|
|
this.setupMobileVisibilityHandling()
|
|
}
|
|
|
|
get isConnected(): boolean {
|
|
return this._isConnected
|
|
}
|
|
|
|
get connectedRelayCount(): number {
|
|
// Return the actual size of connectedRelays map
|
|
const count = this.connectedRelays.size
|
|
console.log('🔍 connectedRelayCount getter called, returning:', count)
|
|
return count
|
|
}
|
|
|
|
get totalRelayCount(): number {
|
|
return this.relayConfigs.size
|
|
}
|
|
|
|
get totalSubscriptionCount(): number {
|
|
return this.subscriptions.size
|
|
}
|
|
|
|
get relayStatuses(): RelayStatus[] {
|
|
console.log('🔍 relayStatuses getter called')
|
|
console.log('🔍 relayConfigs size:', this.relayConfigs.size)
|
|
console.log('🔍 connectedRelays size:', this.connectedRelays.size)
|
|
console.log('🔍 connectedRelays keys:', Array.from(this.connectedRelays.keys()))
|
|
|
|
return Array.from(this.relayConfigs.values()).map(config => {
|
|
const relay = this.connectedRelays.get(config.url)
|
|
const isConnected = !!relay
|
|
console.log(`🔍 Relay ${config.url}: connected=${isConnected}, relay=${relay ? 'exists' : 'null'}`)
|
|
|
|
return {
|
|
url: config.url,
|
|
connected: isConnected,
|
|
lastSeen: relay ? Date.now() : 0,
|
|
error: relay ? undefined : 'Not connected',
|
|
latency: relay ? 0 : undefined // TODO: Implement actual latency measurement
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Initialize the relay hub with relay configurations
|
|
*/
|
|
async initialize(relayUrls: string[]): Promise<void> {
|
|
if (this.isInitialized) {
|
|
console.warn('RelayHub already initialized')
|
|
return
|
|
}
|
|
|
|
// Convert URLs to relay configs
|
|
this.relayConfigs.clear()
|
|
relayUrls.forEach((url, index) => {
|
|
this.relayConfigs.set(url, {
|
|
url,
|
|
read: true,
|
|
write: true,
|
|
priority: index
|
|
})
|
|
})
|
|
|
|
// Start connection management
|
|
await this.connect()
|
|
this.startHealthCheck()
|
|
this.isInitialized = true
|
|
|
|
console.log(`RelayHub initialized with ${relayUrls.length} relays`)
|
|
}
|
|
|
|
/**
|
|
* Connect to all configured relays
|
|
*/
|
|
async connect(): Promise<void> {
|
|
if (this.relayConfigs.size === 0) {
|
|
throw new Error('No relay configurations found. Call initialize() first.')
|
|
}
|
|
|
|
try {
|
|
this._connectionAttempts++
|
|
console.log(`Connecting to ${this.relayConfigs.size} relays (attempt ${this._connectionAttempts})`)
|
|
|
|
// Connect to relays in priority order
|
|
const sortedRelays = Array.from(this.relayConfigs.values())
|
|
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
|
|
|
|
const connectionPromises = sortedRelays.map(async (config) => {
|
|
try {
|
|
const relay = await this.pool.ensureRelay(config.url)
|
|
this.connectedRelays.set(config.url, relay)
|
|
console.log(`Connected to relay: ${config.url}`)
|
|
return { url: config.url, success: true }
|
|
} catch (error) {
|
|
console.error(`Failed to connect to relay ${config.url}:`, error)
|
|
return { url: config.url, success: false, error }
|
|
}
|
|
})
|
|
|
|
const results = await Promise.allSettled(connectionPromises)
|
|
const successfulConnections = results.filter(
|
|
result => result.status === 'fulfilled' && result.value.success
|
|
)
|
|
|
|
if (successfulConnections.length > 0) {
|
|
this._isConnected = true
|
|
this._connectionAttempts = 0
|
|
this.emit('connected', successfulConnections.length)
|
|
console.log(`Successfully connected to ${successfulConnections.length}/${this.relayConfigs.size} relays`)
|
|
} else {
|
|
throw new Error('Failed to connect to any relay')
|
|
}
|
|
} catch (error) {
|
|
this._isConnected = false
|
|
this.emit('connectionError', error)
|
|
console.error('Connection failed:', error)
|
|
|
|
// Schedule reconnection if we haven't exceeded max attempts
|
|
if (this._connectionAttempts < this.maxReconnectAttempts) {
|
|
this.scheduleReconnect()
|
|
} else {
|
|
this.emit('maxReconnectAttemptsReached')
|
|
console.error('Max reconnection attempts reached')
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disconnect from all relays
|
|
*/
|
|
disconnect(): void {
|
|
console.log('Disconnecting from all relays')
|
|
|
|
// Clear intervals
|
|
if (this.reconnectInterval) {
|
|
clearTimeout(this.reconnectInterval)
|
|
this.reconnectInterval = undefined
|
|
}
|
|
|
|
if (this.healthCheckInterval) {
|
|
clearInterval(this.healthCheckInterval)
|
|
this.healthCheckInterval = undefined
|
|
}
|
|
|
|
// Close all subscriptions
|
|
this.subscriptions.forEach(sub => sub.close())
|
|
this.subscriptions.clear()
|
|
|
|
// Close all relay connections
|
|
this.pool.close(Array.from(this.relayConfigs.keys()))
|
|
this.connectedRelays.clear()
|
|
|
|
this._isConnected = false
|
|
this.emit('disconnected')
|
|
}
|
|
|
|
/**
|
|
* Subscribe to events from relays
|
|
*/
|
|
subscribe(config: SubscriptionConfig): () => void {
|
|
if (!this.isInitialized) {
|
|
throw new Error('RelayHub not initialized. Call initialize() first.')
|
|
}
|
|
|
|
if (!this._isConnected) {
|
|
throw new Error('Not connected to any relays')
|
|
}
|
|
|
|
// Determine which relays to use
|
|
const targetRelays = config.relays || Array.from(this.connectedRelays.keys())
|
|
const availableRelays = targetRelays.filter(url => this.connectedRelays.has(url))
|
|
|
|
if (availableRelays.length === 0) {
|
|
throw new Error('No available relays for subscription')
|
|
}
|
|
|
|
console.log(`Creating subscription ${config.id} on ${availableRelays.length} relays`)
|
|
|
|
// Create subscription using the pool
|
|
const subscription = this.pool.subscribeMany(availableRelays, config.filters, {
|
|
onevent: (event: Event) => {
|
|
config.onEvent?.(event)
|
|
this.emit('event', { subscriptionId: config.id, event, relay: 'unknown' })
|
|
},
|
|
oneose: () => {
|
|
config.onEose?.()
|
|
this.emit('eose', { subscriptionId: config.id })
|
|
}
|
|
})
|
|
|
|
// Store subscription for cleanup
|
|
this.subscriptions.set(config.id, subscription)
|
|
|
|
// Return unsubscribe function
|
|
return () => {
|
|
this.unsubscribe(config.id)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe from a specific subscription
|
|
*/
|
|
unsubscribe(subscriptionId: string): void {
|
|
const subscription = this.subscriptions.get(subscriptionId)
|
|
if (subscription) {
|
|
subscription.close()
|
|
this.subscriptions.delete(subscriptionId)
|
|
console.log(`Unsubscribed from ${subscriptionId}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Publish an event to all connected relays
|
|
*/
|
|
async publishEvent(event: Event): Promise<{ success: number; total: number }> {
|
|
if (!this._isConnected) {
|
|
throw new Error('Not connected to any relays')
|
|
}
|
|
|
|
const relayUrls = Array.from(this.connectedRelays.keys())
|
|
const results = await Promise.allSettled(
|
|
relayUrls.map(relay => this.pool.publish([relay], event))
|
|
)
|
|
|
|
const successful = results.filter(result => result.status === 'fulfilled').length
|
|
const total = results.length
|
|
|
|
console.log(`Published event ${event.id} to ${successful}/${total} relays`)
|
|
this.emit('eventPublished', { eventId: event.id, success: successful, total })
|
|
|
|
return { success: successful, total }
|
|
}
|
|
|
|
/**
|
|
* Query events from relays (one-time fetch)
|
|
*/
|
|
async queryEvents(filters: Filter[], relays?: string[]): Promise<Event[]> {
|
|
if (!this._isConnected) {
|
|
throw new Error('Not connected to any relays')
|
|
}
|
|
|
|
const targetRelays = relays || Array.from(this.connectedRelays.keys())
|
|
const availableRelays = targetRelays.filter(url => this.connectedRelays.has(url))
|
|
|
|
if (availableRelays.length === 0) {
|
|
throw new Error('No available relays for query')
|
|
}
|
|
|
|
try {
|
|
// Query each filter separately and combine results
|
|
const allEvents: Event[] = []
|
|
for (const filter of filters) {
|
|
const events = await this.pool.querySync(availableRelays, filter)
|
|
allEvents.push(...events)
|
|
}
|
|
console.log(`Queried ${allEvents.length} events from ${availableRelays.length} relays`)
|
|
return allEvents
|
|
} catch (error) {
|
|
console.error('Query failed:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a specific relay instance
|
|
*/
|
|
getRelay(url: string): Relay | undefined {
|
|
return this.connectedRelays.get(url)
|
|
}
|
|
|
|
/**
|
|
* Check if a specific relay is connected
|
|
*/
|
|
isRelayConnected(url: string): boolean {
|
|
return this.connectedRelays.has(url)
|
|
}
|
|
|
|
/**
|
|
* Force reconnection to all relays
|
|
*/
|
|
async reconnect(): Promise<void> {
|
|
console.log('Forcing reconnection to all relays')
|
|
this.disconnect()
|
|
await this.connect()
|
|
}
|
|
|
|
/**
|
|
* Schedule automatic reconnection
|
|
*/
|
|
private scheduleReconnect(): void {
|
|
if (this.reconnectInterval) {
|
|
clearTimeout(this.reconnectInterval)
|
|
}
|
|
|
|
this.reconnectInterval = setTimeout(async () => {
|
|
console.log('Attempting automatic reconnection...')
|
|
await this.connect()
|
|
}, this.reconnectDelay)
|
|
}
|
|
|
|
/**
|
|
* Start health check monitoring
|
|
*/
|
|
private startHealthCheck(): void {
|
|
if (this.healthCheckInterval) {
|
|
clearInterval(this.healthCheckInterval)
|
|
}
|
|
|
|
this.healthCheckInterval = setInterval(() => {
|
|
this.performHealthCheck()
|
|
}, this.healthCheckIntervalMs)
|
|
}
|
|
|
|
/**
|
|
* Perform health check on all relays
|
|
*/
|
|
private async performHealthCheck(): Promise<void> {
|
|
if (!this._isConnected) return
|
|
|
|
console.log('Performing relay health check...')
|
|
const disconnectedRelays: string[] = []
|
|
|
|
// Check each relay connection
|
|
for (const [url] of this.connectedRelays) {
|
|
try {
|
|
// Try to send a ping or check connection status
|
|
// For now, we'll just check if the relay is still in our connected relays map
|
|
if (!this.connectedRelays.has(url)) {
|
|
disconnectedRelays.push(url)
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Health check failed for relay ${url}:`, error)
|
|
disconnectedRelays.push(url)
|
|
}
|
|
}
|
|
|
|
// Remove disconnected relays
|
|
disconnectedRelays.forEach(url => {
|
|
this.connectedRelays.delete(url)
|
|
console.log(`Removed disconnected relay: ${url}`)
|
|
})
|
|
|
|
// Update connection status
|
|
if (this.connectedRelays.size === 0) {
|
|
this._isConnected = false
|
|
this.emit('allRelaysDisconnected')
|
|
console.warn('All relays disconnected, attempting reconnection...')
|
|
await this.connect()
|
|
} else if (this.connectedRelays.size < this.relayConfigs.size) {
|
|
this.emit('partialDisconnection', {
|
|
connected: this.connectedRelays.size,
|
|
total: this.relayConfigs.size
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup mobile visibility handling for better WebSocket management
|
|
*/
|
|
private setupMobileVisibilityHandling(): void {
|
|
// Handle page visibility changes (mobile app backgrounding)
|
|
if (typeof document !== 'undefined') {
|
|
this.mobileVisibilityHandler = () => {
|
|
if (document.hidden) {
|
|
console.log('Page hidden, maintaining WebSocket connections')
|
|
// Keep connections alive but reduce activity
|
|
} else {
|
|
console.log('Page visible, resuming normal WebSocket activity')
|
|
// Resume normal activity and check connections
|
|
this.performHealthCheck()
|
|
}
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', this.mobileVisibilityHandler)
|
|
}
|
|
|
|
// Handle online/offline events
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('online', () => {
|
|
console.log('Network online, checking relay connections...')
|
|
this.performHealthCheck()
|
|
})
|
|
|
|
window.addEventListener('offline', () => {
|
|
console.log('Network offline, marking as disconnected...')
|
|
this._isConnected = false
|
|
this.emit('networkOffline')
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup resources
|
|
*/
|
|
destroy(): void {
|
|
console.log('Destroying RelayHub...')
|
|
|
|
// Remove event listeners
|
|
if (this.mobileVisibilityHandler && typeof document !== 'undefined') {
|
|
document.removeEventListener('visibilitychange', this.mobileVisibilityHandler)
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.removeEventListener('online', () => {})
|
|
window.removeEventListener('offline', () => {})
|
|
}
|
|
|
|
// Disconnect and cleanup
|
|
this.disconnect()
|
|
this.removeAllListeners()
|
|
this.isInitialized = false
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const relayHub = new RelayHub()
|