From bca1ee2b2eddc3eaaa05be4221ee690ec03fe645 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 16 Nov 2025 18:30:39 +0000 Subject: [PATCH] refactor(relays): unify relay config with typed registry and improve hint usage - Create typed RelayRole and RelayConfig interface in relays.ts - Add centralized RELAY_CONFIGS registry with role annotations - Add helper getters: getLocalRelays(), getDefaultRelays(), getContentRelays(), getFallbackContentRelays() - Maintain backwards compatibility with RELAYS and NON_CONTENT_RELAYS constants - Refactor relayManager to use new registry helpers - Harden applyRelaySetToPool with consistent normalization and local relay preservation - Add RelaySetChangeSummary return type for debugging - Improve articleService to prioritize and filter relay hints from naddr - Use centralized fallback content relay helpers instead of hard-coded arrays --- src/config/relays.ts | 101 ++++++++++++++++++++++++++------- src/services/articleService.ts | 72 ++++++++++++++++------- src/services/relayManager.ts | 90 ++++++++++++++++++----------- 3 files changed, 187 insertions(+), 76 deletions(-) diff --git a/src/config/relays.ts b/src/config/relays.ts index 6abc96cf..00b71ad8 100644 --- a/src/config/relays.ts +++ b/src/config/relays.ts @@ -3,36 +3,95 @@ * Single set of relays used throughout the application */ -// All relays including local relays -export const RELAYS = [ - 'ws://localhost:10547', - 'ws://localhost:4869', - 'wss://relay.nsec.app', - 'wss://relay.damus.io', - 'wss://nos.lol', - 'wss://relay.nostr.band', - 'wss://wot.dergigi.com', - 'wss://relay.snort.social', - 'wss://nostr-pub.wellorder.net', - 'wss://purplepag.es', - 'wss://relay.primal.net', - 'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', +export type RelayRole = 'local-cache' | 'default' | 'fallback' | 'non-content' | 'bunker' + +export interface RelayConfig { + url: string + roles: RelayRole[] +} + +/** + * Central relay registry with role annotations + */ +const RELAY_CONFIGS: RelayConfig[] = [ + { url: 'ws://localhost:10547', roles: ['local-cache'] }, + { url: 'ws://localhost:4869', roles: ['local-cache'] }, + { url: 'wss://relay.nsec.app', roles: ['default', 'non-content'] }, + { url: 'wss://relay.damus.io', roles: ['default', 'fallback'] }, + { url: 'wss://nos.lol', roles: ['default', 'fallback'] }, + { url: 'wss://relay.nostr.band', roles: ['default', 'fallback'] }, + { url: 'wss://wot.dergigi.com', roles: ['default'] }, + { url: 'wss://relay.snort.social', roles: ['default'] }, + { url: 'wss://nostr-pub.wellorder.net', roles: ['default'] }, + { url: 'wss://purplepag.es', roles: ['default'] }, + { url: 'wss://relay.primal.net', roles: ['default', 'fallback'] }, + { url: 'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', roles: ['default'] }, ] /** - * Relays that should NOT be used as content hints (auth/signer, etc.) - * These relays are fine for connection and other purposes, but shouldn't - * be suggested as places where posts/highlights are likely to be found. + * Get all local cache relays (localhost relays) */ -export const NON_CONTENT_RELAYS = [ - 'wss://relay.nsec.app', +export function getLocalRelays(): string[] { + return RELAY_CONFIGS + .filter(config => config.roles.includes('local-cache')) + .map(config => config.url) +} + +/** + * Get all default relays (main public relays) + */ +export function getDefaultRelays(): string[] { + return RELAY_CONFIGS + .filter(config => config.roles.includes('default')) + .map(config => config.url) +} + +/** + * Get fallback content relays (last resort public relays for content fetching) + * These are reliable public relays that should be tried when other methods fail + */ +export function getFallbackContentRelays(): string[] { + return RELAY_CONFIGS + .filter(config => config.roles.includes('fallback')) + .map(config => config.url) +} + +/** + * Get relays suitable for content fetching (excludes non-content relays like auth/signer relays) + */ +export function getContentRelays(): string[] { + return RELAY_CONFIGS + .filter(config => !config.roles.includes('non-content')) + .map(config => config.url) +} + +/** + * Get relays that should NOT be used as content hints + */ +export function getNonContentRelays(): string[] { + return RELAY_CONFIGS + .filter(config => config.roles.includes('non-content')) + .map(config => config.url) +} + +/** + * All relays including local relays (backwards compatibility) + */ +export const RELAYS = [ + ...getLocalRelays(), + ...getDefaultRelays(), ] +/** + * Relays that should NOT be used as content hints (backwards compatibility) + */ +export const NON_CONTENT_RELAYS = getNonContentRelays() + /** * Check if a relay URL is suitable for use as a content hint - * Returns true for remote relays that are reasonable for posts/highlights + * Returns true for relays that are reasonable for posts/highlights */ export function isContentRelay(url: string): boolean { - return !NON_CONTENT_RELAYS.includes(url) + return !getNonContentRelays().includes(url) } diff --git a/src/services/articleService.ts b/src/services/articleService.ts index 994b4329..8582fbe3 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -4,7 +4,7 @@ import { nip19 } from 'nostr-tools' import { AddressPointer } from 'nostr-tools/nip19' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' -import { RELAYS } from '../config/relays' +import { getContentRelays, getFallbackContentRelays, isContentRelay } from '../config/relays' import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers' import { merge, toArray as rxToArray } from 'rxjs' import { UserSettings } from './settingsService' @@ -138,13 +138,6 @@ export async function fetchArticleByNaddr( const pointer = decoded.data as AddressPointer - // Define relays to query - use union of relay hints from naddr and configured relays - // This avoids failures when naddr contains stale/unreachable relay hints - const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : [] - const baseRelays = Array.from(new Set([...hintedRelays, ...RELAYS])) - const orderedRelays = prioritizeLocalRelays(baseRelays) - const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) - // Fetch the article event const filter = { kinds: [pointer.kind], @@ -152,24 +145,59 @@ export async function fetchArticleByNaddr( '#d': [pointer.identifier] } - // Parallel local+remote, stream immediate, collect up to first from each - const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000) - const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())) - let events = collected as NostrEvent[] + let events: NostrEvent[] = [] - // Fallback: if nothing found, try a second round against a set of reliable public relays + // First, try relay hints from naddr (primary source) + // Filter to only content relays to avoid using auth/signer relays + const hintedRelays = (pointer.relays && pointer.relays.length > 0) + ? pointer.relays.filter(isContentRelay) + : [] + + if (hintedRelays.length > 0) { + const orderedHintedRelays = prioritizeLocalRelays(hintedRelays) + const { local: localHinted, remote: remoteHinted } = partitionRelays(orderedHintedRelays) + + const { local$, remote$ } = createParallelReqStreams( + relayPool, + localHinted, + remoteHinted, + filter, + 1200, + 6000 + ) + const collected = await lastValueFrom( + merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()) + ) + events = collected as NostrEvent[] + } + + // Fallback: if no hints or nothing found from hints, try default content relays if (events.length === 0) { - const reliableRelays = Array.from(new Set([ - 'wss://relay.nostr.band', - 'wss://relay.primal.net', - 'wss://relay.damus.io', - 'wss://nos.lol', - ...remoteRelays // keep any configured remote relays - ])) + const defaultContentRelays = getContentRelays() + const orderedDefault = prioritizeLocalRelays(defaultContentRelays) + const { local: localDefault, remote: remoteDefault } = partitionRelays(orderedDefault) + + const { local$, remote$ } = createParallelReqStreams( + relayPool, + localDefault, + remoteDefault, + filter, + 1200, + 6000 + ) + const collected = await lastValueFrom( + merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()) + ) + events = collected as NostrEvent[] + } + + // Last resort: try fallback content relays (most reliable public relays) + if (events.length === 0) { + const fallbackRelays = getFallbackContentRelays() const { remote$: fallback$ } = createParallelReqStreams( relayPool, - [], // no local - reliableRelays, + [], // no local for fallback + fallbackRelays, filter, 1500, 12000 diff --git a/src/services/relayManager.ts b/src/services/relayManager.ts index 6f491aae..1aad6b33 100644 --- a/src/services/relayManager.ts +++ b/src/services/relayManager.ts @@ -1,16 +1,14 @@ import { RelayPool } from 'applesauce-relay' import { prioritizeLocalRelays } from '../utils/helpers' +import { getLocalRelays, getDefaultRelays } from '../config/relays' /** * Local relays that are always included */ -export const ALWAYS_LOCAL_RELAYS = [ - 'ws://localhost:10547', - 'ws://localhost:4869' -] +export const ALWAYS_LOCAL_RELAYS = getLocalRelays() /** - * Hardcoded relays that are always included + * Hardcoded relays that are always included (minimal reliable set) */ export const HARDCODED_RELAYS = [ 'wss://relay.nostr.band' @@ -28,7 +26,7 @@ export function getActiveRelayUrls(relayPool: RelayPool): string[] { * Normalizes a relay URL to match what applesauce-relay stores internally * Adds trailing slash for URLs without a path */ -function normalizeRelayUrl(url: string): string { +export function normalizeRelayUrl(url: string): string { try { const parsed = new URL(url) // If the pathname is empty or just "/", ensure it ends with "/" @@ -42,58 +40,84 @@ function normalizeRelayUrl(url: string): string { } } +export interface RelaySetChangeSummary { + added: string[] + removed: string[] +} + /** * Applies a new relay set to the pool: adds missing relays, removes extras + * Always preserves local relays even if not in finalUrls + * @returns Summary of changes for debugging */ export function applyRelaySetToPool( relayPool: RelayPool, - finalUrls: string[] -): void { - // Normalize all URLs to match pool's internal format - const currentUrls = new Set(Array.from(relayPool.relays.keys())) - const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl)) - + finalUrls: string[], + options?: { preserveAlwaysLocal?: boolean } +): RelaySetChangeSummary { + const preserveLocal = options?.preserveAlwaysLocal !== false // default true - - // Add new relays (use original URLs for adding, not normalized) - const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url))) + // Ensure local relays are always included + const urlsWithLocal = preserveLocal + ? Array.from(new Set([...finalUrls, ...ALWAYS_LOCAL_RELAYS])) + : finalUrls - if (toAdd.length > 0) { - relayPool.group(toAdd) + // Normalize all URLs consistently for comparison + const normalizedCurrent = new Set( + Array.from(relayPool.relays.keys()).map(normalizeRelayUrl) + ) + const normalizedTarget = new Set(urlsWithLocal.map(normalizeRelayUrl)) + + // Map normalized URLs back to original for adding + const normalizedToOriginal = new Map() + for (const url of urlsWithLocal) { + normalizedToOriginal.set(normalizeRelayUrl(url), url) } - - // Remove relays not in target (but always keep local relays) + + // Find relays to add (not in current pool) + const toAdd: string[] = [] + for (const normalizedUrl of normalizedTarget) { + if (!normalizedCurrent.has(normalizedUrl)) { + const originalUrl = normalizedToOriginal.get(normalizedUrl) || normalizedUrl + toAdd.push(originalUrl) + } + } + + // Find relays to remove (not in target, but preserve local relays) + const normalizedLocal = new Set(ALWAYS_LOCAL_RELAYS.map(normalizeRelayUrl)) const toRemove: string[] = [] - for (const url of currentUrls) { - // Check if this normalized URL is in the target set - if (!normalizedTargetUrls.has(url)) { - // Also check if it's a local relay (check both normalized and original forms) - const isLocal = ALWAYS_LOCAL_RELAYS.some(localUrl => - normalizeRelayUrl(localUrl) === url || localUrl === url - ) - if (!isLocal) { - toRemove.push(url) + for (const currentUrl of relayPool.relays.keys()) { + const normalizedCurrentUrl = normalizeRelayUrl(currentUrl) + if (!normalizedTarget.has(normalizedCurrentUrl)) { + // Always preserve local relays + if (!preserveLocal || !normalizedLocal.has(normalizedCurrentUrl)) { + toRemove.push(currentUrl) } } } - + // Apply changes + if (toAdd.length > 0) { + relayPool.group(toAdd) + } + for (const url of toRemove) { const relay = relayPool.relays.get(url) if (relay) { try { - // Only close if relay is actually connected or attempting to connect - // This helps avoid WebSocket warnings for connections that never started relay.close() } catch (error) { // Suppress errors when closing relays that haven't fully connected yet - // This can happen when switching relay sets before connections establish - // Silently ignore } relayPool.relays.delete(url) } } + // Return summary for debugging (useful for understanding relay churn) + if (import.meta.env.DEV && (toAdd.length > 0 || toRemove.length > 0)) { + console.debug('[relay-pool] Changes:', { added: toAdd, removed: toRemove }) + } + return { added: toAdd, removed: toRemove } }