diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index c71afc2..d4e0b1e 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -4,12 +4,23 @@ import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; import logger from '../logger.js'; +import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js'; // Polyfill WebSocket for Node.js environments (lazy initialization) +// Note: The 'module' import warning in browser builds is expected and harmless. +// This code only executes in Node.js/server environments. let wsPolyfillInitialized = false; async function initializeWebSocketPolyfill() { if (wsPolyfillInitialized) return; - if (typeof global === 'undefined' || typeof global.WebSocket !== 'undefined') { + + // Check if WebSocket already exists (browser or already polyfilled) + if (typeof WebSocket !== 'undefined') { + wsPolyfillInitialized = true; + return; + } + + // Skip in browser environment - WebSocket should be native + if (typeof window !== 'undefined') { wsPolyfillInitialized = true; return; } @@ -20,22 +31,25 @@ async function initializeWebSocketPolyfill() { return; } + // Only attempt polyfill in Node.js runtime + // This import is only executed server-side, but Vite may still analyze it try { - // Dynamic import to avoid bundling for browser - const { createRequire } = await import('module'); - const requireFunc = createRequire(import.meta.url); + // @ts-ignore - Dynamic import that only runs in Node.js + const moduleModule = await import('module'); + const requireFunc = moduleModule.createRequire(import.meta.url); const WebSocketImpl = requireFunc('ws'); - global.WebSocket = WebSocketImpl as any; + (global as any).WebSocket = WebSocketImpl; wsPolyfillInitialized = true; - } catch { - // ws package not available, will fail at runtime in Node.js - logger.warn('WebSocket polyfill not available. Install "ws" package for Node.js support.'); - wsPolyfillInitialized = true; // Mark as initialized to avoid repeated warnings + } catch (error) { + // ws package not available or import failed + // This is expected in browser builds, so we don't warn + wsPolyfillInitialized = true; // Mark as initialized to avoid repeated attempts } } // Initialize on module load if in Node.js (fire and forget) -if (typeof process !== 'undefined' && process.versions?.node) { +// Only in SSR/server environment - check for window to exclude browser +if (typeof process !== 'undefined' && process.versions?.node && typeof window === 'undefined') { initializeWebSocketPolyfill().catch(() => { // Ignore errors during initialization }); @@ -43,11 +57,72 @@ if (typeof process !== 'undefined' && process.versions?.node) { export class NostrClient { private relays: string[] = []; + private authenticatedRelays: Set = new Set(); constructor(relays: string[]) { this.relays = relays; } + /** + * Handle AUTH challenge from relay and authenticate using NIP-42 + */ + private async handleAuthChallenge(ws: WebSocket, relay: string, challenge: string): Promise { + // Only try to authenticate if NIP-07 is available (browser environment) + if (typeof window === 'undefined' || !isNIP07Available()) { + return false; + } + + try { + const pubkey = await getPublicKeyWithNIP07(); + + // Create auth event (kind 22242) + const authEvent: Omit = { + kind: 22242, + pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: challenge + }; + + // Sign the event (NIP-07 will calculate the ID) + const signedEvent = await signEventWithNIP07(authEvent); + + // Send AUTH response + ws.send(JSON.stringify(['AUTH', signedEvent])); + + // Wait for OK response with timeout + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false); + }, 5000); + + const okHandler = (event: MessageEvent) => { + try { + const message = JSON.parse(event.data); + if (message[0] === 'OK' && message[1] === 'auth') { + clearTimeout(timeout); + ws.removeEventListener('message', okHandler); + if (message[2] === true) { + this.authenticatedRelays.add(relay); + resolve(true); + } else { + logger.warn({ relay, reason: message[3] }, 'AUTH rejected by relay'); + resolve(false); + } + } + } catch { + // Ignore parse errors, continue waiting + } + }; + + ws.addEventListener('message', okHandler); + }); + } catch (error) { + logger.error({ error, relay }, 'Failed to authenticate with relay'); + return false; + } + } + async fetchEvents(filters: NostrFilter[]): Promise { const events: NostrEvent[] = []; @@ -76,18 +151,24 @@ export class NostrClient { // Ensure WebSocket polyfill is initialized await initializeWebSocketPolyfill(); - return new Promise((resolve, reject) => { - const ws = new WebSocket(relay); + return new Promise((resolve) => { + let ws: WebSocket | null = null; const events: NostrEvent[] = []; let resolved = false; let timeoutId: ReturnType | null = null; + let connectionTimeoutId: ReturnType | null = null; + let authHandled = false; const cleanup = () => { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + if (connectionTimeoutId) { + clearTimeout(connectionTimeoutId); + connectionTimeoutId = null; + } + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { try { ws.close(); } catch { @@ -96,34 +177,75 @@ export class NostrClient { } }; - const resolveOnce = (value: NostrEvent[]) => { + const resolveOnce = (value: NostrEvent[] = []) => { if (!resolved) { resolved = true; cleanup(); resolve(value); } }; - - const rejectOnce = (error: Error) => { - if (!resolved) { - resolved = true; - cleanup(); - reject(error); + + let authPromise: Promise | null = null; + + try { + ws = new WebSocket(relay); + } catch (error) { + // Connection failed immediately + resolveOnce([]); + return; + } + + // Connection timeout - if we can't connect within 3 seconds, give up + connectionTimeoutId = setTimeout(() => { + if (!resolved && ws && ws.readyState !== WebSocket.OPEN) { + resolveOnce([]); } - }; + }, 3000); ws.onopen = () => { - try { - ws.send(JSON.stringify(['REQ', 'sub', ...filters])); - } catch (error) { - rejectOnce(error instanceof Error ? error : new Error(String(error))); + if (connectionTimeoutId) { + clearTimeout(connectionTimeoutId); + connectionTimeoutId = null; } + // Connection opened, wait for AUTH challenge or proceed + // If no AUTH challenge comes within 1 second, send REQ + setTimeout(() => { + if (!authHandled && ws && ws.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify(['REQ', 'sub', ...filters])); + } catch { + // Connection might have closed + resolveOnce(events); + } + } + }, 1000); }; - ws.onmessage = (event: MessageEvent) => { + ws.onmessage = async (event: MessageEvent) => { try { const message = JSON.parse(event.data); + // Handle AUTH challenge + if (message[0] === 'AUTH' && message[1] && !authHandled) { + authHandled = true; + authPromise = this.handleAuthChallenge(ws!, relay, message[1]); + const authenticated = await authPromise; + // After authentication, send the REQ + if (ws && ws.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify(['REQ', 'sub', ...filters])); + } catch { + resolveOnce(events); + } + } + return; + } + + // Wait for auth to complete before processing other messages + if (authPromise) { + await authPromise; + } + if (message[0] === 'EVENT') { events.push(message[2]); } else if (message[0] === 'EOSE') { @@ -134,8 +256,12 @@ export class NostrClient { } }; - ws.onerror = (error) => { - rejectOnce(new Error(`WebSocket error for ${relay}: ${error}`)); + ws.onerror = () => { + // Silently handle connection errors - some relays may be down + // Don't log or reject, just resolve with empty results + if (!resolved) { + resolveOnce([]); + } }; ws.onclose = () => { @@ -145,9 +271,10 @@ export class NostrClient { } }; + // Overall timeout - resolve with what we have after 8 seconds timeoutId = setTimeout(() => { resolveOnce(events); - }, 5000); + }, 8000); }); } @@ -175,16 +302,22 @@ export class NostrClient { await initializeWebSocketPolyfill(); return new Promise((resolve, reject) => { - const ws = new WebSocket(relay); + let ws: WebSocket | null = null; let resolved = false; let timeoutId: ReturnType | null = null; + let connectionTimeoutId: ReturnType | null = null; + let authHandled = false; const cleanup = () => { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + if (connectionTimeoutId) { + clearTimeout(connectionTimeoutId); + connectionTimeoutId = null; + } + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { try { ws.close(); } catch { @@ -209,18 +342,65 @@ export class NostrClient { } }; + try { + ws = new WebSocket(relay); + } catch (error) { + rejectOnce(new Error(`Failed to create WebSocket connection to ${relay}`)); + return; + } + + // Connection timeout - if we can't connect within 3 seconds, reject + connectionTimeoutId = setTimeout(() => { + if (!resolved && ws && ws.readyState !== WebSocket.OPEN) { + rejectOnce(new Error(`Connection timeout for ${relay}`)); + } + }, 3000); + + let authPromise: Promise | null = null; + ws.onopen = () => { - try { - ws.send(JSON.stringify(['EVENT', nostrEvent])); - } catch (error) { - rejectOnce(error instanceof Error ? error : new Error(String(error))); + if (connectionTimeoutId) { + clearTimeout(connectionTimeoutId); + connectionTimeoutId = null; } + // Connection opened, wait for AUTH challenge or proceed + // If no AUTH challenge comes within 1 second, send EVENT + setTimeout(() => { + if (!authHandled && ws && ws.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify(['EVENT', nostrEvent])); + } catch (error) { + rejectOnce(error instanceof Error ? error : new Error(String(error))); + } + } + }, 1000); }; - ws.onmessage = (event: MessageEvent) => { + ws.onmessage = async (event: MessageEvent) => { try { const message = JSON.parse(event.data); + // Handle AUTH challenge + if (message[0] === 'AUTH' && message[1] && !authHandled) { + authHandled = true; + authPromise = this.handleAuthChallenge(ws!, relay, message[1]); + await authPromise; + // After authentication attempt, send the EVENT + if (ws && ws.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify(['EVENT', nostrEvent])); + } catch (error) { + rejectOnce(error instanceof Error ? error : new Error(String(error))); + } + } + return; + } + + // Wait for auth to complete before processing other messages + if (authPromise) { + await authPromise; + } + if (message[0] === 'OK' && message[1] === nostrEvent.id) { if (message[2] === true) { resolveOnce(); @@ -233,8 +413,16 @@ export class NostrClient { } }; - ws.onerror = (error) => { - rejectOnce(new Error(`WebSocket error for ${relay}: ${error}`)); + ws.onerror = () => { + // Silently handle connection errors - reject after a short delay + // to allow connection to attempt + if (!resolved) { + setTimeout(() => { + if (!resolved) { + rejectOnce(new Error(`Connection failed for ${relay}`)); + } + }, 100); + } }; ws.onclose = () => { @@ -246,7 +434,7 @@ export class NostrClient { timeoutId = setTimeout(() => { rejectOnce(new Error('Publish timeout')); - }, 5000); + }, 10000); }); } } diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts index 2b999e7..ee7618a 100644 --- a/src/routes/api/search/+server.ts +++ b/src/routes/api/search/+server.ts @@ -15,7 +15,6 @@ import logger from '$lib/services/logger.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); -const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); export const GET: RequestHandler = async ({ url }) => { const query = url.searchParams.get('q'); @@ -31,6 +30,9 @@ export const GET: RequestHandler = async ({ url }) => { } try { + // Create a new client instance for each search to ensure fresh connections + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const results: { repos: Array<{ id: string; name: string; description: string; owner: string; npub: string }>; code: Array<{ repo: string; npub: string; file: string; matches: number }>; diff --git a/src/routes/docs/+page.svelte b/src/routes/docs/+page.svelte index 2a95c8d..e5a6ec5 100644 --- a/src/routes/docs/+page.svelte +++ b/src/routes/docs/+page.svelte @@ -59,11 +59,6 @@