@ -49,6 +49,7 @@ class NostrClient {
@@ -49,6 +49,7 @@ class NostrClient {
private readonly INITIAL_RETRY_DELAY = 5000 ; // 5 seconds
private readonly MAX_RETRY_DELAY = 300000 ; // 5 minutes
private readonly MAX_FAILURE_COUNT = 10 ; // After 10 failures, wait max delay
private readonly PERMANENT_FAILURE_THRESHOLD = 20 ; // After 20 failures, skip relay for this session
// Track authenticated relays to avoid re-authenticating
private authenticatedRelays : Set < string > = new Set ( ) ;
@ -132,21 +133,48 @@ class NostrClient {
@@ -132,21 +133,48 @@ class NostrClient {
async addRelay ( url : string ) : Promise < void > {
if ( this . relays . has ( url ) ) return ;
// Check if this relay has failed recently and we should wait
// Check if this relay has failed too many times - skip permanently for this session
const failureInfo = this . failedRelays . get ( url ) ;
if ( failureInfo && failureInfo . failureCount >= this . PERMANENT_FAILURE_THRESHOLD ) {
console . debug ( ` [nostr-client] Relay ${ url } has failed ${ failureInfo . failureCount } times, skipping for this session ` ) ;
throw new Error ( ` Relay has failed too many times ( ${ failureInfo . failureCount } ), skipping for this session ` ) ;
}
// Check if this relay has failed recently and we should wait
if ( failureInfo ) {
const timeSinceFailure = Date . now ( ) - failureInfo . lastFailure ;
if ( timeSinceFailure < failureInfo . retryAfter ) {
const waitTime = failureInfo . retryAfter - timeSinceFailure ;
console . log ( ` [nostr-client] Relay ${ url } failed recently, waiting ${ Math . round ( waitTime / 1000 ) } s before retry ` ) ;
console . debu g( ` [nostr-client] Relay ${ url } failed recently, waiting ${ Math . round ( waitTime / 1000 ) } s before retry ` ) ;
throw new Error ( ` Relay failed recently, retry after ${ Math . round ( waitTime / 1000 ) } s ` ) ;
}
}
try {
const relay = await Relay . connect ( url ) ;
// Use connection timeout similar to jumble's approach (5 seconds)
const relay = await Promise . race ( [
Relay . connect ( url ) ,
new Promise < never > ( ( _ , reject ) = >
setTimeout ( ( ) = > reject ( new Error ( 'Connection timeout' ) ) , 5000 )
)
] ) ;
this . relays . set ( url , relay ) ;
// Add connection close handler to automatically clean up closed relays
try {
const ws = ( relay as any ) . ws ;
if ( ws ) {
ws . addEventListener ( 'close' , ( ) = > {
console . debug ( ` [nostr-client] Relay ${ url } connection closed, removing from active relays ` ) ;
this . relays . delete ( url ) ;
this . authenticatedRelays . delete ( url ) ;
} ) ;
}
} catch ( error ) {
// Ignore errors accessing WebSocket
}
// Don't proactively authenticate - only authenticate when:
// 1. Relay sends an AUTH challenge (handled by nostr-tools automatically)
// 2. An operation fails with 'auth-required' error (handled in publish/subscribe methods)
@ -156,7 +184,7 @@ class NostrClient {
@@ -156,7 +184,7 @@ class NostrClient {
// Log successful connection at debug level to reduce console noise
console . debug ( ` [nostr-client] Successfully connected to relay: ${ url } ` ) ;
} catch ( error ) {
// Track the failure
// Track the failure but don't throw - allow graceful degradation like jumble
const existingFailure = this . failedRelays . get ( url ) || { lastFailure : 0 , retryAfter : this.INITIAL_RETRY_DELAY , failureCount : 0 } ;
const failureCount = existingFailure . failureCount + 1 ;
@ -184,7 +212,12 @@ class NostrClient {
@@ -184,7 +212,12 @@ class NostrClient {
if ( failureCount > 3 ) {
console . debug ( ` [nostr-client] Relay ${ url } connection failed (failure # ${ failureCount } ), will retry after ${ Math . round ( retryAfter / 1000 ) } s ` ) ;
}
throw error ;
// Warn if approaching permanent failure threshold
if ( failureCount >= this . PERMANENT_FAILURE_THRESHOLD ) {
console . warn ( ` [nostr-client] Relay ${ url } has failed ${ failureCount } times, will be skipped for this session ` ) ;
}
// Don't throw - allow graceful degradation like jumble does
// The caller can check if relay was added by checking this.relays.has(url)
}
}
@ -205,25 +238,47 @@ class NostrClient {
@@ -205,25 +238,47 @@ class NostrClient {
private checkAndCleanupRelay ( relayUrl : string ) : boolean {
const relay = this . relays . get ( relayUrl ) ;
if ( ! relay ) return false ;
// Check relay connection status
// Status values: 0 = CONNECTING, 1 = OPEN, 2 = CLOSING, 3 = CLOSED
const status = ( relay as any ) . status ;
if ( status === 3 ) {
if ( status === 3 || status === 2 ) {
// Relay is closed or closing, remove it
console . debug ( ` [nostr-client] Relay ${ relayUrl } is closed (status: ${ status } ), removing from active relays ` ) ;
this . relays . delete ( relayUrl ) ;
this . authenticatedRelays . delete ( relayUrl ) ;
return false ;
}
// Check if relay has a connection property and if it's closed
try {
const ws = ( relay as any ) . ws ;
if ( ws && ( ws . readyState === WebSocket . CLOSED || ws . readyState === WebSocket . CLOSING ) ) {
console . debug ( ` [nostr-client] Relay ${ relayUrl } WebSocket is closed, removing from active relays ` ) ;
this . relays . delete ( relayUrl ) ;
this . authenticatedRelays . delete ( relayUrl ) ;
return false ;
}
} catch ( error ) {
// If we can't check the WebSocket, assume it's still valid
}
return true ;
}
/ * *
* Get a relay instance by URL
* Will connect if not already connected
* Returns null if connection fails ( graceful degradation like jumble )
* /
async getRelay ( url : string ) : Promise < Relay | null > {
// Ensure relay is connected
if ( ! this . relays . has ( url ) ) {
try {
await this . addRelay ( url ) ;
} catch ( error ) {
console . debug ( ` [nostr-client] Failed to connect to relay ${ url } : ` , error ) ;
// addRelay doesn't throw on failure, it just doesn't add the relay
await this . addRelay ( url ) ;
// Check if relay was actually added
if ( ! this . relays . has ( url ) ) {
console . debug ( ` [nostr-client] Failed to connect to relay ${ url } , skipping gracefully ` ) ;
return null ;
}
}
@ -425,14 +480,14 @@ class NostrClient {
@@ -425,14 +480,14 @@ class NostrClient {
for ( const url of relays ) {
const relay = this . relays . get ( url ) ;
if ( ! relay ) {
try {
await this . addRelay ( url ) ;
const newRelay = this . relays . get ( url ) ;
if ( newRelay ) {
try {
await newRelay . publish ( event ) ;
results . success . push ( url ) ;
} catch ( error ) {
// addRelay doesn't throw on failure, it just doesn't add the relay (graceful degradation like jumble)
await this . addRelay ( url ) ;
const newRelay = this . relays . get ( url ) ;
if ( newRelay && this . checkAndCleanupRelay ( url ) ) {
try {
await newRelay . publish ( event ) ;
results . success . push ( url ) ;
} catch ( error ) {
// Check if error is auth-required
if ( error instanceof Error && error . message . startsWith ( 'auth-required' ) ) {
// Try to authenticate and retry
@ -454,20 +509,36 @@ class NostrClient {
@@ -454,20 +509,36 @@ class NostrClient {
} ) ;
}
} else {
// Check if it's a closed connection error
const errorMessage = error instanceof Error ? error.message : String ( error ) ;
if ( errorMessage . includes ( 'SendingOnClosedConnection' ) || errorMessage . includes ( 'closed connection' ) ) {
console . debug ( ` [nostr-client] Relay ${ url } connection closed during publish, removing from active relays ` ) ;
this . relays . delete ( url ) ;
this . authenticatedRelays . delete ( url ) ;
}
results . failed . push ( {
relay : url ,
error : error instanceof Error ? error . message : 'Unknown error'
} ) ;
}
}
} else {
// Relay connection failed (addRelay didn't add it)
results . failed . push ( {
relay : url ,
error : 'Failed to connect'
} ) ;
}
} catch ( error ) {
} else {
// Check relay status before publishing
if ( ! this . checkAndCleanupRelay ( url ) ) {
results . failed . push ( {
relay : url ,
error : 'Failed to connect'
error : 'Relay connection closed '
} ) ;
continue ;
}
} else {
try {
await relay . publish ( event ) ;
results . success . push ( url ) ;
@ -493,6 +564,13 @@ class NostrClient {
@@ -493,6 +564,13 @@ class NostrClient {
} ) ;
}
} else {
// Check if it's a closed connection error
const errorMessage = error instanceof Error ? error.message : String ( error ) ;
if ( errorMessage . includes ( 'SendingOnClosedConnection' ) || errorMessage . includes ( 'closed connection' ) ) {
console . debug ( ` [nostr-client] Relay ${ url } connection closed during publish, removing from active relays ` ) ;
this . relays . delete ( url ) ;
this . authenticatedRelays . delete ( url ) ;
}
results . failed . push ( {
relay : url ,
error : error instanceof Error ? error . message : 'Unknown error'
@ -515,13 +593,19 @@ class NostrClient {
@@ -515,13 +593,19 @@ class NostrClient {
for ( const url of relays ) {
if ( ! this . relays . has ( url ) ) {
// Check if relay should be skipped before attempting connection
const failureInfo = this . failedRelays . get ( url ) ;
if ( failureInfo && failureInfo . failureCount >= this . PERMANENT_FAILURE_THRESHOLD ) {
console . debug ( ` [nostr-client] Skipping permanently failed relay ${ url } for subscription ` ) ;
continue ;
}
// addRelay doesn't throw on failure, it just doesn't add the relay (graceful degradation like jumble)
this . addRelay ( url ) . then ( ( ) = > {
const newRelay = this . relays . get ( url ) ;
if ( newRelay ) {
this . setupSubscription ( newRelay , url , subId , filters , onEvent , onEose ) ;
}
} ) . catch ( ( ) = > {
// Silently fail
} ) ;
continue ;
}
@ -529,10 +613,22 @@ class NostrClient {
@@ -529,10 +613,22 @@ class NostrClient {
const relay = this . relays . get ( url ) ;
if ( ! relay ) continue ;
// Check relay status before setting up subscription
if ( ! this . checkAndCleanupRelay ( url ) ) {
console . debug ( ` [nostr-client] Relay ${ url } is closed, skipping subscription ` ) ;
continue ;
}
try {
this . setupSubscription ( relay , url , subId , filters , onEvent , onEose ) ;
} catch ( error ) {
// Handle errors
// Handle errors - setupSubscription already handles closed connection errors
const errorMessage = error instanceof Error ? error.message : String ( error ) ;
if ( errorMessage . includes ( 'SendingOnClosedConnection' ) || errorMessage . includes ( 'closed connection' ) ) {
console . debug ( ` [nostr-client] Relay ${ url } connection closed, removing from active relays ` ) ;
this . relays . delete ( url ) ;
this . authenticatedRelays . delete ( url ) ;
}
}
}
@ -549,11 +645,23 @@ class NostrClient {
@@ -549,11 +645,23 @@ class NostrClient {
) : void {
if ( ! this . relays . has ( url ) ) return ;
// Check relay status before setting up subscription
if ( ! this . checkAndCleanupRelay ( url ) ) {
console . debug ( ` [nostr-client] Relay ${ url } is closed, skipping subscription setup ` ) ;
return ;
}
try {
const client = this ;
let hasAuthed = this . authenticatedRelays . has ( url ) ;
const startSub = ( ) = > {
// Check relay status again before subscribing
if ( ! client . checkAndCleanupRelay ( url ) ) {
console . debug ( ` [nostr-client] Relay ${ url } closed before subscription, aborting ` ) ;
return ;
}
const sub = relay . subscribe ( filters , {
onevent : ( event : NostrEvent ) = > {
try {
@ -599,7 +707,13 @@ class NostrClient {
@@ -599,7 +707,13 @@ class NostrClient {
startSub ( ) ;
} catch ( error ) {
// Handle errors
// Handle SendingOnClosedConnection and other errors
const errorMessage = error instanceof Error ? error.message : String ( error ) ;
if ( errorMessage . includes ( 'SendingOnClosedConnection' ) || errorMessage . includes ( 'closed connection' ) ) {
console . debug ( ` [nostr-client] Relay ${ url } connection closed during subscription setup, removing from active relays ` ) ;
this . relays . delete ( url ) ;
this . authenticatedRelays . delete ( url ) ;
}
}
}
@ -696,6 +810,13 @@ class NostrClient {
@@ -696,6 +810,13 @@ class NostrClient {
const startSub = ( ) = > {
try {
// Check relay status before subscribing
if ( ! this . checkAndCleanupRelay ( relayUrl ) ) {
console . debug ( ` [nostr-client] Relay ${ relayUrl } is closed, skipping subscription ` ) ;
finish ( ) ;
return ;
}
const client = this ;
const sub = relay . subscribe ( filters , {
onevent : ( event : NostrEvent ) = > {
@ -749,6 +870,13 @@ class NostrClient {
@@ -749,6 +870,13 @@ class NostrClient {
if ( ! resolved ) finish ( ) ;
} , timeout ) ;
} catch ( error ) {
// Handle SendingOnClosedConnection and other errors
const errorMessage = error instanceof Error ? error.message : String ( error ) ;
if ( errorMessage . includes ( 'SendingOnClosedConnection' ) || errorMessage . includes ( 'closed connection' ) ) {
console . debug ( ` [nostr-client] Relay ${ relayUrl } connection closed, removing from active relays ` ) ;
this . relays . delete ( relayUrl ) ;
this . authenticatedRelays . delete ( relayUrl ) ;
}
finish ( ) ;
}
} ;
@ -840,13 +968,18 @@ class NostrClient {
@@ -840,13 +968,18 @@ class NostrClient {
) : Promise < NostrEvent [ ] > {
const timeout = options . timeout || config . relayTimeout ;
// Filter out relays that have failed recently
// Filter out relays that have failed recently or permanently
const now = Date . now ( ) ;
const availableRelays = relays . filter ( url = > {
if ( this . relays . has ( url ) ) return true ; // Already connected
const failureInfo = this . failedRelays . get ( url ) ;
if ( failureInfo ) {
// Skip permanently failed relays
if ( failureInfo . failureCount >= this . PERMANENT_FAILURE_THRESHOLD ) {
return false ; // Skip this relay, it has failed too many times
}
// Skip relays that failed recently (still in backoff period)
const timeSinceFailure = now - failureInfo . lastFailure ;
if ( timeSinceFailure < failureInfo . retryAfter ) {
return false ; // Skip this relay, it failed recently
@ -856,15 +989,11 @@ class NostrClient {
@@ -856,15 +989,11 @@ class NostrClient {
} ) ;
// Try to connect to relays that aren't already connected
// Like jumble, we gracefully handle failures - addRelay doesn't throw, it just doesn't add failed relays
const relaysToConnect = availableRelays . filter ( url = > ! this . relays . has ( url ) ) ;
if ( relaysToConnect . length > 0 ) {
await Promise . allSettled (
relaysToConnect . map ( url = >
this . addRelay ( url ) . catch ( ( error ) = > {
// Error already logged in addRelay
return null ;
} )
)
relaysToConnect . map ( url = > this . addRelay ( url ) )
) ;
}