import { Inbox, Lifecycle, spawn, loop, STOP } from 'anneal' import { EventTemplate, Filter, Event as NEvent, VerifiedEvent } from 'nostr-tools' import { SimplePool } from 'nostr-tools' import { AbstractRelay } from 'nostr-tools/abstract-relay' import { ISigner } from '@/types' type RelayEventMsg = { event: NEvent; url: string } type RelayEoseMsg = { url: string } type RelayCloseMsg = { reason: string; url: string } export type SubscriptionHandle = { lc: Lifecycle close: () => void } export type SubscriptionConfig = { urls: string[] filters: Filter[] pool: SimplePool signer?: ISigner startLogin?: () => void onevent?: (evt: NEvent) => void oneose?: (eosed: boolean) => void onclose?: (reasons: string[]) => void onReceived?: (id: string, relay: AbstractRelay) => void } export function createSubscription(config: SubscriptionConfig): SubscriptionHandle { const { urls, filters, pool, signer, startLogin, onReceived } = config let { onevent, oneose, onclose } = config const relays = Array.from(new Set(urls)) const relayEvent = new Inbox(0) const relayEose = new Inbox(0) const relayClose = new Inbox(0) const lc = new Lifecycle() const subClosers: (() => void)[] = [] const knownIds = new Set() // Fix for race #1: count all relays BEFORE any async work. // EOSE check uses this fixed value - no interleaving possible. const startedCount = relays.length let eosedCount = 0 let eosed = false let closedCount = 0 const closeReasons: string[] = [] const eosedRelays = new Set() const authedRelays = new Set() function connectRelay(url: string) { ;(async () => { let relay: AbstractRelay | undefined try { relay = await pool.ensureRelay(url, { connectionTimeout: 5000 }) } catch { // Connection failed - count as EOSE so we don't block the threshold relayEose.send({ url }) return } if (!relay) { relayEose.send({ url }) return } const sub = relay.subscribe(filters, { receivedEvent: (r: AbstractRelay, id: string) => { onReceived?.(id, r) }, alreadyHaveEvent: (id: string) => { if (knownIds.has(id)) return true knownIds.add(id) return false }, onevent: (evt: NEvent) => { relayEvent.send({ event: evt, url }) }, oneose: () => { relayEose.send({ url }) }, onclose: (reason: string) => { relayClose.send({ reason, url }) }, eoseTimeout: 10_000 }) subClosers.push(() => sub.close()) })() } // Fire-and-forget relay connections for (const url of relays) { connectRelay(url) } // Actor loop - processes relay messages one at a time spawn(lc, async () => { await loop( lc.on(() => { for (const closer of subClosers) closer() return STOP }), relayEvent.on(({ event }) => { onevent?.(event) }), relayEose.on(({ url }) => { if (eosed) return if (eosedRelays.has(url)) return eosedRelays.add(url) eosedCount++ eosed = eosedCount >= startedCount oneose?.(eosed) }), relayClose.on(({ reason, url }) => { if (reason.startsWith('auth-required') && !authedRelays.has(url)) { if (signer) { authedRelays.add(url) ;(async () => { try { const relay = await pool.ensureRelay(url) await relay.auth(async (authEvt: EventTemplate) => { const evt = await signer.signEvent(authEvt) if (!evt) throw new Error('sign event failed') return evt as VerifiedEvent }) // Re-subscribe only if we haven't reached final EOSE yet. // startedCount is NOT incremented - the re-sub fills the // original relay's EOSE slot. if (!eosed) connectRelay(url) } catch { // Auth failed - count this relay's slot as EOSE relayEose.send({ url }) } })() return } if (startLogin) { startLogin() return } } closedCount++ closeReasons.push(reason) if (closedCount >= startedCount) { onclose?.(closeReasons) } }) ) }) return { lc, close: () => { onevent = undefined oneose = undefined onclose = undefined for (const closer of subClosers) closer() lc.stop() } } }