|
|
|
|
@ -3,12 +3,13 @@
@@ -3,12 +3,13 @@
|
|
|
|
|
* Features: request throttling, batching, rate limiting, efficient caching |
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
import { Relay, type Filter, matchFilter } from 'nostr-tools'; |
|
|
|
|
import { Relay, type Filter, matchFilter, type EventTemplate, type VerifiedEvent } from 'nostr-tools'; |
|
|
|
|
import { config } from './config.js'; |
|
|
|
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
|
|
import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey } from '../cache/event-cache.js'; |
|
|
|
|
import { getDB } from '../cache/indexeddb-store.js'; |
|
|
|
|
import { filterEvents, shouldHideEvent } from '../event-filter.js'; |
|
|
|
|
import { sessionManager } from '../auth/session-manager.js'; |
|
|
|
|
|
|
|
|
|
export interface PublishOptions { |
|
|
|
|
relays?: string[]; |
|
|
|
|
@ -47,6 +48,9 @@ class NostrClient {
@@ -47,6 +48,9 @@ class NostrClient {
|
|
|
|
|
private readonly INITIAL_RETRY_DELAY = 5000; // 5 seconds
|
|
|
|
|
private readonly MAX_RETRY_DELAY = 300000; // 5 minutes
|
|
|
|
|
private readonly MAX_FAILURE_COUNT = 10; // After 10 failures, wait max delay
|
|
|
|
|
|
|
|
|
|
// Track authenticated relays to avoid re-authenticating
|
|
|
|
|
private authenticatedRelays: Set<string> = new Set(); |
|
|
|
|
|
|
|
|
|
async initialize(): Promise<void> { |
|
|
|
|
if (this.initialized) return; |
|
|
|
|
@ -69,6 +73,61 @@ class NostrClient {
@@ -69,6 +73,61 @@ class NostrClient {
|
|
|
|
|
this.initialized = true; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Authenticate with a relay using NIP-42 AUTH |
|
|
|
|
* Only call this when the relay has sent an AUTH challenge or an operation failed with auth-required |
|
|
|
|
*/ |
|
|
|
|
private async authenticateRelay(relay: Relay, url: string): Promise<boolean> { |
|
|
|
|
// Check if we're already authenticated
|
|
|
|
|
if (this.authenticatedRelays.has(url)) { |
|
|
|
|
return true; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Check if user is logged in
|
|
|
|
|
const session = sessionManager.getSession(); |
|
|
|
|
if (!session) { |
|
|
|
|
console.debug(`[nostr-client] Cannot authenticate with ${url}: user not logged in`); |
|
|
|
|
return false; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
// Check if relay supports AUTH by checking for auth method
|
|
|
|
|
// nostr-tools Relay.auth() will handle the AUTH flow
|
|
|
|
|
// Only call this when relay has sent a challenge (handled by nostr-tools)
|
|
|
|
|
if (typeof (relay as any).auth === 'function') { |
|
|
|
|
await relay.auth(async (authEvent: EventTemplate): Promise<VerifiedEvent> => { |
|
|
|
|
// Ensure authEvent has required fields
|
|
|
|
|
const eventToSign: Omit<NostrEvent, 'sig' | 'id'> = { |
|
|
|
|
pubkey: session.pubkey, |
|
|
|
|
created_at: authEvent.created_at, |
|
|
|
|
kind: authEvent.kind, |
|
|
|
|
tags: authEvent.tags || [], |
|
|
|
|
content: authEvent.content || '' |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
// Sign the auth event using the session signer
|
|
|
|
|
const signedEvent = await session.signer(eventToSign); |
|
|
|
|
|
|
|
|
|
// Return as VerifiedEvent (nostr-tools will verify the signature)
|
|
|
|
|
return signedEvent as VerifiedEvent; |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
this.authenticatedRelays.add(url); |
|
|
|
|
console.debug(`[nostr-client] Successfully authenticated with relay: ${url}`); |
|
|
|
|
return true; |
|
|
|
|
} |
|
|
|
|
} catch (error) { |
|
|
|
|
// Only log if it's not the "no challenge" error (which is expected for relays that don't require auth)
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : String(error); |
|
|
|
|
if (!errorMessage.includes('no challenge was received')) { |
|
|
|
|
console.debug(`[nostr-client] Failed to authenticate with relay ${url}:`, errorMessage); |
|
|
|
|
} |
|
|
|
|
return false; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
return false; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
async addRelay(url: string): Promise<void> { |
|
|
|
|
if (this.relays.has(url)) return; |
|
|
|
|
|
|
|
|
|
@ -86,6 +145,11 @@ class NostrClient {
@@ -86,6 +145,11 @@ class NostrClient {
|
|
|
|
|
try { |
|
|
|
|
const relay = await Relay.connect(url); |
|
|
|
|
this.relays.set(url, relay); |
|
|
|
|
|
|
|
|
|
// Don't proactively authenticate - only authenticate when:
|
|
|
|
|
// 1. Relay sends an AUTH challenge (handled by nostr-tools automatically)
|
|
|
|
|
// 2. An operation fails with 'auth-required' error (handled in publish/subscribe methods)
|
|
|
|
|
|
|
|
|
|
// Clear failure tracking on successful connection
|
|
|
|
|
this.failedRelays.delete(url); |
|
|
|
|
// Log successful connection at debug level to reduce console noise
|
|
|
|
|
@ -94,8 +158,17 @@ class NostrClient {
@@ -94,8 +158,17 @@ class NostrClient {
|
|
|
|
|
// Track the failure
|
|
|
|
|
const existingFailure = this.failedRelays.get(url) || { lastFailure: 0, retryAfter: this.INITIAL_RETRY_DELAY, failureCount: 0 }; |
|
|
|
|
const failureCount = existingFailure.failureCount + 1; |
|
|
|
|
|
|
|
|
|
// For connection refused errors, use longer backoff to avoid spam
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : String(error); |
|
|
|
|
const isConnectionRefused = errorMessage.includes('CONNECTION_REFUSED') ||
|
|
|
|
|
errorMessage.includes('connection refused') || |
|
|
|
|
errorMessage.includes('Connection refused'); |
|
|
|
|
|
|
|
|
|
// Use longer initial delay for connection refused errors
|
|
|
|
|
const initialDelay = isConnectionRefused ? this.INITIAL_RETRY_DELAY * 2 : this.INITIAL_RETRY_DELAY; |
|
|
|
|
const retryAfter = Math.min( |
|
|
|
|
this.INITIAL_RETRY_DELAY * Math.pow(2, Math.min(failureCount - 1, 6)), // Exponential backoff, max 2^6 = 64x
|
|
|
|
|
initialDelay * Math.pow(2, Math.min(failureCount - 1, 6)), // Exponential backoff, max 2^6 = 64x
|
|
|
|
|
this.MAX_RETRY_DELAY |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
@ -105,7 +178,11 @@ class NostrClient {
@@ -105,7 +178,11 @@ class NostrClient {
|
|
|
|
|
failureCount |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
console.warn(`[nostr-client] Failed to connect to relay ${url} (failure #${failureCount}), will retry after ${Math.round(retryAfter / 1000)}s`); |
|
|
|
|
// Only log at debug level to reduce console noise - connection failures are expected
|
|
|
|
|
// Only warn if it's a persistent failure (after several attempts)
|
|
|
|
|
if (failureCount > 3) { |
|
|
|
|
console.debug(`[nostr-client] Relay ${url} connection failed (failure #${failureCount}), will retry after ${Math.round(retryAfter / 1000)}s`); |
|
|
|
|
} |
|
|
|
|
throw error; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
@ -119,6 +196,8 @@ class NostrClient {
@@ -119,6 +196,8 @@ class NostrClient {
|
|
|
|
|
// Ignore
|
|
|
|
|
} |
|
|
|
|
this.relays.delete(url); |
|
|
|
|
// Remove from authenticated relays when disconnecting
|
|
|
|
|
this.authenticatedRelays.delete(url); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -328,10 +407,32 @@ class NostrClient {
@@ -328,10 +407,32 @@ class NostrClient {
|
|
|
|
|
await newRelay.publish(event); |
|
|
|
|
results.success.push(url); |
|
|
|
|
} catch (error) { |
|
|
|
|
results.failed.push({ |
|
|
|
|
relay: url, |
|
|
|
|
error: error instanceof Error ? error.message : 'Unknown error' |
|
|
|
|
}); |
|
|
|
|
// Check if error is auth-required
|
|
|
|
|
if (error instanceof Error && error.message.startsWith('auth-required')) { |
|
|
|
|
// Try to authenticate and retry
|
|
|
|
|
const authSuccess = await this.authenticateRelay(newRelay, url); |
|
|
|
|
if (authSuccess) { |
|
|
|
|
try { |
|
|
|
|
await newRelay.publish(event); |
|
|
|
|
results.success.push(url); |
|
|
|
|
} catch (retryError) { |
|
|
|
|
results.failed.push({ |
|
|
|
|
relay: url, |
|
|
|
|
error: retryError instanceof Error ? retryError.message : 'Unknown error' |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
} else { |
|
|
|
|
results.failed.push({ |
|
|
|
|
relay: url, |
|
|
|
|
error: 'Authentication required but failed' |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
} else { |
|
|
|
|
results.failed.push({ |
|
|
|
|
relay: url, |
|
|
|
|
error: error instanceof Error ? error.message : 'Unknown error' |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} catch (error) { |
|
|
|
|
@ -345,10 +446,32 @@ class NostrClient {
@@ -345,10 +446,32 @@ class NostrClient {
|
|
|
|
|
await relay.publish(event); |
|
|
|
|
results.success.push(url); |
|
|
|
|
} catch (error) { |
|
|
|
|
results.failed.push({ |
|
|
|
|
relay: url, |
|
|
|
|
error: error instanceof Error ? error.message : 'Unknown error' |
|
|
|
|
}); |
|
|
|
|
// Check if error is auth-required
|
|
|
|
|
if (error instanceof Error && error.message.startsWith('auth-required')) { |
|
|
|
|
// Try to authenticate and retry
|
|
|
|
|
const authSuccess = await this.authenticateRelay(relay, url); |
|
|
|
|
if (authSuccess) { |
|
|
|
|
try { |
|
|
|
|
await relay.publish(event); |
|
|
|
|
results.success.push(url); |
|
|
|
|
} catch (retryError) { |
|
|
|
|
results.failed.push({ |
|
|
|
|
relay: url, |
|
|
|
|
error: retryError instanceof Error ? retryError.message : 'Unknown error' |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
} else { |
|
|
|
|
results.failed.push({ |
|
|
|
|
relay: url, |
|
|
|
|
error: 'Authentication required but failed' |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
} else { |
|
|
|
|
results.failed.push({ |
|
|
|
|
relay: url, |
|
|
|
|
error: error instanceof Error ? error.message : 'Unknown error' |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
@ -402,28 +525,53 @@ class NostrClient {
@@ -402,28 +525,53 @@ class NostrClient {
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
const client = this; |
|
|
|
|
const sub = relay.subscribe(filters, { |
|
|
|
|
onevent: (event: NostrEvent) => { |
|
|
|
|
try { |
|
|
|
|
if (!client.relays.has(url)) return; |
|
|
|
|
if (client.shouldFilterZapReceipt(event)) return; |
|
|
|
|
client.addToCache(event); |
|
|
|
|
onEvent(event, url); |
|
|
|
|
} catch (err) { |
|
|
|
|
// Silently handle errors - connection may be closed
|
|
|
|
|
} |
|
|
|
|
}, |
|
|
|
|
oneose: () => { |
|
|
|
|
try { |
|
|
|
|
if (!client.relays.has(url)) return; |
|
|
|
|
onEose?.(url); |
|
|
|
|
} catch (err) { |
|
|
|
|
// Silently handle errors - connection may be closed
|
|
|
|
|
let hasAuthed = this.authenticatedRelays.has(url); |
|
|
|
|
|
|
|
|
|
const startSub = () => { |
|
|
|
|
const sub = relay.subscribe(filters, { |
|
|
|
|
onevent: (event: NostrEvent) => { |
|
|
|
|
try { |
|
|
|
|
if (!client.relays.has(url)) return; |
|
|
|
|
if (client.shouldFilterZapReceipt(event)) return; |
|
|
|
|
client.addToCache(event); |
|
|
|
|
onEvent(event, url); |
|
|
|
|
} catch (err) { |
|
|
|
|
// Silently handle errors - connection may be closed
|
|
|
|
|
} |
|
|
|
|
}, |
|
|
|
|
oneose: () => { |
|
|
|
|
try { |
|
|
|
|
if (!client.relays.has(url)) return; |
|
|
|
|
onEose?.(url); |
|
|
|
|
} catch (err) { |
|
|
|
|
// Silently handle errors - connection may be closed
|
|
|
|
|
} |
|
|
|
|
}, |
|
|
|
|
onclose: (reason: string) => { |
|
|
|
|
// Handle auth-required on subscription close
|
|
|
|
|
if (reason.startsWith('auth-required') && !hasAuthed) { |
|
|
|
|
const session = sessionManager.getSession(); |
|
|
|
|
if (session) { |
|
|
|
|
client.authenticateRelay(relay, url) |
|
|
|
|
.then((authSuccess) => { |
|
|
|
|
if (authSuccess) { |
|
|
|
|
hasAuthed = true; |
|
|
|
|
// Retry subscription after authentication
|
|
|
|
|
startSub(); |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
.catch(() => { |
|
|
|
|
// Authentication failed, give up
|
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
}); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
this.subscriptions.set(`${url}_${subId}`, { relay, sub }); |
|
|
|
|
client.subscriptions.set(`${url}_${subId}`, { relay, sub }); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
startSub(); |
|
|
|
|
} catch (error) { |
|
|
|
|
// Handle errors
|
|
|
|
|
} |
|
|
|
|
@ -511,6 +659,7 @@ class NostrClient {
@@ -511,6 +659,7 @@ class NostrClient {
|
|
|
|
|
const subId = `sub_${this.nextSubId++}_${Date.now()}`; |
|
|
|
|
let resolved = false; |
|
|
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null; |
|
|
|
|
let hasAuthed = this.authenticatedRelays.has(relayUrl); |
|
|
|
|
|
|
|
|
|
const finish = () => { |
|
|
|
|
if (resolved) return; |
|
|
|
|
@ -519,37 +668,66 @@ class NostrClient {
@@ -519,37 +668,66 @@ class NostrClient {
|
|
|
|
|
this.unsubscribe(subId); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
const client = this; |
|
|
|
|
const sub = relay.subscribe(filters, { |
|
|
|
|
onevent: (event: NostrEvent) => { |
|
|
|
|
try { |
|
|
|
|
if (!client.relays.has(relayUrl)) return; |
|
|
|
|
if (shouldHideEvent(event)) return; |
|
|
|
|
if (client.shouldFilterZapReceipt(event)) return; |
|
|
|
|
events.set(event.id, event); |
|
|
|
|
client.addToCache(event); |
|
|
|
|
} catch (error) { |
|
|
|
|
// Silently handle errors - connection may be closed
|
|
|
|
|
} |
|
|
|
|
}, |
|
|
|
|
oneose: () => { |
|
|
|
|
try { |
|
|
|
|
if (!resolved) finish(); |
|
|
|
|
} catch (error) { |
|
|
|
|
// Silently handle errors - connection may be closed
|
|
|
|
|
const startSub = () => { |
|
|
|
|
try { |
|
|
|
|
const client = this; |
|
|
|
|
const sub = relay.subscribe(filters, { |
|
|
|
|
onevent: (event: NostrEvent) => { |
|
|
|
|
try { |
|
|
|
|
if (!client.relays.has(relayUrl)) return; |
|
|
|
|
if (shouldHideEvent(event)) return; |
|
|
|
|
if (client.shouldFilterZapReceipt(event)) return; |
|
|
|
|
events.set(event.id, event); |
|
|
|
|
client.addToCache(event); |
|
|
|
|
} catch (error) { |
|
|
|
|
// Silently handle errors - connection may be closed
|
|
|
|
|
} |
|
|
|
|
}, |
|
|
|
|
oneose: () => { |
|
|
|
|
try { |
|
|
|
|
if (!resolved) finish(); |
|
|
|
|
} catch (error) { |
|
|
|
|
// Silently handle errors - connection may be closed
|
|
|
|
|
} |
|
|
|
|
}, |
|
|
|
|
onclose: (reason: string) => { |
|
|
|
|
// Handle auth-required on subscription close
|
|
|
|
|
if (reason.startsWith('auth-required') && !hasAuthed && !resolved) { |
|
|
|
|
const session = sessionManager.getSession(); |
|
|
|
|
if (session) { |
|
|
|
|
client.authenticateRelay(relay, relayUrl) |
|
|
|
|
.then((authSuccess) => { |
|
|
|
|
if (authSuccess) { |
|
|
|
|
hasAuthed = true; |
|
|
|
|
// Retry subscription after authentication
|
|
|
|
|
if (!resolved) { |
|
|
|
|
startSub(); |
|
|
|
|
} |
|
|
|
|
} else { |
|
|
|
|
finish(); |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
.catch(() => { |
|
|
|
|
finish(); |
|
|
|
|
}); |
|
|
|
|
} else { |
|
|
|
|
finish(); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
}); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
this.subscriptions.set(`${relayUrl}_${subId}`, { relay, sub }); |
|
|
|
|
client.subscriptions.set(`${relayUrl}_${subId}`, { relay, sub }); |
|
|
|
|
|
|
|
|
|
timeoutId = setTimeout(() => { |
|
|
|
|
if (!resolved) finish(); |
|
|
|
|
}, timeout); |
|
|
|
|
} catch (error) { |
|
|
|
|
finish(); |
|
|
|
|
} |
|
|
|
|
timeoutId = setTimeout(() => { |
|
|
|
|
if (!resolved) finish(); |
|
|
|
|
}, timeout); |
|
|
|
|
} catch (error) { |
|
|
|
|
finish(); |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
startSub(); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
private processQueue(): void { |
|
|
|
|
@ -672,7 +850,10 @@ class NostrClient {
@@ -672,7 +850,10 @@ class NostrClient {
|
|
|
|
|
return []; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
console.debug(`[nostr-client] Fetching from ${connectedRelays.length} connected relay(s) out of ${relays.length} requested`); |
|
|
|
|
// Only log if we're missing a significant number of relays
|
|
|
|
|
if (connectedRelays.length < relays.length * 0.5) { |
|
|
|
|
console.debug(`[nostr-client] Fetching from ${connectedRelays.length} connected relay(s) out of ${relays.length} requested`); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Process relays sequentially with throttling to avoid overload
|
|
|
|
|
const events: Map<string, NostrEvent> = new Map(); |
|
|
|
|
|