Files
damus/damus/Core/Nostr/RelayPool.swift
T
William CasarinandClaude Opus 4.6 aae43cff3b Fix connectivity indicator and port signal bars from Android
The SignalView was never appearing because update_signal_from_pool was
defined but never called, leaving signal data at 0/0. Fix by having
RelayPool maintain its own SignalModel, updating it on connection
events, relay add/remove. Wire views directly to the pool signal via
NostrNetworkManager instead of the disconnected HomeModel.signal.

Replace the text-based indicator with signal strength bars matching
the Android app: 4 progressive-height bars colored red->yellow->green
based on the connected/total relay ratio.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 11:00:00 -07:00

948 lines
43 KiB
Swift

//
// RelayPool.swift
// damus
//
// Created by William Casarin on 2022-04-11.
//
import Foundation
import Network
import Negentropy
import Sentry
struct RelayHandler {
let sub_id: String
/// The filters that this handler will handle. Set this to `nil` if you want your handler to receive all events coming from the relays.
let filters: [NostrFilter]?
let to: [RelayURL]?
var handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation
}
struct QueuedRequest {
let req: NostrRequestType
let relay: RelayURL
let skip_ephemeral: Bool
}
struct SeenEvent: Hashable {
let relay_id: RelayURL
let evid: NoteId
}
@globalActor
actor RelayPoolActor {
static let shared = RelayPoolActor()
private init() {}
}
/// Establishes and manages connections and subscriptions to a list of relays.
@RelayPoolActor
class RelayPool {
@MainActor
private(set) var relays: [Relay] = []
var open: Bool = false
var handlers: [RelayHandler] = []
var request_queue: [QueuedRequest] = []
var seen: [NoteId: Set<RelayURL>] = [:]
var counts: [RelayURL: UInt64] = [:]
var ndb: Ndb?
/// The keypair used to authenticate with relays
var keypair: Keypair?
var message_received_function: (((String, RelayDescriptor)) -> Void)?
var message_sent_function: (((String, Relay)) -> Void)?
var delegate: Delegate?
@MainActor
private(set) var signal: SignalModel = SignalModel()
/// Tracks active leases on ephemeral relays to prevent premature cleanup.
/// Each lookup that uses an ephemeral relay acquires a lease; cleanup only
/// happens when the last lease is released.
private var ephemeralLeases: [RelayURL: Int] = [:]
let network_monitor = NWPathMonitor()
private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor")
private var last_network_status: NWPath.Status = .unsatisfied
/// The limit of maximum concurrent subscriptions. Any subscriptions beyond this limit will be paused until subscriptions clear
/// This is to avoid error states and undefined behaviour related to hitting subscription limits on the relays, by letting those wait instead — with the principle that although slower is not ideal, it is better than completely broken.
static let MAX_CONCURRENT_SUBSCRIPTION_LIMIT = 14 // This number is only an educated guess based on some local experiments.
func close() async {
await disconnect()
await clearRelays()
open = false
handlers = []
request_queue = []
await clearSeen()
counts = [:]
keypair = nil
}
@MainActor
private func clearRelays() {
relays = []
}
private func clearSeen() {
seen.removeAll()
}
nonisolated init(ndb: Ndb?, keypair: Keypair? = nil) {
self.ndb = ndb
self.keypair = keypair
network_monitor.pathUpdateHandler = { [weak self] path in
Task { await self?.pathUpdateHandler(path: path) }
}
network_monitor.start(queue: network_monitor_queue)
}
private func pathUpdateHandler(path: NWPath) async {
if (path.status == .satisfied || path.status == .requiresConnection) && self.last_network_status != path.status {
await self.connect_to_disconnected()
}
if path.status != self.last_network_status {
for relay in await self.relays {
relay.connection.log?.add("Network state: \(path.status)")
}
}
self.last_network_status = path.status
}
@MainActor
var our_descriptors: [RelayDescriptor] {
return all_descriptors.filter { d in !d.ephemeral }
}
@MainActor
var all_descriptors: [RelayDescriptor] {
relays.map { r in r.descriptor }
}
@MainActor
var num_connected: Int {
return relays.reduce(0) { n, r in n + (r.connection.isConnected ? 1 : 0) }
}
@MainActor
func update_signal() {
let connected = num_connected
let total = relays.count
if signal.signal != connected {
signal.signal = connected
}
if signal.max_signal != total {
signal.max_signal = total
}
}
func remove_handler(sub_id: String) {
self.handlers = handlers.filter {
if $0.sub_id != sub_id {
return true
}
else {
$0.handler.finish()
return false
}
}
Log.debug("Removing %s handler, current: %d", for: .networking, sub_id, handlers.count)
}
func ping() async {
Log.info("Pinging %d relays", for: .networking, await relays.count)
for relay in await relays {
relay.connection.ping()
}
}
func register_handler(sub_id: String, filters: [NostrFilter]?, to relays: [RelayURL]? = nil, handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation) async {
while handlers.count > Self.MAX_CONCURRENT_SUBSCRIPTION_LIMIT {
Log.debug("%s: Too many subscriptions, waiting for subscription pool to clear", for: .networking, sub_id)
try? await Task.sleep(for: .seconds(1))
}
Log.debug("%s: Subscription pool cleared", for: .networking, sub_id)
handlers = handlers.filter({ handler in
if handler.sub_id == sub_id {
Log.error("Duplicate handler detected for the same subscription ID. Overriding.", for: .networking)
handler.handler.finish()
return false
}
else {
return true
}
})
self.handlers.append(RelayHandler(sub_id: sub_id, filters: filters, to: relays, handler: handler))
Log.debug("Registering %s handler, current: %d", for: .networking, sub_id, self.handlers.count)
}
/// Removes the relay with the given URL from the pool, permanently disables its connection, and ensures it is disconnected.
/// - Parameters:
/// - relay_id: The RelayURL identifying the relay to disable and remove.
@MainActor
func remove_relay(_ relay_id: RelayURL) async {
var i: Int = 0
await self.disconnect(to: [relay_id])
for relay in relays {
if relay.id == relay_id {
relay.connection.disablePermanently()
relays.remove(at: i)
break
}
i += 1
}
update_signal()
}
/// Acquires a lease on ephemeral relays to prevent them from being cleaned up
/// Increment lease counts for the given ephemeral relay URLs to prevent their removal while leased.
/// - Parameters:
/// - relayURLs: The relay URLs whose ephemeral lease counts will be incremented; each URL's lease count is increased by one.
func acquireEphemeralRelays(_ relayURLs: [RelayURL]) {
for url in relayURLs {
ephemeralLeases[url, default: 0] += 1
#if DEBUG
print("[RelayPool] Acquired lease on ephemeral relay \(url.absoluteString), count: \(ephemeralLeases[url] ?? 0)")
#endif
}
}
/// Releases leases on ephemeral relays. When the last lease is released,
/// Releases one lease for each specified relay and removes any ephemeral relay when its last lease is released.
/// - Parameters:
/// - relayURLs: Relay URLs whose leases should be decremented. If a relay's lease count reaches zero and the relay is marked ephemeral, the relay will be removed. Relays not present in the lease table are ignored.
func releaseEphemeralRelays(_ relayURLs: [RelayURL]) async {
for url in relayURLs {
guard let count = ephemeralLeases[url], count > 0 else { continue }
// Decrement immediately (atomic with respect to this actor, before any suspension)
let newCount = count - 1
ephemeralLeases[url] = newCount == 0 ? nil : newCount
#if DEBUG
print("[RelayPool] Released lease on ephemeral relay \(url.absoluteString), count: \(newCount)")
#endif
if newCount == 0 {
// Check if relay exists and is ephemeral
if let relay = await get_relay(url), relay.descriptor.ephemeral {
// Re-check: only remove if lease is still nil (not re-acquired during await)
guard ephemeralLeases[url] == nil else {
#if DEBUG
print("[RelayPool] Lease re-acquired during check, skipping removal: \(url.absoluteString)")
#endif
continue
}
#if DEBUG
print("[RelayPool] Removing ephemeral relay: \(url.absoluteString)")
#endif
await remove_relay(url)
}
}
}
}
/// Adds and registers a new relay in the pool using the provided descriptor.
/// - Parameter desc: Descriptor for the relay to add (includes its URL, metadata, and whether it is ephemeral).
/// - Throws: `RelayError.RelayAlreadyExists` if a relay with the same URL is already present in the pool.
func add_relay(_ desc: RelayDescriptor) async throws(RelayError) {
let relay_id = desc.url
if await get_relay(relay_id) != nil {
throw RelayError.RelayAlreadyExists
}
let conn = RelayConnection(url: desc.url, handleEvent: { event in
await self.handle_event(relay_id: relay_id, event: event)
}, processUnverifiedWSEvent: { wsev in
guard case .message(let msg) = wsev,
case .string(let str) = msg
else { return }
#if DEBUG
if desc.ephemeral {
if str.hasPrefix("[\"EVENT\"") {
print("[RelayPool] Received EVENT from ephemeral relay \(relay_id.absoluteString): \(str.prefix(200))...")
} else if str.hasPrefix("[\"EOSE\"") {
print("[RelayPool] Received EOSE from ephemeral relay \(relay_id.absoluteString)")
}
}
#endif
let _ = self.ndb?.processEvent(str, originRelayURL: relay_id)
self.message_received_function?((str, desc))
})
let relay = Relay(descriptor: desc, connection: conn)
await self.appendRelayToList(relay: relay)
}
/// Appends the given Relay to the pool's internal list of relays.
@MainActor
private func appendRelayToList(relay: Relay) {
self.relays.append(relay)
update_signal()
}
/// Ensures the given relay URLs are connected, adding them as ephemeral relays if not already in the pool.
/// Returns the list of relay URLs that are actually connected (ready for subscriptions).
///
/// Callers should use `acquireEphemeralRelays` before the lookup and `releaseEphemeralRelays` after.
///
/// - Parameters:
/// - relayURLs: The relay URLs to ensure are connected
/// - timeout: Maximum time to wait for pending connections (default 2s). Returns early when first relay connects.
/// Ensure the given relays are present in the pool and return those that are connected.
///
/// This will add missing URLs as ephemeral relays, initiate connections for relays that are not connected, and wait up to `timeout` for connections to establish. Once any relay connects, the method allows a short grace period for additional relays to connect before returning.
/// - Parameters:
/// - relayURLs: The relay URLs to ensure connectivity for. Missing URLs will be added as ephemeral relays.
/// - timeout: Maximum time to wait for connections (default: 2 seconds). A short grace period (≈300 ms) is applied after the first relay connects.
/// - Returns: The subset of `relayURLs` that are currently connected (includes relays that were already connected and those that became connected during the wait).
func ensureConnected(to relayURLs: [RelayURL], timeout: Duration = .seconds(2)) async -> [RelayURL] {
var toConnect: [RelayURL] = []
var alreadyConnected: [RelayURL] = []
for url in relayURLs {
if let existing = await get_relay(url) {
if existing.connection.isConnected {
alreadyConnected.append(url)
#if DEBUG
print("[RelayPool] Relay \(url.absoluteString) already connected")
#endif
} else {
toConnect.append(url)
}
continue
}
let descriptor = RelayDescriptor(url: url, info: .readWrite, variant: .ephemeral)
do {
try await add_relay(descriptor)
toConnect.append(url)
#if DEBUG
print("[RelayPool] Added ephemeral relay: \(url.absoluteString)")
#endif
} catch {
#if DEBUG
print("[RelayPool] Failed to add relay \(url.absoluteString): \(error)")
#endif
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "add_ephemeral_relay",
"relay_url": url.absoluteString
], key: "relay_pool")
}
}
}
guard !toConnect.isEmpty else { return alreadyConnected }
await connect(to: toConnect)
let checkInterval: Duration = .milliseconds(50)
let overallDeadline = ContinuousClock.now + timeout
var graceDeadline: ContinuousClock.Instant? = alreadyConnected.isEmpty ? nil : ContinuousClock.now + .milliseconds(300)
// Wait for relays to connect. Once the first connects, start a grace period for others.
waitLoop: while ContinuousClock.now < overallDeadline {
do {
try await Task.sleep(for: checkInterval)
} catch {
break
}
// Check if any relay has connected
var anyConnected = false
for url in toConnect {
if let relay = await get_relay(url), relay.connection.isConnected {
anyConnected = true
break
}
}
if anyConnected && graceDeadline == nil {
// Start grace period on first connection
graceDeadline = ContinuousClock.now + .milliseconds(300)
}
// Exit once grace period expires (check every iteration if deadline is set)
if let deadline = graceDeadline, ContinuousClock.now >= deadline {
break waitLoop
}
}
// Collect all connected relays
var connected = alreadyConnected
for url in toConnect {
if let relay = await get_relay(url), relay.connection.isConnected {
connected.append(url)
#if DEBUG
print("[RelayPool] Relay \(url.absoluteString) connected: true")
#endif
} else {
#if DEBUG
print("[RelayPool] Relay \(url.absoluteString) connected: false (excluded)")
#endif
}
}
return connected
}
/// Attaches a `RelayLog` to the connection for the specified relay and records the current network status in the log.
/// - Parameters:
/// - log: The `RelayLog` instance to attach to the relay's connection.
/// - relay_id: The `RelayURL` identifying the relay whose connection will receive the log.
func setLog(_ log: RelayLog, for relay_id: RelayURL) async {
// add the current network state to the log
log.add("Network state: \(network_monitor.currentPath.status)")
await get_relay(relay_id)?.connection.log = log
}
/// This is used to retry dead connections
func connect_to_disconnected() async {
for relay in await relays {
let c = relay.connection
let is_connecting = c.isConnecting
if is_connecting && (Date.now.timeIntervalSince1970 - c.last_connection_attempt) > 5 {
print("stale connection detected (\(relay.descriptor.url.absoluteString)). retrying...")
relay.connection.reconnect()
} else if relay.is_broken || is_connecting || c.isConnected {
continue
} else {
relay.connection.reconnect()
}
}
}
func reconnect(to targetRelays: [RelayURL]? = nil) async {
let relays = await getRelays(targetRelays: targetRelays)
for relay in relays {
// don't try to reconnect to broken relays
relay.connection.reconnect()
}
}
func connect(to targetRelays: [RelayURL]? = nil) async {
let relays = await getRelays(targetRelays: targetRelays)
for relay in relays {
relay.connection.connect()
}
// Mark as open last, to prevent other classes from pulling data before the relays are actually connected
// Only mark as open when connecting ALL relays (not specific ones)
if targetRelays == nil {
open = true
}
}
func disconnect(to targetRelays: [RelayURL]? = nil) async {
// Mark as closed first, to prevent other classes from pulling data while the relays are being disconnected
// Only mark as closed when disconnecting ALL relays (not specific ones)
if targetRelays == nil {
open = false
}
let relays = await getRelays(targetRelays: targetRelays)
for relay in relays {
relay.connection.disconnect()
}
}
/// Gets relays matching the provided relay URLs, or all relays when no targets are specified.
/// - Parameter targetRelays: Optional list of relay URLs to filter by. If `nil`, the pool's full relay list is returned.
/// - Returns: An array of `Relay` instances corresponding to the requested URLs; any requested URL not present in the pool is omitted from the result.
@MainActor
func getRelays(targetRelays: [RelayURL]? = nil) -> [Relay] {
let result = targetRelays.map{ get_relays($0) } ?? self.relays
#if DEBUG
if let targets = targetRelays {
let found = result.map { $0.descriptor.url.absoluteString }
let requested = targets.map { $0.absoluteString }
if found.count != targets.count {
print("[RelayPool] getRelays: MISMATCH! requested=\(requested) but found=\(found)")
}
}
#endif
return result
}
/// Deletes queued up requests that should not persist between app sessions (i.e. when the app goes to background then back to foreground)
func cleanQueuedRequestForSessionEnd() {
request_queue = request_queue.filter { request in
guard case .typical(let typicalRequest) = request.req else { return true }
switch typicalRequest {
case .subscribe(_):
return true
case .unsubscribe(_):
return false // Do not persist unsubscribe requests to prevent them to race against subscribe requests when we come back to the foreground.
case .event(_):
return true
case .auth(_):
return true
case .negentropyOpen(subscriptionId: _, filter: _, initialMessage: _):
return false // Do not persist negentropy requests across sessions
case .negentropyMessage(subscriptionId: _, message: _):
return false // Do not persist negentropy requests across sessions
case .negentropyClose(subscriptionId: _):
return false // Do not persist negentropy requests across sessions
}
}
}
func unsubscribe(sub_id: String, to: [RelayURL]? = nil) async {
if to == nil {
self.remove_handler(sub_id: sub_id)
}
await self.send(.unsubscribe(sub_id), to: to)
}
func subscribe(sub_id: String, filters: [NostrFilter], handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation, to: [RelayURL]? = nil) {
Task {
await register_handler(sub_id: sub_id, filters: filters, to: to, handler: handler)
// When the caller specifies no relays, it is implied that the user wants to use the ones in the user relay list. Skip ephemeral relays in that case.
// When the caller specifies specific relays, do not skip ephemeral relays to respect the exact list given by the caller.
let shouldSkipEphemeralRelays = to == nil ? true : false
await send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to, skip_ephemeral: shouldSkipEphemeralRelays)
}
}
/// Subscribes to data from the `RelayPool` based on a filter and a list of desired relays.
///
/// - Parameters:
/// - filters: The filters specifying the desired content.
/// - desiredRelays: The desired relays which to subsctibe to. If `nil`, it defaults to the `RelayPool`'s default list
/// - eoseTimeout: The maximum timeout which to give up waiting for the eoseSignal
/// Open a subscription for the given filters and provide a stream of matching items and EOSE notifications.
/// - Parameters:
/// - filters: The list of NostrFilter objects that define which events to receive.
/// - desiredRelays: Optional list of RelayURL to subscribe to; when `nil` the pool's relays are used.
/// - eoseTimeout: Optional timeout to wait before emitting an EOSE if not all relays have reported EOSE; defaults to 5 seconds.
/// - id: Optional UUID to use as the subscription identifier; a new UUID is generated when `nil`.
/// - Returns: An AsyncStream that yields StreamItem values representing matched events and end-of-stream (EOSE) notifications for this subscription. The stream deduplicates events by their NoteId. When the stream terminates it will unsubscribe from the chosen relays and remove the internal handler.
func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil, id: UUID? = nil) async -> AsyncStream<StreamItem> {
let eoseTimeout = eoseTimeout ?? .seconds(5)
let desiredRelays = await getRelays(targetRelays: desiredRelays)
#if DEBUG
print("[RelayPool] subscribe: requested=\(desiredRelays.map { $0.descriptor.url.absoluteString }), pool has \(await relays.count) relays")
if let ids = filters.first?.ids {
print("[RelayPool] subscribe: filter ids=\(ids.map { $0.hex() })")
}
#endif
let startTime = CFAbsoluteTimeGetCurrent()
return AsyncStream<StreamItem> { continuation in
let id = id ?? UUID()
let sub_id = id.uuidString
var seenEvents: Set<NoteId> = []
var relaysWhoFinishedInitialResults: Set<RelayURL> = []
var eoseSent = false
let upstreamStream = AsyncStream<(RelayURL, NostrConnectionEvent)> { upstreamContinuation in
self.subscribe(sub_id: sub_id, filters: filters, handler: upstreamContinuation, to: desiredRelays.map({ $0.descriptor.url }))
}
let upstreamStreamingTask = Task {
for await (relayUrl, connectionEvent) in upstreamStream {
try Task.checkCancellation()
switch connectionEvent {
case .ws_connection_event(let ev):
// Websocket events such as connect/disconnect/error are already handled in `RelayConnection`. Do not perform any handling here.
// For the future, perhaps we should abstract away `.ws_connection_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer.
break
case .nostr_event(let nostrResponse):
guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription
switch nostrResponse {
case .event(_, let nostrEvent):
if seenEvents.contains(nostrEvent.id) { break } // Don't send two of the same events.
continuation.yield(with: .success(.event(nostrEvent)))
seenEvents.insert(nostrEvent.id)
case .notice(let note):
break // We do not support handling these yet
case .eose(_):
relaysWhoFinishedInitialResults.insert(relayUrl)
let desiredAndConnectedRelays = desiredRelays.filter({ $0.connection.isConnected }).map({ $0.descriptor.url })
Log.debug("RelayPool subscription %s: EOSE from %s. EOSE count: %d/%d. Elapsed: %.2f seconds.", for: .networking, id.uuidString, relayUrl.absoluteString, relaysWhoFinishedInitialResults.count, Set(desiredAndConnectedRelays).count, CFAbsoluteTimeGetCurrent() - startTime)
if relaysWhoFinishedInitialResults == Set(desiredAndConnectedRelays) {
continuation.yield(with: .success(.eose))
eoseSent = true
}
case .ok(_): break // No need to handle this, we are not sending an event to the relay
case .auth(_): break // Handled in a separate function in RelayPool
case .negentropyError(subscriptionId: _, reasonCodeString: _): break // Not handled in regular subscriptions
case .negentropyMessage(subscriptionId: _, hexEncodedData: _): break // Not handled in regular subscriptions
}
}
}
}
let timeoutTask = Task {
try? await Task.sleep(for: eoseTimeout)
if !eoseSent { continuation.yield(with: .success(.eose)) }
}
continuation.onTermination = { @Sendable termination in
switch termination {
case .finished:
Log.debug("RelayPool subscription %s finished. Closing.", for: .networking, sub_id)
case .cancelled:
Log.debug("RelayPool subscription %s cancelled. Closing.", for: .networking, sub_id)
@unknown default:
break
}
Task {
await self.unsubscribe(sub_id: sub_id, to: desiredRelays.map({ $0.descriptor.url }))
await self.remove_handler(sub_id: sub_id)
}
timeoutTask.cancel()
upstreamStreamingTask.cancel()
}
}
}
/// This streams events that are pre-existing on the relay, and stops streaming as soon as it receives the EOSE signal.
func subscribeExistingItems(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil, id: UUID? = nil) -> AsyncStream<NostrEvent> {
return AsyncStream<NostrEvent>.with(task: { continuation in
outerLoop: for await item in await self.subscribe(filters: filters, to: desiredRelays, eoseTimeout: eoseTimeout, id: id) {
if Task.isCancelled { return }
switch item {
case .event(let event):
continuation.yield(event)
case .eose:
break outerLoop
}
}
})
}
enum StreamItem {
/// A Nostr event
case event(NostrEvent)
/// The "end of stored events" signal
case eose
}
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation) {
Task {
await register_handler(sub_id: sub_id, filters: filters, to: to, handler: handler)
await send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
}
}
func count_queued(relay: RelayURL) -> Int {
var c = 0
for request in request_queue {
if request.relay == relay {
c += 1
}
}
return c
}
func queue_req(r: NostrRequestType, relay: RelayURL, skip_ephemeral: Bool) {
let count = count_queued(relay: relay)
guard count <= 10 else {
print("can't queue, too many queued events for \(relay)")
return
}
print("queueing request for \(relay)")
request_queue.append(QueuedRequest(req: r, relay: relay, skip_ephemeral: skip_ephemeral))
}
func send_raw_to_local_ndb(_ req: NostrRequestType) {
// send to local relay (nostrdb)
switch req {
case .typical(let r):
if case .event = r, let rstr = make_nostr_req(r) {
let _ = ndb?.process_client_event(rstr)
}
case .custom(let string):
let _ = ndb?.process_client_event(string)
}
}
/// Dispatches a Nostr request to the pool's matching relays, writing a local copy to the NostrDB and queuing the request for any relay that is not currently connected.
///
/// Filters target relays by their read/write capabilities and, optionally, by ephemeral status; connected relays receive the request immediately and disconnected relays have the request queued for later delivery. Sent messages are reported via `message_sent_function` when available.
/// - Parameters:
/// - req: The Nostr request to send.
/// - to: Optional list of relay URLs to restrict delivery to; `nil` targets the pool's default set of relays.
/// - skip_ephemeral: If `true`, skip ephemeral relays when sending the request.
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) async {
let relays = await getRelays(targetRelays: to)
self.send_raw_to_local_ndb(req) // Always send Nostr events and data to NostrDB for a local copy
for relay in relays {
if req.is_read && !(relay.descriptor.info.canRead) {
continue // Do not send read requests to relays that are not READ relays
}
if req.is_write && !(relay.descriptor.info.canWrite) {
continue // Do not send write requests to relays that are not WRITE relays
}
if relay.descriptor.ephemeral && skip_ephemeral {
continue // Do not send requests to ephemeral relays if we want to skip them
}
guard relay.connection.isConnected else {
Task { await queue_req(r: req, relay: relay.id, skip_ephemeral: skip_ephemeral) }
continue
}
relay.connection.send(req, callback: { str in
#if DEBUG
if relay.descriptor.ephemeral && str.hasPrefix("[\"REQ\"") {
print("[RelayPool] Sending REQ to ephemeral relay \(relay.id.absoluteString): \(str)")
}
#endif
self.message_sent_function?((str, relay))
})
}
}
func send(_ req: NostrRequest, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) async {
await send_raw(.typical(req), to: to, skip_ephemeral: skip_ephemeral)
}
@MainActor
func get_relays(_ ids: [RelayURL]) -> [Relay] {
// don't include ephemeral relays in the default list to query
relays.filter { ids.contains($0.id) }
}
@MainActor
func get_relay(_ id: RelayURL) -> Relay? {
relays.first(where: { $0.id == id })
}
func run_queue(_ relay_id: RelayURL) {
self.request_queue = request_queue.reduce(into: Array<QueuedRequest>()) { (q, req) in
guard req.relay == relay_id else {
q.append(req)
return
}
print("running queueing request: \(req.req) for \(relay_id)")
Task { await self.send_raw(req.req, to: [relay_id], skip_ephemeral: false) }
}
}
func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
if case .nostr_event(let ev) = event {
if case .event(_, let nev) = ev {
if seen[nev.id]?.contains(relay_id) == true {
return
}
seen[nev.id, default: Set()].insert(relay_id)
counts[relay_id, default: 0] += 1
notify(.update_stats(note_id: nev.id))
}
}
}
func resubscribeAll(relayId: RelayURL) async {
for handler in self.handlers {
guard let filters = handler.filters else { continue }
// When the caller specifies no relays, it is implied that the user wants to use the ones in the user relay list. Skip ephemeral relays in that case.
// When the caller specifies specific relays, do not skip ephemeral relays to respect the exact list given by the caller.
let shouldSkipEphemeralRelays = handler.to == nil ? true : false
if let handlerTargetRelays = handler.to,
!handlerTargetRelays.contains(where: { $0 == relayId }) {
// Not part of the target relays, skip
continue
}
Log.debug("%s: Sending resubscribe request to %s", for: .networking, handler.sub_id, relayId.absoluteString)
await send(.subscribe(.init(filters: filters, sub_id: handler.sub_id)), to: [relayId], skip_ephemeral: shouldSkipEphemeralRelays)
}
}
func handle_event(relay_id: RelayURL, event: NostrConnectionEvent) async {
record_seen(relay_id: relay_id, event: event)
// When we reconnect, do two things
// - Send messages that were stored in the queue
// - Re-subscribe to filters we had subscribed before
if case .ws_connection_event(let ws) = event {
if case .connected = ws {
run_queue(relay_id)
await self.resubscribeAll(relayId: relay_id)
}
await update_signal()
}
// Handle auth
if case let .nostr_event(nostrResponse) = event,
case let .auth(challenge_string) = nostrResponse {
if let relay = await get_relay(relay_id) {
print("received auth request from \(relay.descriptor.url.id)")
relay.authentication_state = .pending
if let keypair {
if let fullKeypair = keypair.to_full() {
if let authRequest = make_auth_request(keypair: fullKeypair, challenge_string: challenge_string, relay: relay) {
await send(.auth(authRequest), to: [relay_id], skip_ephemeral: false)
relay.authentication_state = .verified
} else {
print("failed to make auth request")
}
} else {
print("keypair provided did not contain private key, can not sign auth request")
relay.authentication_state = .error(.no_private_key)
}
} else {
print("no keypair to reply to auth request")
relay.authentication_state = .error(.no_key)
}
} else {
print("no relay found for \(relay_id)")
}
}
for handler in handlers {
// We send data to the handlers if:
// - the subscription ID matches, or
// - the handler filters is `nil`, which is used in some cases as a blanket "give me all notes" (e.g. during signup)
guard handler.sub_id == event.subId || handler.filters == nil else { continue }
logStreamPipelineStats("RelayPool_\(relay_id.absoluteString)", "RelayPool_Handler_\(handler.sub_id)")
handler.handler.yield((relay_id, event))
}
}
// MARK: - Negentropy
/// This streams items in the following fashion:
/// 1. Performs a negentropy sync, sending missing notes to the stream
/// 2. Send EOSE to signal end of syncing
/// 3. Stream new notes
func negentropySubscribe(
filters: [NostrFilter],
to desiredRelayURLs: [RelayURL]? = nil,
negentropyVector: NegentropyStorageVector,
eoseTimeout: Duration? = nil,
id: UUID? = nil,
ignoreUnsupportedRelays: Bool
) async throws -> AsyncThrowingStream<StreamItem, any Error> {
return AsyncThrowingStream<StreamItem, any Error>.with(task: { continuation in
// 1. Mark the time when we begin negentropy syncing
let negentropyStartTimestamp = UInt32(Date().timeIntervalSince1970)
// 2. Negentropy sync missing notes and send the missing notes over
for try await event in try await self.negentropySync(filters: filters, to: desiredRelayURLs, negentropyVector: negentropyVector, ignoreUnsupportedRelays: ignoreUnsupportedRelays) {
continuation.yield(.event(event))
}
// 3. When syncing is done, send the EOSE signal
continuation.yield(.eose)
// 3. Stream new notes that match the filter
let updatedFilters = filters.map({ filter in
var newFilter = filter
newFilter.since = negentropyStartTimestamp
return newFilter
})
for await item in await self.subscribe(filters: updatedFilters, to: desiredRelayURLs, eoseTimeout: eoseTimeout, id: id) {
try Task.checkCancellation()
switch item {
case .event(let nostrEvent):
continuation.yield(.event(nostrEvent))
case .eose:
continue // We already sent the EOSE signal after negentropy sync, ignore this redundant EOSE
}
}
})
}
/// This performs a negentropy syncing with various relays and various filters and sends missing notes over an async stream
func negentropySync(
filters: [NostrFilter],
to desiredRelayURLs: [RelayURL]? = nil,
negentropyVector: NegentropyStorageVector,
eoseTimeout: Duration? = nil,
ignoreUnsupportedRelays: Bool
) async throws -> AsyncThrowingStream<NostrEvent, any Error> {
return AsyncThrowingStream<NostrEvent, any Error>.with(task: { continuation in
for filter in filters {
try Task.checkCancellation()
for try await event in try await self.negentropySync(filter: filter, to: desiredRelayURLs, negentropyVector: negentropyVector, eoseTimeout: eoseTimeout, ignoreUnsupportedRelays: ignoreUnsupportedRelays) {
try Task.checkCancellation()
continuation.yield(event)
// Note: Negentropy vector already updated by the underlying stream, since it is a reference type
try Task.checkCancellation()
}
}
})
}
/// This performs a negentropy syncing with various relays and sends missing notes over an async stream
func negentropySync(
filter: NostrFilter,
to desiredRelayURLs: [RelayURL]? = nil,
negentropyVector: NegentropyStorageVector,
eoseTimeout: Duration? = nil,
ignoreUnsupportedRelays: Bool
) async throws -> AsyncThrowingStream<NostrEvent, any Error> {
return AsyncThrowingStream<NostrEvent, any Error>.with(task: { continuation in
let desiredRelays = await self.getRelays(targetRelays: desiredRelayURLs)
for desiredRelay in desiredRelays {
try Task.checkCancellation()
do {
for try await event in try await self.negentropySync(filter: filter, to: desiredRelay, negentropyVector: negentropyVector, eoseTimeout: eoseTimeout) {
try Task.checkCancellation()
continuation.yield(event)
// Add to our negentropy vector so that we don't need to receive it from the next relay!
negentropyVector.unseal()
try negentropyVector.insert(nostrEvent: event)
try Task.checkCancellation()
}
}
catch {
if ignoreUnsupportedRelays {
// Do not throw error, ignore the relays that do not support negentropy
// Note: Some relays such as wss://nos.lol/v2 advertise negentropy but throw an error such as `["NOTICE","ERROR: bad msg: negentropy disabled"]`
// Therefore, realistically, we cannot rely on what the relay advertises and
// we have to suppress those errors if we want to ignore unsupported relays to avoid the whole multi-relay negentropy syncing operation to fail
Log.error("Error while negentropy streaming: %s", for: .networking, error.localizedDescription)
}
else {
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "negentropy_sync_multi_relay",
"ignore_unsupported": ignoreUnsupportedRelays
], key: "relay_pool")
}
throw error
}
}
}
})
}
/// This performs a negentropy syncing with one relay and sends missing notes over an async stream
func negentropySync(filter: NostrFilter, to desiredRelay: Relay, negentropyVector: NegentropyStorageVector, eoseTimeout: Duration? = nil) async throws -> AsyncThrowingStream<NostrEvent, any Error> {
return AsyncThrowingStream<NostrEvent, any Error>.with(task: { streamContinuation in
let missingIds = try await desiredRelay.connection.getMissingIds(filter: filter, negentropyVector: negentropyVector, timeout: eoseTimeout)
let missingIdsFilter = NostrFilter(ids: missingIds.map { NoteId($0.toData()) })
for await event in self.subscribeExistingItems(filters: [missingIdsFilter], to: [desiredRelay.descriptor.url], eoseTimeout: eoseTimeout) {
streamContinuation.yield(event)
}
})
}
}
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) async {
try? await pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite))
}
extension RelayPool {
protocol Delegate {
func latestRelayListChanged(_ newEvent: NdbNote)
}
}