Merge pull request #46 from dergigi/relay-hints

refactor: improve relay hint selection and relay management
This commit is contained in:
Gigi
2025-11-22 01:21:40 +01:00
committed by GitHub
8 changed files with 304 additions and 153 deletions

View File

@@ -576,6 +576,31 @@ function App() {
}
})
// Helper to update keep-alive subscription based on current active relays
const updateKeepAlive = (relayUrls?: string[]) => {
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const targetRelays = relayUrls || getActiveRelayUrls(pool)
const newKeepAliveSub = pool.subscription(targetRelays, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: () => {}
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
}
// Helper to update address loader based on current active relays
const updateAddressLoader = (relayUrls?: string[]) => {
const targetRelays = relayUrls || getActiveRelayUrls(pool)
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: targetRelays
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
}
// Handle user relay list and blocked relays when account changes
const userRelaysSub = accounts.active$.subscribe((account) => {
if (account) {
@@ -604,20 +629,6 @@ function App() {
// Apply initial set immediately
applyRelaySetToPool(pool, initialRelays)
// Prepare keep-alive helper
const updateKeepAlive = () => {
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const activeRelays = getActiveRelayUrls(pool)
const newKeepAliveSub = pool.subscription(activeRelays, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: () => {}
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
}
// Begin loading blocked relays in background
const blockedPromise = loadBlockedRelays(pool, pubkey)
@@ -649,43 +660,16 @@ function App() {
applyRelaySetToPool(pool, finalRelays)
updateKeepAlive()
// Update address loader with new relays
const activeRelays = getActiveRelayUrls(pool)
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: activeRelays
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
updateAddressLoader()
}).catch((error) => {
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error)
// Continue with initial relay set on error - no need to change anything
})
} else {
// User logged out - reset to hardcoded relays
applyRelaySetToPool(pool, RELAYS)
// Update keep-alive subscription
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const newKeepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: () => {}
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
// Reset address loader
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: RELAYS
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
updateKeepAlive(RELAYS)
updateAddressLoader(RELAYS)
}
})

View File

@@ -12,6 +12,8 @@ import { nip19 } from 'nostr-tools'
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
import { RelayPool } from 'applesauce-relay'
import { getActiveRelayUrls } from '../services/relayManager'
import { isContentRelay } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
@@ -432,9 +434,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3)
const relayHints = activeRelays
.filter(url => !isLocalRelay(url))
.filter(url => isContentRelay(url))
.slice(0, 3)
const naddr = nip19.naddrEncode({
kind: 30023,

View File

@@ -10,6 +10,7 @@ import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
import { getActiveRelayUrls } from '../services/relayManager'
import { isContentRelay, getContentRelays, getFallbackContentRelays } from '../config/relays'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
@@ -224,11 +225,36 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const getHighlightLinks = () => {
// Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
// Relay hint selection priority:
// 1. Published relays (where we successfully published the event)
// 2. Seen relays (where we observed the event)
// 3. Configured content relays (deterministic fallback)
// All candidates are deduplicated, filtered to content-capable remote relays, and limited to 3
const publishedRelays = highlight.publishedRelays || []
const seenOnRelays = highlight.seenOnRelays || []
// Determine base candidates: prefer published, then seen, then configured relays
let candidates: string[]
if (publishedRelays.length > 0) {
// Prefer published relays, but include seen relays as backup
candidates = Array.from(new Set([...publishedRelays, ...seenOnRelays]))
.sort((a, b) => a.localeCompare(b))
} else if (seenOnRelays.length > 0) {
candidates = seenOnRelays
} else {
// Fallback to deterministic configured content relays
const contentRelays = getContentRelays()
const fallbackRelays = getFallbackContentRelays()
candidates = Array.from(new Set([...contentRelays, ...fallbackRelays]))
}
// Filter to content-capable remote relays (exclude local and non-content relays)
// Then take up to 3 for relay hints
const relayHints = candidates
.filter(url => !isLocalRelay(url))
.filter(url => isContentRelay(url))
.slice(0, 3)
const nevent = nip19.neventEncode({
id: highlight.id,

View File

@@ -1,21 +1,101 @@
import { normalizeRelayUrl } from '../utils/helpers'
/**
* Centralized relay configuration
* 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'] },
]
/**
* Get all local cache relays (localhost relays)
*/
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 relays that are reasonable for posts/highlights
*/
export function isContentRelay(url: string): boolean {
const normalized = normalizeRelayUrl(url)
const nonContentRelays = getNonContentRelays().map(normalizeRelayUrl)
return !nonContentRelays.includes(normalized)
}

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,45 @@ 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
// Build unified relay set: hints + configured content relays
// Filter hinted relays to only content-capable relays
const hintedRelays = (pointer.relays && pointer.relays.length > 0)
? pointer.relays.filter(isContentRelay)
: []
// Get configured content relays
const contentRelays = getContentRelays()
// Union of hinted and configured relays (deduplicated)
const unifiedRelays = Array.from(new Set([...hintedRelays, ...contentRelays]))
if (unifiedRelays.length > 0) {
const orderedUnified = prioritizeLocalRelays(unifiedRelays)
const { local: localUnified, remote: remoteUnified } = partitionRelays(orderedUnified)
const { local$, remote$ } = createParallelReqStreams(
relayPool,
localUnified,
remoteUnified,
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 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 fallbackRelays = getFallbackContentRelays()
const { remote$: fallback$ } = createParallelReqStreams(
relayPool,
[], // no local
reliableRelays,
[], // no local for fallback
fallbackRelays,
filter,
1500,
12000

View File

@@ -1,6 +1,7 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { normalizeRelayUrl } from '../utils/helpers'
export interface UserRelayInfo {
url: string
@@ -144,35 +145,55 @@ export function computeRelaySet(params: {
alwaysIncludeLocal
} = params
// Normalize all URLs for consistent comparison and deduplication
const normalizedBlocked = new Set(blocked.map(normalizeRelayUrl))
const normalizedLocal = new Set(alwaysIncludeLocal.map(normalizeRelayUrl))
const relaySet = new Set<string>()
const blockedSet = new Set(blocked)
const normalizedRelaySet = new Set<string>()
// Helper to check if relay should be included
const shouldInclude = (url: string): boolean => {
// Helper to check if relay should be included (using normalized URLs)
const shouldInclude = (normalizedUrl: string): boolean => {
// Always include local relays
if (alwaysIncludeLocal.includes(url)) return true
if (normalizedLocal.has(normalizedUrl)) return true
// Otherwise check if blocked
return !blockedSet.has(url)
return !normalizedBlocked.has(normalizedUrl)
}
// Add hardcoded relays
// Add hardcoded relays (normalized)
for (const url of hardcoded) {
if (shouldInclude(url)) relaySet.add(url)
const normalized = normalizeRelayUrl(url)
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
normalizedRelaySet.add(normalized)
relaySet.add(url) // Keep original URL for output
}
}
// Add bunker relays
// Add bunker relays (normalized)
for (const url of bunker) {
if (shouldInclude(url)) relaySet.add(url)
const normalized = normalizeRelayUrl(url)
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
normalizedRelaySet.add(normalized)
relaySet.add(url) // Keep original URL for output
}
}
// Add user relays (treating 'both' and 'read' as applicable for queries)
// Add user relays (normalized)
for (const relay of userList) {
if (shouldInclude(relay.url)) relaySet.add(relay.url)
const normalized = normalizeRelayUrl(relay.url)
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
normalizedRelaySet.add(normalized)
relaySet.add(relay.url) // Keep original URL for output
}
}
// Always ensure local relays are present
// Always ensure local relays are present (normalized check)
for (const url of alwaysIncludeLocal) {
relaySet.add(url)
const normalized = normalizeRelayUrl(url)
if (!normalizedRelaySet.has(normalized)) {
normalizedRelaySet.add(normalized)
relaySet.add(url) // Keep original URL for output
}
}
return Array.from(relaySet)

View File

@@ -1,20 +1,17 @@
import { RelayPool } from 'applesauce-relay'
import { prioritizeLocalRelays } from '../utils/helpers'
import { prioritizeLocalRelays, normalizeRelayUrl } from '../utils/helpers'
import { getLocalRelays, getFallbackContentRelays } 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)
* Derived from RELAY_CONFIGS fallback relays
*/
export const HARDCODED_RELAYS = [
'wss://relay.nostr.band'
]
export const HARDCODED_RELAYS = getFallbackContentRelays()
/**
* Gets active relay URLs from the relay pool
@@ -24,76 +21,84 @@ export function getActiveRelayUrls(relayPool: RelayPool): string[] {
return prioritizeLocalRelays(urls)
}
/**
* Normalizes a relay URL to match what applesauce-relay stores internally
* Adds trailing slash for URLs without a path
*/
function normalizeRelayUrl(url: string): string {
try {
const parsed = new URL(url)
// If the pathname is empty or just "/", ensure it ends with "/"
if (parsed.pathname === '' || parsed.pathname === '/') {
return url.endsWith('/') ? url : url + '/'
}
return url
} catch {
// If URL parsing fails, return as-is
return url
}
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 }
}

View File

@@ -39,6 +39,24 @@ export const classifyUrl = (url: string | undefined): UrlClassification => {
return { type: 'article' }
}
/**
* Normalizes a relay URL to match what applesauce-relay stores internally
* Adds trailing slash for URLs without a path
*/
export function normalizeRelayUrl(url: string): string {
try {
const parsed = new URL(url)
// If the pathname is empty or just "/", ensure it ends with "/"
if (parsed.pathname === '' || parsed.pathname === '/') {
return url.endsWith('/') ? url : url + '/'
}
return url
} catch {
// If URL parsing fails, return as-is
return url
}
}
/**
* Checks if a relay URL is a local relay (localhost or 127.0.0.1)
*/