@ -12,12 +12,16 @@ import {
relayFilterIncludesSocialKindBlockedKind ,
relayFilterIncludesSocialKindBlockedKind ,
relaysAfterSocialKindBlockedStrip ,
relaysAfterSocialKindBlockedStrip ,
SOCIAL_KIND_BLOCKED_RELAY_URLS ,
SOCIAL_KIND_BLOCKED_RELAY_URLS ,
MAX_CONCURRENT_RELAY_CONNECTIONS ,
MAX_PUBLISH_RELAYS ,
MAX_PUBLISH_RELAYS ,
PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS ,
PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS ,
PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS ,
RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS ,
RELAY_POOL_CONNECTION_TIMEOUT_MS ,
RELAY_POOL_CONNECTION_TIMEOUT_MS ,
RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS ,
RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS ,
TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY ,
TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY ,
OUTBOX_PUBLISH_RETRY_DELAY_MS ,
OUTBOX_PUBLISH_RETRY_DELAY_MS ,
DEFAULT_FAVORITE_RELAYS ,
NIP66_DISCOVERY_RELAY_URLS ,
NIP66_DISCOVERY_RELAY_URLS ,
PROFILE_FETCH_RELAY_URLS ,
PROFILE_FETCH_RELAY_URLS ,
READ_ONLY_RELAY_URLS ,
READ_ONLY_RELAY_URLS ,
@ -115,7 +119,15 @@ import {
relayUrlsStripExtendedTagReqBlocked
relayUrlsStripExtendedTagReqBlocked
} from '@/lib/relay-extended-tag-req-blocks'
} from '@/lib/relay-extended-tag-req-blocks'
import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize'
import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize'
import { isHttpRelayUrl , isLocalNetworkUrl , normalizeAnyRelayUrl , normalizeHttpRelayUrl , normalizeUrl , simplifyUrl } from '@/lib/url'
import {
canonicalRelayStrikeKey ,
isHttpRelayUrl ,
isLocalNetworkUrl ,
normalizeAnyRelayUrl ,
normalizeHttpRelayUrl ,
normalizeUrl ,
simplifyUrl
} from '@/lib/url'
import { isSafari } from '@/lib/utils'
import { isSafari } from '@/lib/utils'
import {
import {
ISigner ,
ISigner ,
@ -270,7 +282,10 @@ class ClientService extends EventTarget {
* { @link ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD } strikes we skip that relay for reads and publishes until reload .
* { @link ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD } strikes we skip that relay for reads and publishes until reload .
* /
* /
private publishStrikeCount = new Map < string , number > ( )
private publishStrikeCount = new Map < string , number > ( )
public static readonly SESSION_RELAY_FAILURE_STRIKE_THRESHOLD = 2
/** Many shards / parallel REQs used to hit the strike threshold instantly on one dead relay; only one increment per window. */
private sessionRelayFailureLastIncrementAt = new Map < string , number > ( )
public static readonly SESSION_RELAY_FAILURE_STRIKE_THRESHOLD = 4
private static readonly SESSION_RELAY_FAILURE_INCREMENT_DEBOUNCE_MS = 12 _000
/** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */
/** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */
private sessionRelayPublishStats = new Map < string , { successCount : number ; sumLatencyMs : number } > ( )
private sessionRelayPublishStats = new Map < string , { successCount : number ; sumLatencyMs : number } > ( )
@ -313,7 +328,8 @@ class ClientService extends EventTarget {
// Initialize sub-services
// Initialize sub-services
this . queryService = new QueryService ( this . pool , {
this . queryService = new QueryService ( this . pool , {
shouldSkipRelayForSession : ( url ) = > {
shouldSkipRelayForSession : ( url ) = > {
const key = normalizeAnyRelayUrl ( url ) || url
const key = canonicalRelayStrikeKey ( url )
if ( ! key ) return false
return (
return (
( this . publishStrikeCount . get ( key ) ? ? 0 ) >=
( this . publishStrikeCount . get ( key ) ? ? 0 ) >=
ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
@ -602,33 +618,39 @@ class ClientService extends EventTarget {
event : NEvent ,
event : NEvent ,
favoriteRelayUrls : string [ ] = [ ]
favoriteRelayUrls : string [ ] = [ ]
) : Promise < string [ ] > {
) : Promise < string [ ] > {
let userWriteSet = new Set < string > ( )
const ctx = this . collectReplyAndMentionPubkeys ( event )
/** One `fetchRelayLists` round-trip (author first) — avoids two back-to-back relay-list budgets that always lost the outer race. */
const pubkeyOrder = Array . from ( new Set ( [ event . pubkey , . . . ctx ] ) )
let lists : TRelayList [ ] = [ ]
try {
try {
const rl = await this . fetchRelayList ( event . pubkey )
lists = await this . fetchRelayLists ( pubkeyOrder )
} catch {
lists = [ ]
}
let userWriteSet = new Set < string > ( )
const authorRl = lists [ 0 ]
if ( authorRl ) {
userWriteSet = new Set ( [
userWriteSet = new Set ( [
. . . ( rl ? . write ? ? [ ] ) . map ( ( u ) = > normalizeUrl ( u ) || u ) . filter ( ( u ) : u is string = > ! ! u ) ,
. . . ( authorRl . write ? ? [ ] ) . map ( ( u ) = > normalizeUrl ( u ) || u ) . filter ( ( u ) : u is string = > ! ! u ) ,
. . . ( rl ? . httpWrite ? ? [ ] ) . map ( ( u ) = > normalizeHttpRelayUrl ( u ) || u ) . filter ( ( u ) : u is string = > ! ! u )
. . . ( authorRl . httpWrite ? ? [ ] ) . map ( ( u ) = > normalizeHttpRelayUrl ( u ) || u ) . filter ( ( u ) : u is string = > ! ! u )
] )
] )
} catch {
// ignore
}
}
const ctx = this . collectReplyAndMentionPubkeys ( event )
let authorReadSet = new Set < string > ( )
let authorReadSet = new Set < string > ( )
if ( ctx . length > 0 ) {
for ( let i = 1 ; i < lists . length ; i ++ ) {
const lists = await this . fetchRelayLists ( ctx )
const list = lists [ i ]
for ( const list of lists ) {
if ( ! list ) continue
for ( const u of list ? . read ? ? [ ] ) {
for ( const u of list . read ? ? [ ] ) {
const n = normalizeUrl ( u ) || u
const n = normalizeUrl ( u ) || u
if ( n ) authorReadSet . add ( n )
if ( n ) authorReadSet . add ( n )
}
}
for ( const u of list ? . httpRead ? ? [ ] ) {
for ( const u of list . httpRead ? ? [ ] ) {
const n = normalizeHttpRelayUrl ( u ) || u
const n = normalizeHttpRelayUrl ( u ) || u
if ( n ) authorReadSet . add ( n )
if ( n ) authorReadSet . add ( n )
}
}
}
authorReadSet = new Set ( filterContextAuthorReadRelaysForPublish ( [ . . . authorReadSet ] ) )
}
}
authorReadSet = new Set ( filterContextAuthorReadRelaysForPublish ( [ . . . authorReadSet ] ) )
const favSet = new Set (
const favSet = new Set (
favoriteRelayUrls . map ( ( f ) = > normalizeUrl ( f ) || f ) . filter ( ( u ) : u is string = > ! ! u )
favoriteRelayUrls . map ( ( f ) = > normalizeUrl ( f ) || f ) . filter ( ( u ) : u is string = > ! ! u )
@ -691,7 +713,7 @@ class ClientService extends EventTarget {
relayCount : relayUrls.length
relayCount : relayUrls.length
} )
} )
resolve ( fallbackOrder ( ) )
resolve ( fallbackOrder ( ) )
} , PUBLISH_RELAY_LIST_RESOLUTION _TIMEOUT_MS )
} , PUBLISH_PRIORITIZE_RELAY_ORDER _TIMEOUT_MS )
)
)
] )
] )
}
}
@ -1087,7 +1109,8 @@ class ClientService extends EventTarget {
/** Strikes accumulated this session for this relay (connection / NOTICE failures). */
/** Strikes accumulated this session for this relay (connection / NOTICE failures). */
getSessionRelayStrikeCountForUrl ( url : string ) : number {
getSessionRelayStrikeCountForUrl ( url : string ) : number {
const n = normalizeAnyRelayUrl ( url ) || url
const n = canonicalRelayStrikeKey ( url )
if ( ! n ) return 0
return this . publishStrikeCount . get ( n ) ? ? 0
return this . publishStrikeCount . get ( n ) ? ? 0
}
}
@ -1101,7 +1124,7 @@ class ClientService extends EventTarget {
}
}
private recordRelayNoticeFetchFailure ( url : string , noticeMessage : string ) {
private recordRelayNoticeFetchFailure ( url : string , noticeMessage : string ) {
const n = normalizeAnyRelayUrl ( url ) || url
const n = canonicalRelayStrikeKey ( url )
if ( ! n ) return
if ( ! n ) return
const prev = this . publishStrikeCount . get ( n ) ? ? 0
const prev = this . publishStrikeCount . get ( n ) ? ? 0
if ( prev >= ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD ) {
if ( prev >= ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD ) {
@ -1115,12 +1138,21 @@ class ClientService extends EventTarget {
}
}
private recordSessionRelayFailure ( url : string ) {
private recordSessionRelayFailure ( url : string ) {
const n = normalizeAnyRelayUrl ( url ) || url
const n = canonicalRelayStrikeKey ( url )
if ( ! n ) return
if ( ! n ) return
if ( isLocalNetworkUrl ( n ) ) {
return
}
const prev = this . publishStrikeCount . get ( n ) ? ? 0
const prev = this . publishStrikeCount . get ( n ) ? ? 0
if ( prev >= ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD ) {
if ( prev >= ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD ) {
return
return
}
}
const now = Date . now ( )
const lastInc = this . sessionRelayFailureLastIncrementAt . get ( n ) ? ? 0
if ( now - lastInc < ClientService . SESSION_RELAY_FAILURE_INCREMENT_DEBOUNCE_MS ) {
return
}
this . sessionRelayFailureLastIncrementAt . set ( n , now )
const count = prev + 1
const count = prev + 1
this . publishStrikeCount . set ( n , count )
this . publishStrikeCount . set ( n , count )
if ( count === ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD ) {
if ( count === ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD ) {
@ -1134,7 +1166,8 @@ class ClientService extends EventTarget {
private filterSessionStrikedRelays ( urls : string [ ] ) : string [ ] {
private filterSessionStrikedRelays ( urls : string [ ] ) : string [ ] {
return urls . filter ( ( u ) = > {
return urls . filter ( ( u ) = > {
const n = normalizeAnyRelayUrl ( u ) || u
const n = canonicalRelayStrikeKey ( u )
if ( ! n ) return true
return ( this . publishStrikeCount . get ( n ) ? ? 0 ) < ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
return ( this . publishStrikeCount . get ( n ) ? ? 0 ) < ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
} )
} )
}
}
@ -1143,9 +1176,10 @@ class ClientService extends EventTarget {
* If every URL was session - striked , clear strikes once so reads / publishes can retry ( mobile WebSocket churn ) .
* If every URL was session - striked , clear strikes once so reads / publishes can retry ( mobile WebSocket churn ) .
* /
* /
clearSessionRelayStrikes ( ) : void {
clearSessionRelayStrikes ( ) : void {
if ( this . publishStrikeCount . size === 0 ) return
if ( this . publishStrikeCount . size === 0 && this . sessionRelayFailureLastIncrementAt . size === 0 ) return
logger . info ( '[Relay] Session relay strikes cleared' , { relayCount : this.publishStrikeCount.size } )
logger . info ( '[Relay] Session relay strikes cleared' , { relayCount : this.publishStrikeCount.size } )
this . publishStrikeCount . clear ( )
this . publishStrikeCount . clear ( )
this . sessionRelayFailureLastIncrementAt . clear ( )
this . notifySessionRelayStrikesChanged ( )
this . notifySessionRelayStrikesChanged ( )
}
}
@ -1154,9 +1188,10 @@ class ClientService extends EventTarget {
* until new failures accrue ( same counter as { @link clearSessionRelayStrikes } ) .
* until new failures accrue ( same counter as { @link clearSessionRelayStrikes } ) .
* /
* /
clearSessionRelayStrikeForUrl ( url : string ) : boolean {
clearSessionRelayStrikeForUrl ( url : string ) : boolean {
const n = normalizeAnyRelayUrl ( url ) || url
const n = canonicalRelayStrikeKey ( url )
if ( ! n ) return false
if ( ! n ) return false
const had = this . publishStrikeCount . delete ( n )
const had = this . publishStrikeCount . delete ( n )
this . sessionRelayFailureLastIncrementAt . delete ( n )
if ( had ) {
if ( had ) {
logger . info ( '[Relay] Session strikes cleared for relay (manual)' , { url : n } )
logger . info ( '[Relay] Session strikes cleared for relay (manual)' , { url : n } )
this . notifySessionRelayStrikesChanged ( n )
this . notifySessionRelayStrikesChanged ( n )
@ -1170,9 +1205,12 @@ class ClientService extends EventTarget {
clearSessionRelayStrikesForUrls ( urls : string [ ] ) : number {
clearSessionRelayStrikesForUrls ( urls : string [ ] ) : number {
let cleared = 0
let cleared = 0
for ( const url of urls ) {
for ( const url of urls ) {
const n = normalizeAnyRelayUrl ( url ) || url
const n = canonicalRelayStrikeKey ( url )
if ( ! n ) continue
if ( ! n ) continue
if ( this . publishStrikeCount . delete ( n ) ) cleared += 1
if ( this . publishStrikeCount . delete ( n ) ) {
cleared += 1
this . sessionRelayFailureLastIncrementAt . delete ( n )
}
}
}
if ( cleared > 0 ) {
if ( cleared > 0 ) {
logger . info ( '[Relay] Session strikes cleared for relays (added to publish selection)' , {
logger . info ( '[Relay] Session strikes cleared for relays (added to publish selection)' , {
@ -1195,11 +1233,11 @@ class ClientService extends EventTarget {
if ( filtered . length === 0 && unique . length > 0 ) {
if ( filtered . length === 0 && unique . length > 0 ) {
let cleared = 0
let cleared = 0
for ( const u of unique ) {
for ( const u of unique ) {
// HTTP index relays (CORS down, wrong origin) do not recover like WebSockets; clearing their strikes
const n = canonicalRelayStrikeKey ( u )
// here caused retry storms with many parallel fetchEvents hitting the same dead endpoint.
if ( n && this . publishStrikeCount . delete ( n ) ) {
if ( isHttpRelayUrl ( u ) ) continue
cleared += 1
const n = normalizeAnyRelayUrl ( u ) || u
this . sessionRelayFailureLastIncrementAt . delete ( n )
if ( n && this . publishStrikeCount . delete ( n ) ) cleared += 1
}
}
}
if ( cleared === 0 ) return filtered
if ( cleared === 0 ) return filtered
logger . info ( '[Relay] Batch was all session-striked — cleared strikes for this batch only' , {
logger . info ( '[Relay] Batch was all session-striked — cleared strikes for this batch only' , {
@ -1214,7 +1252,8 @@ class ClientService extends EventTarget {
/** Record a successful publish and its latency for session-based preference when selecting random relays. */
/** Record a successful publish and its latency for session-based preference when selecting random relays. */
recordPublishSuccess ( url : string , latencyMs : number ) {
recordPublishSuccess ( url : string , latencyMs : number ) {
const n = normalizeAnyRelayUrl ( url ) || url
const n = canonicalRelayStrikeKey ( url )
if ( ! n ) return
const cur = this . sessionRelayPublishStats . get ( n )
const cur = this . sessionRelayPublishStats . get ( n )
if ( cur ) {
if ( cur ) {
cur . successCount += 1
cur . successCount += 1
@ -1233,7 +1272,7 @@ class ClientService extends EventTarget {
const out : string [ ] = [ ]
const out : string [ ] = [ ]
for ( const [ url , stats ] of this . sessionRelayPublishStats . entries ( ) ) {
for ( const [ url , stats ] of this . sessionRelayPublishStats . entries ( ) ) {
if ( stats . successCount < 1 ) continue
if ( stats . successCount < 1 ) continue
const n = normalizeAnyRelayUrl ( url ) || url
const n = canonicalRelayStrikeKey ( url )
if ( ! n || readOnlySet . has ( n ) ) continue
if ( ! n || readOnlySet . has ( n ) ) continue
if ( ( this . publishStrikeCount . get ( n ) ? ? 0 ) >= ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD ) continue
if ( ( this . publishStrikeCount . get ( n ) ? ? 0 ) >= ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD ) continue
out . push ( n )
out . push ( n )
@ -1258,9 +1297,14 @@ class ClientService extends EventTarget {
presetStriked : string [ ]
presetStriked : string [ ]
} {
} {
const presetSet = new Set < string > ( )
const presetSet = new Set < string > ( )
for ( const u of [ . . . FAST_WRITE_RELAY_URLS , . . . FAST_READ_RELAY_URLS ] ) {
for ( const u of [
. . . FAST_WRITE_RELAY_URLS ,
. . . FAST_READ_RELAY_URLS ,
. . . DEFAULT_FAVORITE_RELAYS ,
. . . SEARCHABLE_RELAY_URLS
] ) {
const n = normalizeUrl ( u ) || u
const n = normalizeUrl ( u ) || u
if ( n ) presetSet . add ( n )
if ( n ) presetSet . add ( ca nonicalRelayStrikeKey ( n ) )
}
}
const preset = Array . from ( presetSet )
const preset = Array . from ( presetSet )
const strikedUrls = Array . from ( this . publishStrikeCount . entries ( ) )
const strikedUrls = Array . from ( this . publishStrikeCount . entries ( ) )
@ -1291,19 +1335,23 @@ class ClientService extends EventTarget {
. map ( ( u ) = > normalizeAnyRelayUrl ( u ) || u )
. map ( ( u ) = > normalizeAnyRelayUrl ( u ) || u )
. filter ( ( n ) = > n && ! readOnlySet . has ( n ) )
. filter ( ( n ) = > n && ! readOnlySet . has ( n ) )
const unique = Array . from ( new Set ( normalizedCandidates ) )
const unique = Array . from ( new Set ( normalizedCandidates ) )
const notStruckOut = unique . filter (
const notStruckOut = unique . filter ( ( u ) = > {
( n ) = > ( this . publishStrikeCount . get ( n ) ? ? 0 ) < ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
const n = canonicalRelayStrikeKey ( u )
)
if ( ! n ) return false
return ( this . publishStrikeCount . get ( n ) ? ? 0 ) < ClientService . SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
} )
const preferred : string [ ] = [ ]
const preferred : string [ ] = [ ]
const rest : string [ ] = [ ]
const rest : string [ ] = [ ]
for ( const url of notStruckOut ) {
for ( const url of notStruckOut ) {
const stats = this . sessionRelayPublishStats . get ( url )
const sk = canonicalRelayStrikeKey ( url )
const stats = sk ? this . sessionRelayPublishStats . get ( sk ) : undefined
if ( stats && stats . successCount >= 1 ) preferred . push ( url )
if ( stats && stats . successCount >= 1 ) preferred . push ( url )
else rest . push ( url )
else rest . push ( url )
}
}
preferred . sort ( ( a , b ) = > {
preferred . sort ( ( a , b ) = > {
const sa = this . sessionRelayPublishStats . get ( a ) !
const sa = this . sessionRelayPublishStats . get ( canonicalRelayStrikeKey ( a ) )
const sb = this . sessionRelayPublishStats . get ( b ) !
const sb = this . sessionRelayPublishStats . get ( canonicalRelayStrikeKey ( b ) )
if ( ! sa || ! sb ) return 0
if ( sb . successCount !== sa . successCount ) return sb . successCount - sa . successCount
if ( sb . successCount !== sa . successCount ) return sb . successCount - sa . successCount
const avgA = sa . sumLatencyMs / sa . successCount
const avgA = sa . sumLatencyMs / sa . successCount
const avgB = sb . sumLatencyMs / sb . successCount
const avgB = sb . sumLatencyMs / sb . successCount
@ -1436,10 +1484,31 @@ class ClientService extends EventTarget {
publishOpBatch . logEnd ( status )
publishOpBatch . logEnd ( status )
}
}
logger . debug ( '[PublishEvent] Setting up global timeout (30 seconds)' )
/ * *
* Publish intentionally does * * not * * use { @link QueryService . acquireGlobalRelayConnectionSlot } : feed
* REQ setup can hold every slot for hung ` ensureRelay ` handshakes , which left ` finishedCount === 0 `
* until the global timeout ( user saw “ Request timed out ” on every relay ) . Publish is already bounded
* by { @link MAX_PUBLISH_RELAYS } . Budget still scales with relay count as a rough upper bound .
* /
const slotCap = Math . max ( 1 , MAX_CONCURRENT_RELAY_CONNECTIONS )
const publishWaves = Math . max ( 1 , Math . ceil ( uniqueRelayUrls . length / slotCap ) )
const perWaveBudgetMs =
RELAY_POOL_CONNECTION_TIMEOUT_MS + RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS + 10 _000
const publishGlobalDeadlineMs = Math . min (
600 _000 ,
Math . max (
RELAY_POOL_CONNECTION_TIMEOUT_MS + RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS + 30 _000 ,
publishWaves * perWaveBudgetMs + 25 _000
)
)
logger . debug ( '[PublishEvent] Setting up global timeout' , {
publishGlobalDeadlineMs ,
publishWaves ,
relayCount : uniqueRelayUrls.length ,
slotCap
} )
let hasResolved = false
let hasResolved = false
// Add a global timeout to prevent hanging - use 30 seconds for faster feedback
const globalTimeout = setTimeout ( ( ) = > {
const globalTimeout = setTimeout ( ( ) = > {
if ( hasResolved ) {
if ( hasResolved ) {
logger . debug ( '[PublishEvent] Already resolved, ignoring timeout' )
logger . debug ( '[PublishEvent] Already resolved, ignoring timeout' )
@ -1481,25 +1550,29 @@ class ClientService extends EventTarget {
totalCount : uniqueRelayUrls.length
totalCount : uniqueRelayUrls.length
} )
} )
}
}
} , 30 _000 ) // 30 seconds global timeout (reduced from 2 minutes)
} , publishGlobalDeadlineMs )
logger . debug ( '[PublishEvent] Starting Promise.allSettled for all relays' )
logger . debug ( '[PublishEvent] Starting Promise.allSettled for all relays' )
const relayPublishAllSettled = Promise . allSettled (
const relayPublishAllSettled = Promise . allSettled (
uniqueRelayUrls . map ( async ( url , index ) = > {
uniqueRelayUrls . map ( async ( url , index ) = > {
// eslint-disable-next-line @typescript-eslint/no-this-alias
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this
const that = this
await that . queryService . acquireGlobalRelayConnectionSlot ( )
const startMs = Date . now ( )
const startMs = Date . now ( )
logger . debug ( ` [PublishEvent] Starting relay ${ index + 1 } / ${ uniqueRelayUrls . length } ` , { url } )
logger . debug ( ` [PublishEvent] Starting relay ${ index + 1 } / ${ uniqueRelayUrls . length } ` , { url } )
const isLocal = isLocalNetworkUrl ( url )
const isLocal = isLocalNetworkUrl ( url )
const connectionTimeout = isLocal ? 5_000 : 8_000 // 5s for local, 8s for remote
/** Match pool handshake budget; a shorter outer race used to abort `ensureRelay` at 8s while the pool allowed 20s — slow TLS never won. */
const publishTimeout = isLocal ? 5_000 : 8_000 // 5s for local, 8s for remote
const connectionTimeout = isLocal ? 5_000 : RELAY_POOL_CONNECTION_TIMEOUT_MS
/** ACK wait: {@link applyRelayNip42AckTimeout} already sets relay.publishTimeout; do not override with a few seconds (extension signers + slow relays). */
const publishAckBudgetMs = isLocal ? 5_000 : RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS
const httpPublishBudgetMs = isLocal ? 5_000 : 8_000
// Set up a per-relay timeout to ensure we always reach the finally block
// Set up a per-relay timeout to ensure we always reach the finally block
const relayTimeout = setTimeout ( ( ) = > {
const relayTimeout = setTimeout ( ( ) = > {
logger . warn ( ` [PublishEvent] Per-relay timeout for ${ url } ` , { connectionTimeout , publishTimeout } )
logger . warn ( ` [PublishEvent] Per-relay watchdog fired for ${ url } ` , {
// This will be caught in the catch block if the promise is still pending
connectionTimeout ,
} , connectionTimeout + publishTimeout + 2 _000 ) // Add 2s buffer
publishAckBudgetMs
} )
} , connectionTimeout + publishAckBudgetMs + 2 _000 )
try {
try {
if ( isHttpRelayUrl ( url ) ) {
if ( isHttpRelayUrl ( url ) ) {
@ -1508,7 +1581,10 @@ class ClientService extends EventTarget {
await Promise . race ( [
await Promise . race ( [
publishEventToHttpRelay ( base , event ) ,
publishEventToHttpRelay ( base , event ) ,
new Promise < never > ( ( _ , reject ) = >
new Promise < never > ( ( _ , reject ) = >
setTimeout ( ( ) = > reject ( new Error ( ` HTTP publish timeout after ${ publishTimeout } ms ` ) ) , publishTimeout )
setTimeout (
( ) = > reject ( new Error ( ` HTTP publish timeout after ${ httpPublishBudgetMs } ms ` ) ) ,
httpPublishBudgetMs
)
)
)
] )
] )
that . recordPublishSuccess ( url , Date . now ( ) - startMs )
that . recordPublishSuccess ( url , Date . now ( ) - startMs )
@ -1518,89 +1594,120 @@ class ClientService extends EventTarget {
return
return
}
}
// For local relays, add a connection timeout
let relay : Relay
let relay : Relay
logger . debug ( ` [PublishEvent] Ensuring relay connection ` , { url , isLocal , connectionTimeout } )
for ( let wsAttempt = 0 ; wsAttempt < 2 ; wsAttempt ++ ) {
try {
logger . debug ( ` [PublishEvent] Ensuring relay connection ` , {
url ,
isLocal ,
connectionTimeout ,
wsAttempt
} )
const connectionPromise = isLocal
const ensureOpts = { connectionTimeout }
? Promise . race ( [
const connectionPromise = isLocal
this . pool . ensureRelay ( url ) ,
? Promise . race ( [
new Promise < Relay > ( ( _ , reject ) = >
this . pool . ensureRelay ( url , ensureOpts ) ,
setTimeout ( ( ) = > reject ( new Error ( 'Local relay connection timeout' ) ) , connectionTimeout )
new Promise < Relay > ( ( _ , reject ) = >
)
setTimeout ( ( ) = > reject ( new Error ( 'Local relay connection timeout' ) ) , connectionTimeout )
] )
)
: Promise . race ( [
] )
this . pool . ensureRelay ( url ) ,
: Promise . race ( [
new Promise < Relay > ( ( _ , reject ) = >
this . pool . ensureRelay ( url , ensureOpts ) ,
setTimeout ( ( ) = > reject ( new Error ( 'Remote relay connection timeout' ) ) , connectionTimeout )
new Promise < Relay > ( ( _ , reject ) = >
)
setTimeout ( ( ) = > reject ( new Error ( 'Remote relay connection timeout' ) ) , connectionTimeout )
] )
)
] )
relay = await connectionPromise
logger . debug ( ` [PublishEvent] Relay connected ` , { url } )
const relayKeyPub = normalizeUrl ( url ) || url
patchRelayNoticeForFetchFailures ( relay as unknown as AbstractRelay , relayKeyPub , ( u , m ) = >
that . recordRelayNoticeFetchFailure ( u , m )
)
relay = await connectionPromise
applyRelayNip42AckTimeout ( relay as unknown as AbstractRelay )
logger . debug ( ` [PublishEvent] Relay connected ` , { url } )
const relayKeyPub = normalizeUrl ( url ) || url
patchRelayNoticeForFetchFailures ( relay as unknown as AbstractRelay , relayKeyPub , ( u , m ) = >
that . recordRelayNoticeFetchFailure ( u , m )
)
relay . publishTimeout = publishTimeout
logger . debug ( ` [PublishEvent] Publishing to relay ` , { url } )
logger . debug ( ` [PublishEvent] Publishing to relay ` , { url } )
const publishPromise = relay
. publish ( event )
// Wrap publish in a timeout promise
. then ( ( ) = > {
const publishPromise = relay
logger . debug ( ` [PublishEvent] Successfully published to relay ` , { url } )
. publish ( event )
that . recordPublishSuccess ( url , Date . now ( ) - startMs )
. then ( ( ) = > {
this . trackEventSeenOn ( event . id , relay )
logger . debug ( ` [PublishEvent] Successfully published to relay ` , { url } )
successCount ++
that . recordPublishSuccess ( url , Date . now ( ) - startMs )
relayStatuses . push ( { url , success : true } )
this . trackEventSeenOn ( event . id , relay )
} )
successCount ++
. catch ( ( error ) = > {
relayStatuses . push ( { url , success : true } )
logger . warn ( ` [PublishEvent] Publish failed, checking if auth required ` , {
} )
url ,
. catch ( ( error ) = > {
error : error.message
logger . warn ( ` [PublishEvent] Publish failed, checking if auth required ` , { url , error : error.message } )
if (
error instanceof Error &&
isRelayAuthRequiredErrorMessage ( error . message ) &&
that . canSignerAuthenticateRelay ( )
) {
logger . debug ( ` [PublishEvent] Auth required, attempting authentication ` , { url } )
applyRelayNip42AckTimeout ( relay )
return authenticateNip42Relay ( relay , ( authEvt : EventTemplate ) = >
queueRelayAuthSign ( ( ) = > that . signer ! . signEvent ( authEvt ) )
)
. then ( ( ) = > {
logger . debug ( ` [PublishEvent] Auth successful, retrying publish ` , { url } )
return relay . publish ( event )
} )
. then ( ( ) = > {
logger . debug ( ` [PublishEvent] Successfully published after auth ` , { url } )
that . recordPublishSuccess ( url , Date . now ( ) - startMs )
this . trackEventSeenOn ( event . id , relay )
successCount ++
relayStatuses . push ( { url , success : true } )
} )
} )
. catch ( ( authError ) = > {
if (
logger . error ( ` [PublishEvent] Auth or publish failed ` , { url , error : authError.message } )
error instanceof Error &&
errors . push ( { url , error : authError } )
isRelayAuthRequiredErrorMessage ( error . message ) &&
relayStatuses . push ( { url , success : false , error : authError.message } )
that . canSignerAuthenticateRelay ( )
) {
logger . debug ( ` [PublishEvent] Auth required, attempting authentication ` , { url } )
applyRelayNip42AckTimeout ( relay as unknown as AbstractRelay )
return authenticateNip42Relay ( relay , ( authEvt : EventTemplate ) = >
queueRelayAuthSign ( ( ) = > that . signer ! . signEvent ( authEvt ) )
)
. then ( ( ) = > {
logger . debug ( ` [PublishEvent] Auth successful, retrying publish ` , { url } )
return relay . publish ( event )
} )
. then ( ( ) = > {
logger . debug ( ` [PublishEvent] Successfully published after auth ` , { url } )
that . recordPublishSuccess ( url , Date . now ( ) - startMs )
this . trackEventSeenOn ( event . id , relay )
successCount ++
relayStatuses . push ( { url , success : true } )
} )
. catch ( ( authError ) = > {
logger . error ( ` [PublishEvent] Auth or publish failed ` , { url , error : authError.message } )
errors . push ( { url , error : authError } )
relayStatuses . push ( { url , success : false , error : authError.message } )
that . recordSessionRelayFailure ( url )
} )
} else {
logger . error ( ` [PublishEvent] Publish failed ` , { url , error : error.message } )
errors . push ( { url , error } )
relayStatuses . push ( { url , success : false , error : error.message } )
that . recordSessionRelayFailure ( url )
that . recordSessionRelayFailure ( url )
} )
}
} else {
} )
logger . error ( ` [PublishEvent] Publish failed ` , { url , error : error.message } )
errors . push ( { url , error } )
await Promise . race ( [
relayStatuses . push ( { url , success : false , error : error.message } )
publishPromise ,
that . recordSessionRelayFailure ( url )
new Promise < void > ( ( _ , reject ) = >
setTimeout (
( ) = > reject ( new Error ( ` Publish timeout after ${ publishAckBudgetMs } ms ` ) ) ,
publishAckBudgetMs
)
)
] )
break
} catch ( wsErr ) {
const msg = wsErr instanceof Error ? wsErr.message : String ( wsErr )
const retriable =
wsAttempt === 0 &&
/Remote relay connection timeout|Local relay connection timeout|Publish timeout after|publish timed out|websocket closed|connection failed|relay connection closed|SendingOnClosedConnection/i . test (
msg
)
if ( ! retriable ) {
throw wsErr
}
}
} )
logger . info ( '[PublishEvent] Closing pooled relay and retrying publish once' , { url , msg } )
try {
// Add a timeout wrapper for the entire publish operation
this . pool . close ( [ url ] )
await Promise . race ( [
} catch {
publishPromise ,
/* ignore */
new Promise < void > ( ( _ , reject ) = >
}
setTimeout ( ( ) = > reject ( new Error ( ` Publish timeout after ${ publishTimeout } ms ` ) ) , publishTimeout )
await new Promise ( ( r ) = > setTimeout ( r , 400 ) )
)
}
] )
}
} catch ( error ) {
} catch ( error ) {
const softHttpDown =
const softHttpDown =
isHttpRelayUrl ( url ) &&
isHttpRelayUrl ( url ) &&
@ -1624,7 +1731,6 @@ class ClientService extends EventTarget {
} )
} )
that . recordSessionRelayFailure ( url )
that . recordSessionRelayFailure ( url )
} finally {
} finally {
that . queryService . releaseGlobalRelayConnectionSlot ( )
clearTimeout ( relayTimeout )
clearTimeout ( relayTimeout )
const currentFinished = ++ finishedCount
const currentFinished = ++ finishedCount
logger . debug ( ` [PublishEvent] Relay finished ` , {
logger . debug ( ` [PublishEvent] Relay finished ` , {
@ -3650,21 +3756,74 @@ class ClientService extends EventTarget {
)
)
const budgetMs = PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS
const budgetMs = PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS
/** True only when *every* pubkey in this batch already has kind 10002 in IDB (not just you). */
const allHaveKind10002 = pubkeys . every ( ( _ , i ) = > storedRelayEvents [ i ] != null )
const allHaveKind10002 = pubkeys . every ( ( _ , i ) = > storedRelayEvents [ i ] != null )
const networkBundle = async ( ) : Promise < {
/ * *
* Fill gaps from the network : start from IDB rows , then fetch kind 10002 + 10243 * * only for pubkeys
* missing 10002 * * ( and 10243 - only where 10002 exists but HTTP list does not ) . Avoids re - downloading
* your relay list on every reply just because the parent author ’ s 10002 was never cached .
* /
const hydrateRelayListsFromNetwork = async ( ) : Promise < {
relayEvents : ( NEvent | null | undefined ) [ ]
relayEvents : ( NEvent | null | undefined ) [ ]
httpRelayEvents : ( NEvent | null | undefined ) [ ]
httpRelayEvents : ( NEvent | null | undefined ) [ ]
cacheRelayEvents : ( NEvent | null | undefined ) [ ]
cacheRelayEvents : ( NEvent | null | undefined ) [ ]
} > = > {
} > = > {
const relayEvents = await this . replaceableEventService . fetchReplaceableEventsFromProfileFetchRelays (
const relayEvents : ( NEvent | null | undefined ) [ ] = pubkeys . map ( ( _ , i ) = >
pubkeys ,
storedRelayEvents [ i ] != null ? ( storedRelayEvents [ i ] as NEvent ) : undefined
kinds . RelayList
)
)
const httpRelayEvents = await this . replaceableEventService . fetchReplaceableEventsFromProfileFetchRelays (
const httpRelayEvents : ( NEvent | null | undefined ) [ ] = pubkeys . map ( ( _ , i ) = >
pubkeys ,
storedHttpRelayEvents [ i ] != null ? ( storedHttpRelayEvents [ i ] as NEvent ) : undefined
ExtendedKind . HTTP_RELAY_LIST
)
const missing10002Pubkeys = pubkeys . filter ( ( _pk , i ) = > storedRelayEvents [ i ] == null )
if ( missing10002Pubkeys . length > 0 ) {
logger . debug (
'[FetchRelayLists] Kind 10002 missing in IndexedDB for some pubkeys; fetching only those over the network' ,
{
batchSize : pubkeys.length ,
missingCount : missing10002Pubkeys.length ,
missingPubkeyPrefixes : missing10002Pubkeys.map ( ( p ) = > p . slice ( 0 , 12 ) )
}
)
const [ relFetched , httpFetched ] = await Promise . all ( [
this . replaceableEventService . fetchReplaceableEventsFromProfileFetchRelays (
missing10002Pubkeys ,
kinds . RelayList
) ,
this . replaceableEventService . fetchReplaceableEventsFromProfileFetchRelays (
missing10002Pubkeys ,
ExtendedKind . HTTP_RELAY_LIST
)
] )
let j = 0
for ( let i = 0 ; i < pubkeys . length ; i ++ ) {
if ( storedRelayEvents [ i ] == null ) {
relayEvents [ i ] = relFetched [ j ] ? ? undefined
httpRelayEvents [ i ] = httpFetched [ j ] ? ? undefined
j ++
}
}
}
const missingHttpOnlyPubkeys = pubkeys . filter (
( _pk , i ) = > storedRelayEvents [ i ] != null && storedHttpRelayEvents [ i ] == null
)
)
if ( missingHttpOnlyPubkeys . length > 0 ) {
const httpOnlyFetched =
await this . replaceableEventService . fetchReplaceableEventsFromProfileFetchRelays (
missingHttpOnlyPubkeys ,
ExtendedKind . HTTP_RELAY_LIST
)
let j = 0
for ( let i = 0 ; i < pubkeys . length ; i ++ ) {
if ( storedRelayEvents [ i ] != null && storedHttpRelayEvents [ i ] == null ) {
httpRelayEvents [ i ] = httpOnlyFetched [ j ] ? ? undefined
j ++
}
}
}
const cacheRelayEvents = await this . fetchCacheRelayEventsFromMultipleSources (
const cacheRelayEvents = await this . fetchCacheRelayEventsFromMultipleSources (
pubkeys ,
pubkeys ,
relayEvents ,
relayEvents ,
@ -3697,10 +3856,11 @@ class ClientService extends EventTarget {
}
}
const raced = await Promise . race ( [
const raced = await Promise . race ( [
networkBundle ( ) ,
hydrateRelayListsFromNetwork ( ) ,
new Promise < null > ( ( resolve ) = > setTimeout ( ( ) = > resolve ( null ) , budgetMs ) )
new Promise < null > ( ( resolve ) = > setTimeout ( ( ) = > resolve ( null ) , budgetMs ) )
] )
] )
if ( raced != null ) {
if ( raced != null ) {
this . refreshRelayListsFromNetwork ( pubkeys , storedRelayEvents )
return this . mergeRelayListsBundle (
return this . mergeRelayListsBundle (
pubkeys ,
pubkeys ,
raced . relayEvents ,
raced . relayEvents ,