mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 17:51:12 +02:00
feat: outbox relay selection
This commit is contained in:
690
claudedocs/outbox-future-improvements.md
Normal file
690
claudedocs/outbox-future-improvements.md
Normal 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*
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
125
src/hooks/useOutboxRelays.ts
Normal file
125
src/hooks/useOutboxRelays.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
24
src/hooks/useRelayListCacheSync.ts
Normal file
24
src/hooks/useRelayListCacheSync.ts
Normal 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]);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
413
src/services/loaders.test.ts
Normal file
413
src/services/loaders.test.ts
Normal 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/");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
337
src/services/relay-list-cache.ts
Normal file
337
src/services/relay-list-cache.ts
Normal 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;
|
||||
26
src/services/relay-liveness.ts
Normal file
26
src/services/relay-liveness.ts
Normal 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;
|
||||
394
src/services/relay-selection.test.ts
Normal file
394
src/services/relay-selection.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
507
src/services/relay-selection.ts
Normal file
507
src/services/relay-selection.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
54
src/types/relay-selection.ts
Normal file
54
src/types/relay-selection.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user