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
This commit is contained in:
Gigi
2025-11-16 18:30:39 +00:00
parent 4d18c84243
commit bca1ee2b2e
3 changed files with 187 additions and 76 deletions

View File

@@ -3,36 +3,95 @@
* Single set of relays used throughout the application * Single set of relays used throughout the application
*/ */
// All relays including local relays export type RelayRole = 'local-cache' | 'default' | 'fallback' | 'non-content' | 'bunker'
export const RELAYS = [
'ws://localhost:10547', export interface RelayConfig {
'ws://localhost:4869', url: string
'wss://relay.nsec.app', roles: RelayRole[]
'wss://relay.damus.io', }
'wss://nos.lol',
'wss://relay.nostr.band', /**
'wss://wot.dergigi.com', * Central relay registry with role annotations
'wss://relay.snort.social', */
'wss://nostr-pub.wellorder.net', const RELAY_CONFIGS: RelayConfig[] = [
'wss://purplepag.es', { url: 'ws://localhost:10547', roles: ['local-cache'] },
'wss://relay.primal.net', { url: 'ws://localhost:4869', roles: ['local-cache'] },
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', { 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.) * Get all local cache relays (localhost relays)
* These relays are fine for connection and other purposes, but shouldn't
* be suggested as places where posts/highlights are likely to be found.
*/ */
export const NON_CONTENT_RELAYS = [ export function getLocalRelays(): string[] {
'wss://relay.nsec.app', 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 * 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 { export function isContentRelay(url: string): boolean {
return !NON_CONTENT_RELAYS.includes(url) return !getNonContentRelays().includes(url)
} }

View File

@@ -4,7 +4,7 @@ import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19' import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core' 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 { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
import { merge, toArray as rxToArray } from 'rxjs' import { merge, toArray as rxToArray } from 'rxjs'
import { UserSettings } from './settingsService' import { UserSettings } from './settingsService'
@@ -138,13 +138,6 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer 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<string>([...hintedRelays, ...RELAYS]))
const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
// Fetch the article event // Fetch the article event
const filter = { const filter = {
kinds: [pointer.kind], kinds: [pointer.kind],
@@ -152,24 +145,59 @@ export async function fetchArticleByNaddr(
'#d': [pointer.identifier] '#d': [pointer.identifier]
} }
// Parallel local+remote, stream immediate, collect up to first from each let events: NostrEvent[] = []
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[]
// 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) { if (events.length === 0) {
const reliableRelays = Array.from(new Set<string>([ const defaultContentRelays = getContentRelays()
'wss://relay.nostr.band', const orderedDefault = prioritizeLocalRelays(defaultContentRelays)
'wss://relay.primal.net', const { local: localDefault, remote: remoteDefault } = partitionRelays(orderedDefault)
'wss://relay.damus.io',
'wss://nos.lol', const { local$, remote$ } = createParallelReqStreams(
...remoteRelays // keep any configured remote relays 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( const { remote$: fallback$ } = createParallelReqStreams(
relayPool, relayPool,
[], // no local [], // no local for fallback
reliableRelays, fallbackRelays,
filter, filter,
1500, 1500,
12000 12000

View File

@@ -1,16 +1,14 @@
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { prioritizeLocalRelays } from '../utils/helpers' import { prioritizeLocalRelays } from '../utils/helpers'
import { getLocalRelays, getDefaultRelays } from '../config/relays'
/** /**
* Local relays that are always included * Local relays that are always included
*/ */
export const ALWAYS_LOCAL_RELAYS = [ export const ALWAYS_LOCAL_RELAYS = getLocalRelays()
'ws://localhost:10547',
'ws://localhost:4869'
]
/** /**
* Hardcoded relays that are always included * Hardcoded relays that are always included (minimal reliable set)
*/ */
export const HARDCODED_RELAYS = [ export const HARDCODED_RELAYS = [
'wss://relay.nostr.band' '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 * Normalizes a relay URL to match what applesauce-relay stores internally
* Adds trailing slash for URLs without a path * Adds trailing slash for URLs without a path
*/ */
function normalizeRelayUrl(url: string): string { export function normalizeRelayUrl(url: string): string {
try { try {
const parsed = new URL(url) const parsed = new URL(url)
// If the pathname is empty or just "/", ensure it ends with "/" // 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 * 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( export function applyRelaySetToPool(
relayPool: RelayPool, relayPool: RelayPool,
finalUrls: string[] finalUrls: string[],
): void { options?: { preserveAlwaysLocal?: boolean }
// Normalize all URLs to match pool's internal format ): RelaySetChangeSummary {
const currentUrls = new Set(Array.from(relayPool.relays.keys())) const preserveLocal = options?.preserveAlwaysLocal !== false // default true
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
// Ensure local relays are always included
const urlsWithLocal = preserveLocal
? Array.from(new Set([...finalUrls, ...ALWAYS_LOCAL_RELAYS]))
: finalUrls
// Normalize all URLs consistently for comparison
const normalizedCurrent = new Set(
Array.from(relayPool.relays.keys()).map(normalizeRelayUrl)
)
const normalizedTarget = new Set(urlsWithLocal.map(normalizeRelayUrl))
// Add new relays (use original URLs for adding, not normalized) // Map normalized URLs back to original for adding
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url))) const normalizedToOriginal = new Map<string, string>()
for (const url of urlsWithLocal) {
normalizedToOriginal.set(normalizeRelayUrl(url), url)
}
// 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 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) { if (toAdd.length > 0) {
relayPool.group(toAdd) relayPool.group(toAdd)
} }
// Remove relays not in target (but always keep local relays)
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 url of toRemove) { for (const url of toRemove) {
const relay = relayPool.relays.get(url) const relay = relayPool.relays.get(url)
if (relay) { if (relay) {
try { try {
// Only close if relay is actually connected or attempting to connect
// This helps avoid WebSocket warnings for connections that never started
relay.close() relay.close()
} catch (error) { } catch (error) {
// Suppress errors when closing relays that haven't fully connected yet // 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) 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 }
} }