Browse Source

make search more robust

imwald
Silberengel 4 months ago
parent
commit
f632240050
  1. 96
      src/components/Embedded/EmbeddedNote.tsx
  2. 103
      src/pages/secondary/NotePage/NotFound.tsx
  3. 192
      src/services/client.service.ts

96
src/components/Embedded/EmbeddedNote.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { Skeleton } from '@/components/ui/skeleton'
import { BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { useFetchEvent } from '@/hooks'
import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
@ -106,30 +106,36 @@ function EmbeddedNoteNotFound({ @@ -106,30 +106,36 @@ function EmbeddedNoteNotFound({
// Calculate which external relays would be tried
useEffect(() => {
const getExternalRelays = async () => {
let relays: string[] = []
// Get all relays that have already been tried (BIG_RELAY_URLS + FAST_READ_RELAY_URLS)
// These are the relays used in the initial fetch
const alreadyTriedRelaysSet = new Set<string>()
;[...BIG_RELAY_URLS, ...FAST_READ_RELAY_URLS].forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) alreadyTriedRelaysSet.add(normalized)
})
let hintRelays: string[] = []
let extractedHexEventId: string | null = null
// Parse relay hints and author from bech32 ID
if (!/^[0-9a-f]{64}$/.test(noteId)) {
try {
const { type, data } = nip19.decode(noteId)
if (type === 'nevent') {
extractedHexEventId = data.id
if (data.relays) relays.push(...data.relays)
if (data.relays) hintRelays.push(...data.relays)
if (data.author) {
const authorRelayList = await client.fetchRelayList(data.author)
relays.push(...authorRelayList.write.slice(0, 6))
hintRelays.push(...authorRelayList.write.slice(0, 6))
}
} else if (type === 'naddr') {
if (data.relays) relays.push(...data.relays)
if (data.relays) hintRelays.push(...data.relays)
const authorRelayList = await client.fetchRelayList(data.pubkey)
relays.push(...authorRelayList.write.slice(0, 6))
hintRelays.push(...authorRelayList.write.slice(0, 6))
} else if (type === 'note') {
extractedHexEventId = data
}
// Normalize and deduplicate relays
relays = relays.map(url => normalizeUrl(url) || url)
relays = Array.from(new Set(relays))
} catch (err) {
logger.error('Failed to parse external relays', { error: err, noteId })
}
@ -139,25 +145,40 @@ function EmbeddedNoteNotFound({ @@ -139,25 +145,40 @@ function EmbeddedNoteNotFound({
setHexEventId(extractedHexEventId)
// Get relays where this event was seen
const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : []
relays.push(...seenOn)
hintRelays.push(...seenOn)
// Normalize all relays first
let normalizedRelays = relays.map(url => normalizeUrl(url) || url).filter(Boolean)
normalizedRelays = Array.from(new Set(normalizedRelays))
// Normalize all hint relays
const normalizedHints = hintRelays
.map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url))
// If no external relays from hints, try SEARCHABLE_RELAY_URLS as fallback
// Filter out relays that overlap with BIG_RELAY_URLS (already tried first)
if (normalizedRelays.length === 0) {
const searchableRelays = SEARCHABLE_RELAY_URLS
.map(url => normalizeUrl(url) || url)
.filter((url): url is string => Boolean(url))
.filter(relay => !BIG_RELAY_URLS.includes(relay))
normalizedRelays.push(...searchableRelays)
}
// Combine hints with SEARCHABLE_RELAY_URLS (always include as fallback)
// Normalize SEARCHABLE_RELAY_URLS for comparison
const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS
.map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url))
// Combine all potential relays (hints + searchable)
const allPotentialRelays = new Set([...normalizedHints, ...normalizedSearchableRelays])
// Filter out relays that were already tried
const externalRelays = Array.from(allPotentialRelays).filter(
relay => !alreadyTriedRelaysSet.has(relay)
)
// Deduplicate final relay list
setExternalRelays(Array.from(new Set(normalizedRelays)))
setExternalRelays(externalRelays)
logger.debug('External relays calculated', {
noteId,
hintRelaysCount: normalizedHints.length,
searchableRelaysCount: normalizedSearchableRelays.length,
alreadyTriedCount: alreadyTriedRelaysSet.size,
externalRelaysCount: externalRelays.length,
externalRelays: externalRelays.slice(0, 10) // Log first 10
})
}
getExternalRelays()
@ -166,14 +187,37 @@ function EmbeddedNoteNotFound({ @@ -166,14 +187,37 @@ function EmbeddedNoteNotFound({
const handleTryExternalRelays = async () => {
if (!hexEventId || isSearchingExternal) return
if (externalRelays.length === 0) {
logger.warn('No external relays to search', { noteId, hexEventId })
setTriedExternal(true)
return
}
setIsSearchingExternal(true)
try {
logger.info('Searching external relays', {
noteId,
hexEventId,
relayCount: externalRelays.length,
relays: externalRelays.slice(0, 5) // Log first 5 relays
})
const event = await client.fetchEventWithExternalRelays(hexEventId, externalRelays)
if (event && onEventFound) {
onEventFound(event)
if (event) {
logger.info('Event found on external relay', { noteId, hexEventId })
if (onEventFound) {
onEventFound(event)
}
} else {
logger.info('Event not found on external relays', {
noteId,
hexEventId,
relayCount: externalRelays.length
})
}
} catch (error) {
logger.error('External relay fetch failed', { error, noteId })
logger.error('External relay fetch failed', { error, noteId, hexEventId, externalRelays })
} finally {
setIsSearchingExternal(false)
setTriedExternal(true)

103
src/pages/secondary/NotePage/NotFound.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import ClientSelect from '@/components/ClientSelect'
import { Button } from '@/components/ui/button'
import { BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
import { AlertCircle, Search } from 'lucide-react'
@ -27,10 +27,15 @@ export default function NotFound({ @@ -27,10 +27,15 @@ export default function NotFound({
if (!bech32Id) return
const getExternalRelays = async () => {
// Get all relays that would be tried in tiers 1-3 (already tried)
const alreadyTriedRelays: string[] = await client.getAlreadyTriedRelays()
// Get all relays that have already been tried (BIG_RELAY_URLS + FAST_READ_RELAY_URLS)
// These are the relays used in the initial fetch
const alreadyTriedRelaysSet = new Set<string>()
;[...BIG_RELAY_URLS, ...FAST_READ_RELAY_URLS].forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) alreadyTriedRelaysSet.add(normalized)
})
let externalRelays: string[] = []
let hintRelays: string[] = []
let extractedHexEventId: string | null = null
// Parse relay hints and author from bech32 ID
@ -40,23 +45,20 @@ export default function NotFound({ @@ -40,23 +45,20 @@ export default function NotFound({
if (type === 'nevent') {
extractedHexEventId = data.id
if (data.relays) externalRelays.push(...data.relays)
if (data.relays) hintRelays.push(...data.relays)
if (data.author) {
const authorRelayList = await client.fetchRelayList(data.author)
externalRelays.push(...authorRelayList.write.slice(0, 6))
hintRelays.push(...authorRelayList.write.slice(0, 6))
}
} else if (type === 'naddr') {
if (data.relays) externalRelays.push(...data.relays)
if (data.relays) hintRelays.push(...data.relays)
const authorRelayList = await client.fetchRelayList(data.pubkey)
externalRelays.push(...authorRelayList.write.slice(0, 6))
hintRelays.push(...authorRelayList.write.slice(0, 6))
} else if (type === 'note') {
extractedHexEventId = data
}
// Normalize and deduplicate external relays
externalRelays = externalRelays.map(url => normalizeUrl(url) || url)
externalRelays = Array.from(new Set(externalRelays))
} catch (err) {
logger.error('Failed to parse external relays', { error: err, bech32Id })
logger.error('Failed to parse external relays', { error: err, bech32Id })
}
} else {
extractedHexEventId = bech32Id
@ -64,28 +66,40 @@ export default function NotFound({ @@ -64,28 +66,40 @@ export default function NotFound({
setHexEventId(extractedHexEventId)
// Get relays where this event was seen
const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : []
externalRelays.push(...seenOn)
// Normalize all relays first
let normalizedRelays = externalRelays.map(url => normalizeUrl(url) || url).filter(Boolean)
normalizedRelays = Array.from(new Set(normalizedRelays))
// If no external relays from hints, try SEARCHABLE_RELAY_URLS as fallback
// Filter out relays that overlap with BIG_RELAY_URLS (already tried first)
if (normalizedRelays.length === 0) {
const searchableRelays = SEARCHABLE_RELAY_URLS
.map(url => normalizeUrl(url) || url)
.filter((url): url is string => Boolean(url))
.filter(relay => !BIG_RELAY_URLS.includes(relay))
normalizedRelays.push(...searchableRelays)
}
hintRelays.push(...seenOn)
// Normalize all hint relays
const normalizedHints = hintRelays
.map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url))
// Combine hints with SEARCHABLE_RELAY_URLS (always include as fallback)
// Normalize SEARCHABLE_RELAY_URLS for comparison
const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS
.map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url))
// Filter out relays that were already tried in tiers 1-3
const newRelays = normalizedRelays.filter(relay => !alreadyTriedRelays.includes(relay))
// Combine all potential relays (hints + searchable)
const allPotentialRelays = new Set([...normalizedHints, ...normalizedSearchableRelays])
// Filter out relays that were already tried
const externalRelays = Array.from(allPotentialRelays).filter(
relay => !alreadyTriedRelaysSet.has(relay)
)
// Deduplicate final relay list
setExternalRelays(Array.from(new Set(newRelays)))
setExternalRelays(externalRelays)
logger.debug('External relays calculated (NotFound)', {
bech32Id,
hintRelaysCount: normalizedHints.length,
searchableRelaysCount: normalizedSearchableRelays.length,
alreadyTriedCount: alreadyTriedRelaysSet.size,
externalRelaysCount: externalRelays.length,
externalRelays: externalRelays.slice(0, 10) // Log first 10
})
}
getExternalRelays()
@ -94,14 +108,37 @@ export default function NotFound({ @@ -94,14 +108,37 @@ export default function NotFound({
const handleTryExternalRelays = async () => {
if (!hexEventId || isSearchingExternal) return
if (externalRelays.length === 0) {
logger.warn('No external relays to search (NotFound)', { bech32Id, hexEventId })
setTriedExternal(true)
return
}
setIsSearchingExternal(true)
try {
logger.info('Searching external relays (NotFound)', {
bech32Id,
hexEventId,
relayCount: externalRelays.length,
relays: externalRelays.slice(0, 5) // Log first 5 relays
})
const event = await client.fetchEventWithExternalRelays(hexEventId, externalRelays)
if (event && onEventFound) {
onEventFound(event)
if (event) {
logger.info('Event found on external relay (NotFound)', { bech32Id, hexEventId })
if (onEventFound) {
onEventFound(event)
}
} else {
logger.info('Event not found on external relays (NotFound)', {
bech32Id,
hexEventId,
relayCount: externalRelays.length
})
}
} catch (error) {
logger.error('External relay fetch failed', { error, bech32Id, hexEventId })
logger.error('External relay fetch failed (NotFound)', { error, bech32Id, hexEventId, externalRelays })
} finally {
setIsSearchingExternal(false)
setTriedExternal(true)

192
src/services/client.service.ts

@ -6,6 +6,7 @@ import { @@ -6,6 +6,7 @@ import {
isReplaceableEvent
} from '@/lib/event'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { getPubkeysFromPTags, getServersFromServerTags, tagNameEquals } from '@/lib/tag'
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
@ -753,65 +754,156 @@ class ClientService extends EventTarget { @@ -753,65 +754,156 @@ class ClientService extends EventTarget {
set.add(relay)
}
private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) {
private async query(
urls: string[],
filter: Filter | Filter[],
onevent?: (evt: NEvent) => void,
options?: { eoseTimeout?: number; globalTimeout?: number }
) {
const eoseTimeout = options?.eoseTimeout ?? 500 // Default 500ms after EOSE
const globalTimeout = options?.globalTimeout ?? 10000 // Default 10s global timeout
const isExternalSearch = eoseTimeout > 1000 // Consider it external search if timeout > 1s
if (isExternalSearch) {
logger.info('query: Starting external relay search', {
relayCount: urls.length,
relays: urls,
eoseTimeout,
globalTimeout,
filter: Array.isArray(filter) ? filter : [filter]
})
}
return await new Promise<NEvent[]>((resolve) => {
const events: NEvent[] = []
let hasEosed = false
let resolveTimeout: ReturnType<typeof setTimeout> | null = null
let allEosed = false
let eoseTime: number | null = null
let eventCount = 0
let globalTimeoutId: ReturnType<typeof setTimeout> | null = null
const resolveWithEvents = () => {
if (resolveTimeout) {
clearTimeout(resolveTimeout)
resolveTimeout = null
}
if (globalTimeoutId) {
clearTimeout(globalTimeoutId)
globalTimeoutId = null
}
const duration = eoseTime ? Date.now() - eoseTime : 0
if (isExternalSearch) {
logger.info('query: Resolving external search', {
eventsFound: events.length,
eventCount,
allEosed,
timeSinceEose: duration
})
}
sub.close()
resolve(events)
}
const sub = this.subscribe(urls, filter, {
onevent(evt) {
eventCount++
if (isExternalSearch && eventCount <= 3) {
logger.info('query: Received event', {
eventId: evt.id.substring(0, 8),
eventCount,
timeSinceEose: eoseTime ? Date.now() - eoseTime : null
})
}
onevent?.(evt)
events.push(evt)
// If we got events, clear any timeout - we're making progress
if (resolveTimeout) {
clearTimeout(resolveTimeout)
resolveTimeout = null
// Check if we're looking for a specific event ID (limit: 1 with ids filter)
const filters = Array.isArray(filter) ? filter : [filter]
const hasIdFilter = filters.some(f => f.ids && f.ids.length > 0)
const hasLimitOne = filters.some(f => f.limit === 1)
// If we're searching for a specific event and found it, we can resolve early
// But wait a bit (100ms) in case duplicate events arrive
if (hasIdFilter && hasLimitOne && events.length > 0 && allEosed) {
// We've found the event and received EOSE, wait a short moment then resolve
if (resolveTimeout) {
clearTimeout(resolveTimeout)
}
resolveTimeout = setTimeout(() => {
resolveWithEvents()
}, 100) // Short delay to catch any duplicate events
}
},
oneose: (eosed) => {
if (eosed) {
hasEosed = true
// Wait a bit more after EOSE to ensure we got all events
// When eosed is true, it means all relays have finished (either sent EOSE or failed to connect)
allEosed = true
eoseTime = Date.now()
if (isExternalSearch) {
logger.info('query: Received EOSE from all relays', {
eventsSoFar: events.length,
eventCount,
willWait: eoseTimeout
})
}
// Clear any existing timeout
if (resolveTimeout) {
clearTimeout(resolveTimeout)
}
// Wait longer after all relays send EOSE to allow searchable relays to finish searching
// For searchable relays, they may send EOSE quickly but still need time to search their database
// Important: We keep the subscription open during this timeout so we can receive events
resolveTimeout = setTimeout(() => {
resolveWithEvents()
}, 500)
}, eoseTimeout)
}
},
onclose: () => {
// Only resolve immediately on close if we've received EOSE or have events
// Otherwise, wait a bit to see if more events come
if (hasEosed || events.length > 0) {
if (resolveTimeout) {
clearTimeout(resolveTimeout)
onclose: (url, reason) => {
if (isExternalSearch) {
logger.info('query: Relay connection closed', { url, reason, eventsSoFar: events.length, allEosed })
}
// If we've received EOSE, we have a timeout set - let it handle resolution
// This gives searchable relays time to search their databases
if (allEosed) {
// Don't resolve immediately - let the EOSE timeout handle it
// This allows searchable relays to continue searching even if connections close
return
}
// If we have events but no EOSE yet, we might want to wait a bit more
// But if connections are closing, we should resolve
if (events.length > 0) {
// We have events, but haven't received EOSE from all relays
// Wait a short time to see if more events come, then resolve
if (!resolveTimeout) {
resolveTimeout = setTimeout(() => {
resolveWithEvents()
}, 1000) // Wait 1 second for more events
}
resolve(events)
} else {
// Wait up to 3 seconds for events if connection closes early
resolveTimeout = setTimeout(() => {
resolve(events)
}, 3000)
// No events and no EOSE - connection closed early
// Wait a bit to see if events arrive, but not too long
if (!resolveTimeout) {
resolveTimeout = setTimeout(() => {
resolveWithEvents()
}, 2000) // Wait 2 seconds for events
}
}
}
})
// Fallback timeout: resolve after 10 seconds max to prevent hanging
setTimeout(() => {
if (resolveTimeout) {
clearTimeout(resolveTimeout)
// Fallback timeout: resolve after globalTimeout to prevent hanging
globalTimeoutId = setTimeout(() => {
if (isExternalSearch) {
logger.info('query: Global timeout reached', {
eventsFound: events.length,
eventCount,
allEosed
})
}
sub.close()
resolve(events)
}, 10000)
resolveWithEvents()
}, globalTimeout)
})
}
@ -820,14 +912,23 @@ class ClientService extends EventTarget { @@ -820,14 +912,23 @@ class ClientService extends EventTarget {
filter: Filter | Filter[],
{
onevent,
cache = false
cache = false,
eoseTimeout,
globalTimeout
}: {
onevent?: (evt: NEvent) => void
cache?: boolean
eoseTimeout?: number
globalTimeout?: number
} = {}
) {
const relays = Array.from(new Set(urls))
const events = await this.query(relays.length > 0 ? relays : BIG_RELAY_URLS, filter, onevent)
const events = await this.query(
relays.length > 0 ? relays : BIG_RELAY_URLS,
filter,
onevent,
{ eoseTimeout, globalTimeout }
)
if (cache) {
events.forEach((evt) => {
this.addEventToCache(evt)
@ -1749,8 +1850,39 @@ class ClientService extends EventTarget { @@ -1749,8 +1850,39 @@ class ClientService extends EventTarget {
}
async fetchEventWithExternalRelays(eventId: string, externalRelays: string[]) {
if (!externalRelays || externalRelays.length === 0) {
logger.warn('fetchEventWithExternalRelays: No external relays provided', { eventId })
return undefined
}
logger.info('fetchEventWithExternalRelays: Starting search', {
eventId: eventId.substring(0, 8),
relayCount: externalRelays.length,
relays: externalRelays
})
// Use external relays for fetching the event
const events = await this.fetchEvents(externalRelays, { ids: [eventId], limit: 1 })
// For searchable relays, we want to give them more time to search their database
// Use a longer EOSE timeout (10 seconds) to allow searchable relays to complete their search
// and a longer global timeout (20 seconds) to ensure we wait long enough
const startTime = Date.now()
const events = await this.fetchEvents(
externalRelays,
{ ids: [eventId], limit: 1 },
{
eoseTimeout: 10000, // Wait 10 seconds after all EOSE (searchable relays need time to search)
globalTimeout: 20000 // 20 second global timeout
}
)
const duration = Date.now() - startTime
logger.info('fetchEventWithExternalRelays: Search completed', {
eventId: eventId.substring(0, 8),
relayCount: externalRelays.length,
eventsFound: events.length,
durationMs: duration
})
return events[0]
}

Loading…
Cancel
Save