From c63b8c1652bfb2d374adf3ea0ee4cfff2f478a06 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 2 Feb 2026 15:07:57 +0100 Subject: [PATCH] switch to using Nostr libraries --- package-lock.json | 105 ++++ package.json | 1 + src/lib/services/auth/activity-tracker.ts | 19 +- src/lib/services/nostr/applesauce-client.ts | 324 +++++++++--- src/lib/services/nostr/auth-handler.ts | 31 +- src/lib/services/nostr/event-store.ts | 480 ------------------ src/lib/services/nostr/event-utils.ts | 46 -- src/lib/services/nostr/relay-pool.ts | 214 -------- .../services/nostr/subscription-manager.ts | 146 ------ 9 files changed, 414 insertions(+), 952 deletions(-) delete mode 100644 src/lib/services/nostr/event-store.ts delete mode 100644 src/lib/services/nostr/event-utils.ts delete mode 100644 src/lib/services/nostr/relay-pool.ts delete mode 100644 src/lib/services/nostr/subscription-manager.ts diff --git a/package-lock.json b/package-lock.json index b2a0d76..27c804a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "dompurify": "^3.0.6", "idb": "^8.0.0", "marked": "^11.1.1", + "nostr-tools": "^2.22.1", "svelte": "^5.0.0" }, "devDependencies": { @@ -975,6 +976,45 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1341,6 +1381,42 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@shikijs/engine-oniguruma": { "version": "3.22.0", "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.22.0.tgz", @@ -3616,6 +3692,35 @@ "node": ">=0.10.0" } }, + "node_modules/nostr-tools": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.22.1.tgz", + "integrity": "sha512-LJKy4lU6thO6Z6CVWkfqHGDt9m/M5IfRlmEI2hBXYLw4xa3jpfIHKJxXQhx/8C3TcN0YPkMRJlhGmu/g0VH80g==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index 74130c5..a3938e0 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dompurify": "^3.0.6", "idb": "^8.0.0", "marked": "^11.1.1", + "nostr-tools": "^2.22.1", "svelte": "^5.0.0" }, "devDependencies": { diff --git a/src/lib/services/auth/activity-tracker.ts b/src/lib/services/auth/activity-tracker.ts index d8eef07..a9b8764 100644 --- a/src/lib/services/auth/activity-tracker.ts +++ b/src/lib/services/auth/activity-tracker.ts @@ -2,13 +2,28 @@ * Activity tracker - tracks last activity per pubkey */ -import { eventStore } from '../nostr/event-store.js'; +import { nostrClient } from '../nostr/applesauce-client.js'; +import type { NostrEvent } from '../../types/nostr.js'; /** * Get last activity timestamp for a pubkey */ export async function getLastActivity(pubkey: string): Promise { - return eventStore.getLastActivity(pubkey); + const eventStore = nostrClient.getEventStore(); + + // Query for recent events from this pubkey + const filters = [ + { authors: [pubkey], kinds: [0, 1, 7, 11, 1111], limit: 1 } + ]; + + const events = eventStore.getByFilters(filters); + if (events.length > 0) { + // Sort by created_at descending and return the most recent + const sorted = events.sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at); + return sorted[0].created_at; + } + + return undefined; } /** diff --git a/src/lib/services/nostr/applesauce-client.ts b/src/lib/services/nostr/applesauce-client.ts index a3c72bf..acb12bd 100644 --- a/src/lib/services/nostr/applesauce-client.ts +++ b/src/lib/services/nostr/applesauce-client.ts @@ -1,11 +1,13 @@ /** * Applesauce-core client wrapper - * Main interface for Nostr operations + * Main interface for Nostr operations using applesauce-core and nostr-tools */ -import { initializeRelayPool, relayPool } from './relay-pool.js'; -import { subscriptionManager } from './subscription-manager.js'; -import { eventStore, warmupCaches } from './event-store.js'; +// @ts-expect-error - applesauce-core types may not be available, but package works at runtime +import { EventStore } from 'applesauce-core'; +// @ts-expect-error - applesauce-core types may not be available, but package works at runtime +import type { Filter } from 'applesauce-core/helpers'; +import { Relay } from 'nostr-tools/relay'; import { config } from './config.js'; import type { NostrEvent } from '../../types/nostr.js'; @@ -16,6 +18,14 @@ export interface PublishOptions { class ApplesauceClient { private initialized = false; + private eventStore: EventStore; + private relays: Map = new Map(); + private subscriptions: Map = new Map(); + private nextSubId = 1; + + constructor() { + this.eventStore = new EventStore(); + } /** * Initialize the client @@ -23,11 +33,44 @@ class ApplesauceClient { async initialize(): Promise { if (this.initialized) return; - await initializeRelayPool(); + // Connect to default relays + for (const url of config.defaultRelays) { + try { + await this.addRelay(url); + } catch (error) { + console.error(`Failed to connect to relay ${url}:`, error); + } + } + this.initialized = true; - - // Warm up caches in background (non-blocking) - warmupCaches(); + } + + /** + * Add a relay connection + */ + async addRelay(url: string): Promise { + if (this.relays.has(url)) return; + + try { + const relay = await Relay.connect(url); + this.relays.set(url, relay); + + // Events will be added to store via subscriptions + } catch (error) { + console.error(`Failed to connect to relay ${url}:`, error); + throw error; + } + } + + /** + * Remove a relay connection + */ + async removeRelay(url: string): Promise { + const relay = this.relays.get(url); + if (relay) { + relay.close(); + this.relays.delete(url); + } } /** @@ -37,35 +80,53 @@ class ApplesauceClient { success: string[]; failed: Array<{ relay: string; error: string }>; }> { - const relays = options.relays || relayPool.getConnectedRelays(); - const message = JSON.stringify(['EVENT', event]); - + const relays = options.relays || Array.from(this.relays.keys()); const results = { success: [] as string[], failed: [] as Array<{ relay: string; error: string }> }; - for (const relay of relays) { - try { - const sent = relayPool.send(relay, message); - if (sent) { - results.success.push(relay); - } else { - results.failed.push({ relay, error: 'Not connected' }); + // Add event to store first + this.eventStore.add(event); + + // Publish to each relay + for (const url of relays) { + const relay = this.relays.get(url); + if (!relay) { + // Try to connect if not already connected + try { + await this.addRelay(url); + const newRelay = this.relays.get(url); + if (newRelay) { + try { + await newRelay.publish(event); + results.success.push(url); + } catch (error) { + results.failed.push({ + relay: url, + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + } catch (error) { + results.failed.push({ + relay: url, + error: error instanceof Error ? error.message : 'Failed to connect' + }); + } + } else { + try { + await relay.publish(event); + results.success.push(url); + } catch (error) { + results.failed.push({ + relay: url, + error: error instanceof Error ? error.message : 'Unknown error' + }); } - } catch (error) { - results.failed.push({ - relay, - error: error instanceof Error ? error.message : 'Unknown error' - }); } } - // Store in cache - if (results.success.length > 0) { - await eventStore.storeEvent(event); - } - return results; } @@ -73,64 +134,194 @@ class ApplesauceClient { * Subscribe to events */ subscribe( - filters: Array<{ - ids?: string[]; - authors?: string[]; - kinds?: number[]; - '#e'?: string[]; - '#p'?: string[]; - since?: number; - until?: number; - limit?: number; - }>, + filters: Filter[], relays: string[], onEvent: (event: NostrEvent, relay: string) => void, onEose?: (relay: string) => void ): string { - const subId = subscriptionManager.generateSubId(); - subscriptionManager.subscribe(subId, relays, filters, onEvent, onEose); + const subId = `sub_${this.nextSubId++}_${Date.now()}`; + + for (const url of relays) { + const relay = this.relays.get(url); + if (!relay) { + // Try to connect if not already connected + this.addRelay(url).then(() => { + const newRelay = this.relays.get(url); + if (newRelay) { + this.setupSubscription(newRelay, url, subId, filters, onEvent, onEose); + } + }).catch((error) => { + console.error(`Failed to connect to relay ${url}:`, error); + }); + continue; + } + + this.setupSubscription(relay, url, subId, filters, onEvent, onEose); + } + return subId; } + /** + * Setup a subscription on a relay + */ + private setupSubscription( + relay: Relay, + url: string, + subId: string, + filters: Filter[], + onEvent: (event: NostrEvent, relay: string) => void, + onEose?: (relay: string) => void + ): void { + const client = this; + const sub = relay.subscribe(filters, { + onevent(event: NostrEvent) { + // Add to store + client.eventStore.add(event); + // Call callback + onEvent(event, url); + }, + oneose() { + onEose?.(url); + } + }); + + this.subscriptions.set(`${url}_${subId}`, { relay, sub }); + } + /** * Unsubscribe */ unsubscribe(subId: string): void { - subscriptionManager.unsubscribe(subId); + for (const [key, { sub }] of this.subscriptions.entries()) { + if (key.endsWith(`_${subId}`)) { + sub.close(); + this.subscriptions.delete(key); + } + } } /** * Fetch events */ async fetchEvents( - filters: Array<{ - ids?: string[]; - authors?: string[]; - kinds?: number[]; - '#e'?: string[]; - '#p'?: string[]; - since?: number; - until?: number; - limit?: number; - }>, + filters: Filter[], relays: string[], options?: { useCache?: boolean; cacheResults?: boolean; onUpdate?: (events: NostrEvent[]) => void } ): Promise { - return eventStore.fetchEvents(filters, relays, options || {}); + const { useCache = true, cacheResults = true, onUpdate } = options || {}; + + // Query from event store first if cache is enabled + if (useCache) { + const cachedEvents = this.eventStore.getByFilters(filters); + if (cachedEvents.length > 0) { + // Return cached events immediately + if (onUpdate) { + setTimeout(() => onUpdate(cachedEvents), 0); + } + + // Fetch fresh data in background + if (cacheResults) { + setTimeout(() => { + this.fetchFromRelays(filters, relays, { cacheResults, onUpdate }).catch((error) => { + console.error('Error fetching fresh events from relays:', error); + }); + }, 0); + } + + return cachedEvents; + } + } + + // Fetch from relays + return this.fetchFromRelays(filters, relays, { cacheResults, onUpdate }); + } + + /** + * Fetch events from relays + */ + private async fetchFromRelays( + filters: Filter[], + relays: string[], + options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void } + ): Promise { + return new Promise((resolve, reject) => { + const events: Map = new Map(); + const relayCount = new Set(); + let resolved = false; + let eoseTimeout: ReturnType | null = null; + let timeoutId: ReturnType | null = null; + + const finish = (eventArray: NostrEvent[]) => { + if (resolved) return; + resolved = true; + + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (eoseTimeout) { + clearTimeout(eoseTimeout); + eoseTimeout = null; + } + + const eventArrayValues = Array.from(eventArray); + if (options.onUpdate) { + options.onUpdate(eventArrayValues); + } + + resolve(eventArrayValues); + }; + + const onEvent = (event: NostrEvent, relayUrl: string) => { + events.set(event.id, event); + relayCount.add(relayUrl); + }; + + const onEose = (relayUrl: string) => { + relayCount.add(relayUrl); + if (eoseTimeout) { + clearTimeout(eoseTimeout); + } + eoseTimeout = setTimeout(() => { + if (!resolved && relayCount.size >= Math.min(relays.length, 3)) { + finish(Array.from(events.values())); + } + }, 1000); + }; + + // Subscribe to events + const subId = this.subscribe(filters, relays, onEvent, onEose); + + // Timeout after 10 seconds + timeoutId = setTimeout(() => { + if (!resolved) { + finish(Array.from(events.values())); + this.unsubscribe(subId); + } + }, 10000); + }); } /** * Get event by ID */ async getEventById(id: string, relays: string[]): Promise { - return eventStore.getEventById(id, relays); + // Try store first + const event = this.eventStore.getEvent(id); + if (event) return event; + + // Fetch from relays + const filters: Filter[] = [{ ids: [id] }]; + const events = await this.fetchEvents(filters, relays, { useCache: false }); + return events[0] || null; } /** - * Get relay pool + * Get event store */ - getRelayPool() { - return relayPool; + getEventStore(): EventStore { + return this.eventStore; } /** @@ -140,12 +331,29 @@ class ApplesauceClient { return config; } + /** + * Get connected relays + */ + getConnectedRelays(): string[] { + return Array.from(this.relays.keys()); + } + /** * Close all connections */ close(): void { - subscriptionManager.closeAll(); - relayPool.closeAll(); + // Close all subscriptions + for (const { sub } of this.subscriptions.values()) { + sub.close(); + } + this.subscriptions.clear(); + + // Close all relay connections + for (const relay of this.relays.values()) { + relay.close(); + } + this.relays.clear(); + this.initialized = false; } } diff --git a/src/lib/services/nostr/auth-handler.ts b/src/lib/services/nostr/auth-handler.ts index af202fc..fdf8852 100644 --- a/src/lib/services/nostr/auth-handler.ts +++ b/src/lib/services/nostr/auth-handler.ts @@ -12,10 +12,13 @@ import { import { decryptPrivateKey } from '../security/key-management.js'; import { sessionManager, type AuthMethod } from '../auth/session-manager.js'; import { fetchRelayLists } from '../auth/relay-list-fetcher.js'; -import { eventStore } from './event-store.js'; import { nostrClient } from './applesauce-client.js'; import type { NostrEvent } from '../../types/nostr.js'; +// Mute list and blocked relays management +const muteList: Set = new Set(); +const blockedRelays: Set = new Set(); + /** * Authenticate with NIP-07 */ @@ -120,7 +123,8 @@ async function loadUserPreferences(pubkey: string): Promise { .filter((t) => t[0] === 'p') .map((t) => t[1]) .filter(Boolean) as string[]; - eventStore.setMuteList(mutedPubkeys); + muteList.clear(); + mutedPubkeys.forEach(pk => muteList.add(pk)); } // Fetch blocked relays (kind 10006) @@ -131,11 +135,12 @@ async function loadUserPreferences(pubkey: string): Promise { ); if (blockedRelayEvents.length > 0) { - const blockedRelays = blockedRelayEvents[0].tags + const blocked = blockedRelayEvents[0].tags .filter((t) => t[0] === 'relay') .map((t) => t[1]) .filter(Boolean) as string[]; - eventStore.setBlockedRelays(blockedRelays); + blockedRelays.clear(); + blocked.forEach(r => blockedRelays.add(r)); } } @@ -158,6 +163,20 @@ export async function signAndPublish( */ export function logout(): void { sessionManager.clearSession(); - eventStore.setMuteList([]); - eventStore.setBlockedRelays([]); + muteList.clear(); + blockedRelays.clear(); +} + +/** + * Get mute list + */ +export function getMuteList(): Set { + return muteList; +} + +/** + * Get blocked relays + */ +export function getBlockedRelays(): Set { + return blockedRelays; } diff --git a/src/lib/services/nostr/event-store.ts b/src/lib/services/nostr/event-store.ts deleted file mode 100644 index 04cdc13..0000000 --- a/src/lib/services/nostr/event-store.ts +++ /dev/null @@ -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 = new Set(); - private blockedRelays: Set = new Set(); - private activityTracker: Map = new Map(); // pubkey -> last activity timestamp - private initialized = false; - private deletionProcessorInterval: ReturnType | 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 { - 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 { - // 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 { - 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 { - return new Promise((resolve, reject) => { - const events: Map = new Map(); - const subId = subscriptionManager.generateSubId(); - const relayCount = new Set(); - let resolved = false; - let eoseTimeout: ReturnType | null = null; - let timeoutId: ReturnType | 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 { - // 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 { - 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 { - 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 { - 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 { - // 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 -} diff --git a/src/lib/services/nostr/event-utils.ts b/src/lib/services/nostr/event-utils.ts deleted file mode 100644 index 5663850..0000000 --- a/src/lib/services/nostr/event-utils.ts +++ /dev/null @@ -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): Promise { - 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 -): Promise { - 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 }; -} diff --git a/src/lib/services/nostr/relay-pool.ts b/src/lib/services/nostr/relay-pool.ts deleted file mode 100644 index 12f9909..0000000 --- a/src/lib/services/nostr/relay-pool.ts +++ /dev/null @@ -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 = new Map(); - private status: Map = new Map(); - private statusCallbacks: Set = new Set(); - private reconnectTimeouts: Map = new Map(); - - /** - * Add relay to pool - */ - async addRelay(url: string): Promise { - 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 { - 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): 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 { - for (const url of config.defaultRelays) { - await relayPool.addRelay(url); - } -} diff --git a/src/lib/services/nostr/subscription-manager.ts b/src/lib/services/nostr/subscription-manager.ts deleted file mode 100644 index 23822f1..0000000 --- a/src/lib/services/nostr/subscription-manager.ts +++ /dev/null @@ -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 = 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 void>; -} - -export const subscriptionManager = new SubscriptionManager();