9 changed files with 414 additions and 952 deletions
@ -1,480 +0,0 @@ |
|||||||
/** |
|
||||||
* Event store with IndexedDB caching and filtering |
|
||||||
*/ |
|
||||||
|
|
||||||
import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey, deleteEvents } from '../cache/event-cache.js'; |
|
||||||
import { subscriptionManager } from './subscription-manager.js'; |
|
||||||
import { relayPool } from './relay-pool.js'; |
|
||||||
import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; |
|
||||||
|
|
||||||
export interface EventStoreOptions { |
|
||||||
muteList?: string[]; // Pubkeys to mute (from kind 10000)
|
|
||||||
blockedRelays?: string[]; // Relays to block (from kind 10006)
|
|
||||||
} |
|
||||||
|
|
||||||
class EventStore { |
|
||||||
private muteList: Set<string> = new Set(); |
|
||||||
private blockedRelays: Set<string> = new Set(); |
|
||||||
private activityTracker: Map<string, number> = new Map(); // pubkey -> last activity timestamp
|
|
||||||
private initialized = false; |
|
||||||
private deletionProcessorInterval: ReturnType<typeof setInterval> | null = null; |
|
||||||
|
|
||||||
/** |
|
||||||
* Update mute list |
|
||||||
*/ |
|
||||||
setMuteList(pubkeys: string[]): void { |
|
||||||
this.muteList = new Set(pubkeys); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Update blocked relays |
|
||||||
*/ |
|
||||||
setBlockedRelays(relays: string[]): void { |
|
||||||
this.blockedRelays = new Set(relays); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Filter out muted events |
|
||||||
*/ |
|
||||||
private isMuted(event: NostrEvent): boolean { |
|
||||||
return this.muteList.has(event.pubkey); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Filter out blocked relays |
|
||||||
*/ |
|
||||||
private filterBlockedRelays(relays: string[]): string[] { |
|
||||||
return relays.filter((r) => !this.blockedRelays.has(r)); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Track activity for a pubkey |
|
||||||
*/ |
|
||||||
private trackActivity(pubkey: string, timestamp: number): void { |
|
||||||
const current = this.activityTracker.get(pubkey) || 0; |
|
||||||
if (timestamp > current) { |
|
||||||
this.activityTracker.set(pubkey, timestamp); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Initialize activity tracker from cached events |
|
||||||
*/ |
|
||||||
private async initializeActivityTracker(): Promise<void> { |
|
||||||
if (this.initialized) return; |
|
||||||
this.initialized = true; |
|
||||||
|
|
||||||
try { |
|
||||||
// Load recent events from cache to populate activity tracker
|
|
||||||
// We'll check the most recent events for each pubkey
|
|
||||||
// Include all event kinds that indicate user activity
|
|
||||||
const recentEvents = await getEventsByKind(1, 1000); // Get recent kind 1 events (notes)
|
|
||||||
const threadEvents = await getEventsByKind(11, 1000); // Get recent thread events
|
|
||||||
const commentEvents = await getEventsByKind(1111, 1000); // Get recent comment events
|
|
||||||
const reactionEvents = await getEventsByKind(7, 1000).catch(() => []); // Get recent reactions (kind 7)
|
|
||||||
const profileEvents = await getEventsByKind(0, 1000).catch(() => []); // Get recent profile updates (kind 0)
|
|
||||||
|
|
||||||
const allEvents = [...recentEvents, ...threadEvents, ...commentEvents, ...reactionEvents, ...profileEvents]; |
|
||||||
|
|
||||||
// Update activity tracker with the most recent event per pubkey
|
|
||||||
for (const event of allEvents) { |
|
||||||
this.trackActivity(event.pubkey, event.created_at); |
|
||||||
} |
|
||||||
} catch (error) { |
|
||||||
console.error('Error initializing activity tracker:', error); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get last activity timestamp for a pubkey |
|
||||||
*/ |
|
||||||
async getLastActivity(pubkey: string): Promise<number | undefined> { |
|
||||||
// Initialize from cache if not done yet
|
|
||||||
await this.initializeActivityTracker(); |
|
||||||
|
|
||||||
// Check in-memory tracker first
|
|
||||||
const inMemory = this.activityTracker.get(pubkey); |
|
||||||
if (inMemory) return inMemory; |
|
||||||
|
|
||||||
// If not in memory, check cache for this specific pubkey
|
|
||||||
try { |
|
||||||
const events = await getEventsByPubkey(pubkey, 1); // Get most recent event
|
|
||||||
if (events.length > 0) { |
|
||||||
const latestEvent = events[0]; |
|
||||||
this.trackActivity(pubkey, latestEvent.created_at); |
|
||||||
return latestEvent.created_at; |
|
||||||
} |
|
||||||
} catch (error) { |
|
||||||
console.error('Error fetching activity from cache:', error); |
|
||||||
} |
|
||||||
|
|
||||||
return undefined; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Check if event should be hidden (content filtering) |
|
||||||
*/ |
|
||||||
private shouldHideEvent(event: NostrEvent): boolean { |
|
||||||
// Check for content-warning or sensitive tags
|
|
||||||
const hasContentWarning = event.tags.some((t) => t[0] === 'content-warning' || t[0] === 'sensitive'); |
|
||||||
if (hasContentWarning) return true; |
|
||||||
|
|
||||||
// Check for #NSFW in content or tags
|
|
||||||
const content = event.content.toLowerCase(); |
|
||||||
const hasNSFW = content.includes('#nsfw') || event.tags.some((t) => t[1]?.toLowerCase() === 'nsfw'); |
|
||||||
if (hasNSFW) return true; |
|
||||||
|
|
||||||
return false; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Fetch events with filters |
|
||||||
* Returns cached data immediately, then fetches from relays in background |
|
||||||
*/ |
|
||||||
async fetchEvents( |
|
||||||
filters: NostrFilter[], |
|
||||||
relays: string[], |
|
||||||
options: { useCache?: boolean; cacheResults?: boolean; onUpdate?: (events: NostrEvent[]) => void } = {} |
|
||||||
): Promise<NostrEvent[]> { |
|
||||||
const { useCache = true, cacheResults = true, onUpdate } = options; |
|
||||||
|
|
||||||
// Filter out blocked relays
|
|
||||||
const filteredRelays = this.filterBlockedRelays(relays); |
|
||||||
|
|
||||||
// Try cache first if enabled - return immediately
|
|
||||||
if (useCache) { |
|
||||||
const cachedEvents: NostrEvent[] = []; |
|
||||||
for (const filter of filters) { |
|
||||||
if (filter.kinds && filter.kinds.length === 1) { |
|
||||||
const events = await getEventsByKind(filter.kinds[0], filter.limit || 50); |
|
||||||
cachedEvents.push(...events); |
|
||||||
} |
|
||||||
if (filter.authors && filter.authors.length === 1) { |
|
||||||
const events = await getEventsByPubkey(filter.authors[0], filter.limit || 50); |
|
||||||
cachedEvents.push(...events); |
|
||||||
} |
|
||||||
// Handle multiple kinds
|
|
||||||
if (filter.kinds && filter.kinds.length > 1) { |
|
||||||
for (const kind of filter.kinds) { |
|
||||||
const events = await getEventsByKind(kind, filter.limit || 50); |
|
||||||
cachedEvents.push(...events); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Return cached events immediately (non-blocking)
|
|
||||||
const filteredCached = this.filterEvents(cachedEvents); |
|
||||||
|
|
||||||
// Fetch fresh data from relays in background (non-blocking)
|
|
||||||
if (cacheResults) { |
|
||||||
// Use setTimeout to ensure this doesn't block the return
|
|
||||||
setTimeout(() => { |
|
||||||
this.fetchEventsFromRelays(filters, filteredRelays, { cacheResults, onUpdate }).catch((error) => { |
|
||||||
console.error('Error fetching fresh events from relays:', error); |
|
||||||
}); |
|
||||||
}, 0); |
|
||||||
} |
|
||||||
|
|
||||||
return filteredCached; |
|
||||||
} |
|
||||||
|
|
||||||
// No cache - fetch from relays
|
|
||||||
return this.fetchEventsFromRelays(filters, filteredRelays, { cacheResults, onUpdate }); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Fetch events from relays |
|
||||||
*/ |
|
||||||
private async fetchEventsFromRelays( |
|
||||||
filters: NostrFilter[], |
|
||||||
relays: string[], |
|
||||||
options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void } |
|
||||||
): Promise<NostrEvent[]> { |
|
||||||
return new Promise((resolve, reject) => { |
|
||||||
const events: Map<string, NostrEvent> = new Map(); |
|
||||||
const subId = subscriptionManager.generateSubId(); |
|
||||||
const relayCount = new Set<string>(); |
|
||||||
let resolved = false; |
|
||||||
let eoseTimeout: ReturnType<typeof setTimeout> | null = null; |
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null; |
|
||||||
|
|
||||||
const finish = (eventArray: NostrEvent[]) => { |
|
||||||
if (resolved) return; |
|
||||||
resolved = true; |
|
||||||
|
|
||||||
// Clean up timeouts
|
|
||||||
if (timeoutId) { |
|
||||||
clearTimeout(timeoutId); |
|
||||||
timeoutId = null; |
|
||||||
} |
|
||||||
if (eoseTimeout) { |
|
||||||
clearTimeout(eoseTimeout); |
|
||||||
eoseTimeout = null; |
|
||||||
} |
|
||||||
|
|
||||||
subscriptionManager.unsubscribe(subId); |
|
||||||
const filtered = this.filterEvents(eventArray); |
|
||||||
|
|
||||||
// Notify callback if provided (for reactive UI updates)
|
|
||||||
if (options.onUpdate) { |
|
||||||
options.onUpdate(filtered); |
|
||||||
} |
|
||||||
|
|
||||||
resolve(filtered); |
|
||||||
}; |
|
||||||
|
|
||||||
const onEvent = (event: NostrEvent, relay: string) => { |
|
||||||
// Skip muted events
|
|
||||||
if (this.isMuted(event)) return; |
|
||||||
|
|
||||||
// Skip hidden events
|
|
||||||
if (this.shouldHideEvent(event)) return; |
|
||||||
|
|
||||||
// Process kind 5 deletion events immediately when received
|
|
||||||
if (event.kind === 5) { |
|
||||||
this.processDeletionEvent(event).catch((error) => { |
|
||||||
console.error('Error processing deletion event:', error); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
// Track activity
|
|
||||||
this.trackActivity(event.pubkey, event.created_at); |
|
||||||
|
|
||||||
// Deduplicate by event ID
|
|
||||||
events.set(event.id, event); |
|
||||||
relayCount.add(relay); |
|
||||||
}; |
|
||||||
|
|
||||||
const onEose = (relay: string) => { |
|
||||||
relayCount.add(relay); |
|
||||||
// Wait a bit for all relays to respond
|
|
||||||
if (eoseTimeout) { |
|
||||||
clearTimeout(eoseTimeout); |
|
||||||
} |
|
||||||
eoseTimeout = setTimeout(() => { |
|
||||||
if (!resolved && relayCount.size >= Math.min(relays.length, 3)) { |
|
||||||
// Got responses from enough relays
|
|
||||||
const eventArray = Array.from(events.values()); |
|
||||||
if (options.cacheResults) { |
|
||||||
cacheEvents(eventArray).catch((error) => { |
|
||||||
console.error('Error caching events:', error); |
|
||||||
}); |
|
||||||
} |
|
||||||
finish(eventArray); |
|
||||||
} |
|
||||||
}, 1000); |
|
||||||
}; |
|
||||||
|
|
||||||
try { |
|
||||||
subscriptionManager.subscribe(subId, relays, filters, onEvent, onEose); |
|
||||||
} catch (error) { |
|
||||||
reject(error); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// Timeout after 10 seconds
|
|
||||||
timeoutId = setTimeout(() => { |
|
||||||
if (!resolved) { |
|
||||||
const eventArray = Array.from(events.values()); |
|
||||||
if (options.cacheResults) { |
|
||||||
cacheEvents(eventArray).catch((error) => { |
|
||||||
console.error('Error caching events:', error); |
|
||||||
}); |
|
||||||
} |
|
||||||
finish(eventArray); |
|
||||||
} |
|
||||||
}, 10000); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Filter events (remove muted, hidden, etc.) |
|
||||||
*/ |
|
||||||
private filterEvents(events: NostrEvent[]): NostrEvent[] { |
|
||||||
return events.filter((event) => { |
|
||||||
if (this.isMuted(event)) return false; |
|
||||||
if (this.shouldHideEvent(event)) return false; |
|
||||||
return true; |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get event by ID (from cache or fetch) |
|
||||||
*/ |
|
||||||
async getEventById(id: string, relays: string[]): Promise<NostrEvent | null> { |
|
||||||
// Try cache first
|
|
||||||
const cached = await getEvent(id); |
|
||||||
if (cached) return cached; |
|
||||||
|
|
||||||
// Fetch from relays
|
|
||||||
const filters: NostrFilter[] = [{ ids: [id] }]; |
|
||||||
const events = await this.fetchEvents(filters, relays, { useCache: false }); |
|
||||||
return events[0] || null; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Store event in cache |
|
||||||
*/ |
|
||||||
async storeEvent(event: NostrEvent): Promise<void> { |
|
||||||
if (this.isMuted(event) || this.shouldHideEvent(event)) return; |
|
||||||
|
|
||||||
// Process kind 5 deletion events immediately
|
|
||||||
if (event.kind === 5) { |
|
||||||
await this.processDeletionEvent(event); |
|
||||||
// Also cache the deletion event itself
|
|
||||||
this.trackActivity(event.pubkey, event.created_at); |
|
||||||
await cacheEvent(event); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
this.trackActivity(event.pubkey, event.created_at); |
|
||||||
await cacheEvent(event); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Process a kind 5 deletion event (NIP-09) |
|
||||||
* Deletes events referenced in the 'e' tags that belong to the same author |
|
||||||
*/ |
|
||||||
private async processDeletionEvent(deletionEvent: NostrEvent): Promise<void> { |
|
||||||
if (deletionEvent.kind !== 5) return; |
|
||||||
|
|
||||||
const authorPubkey = deletionEvent.pubkey; |
|
||||||
const eventIdsToDelete: string[] = []; |
|
||||||
|
|
||||||
// Extract event IDs from 'e' tags
|
|
||||||
for (const tag of deletionEvent.tags) { |
|
||||||
if (tag[0] === 'e' && tag[1]) { |
|
||||||
eventIdsToDelete.push(tag[1]); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (eventIdsToDelete.length === 0) return; |
|
||||||
|
|
||||||
// Verify that the events to delete belong to the same author
|
|
||||||
// This is a security measure - only delete events from the same pubkey
|
|
||||||
const eventsToVerify = await Promise.all( |
|
||||||
eventIdsToDelete.map(id => getEvent(id)) |
|
||||||
); |
|
||||||
|
|
||||||
const verifiedIds: string[] = []; |
|
||||||
for (let i = 0; i < eventIdsToDelete.length; i++) { |
|
||||||
const event = eventsToVerify[i]; |
|
||||||
if (event && event.pubkey === authorPubkey) { |
|
||||||
verifiedIds.push(eventIdsToDelete[i]); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (verifiedIds.length > 0) { |
|
||||||
await deleteEvents(verifiedIds); |
|
||||||
console.log(`Deleted ${verifiedIds.length} event(s) per NIP-09 deletion request from ${authorPubkey.slice(0, 16)}...`); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Process all kind 5 deletion events in the cache |
|
||||||
* This should be run periodically to clean up deleted events |
|
||||||
*/ |
|
||||||
async processAllDeletionEvents(): Promise<void> { |
|
||||||
try { |
|
||||||
// Get all kind 5 events from cache
|
|
||||||
const deletionEvents = await getEventsByKind(5, 1000); |
|
||||||
|
|
||||||
if (deletionEvents.length === 0) return; |
|
||||||
|
|
||||||
// Process each deletion event
|
|
||||||
for (const deletionEvent of deletionEvents) { |
|
||||||
await this.processDeletionEvent(deletionEvent); |
|
||||||
} |
|
||||||
|
|
||||||
console.log(`Processed ${deletionEvents.length} deletion event(s)`); |
|
||||||
} catch (error) { |
|
||||||
console.error('Error processing deletion events:', error); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Start the background deletion processor |
|
||||||
* Runs on startup and every 15 minutes |
|
||||||
*/ |
|
||||||
startDeletionProcessor(): void { |
|
||||||
// Process immediately on startup
|
|
||||||
this.processAllDeletionEvents().catch((error) => { |
|
||||||
console.error('Error in initial deletion processing:', error); |
|
||||||
}); |
|
||||||
|
|
||||||
// Then run every 15 minutes (900000 ms)
|
|
||||||
if (this.deletionProcessorInterval) { |
|
||||||
clearInterval(this.deletionProcessorInterval); |
|
||||||
} |
|
||||||
|
|
||||||
this.deletionProcessorInterval = setInterval(() => { |
|
||||||
this.processAllDeletionEvents().catch((error) => { |
|
||||||
console.error('Error in periodic deletion processing:', error); |
|
||||||
}); |
|
||||||
}, 15 * 60 * 1000); // 15 minutes
|
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Stop the background deletion processor |
|
||||||
*/ |
|
||||||
stopDeletionProcessor(): void { |
|
||||||
if (this.deletionProcessorInterval) { |
|
||||||
clearInterval(this.deletionProcessorInterval); |
|
||||||
this.deletionProcessorInterval = null; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export const eventStore = new EventStore(); |
|
||||||
|
|
||||||
/** |
|
||||||
* Warm up caches on app initialization |
|
||||||
* This runs in the background and doesn't block the UI |
|
||||||
*/ |
|
||||||
export async function warmupCaches(): Promise<void> { |
|
||||||
// Run in background - don't await, just start the process
|
|
||||||
setTimeout(async () => { |
|
||||||
try { |
|
||||||
// Initialize activity tracker (loads from cache)
|
|
||||||
await eventStore.getLastActivity('dummy').catch(() => {}); // This triggers initialization
|
|
||||||
|
|
||||||
// Pre-warm common event types
|
|
||||||
const config = (await import('./config.js')).config; |
|
||||||
const defaultRelays = config.defaultRelays; |
|
||||||
|
|
||||||
// Warm up threads (kind 11)
|
|
||||||
eventStore.fetchEvents( |
|
||||||
[{ kinds: [11], limit: 50 }], |
|
||||||
defaultRelays, |
|
||||||
{ useCache: true, cacheResults: true } |
|
||||||
).catch((error) => { |
|
||||||
console.error('Error warming thread cache:', error); |
|
||||||
}); |
|
||||||
|
|
||||||
// Warm up notes (kind 1)
|
|
||||||
eventStore.fetchEvents( |
|
||||||
[{ kinds: [1], limit: 100 }], |
|
||||||
defaultRelays, |
|
||||||
{ useCache: true, cacheResults: true } |
|
||||||
).catch((error) => { |
|
||||||
console.error('Error warming notes cache:', error); |
|
||||||
}); |
|
||||||
|
|
||||||
// Warm up comments (kind 1111)
|
|
||||||
eventStore.fetchEvents( |
|
||||||
[{ kinds: [1111], limit: 100 }], |
|
||||||
defaultRelays, |
|
||||||
{ useCache: true, cacheResults: true } |
|
||||||
).catch((error) => { |
|
||||||
console.error('Error warming comments cache:', error); |
|
||||||
}); |
|
||||||
|
|
||||||
// Start the deletion processor (runs on startup and every 15 minutes)
|
|
||||||
eventStore.startDeletionProcessor(); |
|
||||||
|
|
||||||
console.log('Cache warming started in background'); |
|
||||||
} catch (error) { |
|
||||||
console.error('Error during cache warmup:', error); |
|
||||||
} |
|
||||||
}, 100); // Small delay to not block initial render
|
|
||||||
} |
|
||||||
@ -1,46 +0,0 @@ |
|||||||
/** |
|
||||||
* Event utilities for creating and signing events |
|
||||||
*/ |
|
||||||
|
|
||||||
import type { NostrEvent } from '../../types/nostr.js'; |
|
||||||
|
|
||||||
/** |
|
||||||
* Create event ID (SHA256 of serialized event) |
|
||||||
*/ |
|
||||||
export async function createEventId(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<string> { |
|
||||||
const serialized = JSON.stringify([ |
|
||||||
0, |
|
||||||
event.pubkey, |
|
||||||
event.created_at, |
|
||||||
event.kind, |
|
||||||
event.tags, |
|
||||||
event.content |
|
||||||
]); |
|
||||||
const encoder = new TextEncoder(); |
|
||||||
const data = encoder.encode(serialized); |
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data); |
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer)); |
|
||||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Sign event (placeholder) |
|
||||||
*
|
|
||||||
* TEMPORARY: Generates a deterministic signature-like string. |
|
||||||
* This is NOT a valid secp256k1 signature but has the correct length. |
|
||||||
* Production code MUST compute actual secp256k1 signature. |
|
||||||
*/ |
|
||||||
export async function signEvent( |
|
||||||
event: Omit<NostrEvent, 'id' | 'sig'> |
|
||||||
): Promise<NostrEvent> { |
|
||||||
const id = await createEventId(event); |
|
||||||
// TEMPORARY: Generate deterministic signature-like string (128 chars)
|
|
||||||
const encoder = new TextEncoder(); |
|
||||||
const sigData = encoder.encode(event.pubkey + id); |
|
||||||
const sigHashBuffer = await crypto.subtle.digest('SHA-256', sigData); |
|
||||||
const sigHashArray = Array.from(new Uint8Array(sigHashBuffer)); |
|
||||||
const sigHash = sigHashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); |
|
||||||
// Double the hash to get 128 chars (64 * 2)
|
|
||||||
const sig = (sigHash + sigHash).slice(0, 128); |
|
||||||
return { ...event, id, sig }; |
|
||||||
} |
|
||||||
@ -1,214 +0,0 @@ |
|||||||
/** |
|
||||||
* Relay pool management |
|
||||||
* Manages WebSocket connections to Nostr relays |
|
||||||
*/ |
|
||||||
|
|
||||||
import { config } from './config.js'; |
|
||||||
|
|
||||||
export interface RelayStatus { |
|
||||||
url: string; |
|
||||||
connected: boolean; |
|
||||||
latency?: number; |
|
||||||
lastError?: string; |
|
||||||
lastConnected?: number; |
|
||||||
} |
|
||||||
|
|
||||||
export type RelayStatusCallback = (status: RelayStatus) => void; |
|
||||||
|
|
||||||
class RelayPool { |
|
||||||
private relays: Map<string, WebSocket | null> = new Map(); |
|
||||||
private status: Map<string, RelayStatus> = new Map(); |
|
||||||
private statusCallbacks: Set<RelayStatusCallback> = new Set(); |
|
||||||
private reconnectTimeouts: Map<string, NodeJS.Timeout> = new Map(); |
|
||||||
|
|
||||||
/** |
|
||||||
* Add relay to pool |
|
||||||
*/ |
|
||||||
async addRelay(url: string): Promise<void> { |
|
||||||
if (this.relays.has(url)) return; |
|
||||||
|
|
||||||
this.relays.set(url, null); |
|
||||||
this.updateStatus(url, { connected: false }); |
|
||||||
|
|
||||||
await this.connect(url); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Remove relay from pool |
|
||||||
*/ |
|
||||||
removeRelay(url: string): void { |
|
||||||
const ws = this.relays.get(url); |
|
||||||
if (ws) { |
|
||||||
ws.close(); |
|
||||||
} |
|
||||||
this.relays.delete(url); |
|
||||||
this.status.delete(url); |
|
||||||
|
|
||||||
const timeout = this.reconnectTimeouts.get(url); |
|
||||||
if (timeout) { |
|
||||||
clearTimeout(timeout); |
|
||||||
this.reconnectTimeouts.delete(url); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Connect to a relay |
|
||||||
*/ |
|
||||||
private async connect(url: string): Promise<void> { |
|
||||||
try { |
|
||||||
const ws = new WebSocket(url); |
|
||||||
const startTime = Date.now(); |
|
||||||
|
|
||||||
ws.onopen = () => { |
|
||||||
const latency = Date.now() - startTime; |
|
||||||
this.relays.set(url, ws); |
|
||||||
this.updateStatus(url, { |
|
||||||
connected: true, |
|
||||||
latency, |
|
||||||
lastConnected: Date.now() |
|
||||||
}); |
|
||||||
}; |
|
||||||
|
|
||||||
ws.onerror = (error: Event) => { |
|
||||||
this.updateStatus(url, { |
|
||||||
connected: false, |
|
||||||
lastError: error instanceof Error ? error.message : 'Connection error' |
|
||||||
}); |
|
||||||
this.scheduleReconnect(url); |
|
||||||
}; |
|
||||||
|
|
||||||
ws.onclose = () => { |
|
||||||
this.relays.set(url, null); |
|
||||||
this.updateStatus(url, { connected: false }); |
|
||||||
this.scheduleReconnect(url); |
|
||||||
}; |
|
||||||
|
|
||||||
// Store WebSocket for message sending
|
|
||||||
this.relays.set(url, ws); |
|
||||||
} catch (error) { |
|
||||||
this.updateStatus(url, { |
|
||||||
connected: false, |
|
||||||
lastError: error instanceof Error ? error.message : 'Unknown error' |
|
||||||
}); |
|
||||||
this.scheduleReconnect(url); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Schedule reconnection attempt |
|
||||||
*/ |
|
||||||
private scheduleReconnect(url: string): void { |
|
||||||
const existing = this.reconnectTimeouts.get(url); |
|
||||||
if (existing) clearTimeout(existing); |
|
||||||
|
|
||||||
const timeout = setTimeout(() => { |
|
||||||
this.reconnectTimeouts.delete(url); |
|
||||||
this.connect(url); |
|
||||||
}, 5000); // 5 second delay
|
|
||||||
|
|
||||||
this.reconnectTimeouts.set(url, timeout); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Update relay status and notify callbacks |
|
||||||
*/ |
|
||||||
private updateStatus(url: string, updates: Partial<RelayStatus>): void { |
|
||||||
const current = this.status.get(url) || { url, connected: false }; |
|
||||||
const updated = { ...current, ...updates }; |
|
||||||
this.status.set(url, updated); |
|
||||||
|
|
||||||
// Notify callbacks
|
|
||||||
this.statusCallbacks.forEach((cb) => cb(updated)); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get WebSocket for a relay |
|
||||||
*/ |
|
||||||
getRelay(url: string): WebSocket | null { |
|
||||||
return this.relays.get(url) || null; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get all connected relays |
|
||||||
*/ |
|
||||||
getConnectedRelays(): string[] { |
|
||||||
return Array.from(this.relays.entries()) |
|
||||||
.filter(([, ws]) => ws && ws.readyState === WebSocket.OPEN) |
|
||||||
.map(([url]) => url); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get relay status |
|
||||||
*/ |
|
||||||
getStatus(url: string): RelayStatus | undefined { |
|
||||||
return this.status.get(url); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get all relay statuses |
|
||||||
*/ |
|
||||||
getAllStatuses(): RelayStatus[] { |
|
||||||
return Array.from(this.status.values()); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Subscribe to status updates |
|
||||||
*/ |
|
||||||
onStatusUpdate(callback: RelayStatusCallback): () => void { |
|
||||||
this.statusCallbacks.add(callback); |
|
||||||
return () => this.statusCallbacks.delete(callback); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Send message to relay |
|
||||||
*/ |
|
||||||
send(url: string, message: string): boolean { |
|
||||||
const ws = this.relays.get(url); |
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) { |
|
||||||
ws.send(message); |
|
||||||
return true; |
|
||||||
} |
|
||||||
return false; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Send message to all connected relays |
|
||||||
*/ |
|
||||||
broadcast(message: string): string[] { |
|
||||||
const sent: string[] = []; |
|
||||||
for (const [url, ws] of this.relays.entries()) { |
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) { |
|
||||||
ws.send(message); |
|
||||||
sent.push(url); |
|
||||||
} |
|
||||||
} |
|
||||||
return sent; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Close all connections |
|
||||||
*/ |
|
||||||
closeAll(): void { |
|
||||||
for (const [url, ws] of this.relays.entries()) { |
|
||||||
if (ws) { |
|
||||||
ws.close(); |
|
||||||
} |
|
||||||
const timeout = this.reconnectTimeouts.get(url); |
|
||||||
if (timeout) { |
|
||||||
clearTimeout(timeout); |
|
||||||
this.reconnectTimeouts.delete(url); |
|
||||||
} |
|
||||||
} |
|
||||||
this.relays.clear(); |
|
||||||
this.status.clear(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export const relayPool = new RelayPool(); |
|
||||||
|
|
||||||
// Initialize with default relays
|
|
||||||
export async function initializeRelayPool(): Promise<void> { |
|
||||||
for (const url of config.defaultRelays) { |
|
||||||
await relayPool.addRelay(url); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,146 +0,0 @@ |
|||||||
/** |
|
||||||
* Subscription manager for Nostr subscriptions |
|
||||||
*/ |
|
||||||
|
|
||||||
import { relayPool } from './relay-pool.js'; |
|
||||||
import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; |
|
||||||
|
|
||||||
export type EventCallback = (event: NostrEvent, relay: string) => void; |
|
||||||
export type EoseCallback = (relay: string) => void; |
|
||||||
|
|
||||||
class SubscriptionManager { |
|
||||||
private subscriptions: Map<string, Subscription> = new Map(); |
|
||||||
private nextSubId = 1; |
|
||||||
|
|
||||||
/** |
|
||||||
* Create a new subscription |
|
||||||
*/ |
|
||||||
subscribe( |
|
||||||
subId: string, |
|
||||||
relays: string[], |
|
||||||
filters: NostrFilter[], |
|
||||||
onEvent: EventCallback, |
|
||||||
onEose?: EoseCallback |
|
||||||
): void { |
|
||||||
// Close existing subscription if any
|
|
||||||
this.unsubscribe(subId); |
|
||||||
|
|
||||||
const subscription: Subscription = { |
|
||||||
id: subId, |
|
||||||
relays, |
|
||||||
filters, |
|
||||||
onEvent, |
|
||||||
onEose, |
|
||||||
messageHandlers: new Map() |
|
||||||
}; |
|
||||||
|
|
||||||
// Set up message handlers for each relay
|
|
||||||
for (const relayUrl of relays) { |
|
||||||
const ws = relayPool.getRelay(relayUrl); |
|
||||||
if (!ws) continue; |
|
||||||
|
|
||||||
const handler = (event: MessageEvent) => { |
|
||||||
try { |
|
||||||
const data = JSON.parse(event.data); |
|
||||||
if (Array.isArray(data)) { |
|
||||||
const [type, ...rest] = data; |
|
||||||
|
|
||||||
if (type === 'EVENT' && rest[0] === subId) { |
|
||||||
const event = rest[1] as NostrEvent; |
|
||||||
if (this.matchesFilters(event, filters)) { |
|
||||||
onEvent(event, relayUrl); |
|
||||||
} |
|
||||||
} else if (type === 'EOSE' && rest[0] === subId) { |
|
||||||
onEose?.(relayUrl); |
|
||||||
} |
|
||||||
} |
|
||||||
} catch (error) { |
|
||||||
console.error('Error parsing relay message:', error); |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
ws.addEventListener('message', handler); |
|
||||||
subscription.messageHandlers.set(relayUrl, handler); |
|
||||||
|
|
||||||
// Send subscription request
|
|
||||||
const message = JSON.stringify(['REQ', subId, ...filters]); |
|
||||||
relayPool.send(relayUrl, message); |
|
||||||
} |
|
||||||
|
|
||||||
this.subscriptions.set(subId, subscription); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Check if event matches filters |
|
||||||
*/ |
|
||||||
private matchesFilters(event: NostrEvent, filters: NostrFilter[]): boolean { |
|
||||||
return filters.some((filter) => { |
|
||||||
if (filter.ids && !filter.ids.includes(event.id)) return false; |
|
||||||
if (filter.authors && !filter.authors.includes(event.pubkey)) return false; |
|
||||||
if (filter.kinds && !filter.kinds.includes(event.kind)) return false; |
|
||||||
if (filter.since && event.created_at < filter.since) return false; |
|
||||||
if (filter.until && event.created_at > filter.until) return false; |
|
||||||
|
|
||||||
// Tag filters
|
|
||||||
if (filter['#e']) { |
|
||||||
const hasE = event.tags.some((t) => t[0] === 'e' && filter['#e']!.includes(t[1])); |
|
||||||
if (!hasE) return false; |
|
||||||
} |
|
||||||
if (filter['#p']) { |
|
||||||
const hasP = event.tags.some((t) => t[0] === 'p' && filter['#p']!.includes(t[1])); |
|
||||||
if (!hasP) return false; |
|
||||||
} |
|
||||||
|
|
||||||
return true; |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Unsubscribe from a subscription |
|
||||||
*/ |
|
||||||
unsubscribe(subId: string): void { |
|
||||||
const subscription = this.subscriptions.get(subId); |
|
||||||
if (!subscription) return; |
|
||||||
|
|
||||||
// Remove message handlers
|
|
||||||
for (const [relayUrl, handler] of subscription.messageHandlers.entries()) { |
|
||||||
const ws = relayPool.getRelay(relayUrl); |
|
||||||
if (ws) { |
|
||||||
ws.removeEventListener('message', handler); |
|
||||||
} |
|
||||||
|
|
||||||
// Send close message
|
|
||||||
const message = JSON.stringify(['CLOSE', subId]); |
|
||||||
relayPool.send(relayUrl, message); |
|
||||||
} |
|
||||||
|
|
||||||
this.subscriptions.delete(subId); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Generate a unique subscription ID |
|
||||||
*/ |
|
||||||
generateSubId(): string { |
|
||||||
return `sub_${this.nextSubId++}_${Date.now()}`; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Close all subscriptions |
|
||||||
*/ |
|
||||||
closeAll(): void { |
|
||||||
for (const subId of this.subscriptions.keys()) { |
|
||||||
this.unsubscribe(subId); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
interface Subscription { |
|
||||||
id: string; |
|
||||||
relays: string[]; |
|
||||||
filters: NostrFilter[]; |
|
||||||
onEvent: EventCallback; |
|
||||||
onEose?: EoseCallback; |
|
||||||
messageHandlers: Map<string, (event: MessageEvent) => void>; |
|
||||||
} |
|
||||||
|
|
||||||
export const subscriptionManager = new SubscriptionManager(); |
|
||||||
Loading…
Reference in new issue