9 changed files with 414 additions and 952 deletions
@ -1,480 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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