import NDK, { NDKEvent, NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser, } from "@nostr-dev-kit/ndk"; import { get, type Writable, writable } from "svelte/store"; import { anonymousRelays, loginStorageKey } from "./consts.ts"; import { buildCompleteRelaySet, deduplicateRelayUrls, testRelayConnection, } from "./utils/relay_management.ts"; import { userStore } from "./stores/userStore.ts"; import { userPubkey } from "./stores/authStore.Svelte.ts"; import { startNetworkStatusMonitoring, stopNetworkStatusMonitoring, } from "./stores/networkStore.ts"; import { WebSocketPool } from "./data_structures/websocket_pool.ts"; import { getContext, setContext } from "svelte"; // Re-export testRelayConnection for components that need it export { testRelayConnection }; export const ndkSignedIn = writable(false); export const activePubkey = writable(null); export const inboxRelays = writable([]); export const outboxRelays = writable([]); // New relay management stores export const activeInboxRelays = writable([]); export const activeOutboxRelays = writable([]); const NDK_CONTEXT_KEY = "ndk"; export function getNdkContext(): NDK { return getContext(NDK_CONTEXT_KEY) as NDK; } export function setNdkContext(ndk: NDK): void { setContext(NDK_CONTEXT_KEY, ndk); } // AI-NOTE: 2025-01-08 - Persistent relay storage to avoid recalculation let persistentRelaySet: | { inboxRelays: string[]; outboxRelays: string[] } | null = null; let relaySetLastUpdated: number = 0; const RELAY_SET_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes const RELAY_SET_STORAGE_KEY = "alexandria/relay_set_cache"; /** * Load persistent relay set from localStorage */ function loadPersistentRelaySet(): { relaySet: { inboxRelays: string[]; outboxRelays: string[] } | null; lastUpdated: number; } { // Only load from localStorage on client-side if (typeof window === "undefined") return { relaySet: null, lastUpdated: 0 }; try { const stored = localStorage.getItem(RELAY_SET_STORAGE_KEY); if (!stored) return { relaySet: null, lastUpdated: 0 }; const data = JSON.parse(stored); const now = Date.now(); // Check if cache is expired if (now - data.timestamp > RELAY_SET_CACHE_DURATION) { localStorage.removeItem(RELAY_SET_STORAGE_KEY); return { relaySet: null, lastUpdated: 0 }; } return { relaySet: data.relaySet, lastUpdated: data.timestamp }; } catch (error) { console.warn("[NDK.ts] Failed to load persistent relay set:", error); localStorage.removeItem(RELAY_SET_STORAGE_KEY); return { relaySet: null, lastUpdated: 0 }; } } /** * Save persistent relay set to localStorage */ function savePersistentRelaySet( relaySet: { inboxRelays: string[]; outboxRelays: string[] }, ): void { // Only save to localStorage on client-side if (typeof window === "undefined") return; try { const data = { relaySet, timestamp: Date.now(), }; localStorage.setItem(RELAY_SET_STORAGE_KEY, JSON.stringify(data)); } catch (error) { console.warn("[NDK.ts] Failed to save persistent relay set:", error); } } /** * Clear persistent relay set from localStorage */ function clearPersistentRelaySet(): void { // Only clear from localStorage on client-side if (typeof window === "undefined") return; try { localStorage.removeItem(RELAY_SET_STORAGE_KEY); } catch (error) { console.warn("[NDK.ts] Failed to clear persistent relay set:", error); } } // AI-NOTE: userStore subscription moved to initNdk function to prevent initialization errors // The subscription will be set up after userStore is properly initialized /** * Custom authentication policy that handles NIP-42 authentication manually * when the default NDK authentication fails */ class CustomRelayAuthPolicy { private ndk: NDK; private challenges: Map = new Map(); constructor(ndk: NDK) { this.ndk = ndk; } /** * Handles authentication for a relay * @param relay The relay to authenticate with * @returns Promise that resolves when authentication is complete */ authenticate(relay: NDKRelay): void { if (!this.ndk.signer || !this.ndk.activeUser) { console.warn( "[NDK.ts] No signer or active user available for relay authentication", ); return; } try { console.debug(`[NDK.ts] Setting up authentication for ${relay.url}`); // Listen for AUTH challenges relay.on("auth", (challenge: string) => { console.debug( `[NDK.ts] Received AUTH challenge from ${relay.url}:`, challenge, ); this.challenges.set(relay.url, challenge); this.handleAuthChallenge(relay, challenge); }); // Listen for auth-required errors (handle via notice events) relay.on("notice", (message: string) => { if (message.includes("auth-required")) { console.debug(`[NDK.ts] Auth required from ${relay.url}:`, message); this.handleAuthRequired(relay); } }); // Listen for successful authentication relay.on("authed", () => { console.debug(`[NDK.ts] Successfully authenticated to ${relay.url}`); }); // Listen for authentication failures relay.on("auth:failed", (error) => { console.error( `[NDK.ts] Authentication failed for ${relay.url}:`, error, ); }); } catch (error) { console.error( `[NDK.ts] Error setting up authentication for ${relay.url}:`, error, ); } } /** * Handles AUTH challenge from relay */ private async handleAuthChallenge( relay: NDKRelay, challenge: string, ): Promise { try { if (!this.ndk.signer || !this.ndk.activeUser) { console.warn("[NDK.ts] No signer available for AUTH challenge"); return; } // Create NIP-42 authentication event const authEvent = { kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [ ["relay", relay.url], ["challenge", challenge], ], content: "", pubkey: this.ndk.activeUser.pubkey, }; // Create and sign the authentication event using NDKEvent const authNDKEvent = new NDKEvent(this.ndk, authEvent); await authNDKEvent.sign(); // Send AUTH message to relay using the relay's publish method await relay.publish(authNDKEvent); console.debug(`[NDK.ts] Sent AUTH to ${relay.url}`); } catch (error) { console.error( `[NDK.ts] Error handling AUTH challenge for ${relay.url}:`, error, ); } } /** * Handles auth-required error from relay */ private async handleAuthRequired(relay: NDKRelay): Promise { const challenge = this.challenges.get(relay.url); if (challenge) { await this.handleAuthChallenge(relay, challenge); } else { console.warn( `[NDK.ts] Auth required from ${relay.url} but no challenge available`, ); } } } /** * Checks if the current environment might cause WebSocket protocol downgrade */ export function checkEnvironmentForWebSocketDowngrade(): void { console.debug("[NDK.ts] Environment Check for WebSocket Protocol:"); const isLocalhost = globalThis.location.hostname === "localhost" || globalThis.location.hostname === "127.0.0.1"; const isHttp = globalThis.location.protocol === "http:"; const isHttps = globalThis.location.protocol === "https:"; console.debug("[NDK.ts] - Is localhost:", isLocalhost); console.debug("[NDK.ts] - Protocol:", globalThis.location.protocol); console.debug("[NDK.ts] - Is HTTP:", isHttp); console.debug("[NDK.ts] - Is HTTPS:", isHttps); if (isLocalhost && isHttp) { console.warn( "[NDK.ts] ⚠️ Running on localhost with HTTP - WebSocket downgrade to ws:// is expected", ); console.warn("[NDK.ts] This is normal for development environments"); } else if (isHttp) { console.error( "[NDK.ts] ❌ Running on HTTP - WebSocket connections will be insecure", ); console.error("[NDK.ts] Consider using HTTPS in production"); } else if (isHttps) { console.debug( "[NDK.ts] ✓ Running on HTTPS - Secure WebSocket connections should work", ); } } /** * Checks WebSocket protocol support and logs diagnostic information */ export function checkWebSocketSupport(): void { console.debug("[NDK.ts] WebSocket Support Diagnostics:"); console.debug("[NDK.ts] - Protocol:", globalThis.location.protocol); console.debug("[NDK.ts] - Hostname:", globalThis.location.hostname); console.debug("[NDK.ts] - Port:", globalThis.location.port); console.debug("[NDK.ts] - User Agent:", navigator.userAgent); // Test if secure WebSocket is supported try { WebSocketPool.instance.acquire("wss://echo.websocket.org").then((ws) => { console.debug("[NDK.ts] ✓ Secure WebSocket (wss://) is supported"); WebSocketPool.instance.release(ws); }).catch((_) => { console.warn("[NDK.ts] ✗ Secure WebSocket (wss://) may not be supported"); }); } catch { console.warn("[NDK.ts] ✗ WebSocket test failed"); } } /** * Gets the user's pubkey from local storage, if it exists. * @returns The user's pubkey, or null if there is no logged-in user. * @remarks Local storage is used in place of cookies to persist the user's login across browser * sessions. */ export function getPersistedLogin(): string | null { // Only access localStorage on client-side if (typeof window === "undefined") return null; const pubkey = localStorage.getItem(loginStorageKey); return pubkey; } /** * Writes the user's pubkey to local storage. * @param user The user to persist. * @remarks Use this function when the user logs in. Currently, only one pubkey is stored at a * time. */ export function persistLogin(user: NDKUser): void { // Only access localStorage on client-side if (typeof window === "undefined") return; localStorage.setItem(loginStorageKey, user.pubkey); } /** * Clears the user's pubkey from local storage. * @remarks Use this function when the user logs out. */ export function clearLogin(): void { // Only access localStorage on client-side if (typeof window === "undefined") return; localStorage.removeItem(loginStorageKey); } /** * Constructs a key use to designate a user's relay lists in local storage. * @param user The user for whom to construct the key. * @param type The type of relay list to designate. * @returns The constructed key. */ function getRelayStorageKey(user: NDKUser, type: "inbox" | "outbox"): string { return `${loginStorageKey}/${user.pubkey}/${type}`; } export function clearPersistedRelays(user: NDKUser): void { // Only access localStorage on client-side if (typeof window === "undefined") return; localStorage.removeItem(getRelayStorageKey(user, "inbox")); localStorage.removeItem(getRelayStorageKey(user, "outbox")); } /** * Ensures a relay URL uses secure WebSocket protocol * @param url The relay URL to secure * @returns The URL with appropriate protocol (ws:// for localhost, wss:// for remote) */ function ensureSecureWebSocket(url: string): string { // For localhost, always use ws:// (never wss://) if (url.includes("localhost") || url.includes("127.0.0.1")) { // Convert any wss://localhost to ws://localhost return url.replace(/^wss:\/\//, "ws://"); } // Replace ws:// with wss:// for remote relays const secureUrl = url.replace(/^ws:\/\//, "wss://"); if (secureUrl !== url) { console.warn( `[NDK.ts] Protocol upgrade for remote relay: ${url} -> ${secureUrl}`, ); } return secureUrl; } /** * Creates a relay with proper authentication handling */ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay { try { // Reduce verbosity in development - only log relay creation if debug mode is enabled if (process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS) { console.debug(`[NDK.ts] Creating relay with URL: ${url}`); } // Ensure the URL is using appropriate protocol const secureUrl = ensureSecureWebSocket(url); // Add connection timeout and error handling const relay = new NDKRelay( secureUrl, NDKRelayAuthPolicies.signIn({ ndk }), ndk, ); // Set up connection timeout const connectionTimeout = setTimeout(() => { try { // Only log connection timeouts if debug mode is enabled if ( process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS ) { console.debug(`[NDK.ts] Connection timeout for ${secureUrl}`); } relay.disconnect(); } catch { // Silently ignore disconnect errors } }, 5000); // 5 second timeout // Set up custom authentication handling only if user is signed in if (ndk.signer && ndk.activeUser) { const authPolicy = new CustomRelayAuthPolicy(ndk); relay.on("connect", () => { try { // Only log successful connections if debug mode is enabled if ( process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS ) { console.debug(`[NDK.ts] Relay connected: ${secureUrl}`); } clearTimeout(connectionTimeout); authPolicy.authenticate(relay); } catch { // Silently handle connect handler errors } }); } else { relay.on("connect", () => { try { // Only log successful connections if debug mode is enabled if ( process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS ) { console.debug(`[NDK.ts] Relay connected: ${secureUrl}`); } clearTimeout(connectionTimeout); } catch { // Silently handle connect handler errors } }); } // Add error handling relay.on("disconnect", () => { try { console.debug(`[NDK.ts] Relay disconnected: ${secureUrl}`); clearTimeout(connectionTimeout); } catch { // Silently handle disconnect handler errors } }); return relay; } catch (error) { // If relay creation fails, try to use an anonymous relay as fallback console.debug( `[NDK.ts] Failed to create relay for ${url}, trying anonymous relay fallback`, ); // Find an anonymous relay that's not the same as the failed URL const fallbackUrl = anonymousRelays.find((relay) => relay !== url) || anonymousRelays[0]; if (fallbackUrl) { console.debug( `[NDK.ts] Using anonymous relay as fallback: ${fallbackUrl}`, ); try { const fallbackRelay = new NDKRelay( fallbackUrl, NDKRelayAuthPolicies.signIn({ ndk }), ndk, ); return fallbackRelay; } catch (fallbackError) { console.debug( `[NDK.ts] Fallback relay creation also failed: ${fallbackError}`, ); } } // If all else fails, create a minimal relay that will fail gracefully console.debug( `[NDK.ts] All fallback attempts failed, creating minimal relay for ${url}`, ); const minimalRelay = new NDKRelay(url, undefined, ndk); return minimalRelay; } } /** * Gets the active relay set for the current user * @param ndk NDK instance * @returns Promise that resolves to object with inbox and outbox relay arrays */ export async function getActiveRelaySet( ndk: NDK, ): Promise<{ inboxRelays: string[]; outboxRelays: string[] }> { const user = get(userStore); console.debug("[NDK.ts] getActiveRelaySet: User state:", { signedIn: user.signedIn, hasNdkUser: !!user.ndkUser, pubkey: user.pubkey, }); if (user.signedIn && user.ndkUser) { console.debug( "[NDK.ts] getActiveRelaySet: Building relay set for authenticated user:", user.ndkUser.pubkey, ); return await buildCompleteRelaySet(ndk, user.ndkUser); } else { console.debug( "[NDK.ts] getActiveRelaySet: Building relay set for anonymous user", ); return await buildCompleteRelaySet(ndk, null); } } /** * Updates the active relay stores and NDK pool with new relay URLs * @param ndk NDK instance * @param forceUpdate Force update even if cached (default: false) */ export async function updateActiveRelayStores( ndk: NDK, forceUpdate: boolean = false, ): Promise { try { // AI-NOTE: 2025-01-08 - Use persistent relay set to avoid recalculation const now = Date.now(); const cacheExpired = now - relaySetLastUpdated > RELAY_SET_CACHE_DURATION; // Load from persistent storage if not already loaded if (!persistentRelaySet) { const loaded = loadPersistentRelaySet(); persistentRelaySet = loaded.relaySet; relaySetLastUpdated = loaded.lastUpdated; } if (!forceUpdate && persistentRelaySet && !cacheExpired) { console.debug("[NDK.ts] updateActiveRelayStores: Using cached relay set"); activeInboxRelays.set(persistentRelaySet.inboxRelays); activeOutboxRelays.set(persistentRelaySet.outboxRelays); return; } console.debug( "[NDK.ts] updateActiveRelayStores: Starting relay store update", ); // Get the active relay set from the relay management system const relaySet = await getActiveRelaySet(ndk); console.debug("[NDK.ts] updateActiveRelayStores: Got relay set:", relaySet); // Cache the relay set persistentRelaySet = relaySet; relaySetLastUpdated = now; savePersistentRelaySet(relaySet); // Save to persistent storage // Update the stores with the new relay configuration activeInboxRelays.set(relaySet.inboxRelays); activeOutboxRelays.set(relaySet.outboxRelays); console.debug( "[NDK.ts] updateActiveRelayStores: Updated stores with inbox:", relaySet.inboxRelays.length, "outbox:", relaySet.outboxRelays.length, ); // Add relays to NDK pool (deduplicated) const allRelayUrls = deduplicateRelayUrls([ ...relaySet.inboxRelays, ...relaySet.outboxRelays, ]); // Reduce verbosity in development - only log relay addition if debug mode is enabled if (process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS) { console.debug( "[NDK.ts] updateActiveRelayStores: Adding", allRelayUrls.length, "relays to NDK pool", ); } for (const url of allRelayUrls) { try { const relay = createRelayWithAuth(url, ndk); ndk.pool?.addRelay(relay); } catch (error) { console.debug( "[NDK.ts] updateActiveRelayStores: Failed to add relay", url, ":", error, ); } } console.debug( "[NDK.ts] updateActiveRelayStores: Relay store update completed", ); } catch (error) { console.warn( "[NDK.ts] updateActiveRelayStores: Error updating relay stores:", error, ); } } /** * Logs the current relay configuration to console */ export function logCurrentRelayConfiguration(): void { const inboxRelays = get(activeInboxRelays); const outboxRelays = get(activeOutboxRelays); console.log("🔌 Current Relay Configuration:"); console.log("📥 Inbox Relays:", inboxRelays); console.log("📤 Outbox Relays:", outboxRelays); console.log( `📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`, ); } /** * Clears the relay set cache to force a rebuild */ export function clearRelaySetCache(): void { console.debug("[NDK.ts] Clearing relay set cache"); persistentRelaySet = null; relaySetLastUpdated = 0; // Clear from localStorage as well (client-side only) if (typeof window !== "undefined") { localStorage.removeItem("alexandria/relay_set_cache"); } } /** * Updates relay stores when user state changes * @param ndk NDK instance */ export async function refreshRelayStores(ndk: NDK): Promise { console.debug("[NDK.ts] Refreshing relay stores due to user state change"); clearRelaySetCache(); // Clear cache when user state changes await updateActiveRelayStores(ndk, true); // Force update } /** * Updates relay stores when network condition changes * @param ndk NDK instance */ export async function refreshRelayStoresOnNetworkChange( ndk: NDK, ): Promise { console.debug( "[NDK.ts] Refreshing relay stores due to network condition change", ); await updateActiveRelayStores(ndk); } /** * Starts network monitoring for relay optimization * @param ndk NDK instance */ export function startNetworkMonitoringForRelays(): void { // Use centralized network monitoring instead of separate monitoring startNetworkStatusMonitoring(); } /** * Creates NDKRelaySet from relay URLs with proper authentication * @param relayUrls Array of relay URLs * @param ndk NDK instance * @returns NDKRelaySet */ function createRelaySetFromUrls(relayUrls: string[], ndk: NDK): NDKRelaySet { const relays = relayUrls.map((url) => new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk) ); return new NDKRelaySet(new Set(relays), ndk); } /** * Gets the active relay set as NDKRelaySet for use in queries * @param ndk NDK instance * @param useInbox Whether to use inbox relays (true) or outbox relays (false) * @returns Promise that resolves to NDKRelaySet */ export async function getActiveRelaySetAsNDKRelaySet( ndk: NDK, useInbox: boolean = true, ): Promise { const relaySet = await getActiveRelaySet(ndk); const urls = useInbox ? relaySet.inboxRelays : relaySet.outboxRelays; return createRelaySetFromUrls(urls, ndk); } /** * Initializes an instance of NDK with the new relay management system * @returns The initialized NDK instance */ export function initNdk(): NDK { console.debug("[NDK.ts] Initializing NDK with new relay management system"); const ndk = new NDK({ autoConnectUserRelays: false, // We'll manage relays manually enableOutboxModel: true, }); // Set up custom authentication policy ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk }); // Connect with better error handling and reduced retry attempts let retryCount = 0; const maxRetries = 1; // Reduce to 1 retry const attemptConnection = async () => { // Only attempt connection on client-side if (typeof window === "undefined") { console.debug("[NDK.ts] Skipping NDK connection during SSR"); return; } try { await ndk.connect(); console.debug("[NDK.ts] NDK connected successfully"); // Update relay stores after connection await updateActiveRelayStores(ndk); // Start network monitoring for relay optimization startNetworkMonitoringForRelays(); } catch (error) { console.warn("[NDK.ts] Failed to connect NDK:", error); // Only retry a limited number of times if (retryCount < maxRetries) { retryCount++; console.debug( `[NDK.ts] Attempting to reconnect (${retryCount}/${maxRetries})...`, ); // Use a more reasonable retry delay and prevent memory leaks setTimeout(() => { attemptConnection(); }, 2000 * retryCount); // Exponential backoff } else { console.warn( "[NDK.ts] Max retries reached, continuing with limited functionality", ); // Still try to update relay stores even if connection failed try { await updateActiveRelayStores(ndk); startNetworkMonitoringForRelays(); } catch (storeError) { console.warn("[NDK.ts] Failed to update relay stores:", storeError); } } } }; // Only attempt connection on client-side if (typeof window !== "undefined") { attemptConnection(); } // AI-NOTE: Set up userStore subscription after NDK initialization to prevent initialization errors userStore.subscribe(async (userState) => { ndkSignedIn.set(userState.signedIn); // Refresh relay stores when user state changes if (ndk) { try { await refreshRelayStores(ndk); } catch (error) { console.warn( "[NDK.ts] Failed to refresh relay stores on user state change:", error, ); } } }); return ndk; } /** * Cleans up NDK resources to prevent memory leaks * Should be called when the application is shutting down or when NDK needs to be reset */ export function cleanupNdk(): void { console.debug("[NDK.ts] Cleaning up NDK resources"); const ndk = getNdkContext(); if (ndk) { try { // Disconnect from all relays if (ndk.pool) { for (const relay of ndk.pool.relays.values()) { relay.disconnect(); } } // Drain the WebSocket pool WebSocketPool.instance.drain(); // Stop network monitoring stopNetworkStatusMonitoring(); console.debug("[NDK.ts] NDK cleanup completed"); } catch (error) { console.warn("[NDK.ts] Error during NDK cleanup:", error); } } } /** * Signs in with a NIP-07 browser extension using the new relay management system * @returns The user's profile, if it is available * @throws If sign-in fails */ export async function loginWithExtension( pubkey?: string, ): Promise { try { const ndk = getNdkContext(); const signer = new NDKNip07Signer(); const signerUser = await signer.user(); // TODO: Handle changing pubkeys. if (pubkey && signerUser.pubkey !== pubkey) { console.debug("[NDK.ts] Switching pubkeys from last login."); } activePubkey.set(signerUser.pubkey); userPubkey.set(signerUser.pubkey); const user = ndk.getUser({ pubkey: signerUser.pubkey }); // Update relay stores with the new system await updateActiveRelayStores(ndk); ndk.signer = signer; ndk.activeUser = user; setNdkContext(ndk); ndkSignedIn.set(true); return user; } catch (e) { throw new Error(`Failed to sign in with NIP-07 extension: ${e}`); } } /** * Handles logging out a user. * @param user The user to log out. */ export function logout(user: NDKUser): void { clearLogin(); clearPersistedRelays(user); activePubkey.set(null); userPubkey.set(null); ndkSignedIn.set(false); // Clear relay stores activeInboxRelays.set([]); activeOutboxRelays.set([]); // AI-NOTE: 2025-01-08 - Clear persistent relay set on logout persistentRelaySet = null; relaySetLastUpdated = 0; clearPersistentRelaySet(); // Clear persistent storage // Stop network monitoring stopNetworkStatusMonitoring(); // Re-initialize with anonymous instance const newNdk = initNdk(); setNdkContext(newNdk); }