Browse Source

switch to using Nostr libraries

master
Silberengel 1 month ago
parent
commit
c63b8c1652
  1. 105
      package-lock.json
  2. 1
      package.json
  3. 19
      src/lib/services/auth/activity-tracker.ts
  4. 310
      src/lib/services/nostr/applesauce-client.ts
  5. 31
      src/lib/services/nostr/auth-handler.ts
  6. 480
      src/lib/services/nostr/event-store.ts
  7. 46
      src/lib/services/nostr/event-utils.ts
  8. 214
      src/lib/services/nostr/relay-pool.ts
  9. 146
      src/lib/services/nostr/subscription-manager.ts

105
package-lock.json generated

@ -15,6 +15,7 @@
"dompurify": "^3.0.6", "dompurify": "^3.0.6",
"idb": "^8.0.0", "idb": "^8.0.0",
"marked": "^11.1.1", "marked": "^11.1.1",
"nostr-tools": "^2.22.1",
"svelte": "^5.0.0" "svelte": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
@ -975,6 +976,45 @@
"node": ">=6 <7 || >=8" "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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -1341,6 +1381,42 @@
"win32" "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": { "node_modules/@shikijs/engine-oniguruma": {
"version": "3.22.0", "version": "3.22.0",
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.22.0.tgz", "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.22.0.tgz",
@ -3616,6 +3692,35 @@
"node": ">=0.10.0" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

1
package.json

@ -29,6 +29,7 @@
"dompurify": "^3.0.6", "dompurify": "^3.0.6",
"idb": "^8.0.0", "idb": "^8.0.0",
"marked": "^11.1.1", "marked": "^11.1.1",
"nostr-tools": "^2.22.1",
"svelte": "^5.0.0" "svelte": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {

19
src/lib/services/auth/activity-tracker.ts

@ -2,13 +2,28 @@
* Activity tracker - tracks last activity per pubkey * 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 * Get last activity timestamp for a pubkey
*/ */
export async function getLastActivity(pubkey: string): Promise<number | undefined> { export async function getLastActivity(pubkey: string): Promise<number | undefined> {
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;
} }
/** /**

310
src/lib/services/nostr/applesauce-client.ts

@ -1,11 +1,13 @@
/** /**
* Applesauce-core client wrapper * 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'; // @ts-expect-error - applesauce-core types may not be available, but package works at runtime
import { subscriptionManager } from './subscription-manager.js'; import { EventStore } from 'applesauce-core';
import { eventStore, warmupCaches } from './event-store.js'; // @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 { config } from './config.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
@ -16,6 +18,14 @@ export interface PublishOptions {
class ApplesauceClient { class ApplesauceClient {
private initialized = false; private initialized = false;
private eventStore: EventStore;
private relays: Map<string, Relay> = new Map();
private subscriptions: Map<string, { relay: Relay; sub: any }> = new Map();
private nextSubId = 1;
constructor() {
this.eventStore = new EventStore();
}
/** /**
* Initialize the client * Initialize the client
@ -23,11 +33,44 @@ class ApplesauceClient {
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (this.initialized) return; 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; this.initialized = true;
}
/**
* Add a relay connection
*/
async addRelay(url: string): Promise<void> {
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;
}
}
// Warm up caches in background (non-blocking) /**
warmupCaches(); * Remove a relay connection
*/
async removeRelay(url: string): Promise<void> {
const relay = this.relays.get(url);
if (relay) {
relay.close();
this.relays.delete(url);
}
} }
/** /**
@ -37,33 +80,51 @@ class ApplesauceClient {
success: string[]; success: string[];
failed: Array<{ relay: string; error: string }>; failed: Array<{ relay: string; error: string }>;
}> { }> {
const relays = options.relays || relayPool.getConnectedRelays(); const relays = options.relays || Array.from(this.relays.keys());
const message = JSON.stringify(['EVENT', event]);
const results = { const results = {
success: [] as string[], success: [] as string[],
failed: [] as Array<{ relay: string; error: string }> failed: [] as Array<{ relay: string; error: string }>
}; };
for (const relay of relays) { // 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 { try {
const sent = relayPool.send(relay, message); await this.addRelay(url);
if (sent) { const newRelay = this.relays.get(url);
results.success.push(relay); if (newRelay) {
} else { try {
results.failed.push({ relay, error: 'Not connected' }); 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) { } catch (error) {
results.failed.push({ results.failed.push({
relay, relay: url,
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : 'Unknown error'
}); });
} }
} }
// Store in cache
if (results.success.length > 0) {
await eventStore.storeEvent(event);
} }
return results; return results;
@ -73,64 +134,194 @@ class ApplesauceClient {
* Subscribe to events * Subscribe to events
*/ */
subscribe( subscribe(
filters: Array<{ filters: Filter[],
ids?: string[];
authors?: string[];
kinds?: number[];
'#e'?: string[];
'#p'?: string[];
since?: number;
until?: number;
limit?: number;
}>,
relays: string[], relays: string[],
onEvent: (event: NostrEvent, relay: string) => void, onEvent: (event: NostrEvent, relay: string) => void,
onEose?: (relay: string) => void onEose?: (relay: string) => void
): string { ): string {
const subId = subscriptionManager.generateSubId(); const subId = `sub_${this.nextSubId++}_${Date.now()}`;
subscriptionManager.subscribe(subId, relays, filters, onEvent, onEose);
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; 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
*/ */
unsubscribe(subId: string): void { 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 * Fetch events
*/ */
async fetchEvents( async fetchEvents(
filters: Array<{ filters: Filter[],
ids?: string[];
authors?: string[];
kinds?: number[];
'#e'?: string[];
'#p'?: string[];
since?: number;
until?: number;
limit?: number;
}>,
relays: string[], relays: string[],
options?: { useCache?: boolean; cacheResults?: boolean; onUpdate?: (events: NostrEvent[]) => void } options?: { useCache?: boolean; cacheResults?: boolean; onUpdate?: (events: NostrEvent[]) => void }
): Promise<NostrEvent[]> { ): Promise<NostrEvent[]> {
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<NostrEvent[]> {
return new Promise((resolve, reject) => {
const events: Map<string, NostrEvent> = new Map();
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;
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 * Get event by ID
*/ */
async getEventById(id: string, relays: string[]): Promise<NostrEvent | null> { async getEventById(id: string, relays: string[]): Promise<NostrEvent | null> {
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() { getEventStore(): EventStore {
return relayPool; return this.eventStore;
} }
/** /**
@ -140,12 +331,29 @@ class ApplesauceClient {
return config; return config;
} }
/**
* Get connected relays
*/
getConnectedRelays(): string[] {
return Array.from(this.relays.keys());
}
/** /**
* Close all connections * Close all connections
*/ */
close(): void { close(): void {
subscriptionManager.closeAll(); // Close all subscriptions
relayPool.closeAll(); 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; this.initialized = false;
} }
} }

31
src/lib/services/nostr/auth-handler.ts

@ -12,10 +12,13 @@ import {
import { decryptPrivateKey } from '../security/key-management.js'; import { decryptPrivateKey } from '../security/key-management.js';
import { sessionManager, type AuthMethod } from '../auth/session-manager.js'; import { sessionManager, type AuthMethod } from '../auth/session-manager.js';
import { fetchRelayLists } from '../auth/relay-list-fetcher.js'; import { fetchRelayLists } from '../auth/relay-list-fetcher.js';
import { eventStore } from './event-store.js';
import { nostrClient } from './applesauce-client.js'; import { nostrClient } from './applesauce-client.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
// Mute list and blocked relays management
const muteList: Set<string> = new Set();
const blockedRelays: Set<string> = new Set();
/** /**
* Authenticate with NIP-07 * Authenticate with NIP-07
*/ */
@ -120,7 +123,8 @@ async function loadUserPreferences(pubkey: string): Promise<void> {
.filter((t) => t[0] === 'p') .filter((t) => t[0] === 'p')
.map((t) => t[1]) .map((t) => t[1])
.filter(Boolean) as string[]; .filter(Boolean) as string[];
eventStore.setMuteList(mutedPubkeys); muteList.clear();
mutedPubkeys.forEach(pk => muteList.add(pk));
} }
// Fetch blocked relays (kind 10006) // Fetch blocked relays (kind 10006)
@ -131,11 +135,12 @@ async function loadUserPreferences(pubkey: string): Promise<void> {
); );
if (blockedRelayEvents.length > 0) { if (blockedRelayEvents.length > 0) {
const blockedRelays = blockedRelayEvents[0].tags const blocked = blockedRelayEvents[0].tags
.filter((t) => t[0] === 'relay') .filter((t) => t[0] === 'relay')
.map((t) => t[1]) .map((t) => t[1])
.filter(Boolean) as string[]; .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 { export function logout(): void {
sessionManager.clearSession(); sessionManager.clearSession();
eventStore.setMuteList([]); muteList.clear();
eventStore.setBlockedRelays([]); blockedRelays.clear();
}
/**
* Get mute list
*/
export function getMuteList(): Set<string> {
return muteList;
}
/**
* Get blocked relays
*/
export function getBlockedRelays(): Set<string> {
return blockedRelays;
} }

480
src/lib/services/nostr/event-store.ts

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

46
src/lib/services/nostr/event-utils.ts

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

214
src/lib/services/nostr/relay-pool.ts

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

146
src/lib/services/nostr/subscription-manager.ts

@ -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…
Cancel
Save