diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index b464fe8..750731f 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -60,3 +60,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771836045,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix repo management and refactor\nimplement more GRASP support"]],"content":"Signed commit: fix repo management and refactor\nimplement more GRASP support","id":"6ae016621b13e22809e7bcebe34e5250fd6e0767d2b12ca634104def4ca78a29","sig":"99c34f66a8a67d352622621536545b7dee11cfd9d14a007ec0550d138109116a2f24483c6836fea59b94b9e96066fba548bcb7600bc55adbe0562d999c3c651d"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771838236,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor repo manager"]],"content":"Signed commit: refactor repo manager","id":"d134c35516991f27e47ed8a4aa0d3f1d6e6be41c46c9cf3f6c982c1442b09b4b","sig":"cb699fae6a8e44a3b9123f215749f6fec0470c75a0401a94c37dfb8e572c07281b3941862e704b868663f943c573ab2ee9fec217e87f7be567cc6bb3514cacdb"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771840654,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"0580e0df8000275817f040bbd6c04dfdfbff08a366df7a1686f227d8b7310053","sig":"9a238266f989c0664dc5c9743675907477e2fcb5311e8edeb505dec97027f619f6dc6742ee5f3887ff6a864274b45005fc7dd4432f8e2772dfe0bb7e2d8a449c"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771840660,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"e96c955f550a94c9c6d1228d2a7e479ced331334aaa4eea84525b362b8484d6e","sig":"1218bd9e449404ccc56c5727e8bdff5db31e37c2053a2d91ba02d214c0988173ba480010e53401661cb439884308a575230a7a12124f8e6d8f058c8a804a42f6"} diff --git a/src/lib/config.ts b/src/lib/config.ts index a63d10b..1c2bdb8 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -34,13 +34,18 @@ export const DEFAULT_NOSTR_SEARCH_RELAYS = typeof process !== 'undefined' && process.env?.NOSTR_SEARCH_RELAYS ? process.env.NOSTR_SEARCH_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0) : [ - 'wss://theforest.nostr1.com', 'wss://nostr.land', 'wss://relay.damus.io', 'wss://thecitadel.nostr1.com', 'wss://nostr21.com', + 'wss://theforest.nostr1.com', + 'wss://freelay.sovbit.host', + 'wss://nostr.sovbit.host', + 'wss://bevos.nostr1.com', 'wss://relay.primal.net', - + 'wss://nostr.mom', + 'wss://relay.snort.social', + 'wss://aggr.nostr.land', ]; /** diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 275a991..bc54ac0 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -1,12 +1,12 @@ /** * Nostr client for fetching and publishing events + * Uses nostr-tools Pool for relay connection management */ import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; import logger from '../logger.js'; import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js'; -import { shouldUseTor, getTorProxy } from '../../utils/tor.js'; -// Removed separate in-memory cache - persistent cache now has built-in memory layer +import { SimplePool, type Filter } from 'nostr-tools'; import { KIND } from '../../types/nostr.js'; // Replaceable event kinds (only latest per pubkey matters) @@ -14,11 +14,25 @@ const REPLACEABLE_KINDS = [0, 3, 10002]; // Profile, Contacts, Relay List /** * Check if an event is a parameterized replaceable event (NIP-33) - * Parameterized replaceable events have kind >= 10000 && kind < 20000 and a 'd' tag + * Parameterized replaceable events have: + * - kind >= 10000 && kind < 20000 (replaceable range) with a 'd' tag, OR + * - kind >= 30000 && kind < 40000 (addressable range) with a 'd' tag */ function isParameterizedReplaceable(event: NostrEvent): boolean { - return event.kind >= 10000 && event.kind < 20000 && - event.tags.some(t => t[0] === 'd' && t[1]); + const hasDTag = event.tags.some(t => t[0] === 'd' && t[1]); + if (!hasDTag) return false; + + // Replaceable range (NIP-33) + if (event.kind >= 10000 && event.kind < 20000) { + return true; + } + + // Addressable range (NIP-34) - also parameterized replaceable + if (event.kind >= 30000 && event.kind < 40000) { + return true; + } + + return false; } /** @@ -109,299 +123,34 @@ if (typeof process !== 'undefined' && process.versions?.node && typeof window == }); } -/** - * Create a WebSocket connection, optionally through Tor SOCKS proxy - */ -async function createWebSocketWithTor(url: string): Promise { - await initializeWebSocketPolyfill(); - - // Check if we need Tor - if (!shouldUseTor(url)) { - return new WebSocket(url); - } - - // Only use Tor in Node.js environment - if (typeof process === 'undefined' || !process.versions?.node || typeof window !== 'undefined') { - // Browser environment - can't use SOCKS proxy directly - // Fall back to regular WebSocket (will fail for .onion in browser) - logger.warn({ url }, 'Tor support not available in browser. .onion addresses may not work.'); - return new WebSocket(url); - } - - const proxy = getTorProxy(); - if (!proxy) { - logger.warn({ url }, 'Tor proxy not configured. Cannot connect to .onion address.'); - return new WebSocket(url); - } - - try { - // Dynamic import for SOCKS support - const { SocksClient } = await import('socks'); - const { WebSocket: WS } = await import('ws'); - - // Parse the WebSocket URL - const wsUrl = new URL(url); - const host = wsUrl.hostname; - const port = wsUrl.port ? parseInt(wsUrl.port, 10) : (wsUrl.protocol === 'wss:' ? 443 : 80); - - // Create SOCKS connection - const socksOptions = { - proxy: { - host: proxy.host, - port: proxy.port, - type: 5 as const // SOCKS5 - }, - command: 'connect' as const, - destination: { - host, - port - } - }; - - const info = await SocksClient.createConnection(socksOptions); - - // Create WebSocket over the SOCKS connection - // socket option is supported at runtime but not in types - const ws = new WS(url, { - socket: info.socket, - // For wss://, we need to handle TLS - rejectUnauthorized: false // .onion addresses use self-signed certs - } as any); - - return ws as any as WebSocket; - } catch (error) { - logger.error({ error, url, proxy }, 'Failed to create WebSocket through Tor'); - // Fall back to regular connection (will likely fail for .onion) - return new WebSocket(url); - } -} - -// Connection pool for WebSocket connections -interface RelayConnection { - ws: WebSocket; - lastUsed: number; - pendingRequests: number; - reconnectAttempts: number; - messageHandlers: Map void>; // subscription ID -> handler - nextSubscriptionId: number; -} +// Note: SimplePool from nostr-tools handles WebSocket connections automatically +// Tor support would require custom WebSocket factory, which SimplePool doesn't easily support +// For now, we rely on SimplePool's built-in connection management export class NostrClient { private relays: string[] = []; + private pool: SimplePool; private authenticatedRelays: Set = new Set(); private processingDeletions: boolean = false; // Guard to prevent recursive deletion processing - private connectionPool: Map = new Map(); - private readonly CONNECTION_TIMEOUT = 30000; // Close idle connections after 30 seconds - private readonly MAX_RECONNECT_ATTEMPTS = 3; - private readonly RECONNECT_DELAY = 2000; // 2 seconds between reconnect attempts - private connectionAttempts: Map = new Map(); - private readonly MAX_CONCURRENT_CONNECTIONS = 3; // Max concurrent connections per relay - private readonly CONNECTION_BACKOFF_BASE = 1000; // Base backoff in ms - private readonly THROTTLE_RESET_TIME = 5 * 60 * 1000; // Reset throttling after 5 minutes constructor(relays: string[]) { this.relays = relays; - // Clean up idle connections periodically - if (typeof window !== 'undefined') { - setInterval(() => this.cleanupIdleConnections(), 10000); // Check every 10 seconds - } - } - - /** - * Clean up idle connections that haven't been used recently - */ - private cleanupIdleConnections(): void { - const now = Date.now(); - for (const [relay, conn] of this.connectionPool.entries()) { - // Close connections that are idle and have no pending requests - if (conn.pendingRequests === 0 && - now - conn.lastUsed > this.CONNECTION_TIMEOUT && - (conn.ws.readyState === WebSocket.OPEN || conn.ws.readyState === WebSocket.CLOSED)) { - try { - if (conn.ws.readyState === WebSocket.OPEN) { - conn.ws.close(); - } - } catch { - // Ignore errors - } - this.connectionPool.delete(relay); - } - } - } - - /** - * Get or create a WebSocket connection to a relay - * @param relay - The relay URL - * @param isReadOperation - If true, this is a read operation (like search) that can bypass throttling more easily - */ - private async getConnection(relay: string, isReadOperation: boolean = false): Promise { - const existing = this.connectionPool.get(relay); - - // Reuse existing connection if it's open - if (existing && existing.ws.readyState === WebSocket.OPEN) { - existing.lastUsed = Date.now(); - existing.pendingRequests++; - return existing.ws; - } - - // Check connection attempt throttling - const attemptInfo = this.connectionAttempts.get(relay) || { count: 0, lastAttempt: 0 }; - const now = Date.now(); - const timeSinceLastAttempt = now - attemptInfo.lastAttempt; - - // Reset throttling if enough time has passed (relays may have recovered) - if (attemptInfo.count > 0 && timeSinceLastAttempt > this.THROTTLE_RESET_TIME) { - logger.debug({ relay, timeSinceLastAttempt }, 'Resetting throttling - enough time has passed'); - this.connectionAttempts.set(relay, { count: 0, lastAttempt: now }); - } - - // If we've had too many recent failures, apply exponential backoff - // For read operations, use less aggressive throttling (half the backoff time) - if (attemptInfo.count > 0) { - const backoffMultiplier = isReadOperation ? 0.5 : 1.0; - const backoffTime = this.CONNECTION_BACKOFF_BASE * Math.pow(2, Math.min(attemptInfo.count - 1, 5)) * backoffMultiplier; - - if (timeSinceLastAttempt < backoffTime) { - const waitTime = backoffTime - timeSinceLastAttempt; - - // For read operations, be more lenient - allow longer waits or bypass if we have cached data - const maxWaitTime = isReadOperation ? 10000 : 5000; // 10s for reads, 5s for writes - - if (waitTime <= maxWaitTime) { - logger.debug({ relay, backoffTime, timeSinceLastAttempt, waitTime, isReadOperation }, 'Throttling connection attempt - waiting for backoff'); - await new Promise(resolve => setTimeout(resolve, waitTime)); - // After waiting, check if connection is now available - const existingAfterWait = this.connectionPool.get(relay); - if (existingAfterWait && (existingAfterWait.ws.readyState === WebSocket.OPEN || existingAfterWait.ws.readyState === WebSocket.CONNECTING)) { - existingAfterWait.pendingRequests++; - return existingAfterWait.ws; - } - // Continue to create new connection after backoff - } else { - // Backoff is too long, return null to avoid long waits - // For read operations, we might still want to try (if we have no cached data) - // but for now, we'll be conservative and return null - logger.debug({ relay, waitTime, maxWaitTime, isReadOperation }, 'Backoff too long, skipping connection attempt'); - return null; - } - } - } - - // Check if we have too many concurrent connections to this relay - const openConnections = Array.from(this.connectionPool.values()) - .filter(c => c.ws === existing?.ws || (c.ws.readyState === WebSocket.OPEN || c.ws.readyState === WebSocket.CONNECTING)) - .length; - - if (openConnections >= this.MAX_CONCURRENT_CONNECTIONS) { - logger.debug({ relay, openConnections }, 'Too many concurrent connections, skipping'); - return null; - } - - // Remove dead connection - if (existing) { - this.connectionPool.delete(relay); - try { - if (existing.ws.readyState !== WebSocket.CLOSED) { - existing.ws.close(); - } - } catch { - // Ignore errors - } - } - - // Update attempt tracking - this.connectionAttempts.set(relay, { count: attemptInfo.count + 1, lastAttempt: now }); - - // Create new connection - try { - const ws = await createWebSocketWithTor(relay); - const conn: RelayConnection = { - ws, - lastUsed: Date.now(), - pendingRequests: 1, - reconnectAttempts: 0, - messageHandlers: new Map(), - nextSubscriptionId: 1 - }; - - // Set up shared message handler for routing - ws.onmessage = (event: MessageEvent) => { - try { - const message = JSON.parse(event.data); - - // Route to appropriate handler based on message type - if (message[0] === 'EVENT' && message[1]) { - // message[1] is the subscription ID - const handler = conn.messageHandlers.get(message[1]); - if (handler) { - handler(message); - } - } else if (message[0] === 'EOSE' && message[1]) { - // message[1] is the subscription ID - const handler = conn.messageHandlers.get(message[1]); - if (handler) { - handler(message); - } - } else if (message[0] === 'AUTH') { - // AUTH challenge - broadcast to all handlers (they'll handle it) - for (const handler of conn.messageHandlers.values()) { - handler(message); - } - } else if (message[0] === 'OK' && message[1] === 'auth') { - // AUTH response - broadcast to all handlers - for (const handler of conn.messageHandlers.values()) { - handler(message); - } - } - } catch (error) { - // Ignore parse errors - } - }; - - // Handle connection close/error - ws.onclose = () => { - // Remove from pool when closed - const poolConn = this.connectionPool.get(relay); - if (poolConn && poolConn.ws === ws) { - this.connectionPool.delete(relay); - } - }; - - ws.onerror = () => { - // Remove from pool on error - const poolConn = this.connectionPool.get(relay); - if (poolConn && poolConn.ws === ws) { - this.connectionPool.delete(relay); - } - }; - - this.connectionPool.set(relay, conn); - - // Reset attempt count on successful connection - ws.onopen = () => { - this.connectionAttempts.set(relay, { count: 0, lastAttempt: Date.now() }); - }; - - return ws; - } catch (error) { - logger.debug({ error, relay }, 'Failed to create WebSocket connection'); - return null; - } + // Use nostr-tools SimplePool for relay connection management + // SimplePool handles all WebSocket connections, retries, and error handling automatically + this.pool = new SimplePool(); } /** - * Release a connection (decrement pending requests counter) + * Clean up pool connections when done */ - private releaseConnection(relay: string): void { - const conn = this.connectionPool.get(relay); - if (conn) { - conn.pendingRequests = Math.max(0, conn.pendingRequests - 1); - conn.lastUsed = Date.now(); - } + close(): void { + this.pool.close(this.relays); } /** * Handle AUTH challenge from relay and authenticate using NIP-42 + * Note: SimplePool doesn't expose WebSocket directly, so AUTH handling + * may need to be done differently. For now, this is kept for compatibility. */ private async handleAuthChallenge(ws: WebSocket, relay: string, challenge: string): Promise { // Only try to authenticate if NIP-07 is available (browser environment) @@ -516,14 +265,23 @@ export class NostrClient { private async fetchAndMergeFromRelays(filters: NostrFilter[], existingEvents: NostrEvent[]): Promise { const events: NostrEvent[] = []; - // Fetch from all relays in parallel - const promises = this.relays.map(relay => this.fetchFromRelay(relay, filters)); - const results = await Promise.allSettled(promises); - - for (const result of results) { - if (result.status === 'fulfilled') { - events.push(...result.value); + // Use nostr-tools SimplePool to fetch from all relays in parallel + // SimplePool handles connection management, retries, and error handling automatically + try { + // querySync takes a single filter, so we query each filter and combine results + const queryPromises = filters.map(filter => + this.pool.querySync(this.relays, filter as Filter, { maxWait: 8000 }) + ); + const results = await Promise.allSettled(queryPromises); + + for (const result of results) { + if (result.status === 'fulfilled') { + events.push(...result.value); + } } + } catch (err) { + logger.debug({ error: err, filters }, 'Pool querySync failed'); + // Continue with empty events - will use cached events } // Merge with existing events - handle replaceable and parameterized replaceable events @@ -563,18 +321,8 @@ export class NostrClient { } } - // Remove events that should be deleted - for (const eventId of eventsToDelete) { - eventMap.delete(eventId); // Remove by ID if it was keyed by ID - // Also remove from map if it's keyed by deduplication key - for (const [key, event] of eventMap.entries()) { - if (event.id === eventId) { - eventMap.delete(key); - break; - } - } - } - + // eventMap already contains only the latest events per deduplication key + // No need to remove from eventMap - the merge logic above already handles that const finalEvents = Array.from(eventMap.values()); // Sort by created_at descending @@ -607,7 +355,7 @@ export class NostrClient { // Cache the merged results (skip cache for search queries) const hasSearchQuery = filters.some(f => f.search && f.search.trim().length > 0); if (!hasSearchQuery) { - if (finalEvents.length > 0 || results.some(r => r.status === 'fulfilled')) { + if (finalEvents.length > 0 || events.length > 0) { // Cache successful fetches for 5 minutes, empty results for 1 minute const ttl = finalEvents.length > 0 ? 5 * 60 * 1000 : 60 * 1000; @@ -658,18 +406,16 @@ export class NostrClient { const since = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000); const events: NostrEvent[] = []; - // Fetch from all relays in parallel, bypassing cache to avoid recursion - const promises = this.relays.map(relay => this.fetchFromRelay(relay, [{ - kinds: [KIND.DELETION_REQUEST], - since, - limit: 100 - }])); - const results = await Promise.allSettled(promises); - - for (const result of results) { - if (result.status === 'fulfilled') { - events.push(...result.value); - } + // Fetch from all relays in parallel using SimplePool, bypassing cache to avoid recursion + try { + const relayEvents = await this.pool.querySync(this.relays, { + kinds: [KIND.DELETION_REQUEST], + since, + limit: 100 + } as Filter, { maxWait: 8000 }); + events.push(...relayEvents); + } catch (err) { + logger.debug({ error: err }, 'Failed to fetch deletion events from pool'); } // Deduplicate deletion events by ID @@ -696,248 +442,26 @@ export class NostrClient { } } - private async fetchFromRelay(relay: string, filters: NostrFilter[]): Promise { - // Ensure WebSocket polyfill is initialized - await initializeWebSocketPolyfill(); - - const self = this; - 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; - let isNewConnection = false; - - const cleanup = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - if (connectionTimeoutId) { - clearTimeout(connectionTimeoutId); - connectionTimeoutId = null; - } - // Only close if it's a new connection we created (not from pool) - // Pool connections are managed separately - if (isNewConnection && ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { - try { - ws.close(); - } catch { - // Ignore errors during cleanup - } - } else { - // Release connection back to pool - self.releaseConnection(relay); - } - }; - - const resolveOnce = (value: NostrEvent[] = []) => { - if (!resolved) { - resolved = true; - cleanup(); - resolve(value); - } - }; - - let authPromise: Promise | null = null; - - // Get connection from pool or create new one - // fetchFromRelay is always a read operation, so pass isReadOperation: true - this.getConnection(relay, true).then(websocket => { - if (!websocket) { - resolveOnce([]); - return; - } - ws = websocket; - isNewConnection = false; // From pool - setupWebSocketHandlers(); - }).catch(error => { - // Connection failed, try creating new one - createWebSocketWithTor(relay).then(websocket => { - ws = websocket; - isNewConnection = true; // New connection - setupWebSocketHandlers(); - }).catch(err => { - // Connection failed immediately - resolveOnce([]); - }); - }); - - function setupWebSocketHandlers() { - if (!ws) return; - - const conn = self.connectionPool.get(relay); - if (!conn) { - resolveOnce([]); - return; - } - - // Get unique subscription ID for this request - const subscriptionId = `sub${conn.nextSubscriptionId++}`; - - // Connection timeout - if we can't connect within 3 seconds, give up - connectionTimeoutId = setTimeout(() => { - if (!resolved && ws && ws.readyState !== WebSocket.OPEN) { - conn.messageHandlers.delete(subscriptionId); - resolveOnce([]); - } - }, 3000); - - // Set up message handler for this subscription - const messageHandler = async (message: any) => { - try { - // Handle AUTH challenge - if (message[0] === 'AUTH' && message[1] && !authHandled) { - authHandled = true; - authPromise = self.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', subscriptionId, ...filters])); - } catch { - conn.messageHandlers.delete(subscriptionId); - resolveOnce(events); - } - } - return; - } - - // Handle AUTH OK response - if (message[0] === 'OK' && message[1] === 'auth' && ws) { - // AUTH completed, send REQ if not already sent - if (ws.readyState === WebSocket.OPEN && !authHandled) { - setTimeout(() => { - if (ws && ws.readyState === WebSocket.OPEN) { - try { - ws.send(JSON.stringify(['REQ', subscriptionId, ...filters])); - } catch { - conn.messageHandlers.delete(subscriptionId); - resolveOnce(events); - } - } - }, 100); - } - return; - } - - // Wait for auth to complete before processing other messages - if (authPromise) { - await authPromise; - } - - // Only process messages for this subscription - if (message[1] === subscriptionId) { - if (message[0] === 'EVENT') { - events.push(message[2]); - } else if (message[0] === 'EOSE') { - conn.messageHandlers.delete(subscriptionId); - resolveOnce(events); - } - } - } catch (error) { - // Ignore parse errors, continue receiving events - } - }; - - conn.messageHandlers.set(subscriptionId, messageHandler); - - // If connection is already open, send REQ immediately - if (ws.readyState === WebSocket.OPEN) { - // Wait a bit for AUTH challenge if needed - setTimeout(() => { - if (!authHandled && ws && ws.readyState === WebSocket.OPEN) { - try { - ws.send(JSON.stringify(['REQ', subscriptionId, ...filters])); - } catch { - conn.messageHandlers.delete(subscriptionId); - resolveOnce(events); - } - } - }, 1000); - } else { - // Wait for connection to open - ws.onopen = () => { - 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', subscriptionId, ...filters])); - } catch { - conn.messageHandlers.delete(subscriptionId); - resolveOnce(events); - } - } - }, 1000); - }; - } - - // Error and close handlers are set on the connection itself - // But we need to clean up our handler - if (ws) { - const wsRef = ws; // Capture for closure - const originalOnError = ws.onerror; - ws.onerror = () => { - conn.messageHandlers.delete(subscriptionId); - if (originalOnError) { - // Create an Event-like object for Node.js compatibility - const errorEvent = typeof Event !== 'undefined' - ? new Event('error') - : ({ type: 'error', target: wsRef } as unknown as Event); - originalOnError.call(wsRef, errorEvent); - } - if (!resolved) { - resolveOnce([]); - } - }; - - const originalOnClose = ws.onclose; - ws.onclose = () => { - conn.messageHandlers.delete(subscriptionId); - if (originalOnClose) { - // Create a CloseEvent-like object for Node.js compatibility - const closeEvent = typeof CloseEvent !== 'undefined' - ? new CloseEvent('close') - : ({ type: 'close', code: 1000, reason: '', wasClean: true } as unknown as CloseEvent); - originalOnClose.call(wsRef, closeEvent); - } - // If we haven't resolved yet, resolve with what we have - if (!resolved) { - resolveOnce(events); - } - }; - } - - // Overall timeout - resolve with what we have after 8 seconds - timeoutId = setTimeout(() => { - resolveOnce(events); - }, 8000); - } - }); - } async publishEvent(event: NostrEvent, relays?: string[]): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }> }> { const targetRelays = relays || this.relays; const success: string[] = []; const failed: Array<{ relay: string; error: string }> = []; - const promises = targetRelays.map(async (relay) => { - try { - await this.publishToRelay(relay, event); - success.push(relay); - } catch (error) { + // Use nostr-tools SimplePool to publish to all relays + try { + await this.pool.publish(targetRelays, event); + + // If publish succeeded, all relays succeeded + // Note: SimplePool.publish doesn't return per-relay results, so we assume all succeeded + success.push(...targetRelays); + } catch (error) { + // If publish failed, mark all as failed + // In a more sophisticated implementation, we could check individual relays + targetRelays.forEach(relay => { failed.push({ relay, error: String(error) }); - } - }); - - await Promise.allSettled(promises); + }); + } // Invalidate cache for events from this pubkey (new event published) // This ensures fresh data on next fetch @@ -956,150 +480,4 @@ export class NostrClient { return { success, failed }; } - private async publishToRelay(relay: string, nostrEvent: NostrEvent): Promise { - // Ensure WebSocket polyfill is initialized - await initializeWebSocketPolyfill(); - - const self = this; - return new Promise((resolve, reject) => { - 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 (connectionTimeoutId) { - clearTimeout(connectionTimeoutId); - connectionTimeoutId = null; - } - if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { - try { - ws.close(); - } catch { - // Ignore errors during cleanup - } - } - }; - - const resolveOnce = () => { - if (!resolved) { - resolved = true; - cleanup(); - resolve(); - } - }; - - const rejectOnce = (error: Error) => { - if (!resolved) { - resolved = true; - cleanup(); - reject(error); - } - }; - - let authPromise: Promise | null = null; - - // Create WebSocket connection (with Tor support if needed) - createWebSocketWithTor(relay).then(websocket => { - ws = websocket; - setupWebSocketHandlers(); - }).catch(error => { - rejectOnce(new Error(`Failed to create WebSocket connection to ${relay}: ${error}`)); - }); - - function setupWebSocketHandlers() { - if (!ws) 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); - - ws.onopen = () => { - 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 = async (event: MessageEvent) => { - try { - const message = JSON.parse(event.data); - - // Handle AUTH challenge - if (message[0] === 'AUTH' && message[1] && !authHandled) { - authHandled = true; - authPromise = self.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(); - } else { - rejectOnce(new Error(message[3] || 'Publish rejected')); - } - } - } catch (error) { - // Ignore parse errors, continue waiting for OK message - } - }; - - 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 = () => { - // If we haven't resolved yet, it's an unexpected close - if (!resolved) { - rejectOnce(new Error('WebSocket closed unexpectedly')); - } - }; - - timeoutId = setTimeout(() => { - rejectOnce(new Error('Publish timeout')); - }, 10000); - } - }); - } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 4ee23a6..9b9a897 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -180,12 +180,19 @@ async function checkPendingTransfers(userPubkeyHex: string) { try { + // Add timeout to prevent hanging + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + const response = await fetch('/api/transfers/pending', { headers: { 'X-User-Pubkey': userPubkeyHex - } + }, + signal: controller.signal }); + clearTimeout(timeoutId); + if (response.ok) { const data = await response.json(); if (data.pendingTransfers && data.pendingTransfers.length > 0) { @@ -196,7 +203,11 @@ } } } catch (err) { - console.error('Failed to check for pending transfers:', err); + // Only log if it's not an abort (timeout) + if (err instanceof Error && err.name !== 'AbortError') { + console.error('Failed to check for pending transfers:', err); + } + // Silently ignore timeouts - they're expected if the server is slow } } diff --git a/src/routes/api/repos/[npub]/[repo]/file/+server.ts b/src/routes/api/repos/[npub]/[repo]/file/+server.ts index b001854..a100d73 100644 --- a/src/routes/api/repos/[npub]/[repo]/file/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/file/+server.ts @@ -79,6 +79,7 @@ export const GET: RequestHandler = async (event) => { const fileContent = await tryApiFetchFile(announcement, npub, repo, filePath, ref); if (fileContent && fileContent.content) { + logger.debug({ npub, repo, filePath, ref }, 'Successfully fetched file via API fallback'); return json(fileContent); } } catch (apiErr) { @@ -87,7 +88,16 @@ export const GET: RequestHandler = async (event) => { } // API fetch failed - repo is not cloned and API fetch didn't work - return error(404, 'Repository is not cloned locally and could not fetch file via API. Privileged users can clone this repository using the "Clone to Server" button.'); + // Check if announcement has clone URLs to provide better error message + const { extractCloneUrls } = await import('$lib/utils/nostr-utils.js'); + const cloneUrls = extractCloneUrls(announcement); + const hasCloneUrls = cloneUrls.length > 0; + + logger.debug({ npub, repo, filePath, hasCloneUrls, cloneUrlCount: cloneUrls.length }, 'API fallback failed or no clone URLs available'); + + return error(404, hasCloneUrls + ? 'Repository is not cloned locally and could not fetch file via API. Privileged users can clone this repository using the "Clone to Server" button.' + : 'Repository is not cloned locally and has no external clone URLs for API fallback. Privileged users can clone this repository using the "Clone to Server" button.'); } else { return error(404, 'Repository announcement not found in Nostr'); } diff --git a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts index 2a3b755..f2e3d34 100644 --- a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts @@ -37,6 +37,7 @@ export const GET: RequestHandler = createRepoGetHandler( const apiData = await tryApiFetch(announcement, context.npub, context.repo); if (apiData && apiData.files) { + logger.debug({ npub: context.npub, repo: context.repo, fileCount: apiData.files.length }, 'Successfully fetched files via API fallback'); // Return API data directly without cloning const path = context.path || ''; // Filter files by path if specified @@ -92,8 +93,17 @@ export const GET: RequestHandler = createRepoGetHandler( } // API fetch failed - repo is not cloned and API fetch didn't work + // Check if announcement has clone URLs to provide better error message + const { extractCloneUrls } = await import('$lib/utils/nostr-utils.js'); + const cloneUrls = extractCloneUrls(announcement); + const hasCloneUrls = cloneUrls.length > 0; + + logger.debug({ npub: context.npub, repo: context.repo, hasCloneUrls, cloneUrlCount: cloneUrls.length }, 'API fallback failed or no clone URLs available'); + throw handleNotFoundError( - 'Repository is not cloned locally and could not be fetched via API. Privileged users can clone this repository using the "Clone to Server" button.', + hasCloneUrls + ? 'Repository is not cloned locally and could not be fetched via API. Privileged users can clone this repository using the "Clone to Server" button.' + : 'Repository is not cloned locally and has no external clone URLs for API fallback. Privileged users can clone this repository using the "Clone to Server" button.', { operation: 'listFiles', npub: context.npub, repo: context.repo } ); } else { diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts index a334f92..d451d06 100644 --- a/src/routes/api/search/+server.ts +++ b/src/routes/api/search/+server.ts @@ -1,12 +1,13 @@ /** - * API endpoint for searching repositories and code + * API endpoint for searching repositories + * Cache-first with parallel relay queries (normal filters + NIP-50) */ -import { json, error } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; -import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js'; +import { DEFAULT_NOSTR_SEARCH_RELAYS } from '$lib/config.js'; import { KIND } from '$lib/types/nostr.js'; import type { NostrEvent, NostrFilter } from '$lib/types/nostr.js'; import { nip19 } from 'nostr-tools'; @@ -15,10 +16,9 @@ import { extractRequestContext } from '$lib/utils/api-context.js'; import { resolvePubkey } from '$lib/utils/pubkey-resolver.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { eventCache } from '$lib/services/nostr/event-cache.js'; +import { decodeNostrAddress } from '$lib/services/nostr/nip19-utils.js'; import logger from '$lib/services/logger.js'; -// MaintainerService will be created with all available relays per request - export const GET: RequestHandler = async (event) => { const query = event.url.searchParams.get('q'); const limit = parseInt(event.url.searchParams.get('limit') || '20', 10); @@ -36,17 +36,13 @@ export const GET: RequestHandler = async (event) => { } try { - // Collect all available relays + // Collect all available relays - prioritize DEFAULT_NOSTR_SEARCH_RELAYS const allRelays = new Set(); - - // Add default search relays DEFAULT_NOSTR_SEARCH_RELAYS.forEach(relay => allRelays.add(relay)); - DEFAULT_NOSTR_RELAYS.forEach(relay => allRelays.add(relay)); // Add user's relays if logged in if (userPubkey) { try { - // Create a temporary client to fetch user relays const tempClient = new NostrClient(Array.from(allRelays)); const userRelays = await getUserRelays(userPubkey, tempClient); userRelays.inbox.forEach(relay => allRelays.add(relay)); @@ -56,641 +52,521 @@ export const GET: RequestHandler = async (event) => { } } - const relays = Array.from(allRelays); + const relays = [ + ...DEFAULT_NOSTR_SEARCH_RELAYS, + ...Array.from(allRelays).filter(r => !DEFAULT_NOSTR_SEARCH_RELAYS.includes(r)) + ]; + logger.info({ relayCount: relays.length, - relays: relays.slice(0, 5), // Log first 5 relays - query: query.trim().substring(0, 50), // Log first 50 chars of query + query: query.trim().substring(0, 50), hasUserPubkey: !!userPubkey - }, 'Starting search with relays'); + }, 'Starting search'); - // Create client with all available relays + // Create client with all available relays (no throttling for read operations) const nostrClient = new NostrClient(relays); - - // Create maintainer service with all available relays const maintainerService = new MaintainerService(relays); - const results: { - repos: Array<{ - id: string; - name: string; - description: string; - owner: string; - npub: string; - repoId?: string; // The actual repo ID (d-tag) from the announcement - maintainers?: Array<{ pubkey: string; isOwner: boolean }>; - announcement?: any; // Full announcement event - }>; - } = { - repos: [] - }; - - // Check if query is a URL (clone URL search) - const isUrl = (str: string): boolean => { - const trimmed = str.trim(); - return trimmed.startsWith('http://') || - trimmed.startsWith('https://') || - trimmed.startsWith('git://') || - trimmed.startsWith('ssh://') || - trimmed.includes('.git') || - (trimmed.includes('://') && trimmed.includes('/')); - }; - - const queryIsUrl = isUrl(query.trim()); - - // Check if query is a pubkey (hex, npub, nprofile, or NIP-05) - const resolvedPubkey = await resolvePubkey(query.trim()); - - // Helper function to fetch events with cache-first strategy - async function fetchEventsWithCache(filters: NostrFilter[]): Promise { - // Check cache first - const cachedEvents = eventCache.get(filters); - if (cachedEvents && cachedEvents.length > 0) { - logger.debug({ filters, cachedCount: cachedEvents.length }, 'Returning cached events for search'); - - // Return cached events immediately, fetch from relays in background - nostrClient.fetchEvents(filters).then(freshEvents => { - // Only update cache if we got results (don't replace with empty results from failed fetches) - if (freshEvents.length > 0) { - // Merge fresh events with cached ones (deduplicate by event ID) - const eventMap = new Map(); - cachedEvents.forEach(e => eventMap.set(e.id, e)); - freshEvents.forEach(e => { - const existing = eventMap.get(e.id); - if (!existing || e.created_at > existing.created_at) { - eventMap.set(e.id, e); - } - }); - - const mergedEvents = Array.from(eventMap.values()); - // Update cache with merged results - eventCache.set(filters, mergedEvents); - logger.debug({ filters, mergedCount: mergedEvents.length }, 'Updated cache with fresh events'); - } else { - logger.debug({ filters }, 'Background fetch returned no events (relays may be throttled), keeping cached data'); - } - }).catch(err => { - logger.debug({ error: err, filters }, 'Background fetch failed, using cached events'); - }); - - return cachedEvents; - } - - // No cache, fetch from relays with timeout - try { - const freshEvents = await Promise.race([ - nostrClient.fetchEvents(filters), - new Promise((resolve) => { - setTimeout(() => { - logger.warn({ filters, relayCount: relays.length }, 'Fetch timeout - relays may be throttled or slow'); - resolve([]); // Return empty array on timeout - }, 10000); // 10 second timeout for search - }) - ]); - - // Cache the results only if we got some - if (freshEvents.length > 0) { - eventCache.set(filters, freshEvents); - logger.debug({ filters, fetchedCount: freshEvents.length }, 'Fetched and cached events from relays'); - } else { - logger.warn({ filters, relayCount: relays.length }, 'No events fetched from relays - may be throttled or unavailable'); - } - return freshEvents; - } catch (err) { - logger.warn({ error: err, filters, relayCount: relays.length }, 'Failed to fetch events from relays'); - return []; // Return empty array on error + // Step 1: Check cache and return results immediately + const cacheKey = [{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 1000 }]; + const cachedRepos = eventCache.get(cacheKey) || []; + + logger.debug({ cachedReposCount: cachedRepos.length, query: query.trim().substring(0, 50) }, 'Cache check'); + + // Normalize query for searching + const queryLower = query.trim().toLowerCase(); + const queryTrimmed = query.trim(); + + // Try to resolve query as various identifiers + let resolvedPubkey: string | null = null; + let decodedAddress: ReturnType | null = null; + + // Try to resolve as pubkey (npub, nprofile, hex, nip-05) + try { + resolvedPubkey = await resolvePubkey(queryTrimmed); + if (resolvedPubkey) { + logger.debug({ query: queryTrimmed.substring(0, 50), resolvedPubkey: resolvedPubkey.substring(0, 16) + '...' }, 'Resolved query as pubkey'); } + } catch { + // Not a pubkey, continue } - let events: NostrEvent[] = []; + // Try to decode as naddr, nevent, note1 + decodedAddress = decodeNostrAddress(queryTrimmed); + if (decodedAddress) { + logger.debug({ query: queryTrimmed.substring(0, 50), decodedType: decodedAddress.type }, 'Decoded query as address'); + } - if (queryIsUrl) { - // Search for repos by clone URL - logger.debug({ query: query.trim() }, 'Searching for repos by clone URL'); - - // Normalize the URL for matching (remove trailing .git, trailing slash, etc.) - const normalizeUrl = (url: string): string => { - let normalized = url.trim().toLowerCase(); - // Remove trailing .git - if (normalized.endsWith('.git')) { - normalized = normalized.slice(0, -4); - } - // Remove trailing slash - normalized = normalized.replace(/\/$/, ''); - // Remove protocol for more flexible matching - normalized = normalized.replace(/^(https?|git|ssh):\/\//, ''); - return normalized; - }; - - const normalizedQuery = normalizeUrl(query.trim()); - - // Fetch all repos with cache-first strategy - let allRepos: NostrEvent[] = []; - try { - allRepos = await fetchEventsWithCache([ - { - kinds: [KIND.REPO_ANNOUNCEMENT], - limit: 1000 // Get more to find URL matches - } - ]); - - // If we got no results and cache was empty, log a warning - if (allRepos.length === 0) { - logger.warn({ query: query.trim(), relayCount: relays.length }, 'No repos found for URL search - relays may be throttled or unavailable'); - } - } catch (err) { - logger.warn({ error: err, query: query.trim() }, 'Failed to fetch repos for URL search'); - allRepos = []; + // Filter cached repos based on query + const cachedResults = filterRepos(cachedRepos, queryTrimmed, queryLower, resolvedPubkey, decodedAddress); + logger.debug({ cachedResultsCount: cachedResults.length, cachedReposCount: cachedRepos.length }, 'Cached results filtered'); + + // Step 2 & 3: Fetch from relays in parallel (normal filters + NIP-50) with 10s timeout + // Start relay fetch in background - don't wait for it, return cached results immediately + logger.debug({ query: queryTrimmed.substring(0, 50), relayCount: relays.length }, 'Starting relay fetch in background'); + const relayFetchPromise = Promise.race([ + fetchFromRelays(nostrClient, queryTrimmed, queryLower, resolvedPubkey, decodedAddress, limit).then(result => { + logger.debug({ + query: queryTrimmed.substring(0, 50), + filteredCount: result.filtered.length, + allReposCount: result.allRepos.length + }, 'Relay fetch completed'); + return result; + }), + new Promise<{ filtered: NostrEvent[]; allRepos: NostrEvent[] }>((resolve) => { + setTimeout(() => { + logger.debug({ query: queryTrimmed.substring(0, 50) }, 'Relay fetch timeout (10s)'); + resolve({ filtered: [], allRepos: [] }); + }, 10000); // Increased to 10s to allow querySync to complete + }) + ]).catch(err => { + logger.debug({ error: err, query: queryTrimmed.substring(0, 50) }, 'Relay fetch error'); + return { filtered: [], allRepos: [] }; + }); + + // Wait for relay results (already has timeout built in) + let relayResults: NostrEvent[] = []; + let allRelayRepos: NostrEvent[] = []; + + try { + const relayResult = await relayFetchPromise; + relayResults = relayResult.filtered; + allRelayRepos = relayResult.allRepos; + } catch (err) { + logger.debug({ error: err }, 'Failed to get relay results'); + } + + // Step 4 & 5: Deduplicate results (cached + relay) + const allResults = new Map(); + + // Add cached results first + cachedResults.forEach(r => allResults.set(r.id, r)); + + // Add relay results (prefer newer events) + relayResults.forEach(r => { + const existing = allResults.get(r.id); + if (!existing || r.created_at > existing.created_at) { + allResults.set(r.id, r); } - - // Filter for repos that have a matching clone URL - events = allRepos.filter(event => { - for (const tag of event.tags) { - if (tag[0] === 'clone') { - for (let i = 1; i < tag.length; i++) { - const cloneUrl = tag[i]; - if (!cloneUrl || typeof cloneUrl !== 'string') continue; - - const normalizedCloneUrl = normalizeUrl(cloneUrl); - - // Check if the normalized query matches the normalized clone URL - // Support partial matches (e.g., "example.com/repo" matches "https://example.com/user/repo.git") - if (normalizedCloneUrl.includes(normalizedQuery) || normalizedQuery.includes(normalizedCloneUrl)) { - return true; - } - } - } + }); + + // Step 6: Update cache with ALL repos found from relays (not just filtered ones) + // This ensures everything discovered during the search is cached for future use + if (allRelayRepos.length > 0) { + const repoMap = new Map(); + // Start with cached repos + cachedRepos.forEach(r => repoMap.set(r.id, r)); + // Add ALL repos found from relays (prefer newer events) + allRelayRepos.forEach(r => { + const existing = repoMap.get(r.id); + if (!existing || r.created_at > existing.created_at) { + repoMap.set(r.id, r); + } + }); + // Update cache with merged results + eventCache.set(cacheKey, Array.from(repoMap.values())); + logger.debug({ + cachedCount: cachedRepos.length, + relayReposCount: allRelayRepos.length, + filteredCount: relayResults.length, + finalCacheCount: repoMap.size + }, 'Cache updated with all repos found from relays'); + } else if (relayResults.length > 0) { + // Fallback: if we only have filtered results, cache those + const repoMap = new Map(); + cachedRepos.forEach(r => repoMap.set(r.id, r)); + relayResults.forEach(r => { + const existing = repoMap.get(r.id); + if (!existing || r.created_at > existing.created_at) { + repoMap.set(r.id, r); } - return false; }); + eventCache.set(cacheKey, Array.from(repoMap.values())); + logger.debug({ + cachedCount: cachedRepos.length, + filteredCount: relayResults.length, + finalCacheCount: repoMap.size + }, 'Cache updated with filtered relay results (no unfiltered repos available)'); + } + + const mergedResults = Array.from(allResults.values()); + + logger.debug({ + cachedCount: cachedResults.length, + relayCount: relayResults.length, + mergedCount: mergedResults.length + }, 'Search results merged'); + + // Step 6: Process results with privacy filtering + const results: Array<{ + id: string; + name: string; + description: string; + owner: string; + npub: string; + repoId?: string; + maintainers?: Array<{ pubkey: string; isOwner: boolean }>; + announcement?: NostrEvent; + }> = []; + + for (const event of mergedResults.slice(0, limit * 2)) { // Get more to filter by privacy + const repoId = event.tags.find(t => t[0] === 'd')?.[1]; + if (!repoId) continue; - } else if (resolvedPubkey) { - // Search for repos by owner or maintainer pubkey - logger.debug({ query: query.trim(), resolvedPubkey }, 'Searching for repos by pubkey'); + // Check privacy + const isPrivate = event.tags.some(t => + (t[0] === 'private' && t[1] === 'true') || + (t[0] === 't' && t[1] === 'private') + ); - // Fetch repos where this pubkey is the owner (cache-first) - let ownerEvents: NostrEvent[] = []; - try { - ownerEvents = await fetchEventsWithCache([ - { - kinds: [KIND.REPO_ANNOUNCEMENT], - authors: [resolvedPubkey], - limit: limit * 2 - } - ]); - } catch (err) { - logger.warn({ error: err, resolvedPubkey }, 'Failed to fetch owner repos for pubkey search'); - ownerEvents = []; - } + // Check if user can view + let canView = !isPrivate; // Public repos are viewable by anyone - // Fetch repos where this pubkey is a maintainer (cache-first) - // We need to fetch all repos and filter by maintainer tags - let allRepos: NostrEvent[] = []; - try { - allRepos = await fetchEventsWithCache([ - { - kinds: [KIND.REPO_ANNOUNCEMENT], - limit: 1000 // Get more to find maintainer matches - } - ]); - - // If we got no results, log a warning - if (allRepos.length === 0) { - logger.warn({ resolvedPubkey, relayCount: relays.length }, 'No repos found for maintainer search - relays may be throttled or unavailable'); + if (isPrivate && userPubkey) { + try { + canView = await maintainerService.canView(userPubkey, event.pubkey, repoId); + } catch (err) { + logger.debug({ error: err }, 'Failed to check repo access'); + canView = false; } - } catch (err) { - logger.warn({ error: err, resolvedPubkey }, 'Failed to fetch repos for maintainer search'); - allRepos = []; } - // Filter for repos where resolvedPubkey is in maintainers tag - const maintainerEvents = allRepos.filter(event => { - for (const tag of event.tags) { - if (tag[0] === 'maintainers') { - for (let i = 1; i < tag.length; i++) { - const maintainer = tag[i]; - if (!maintainer || typeof maintainer !== 'string') continue; - - // Maintainer can be npub or hex pubkey - let maintainerPubkey = maintainer; - try { - const decoded = nip19.decode(maintainer); - if (decoded.type === 'npub') { - maintainerPubkey = decoded.data as string; - } - } catch { - // Assume it's already a hex pubkey - } - - if (maintainerPubkey.toLowerCase() === resolvedPubkey.toLowerCase()) { - return true; - } - } - } - } - return false; - }); - - // Combine owner and maintainer events, deduplicate by event ID - const eventMap = new Map(); - ownerEvents.forEach(e => eventMap.set(e.id, e)); - maintainerEvents.forEach(e => eventMap.set(e.id, e)); - events = Array.from(eventMap.values()); - - } else { - // Regular text search using NIP-50 - const searchQuery = query.trim().toLowerCase(); + if (!canView) continue; - // For text search, we'll use cache-first for all repos, then filter - // This allows us to leverage cache while still supporting NIP-50 - let allReposForTextSearch: NostrEvent[] = []; + const name = event.tags.find(t => t[0] === 'name')?.[1] || repoId; + const description = event.tags.find(t => t[0] === 'description')?.[1] || ''; - // Check cache first for all repo announcements - const cachedAllRepos = eventCache.get([{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 1000 }]); - if (cachedAllRepos && cachedAllRepos.length > 0) { - logger.debug({ cachedCount: cachedAllRepos.length }, 'Using cached repos for text search'); - allReposForTextSearch = cachedAllRepos; + try { + const npub = nip19.npubEncode(event.pubkey); - // Fetch fresh data in background (don't wait, use cached data immediately) - nostrClient.fetchEvents([{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 1000 }]).then(freshRepos => { - // Only update cache if we got results (don't replace with empty results) - if (freshRepos.length > 0) { - // Merge and update cache - const eventMap = new Map(); - cachedAllRepos.forEach(e => eventMap.set(e.id, e)); - freshRepos.forEach(e => { - const existing = eventMap.get(e.id); - if (!existing || e.created_at > existing.created_at) { - eventMap.set(e.id, e); - } - }); - const merged = Array.from(eventMap.values()); - eventCache.set([{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 1000 }], merged); - logger.debug({ mergedCount: merged.length, freshCount: freshRepos.length }, 'Updated cache with fresh repos'); - } - }).catch(err => { - logger.debug({ error: err }, 'Background fetch failed for text search, using cached data'); - }); - } else { - // No cache, try to fetch all repos - logger.debug({ relayCount: relays.length }, 'No cache available, fetching repos from relays'); + // Get maintainers + let allMaintainers: Array<{ pubkey: string; isOwner: boolean }> = []; try { - allReposForTextSearch = await nostrClient.fetchEvents([ - { kinds: [KIND.REPO_ANNOUNCEMENT], limit: 1000 } - ]); + const { maintainers, owner } = await maintainerService.getMaintainers(event.pubkey, repoId); + const ownerLower = owner.toLowerCase(); + const seenPubkeys = new Set(); - // Only cache if we got results - if (allReposForTextSearch.length > 0) { - eventCache.set([{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 1000 }], allReposForTextSearch); - logger.debug({ fetchedCount: allReposForTextSearch.length }, 'Fetched and cached repos from relays'); - } else { - logger.warn({ relayCount: relays.length }, 'No repos fetched from relays - all relays may be throttled or unavailable'); + for (const maintainer of maintainers) { + const maintainerLower = maintainer.toLowerCase(); + if (seenPubkeys.has(maintainerLower)) continue; + seenPubkeys.add(maintainerLower); + allMaintainers.push({ + pubkey: maintainer, + isOwner: maintainerLower === ownerLower + }); } + + allMaintainers.sort((a, b) => { + if (a.isOwner && !b.isOwner) return -1; + if (!a.isOwner && b.isOwner) return 1; + return 0; + }); } catch (err) { - logger.warn({ error: err, relayCount: relays.length }, 'Failed to fetch repos from relays'); - allReposForTextSearch = []; // Empty array if fetch fails + logger.debug({ error: err }, 'Failed to fetch maintainers'); + allMaintainers = [{ pubkey: event.pubkey, isOwner: true }]; } - } - - try { - // Try NIP-50 search for fresh results (bypass cache for NIP-50) - const searchFilter = { - kinds: [KIND.REPO_ANNOUNCEMENT], - search: query.trim(), // NIP-50: Search field - use trimmed query - limit: limit * 2 // Get more results to account for different relay implementations - }; - // Fetch NIP-50 results in background (don't wait) - const nip50Promise = nostrClient.fetchEvents([searchFilter]).then(nip50Events => { - // Merge NIP-50 results with cached repos - const eventMap = new Map(); - allReposForTextSearch.forEach(e => eventMap.set(e.id, e)); - nip50Events.forEach(e => { - const existing = eventMap.get(e.id); - if (!existing || e.created_at > existing.created_at) { - eventMap.set(e.id, e); - } - }); - return Array.from(eventMap.values()); + results.push({ + id: event.id, + name, + description, + owner: event.pubkey, + npub, + repoId, + maintainers: allMaintainers, + announcement: event }); - - // Filter cached repos immediately for fast results - const searchLower = searchQuery; - events = allReposForTextSearch.filter(event => { - const name = event.tags.find(t => t[0] === 'name')?.[1] || ''; - const description = event.tags.find(t => t[0] === 'description')?.[1] || ''; - const repoId = event.tags.find(t => t[0] === 'd')?.[1] || ''; - const content = event.content || ''; - - return name.toLowerCase().includes(searchLower) || - description.toLowerCase().includes(searchLower) || - repoId.toLowerCase().includes(searchLower) || - content.toLowerCase().includes(searchLower); - }); - - // Merge NIP-50 results when available (in background) - nip50Promise.then(mergedEvents => { - // Update events with NIP-50 results if they're better - const eventMap = new Map(); - events.forEach(e => eventMap.set(e.id, e)); - mergedEvents.forEach(e => { - const existing = eventMap.get(e.id); - if (!existing || e.created_at > existing.created_at) { - eventMap.set(e.id, e); - } - }); - // Note: We can't update the events array here since it's already being processed - // The next search will benefit from the updated cache - }).catch(err => { - logger.debug({ error: err }, 'NIP-50 search failed, using cached results'); - }); - - // If NIP-50 returned results, verify they actually match the query - // Some relays might not properly implement NIP-50 search - if (events.length > 0) { - const searchLower = searchQuery; - events = events.filter(event => { - const name = event.tags.find(t => t[0] === 'name')?.[1] || ''; - const description = event.tags.find(t => t[0] === 'description')?.[1] || ''; - const repoId = event.tags.find(t => t[0] === 'd')?.[1] || ''; - const content = event.content || ''; + } catch { + // Skip if npub encoding fails + } + } + + // Limit results + const limitedResults = results.slice(0, limit); + + // Determine if we're showing cached results (relays still being checked) + const fromCache = cachedResults.length > 0 && relayResults.length === 0; + + logger.info({ + query: queryTrimmed.substring(0, 50), + resultCount: limitedResults.length, + totalMatches: mergedResults.length, + fromCache + }, 'Search completed'); + + return json({ + query: queryTrimmed, + results: { + repos: limitedResults + }, + total: limitedResults.length, + fromCache: fromCache // Indicate if results are from cache (relays may still be checking) + }); + } catch (err) { + return handleApiError(err, { operation: 'search', query }, 'Failed to search'); + } +}; - return name.toLowerCase().includes(searchLower) || - description.toLowerCase().includes(searchLower) || - repoId.toLowerCase().includes(searchLower) || - content.toLowerCase().includes(searchLower); - }); +/** + * Fetch from relays using normal filters and NIP-50 search in parallel + * Returns both filtered results and all unfiltered repos for caching + */ +async function fetchFromRelays( + nostrClient: NostrClient, + queryTrimmed: string, + queryLower: string, + resolvedPubkey: string | null, + decodedAddress: ReturnType | null, + limit: number +): Promise<{ filtered: NostrEvent[]; allRepos: NostrEvent[] }> { + const filters: NostrFilter[] = []; + + // Build normal filters based on query type + if (resolvedPubkey) { + // Search by owner pubkey + filters.push({ + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [resolvedPubkey], + limit: limit * 2 + }); + } + + if (decodedAddress?.type === 'naddr') { + // Search by naddr (repo address) + const naddrPubkey = decodedAddress.pubkey; + const naddrIdentifier = decodedAddress.identifier; + if (naddrPubkey && naddrIdentifier) { + filters.push({ + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [naddrPubkey], + '#d': [naddrIdentifier], + limit: limit * 2 + }); + } + } + + if (decodedAddress?.type === 'note' || decodedAddress?.type === 'nevent') { + // Search by event ID + const eventId = decodedAddress.id; + if (eventId) { + filters.push({ + kinds: [KIND.REPO_ANNOUNCEMENT], + ids: [eventId], + limit: 1 + }); + } + } + + // Check if it's a hex event ID + if (/^[a-f0-9]{64}$/i.test(queryTrimmed)) { + filters.push({ + kinds: [KIND.REPO_ANNOUNCEMENT], + ids: [queryTrimmed.toLowerCase()], + limit: 1 + }); + } + + // Check if query looks like a d-tag (repo name) + if (/^[a-zA-Z0-9_-]+$/.test(queryTrimmed)) { + filters.push({ + kinds: [KIND.REPO_ANNOUNCEMENT], + '#d': [queryTrimmed], + limit: limit * 2 + }); + } + + // Always fetch all repos for text-based filtering (if not a specific identifier) + const isSpecificIdentifier = resolvedPubkey !== null || decodedAddress !== null || /^[a-f0-9]{64}$/i.test(queryTrimmed); + if (!isSpecificIdentifier) { + // Fetch all repos for client-side filtering + filters.push({ + kinds: [KIND.REPO_ANNOUNCEMENT], + limit: 1000 + }); + } + + // Add NIP-50 search filter (for text queries) + if (!isSpecificIdentifier) { + filters.push({ + kinds: [KIND.REPO_ANNOUNCEMENT], + search: queryTrimmed, + limit: limit * 2 + }); + } + + // Fetch from all filters in parallel (with error handling) + logger.debug({ filterCount: filters.length, query: queryTrimmed.substring(0, 50) }, 'Fetching from relays with filters'); + const fetchPromises = filters.map((filter, index) => + nostrClient.fetchEvents([filter]) + .catch(err => { + logger.debug({ error: err, filterIndex: index, filter }, 'Failed to fetch events for filter'); + return [] as NostrEvent[]; // Return empty array on error + }) + ); + const resultsArrays = await Promise.allSettled(fetchPromises); + + // Merge and deduplicate by event ID (all repos fetched) + const allReposMap = new Map(); + for (const result of resultsArrays) { + if (result.status === 'fulfilled') { + const results = result.value; + for (const event of results) { + const existing = allReposMap.get(event.id); + if (!existing || event.created_at > existing.created_at) { + allReposMap.set(event.id, event); } - - // NIP-50 search succeeded - } catch (nip50Error) { - // Fallback to manual filtering if NIP-50 fails or isn't supported - - const allEvents = await nostrClient.fetchEvents([ - { - kinds: [KIND.REPO_ANNOUNCEMENT], - limit: 500 // Get more events for manual filtering - } - ]); - - const searchLower = searchQuery; - events = allEvents.filter(event => { - const name = event.tags.find(t => t[0] === 'name')?.[1] || ''; - const description = event.tags.find(t => t[0] === 'description')?.[1] || ''; - const repoId = event.tags.find(t => t[0] === 'd')?.[1] || ''; - const content = event.content || ''; - - return name.toLowerCase().includes(searchLower) || - description.toLowerCase().includes(searchLower) || - repoId.toLowerCase().includes(searchLower) || - content.toLowerCase().includes(searchLower); - }); } + } else { + logger.debug({ error: result.reason }, 'Filter fetch rejected'); } + } + + const allRepos = Array.from(allReposMap.values()); + + // Filter results based on query (for text-based searches) + let filteredResults = allRepos; + + if (!isSpecificIdentifier) { + // Apply client-side filtering for text queries + filteredResults = filterRepos(allRepos, queryTrimmed, queryLower, resolvedPubkey, decodedAddress); + } + + return { filtered: filteredResults, allRepos }; +} + +/** + * Filter repos based on query + */ +function filterRepos( + repos: NostrEvent[], + queryTrimmed: string, + queryLower: string, + resolvedPubkey: string | null, + decodedAddress: ReturnType | null +): NostrEvent[] { + return repos.filter(event => { + // Extract repo fields + const repoId = event.tags.find(t => t[0] === 'd')?.[1] || ''; + const name = event.tags.find(t => t[0] === 'name')?.[1] || ''; + const title = event.tags.find(t => t[0] === 'title')?.[1] || ''; + const description = event.tags.find(t => t[0] === 'description')?.[1] || ''; + const summary = event.tags.find(t => t[0] === 'summary')?.[1] || ''; + const content = event.content || ''; - // Process events into results with privacy filtering - const searchLower = query.trim().toLowerCase(); - - // Check if this is a pubkey search and if the resolved pubkey matches the logged-in user - const isSearchingOwnPubkey = resolvedPubkey && userPubkey && - resolvedPubkey.toLowerCase() === userPubkey.toLowerCase(); + // Check if query matches event ID (hex or note1) + if (decodedAddress?.type === 'note' || decodedAddress?.type === 'nevent') { + const eventId = decodedAddress.id; + if (eventId && event.id.toLowerCase() === eventId.toLowerCase()) { + return true; + } + } - // Map to track repo relationships for sorting - const repoRelationships = new Map; - }>(); + // Check if query matches hex event ID + if (/^[a-f0-9]{64}$/i.test(queryTrimmed) && event.id.toLowerCase() === queryTrimmed.toLowerCase()) { + return true; + } - // Pre-fetch maintainers and bookmarks for all repos (batch processing) - const bookmarkChecks = new Map>(); - if (userPubkey) { - const { BookmarksService } = await import('$lib/services/nostr/bookmarks-service.js'); - const bookmarksService = new BookmarksService(relays); - - for (const event of events) { - const repoId = event.tags.find(t => t[0] === 'd')?.[1]; - if (!repoId) continue; - - const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${event.pubkey}:${repoId}`; - bookmarkChecks.set(event.id, bookmarksService.isBookmarked(userPubkey, repoAddress)); + // Check if query matches naddr (repo address) + if (decodedAddress?.type === 'naddr') { + const naddrPubkey = decodedAddress.pubkey; + const naddrIdentifier = decodedAddress.identifier; + if (naddrPubkey && naddrIdentifier && + event.pubkey.toLowerCase() === naddrPubkey.toLowerCase() && + repoId.toLowerCase() === naddrIdentifier.toLowerCase()) { + return true; } } - for (const event of events) { - const repoId = event.tags.find(t => t[0] === 'd')?.[1]; - if (!repoId) continue; - - // Check privacy - const isPrivate = event.tags.some(t => - (t[0] === 'private' && t[1] === 'true') || - (t[0] === 't' && t[1] === 'private') - ); - - // Check if user can view this repo - let canView = false; - if (!isPrivate) { - canView = true; // Public repos are viewable by anyone - } else { - // Private repos require authentication - - // Special case: if searching by pubkey and the resolved pubkey matches the logged-in user, - // show all their repos (public and private) regardless of who owns them - if (isSearchingOwnPubkey && resolvedPubkey) { - // Check if the logged-in user is the owner or maintainer of this repo - try { - // Check if user is owner (event.pubkey matches resolvedPubkey) - if (event.pubkey.toLowerCase() === resolvedPubkey.toLowerCase()) { - canView = true; // User owns this repo - } else { - // Check if user is a maintainer - const { maintainers } = await maintainerService.getMaintainers(event.pubkey, repoId); - if (maintainers.some(m => m.toLowerCase() === resolvedPubkey.toLowerCase())) { - canView = true; // User is a maintainer - } - } - } catch (err) { - logger.warn({ error: err, pubkey: event.pubkey, repo: repoId }, 'Failed to check maintainer status in pubkey search'); - canView = false; - } - } else if (userPubkey) { - // Regular privacy check: check if logged-in user owns, maintains, or has bookmarked - try { - // Check if user is owner or maintainer - canView = await maintainerService.canView(userPubkey, event.pubkey, repoId); - - // If not owner/maintainer, check if user has bookmarked it - if (!canView) { - const { BookmarksService } = await import('$lib/services/nostr/bookmarks-service.js'); - const bookmarksService = new BookmarksService(relays); - const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${event.pubkey}:${repoId}`; - canView = await bookmarksService.isBookmarked(userPubkey, repoAddress); - } - } catch (err) { - logger.warn({ error: err, pubkey: event.pubkey, repo: repoId }, 'Failed to check repo access in search'); - canView = false; - } - } - // If no userPubkey and repo is private, canView remains false - } - - // Only include repos the user can view - if (!canView) continue; - - const name = event.tags.find(t => t[0] === 'name')?.[1] || ''; - const description = event.tags.find(t => t[0] === 'description')?.[1] || ''; - - try { - const npub = nip19.npubEncode(event.pubkey); - - // Determine relationship to user - const isOwned = !!(userPubkey && event.pubkey.toLowerCase() === userPubkey.toLowerCase()); - let isMaintained = false; - let allMaintainers: Array<{ pubkey: string; isOwner: boolean }> = []; - - // Fetch maintainers for this repo - try { - const { maintainers, owner } = await maintainerService.getMaintainers(event.pubkey, repoId); - - // Build maintainers list with owner flag, owner first - // The maintainers array from getMaintainers always includes the owner as the first element - // Use a Set to track which pubkeys we've already added (case-insensitive) - const seenPubkeys = new Set(); - const ownerLower = owner.toLowerCase(); - - // Build the list: owner first, then other maintainers - allMaintainers = []; + // Check if query matches owner pubkey + if (resolvedPubkey && event.pubkey.toLowerCase() === resolvedPubkey.toLowerCase()) { + return true; + } + + // Check if query matches maintainer + if (resolvedPubkey) { + for (const tag of event.tags) { + if (tag[0] === 'maintainers') { + for (let i = 1; i < tag.length; i++) { + const maintainer = tag[i]; + if (!maintainer || typeof maintainer !== 'string') continue; - // Process all maintainers, marking owner and deduplicating - for (const maintainer of maintainers) { - const maintainerLower = maintainer.toLowerCase(); - - // Skip if we've already added this pubkey (case-insensitive check) - if (seenPubkeys.has(maintainerLower)) { - continue; + let maintainerPubkey = maintainer; + try { + const decoded = nip19.decode(maintainer); + if (decoded.type === 'npub') { + maintainerPubkey = decoded.data as string; } - - // Mark as seen - seenPubkeys.add(maintainerLower); - - // Determine if this is the owner - const isOwner = maintainerLower === ownerLower; - - // Add to list - allMaintainers.push({ - pubkey: maintainer, - isOwner - }); + } catch { + // Assume hex } - // Sort: owner first, then other maintainers - allMaintainers.sort((a, b) => { - if (a.isOwner && !b.isOwner) return -1; - if (!a.isOwner && b.isOwner) return 1; - return 0; - }); - - // Ensure owner is always included (in case they weren't in maintainers list) - const hasOwner = allMaintainers.some(m => m.pubkey.toLowerCase() === ownerLower); - if (!hasOwner) { - allMaintainers.unshift({ pubkey: owner, isOwner: true }); + if (maintainerPubkey.toLowerCase() === resolvedPubkey.toLowerCase()) { + return true; } - - // Check if user is a maintainer (but not owner, since we already checked that) - if (userPubkey && !isOwned) { - isMaintained = maintainers.some(m => m.toLowerCase() === userPubkey.toLowerCase()); - } - } catch (err) { - logger.warn({ error: err, pubkey: event.pubkey, repo: repoId }, 'Failed to fetch maintainers for search result'); - // Fallback: just use owner - allMaintainers = [{ pubkey: event.pubkey, isOwner: true }]; } - - // Check if bookmarked - let isBookmarked = false; - if (userPubkey && bookmarkChecks.has(event.id)) { - const bookmarkCheck = bookmarkChecks.get(event.id); - if (bookmarkCheck) { - isBookmarked = await bookmarkCheck; - } - } - - // Store relationship for sorting - repoRelationships.set(event.id, { - isOwned, - isMaintained, - isBookmarked, - maintainers: allMaintainers - }); - - results.repos.push({ - id: event.id, - name: name || repoId, - description: description || '', - owner: event.pubkey, - npub, - repoId, // Include the actual repo ID (d-tag) for proper matching - maintainers: allMaintainers, - announcement: event // Include the full announcement event - }); - } catch { - // Skip if npub encoding fails } } - - // Sort by user relationship priority, then by relevance - // Priority: owned > maintained > bookmarked > others - // Within each group, sort by relevance (name matches first, then description) - results.repos.sort((a, b) => { - const aRel = repoRelationships.get(a.id) || { isOwned: false, isMaintained: false, isBookmarked: false }; - const bRel = repoRelationships.get(b.id) || { isOwned: false, isMaintained: false, isBookmarked: false }; - - // Priority 1: Owned repos first - if (aRel.isOwned && !bRel.isOwned) return -1; - if (!aRel.isOwned && bRel.isOwned) return 1; - - // Priority 2: Maintained repos (but not owned) - if (!aRel.isOwned && !bRel.isOwned) { - if (aRel.isMaintained && !bRel.isMaintained) return -1; - if (!aRel.isMaintained && bRel.isMaintained) return 1; + } + + // Check d-tag (repo ID) - exact match first, then partial + if (repoId) { + if (repoId.toLowerCase() === queryLower) { + return true; // Exact d-tag match + } + if (repoId.toLowerCase().includes(queryLower)) { + return true; // Partial d-tag match + } + } + + // Check text fields: name, title, description, summary, content + if (name.toLowerCase().includes(queryLower) || + title.toLowerCase().includes(queryLower) || + description.toLowerCase().includes(queryLower) || + summary.toLowerCase().includes(queryLower) || + content.toLowerCase().includes(queryLower)) { + return true; + } + + // Check t-tags (topics) - exact match first, then partial + for (const tag of event.tags) { + if (tag[0] === 't' && tag[1]) { + const topic = tag[1].toLowerCase(); + if (topic === queryLower) { + return true; // Exact t-tag match } - - // Priority 3: Bookmarked repos (but not owned or maintained) - if (!aRel.isOwned && !aRel.isMaintained && !bRel.isOwned && !bRel.isMaintained) { - if (aRel.isBookmarked && !bRel.isBookmarked) return -1; - if (!aRel.isBookmarked && bRel.isBookmarked) return 1; + if (topic.includes(queryLower)) { + return true; // Partial t-tag match } - - // Priority 4: Relevance (name matches first, then description) - const aNameMatch = a.name.toLowerCase().includes(searchLower); - const bNameMatch = b.name.toLowerCase().includes(searchLower); - if (aNameMatch && !bNameMatch) return -1; - if (!aNameMatch && bNameMatch) return 1; - - const aDescMatch = a.description.toLowerCase().includes(searchLower); - const bDescMatch = b.description.toLowerCase().includes(searchLower); - if (aDescMatch && !bDescMatch) return -1; - if (!aDescMatch && bDescMatch) return 1; - - return 0; - }); - - results.repos = results.repos.slice(0, limit); - - logger.info({ - query: query.trim().substring(0, 50), - resultCount: results.repos.length, - total: results.repos.length, - relayCount: relays.length - }, 'Search completed'); - - return json({ - query, - results, - total: results.repos.length - }); - } catch (err) { - return handleApiError(err, { operation: 'search', query }, 'Failed to search'); - } -}; + } + } + + // Check other common tags + // Check 'r' tags (references, including earliest unique commit) + for (const tag of event.tags) { + if (tag[0] === 'r' && tag[1] && tag[1].toLowerCase().includes(queryLower)) { + return true; + } + } + + // Check 'web' tags (website URLs) + for (const tag of event.tags) { + if (tag[0] === 'web' && tag[1] && tag[1].toLowerCase().includes(queryLower)) { + return true; + } + } + + // Check clone URLs + for (const tag of event.tags) { + if (tag[0] === 'clone') { + for (let i = 1; i < tag.length; i++) { + const cloneUrl = tag[i]; + if (cloneUrl && typeof cloneUrl === 'string' && cloneUrl.toLowerCase().includes(queryLower)) { + return true; + } + } + } + } + + return false; + }); +} diff --git a/src/routes/api/transfers/pending/+server.ts b/src/routes/api/transfers/pending/+server.ts index 21aede7..75e1f1e 100644 --- a/src/routes/api/transfers/pending/+server.ts +++ b/src/routes/api/transfers/pending/+server.ts @@ -22,8 +22,22 @@ export const GET: RequestHandler = async ({ request }) => { } try { - // Get user's relays for comprehensive search - const { inbox, outbox } = await getUserRelays(userPubkeyHex, nostrClient); + // Get user's relays for comprehensive search (with timeout) + let inbox: string[] = []; + let outbox: string[] = []; + try { + const userRelaysResult = await Promise.race([ + getUserRelays(userPubkeyHex, nostrClient), + new Promise<{ inbox: string[]; outbox: string[] }>((resolve) => { + setTimeout(() => resolve({ inbox: [], outbox: [] }), 3000); // 3s timeout for user relays + }) + ]); + inbox = userRelaysResult.inbox; + outbox = userRelaysResult.outbox; + } catch (err) { + logger.debug({ error: err, userPubkeyHex }, 'Failed to get user relays, using defaults'); + } + // Combine user relays with default and search relays const userRelays = [...inbox, ...outbox]; const allRelays = [...new Set([...userRelays, ...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]; @@ -32,13 +46,21 @@ export const GET: RequestHandler = async ({ request }) => { const { NostrClient } = await import('$lib/services/nostr/nostr-client.js'); const searchClient = new NostrClient(allRelays); - // Search for transfer events where this user is the new owner (p tag) - const transferEvents = await searchClient.fetchEvents([ - { - kinds: [KIND.OWNERSHIP_TRANSFER], - '#p': [userPubkeyHex], - limit: 100 - } + // Search for transfer events where this user is the new owner (p tag) (with timeout) + const transferEvents = await Promise.race([ + searchClient.fetchEvents([ + { + kinds: [KIND.OWNERSHIP_TRANSFER], + '#p': [userPubkeyHex], + limit: 100 + } + ]), + new Promise((resolve) => { + setTimeout(() => { + logger.debug({ userPubkeyHex }, 'Transfer events fetch timeout (5s)'); + resolve([]); + }, 5000); // 5s timeout + }) ]); // Filter for valid, non-self-transfer events that haven't been completed diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 2f84ec7..33e7774 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -2814,23 +2814,26 @@ } } } else if (response.status === 404) { - // Check if this is a "not cloned" error with API fallback suggestion + // Check if this is a "not cloned" error - API fallback might be available const errorText = await response.text().catch(() => ''); - if (errorText.includes('not cloned locally') && errorText.includes('API')) { - // API fallback might be available, but this specific request failed - // Try to detect if API fallback works by checking if we have clone URLs + if (errorText.includes('not cloned locally')) { + // Repository is not cloned - check if API fallback might be available if (pageData.repoCloneUrls && pageData.repoCloneUrls.length > 0) { // We have clone URLs, so API fallback might work - mark as unknown for now // It will be set to true if a subsequent request succeeds apiFallbackAvailable = null; + // Don't set repoNotFound or error yet - allow API fallback to be attempted } else { + // No clone URLs, API fallback won't work + repoNotFound = true; apiFallbackAvailable = false; + error = errorText || `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; } } else { - // Repository not provisioned yet - set error message and flag + // Generic 404 - repository doesn't exist repoNotFound = true; - error = `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; apiFallbackAvailable = false; + error = `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; } } else if (response.status === 403) { // Access denied - don't set repoNotFound, allow retry after login @@ -2874,20 +2877,28 @@ if (!response.ok) { if (response.status === 404) { - // Check if this is a "not cloned" error with API fallback suggestion + // Check if this is a "not cloned" error - API fallback might be available const errorText = await response.text().catch(() => ''); - if (errorText.includes('not cloned locally') && errorText.includes('API')) { - // API fallback might be available, but this specific request failed + if (errorText.includes('not cloned locally')) { + // Repository is not cloned - check if API fallback might be available if (pageData.repoCloneUrls && pageData.repoCloneUrls.length > 0) { - apiFallbackAvailable = null; // Unknown, will be set if a request succeeds + // We have clone URLs, so API fallback might work - mark as unknown for now + // It will be set to true if a subsequent request succeeds + apiFallbackAvailable = null; + // Don't set repoNotFound - allow API fallback to be attempted } else { + // No clone URLs, API fallback won't work + repoNotFound = true; apiFallbackAvailable = false; } + // Throw error but use the actual error text from the API + throw new Error(errorText || 'Repository not found. This repository exists in Nostr but hasn\'t been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.'); } else { + // Generic 404 - repository doesn't exist repoNotFound = true; apiFallbackAvailable = false; + throw new Error(`Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`); } - throw new Error(`Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`); } else if (response.status === 403) { // 403 means access denied - don't set repoNotFound, just show error // This allows retry after login