- Add detailed examples for WebSocket connection recovery and chat message synchronization in the VisibilityService documentation. - Refactor ChatService to register with VisibilityService, enabling automatic handling of app visibility changes and missed message synchronization. - Implement connection recovery logic in NostrclientHub and ChatService to ensure seamless user experience during app backgrounding. - Update base module to ensure proper initialization of services with VisibilityService dependencies, enhancing overall connection management.
442 lines
11 KiB
TypeScript
442 lines
11 KiB
TypeScript
import type { Filter, Event } from 'nostr-tools'
|
|
import { BaseService } from '@/core/base/BaseService'
|
|
|
|
|
|
export interface NostrclientConfig {
|
|
url: string
|
|
privateKey?: string // For private WebSocket endpoint
|
|
}
|
|
|
|
export interface SubscriptionConfig {
|
|
id: string
|
|
filters: Filter[]
|
|
onEvent?: (event: Event) => void
|
|
onEose?: () => void
|
|
onClose?: () => void
|
|
}
|
|
|
|
export interface RelayStatus {
|
|
url: string
|
|
connected: boolean
|
|
lastSeen: number
|
|
error?: string
|
|
}
|
|
|
|
export class NostrclientHub extends BaseService {
|
|
// Service metadata
|
|
protected readonly metadata = {
|
|
name: 'NostrclientHub',
|
|
version: '1.0.0',
|
|
dependencies: ['VisibilityService']
|
|
}
|
|
|
|
// EventEmitter functionality
|
|
private events: { [key: string]: Function[] } = {}
|
|
|
|
// Service state
|
|
private ws: WebSocket | null = null
|
|
private config: NostrclientConfig
|
|
private subscriptions: Map<string, SubscriptionConfig> = new Map()
|
|
private reconnectInterval?: number
|
|
private reconnectAttempts = 0
|
|
private readonly maxReconnectAttempts = 5
|
|
private readonly reconnectDelay = 5000
|
|
private visibilityUnsubscribe?: () => void
|
|
|
|
// Connection state
|
|
private _isConnected = false
|
|
private _isConnecting = false
|
|
|
|
constructor(config: NostrclientConfig) {
|
|
super()
|
|
this.config = config
|
|
}
|
|
|
|
// EventEmitter methods
|
|
on(event: string, listener: Function) {
|
|
if (!this.events[event]) {
|
|
this.events[event] = []
|
|
}
|
|
this.events[event].push(listener)
|
|
}
|
|
|
|
emit(event: string, ...args: any[]) {
|
|
if (this.events[event]) {
|
|
this.events[event].forEach(listener => listener(...args))
|
|
}
|
|
}
|
|
|
|
removeAllListeners(event?: string) {
|
|
if (event) {
|
|
delete this.events[event]
|
|
} else {
|
|
this.events = {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Service-specific initialization (called by BaseService)
|
|
*/
|
|
protected async onInitialize(): Promise<void> {
|
|
// Connect to WebSocket
|
|
console.log('🔧 NostrclientHub: Initializing connection to', this.config.url)
|
|
await this.connect()
|
|
|
|
// Register with visibility service
|
|
this.registerWithVisibilityService()
|
|
|
|
this.debug('NostrclientHub initialized')
|
|
}
|
|
|
|
get isConnected(): boolean {
|
|
return this._isConnected
|
|
}
|
|
|
|
get isConnecting(): boolean {
|
|
return this._isConnecting
|
|
}
|
|
|
|
get totalSubscriptionCount(): number {
|
|
return this.subscriptions.size
|
|
}
|
|
|
|
get subscriptionDetails(): Array<{ id: string; filters: Filter[] }> {
|
|
return Array.from(this.subscriptions.values()).map(sub => ({
|
|
id: sub.id,
|
|
filters: sub.filters
|
|
}))
|
|
}
|
|
|
|
|
|
/**
|
|
* Connect to the nostrclient WebSocket
|
|
*/
|
|
async connect(): Promise<void> {
|
|
if (this._isConnecting || this._isConnected) {
|
|
return
|
|
}
|
|
|
|
this._isConnecting = true
|
|
this.reconnectAttempts++
|
|
|
|
try {
|
|
console.log('🔧 NostrclientHub: Connecting to nostrclient WebSocket')
|
|
|
|
// Determine WebSocket endpoint
|
|
const wsUrl = this.config.privateKey
|
|
? `${this.config.url}/${this.config.privateKey}` // Private endpoint
|
|
: `${this.config.url}/relay` // Public endpoint
|
|
|
|
this.ws = new WebSocket(wsUrl)
|
|
|
|
this.ws.onopen = () => {
|
|
console.log('🔧 NostrclientHub: WebSocket connected')
|
|
this._isConnected = true
|
|
this._isConnecting = false
|
|
this.reconnectAttempts = 0
|
|
this.emit('connected')
|
|
|
|
// Resubscribe to existing subscriptions
|
|
this.resubscribeAll()
|
|
}
|
|
|
|
this.ws.onmessage = (event) => {
|
|
this.handleMessage(event.data)
|
|
}
|
|
|
|
this.ws.onclose = (event) => {
|
|
console.log('🔧 NostrclientHub: WebSocket closed:', event.code, event.reason)
|
|
this._isConnected = false
|
|
this._isConnecting = false
|
|
this.emit('disconnected', event)
|
|
|
|
// Schedule reconnection
|
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
this.scheduleReconnect()
|
|
} else {
|
|
this.emit('maxReconnectionAttemptsReached')
|
|
}
|
|
}
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error('🔧 NostrclientHub: WebSocket error:', error)
|
|
this.emit('error', error)
|
|
}
|
|
|
|
} catch (error) {
|
|
this._isConnecting = false
|
|
console.error('🔧 NostrclientHub: Connection failed:', error)
|
|
this.emit('connectionError', error)
|
|
|
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
this.scheduleReconnect()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disconnect from the WebSocket
|
|
*/
|
|
disconnect(): void {
|
|
if (this.reconnectInterval) {
|
|
clearTimeout(this.reconnectInterval)
|
|
this.reconnectInterval = undefined
|
|
}
|
|
|
|
if (this.ws) {
|
|
this.ws.close()
|
|
this.ws = null
|
|
}
|
|
|
|
this._isConnected = false
|
|
this._isConnecting = false
|
|
this.subscriptions.clear()
|
|
this.emit('disconnected')
|
|
}
|
|
|
|
/**
|
|
* Subscribe to events
|
|
*/
|
|
subscribe(config: SubscriptionConfig): () => void {
|
|
if (!this._isConnected) {
|
|
throw new Error('Not connected to nostrclient')
|
|
}
|
|
|
|
// Store subscription
|
|
this.subscriptions.set(config.id, config)
|
|
|
|
// Send REQ message
|
|
const reqMessage = JSON.stringify([
|
|
'REQ',
|
|
config.id,
|
|
...config.filters
|
|
])
|
|
|
|
this.ws?.send(reqMessage)
|
|
console.log('🔧 NostrclientHub: Subscribed to', config.id)
|
|
|
|
// Return unsubscribe function
|
|
return () => {
|
|
this.unsubscribe(config.id)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe from events
|
|
*/
|
|
unsubscribe(subscriptionId: string): void {
|
|
if (!this._isConnected) {
|
|
return
|
|
}
|
|
|
|
// Send CLOSE message
|
|
const closeMessage = JSON.stringify(['CLOSE', subscriptionId])
|
|
this.ws?.send(closeMessage)
|
|
|
|
// Remove from subscriptions
|
|
this.subscriptions.delete(subscriptionId)
|
|
console.log('🔧 NostrclientHub: Unsubscribed from', subscriptionId)
|
|
}
|
|
|
|
/**
|
|
* Publish an event
|
|
*/
|
|
async publishEvent(event: Event): Promise<void> {
|
|
if (!this._isConnected) {
|
|
throw new Error('Not connected to nostrclient')
|
|
}
|
|
|
|
const eventMessage = JSON.stringify(['EVENT', event])
|
|
this.ws?.send(eventMessage)
|
|
|
|
console.log('🔧 NostrclientHub: Published event', event.id)
|
|
this.emit('eventPublished', { eventId: event.id })
|
|
}
|
|
|
|
/**
|
|
* Query events (one-time fetch)
|
|
*/
|
|
async queryEvents(filters: Filter[]): Promise<Event[]> {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this._isConnected) {
|
|
reject(new Error('Not connected to nostrclient'))
|
|
return
|
|
}
|
|
|
|
const queryId = `query-${Date.now()}`
|
|
const events: Event[] = []
|
|
let eoseReceived = false
|
|
|
|
// Create temporary subscription for query
|
|
const tempSubscription = this.subscribe({
|
|
id: queryId,
|
|
filters,
|
|
onEvent: (event) => {
|
|
events.push(event)
|
|
},
|
|
onEose: () => {
|
|
eoseReceived = true
|
|
this.unsubscribe(queryId)
|
|
resolve(events)
|
|
},
|
|
onClose: () => {
|
|
if (!eoseReceived) {
|
|
reject(new Error('Query subscription closed unexpectedly'))
|
|
}
|
|
}
|
|
})
|
|
|
|
// Timeout after 30 seconds
|
|
setTimeout(() => {
|
|
if (!eoseReceived) {
|
|
tempSubscription()
|
|
reject(new Error('Query timeout'))
|
|
}
|
|
}, 30000)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Handle incoming WebSocket messages
|
|
*/
|
|
private handleMessage(data: string): void {
|
|
try {
|
|
const message = JSON.parse(data)
|
|
|
|
if (Array.isArray(message) && message.length >= 2) {
|
|
const [type, subscriptionId, ...rest] = message
|
|
|
|
switch (type) {
|
|
case 'EVENT':
|
|
const event = rest[0] as Event
|
|
const subscription = this.subscriptions.get(subscriptionId)
|
|
if (subscription?.onEvent) {
|
|
subscription.onEvent(event)
|
|
}
|
|
this.emit('event', { subscriptionId, event })
|
|
break
|
|
|
|
case 'EOSE':
|
|
const eoseSubscription = this.subscriptions.get(subscriptionId)
|
|
if (eoseSubscription?.onEose) {
|
|
eoseSubscription.onEose()
|
|
}
|
|
this.emit('eose', { subscriptionId })
|
|
break
|
|
|
|
case 'NOTICE':
|
|
console.log('🔧 NostrclientHub: Notice from relay:', rest[0])
|
|
this.emit('notice', { message: rest[0] })
|
|
break
|
|
|
|
default:
|
|
console.log('🔧 NostrclientHub: Unknown message type:', type)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('🔧 NostrclientHub: Failed to parse message:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resubscribe to all existing subscriptions after reconnection
|
|
*/
|
|
private resubscribeAll(): void {
|
|
for (const [id, config] of this.subscriptions) {
|
|
const reqMessage = JSON.stringify([
|
|
'REQ',
|
|
id,
|
|
...config.filters
|
|
])
|
|
this.ws?.send(reqMessage)
|
|
}
|
|
console.log('🔧 NostrclientHub: Resubscribed to', this.subscriptions.size, 'subscriptions')
|
|
}
|
|
|
|
/**
|
|
* Schedule automatic reconnection
|
|
*/
|
|
private scheduleReconnect(): void {
|
|
if (this.reconnectInterval) {
|
|
clearTimeout(this.reconnectInterval)
|
|
}
|
|
|
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
|
|
console.log(`🔧 NostrclientHub: Scheduling reconnection in ${delay}ms`)
|
|
|
|
this.reconnectInterval = setTimeout(async () => {
|
|
await this.connect()
|
|
}, delay) as unknown as number
|
|
}
|
|
|
|
/**
|
|
* Register with VisibilityService for connection management
|
|
*/
|
|
private registerWithVisibilityService(): void {
|
|
if (!this.visibilityService) {
|
|
this.debug('VisibilityService not available')
|
|
return
|
|
}
|
|
|
|
this.visibilityUnsubscribe = this.visibilityService.registerService(
|
|
this.metadata.name,
|
|
async () => this.handleAppResume(),
|
|
async () => this.handleAppPause()
|
|
)
|
|
|
|
this.debug('Registered with VisibilityService')
|
|
}
|
|
|
|
/**
|
|
* Handle app resuming from visibility change
|
|
*/
|
|
private async handleAppResume(): Promise<void> {
|
|
this.debug('App resumed - checking nostrclient WebSocket connection')
|
|
|
|
// Check if we need to reconnect
|
|
if (!this.isConnected && !this._isConnecting) {
|
|
this.debug('WebSocket disconnected, attempting to reconnect...')
|
|
await this.connect()
|
|
} else if (this.isConnected) {
|
|
// Connection is alive, resubscribe to ensure all subscriptions are active
|
|
this.resubscribeAll()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle app pausing from visibility change
|
|
*/
|
|
private async handleAppPause(): Promise<void> {
|
|
this.debug('App paused - WebSocket connection will be maintained for quick resume')
|
|
|
|
// Don't immediately disconnect - WebSocket will be checked on resume
|
|
// This allows for quick resume without full reconnection overhead
|
|
}
|
|
|
|
/**
|
|
* Cleanup when service is disposed (overrides BaseService)
|
|
*/
|
|
protected async onDispose(): Promise<void> {
|
|
// Unregister from visibility service
|
|
if (this.visibilityUnsubscribe) {
|
|
this.visibilityUnsubscribe()
|
|
this.visibilityUnsubscribe = undefined
|
|
}
|
|
|
|
// Disconnect WebSocket
|
|
this.disconnect()
|
|
|
|
// Clear all event listeners
|
|
this.removeAllListeners()
|
|
|
|
this.debug('NostrclientHub disposed')
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const nostrclientHub = new NostrclientHub({
|
|
url: import.meta.env.VITE_NOSTRCLIENT_URL || 'wss://localhost:5000/nostrclient/api/v1'
|
|
})
|
|
|
|
// Ensure global export
|
|
;(globalThis as any).nostrclientHub = nostrclientHub
|