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
*/
// 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)
}

View File

@@ -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<string>([...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<string>([
'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

View File

@@ -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
// 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)
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url)))
// Map normalized URLs back to original for adding
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) {
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) {
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 }
}