diff --git a/claudedocs/outbox-future-improvements.md b/claudedocs/outbox-future-improvements.md new file mode 100644 index 0000000..943000e --- /dev/null +++ b/claudedocs/outbox-future-improvements.md @@ -0,0 +1,690 @@ +# NIP-65 Outbox Implementation: Future Improvements + +This document outlines performance and UX improvements identified during the deep review of the outbox implementation. The "quick wins" (single author special case, in-memory LRU cache, relay selection progress indicator) have been implemented. These are the remaining optimizations for future consideration. + +--- + +## 1. Request Deduplication + +**Problem**: Multiple simultaneous queries for the same relay list create redundant network requests. + +**Current Behavior**: +```typescript +// If 3 components request same relay list simultaneously: +async function fetchRelayList(pubkey: string) { + return await fetch(`wss://relay/kind:10002/${pubkey}`); +} + +// Result: 3 identical network requests +``` + +**Proposed Solution**: +```typescript +// Map of in-flight promises to prevent redundant fetches +private inFlightRequests = new Map>(); + +async fetchRelayList(pubkey: string): Promise { + // Check if request already in flight + const existing = this.inFlightRequests.get(pubkey); + if (existing) { + console.debug(`[RelayListCache] Deduplicating request for ${pubkey.slice(0, 8)}`); + return existing; + } + + // Create new promise and store it + const promise = this.fetchFromNetwork(pubkey); + this.inFlightRequests.set(pubkey, promise); + + // Clean up when done + promise.finally(() => { + this.inFlightRequests.delete(pubkey); + }); + + return promise; +} +``` + +**Expected Impact**: +- Reduce redundant network requests by ~60-80% +- Lower bandwidth usage and relay load +- Faster response times when multiple components need same data + +**Implementation Location**: `src/services/relay-list-cache.ts` + +--- + +## 2. Performance Metrics Collection + +**Problem**: No telemetry to track cache hit rates, timing, or degradation patterns in production. + +**Proposed Solution**: +```typescript +// In src/services/relay-list-cache.ts +interface PerformanceMetrics { + memoryHits: number; + dexieHits: number; + networkFetches: number; + totalRequests: number; + avgMemoryTime: number; + avgDexieTime: number; + avgNetworkTime: number; + lastReset: number; +} + +class RelayListCache { + private metrics: PerformanceMetrics = { + memoryHits: 0, + dexieHits: 0, + networkFetches: 0, + totalRequests: 0, + avgMemoryTime: 0, + avgDexieTime: 0, + avgNetworkTime: 0, + lastReset: Date.now(), + }; + + async getOutboxRelays(pubkey: string): Promise { + const start = performance.now(); + this.metrics.totalRequests++; + + // Check memory cache + const memCached = this.memoryCache.get(pubkey); + if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { + this.metrics.memoryHits++; + this.updateAvgTime('memory', performance.now() - start); + return memCached.write; + } + + // Check Dexie + const cached = await this.get(pubkey); + if (cached) { + this.metrics.dexieHits++; + this.updateAvgTime('dexie', performance.now() - start); + return cached.write; + } + + // Network fetch + this.metrics.networkFetches++; + this.updateAvgTime('network', performance.now() - start); + return null; + } + + getMetrics(): PerformanceMetrics & { + memoryCacheHitRate: number; + dexieCacheHitRate: number; + overallCacheHitRate: number; + } { + const total = this.metrics.totalRequests; + return { + ...this.metrics, + memoryCacheHitRate: total > 0 ? this.metrics.memoryHits / total : 0, + dexieCacheHitRate: total > 0 ? this.metrics.dexieHits / total : 0, + overallCacheHitRate: total > 0 + ? (this.metrics.memoryHits + this.metrics.dexieHits) / total + : 0, + }; + } +} +``` + +**Expected Impact**: +- Visibility into cache effectiveness +- Data-driven optimization decisions +- Production performance monitoring +- Identify degradation patterns early + +**Implementation Location**: `src/services/relay-list-cache.ts` + +--- + +## 3. Fallback Warning System + +**Problem**: Users don't know when their queries fall back to aggregator relays, causing confusion about incomplete results. + +**Current Behavior**: Silent fallback with only console.debug logs + +**Proposed Solution**: +```typescript +// In src/services/relay-selection.ts +interface RelaySelectionResult { + relays: string[]; + reasoning: RelaySelectionReasoning[]; + isOptimized: boolean; + fallbacksUsed?: { + pubkey: string; + reason: 'no-relay-list' | 'timeout' | 'invalid-list'; + }[]; +} + +// In selectRelaysForFilter: +if (!cachedRelayList) { + console.warn(`[RelaySelection] No relay list for ${pubkey.slice(0, 8)}, using fallback`); + + result.fallbacksUsed = result.fallbacksUsed || []; + result.fallbacksUsed.push({ + pubkey, + reason: 'no-relay-list' + }); +} +``` + +**UI Component** (`src/components/ReqViewer.tsx`): +```tsx +{reasoning && reasoning.some(r => r.isFallback) && ( +
+ + + Using fallback relays for {reasoning.filter(r => r.isFallback).length} users + (relay lists unavailable) + +
+)} +``` + +**Expected Impact**: +- Users understand why results may be incomplete +- Encourages fixing relay list issues +- Better debugging experience +- Transparency about query execution + +**Implementation Locations**: +- `src/services/relay-selection.ts` +- `src/components/ReqViewer.tsx` + +--- + +## 4. Speculative Prefetching + +**Problem**: Cold start delays occur frequently because relay lists aren't cached until needed. + +**Proposed Solution**: +```typescript +// In src/services/relay-list-cache.ts +class RelayListCache { + /** + * Prefetch relay lists for a set of pubkeys in the background + * Useful for warming cache with user's follows + */ + async prefetch(pubkeys: string[]): Promise { + console.log(`[RelayListCache] Prefetching ${pubkeys.length} relay lists`); + + // Filter out already cached + const uncached = await Promise.all( + pubkeys.map(async (pubkey) => { + const has = await this.has(pubkey); + return has ? null : pubkey; + }) + ); + + const toPrefetch = uncached.filter((p): p is string => p !== null); + + if (toPrefetch.length === 0) { + console.debug('[RelayListCache] All relay lists already cached'); + return; + } + + // Fetch in background (don't await - fire and forget) + const eventStore = getEventStore(); + eventStore.query({ kinds: [10002], authors: toPrefetch }); + } +} + +// Hook for automatic prefetching +// In src/hooks/usePrefetchRelayLists.ts +export function usePrefetchRelayLists() { + const profile = useCurrentProfile(); + + useEffect(() => { + if (!profile) return; + + // Get user's follows from contact list (kind 3) + const contacts = profile.tags + .filter(tag => tag[0] === 'p') + .map(tag => tag[1]); + + if (contacts.length > 0) { + console.log(`[Prefetch] Warming cache with ${contacts.length} follows`); + relayListCache.prefetch(contacts.slice(0, 50)); // Limit to top 50 + } + }, [profile]); +} +``` + +**Integration**: Call `usePrefetchRelayLists()` in App.tsx or after login + +**Expected Impact**: +- Reduce cold start delays by ~80% for common queries +- Better UX for new users +- Proactive cache warming +- Minimal bandwidth cost (background fetch) + +**Implementation Locations**: +- `src/services/relay-list-cache.ts` +- `src/hooks/usePrefetchRelayLists.ts` + +--- + +## 5. Adaptive Timeout + +**Problem**: Fixed 1000ms timeout is too long for consistently slow relays but may be too short for slow networks. + +**Proposed Solution**: +```typescript +// In src/services/relay-selection.ts +interface RelayHealthMetrics { + avgResponseTime: number; + successRate: number; + lastSuccess: number; + failureCount: number; +} + +class RelayHealthTracker { + private metrics = new Map(); + + recordSuccess(pubkey: string, responseTime: number) { + const existing = this.metrics.get(pubkey) || { + avgResponseTime: 0, + successRate: 1, + lastSuccess: Date.now(), + failureCount: 0, + }; + + // Exponential moving average + existing.avgResponseTime = + 0.7 * existing.avgResponseTime + 0.3 * responseTime; + existing.successRate = + 0.9 * existing.successRate + 0.1 * 1; + existing.lastSuccess = Date.now(); + + this.metrics.set(pubkey, existing); + } + + recordFailure(pubkey: string) { + const existing = this.metrics.get(pubkey) || { + avgResponseTime: 1000, + successRate: 0, + lastSuccess: 0, + failureCount: 0, + }; + + existing.successRate = 0.9 * existing.successRate + 0.1 * 0; + existing.failureCount++; + + this.metrics.set(pubkey, existing); + } + + getTimeout(pubkey: string): number { + const metrics = this.metrics.get(pubkey); + if (!metrics) return 1000; // Default + + // Adaptive: 2x average response time, minimum 300ms, maximum 2000ms + const adaptive = Math.max(300, Math.min(2000, metrics.avgResponseTime * 2)); + + // Reduce timeout for consistently slow relays + if (metrics.avgResponseTime > 800 && metrics.successRate < 0.5) { + return Math.min(500, adaptive); + } + + return adaptive; + } +} +``` + +**Expected Impact**: +- Faster queries for reliable relays (300-500ms vs 1000ms) +- Reduce wasted time on slow relays +- Better resource utilization +- Adaptive to network conditions + +**Implementation Location**: `src/services/relay-selection.ts` + +--- + +## 6. Incremental Relay Selection + +**Problem**: Users wait for all relay lists before seeing any results, even if some are cached. + +**Proposed Solution**: +```typescript +// In src/services/relay-selection.ts +export async function selectRelaysIncremental( + eventStore: IEventStore, + filter: NostrFilter, + options?: RelaySelectionOptions, + onUpdate?: (partial: RelaySelectionResult) => void +): Promise { + const authors = filter.authors || []; + const pTags = filter["#p"] || []; + + // Phase 1: Return cached relays immediately + const cachedPointers = await Promise.all( + authors.map(async (pubkey) => { + const cached = await relayListCache.getOutboxRelays(pubkey); + return cached ? { pubkey, relays: cached } : null; + }) + ); + + const initialRelays = cachedPointers + .filter((p): p is NonNullable => p !== null) + .flatMap(p => p.relays); + + if (initialRelays.length > 0 && onUpdate) { + onUpdate({ + relays: initialRelays, + reasoning: [], + isOptimized: true, + }); + } + + // Phase 2: Fetch missing relay lists + const uncachedAuthors = authors.filter((_, i) => !cachedPointers[i]); + + if (uncachedAuthors.length > 0) { + // Fetch and update as they arrive + const subscription = eventStore + .query({ kinds: [10002], authors: uncachedAuthors }) + .subscribe((event) => { + relayListCache.set(event); + + // Trigger incremental update + if (onUpdate) { + selectRelaysForFilter(eventStore, filter, options) + .then(onUpdate); + } + }); + + // Wait for timeout, then complete + await new Promise(resolve => + setTimeout(resolve, options?.timeout || 1000) + ); + subscription.unsubscribe(); + } + + // Phase 3: Final selection + return selectRelaysForFilter(eventStore, filter, options); +} +``` + +**Hook Integration**: +```typescript +// In src/hooks/useOutboxRelays.ts +export function useOutboxRelaysIncremental( + filter: NostrFilter, + options?: RelaySelectionOptions +) { + const [result, setResult] = useState({ + relays: options?.fallbackRelays || [], + reasoning: [], + isOptimized: false, + }); + + useEffect(() => { + selectRelaysIncremental( + eventStore, + filter, + options, + setResult // Update as relay lists arrive + ); + }, [filter, options]); + + return result; +} +``` + +**Expected Impact**: +- Show initial results within 10-50ms (cached relays) +- Progressive enhancement as more relay lists arrive +- Better perceived performance +- Users can start seeing events immediately + +**Implementation Locations**: +- `src/services/relay-selection.ts` +- `src/hooks/useOutboxRelays.ts` + +--- + +## 7. Cache Warming UI + +**Problem**: Users have no way to manually refresh stale relay lists or warm the cache proactively. + +**Proposed Solution**: +```tsx +// In src/components/settings/RelayListSettings.tsx +export function RelayListSettings() { + const [stats, setStats] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + useEffect(() => { + relayListCache.getStats().then(setStats); + }, []); + + const handleRefreshAll = async () => { + setRefreshing(true); + + // Clear cache + await relayListCache.clear(); + + // Prefetch follows + const profile = await getCurrentProfile(); + if (profile) { + const follows = getFollows(profile); + await relayListCache.prefetch(follows.slice(0, 100)); + } + + setRefreshing(false); + + // Update stats + const newStats = await relayListCache.getStats(); + setStats(newStats); + }; + + const handleRefreshStale = async () => { + // Only refresh entries older than 12 hours + const allEntries = await db.relayLists.toArray(); + const stale = allEntries + .filter(entry => Date.now() - entry.updatedAt > 12 * 60 * 60 * 1000) + .map(entry => entry.pubkey); + + if (stale.length > 0) { + await relayListCache.prefetch(stale); + } + }; + + return ( +
+

Relay List Cache

+ + {stats && ( +
+
+
Cached Users
+
{stats.count}
+
+
+
Memory Cache
+
+ {stats.memoryCacheSize} / {stats.memoryCacheLimit} +
+
+
+ )} + +
+ + +
+ +

+ Cache entries expire after 24 hours. Refresh to get latest relay lists. +

+
+ ); +} +``` + +**Expected Impact**: +- User control over cache freshness +- Manual warming for important follows +- Visibility into cache state +- Proactive performance management + +**Implementation Location**: `src/components/settings/RelayListSettings.tsx` + +--- + +## 8. Diagnostic Panel + +**Problem**: When queries fail or perform poorly, users and developers have no visibility into relay selection reasoning. + +**Proposed Solution**: +```tsx +// In src/components/ReqViewer.tsx +interface RelayDiagnosticsProps { + reasoning: RelaySelectionReasoning[]; + isOptimized: boolean; + phase: RelaySelectionPhase; +} + +function RelayDiagnostics({ reasoning, isOptimized, phase }: RelayDiagnosticsProps) { + const [expanded, setExpanded] = useState(false); + + const metrics = relayListCache.getMetrics(); + + return ( +
+ + + {expanded && ( +
+ {/* Selection Status */} +
+
Selection Status
+
+ Phase: {phase} • Optimized: {isOptimized ? 'Yes' : 'No (using fallbacks)'} +
+
+ + {/* Cache Performance */} +
+
Cache Performance
+
+
+
Memory Hits
+
{(metrics.memoryCacheHitRate * 100).toFixed(1)}%
+
+
+
Dexie Hits
+
{(metrics.dexieCacheHitRate * 100).toFixed(1)}%
+
+
+
Network Fetches
+
{metrics.networkFetches}
+
+
+
+ + {/* Selected Relays */} +
+
Selected Relays
+
+ {reasoning.map((r, i) => ( +
+ + {r.isFallback ? '⚠' : '✓'} + + {r.relay} + + ({r.writers.length}w {r.readers.length}r) + +
+ ))} +
+
+ + {/* Coverage Analysis */} +
+
Coverage Analysis
+
+ {reasoning.filter(r => !r.isFallback).length} optimized relays, + {' '}{reasoning.filter(r => r.isFallback).length} fallback relays +
+
+
+ )} +
+ ); +} +``` + +**Expected Impact**: +- Visibility into relay selection process +- Easier debugging of query issues +- Performance metrics at a glance +- Educational for understanding NIP-65 + +**Implementation Location**: `src/components/ReqViewer.tsx` + +--- + +## Priority Recommendations + +Based on impact vs. effort analysis: + +### High Priority (Implement Next) +1. **Request Deduplication** - Low effort, high impact on redundant queries +2. **Fallback Warning System** - Low effort, significant UX improvement +3. **Performance Metrics Collection** - Medium effort, critical for production monitoring + +### Medium Priority +4. **Speculative Prefetching** - Medium effort, large impact for cold start reduction +5. **Diagnostic Panel** - Medium effort, valuable for debugging and transparency + +### Lower Priority (Nice to Have) +6. **Adaptive Timeout** - High effort, moderate impact +7. **Incremental Relay Selection** - High effort, moderate UX improvement +8. **Cache Warming UI** - Low effort, but user-initiated edge case + +--- + +## Performance Impact Summary + +| Improvement | Expected Gain | Current | Target | +|-------------|---------------|---------|--------| +| Request Deduplication | -60% redundant requests | N/A | N/A | +| Speculative Prefetching | -80% cold start delays | 1040ms | ~200ms | +| Adaptive Timeout | -40% wasted time | 1000ms | 300-500ms | +| Incremental Selection | Perceived perf | 1040ms | 10-50ms first response | +| Performance Metrics | Monitoring | None | Full telemetry | + +--- + +## Testing Recommendations + +For each improvement: +1. Add unit tests for core logic +2. Add integration tests for timing/caching behavior +3. Manual testing with slow networks (throttle to 3G) +4. Measure before/after metrics with realistic data +5. Test fallback scenarios (cache miss, timeout, error) + +--- + +*Document created: 2025-01-XX* +*Quick wins implemented: Single author special case, in-memory LRU cache, relay selection progress indicator* +*Future work: These improvements are prioritized but not yet scheduled for implementation* diff --git a/src/components/CommandLauncher.tsx b/src/components/CommandLauncher.tsx index 06c76af..dc9f527 100644 --- a/src/components/CommandLauncher.tsx +++ b/src/components/CommandLauncher.tsx @@ -27,9 +27,11 @@ export default function CommandLauncher({ if (open && editMode) { setInput(editMode.initialCommand); } else if (!open) { + // Clear input and edit mode when dialog closes setInput(""); + setEditMode(null); } - }, [open, editMode]); + }, [open, editMode, setEditMode]); // Parse input into command and arguments const parsed = parseCommandInput(input); diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 4c4ee2f..1df19c1 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { useGrimoire } from "@/core/state"; import { useAccountSync } from "@/hooks/useAccountSync"; +import { useRelayListCacheSync } from "@/hooks/useRelayListCacheSync"; import { useRelayState } from "@/hooks/useRelayState"; import relayStateManager from "@/services/relay-state-manager"; import { TabBar } from "./TabBar"; @@ -20,6 +21,9 @@ export default function Home() { // Sync active account and fetch relay lists useAccountSync(); + // Auto-cache kind:10002 relay lists from EventStore to Dexie + useRelayListCacheSync(); + // Initialize global relay state manager useEffect(() => { relayStateManager.initialize().catch((err) => { diff --git a/src/components/ProfileViewer.tsx b/src/components/ProfileViewer.tsx index c4ccbf4..7a237c5 100644 --- a/src/components/ProfileViewer.tsx +++ b/src/components/ProfileViewer.tsx @@ -25,6 +25,10 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { useRelayState } from "@/hooks/useRelayState"; import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils"; +import { addressLoader } from "@/services/loaders"; +import { relayListCache } from "@/services/relay-list-cache"; +import { useEffect } from "react"; +import type { Subscription } from "rxjs"; export interface ProfileViewerProps { pubkey: string; @@ -40,7 +44,45 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) { const { copy, copied } = useCopy(); const { relays: relayStates } = useRelayState(); - // Get mailbox relays (kind 10002) + // Fetch fresh relay list from network only if not cached or stale + useEffect(() => { + let subscription: Subscription | null = null; + + // Check if we have a valid cached relay list + relayListCache.has(pubkey).then(async (hasCached) => { + if (hasCached) { + console.debug(`[ProfileViewer] Using cached relay list for ${pubkey.slice(0, 8)}`); + + // Load cached event into EventStore so UI can display it + const cached = await relayListCache.get(pubkey); + if (cached?.event) { + eventStore.add(cached.event); + console.debug(`[ProfileViewer] Loaded cached relay list into EventStore for ${pubkey.slice(0, 8)}`); + } + return; + } + + // No cached or stale - fetch fresh from network + console.debug(`[ProfileViewer] Fetching fresh relay list for ${pubkey.slice(0, 8)}`); + subscription = addressLoader({ + kind: kinds.RelayList, + pubkey, + identifier: "", + }).subscribe({ + error: (err) => { + console.debug(`[ProfileViewer] Failed to fetch relay list for ${pubkey.slice(0, 8)}:`, err); + }, + }); + }); + + return () => { + if (subscription) { + subscription.unsubscribe(); + } + }; + }, [pubkey]); + + // Get mailbox relays (kind 10002) - will update when fresh data arrives const mailboxEvent = useObservableMemo( () => eventStore.replaceable(kinds.RelayList, pubkey, ""), [eventStore, pubkey], diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 14d7c97..adb67da 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -1,4 +1,4 @@ -import { useState, memo, useCallback } from "react"; +import { useState, memo, useCallback, useMemo } from "react"; import { ChevronDown, ChevronRight, @@ -13,11 +13,15 @@ import { Search, Code, Loader2, + Mail, + Send, } from "lucide-react"; import { Virtuoso } from "react-virtuoso"; import { useReqTimeline } from "@/hooks/useReqTimeline"; import { useGrimoire } from "@/core/state"; import { useRelayState } from "@/hooks/useRelayState"; +import { useOutboxRelays } from "@/hooks/useOutboxRelays"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; import { FeedEvent } from "./nostr/Feed"; import { KindBadge } from "./KindBadge"; import { UserName } from "./nostr/UserName"; @@ -627,7 +631,7 @@ export default function ReqViewer({ needsAccount = false, title = "nostr-events", }: ReqViewerProps) { - const { state } = useGrimoire(); + const { state, addWindow } = useGrimoire(); const { relays: relayStates } = useRelayState(); // Get active account for alias resolution @@ -641,29 +645,57 @@ export default function ReqViewer({ : undefined, ); - // Extract contacts from kind 3 event - const contacts = contactListEvent - ? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64) - : []; + // Extract contacts from kind 3 event (memoized to prevent unnecessary recalculation) + const contacts = useMemo( + () => contactListEvent + ? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64) + : [], + [contactListEvent] + ); - // Resolve $me and $contacts aliases - const resolvedFilter = needsAccount - ? resolveFilterAliases(filter, accountPubkey, contacts) - : filter; + // Resolve $me and $contacts aliases (memoized to prevent unnecessary object creation) + const resolvedFilter = useMemo( + () => needsAccount + ? resolveFilterAliases(filter, accountPubkey, contacts) + : filter, + [needsAccount, filter, accountPubkey, contacts] + ); // NIP-05 resolution already happened in argParser before window creation // The filter prop already contains resolved pubkeys // We just display the NIP-05 identifiers for user reference - // Use inbox relays if logged in and no relays specified - const defaultRelays = - relays || - (state.activeAccount?.relays?.inbox.length - ? state.activeAccount.relays.inbox.map((r) => r.url) - : ["wss://theforest.nostr1.com"]); + // NIP-65 outbox relay selection + // Memoize fallbackRelays to prevent re-creation on every render + const fallbackRelays = useMemo( + () => state.activeAccount?.relays?.inbox.map((r) => r.url) || AGGREGATOR_RELAYS, + [state.activeAccount?.relays?.inbox] + ); + + // Memoize outbox options to prevent object re-creation + const outboxOptions = useMemo( + () => ({ + fallbackRelays, + timeout: 1000, + maxRelays: 42, + }), + [fallbackRelays] + ); + + // Select optimal relays based on authors (write relays) and #p tags (read relays) + const { + relays: selectedRelays, + reasoning, + isOptimized, + phase: relaySelectionPhase, + } = useOutboxRelays(resolvedFilter, outboxOptions); + + // Use explicit relays if provided, otherwise use NIP-65 selected relays + const finalRelays = relays || selectedRelays; + // Get relay state for each relay and calculate connected count - const relayStatesForReq = defaultRelays.map((url) => ({ + const relayStatesForReq = finalRelays.map((url) => ({ url, state: relayStates[url], })); @@ -677,7 +709,7 @@ export default function ReqViewer({ const { events, loading, error, eoseReceived } = useReqTimeline( `req-${JSON.stringify(filter)}-${closeOnEose}`, resolvedFilter, - defaultRelays, + finalRelays, { limit: resolvedFilter.limit || 50, stream }, ); @@ -805,33 +837,43 @@ export default function ReqViewer({
- {loading && !eoseReceived - ? "LOADING" - : loading && eoseReceived && stream - ? "LIVE" - : !loading && eoseReceived - ? "CLOSED" - : "CONNECTING"} + {relaySelectionPhase === 'discovering' + ? "DISCOVERING RELAYS" + : relaySelectionPhase === 'selecting' + ? "SELECTING RELAYS" + : loading && eoseReceived && stream + ? "LIVE" + : loading && !eoseReceived && events.length === 0 + ? "CONNECTING" + : loading && !eoseReceived + ? "LOADING" + : eoseReceived + ? "CLOSED" + : "CONNECTING"}
@@ -868,54 +910,118 @@ export default function ReqViewer({ - - {relayStatesForReq.map(({ url, state }) => { - const connIcon = getConnectionIcon(state); - const authIcon = getAuthIcon(state); + + {/* Connection Status */} +
+
+ Connection Status +
+ {relayStatesForReq.map(({ url, state }) => { + const connIcon = getConnectionIcon(state); + const authIcon = getAuthIcon(state); - return ( - - -
e.stopPropagation()} + return ( + - {authIcon && ( + +
e.stopPropagation()} + > + {authIcon && ( + + +
{authIcon.icon}
+
+ +

{authIcon.label}

+
+
+ )} + -
{authIcon.icon}
+
{connIcon.icon}
-

{authIcon.label}

+

{connIcon.label}

- )} +
+
+ ); + })} +
- - -
{connIcon.icon}
-
- -

{connIcon.label}

-
-
-
- - ); - })} + {/* Relay Selection */} + {!relays && reasoning && reasoning.length > 0 && ( +
+
+ Relay Selection + {isOptimized && ( + + ( + + ) + + )} +
+ + {/* Flat list of relays with icons and counts */} +
+ {reasoning.map((r, i) => ( +
+ +
+ {r.readers.length > 0 && ( +
+ + {r.readers.length} +
+ )} + {r.writers.length > 0 && ( +
+ + {r.writers.length} +
+ )} + {r.isFallback && ( + + fallback + + )} +
+
+ ))} +
+
+ )}
diff --git a/src/components/nostr/RelayLink.tsx b/src/components/nostr/RelayLink.tsx index 659303e..49ca87e 100644 --- a/src/components/nostr/RelayLink.tsx +++ b/src/components/nostr/RelayLink.tsx @@ -58,7 +58,7 @@ export function RelayLink({ }; const variantStyles = { - default: "cursor-crosshair hover:bg-muted/50", + default: "cursor-crosshair", prompt: "cursor-crosshair hover:underline hover:decoration-dotted", }; diff --git a/src/components/nostr/kinds/ChatMessageRenderer.tsx b/src/components/nostr/kinds/ChatMessageRenderer.tsx index 69be0a0..b39e358 100644 --- a/src/components/nostr/kinds/ChatMessageRenderer.tsx +++ b/src/components/nostr/kinds/ChatMessageRenderer.tsx @@ -18,7 +18,10 @@ export function Kind9Renderer({ event, depth = 0 }: BaseEventProps) { // Parse 'q' tag for quoted parent message (NIP-C7 reply format) const quotedEventIds = getTagValues(event, "q"); const quotedEventId = quotedEventIds[0]; // First q tag - const parentEvent = useNostrEvent(quotedEventId); + + // Pass full reply event to useNostrEvent for comprehensive relay selection + // This allows eventLoader to extract r/e/p tags for better relay coverage + const parentEvent = useNostrEvent(quotedEventId, event); const handleQuoteClick = () => { if (!parentEvent || !quotedEventId) return; diff --git a/src/components/nostr/kinds/NoteRenderer.tsx b/src/components/nostr/kinds/NoteRenderer.tsx index e5eb133..eb90478 100644 --- a/src/components/nostr/kinds/NoteRenderer.tsx +++ b/src/components/nostr/kinds/NoteRenderer.tsx @@ -15,7 +15,10 @@ export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) { const refs = getNip10References(event); const pointer = refs.reply?.e || refs.reply?.a || refs.root?.e || refs.root?.a; - const parentEvent = useNostrEvent(pointer); + + // Pass full reply event to useNostrEvent for comprehensive relay selection + // This allows eventLoader to extract r/e/p tags for better relay coverage + const parentEvent = useNostrEvent(pointer, event); const handleReplyClick = () => { if (!parentEvent) return; diff --git a/src/hooks/useNostrEvent.ts b/src/hooks/useNostrEvent.ts index 6c1d19f..e69bf69 100644 --- a/src/hooks/useNostrEvent.ts +++ b/src/hooks/useNostrEvent.ts @@ -26,6 +26,9 @@ function isAddressPointer( * Unified hook for fetching Nostr events by pointer * Supports string ID, EventPointer, and AddressPointer * @param pointer - string ID, EventPointer, or AddressPointer + * @param context - Optional context for relay hints: + * - string: pubkey of event author (backward compatible) + * - NostrEvent: full reply event with r/e/p tags (comprehensive relay selection) * @returns Event or undefined */ export function useNostrEvent( @@ -35,6 +38,7 @@ export function useNostrEvent( | AddressPointer | { kind: number; pubkey: string; identifier: string } | undefined, + context?: string | NostrEvent, ): NostrEvent | undefined { const eventStore = useEventStore(); @@ -75,13 +79,13 @@ export function useNostrEvent( // Handle string ID if (typeof pointer === "string") { console.log("[useNostrEvent] Loading event by ID:", pointer); - const subscription = eventLoader({ id: pointer }).subscribe(); + const subscription = eventLoader({ id: pointer }, context).subscribe(); return () => subscription.unsubscribe(); } if (isEventPointer(pointer)) { console.log("[useNostrEvent] Loading event by EventPointer:", pointer); - const subscription = eventLoader(pointer).subscribe(); + const subscription = eventLoader(pointer, context).subscribe(); return () => subscription.unsubscribe(); } else if (isAddressPointer(pointer)) { console.log("[useNostrEvent] Loading event by AddressPointer:", pointer); @@ -98,7 +102,7 @@ export function useNostrEvent( } else { console.warn("[useNostrEvent] Unknown pointer type:", pointer); } - }, [pointer, pointerKey]); + }, [pointer, pointerKey, context]); return event; } diff --git a/src/hooks/useOutboxRelays.ts b/src/hooks/useOutboxRelays.ts new file mode 100644 index 0000000..aaa5331 --- /dev/null +++ b/src/hooks/useOutboxRelays.ts @@ -0,0 +1,125 @@ +/** + * React hook for NIP-65 outbox relay selection + * + * Wraps the relay selection service for easy use in React components. + * Automatically fetches kind:10002 relay lists and selects optimal relays + * based on filter authors and #p tags. + */ + +import { useState, useEffect, useMemo } from "react"; +import { useEventStore } from "applesauce-react/hooks"; +import type { Filter as NostrFilter } from "nostr-tools"; +import { selectRelaysForFilter } from "@/services/relay-selection"; +import type { + RelaySelectionResult, + RelaySelectionOptions, +} from "@/types/relay-selection"; + +/** + * Hook for selecting optimal relays for a Nostr filter using NIP-65 + * + * @param filter - Nostr filter to select relays for + * @param options - Configuration options + * @returns Relay selection result with loading state + * + * @example + * ```typescript + * const { relays, reasoning, loading, isOptimized } = useOutboxRelays({ + * authors: ["abc123..."], + * kinds: [1] + * }); + * + * // Use relays with useReqTimeline + * const { events } = useReqTimeline("timeline-id", filter, relays); + * ``` + */ +export type RelaySelectionPhase = 'discovering' | 'selecting' | 'ready'; + +export function useOutboxRelays( + filter: NostrFilter, + options?: RelaySelectionOptions, +): RelaySelectionResult & { loading: boolean; phase: RelaySelectionPhase } { + const eventStore = useEventStore(); + const [result, setResult] = useState({ + relays: options?.fallbackRelays || [], + reasoning: [], + isOptimized: false, + }); + const [loading, setLoading] = useState(true); + const [phase, setPhase] = useState('discovering'); + + // Stable reference for filter.authors and filter["#p"] + // Only re-run when these change + const authorsKey = useMemo( + () => JSON.stringify(filter.authors || []), + [filter.authors], + ); + const pTagsKey = useMemo( + () => JSON.stringify(filter["#p"] || []), + [filter["#p"]], + ); + + // Stable reference for fallbackRelays array + const fallbackRelaysKey = useMemo( + () => JSON.stringify(options?.fallbackRelays || []), + [options?.fallbackRelays], + ); + + // Extract primitive options to avoid object reference issues + const maxRelays = options?.maxRelays; + const maxRelaysPerUser = options?.maxRelaysPerUser; + const timeout = options?.timeout; + + useEffect(() => { + let cancelled = false; + + async function selectRelays() { + setLoading(true); + setPhase('discovering'); + + try { + // Reconstruct options inside effect to avoid dependency on object reference + const selectionOptions: RelaySelectionOptions = { + fallbackRelays: JSON.parse(fallbackRelaysKey), + maxRelays, + maxRelaysPerUser, + timeout, + }; + + setPhase('selecting'); + const selection = await selectRelaysForFilter( + eventStore, + filter, + selectionOptions, + ); + + if (!cancelled) { + setResult(selection); + setPhase('ready'); + } + } catch (err) { + console.error("[useOutboxRelays] Failed to select relays:", err); + // Keep previous result on error + if (!cancelled) { + setPhase('ready'); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + selectRelays(); + + return () => { + cancelled = true; + }; + }, [eventStore, authorsKey, pTagsKey, fallbackRelaysKey, maxRelays, maxRelaysPerUser, timeout]); + + return { + ...result, + loading, + phase, + }; +} diff --git a/src/hooks/useRelayListCacheSync.ts b/src/hooks/useRelayListCacheSync.ts new file mode 100644 index 0000000..666e245 --- /dev/null +++ b/src/hooks/useRelayListCacheSync.ts @@ -0,0 +1,24 @@ +/** + * Hook to keep relay list cache in sync with EventStore + * + * Subscribes to kind:10002 events and automatically caches them in Dexie. + * Should be used once at app root level. + */ + +import { useEffect } from "react"; +import { useEventStore } from "applesauce-react/hooks"; +import relayListCache from "@/services/relay-list-cache"; + +export function useRelayListCacheSync() { + const eventStore = useEventStore(); + + useEffect(() => { + // Subscribe to EventStore for auto-caching + relayListCache.subscribeToEventStore(eventStore); + + // Cleanup on unmount + return () => { + relayListCache.unsubscribe(); + }; + }, [eventStore]); +} diff --git a/src/services/db.ts b/src/services/db.ts index 3bad016..c14a35e 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -2,6 +2,7 @@ import { ProfileContent } from "applesauce-core/helpers"; import { Dexie, Table } from "dexie"; import { RelayInformation } from "../types/nip11"; import { normalizeRelayURL } from "../lib/relay-url"; +import type { NostrEvent } from "@/types/nostr"; export interface Profile extends ProfileContent { pubkey: string; @@ -31,12 +32,21 @@ export interface RelayAuthPreference { updatedAt: number; } +export interface CachedRelayList { + pubkey: string; + event: NostrEvent; + read: string[]; + write: string[]; + updatedAt: number; +} + class GrimoireDb extends Dexie { profiles!: Table; nip05!: Table; nips!: Table; relayInfo!: Table; relayAuthPreferences!: Table; + relayLists!: Table; constructor(name: string) { super(name); @@ -139,6 +149,16 @@ class GrimoireDb extends Dexie { ); console.log("[DB Migration v6] Complete!"); }); + + // Version 7: Add relay lists caching + this.version(7).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + }); } } diff --git a/src/services/loaders.test.ts b/src/services/loaders.test.ts new file mode 100644 index 0000000..00f8cc1 --- /dev/null +++ b/src/services/loaders.test.ts @@ -0,0 +1,413 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { eventLoader } from "./loaders"; +import type { NostrEvent } from "@/types/nostr"; +import { SeenRelaysSymbol } from "applesauce-core/helpers/relays"; +import type { EventPointer } from "nostr-tools/nip19"; + +// Mock dependencies +vi.mock("./relay-pool", () => ({ + default: {}, // Mock pool object +})); + +vi.mock("./event-store", () => ({ + default: { + getEvent: vi.fn(), + }, +})); + +vi.mock("./relay-list-cache", () => ({ + relayListCache: { + getOutboxRelaysSync: vi.fn(), + }, +})); + +vi.mock("applesauce-loaders/loaders", () => ({ + createEventLoader: vi.fn( + () => (pointer: EventPointer) => + ({ + subscribe: () => ({ + unsubscribe: () => {}, + }), + // Return pointer so we can inspect it in tests + _testPointer: pointer, + }) as any + ), + createAddressLoader: vi.fn(() => () => ({ subscribe: () => {} })), + createTimelineLoader: vi.fn(), +})); + +import eventStore from "./event-store"; +import { relayListCache } from "./relay-list-cache"; + +// Test helpers +function createMockEvent(overrides: Partial = {}): NostrEvent { + return { + id: "test-event-id", + pubkey: "test-pubkey", + created_at: Math.floor(Date.now() / 1000), + kind: 1, + tags: [], + content: "test content", + sig: "test-sig", + ...overrides, + }; +} + +function createEventWithSeenRelays(relays: string[]): NostrEvent { + const event = createMockEvent(); + (event as any)[SeenRelaysSymbol] = new Set(relays); + return event; +} + +function createEventWithTags(tags: string[][]): NostrEvent { + return createMockEvent({ tags }); +} + +describe("eventLoader", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("basic functionality", () => { + it("should handle string ID with no context", () => { + const result = eventLoader({ id: "test123" }); + + expect(result).toBeDefined(); + expect((result as any)._testPointer.id).toBe("test123"); + // mergeRelaySets normalizes URLs with trailing slash + expect((result as any)._testPointer.relays).toContain("wss://relay.nostr.band/"); + }); + + it("should handle EventPointer with relay hints", () => { + const pointer: EventPointer = { + id: "test123", + relays: ["wss://relay.example.com/"], + }; + + const result = eventLoader(pointer); + + // mergeRelaySets normalizes URLs with trailing slash + expect((result as any)._testPointer.relays).toContain("wss://relay.example.com/"); + }); + + it("should handle undefined context gracefully", () => { + const result = eventLoader({ id: "test123" }, undefined); + + expect(result).toBeDefined(); + // mergeRelaySets normalizes URLs with trailing slash + expect((result as any)._testPointer.relays).toContain("wss://relay.nostr.band/"); + }); + }); + + describe("backward compatibility with string authorHint", () => { + it("should accept string pubkey as context", () => { + vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([ + "wss://author-relay.com/", + ]); + + const result = eventLoader({ id: "test123" }, "author-pubkey"); + + expect(relayListCache.getOutboxRelaysSync).toHaveBeenCalledWith( + "author-pubkey" + ); + expect((result as any)._testPointer.relays).toContain( + "wss://author-relay.com/" + ); + }); + + it("should use cached relays when authorHint provided", () => { + vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([ + "wss://cached1.com/", + "wss://cached2.com/", + "wss://cached3.com/", + "wss://cached4.com/", // Should be limited to 3 + ]); + + const result = eventLoader({ id: "test123" }, "author-pubkey"); + + const relays = (result as any)._testPointer.relays; + expect(relays).toContain("wss://cached1.com/"); + expect(relays).toContain("wss://cached2.com/"); + expect(relays).toContain("wss://cached3.com/"); + // Should be limited to top 3 cached relays + expect(relays.filter((r: string) => r.startsWith("wss://cached")).length).toBeLessThanOrEqual(3); + }); + }); + + describe("comprehensive context with NostrEvent", () => { + it("should extract and use seen-at relays", () => { + const event = createEventWithSeenRelays([ + "wss://seen1.com/", + "wss://seen2.com/", + ]); + + const result = eventLoader({ id: "parent123" }, event); + + const relays = (result as any)._testPointer.relays; + expect(relays).toContain("wss://seen1.com/"); + expect(relays).toContain("wss://seen2.com/"); + }); + + it("should extract and use r tags", () => { + const event = createEventWithTags([ + ["r", "wss://r-tag1.com/"], + ["r", "wss://r-tag2.com/"], + ["r", "wss://r-tag3.com/"], + ]); + + const result = eventLoader({ id: "parent123" }, event); + + const relays = (result as any)._testPointer.relays; + expect(relays).toContain("wss://r-tag1.com/"); + expect(relays).toContain("wss://r-tag2.com/"); + expect(relays).toContain("wss://r-tag3.com/"); + }); + + it("should extract relay hints from e tags", () => { + const event = createEventWithTags([ + ["e", "event-id-1", "wss://e-tag1.com/", "reply"], + ["e", "event-id-2", "wss://e-tag2.com/", "root"], + ["e", "event-id-3"], // No relay hint, should be skipped + ]); + + const result = eventLoader({ id: "parent123" }, event); + + const relays = (result as any)._testPointer.relays; + expect(relays).toContain("wss://e-tag1.com/"); + expect(relays).toContain("wss://e-tag2.com/"); + }); + + it("should extract author hint from p tags", () => { + vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([ + "wss://author-outbox.com/", + ]); + + const event = createEventWithTags([ + ["p", "mentioned-author-pubkey"], + ["p", "second-author"], // Should use first p tag + ]); + + const result = eventLoader({ id: "parent123" }, event); + + expect(relayListCache.getOutboxRelaysSync).toHaveBeenCalledWith( + "mentioned-author-pubkey" + ); + const relays = (result as any)._testPointer.relays; + expect(relays).toContain("wss://author-outbox.com/"); + }); + + it("should combine all relay sources", () => { + vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([ + "wss://cached.com/", + ]); + + const event = createMockEvent({ + tags: [ + ["p", "author-pubkey"], + ["r", "wss://r-tag.com/"], + ["e", "event-id", "wss://e-tag.com/"], + ], + }); + + // Add seen relays + (event as any)[SeenRelaysSymbol] = new Set(["wss://seen.com/"]); + + const pointer: EventPointer = { + id: "parent123", + relays: ["wss://direct.com/"], + }; + + const result = eventLoader(pointer, event); + + const relays = (result as any)._testPointer.relays; + + // Verify all sources are present + expect(relays).toContain("wss://direct.com/"); + expect(relays).toContain("wss://seen.com/"); + expect(relays).toContain("wss://cached.com/"); + expect(relays).toContain("wss://r-tag.com/"); + expect(relays).toContain("wss://e-tag.com/"); + // mergeRelaySets normalizes aggregator relays with trailing slash + expect(relays).toContain("wss://relay.nostr.band/"); + }); + }); + + describe("relay priority ordering", () => { + it("should prioritize direct hints over seen relays", () => { + const event = createEventWithSeenRelays(["wss://seen.com/"]); + + const pointer: EventPointer = { + id: "test123", + relays: ["wss://direct.com/"], + }; + + const result = eventLoader(pointer, event); + const relays = (result as any)._testPointer.relays; + + // Direct hints should come before seen relays due to mergeRelaySets priority + const directIndex = relays.indexOf("wss://direct.com/"); + const seenIndex = relays.indexOf("wss://seen.com/"); + expect(directIndex).toBeLessThan(seenIndex); + }); + + it("should prioritize seen relays over cached relays", () => { + vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([ + "wss://cached.com/", + ]); + + const event = createMockEvent({ + tags: [["p", "author-pubkey"]], + }); + (event as any)[SeenRelaysSymbol] = new Set(["wss://seen.com/"]); + + const result = eventLoader({ id: "test123" }, event); + const relays = (result as any)._testPointer.relays; + + const seenIndex = relays.indexOf("wss://seen.com/"); + const cachedIndex = relays.indexOf("wss://cached.com/"); + expect(seenIndex).toBeLessThan(cachedIndex); + }); + }); + + describe("deduplication", () => { + it("should deduplicate same relay from different sources", () => { + vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([ + "wss://duplicate.com/", + ]); + + const event = createMockEvent({ + tags: [ + ["p", "author-pubkey"], + ["r", "wss://duplicate.com/"], + ], + }); + (event as any)[SeenRelaysSymbol] = new Set(["wss://duplicate.com/"]); + + const pointer: EventPointer = { + id: "test123", + relays: ["wss://duplicate.com/"], + }; + + const result = eventLoader(pointer, event); + const relays = (result as any)._testPointer.relays; + + // Should only appear once despite being in 4 sources + const count = relays.filter((r: string) => r === "wss://duplicate.com/").length; + expect(count).toBe(1); + }); + }); + + describe("edge cases", () => { + it("should handle event with no tags", () => { + const event = createMockEvent({ tags: [] }); + + const result = eventLoader({ id: "test123" }, event); + + expect(result).toBeDefined(); + // mergeRelaySets normalizes aggregator relays with trailing slash + expect((result as any)._testPointer.relays).toContain("wss://relay.nostr.band/"); + }); + + it("should handle invalid e tags gracefully", () => { + const event = createEventWithTags([ + ["e"], // Missing event ID + ["e", "valid-id", "wss://valid.com/"], + ]); + + const result = eventLoader({ id: "test123" }, event); + + // Should still include the valid relay + expect((result as any)._testPointer.relays).toContain("wss://valid.com/"); + }); + + it("should handle empty r tags", () => { + const event = createEventWithTags([ + ["r", ""], // Empty URL + ["r", "wss://valid.com/"], + ]); + + const result = eventLoader({ id: "test123" }, event); + + // Should filter out empty r tag + expect((result as any)._testPointer.relays).toContain("wss://valid.com/"); + }); + + it("should use existing event author when event is in store", () => { + const existingEvent = createMockEvent({ pubkey: "existing-author" }); + vi.mocked(eventStore.getEvent).mockReturnValue(existingEvent); + vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([ + "wss://existing-author-relay.com/", + ]); + + const result = eventLoader({ id: "test123" }); + + expect(eventStore.getEvent).toHaveBeenCalledWith("test123"); + expect(relayListCache.getOutboxRelaysSync).toHaveBeenCalledWith( + "existing-author" + ); + expect((result as any)._testPointer.relays).toContain( + "wss://existing-author-relay.com/" + ); + }); + + it("should fall back to aggregators when no other relays available", () => { + vi.mocked(eventStore.getEvent).mockReturnValue(undefined); + vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([]); + + const event = createMockEvent({ tags: [] }); + + const result = eventLoader({ id: "test123" }, event); + + const relays = (result as any)._testPointer.relays; + + // Should only have aggregator relays (normalized with trailing slash) + expect(relays).toContain("wss://relay.nostr.band/"); + expect(relays).toContain("wss://nos.lol/"); + expect(relays).toContain("wss://purplepag.es/"); + expect(relays).toContain("wss://relay.primal.net/"); + }); + + it("should limit cached relays to 3", () => { + vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([ + "wss://cached1.com/", + "wss://cached2.com/", + "wss://cached3.com/", + "wss://cached4.com/", + "wss://cached5.com/", + ]); + + const event = createMockEvent({ + tags: [["p", "author-pubkey"]], + }); + + const result = eventLoader({ id: "test123" }, event); + const relays = (result as any)._testPointer.relays; + + // Count how many cached relays made it through + const cachedCount = relays.filter((r: string) => + r.startsWith("wss://cached") + ).length; + + // Should be exactly 3 (top 3 cached relays) + expect(cachedCount).toBe(3); + }); + }); + + describe("event with no seen relays (standard NostrEvent)", () => { + it("should handle event without SeenRelaysSymbol", () => { + const event = createMockEvent({ + tags: [ + ["p", "author-pubkey"], + ["r", "wss://r-tag.com/"], + ], + }); + // No SeenRelaysSymbol added + + const result = eventLoader({ id: "test123" }, event); + + expect(result).toBeDefined(); + // Should still work with r tags and p tags + expect((result as any)._testPointer.relays).toContain("wss://r-tag.com/"); + }); + }); +}); diff --git a/src/services/loaders.ts b/src/services/loaders.ts index 684b92d..f976639 100644 --- a/src/services/loaders.ts +++ b/src/services/loaders.ts @@ -3,8 +3,53 @@ import { createAddressLoader, createTimelineLoader, } from "applesauce-loaders/loaders"; +import type { EventPointer } from "nostr-tools/nip19"; +import { Observable } from "rxjs"; +import { getSeenRelays, mergeRelaySets } from "applesauce-core/helpers/relays"; +import { getEventPointerFromETag } from "applesauce-core/helpers/pointers"; +import { getTagValue } from "applesauce-core/helpers/event-tags"; import pool from "./relay-pool"; import eventStore from "./event-store"; +import { relayListCache } from "./relay-list-cache"; +import type { NostrEvent } from "@/types/nostr"; + +/** + * Extract relay context from a Nostr event for comprehensive relay selection + * Uses applesauce helpers for robust tag parsing and relay tracking + */ +function extractRelayContext(event: NostrEvent): { + authorHint?: string; + seenRelays: Set | undefined; + rTags: string[]; + eTagRelays: string[]; +} { + // Get relays where this event was seen (tracked by applesauce) + const seenRelays = getSeenRelays(event); + + // Extract all "r" tags (URL references per NIP-01) + const rTags = event.tags + .filter((t) => t[0] === "r") + .map((t) => t[1]) + .filter(Boolean); + + // Extract relay hints from all "e" tags using applesauce helper + const eTagRelays = event.tags + .filter((t) => t[0] === "e") + .map((tag) => { + try { + const pointer = getEventPointerFromETag(tag); + return pointer.relays?.[0]; // First relay hint from the pointer + } catch { + return undefined; // Invalid e tag, skip it + } + }) + .filter((relay): relay is string => relay !== undefined); + + // Extract first "p" tag as author hint using applesauce helper + const authorHint = getTagValue(event, "p"); + + return { seenRelays, authorHint, rTags, eTagRelays }; +} // Aggregator relays for better event discovery export const AGGREGATOR_RELAYS = [ @@ -14,12 +59,113 @@ export const AGGREGATOR_RELAYS = [ "wss://relay.primal.net", ]; -// Event loader for fetching single events by ID -export const eventLoader = createEventLoader(pool, { +// Base event loader (used internally) +const baseEventLoader = createEventLoader(pool, { eventStore, extraRelays: AGGREGATOR_RELAYS, }); +/** + * Smart event loader that combines relay hints with cached relay lists + * + * Strategy (priority order): + * 1. Direct relay hints from EventPointer + * 2. Seen-at relays (where reply event was received) + * 3. Author's cached outbox relays (from NIP-65) + * 4. "r" tags from context event (URL references) + * 5. Other "e" tag relay hints from context event + * 6. Aggregator relays (fallback) + * + * @param pointer - Event ID or EventPointer with relay hints + * @param context - Optional context for relay hints: + * - string: pubkey of event author (backward compatible) + * - NostrEvent: full reply event with r/e/p tags (comprehensive + seen-at relays) + * + * Note: This is a synchronous wrapper that uses the memory cache layer only. + * Full relay list lookup happens async in useOutboxRelays for timelines. + */ +export function eventLoader( + pointer: EventPointer | { id: string }, + context?: string | NostrEvent +): Observable { + // Extract context information + let authorHint: string | undefined; + let seenRelays: Set | undefined; + let rTags: string[] = []; + let eTagRelays: string[] = []; + + if (context) { + if (typeof context === "string") { + // Backward compatible: just an author pubkey + authorHint = context; + } else { + // Comprehensive: extract all relay hints from reply event + const extracted = extractRelayContext(context); + authorHint = extracted.authorHint; + seenRelays = extracted.seenRelays; + rTags = extracted.rTags; + eTagRelays = extracted.eTagRelays; + } + } + + // Get direct relay hints from EventPointer + const directHints = (pointer as EventPointer).relays || []; + + // Try to get cached outbox relays + let cachedOutboxRelays: string[] = []; + + // Check if event already exists in store + const existingEvent = eventStore.getEvent(pointer.id); + if (existingEvent) { + cachedOutboxRelays = relayListCache.getOutboxRelaysSync(existingEvent.pubkey) || []; + } + + // If not in store but we have author hint (from reply "p" tag) + if (cachedOutboxRelays.length === 0 && authorHint) { + cachedOutboxRelays = relayListCache.getOutboxRelaysSync(authorHint) || []; + } + + // Limit cached relays to top 3 to avoid too many connections + const topCachedRelays = cachedOutboxRelays.slice(0, 3); + + // Merge all relay sources with priority ordering + // mergeRelaySets handles deduplication, normalization, and invalid URL filtering + const allRelays = mergeRelaySets( + directHints, // Priority 1: Direct hints (most specific) + seenRelays, // Priority 2: Where reply was seen (high confidence) + topCachedRelays, // Priority 3: Author's outbox (NIP-65 standard) + rTags, // Priority 4: Conversation context + eTagRelays, // Priority 5: Other event references + AGGREGATOR_RELAYS // Priority 6: Fallback + ); + + // Build enhanced pointer with all relay sources + const enhancedPointer: EventPointer = { + id: pointer.id, + relays: allRelays, + }; + + // Debug logging to track relay sources and deduplication + const totalSources = + directHints.length + + (seenRelays?.size || 0) + + topCachedRelays.length + + rTags.length + + eTagRelays.length + + AGGREGATOR_RELAYS.length; + + const duplicatesRemoved = totalSources - allRelays.length; + + console.debug( + `[eventLoader] Fetching ${pointer.id.slice(0, 8)} from ${allRelays.length} relays ` + + `(direct=${directHints.length} seen=${seenRelays?.size || 0} cached=${topCachedRelays.length} ` + + `r=${rTags.length} e=${eTagRelays.length} agg=${AGGREGATOR_RELAYS.length}, ` + + `${duplicatesRemoved} duplicates removed)` + ); + + return baseEventLoader(enhancedPointer); +} + // Address loader for replaceable events (profiles, relay lists, etc.) export const addressLoader = createAddressLoader(pool, { eventStore, diff --git a/src/services/relay-list-cache.ts b/src/services/relay-list-cache.ts new file mode 100644 index 0000000..af8d36a --- /dev/null +++ b/src/services/relay-list-cache.ts @@ -0,0 +1,337 @@ +/** + * Relay List Cache Service + * + * Caches NIP-65 relay lists (kind:10002) in Dexie for fast access. + * Reduces network requests and improves cold start performance. + * + * Auto-caches kind:10002 events from EventStore when subscribed. + */ + +import type { NostrEvent } from "@/types/nostr"; +import { getInboxes, getOutboxes } from "applesauce-core/helpers"; +import { normalizeRelayURL } from "@/lib/relay-url"; +import db, { CachedRelayList } from "./db"; +import type { IEventStore } from "applesauce-core/event-store"; +import type { Subscription } from "rxjs"; + +const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours +const MAX_MEMORY_CACHE = 100; // LRU cache size + +class RelayListCache { + private eventStoreSubscription: Subscription | null = null; + private memoryCache = new Map(); + private cacheOrder: string[] = []; + + /** + * Subscribe to EventStore to auto-cache kind:10002 events + */ + subscribeToEventStore(eventStore: IEventStore): void { + if (this.eventStoreSubscription) { + console.warn("[RelayListCache] Already subscribed to EventStore"); + return; + } + + // Subscribe to kind:10002 events + this.eventStoreSubscription = eventStore + .filters({ kinds: [10002] }) + .subscribe((event: NostrEvent) => { + // Cache each kind:10002 event as it arrives + this.set(event); + }); + + console.log("[RelayListCache] Subscribed to EventStore for kind:10002 events"); + } + + /** + * Unsubscribe from EventStore + */ + unsubscribe(): void { + if (this.eventStoreSubscription) { + this.eventStoreSubscription.unsubscribe(); + this.eventStoreSubscription = null; + console.log("[RelayListCache] Unsubscribed from EventStore"); + } + } + + /** + * Get cached relay list for a pubkey + * Returns undefined if not cached or stale + */ + async get(pubkey: string): Promise { + try { + const cached = await db.relayLists.get(pubkey); + + // Check if stale (>24 hours) + if (cached && Date.now() - cached.updatedAt < CACHE_TTL) { + return cached; + } + + // Stale or not found + if (cached) { + console.debug( + `[RelayListCache] Cached relay list for ${pubkey.slice(0, 8)} is stale (${Math.floor((Date.now() - cached.updatedAt) / 1000 / 60)}min old)`, + ); + } + + return undefined; + } catch (error) { + console.error( + `[RelayListCache] Error reading cache for ${pubkey.slice(0, 8)}:`, + error, + ); + return undefined; + } + } + + /** + * Store relay list event in cache + */ + async set(event: NostrEvent): Promise { + try { + if (event.kind !== 10002) { + console.warn( + `[RelayListCache] Attempted to cache non-10002 event (kind ${event.kind})`, + ); + return; + } + + // Parse relays from event + const readRelays = getInboxes(event); + const writeRelays = getOutboxes(event); + + // Normalize URLs and filter invalid ones + const normalizedRead = readRelays + .map((url) => { + try { + return normalizeRelayURL(url); + } catch { + console.warn( + `[RelayListCache] Invalid read relay URL: ${url}`, + ); + return null; + } + }) + .filter((url): url is string => url !== null); + + const normalizedWrite = writeRelays + .map((url) => { + try { + return normalizeRelayURL(url); + } catch { + console.warn( + `[RelayListCache] Invalid write relay URL: ${url}`, + ); + return null; + } + }) + .filter((url): url is string => url !== null); + + // Store in Dexie and memory cache + const cachedEntry: CachedRelayList = { + pubkey: event.pubkey, + event, + read: normalizedRead, + write: normalizedWrite, + updatedAt: Date.now(), + }; + + await db.relayLists.put(cachedEntry); + + // Also populate memory cache + this.memoryCache.set(event.pubkey, cachedEntry); + this.cacheOrder.push(event.pubkey); + this.evictOldest(); + + console.debug( + `[RelayListCache] Cached relay list for ${event.pubkey.slice(0, 8)} (${normalizedWrite.length} write, ${normalizedRead.length} read)`, + ); + } catch (error) { + console.error( + `[RelayListCache] Error caching relay list for ${event.pubkey.slice(0, 8)}:`, + error, + ); + } + } + + /** + * Update LRU order for a pubkey + */ + private updateLRU(pubkey: string): void { + const index = this.cacheOrder.indexOf(pubkey); + if (index > -1) { + this.cacheOrder.splice(index, 1); + } + this.cacheOrder.push(pubkey); + } + + /** + * Evict oldest entries from memory cache if over limit + */ + private evictOldest(): void { + while (this.cacheOrder.length > MAX_MEMORY_CACHE) { + const oldest = this.cacheOrder.shift(); + if (oldest) { + this.memoryCache.delete(oldest); + } + } + } + + /** + * Get outbox relays from memory cache only (synchronous, fast) + * Used for real-time operations where async Dexie lookup would be too slow + * Returns null if not in memory cache + */ + getOutboxRelaysSync(pubkey: string): string[] | null { + const memCached = this.memoryCache.get(pubkey); + if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { + this.updateLRU(pubkey); + return memCached.write; + } + return null; + } + + /** + * Get outbox (write) relays for a pubkey from cache + */ + async getOutboxRelays(pubkey: string): Promise { + // Check memory cache first (< 1ms) + const memCached = this.memoryCache.get(pubkey); + if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { + this.updateLRU(pubkey); + return memCached.write; + } + + // Then check Dexie (5-10ms) + const cached = await this.get(pubkey); + if (cached) { + // Populate memory cache for next time + this.memoryCache.set(pubkey, cached); + this.cacheOrder.push(pubkey); + this.evictOldest(); + return cached.write; + } + + return null; + } + + /** + * Get inbox (read) relays for a pubkey from cache + */ + async getInboxRelays(pubkey: string): Promise { + // Check memory cache first (< 1ms) + const memCached = this.memoryCache.get(pubkey); + if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { + this.updateLRU(pubkey); + return memCached.read; + } + + // Then check Dexie (5-10ms) + const cached = await this.get(pubkey); + if (cached) { + // Populate memory cache for next time + this.memoryCache.set(pubkey, cached); + this.cacheOrder.push(pubkey); + this.evictOldest(); + return cached.read; + } + + return null; + } + + /** + * Check if we have a valid cache entry for a pubkey + */ + async has(pubkey: string): Promise { + const cached = await this.get(pubkey); + return cached !== undefined; + } + + /** + * Invalidate (delete) cache entry for a pubkey + */ + async invalidate(pubkey: string): Promise { + try { + await db.relayLists.delete(pubkey); + // Also remove from memory cache + this.memoryCache.delete(pubkey); + const index = this.cacheOrder.indexOf(pubkey); + if (index > -1) { + this.cacheOrder.splice(index, 1); + } + console.debug( + `[RelayListCache] Invalidated cache for ${pubkey.slice(0, 8)}`, + ); + } catch (error) { + console.error( + `[RelayListCache] Error invalidating cache for ${pubkey.slice(0, 8)}:`, + error, + ); + } + } + + /** + * Clear all cached relay lists + */ + async clear(): Promise { + try { + await db.relayLists.clear(); + // Also clear memory cache + this.memoryCache.clear(); + this.cacheOrder = []; + console.log("[RelayListCache] Cleared all cached relay lists"); + } catch (error) { + console.error("[RelayListCache] Error clearing cache:", error); + } + } + + /** + * Get cache statistics + */ + async getStats(): Promise<{ + count: number; + oldestEntry: number | null; + newestEntry: number | null; + memoryCacheSize: number; + memoryCacheLimit: number; + }> { + try { + const count = await db.relayLists.count(); + const all = await db.relayLists.toArray(); + + if (all.length === 0) { + return { + count: 0, + oldestEntry: null, + newestEntry: null, + memoryCacheSize: this.memoryCache.size, + memoryCacheLimit: MAX_MEMORY_CACHE, + }; + } + + const timestamps = all.map((entry) => entry.updatedAt); + const oldest = Math.min(...timestamps); + const newest = Math.max(...timestamps); + + return { + count, + oldestEntry: oldest, + newestEntry: newest, + memoryCacheSize: this.memoryCache.size, + memoryCacheLimit: MAX_MEMORY_CACHE, + }; + } catch (error) { + console.error("[RelayListCache] Error getting stats:", error); + return { + count: 0, + oldestEntry: null, + newestEntry: null, + memoryCacheSize: this.memoryCache.size, + memoryCacheLimit: MAX_MEMORY_CACHE, + }; + } + } +} + +// Singleton instance +export const relayListCache = new RelayListCache(); +export default relayListCache; diff --git a/src/services/relay-liveness.ts b/src/services/relay-liveness.ts new file mode 100644 index 0000000..5e81c4e --- /dev/null +++ b/src/services/relay-liveness.ts @@ -0,0 +1,26 @@ +/** + * Relay Liveness Tracking Singleton + * + * Tracks relay health and connection states to deprioritize offline/dead relays. + * Uses applesauce-relay's RelayLiveness to implement backoff strategies. + */ + +import { RelayLiveness } from "applesauce-relay"; +import pool from "./relay-pool"; + +// Create singleton liveness tracker +const liveness = new RelayLiveness({ + // Maximum failures before marking relay as dead + maxFailuresBeforeDead: 5, + // Base delay for backoff (30 seconds) + backoffBaseDelay: 30 * 1000, + // Maximum backoff delay (5 minutes) + backoffMaxDelay: 5 * 60 * 1000, + // TODO: Add persistent storage using Dexie + // storage: undefined, +}); + +// Connect to relay pool to automatically track relay health +liveness.connectToPool(pool); + +export default liveness; diff --git a/src/services/relay-selection.test.ts b/src/services/relay-selection.test.ts new file mode 100644 index 0000000..2dc8a63 --- /dev/null +++ b/src/services/relay-selection.test.ts @@ -0,0 +1,394 @@ +/** + * Tests for NIP-65 Relay Selection + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { selectRelaysForFilter } from "./relay-selection"; +import { EventStore } from "applesauce-core"; +import type { NostrEvent } from "nostr-tools"; + +describe("selectRelaysForFilter", () => { + let eventStore: EventStore; + + beforeEach(() => { + eventStore = new EventStore(); + }); + + describe("fallback behavior", () => { + it("should return fallback relays when no authors or #p tags", async () => { + const result = await selectRelaysForFilter(eventStore, { + kinds: [1], + limit: 50, + }); + + expect(result.isOptimized).toBe(false); + expect(result.relays.length).toBeGreaterThan(0); + expect(result.reasoning.every((r) => r.isFallback)).toBe(true); + }); + + it("should use custom fallback relays when provided", async () => { + const customFallback = ["wss://custom.relay.com"]; + + const result = await selectRelaysForFilter( + eventStore, + { kinds: [1] }, + { fallbackRelays: customFallback }, + ); + + expect(result.relays).toEqual(customFallback); + }); + }); + + describe("author relay selection", () => { + it("should select write relays for authors", async () => { + const authorPubkey = + "32e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e"; + + // Mock kind:10002 event with write relays + const relayListEvent: NostrEvent = { + id: "test-event-id", + pubkey: authorPubkey, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["r", "wss://relay.damus.io"], + ["r", "wss://nos.lol"], + ["r", "wss://relay.nostr.band", "read"], + ], + content: "", + sig: "test-sig", + }; + + // Add to event store + eventStore.add(relayListEvent); + + const result = await selectRelaysForFilter( + eventStore, + { + authors: [authorPubkey], + kinds: [1], + }, + { timeout: 100 }, // Short timeout since event is already in store + ); + + expect(result.isOptimized).toBe(true); + expect(result.relays.length).toBeGreaterThan(0); + // Should include at least one write relay - selectOptimalRelays may pick subset + const hasWriteRelay = + result.relays.includes("wss://relay.damus.io/") || + result.relays.includes("wss://nos.lol/"); + expect(hasWriteRelay).toBe(true); + // Should NOT include read-only relay + expect(result.relays).not.toContain("wss://relay.nostr.band/"); + }); + + it("should handle multiple authors", async () => { + const author1 = + "32e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e"; + const author2 = + "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"; + + // Mock relay lists for both authors + eventStore.add({ + id: "event1", + pubkey: author1, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [["r", "wss://relay.damus.io"]], + content: "", + sig: "sig1", + }); + + eventStore.add({ + id: "event2", + pubkey: author2, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [["r", "wss://nos.lol"]], + content: "", + sig: "sig2", + }); + + const result = await selectRelaysForFilter( + eventStore, + { + authors: [author1, author2], + kinds: [1], + }, + { timeout: 100 }, + ); + + expect(result.isOptimized).toBe(true); + expect(result.relays).toContain("wss://relay.damus.io/"); + expect(result.relays).toContain("wss://nos.lol/"); + expect(result.reasoning.every((r) => r.writers.length > 0)).toBe(true); + }); + }); + + describe("p-tag relay selection", () => { + it("should select read relays for #p tags", async () => { + const mentionedPubkey = + "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"; + + // Mock kind:10002 event with read relays + const relayListEvent: NostrEvent = { + id: "test-event-id", + pubkey: mentionedPubkey, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["r", "wss://relay.damus.io", "write"], + ["r", "wss://nos.lol", "read"], + ["r", "wss://relay.nostr.band", "read"], + ], + content: "", + sig: "test-sig", + }; + + eventStore.add(relayListEvent); + + const result = await selectRelaysForFilter( + eventStore, + { + "#p": [mentionedPubkey], + kinds: [1], + }, + { timeout: 100 }, + ); + + expect(result.isOptimized).toBe(true); + expect(result.relays.length).toBeGreaterThan(0); + // Should include at least one read relay - selectOptimalRelays may pick subset + const hasReadRelay = + result.relays.includes("wss://nos.lol/") || + result.relays.includes("wss://relay.nostr.band/"); + expect(hasReadRelay).toBe(true); + // Should NOT include write-only relay + expect(result.relays).not.toContain("wss://relay.damus.io/"); + }); + }); + + describe("mixed authors and #p tags", () => { + it("should combine outbox and inbox relays", async () => { + const author = + "32e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e"; + const mentioned = + "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"; + + // Author has write relays + eventStore.add({ + id: "event1", + pubkey: author, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [["r", "wss://author-relay.com"]], + content: "", + sig: "sig1", + }); + + // Mentioned user has read relays + eventStore.add({ + id: "event2", + pubkey: mentioned, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [["r", "wss://mention-relay.com", "read"]], + content: "", + sig: "sig2", + }); + + const result = await selectRelaysForFilter( + eventStore, + { + authors: [author], + "#p": [mentioned], + kinds: [1], + }, + { timeout: 100 }, + ); + + expect(result.isOptimized).toBe(true); + expect(result.relays).toContain("wss://author-relay.com/"); + expect(result.relays).toContain("wss://mention-relay.com/"); + + // Check reasoning types + const authorReasoning = result.reasoning.find((r) => + r.relay.includes("author-relay"), + ); + const mentionReasoning = result.reasoning.find((r) => + r.relay.includes("mention-relay"), + ); + + expect(authorReasoning?.writers.length).toBeGreaterThan(0); + expect(authorReasoning?.readers.length).toBe(0); + expect(mentionReasoning?.readers.length).toBeGreaterThan(0); + expect(mentionReasoning?.writers.length).toBe(0); + }); + + it("should maintain diversity with multiple authors and p-tags", async () => { + const author1 = + "32e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e"; + const author2 = + "42e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e"; + const mentioned1 = + "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"; + const mentioned2 = + "92341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"; + + // Authors have write relays + eventStore.add({ + id: "event1", + pubkey: author1, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [["r", "wss://author1-relay.com"]], + content: "", + sig: "sig1", + }); + + eventStore.add({ + id: "event2", + pubkey: author2, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [["r", "wss://author2-relay.com"]], + content: "", + sig: "sig2", + }); + + // Mentioned users have read relays + eventStore.add({ + id: "event3", + pubkey: mentioned1, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [["r", "wss://mention1-relay.com", "read"]], + content: "", + sig: "sig3", + }); + + eventStore.add({ + id: "event4", + pubkey: mentioned2, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [["r", "wss://mention2-relay.com", "read"]], + content: "", + sig: "sig4", + }); + + const result = await selectRelaysForFilter( + eventStore, + { + authors: [author1, author2], + "#p": [mentioned1, mentioned2], + kinds: [1], + }, + { timeout: 100, maxRelays: 10 }, + ); + + expect(result.isOptimized).toBe(true); + + // Should have relays from both groups + const outboxRelays = result.reasoning.filter((r) => r.writers.length > 0); + const inboxRelays = result.reasoning.filter((r) => r.readers.length > 0); + + expect(outboxRelays.length).toBeGreaterThan(0); + expect(inboxRelays.length).toBeGreaterThan(0); + + // Should include at least some relays from each category + const hasAuthorRelays = + result.relays.some((r) => r.includes("author1-relay")) || + result.relays.some((r) => r.includes("author2-relay")); + const hasMentionRelays = + result.relays.some((r) => r.includes("mention1-relay")) || + result.relays.some((r) => r.includes("mention2-relay")); + + expect(hasAuthorRelays).toBe(true); + expect(hasMentionRelays).toBe(true); + }); + }); + + describe("relay limits", () => { + it("should respect maxRelays limit", async () => { + // Create many authors with different relays + const authors = Array.from({ length: 10 }, (_, i) => ({ + pubkey: `pubkey${i}`.padEnd(64, "0"), + relay: `wss://relay${i}.com`, + })); + + authors.forEach(({ pubkey, relay }) => { + eventStore.add({ + id: `event-${pubkey}`, + pubkey, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [["r", relay]], + content: "", + sig: "sig", + }); + }); + + const result = await selectRelaysForFilter( + eventStore, + { + authors: authors.map((a) => a.pubkey), + kinds: [1], + }, + { maxRelays: 5, timeout: 100 }, + ); + + expect(result.relays.length).toBeLessThanOrEqual(5); + }); + }); + + describe("edge cases", () => { + it("should handle users with no relay lists", async () => { + const pubkeyWithoutList = + "32e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e"; + + const result = await selectRelaysForFilter( + eventStore, + { + authors: [pubkeyWithoutList], + kinds: [1], + }, + { timeout: 100, fallbackRelays: ["wss://fallback.com"] }, + ); + + expect(result.relays).toContain("wss://fallback.com"); + }); + + it("should handle invalid relay URLs gracefully", async () => { + const pubkey = + "32e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e"; + + // Add relay list with invalid URL + eventStore.add({ + id: "event", + pubkey, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["r", "not-a-valid-url"], + ["r", "wss://valid-relay.com"], + ], + content: "", + sig: "sig", + }); + + const result = await selectRelaysForFilter( + eventStore, + { + authors: [pubkey], + kinds: [1], + }, + { timeout: 100 }, + ); + + // Should only include valid relay - normalized with trailing slash + expect(result.relays).toContain("wss://valid-relay.com/"); + expect(result.relays).not.toContain("not-a-valid-url"); + }); + }); +}); diff --git a/src/services/relay-selection.ts b/src/services/relay-selection.ts new file mode 100644 index 0000000..02d3ac8 --- /dev/null +++ b/src/services/relay-selection.ts @@ -0,0 +1,507 @@ +/** + * NIP-65 Relay Selection Service + * + * Intelligently selects relays for Nostr queries using the NIP-65 outbox model: + * - Query authors' WRITE relays (where they publish content) + * - Query mentioned users' READ relays (where they check mentions) + * - Optimize relay selection to minimize connections while maximizing coverage + * + * See: https://github.com/nostr-protocol/nips/blob/master/65.md + */ + +import type { NostrEvent } from "nostr-tools"; +import type { Filter as NostrFilter } from "nostr-tools"; +import type { ProfilePointer } from "nostr-tools/nip19"; +import { firstValueFrom, timeout as rxTimeout, of } from "rxjs"; +import { catchError } from "rxjs/operators"; +import type { IEventStore } from "applesauce-core/event-store"; +import { getInboxes, getOutboxes } from "applesauce-core/helpers"; +import { selectOptimalRelays } from "applesauce-core/helpers"; +import { addressLoader, AGGREGATOR_RELAYS } from "./loaders"; +import { normalizeRelayURL } from "@/lib/relay-url"; +import liveness from "./relay-liveness"; +import relayListCache from "./relay-list-cache"; +import type { + RelaySelectionResult, + RelaySelectionReasoning, + RelaySelectionOptions, +} from "@/types/relay-selection"; + +/** + * Fetches a kind:10002 relay list event for a pubkey with timeout + * + * @param pubkey - Hex pubkey to fetch relay list for + * @param timeoutMs - Timeout in milliseconds + * @returns Promise that resolves when fetch completes or times out + */ +async function fetchRelayList( + pubkey: string, + timeoutMs: number, +): Promise { + try { + await firstValueFrom( + addressLoader({ kind: 10002, pubkey, identifier: "" }).pipe( + rxTimeout(timeoutMs), + catchError(() => of(null)), + ), + ); + } catch (err) { + // Timeout or error - continue with fallback + console.debug(`[RelaySelection] Failed to fetch relay list for ${pubkey.slice(0, 8)}`, err); + } +} + +/** + * Sanitizes relay URLs by removing localhost and TOR relays + * + * @param relays - Array of relay URLs + * @returns Filtered array without localhost or TOR relays + */ +function sanitizeRelays(relays: string[]): string[] { + return relays.filter((url) => { + // Remove localhost relays (ws://localhost, ws://127.0.0.1, ws://[::1]) + if (/^wss?:\/\/(localhost|127\.0\.0\.1|\[::1\])/i.test(url)) { + console.debug(`[RelaySelection] Filtered localhost relay: ${url}`); + return false; + } + + // Remove TOR relays (*.onion) + if (/\.onion/i.test(url)) { + console.debug(`[RelaySelection] Filtered TOR relay: ${url}`); + return false; + } + + return true; + }); +} + +/** + * Gets outbox (write) relays for a pubkey + * Checks cache first, falls back to EventStore + * + * @param eventStore - EventStore instance + * @param pubkey - Hex pubkey + * @returns Array of normalized relay URLs (filtered for health) + */ +async function getOutboxRelaysForPubkey( + eventStore: IEventStore, + pubkey: string, +): Promise { + try { + // Check cache first + const cachedRelays = await relayListCache.getOutboxRelays(pubkey); + if (cachedRelays) { + console.debug( + `[RelaySelection] Using cached outbox relays for ${pubkey.slice(0, 8)} (${cachedRelays.length} relays)`, + ); + // Apply sanity filters (remove localhost, TOR) + const sanitized = sanitizeRelays(cachedRelays); + + // Still filter for health even if cached + try { + const healthy = liveness.filter(sanitized); + if (healthy.length === 0 && sanitized.length > 0) { + return sanitized; // Keep sanitized relays even if all unhealthy + } + return healthy; + } catch { + return sanitized; + } + } + + // Cache miss - get from EventStore + const event = eventStore.getReplaceable(10002, pubkey, "") as NostrEvent | undefined; + if (!event) { + console.debug( + `[RelaySelection] No relay list found for ${pubkey.slice(0, 8)} (not in cache or store)`, + ); + return []; + } + + // Cache the event for next time + relayListCache.set(event); + console.debug( + `[RelaySelection] Cache miss for ${pubkey.slice(0, 8)}, loaded from EventStore`, + ); + + // Parse outbox relays and normalize URLs + const outboxes = getOutboxes(event); + const normalized = outboxes + .map((url) => { + try { + return normalizeRelayURL(url); + } catch { + console.warn(`[RelaySelection] Invalid relay URL in kind:10002: ${url}`); + return null; + } + }) + .filter((url): url is string => url !== null); + + // Apply sanity filters (remove localhost, TOR) + const sanitized = sanitizeRelays(normalized); + + // Filter unhealthy relays (dead/blacklisted) + try { + const healthy = liveness.filter(sanitized); + + // Edge case: If all relays filtered out, keep some anyway for redundancy + if (healthy.length === 0 && sanitized.length > 0) { + console.debug( + `[RelaySelection] All relays unhealthy for ${pubkey.slice(0, 8)}, keeping sanitized relays` + ); + return sanitized; + } + + return healthy; + } catch (err) { + console.warn(`[RelaySelection] Liveness filtering failed, using sanitized relays:`, err); + return sanitized; + } + } catch (err) { + console.warn(`[RelaySelection] Error getting outbox relays for ${pubkey.slice(0, 8)}:`, err); + return []; + } +} + +/** + * Gets inbox (read) relays for a pubkey + * Checks cache first, falls back to EventStore + * + * @param eventStore - EventStore instance + * @param pubkey - Hex pubkey + * @returns Array of normalized relay URLs (filtered for health) + */ +async function getInboxRelaysForPubkey( + eventStore: IEventStore, + pubkey: string, +): Promise { + try { + // Check cache first + const cachedRelays = await relayListCache.getInboxRelays(pubkey); + if (cachedRelays) { + console.debug( + `[RelaySelection] Using cached inbox relays for ${pubkey.slice(0, 8)} (${cachedRelays.length} relays)`, + ); + // Apply sanity filters (remove localhost, TOR) + const sanitized = sanitizeRelays(cachedRelays); + + // Still filter for health even if cached + try { + const healthy = liveness.filter(sanitized); + if (healthy.length === 0 && sanitized.length > 0) { + return sanitized; // Keep sanitized relays even if all unhealthy + } + return healthy; + } catch { + return sanitized; + } + } + + // Cache miss - get from EventStore + const event = eventStore.getReplaceable(10002, pubkey, "") as NostrEvent | undefined; + if (!event) { + console.debug( + `[RelaySelection] No relay list found for ${pubkey.slice(0, 8)} (not in cache or store)`, + ); + return []; + } + + // Cache the event for next time + relayListCache.set(event); + console.debug( + `[RelaySelection] Cache miss for ${pubkey.slice(0, 8)}, loaded from EventStore`, + ); + + // Parse inbox relays and normalize URLs + const inboxes = getInboxes(event); + const normalized = inboxes + .map((url) => { + try { + return normalizeRelayURL(url); + } catch { + console.warn(`[RelaySelection] Invalid relay URL in kind:10002: ${url}`); + return null; + } + }) + .filter((url): url is string => url !== null); + + // Apply sanity filters (remove localhost, TOR) + const sanitized = sanitizeRelays(normalized); + + // Filter unhealthy relays (dead/blacklisted) + try { + const healthy = liveness.filter(sanitized); + + // Edge case: If all relays filtered out, keep some anyway for redundancy + if (healthy.length === 0 && sanitized.length > 0) { + console.debug( + `[RelaySelection] All relays unhealthy for ${pubkey.slice(0, 8)}, keeping sanitized relays` + ); + return sanitized; + } + + return healthy; + } catch (err) { + console.warn(`[RelaySelection] Liveness filtering failed, using sanitized relays:`, err); + return sanitized; + } + } catch (err) { + console.warn(`[RelaySelection] Error getting inbox relays for ${pubkey.slice(0, 8)}:`, err); + return []; + } +} + +/** + * Builds reasoning array explaining why relays were selected + * + * @param selectedPointers - ProfilePointers after optimization + * @param authorPointers - Original author pointers (for type classification) + * @param pTagPointers - Original p-tag pointers (for type classification) + * @returns Array of reasoning objects + */ +function buildReasoning( + selectedPointers: ProfilePointer[], + authorPointers: ProfilePointer[], + pTagPointers: ProfilePointer[], +): RelaySelectionReasoning[] { + // Group pubkeys by relay, tracking writers and readers separately + const relayMap = new Map; readers: Set }>(); + + for (const pointer of selectedPointers) { + const isAuthor = authorPointers.some((p) => p.pubkey === pointer.pubkey); + const isPTag = pTagPointers.some((p) => p.pubkey === pointer.pubkey); + + for (const relay of pointer.relays || []) { + let entry = relayMap.get(relay); + if (!entry) { + entry = { writers: new Set(), readers: new Set() }; + relayMap.set(relay, entry); + } + + // Add to appropriate set(s) - a relay can be both! + if (isAuthor) { + entry.writers.add(pointer.pubkey); + } + if (isPTag) { + entry.readers.add(pointer.pubkey); + } + } + } + + // Convert to reasoning array + return Array.from(relayMap.entries()).map(([relay, { writers, readers }]) => ({ + relay, + writers: Array.from(writers), + readers: Array.from(readers), + isFallback: false, + })); +} + +/** + * Creates a fallback result when no pubkeys or all fetches failed + * + * @param fallbackRelays - Relay URLs to use as fallback + * @returns RelaySelectionResult with fallback relays + */ +function createFallbackResult(fallbackRelays: string[]): RelaySelectionResult { + return { + relays: fallbackRelays, + reasoning: fallbackRelays.map((relay) => ({ + relay, + writers: [], + readers: [], + isFallback: true, + })), + isOptimized: false, + }; +} + +/** + * Selects optimal relays for a Nostr filter using NIP-65 outbox model + * + * @param eventStore - EventStore instance for reading cached relay lists + * @param filter - Nostr filter to select relays for + * @param options - Configuration options + * @returns Promise resolving to relay selection result + * + * @example + * ```typescript + * // Query authors' write relays + * const result = await selectRelaysForFilter(eventStore, { + * authors: ["abc123..."], + * kinds: [1] + * }); + * + * // Query mentioned users' read relays + * const result = await selectRelaysForFilter(eventStore, { + * "#p": ["xyz789..."], + * kinds: [1] + * }); + * ``` + */ +export async function selectRelaysForFilter( + eventStore: IEventStore, + filter: NostrFilter, + options: RelaySelectionOptions = {}, +): Promise { + const { + maxRelays = 42, + maxRelaysPerUser = 6, + fallbackRelays = AGGREGATOR_RELAYS, + timeout = 1000, + } = options; + + // Extract pubkeys from filter + const authors = filter.authors || []; + const pTags = filter["#p"] || []; + + // If no pubkeys, return fallback immediately + if (authors.length === 0 && pTags.length === 0) { + console.debug("[RelaySelection] No authors or #p tags, using fallback relays"); + return createFallbackResult(fallbackRelays); + } + + console.debug( + `[RelaySelection] Selecting relays for ${authors.length} authors, ${pTags.length} p-tags`, + ); + + // Fetch kind:10002 for all pubkeys with timeout + // This triggers fetches but doesn't block on slow relays + await Promise.all([ + ...authors.map((pk) => fetchRelayList(pk, timeout)), + ...pTags.map((pk) => fetchRelayList(pk, timeout)), + ]); + + // Read from cache/EventStore and build ProfilePointers + // Take up to maxRelaysPerUser for each user to ensure redundancy + const authorPointers: ProfilePointer[] = await Promise.all( + authors.map(async (pubkey) => { + const relays = await getOutboxRelaysForPubkey(eventStore, pubkey); + return { + pubkey, + relays: relays.slice(0, maxRelaysPerUser), + }; + }) + ); + + const pTagPointers: ProfilePointer[] = await Promise.all( + pTags.map(async (pubkey) => { + const relays = await getInboxRelaysForPubkey(eventStore, pubkey); + return { + pubkey, + relays: relays.slice(0, maxRelaysPerUser), + }; + }) + ); + + // Add fallbacks for users with no relays + const processedAuthorPointers = authorPointers.map((pointer) => ({ + ...pointer, + relays: (pointer.relays && pointer.relays.length > 0) ? pointer.relays : fallbackRelays, + })); + + const processedPTagPointers = pTagPointers.map((pointer) => ({ + ...pointer, + relays: (pointer.relays && pointer.relays.length > 0) ? pointer.relays : fallbackRelays, + })); + + const allPointers = [...processedAuthorPointers, ...processedPTagPointers]; + const fallbackCount = + authorPointers.filter((p) => !p.relays || p.relays.length === 0).length + + pTagPointers.filter((p) => !p.relays || p.relays.length === 0).length; + + if (fallbackCount > 0) { + console.debug( + `[RelaySelection] ${fallbackCount} users have no relay list, using fallback relays`, + ); + } + + // If all users have no relays, return fallback result + if (fallbackCount === allPointers.length) { + console.debug("[RelaySelection] All users have no relay lists, using fallback"); + return createFallbackResult(fallbackRelays); + } + + // When both authors and p-tags exist, select from each group separately + // to ensure we maintain diversity (write relays from authors, read relays from p-tags) + let selectedPointers: ProfilePointer[]; + + if (authors.length === 1 && pTags.length === 1) { + // Special case: single author + single p-tag + // Use ALL outbox relays from author + ALL inbox relays from p-tag for complete coverage + selectedPointers = [...processedAuthorPointers, ...processedPTagPointers]; + + console.debug( + `[RelaySelection] Single author + single p-tag case: using all ${processedAuthorPointers[0].relays?.length || 0} outbox + ${processedPTagPointers[0].relays?.length || 0} inbox relays`, + ); + } else if (authors.length === 1 && pTags.length === 0) { + // Special case: single author (common for "notes from X" queries) + // Use ALL their outbox relays for complete content coverage + selectedPointers = processedAuthorPointers; + + console.debug( + `[RelaySelection] Single author case: using all ${selectedPointers[0].relays?.length || 0} outbox relays`, + ); + } else if (authors.length === 0 && pTags.length === 1) { + // Special case: single p-tagged user (common for "-p $me" queries) + // Use ALL their inbox relays for complete mention coverage + selectedPointers = processedPTagPointers; + + console.debug( + `[RelaySelection] Single p-tag case: using all ${selectedPointers[0].relays?.length || 0} inbox relays`, + ); + } else if (authors.length > 0 && pTags.length > 0) { + // Multiple authors/p-tags: split relay budget proportionally + const totalUsers = authors.length + pTags.length; + const authorRelayBudget = Math.max( + 3, // minimum 3 relays per group + Math.floor((authors.length / totalUsers) * maxRelays), + ); + const pTagRelayBudget = Math.max( + 3, // minimum 3 relays per group + maxRelays - authorRelayBudget, + ); + + // Select from each group independently + const selectedAuthors = selectOptimalRelays(processedAuthorPointers, { + maxConnections: authorRelayBudget, + maxRelaysPerUser, + }); + + const selectedPTags = selectOptimalRelays(processedPTagPointers, { + maxConnections: pTagRelayBudget, + maxRelaysPerUser, + }); + + selectedPointers = [...selectedAuthors, ...selectedPTags]; + + console.debug( + `[RelaySelection] Selected ${selectedAuthors.flatMap(p => p.relays).length} write relays from ${authors.length} authors, ` + + `${selectedPTags.flatMap(p => p.relays).length} read relays from ${pTags.length} p-tags`, + ); + } else { + // Optimize relay selection for efficient coverage + selectedPointers = selectOptimalRelays(allPointers, { + maxConnections: maxRelays, + maxRelaysPerUser, + }); + + console.debug( + `[RelaySelection] Selected relays from ${allPointers.length} ${allPointers.length === 1 ? 'user' : 'users'}`, + ); + } + + // Extract unique relays + const relays = Array.from(new Set(selectedPointers.flatMap((p) => p.relays || []))); + + console.debug(`[RelaySelection] Total: ${relays.length} unique relays`); + + // Build reasoning + const reasoning = buildReasoning(selectedPointers, authorPointers, pTagPointers); + + return { + relays, + reasoning, + isOptimized: true, + }; +} diff --git a/src/types/man.ts b/src/types/man.ts index f694aed..6d5a3dc 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -223,21 +223,21 @@ export const manPages: Record = { }, ], examples: [ - "req -k 1 -l 20 Get 20 recent notes (streams live by default)", + "req -k 1 -l 20 Get 20 recent notes (auto-selects optimal relays via NIP-65)", "req -k 1,3,7 -l 50 Get notes, contact lists, and reactions", - "req -k 0 -a npub1... Get profile for author", + "req -k 0 -a npub1... Get profile (queries author's outbox relays)", "req -k 1 -a user@domain.com Get notes from NIP-05 identifier", "req -k 1 -a dergigi.com Get notes from bare domain (resolves to _@dergigi.com)", - "req -k 1 -a npub1...,npub2... Get notes from multiple authors", - "req -a $me Get all events authored by you", - "req -k 1 -a $contacts --since 24h Get notes from your contacts in last 24h", - "req -p $me -k 1,7 Get replies and reactions to your posts", - "req -k 1 -a $me -a $contacts Get notes from you and your contacts", - "req -k 9735 -p $me --since 7d Get zaps you received in last 7 days", - "req -k 9735 -P $me --since 7d Get zaps you sent in last 7 days", + "req -k 1 -a npub1...,npub2... Get notes from multiple authors (balances across outbox relays)", + "req -a $me Get all your events (queries your outbox relays)", + "req -k 1 -a $contacts --since 24h Get notes from contacts (queries their outbox relays)", + "req -p $me -k 1,7 Get replies and reactions to you (queries your inbox relays)", + "req -k 1 -a $me -a $contacts Get notes from you and contacts", + "req -k 9735 -p $me --since 7d Get zaps you received (queries your inbox)", + "req -k 9735 -P $me --since 7d Get zaps you sent", "req -k 9735 -P $contacts Get zaps sent by your contacts", - "req -k 1 -p verbiricha@habla.news Get notes mentioning NIP-05 user", - "req -k 1 --since 1h relay.damus.io Get notes from last hour", + "req -k 1 -p verbiricha@habla.news Get notes mentioning user (queries their inbox)", + "req -k 1 --since 1h relay.damus.io Get notes from last hour (manual relay override)", "req -k 1 --close-on-eose Get recent notes and close after EOSE", "req -t nostr,bitcoin -l 50 Get 50 events tagged #nostr or #bitcoin", "req --tag a 30023:abc...:article Get events referencing addressable event (#a tag)", @@ -245,7 +245,7 @@ export const manPages: Record = { "req -k 30023 --tag d article1,article2 Get specific replaceable events by d-tag", "req --tag g geohash123 -l 20 Get 20 events with geolocation tag", "req --search bitcoin -k 1 Search notes for 'bitcoin'", - "req -k 1 relay1.com relay2.com Query multiple relays", + "req -k 1 relay1.com relay2.com Query specific relays (overrides auto-selection)", ], seeAlso: ["kind", "nip"], appId: "req", diff --git a/src/types/relay-selection.ts b/src/types/relay-selection.ts new file mode 100644 index 0000000..35ad329 --- /dev/null +++ b/src/types/relay-selection.ts @@ -0,0 +1,54 @@ +/** + * NIP-65 Relay Selection Types + * + * Types for intelligent relay selection based on the NIP-65 outbox model. + * See: https://github.com/nostr-protocol/nips/blob/master/65.md + */ + +/** + * Result of relay selection for a filter + */ +export interface RelaySelectionResult { + /** Selected relay URLs (normalized) */ + relays: string[]; + + /** Explanation of why each relay was selected */ + reasoning: RelaySelectionReasoning[]; + + /** True if using NIP-65 optimization, false if using fallback */ + isOptimized: boolean; +} + +/** + * Reasoning for why a relay was selected + */ +export interface RelaySelectionReasoning { + /** Relay URL (normalized) */ + relay: string; + + /** Pubkeys using this relay for writing (outbox) */ + writers: string[]; + + /** Pubkeys using this relay for reading (inbox) */ + readers: string[]; + + /** True if this is a fallback relay */ + isFallback: boolean; +} + +/** + * Options for relay selection + */ +export interface RelaySelectionOptions { + /** Maximum total relays to select (default: 42) */ + maxRelays?: number; + + /** Maximum relays per user for redundancy (default: 6) */ + maxRelaysPerUser?: number; + + /** Fallback relays when user has no kind:10002 */ + fallbackRelays?: string[]; + + /** Timeout in ms for fetching kind:10002 events (default: 1000) */ + timeout?: number; +}