feat: outbox relay selection

This commit is contained in:
Alejandro Gómez
2025-12-16 12:55:07 +01:00
parent 6f05f414d3
commit a7059c8e8f
20 changed files with 2991 additions and 91 deletions

View File

@@ -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<string, Promise<NostrEvent | null>>();
async fetchRelayList(pubkey: string): Promise<NostrEvent | null> {
// 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<string[] | null> {
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) && (
<div className="flex items-center gap-2 text-yellow-600 text-sm mt-2">
<AlertTriangle className="size-4" />
<span>
Using fallback relays for {reasoning.filter(r => r.isFallback).length} users
(relay lists unavailable)
</span>
</div>
)}
```
**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<void> {
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<string, RelayHealthMetrics>();
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<RelaySelectionResult> {
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<typeof p> => 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<RelaySelectionResult>({
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<CacheStats | null>(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 (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Relay List Cache</h3>
{stats && (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-muted-foreground">Cached Users</div>
<div className="text-2xl font-bold">{stats.count}</div>
</div>
<div>
<div className="text-muted-foreground">Memory Cache</div>
<div className="text-2xl font-bold">
{stats.memoryCacheSize} / {stats.memoryCacheLimit}
</div>
</div>
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleRefreshAll}
disabled={refreshing}
>
{refreshing ? "Refreshing..." : "Refresh All"}
</Button>
<Button
onClick={handleRefreshStale}
variant="outline"
>
Refresh Stale Only
</Button>
</div>
<p className="text-sm text-muted-foreground">
Cache entries expire after 24 hours. Refresh to get latest relay lists.
</p>
</div>
);
}
```
**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 (
<div className="border-t pt-4 mt-4">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm font-semibold hover:underline"
>
<ChevronRight className={`size-4 transition-transform ${expanded ? 'rotate-90' : ''}`} />
Relay Selection Diagnostics
</button>
{expanded && (
<div className="mt-4 space-y-4 text-sm">
{/* Selection Status */}
<div>
<div className="font-semibold">Selection Status</div>
<div className="text-muted-foreground">
Phase: {phase} Optimized: {isOptimized ? 'Yes' : 'No (using fallbacks)'}
</div>
</div>
{/* Cache Performance */}
<div>
<div className="font-semibold">Cache Performance</div>
<div className="grid grid-cols-3 gap-2 mt-2">
<div>
<div className="text-muted-foreground text-xs">Memory Hits</div>
<div className="font-mono">{(metrics.memoryCacheHitRate * 100).toFixed(1)}%</div>
</div>
<div>
<div className="text-muted-foreground text-xs">Dexie Hits</div>
<div className="font-mono">{(metrics.dexieCacheHitRate * 100).toFixed(1)}%</div>
</div>
<div>
<div className="text-muted-foreground text-xs">Network Fetches</div>
<div className="font-mono">{metrics.networkFetches}</div>
</div>
</div>
</div>
{/* Selected Relays */}
<div>
<div className="font-semibold">Selected Relays</div>
<div className="mt-2 space-y-1">
{reasoning.map((r, i) => (
<div key={i} className="flex items-center gap-2 font-mono text-xs">
<span className={r.isFallback ? 'text-yellow-500' : 'text-green-500'}>
{r.isFallback ? '⚠' : '✓'}
</span>
<span className="truncate">{r.relay}</span>
<span className="text-muted-foreground">
({r.writers.length}w {r.readers.length}r)
</span>
</div>
))}
</div>
</div>
{/* Coverage Analysis */}
<div>
<div className="font-semibold">Coverage Analysis</div>
<div className="text-muted-foreground">
{reasoning.filter(r => !r.isFallback).length} optimized relays,
{' '}{reasoning.filter(r => r.isFallback).length} fallback relays
</div>
</div>
</div>
)}
</div>
);
}
```
**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*

View File

@@ -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);

View File

@@ -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) => {

View File

@@ -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],

View File

@@ -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({
<div className="flex items-center gap-2">
<Radio
className={`size-3 ${
loading && !eoseReceived
relaySelectionPhase !== 'ready'
? "text-yellow-500 animate-pulse"
: loading && eoseReceived && stream
? "text-green-500 animate-pulse"
: !loading && eoseReceived
? "text-muted-foreground"
: "text-yellow-500 animate-pulse"
: loading && !eoseReceived
? "text-yellow-500 animate-pulse"
: eoseReceived
? "text-muted-foreground"
: "text-yellow-500 animate-pulse"
}`}
/>
<span
className={`${
loading && !eoseReceived
relaySelectionPhase !== 'ready'
? "text-yellow-500"
: loading && eoseReceived && stream
? "text-green-500"
: !loading && eoseReceived
? "text-muted-foreground"
: "text-yellow-500"
: loading && !eoseReceived
? "text-yellow-500"
: eoseReceived
? "text-muted-foreground"
: "text-yellow-500"
} font-semibold`}
>
{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"}
</span>
</div>
@@ -868,54 +910,118 @@ export default function ReqViewer({
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
<Wifi className="size-3" />
<span>
{connectedCount}/{defaultRelays.length}
{connectedCount}/{finalRelays.length}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
{relayStatesForReq.map(({ url, state }) => {
const connIcon = getConnectionIcon(state);
const authIcon = getAuthIcon(state);
<DropdownMenuContent align="end" className="w-80 max-h-96 overflow-y-auto">
{/* Connection Status */}
<div className="py-1 border-b border-border">
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground">
Connection Status
</div>
{relayStatesForReq.map(({ url, state }) => {
const connIcon = getConnectionIcon(state);
const authIcon = getAuthIcon(state);
return (
<DropdownMenuItem
key={url}
className="flex items-center justify-between gap-2"
>
<RelayLink
url={url}
showInboxOutbox={false}
className="flex-1 min-w-0 hover:bg-transparent"
iconClassname="size-3"
urlClassname="text-xs"
/>
<div
className="flex items-center gap-1.5 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
return (
<DropdownMenuItem
key={url}
className="flex items-center justify-between gap-2"
>
{authIcon && (
<RelayLink
url={url}
showInboxOutbox={false}
className="flex-1 min-w-0 hover:bg-transparent"
iconClassname="size-3"
urlClassname="text-xs"
/>
<div
className="flex items-center gap-1.5 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
{authIcon && (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{authIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{authIcon.label}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{authIcon.icon}</div>
<div className="cursor-help">{connIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{authIcon.label}</p>
<p>{connIcon.label}</p>
</TooltipContent>
</Tooltip>
)}
</div>
</DropdownMenuItem>
);
})}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{connIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{connIcon.label}</p>
</TooltipContent>
</Tooltip>
</div>
</DropdownMenuItem>
);
})}
{/* Relay Selection */}
{!relays && reasoning && reasoning.length > 0 && (
<div className="py-2">
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground">
Relay Selection
{isOptimized && (
<span className="ml-1.5 font-normal">
(
<button
className="text-accent underline decoration-dotted cursor-crosshair"
onClick={(e) => {
e.stopPropagation();
addWindow("nip", { number: "65" }, "NIP-65 - Relay List Metadata");
}}
>
NIP-65
</button>
)
</span>
)}
</div>
{/* Flat list of relays with icons and counts */}
<div className="px-3 py-1 space-y-1">
{reasoning.map((r, i) => (
<div
key={i}
className="flex items-center gap-2 text-xs py-0.5"
>
<RelayLink
url={r.relay}
className="flex-1 truncate font-mono text-foreground/80"
/>
<div className="flex items-center gap-2 flex-shrink-0 text-muted-foreground">
{r.readers.length > 0 && (
<div className="flex items-center gap-0.5">
<Mail className="w-3 h-3" />
<span>{r.readers.length}</span>
</div>
)}
{r.writers.length > 0 && (
<div className="flex items-center gap-0.5">
<Send className="w-3 h-3" />
<span>{r.writers.length}</span>
</div>
)}
{r.isFallback && (
<span className="text-[10px] text-muted-foreground/60">
fallback
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -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",
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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<RelaySelectionResult>({
relays: options?.fallbackRelays || [],
reasoning: [],
isOptimized: false,
});
const [loading, setLoading] = useState(true);
const [phase, setPhase] = useState<RelaySelectionPhase>('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,
};
}

View File

@@ -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]);
}

View File

@@ -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<Profile>;
nip05!: Table<Nip05>;
nips!: Table<Nip>;
relayInfo!: Table<RelayInfo>;
relayAuthPreferences!: Table<RelayAuthPreference>;
relayLists!: Table<CachedRelayList>;
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",
});
}
}

View File

@@ -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> = {}): 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/");
});
});
});

View File

@@ -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<string> | 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<NostrEvent> {
// Extract context information
let authorHint: string | undefined;
let seenRelays: Set<string> | 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,

View File

@@ -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<string, CachedRelayList>();
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<CachedRelayList | undefined> {
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<void> {
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<string[] | null> {
// 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<string[] | null> {
// 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<boolean> {
const cached = await this.get(pubkey);
return cached !== undefined;
}
/**
* Invalidate (delete) cache entry for a pubkey
*/
async invalidate(pubkey: string): Promise<void> {
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<void> {
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;

View File

@@ -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;

View File

@@ -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");
});
});
});

View File

@@ -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<void> {
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<string[]> {
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<string[]> {
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<string, { writers: Set<string>; readers: Set<string> }>();
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<RelaySelectionResult> {
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,
};
}

View File

@@ -223,21 +223,21 @@ export const manPages: Record<string, ManPageEntry> = {
},
],
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<string, ManPageEntry> = {
"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",

View File

@@ -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;
}