You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
411 lines
14 KiB
411 lines
14 KiB
/** |
|
* Comprehensive relay list builder utility |
|
* Handles all relay selection requirements: |
|
* - Filters blocked relays |
|
* - Includes local relays from kind 10432 |
|
* - Handles author's outboxes/inboxes |
|
* - Handles user's outboxes/inboxes |
|
* - Includes relay hints |
|
* - Includes seen relays |
|
*/ |
|
|
|
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' |
|
import { normalizeUrl } from '@/lib/url' |
|
import { getCacheRelayUrls } from './private-relays' |
|
import client from '@/services/client.service' |
|
import logger from '@/lib/logger' |
|
|
|
function dedupeNormalizedRelayUrls(urls: string[]): string[] { |
|
const seen = new Set<string>() |
|
const out: string[] = [] |
|
for (const u of urls) { |
|
const n = normalizeUrl(u) || u |
|
if (!n || seen.has(n)) continue |
|
seen.add(n) |
|
out.push(n) |
|
} |
|
return out |
|
} |
|
|
|
/** |
|
* Relays to bootstrap Explore replaceable fetches (e.g. kind 10012 batch) before NIP-65 resolves. |
|
* PROFILE_FETCH + FAST_READ. |
|
*/ |
|
export function exploreDiscoveryBootstrapRelayUrls(): string[] { |
|
return dedupeNormalizedRelayUrls([...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS]) |
|
} |
|
|
|
export interface RelayListBuilderOptions { |
|
/** Author's pubkey - will include their outboxes (write relays) */ |
|
authorPubkey?: string |
|
/** Logged-in user's pubkey - will include their inboxes (read relays) and outboxes (write relays) */ |
|
userPubkey?: string |
|
/** Explicit relay hints (from bech32 IDs or event tags) */ |
|
relayHints?: string[] |
|
/** Relays where an event was seen */ |
|
seenRelays?: string[] |
|
/** Relays where a containing event was found (for embedded events) */ |
|
containingEventRelays?: string[] |
|
/** Whether to include user's own relays (read/write/local) - for profiles/metadata */ |
|
includeUserOwnRelays?: boolean |
|
/** Whether to include PROFILE_FETCH_RELAY_URLS - for profiles/metadata */ |
|
includeProfileFetchRelays?: boolean |
|
/** Whether to include FAST_READ_RELAY_URLS as fallback */ |
|
includeFastReadRelays?: boolean |
|
/** Whether to include FAST_WRITE_RELAY_URLS as fallback */ |
|
includeFastWriteRelays?: boolean |
|
/** Whether to include SEARCHABLE_RELAY_URLS - for search */ |
|
includeSearchableRelays?: boolean |
|
/** Blocked relays to filter out */ |
|
blockedRelays?: string[] |
|
/** Whether to include local relays from kind 10432 */ |
|
includeLocalRelays?: boolean |
|
/** Whether to include user's favorite relays (kind 10012) */ |
|
includeFavoriteRelays?: boolean |
|
} |
|
|
|
/** |
|
* Build comprehensive relay list according to requirements |
|
*/ |
|
export async function buildComprehensiveRelayList(options: RelayListBuilderOptions = {}): Promise<string[]> { |
|
const { |
|
authorPubkey, |
|
userPubkey, |
|
relayHints = [], |
|
seenRelays = [], |
|
containingEventRelays = [], |
|
includeUserOwnRelays = false, |
|
includeProfileFetchRelays = false, |
|
includeFastReadRelays = true, |
|
includeFastWriteRelays = false, |
|
includeSearchableRelays = false, |
|
blockedRelays = [], |
|
includeLocalRelays = true, |
|
includeFavoriteRelays = false |
|
} = options |
|
|
|
const relayUrls = new Set<string>() |
|
const normalizedBlocked = new Set( |
|
(blockedRelays || []).map(url => { |
|
const normalized = normalizeUrl(url) || url |
|
return normalized.toLowerCase() |
|
}).filter((url): url is string => !!url) |
|
) |
|
|
|
const addRelay = (url: string | undefined) => { |
|
if (!url) return |
|
const normalized = normalizeUrl(url) |
|
if (!normalized) return |
|
// Filter blocked (case-insensitive comparison) |
|
if (normalizedBlocked.has(normalized.toLowerCase())) return |
|
relayUrls.add(normalized) |
|
} |
|
|
|
// 1. Relay hints (highest priority - explicit hints) |
|
relayHints.forEach(addRelay) |
|
|
|
// 2. Relays where event was seen |
|
seenRelays.forEach(addRelay) |
|
|
|
// 3. Relays where containing event was found (for embedded events) |
|
containingEventRelays.forEach(addRelay) |
|
|
|
// 4. Author's outboxes (write relays) - where they publish |
|
if (authorPubkey) { |
|
try { |
|
// Add timeout to prevent hanging - 2 seconds max |
|
const relayListPromise = client.fetchRelayList(authorPubkey) |
|
const timeoutPromise = new Promise<null>((resolve) => { |
|
setTimeout(() => { |
|
logger.debug('[RelayListBuilder] fetchRelayList timeout for author', { |
|
author: authorPubkey.substring(0, 8) |
|
}) |
|
resolve(null) |
|
}, 2000) |
|
}) |
|
const authorRelayList = await Promise.race([relayListPromise, timeoutPromise]) |
|
|
|
if (authorRelayList) { |
|
const authorOutboxes = (authorRelayList.write || []).slice(0, 10) |
|
authorOutboxes.forEach(addRelay) |
|
|
|
// Also include author's read relays (inboxes) for better discovery |
|
const authorInboxes = (authorRelayList.read || []).slice(0, 10) |
|
authorInboxes.forEach(addRelay) |
|
|
|
logger.debug('[RelayListBuilder] Added author relays', { |
|
author: authorPubkey.substring(0, 8), |
|
outboxes: authorOutboxes.length, |
|
inboxes: authorInboxes.length |
|
}) |
|
} |
|
} catch (error) { |
|
logger.debug('[RelayListBuilder] Failed to fetch author relay list', { error }) |
|
} |
|
} |
|
|
|
// 5. User's own relays (for profiles/metadata) |
|
if (includeUserOwnRelays && userPubkey) { |
|
try { |
|
// Add timeout to prevent hanging - 2 seconds max |
|
const relayListPromise = client.fetchRelayList(userPubkey) |
|
const timeoutPromise = new Promise<null>((resolve) => { |
|
setTimeout(() => { |
|
logger.debug('[RelayListBuilder] fetchRelayList timeout for user', { |
|
user: userPubkey.substring(0, 8) |
|
}) |
|
resolve(null) |
|
}, 2000) |
|
}) |
|
const userRelayList = await Promise.race([relayListPromise, timeoutPromise]) |
|
|
|
if (userRelayList) { |
|
// Include both read and write |
|
const userRead = (userRelayList.read || []).slice(0, 10) |
|
const userWrite = (userRelayList.write || []).slice(0, 10) |
|
userRead.forEach(addRelay) |
|
userWrite.forEach(addRelay) |
|
} |
|
|
|
// Include local relays from kind 10432 |
|
if (includeLocalRelays) { |
|
const localRelays = await getCacheRelayUrls(userPubkey) |
|
localRelays.forEach(addRelay) |
|
} |
|
|
|
// Include favorite relays (kind 10012) if requested |
|
let favoriteRelaysCount = 0 |
|
if (includeFavoriteRelays) { |
|
try { |
|
const favoriteRelays = await client.fetchFavoriteRelays(userPubkey) |
|
favoriteRelays.forEach(addRelay) |
|
favoriteRelaysCount = favoriteRelays.length |
|
logger.debug('[RelayListBuilder] Added user favorite relays', { |
|
count: favoriteRelaysCount |
|
}) |
|
} catch (error) { |
|
logger.debug('[RelayListBuilder] Failed to fetch user favorite relays', { error }) |
|
} |
|
} |
|
|
|
logger.debug('[RelayListBuilder] Added user own relays', { |
|
read: userRelayList ? (userRelayList.read || []).length : 0, |
|
write: userRelayList ? (userRelayList.write || []).length : 0, |
|
local: includeLocalRelays ? (await getCacheRelayUrls(userPubkey)).length : 0, |
|
favorite: favoriteRelaysCount |
|
}) |
|
} catch (error) { |
|
logger.debug('[RelayListBuilder] Failed to fetch user relay list', { error }) |
|
} |
|
} else if (userPubkey) { |
|
// Even if not including user's own relays, still include user's inboxes for reading |
|
try { |
|
// Add timeout to prevent hanging - 2 seconds max |
|
const relayListPromise = client.fetchRelayList(userPubkey) |
|
const timeoutPromise = new Promise<null>((resolve) => { |
|
setTimeout(() => { |
|
logger.debug('[RelayListBuilder] fetchRelayList timeout for user inboxes', { |
|
user: userPubkey.substring(0, 8) |
|
}) |
|
resolve(null) |
|
}, 2000) |
|
}) |
|
const userRelayList = await Promise.race([relayListPromise, timeoutPromise]) |
|
|
|
if (userRelayList) { |
|
const userInboxes = (userRelayList.read || []).slice(0, 10) |
|
userInboxes.forEach(addRelay) |
|
} |
|
|
|
// Include local relays from kind 10432 if enabled |
|
if (includeLocalRelays) { |
|
const localRelays = await getCacheRelayUrls(userPubkey) |
|
localRelays.forEach(addRelay) |
|
} |
|
} catch (error) { |
|
logger.debug('[RelayListBuilder] Failed to fetch user inboxes', { error }) |
|
} |
|
} |
|
|
|
// 6. Profile fetch relays (for profiles/metadata) |
|
if (includeProfileFetchRelays) { |
|
PROFILE_FETCH_RELAY_URLS.forEach(addRelay) |
|
} |
|
|
|
// 7. Fast read relays (fallback) |
|
if (includeFastReadRelays) { |
|
FAST_READ_RELAY_URLS.forEach(addRelay) |
|
} |
|
|
|
// 8. Fast write relays (for writing) |
|
if (includeFastWriteRelays) { |
|
FAST_WRITE_RELAY_URLS.forEach(addRelay) |
|
} |
|
|
|
// 9. Searchable relays (for search) |
|
if (includeSearchableRelays) { |
|
SEARCHABLE_RELAY_URLS.forEach(addRelay) |
|
} |
|
|
|
return Array.from(relayUrls) |
|
} |
|
|
|
/** |
|
* Explore: Following's Favorites (kind 10012 batch) / replaceable discovery. |
|
* Bootstrap relays (profile + FAST_READ) plus the viewer's read/write and cache (10432) when logged in. |
|
*/ |
|
export async function buildExploreProfileAndUserRelayList( |
|
userPubkey: string | null | undefined |
|
): Promise<string[]> { |
|
const boot = exploreDiscoveryBootstrapRelayUrls() |
|
if (!userPubkey) { |
|
return boot |
|
} |
|
try { |
|
const built = await buildComprehensiveRelayList({ |
|
userPubkey, |
|
includeUserOwnRelays: true, |
|
includeProfileFetchRelays: true, |
|
includeFastReadRelays: true, |
|
includeFavoriteRelays: false, |
|
includeLocalRelays: true, |
|
includeFastWriteRelays: false, |
|
includeSearchableRelays: false |
|
}) |
|
if (!built.length) return boot |
|
return dedupeNormalizedRelayUrls([...boot, ...built]) |
|
} catch { |
|
return boot |
|
} |
|
} |
|
|
|
/** NIP-10 relay hints from `e` / `E` tags (third value) on the focused event or thread. */ |
|
export function relayHintsFromEventTags(event: { tags: string[][] }): string[] { |
|
const out = new Set<string>() |
|
for (const tag of event.tags) { |
|
if ((tag[0] === 'e' || tag[0] === 'E') && tag[2]) { |
|
const n = normalizeUrl(tag[2]) || tag[2] |
|
if (n) out.add(n) |
|
} |
|
} |
|
return [...out] |
|
} |
|
|
|
/** |
|
* Build relay list for reading replies/comments |
|
* READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes |
|
*/ |
|
export async function buildReplyReadRelayList( |
|
opAuthorPubkey: string | undefined, |
|
userPubkey: string | undefined, |
|
blockedRelays: string[] = [], |
|
threadRelayHints: string[] = [] |
|
): Promise<string[]> { |
|
return buildComprehensiveRelayList({ |
|
authorPubkey: opAuthorPubkey, |
|
userPubkey, |
|
relayHints: threadRelayHints, |
|
includeFastReadRelays: true, |
|
includeSearchableRelays: true, |
|
includeLocalRelays: true, |
|
blockedRelays |
|
}) |
|
} |
|
|
|
/** |
|
* Build relay list for writing replies/comments |
|
* WRITE to: OP author's outboxes + OP author's inboxes + reply-to author's inboxes + user's outboxes + local relay |
|
*/ |
|
export async function buildReplyWriteRelayList( |
|
opAuthorPubkey: string | undefined, |
|
replyToAuthorPubkey: string | undefined, |
|
userPubkey: string | undefined, |
|
blockedRelays: string[] = [] |
|
): Promise<string[]> { |
|
const relayUrls = new Set<string>() |
|
const normalizedBlocked = new Set( |
|
(blockedRelays || []).map(url => { |
|
const normalized = normalizeUrl(url) || url |
|
return normalized.toLowerCase() |
|
}).filter((url): url is string => !!url) |
|
) |
|
|
|
const addRelay = (url: string | undefined) => { |
|
if (!url) return |
|
const normalized = normalizeUrl(url) |
|
if (!normalized) return |
|
// Filter blocked (case-insensitive comparison) |
|
if (normalizedBlocked.has(normalized.toLowerCase())) return |
|
relayUrls.add(normalized) |
|
} |
|
|
|
// OP author's outboxes |
|
if (opAuthorPubkey) { |
|
try { |
|
// Add timeout to prevent hanging - 2 seconds max |
|
const relayListPromise = client.fetchRelayList(opAuthorPubkey) |
|
const timeoutPromise = new Promise<null>((resolve) => { |
|
setTimeout(() => resolve(null), 2000) |
|
}) |
|
const opRelayList = await Promise.race([relayListPromise, timeoutPromise]) |
|
|
|
if (opRelayList) { |
|
const opOutboxes = (opRelayList.write || []).slice(0, 10) |
|
opOutboxes.forEach(addRelay) |
|
|
|
// OP author's inboxes |
|
const opInboxes = (opRelayList.read || []).slice(0, 10) |
|
opInboxes.forEach(addRelay) |
|
} |
|
} catch (error) { |
|
logger.debug('[RelayListBuilder] Failed to fetch OP author relay list', { error }) |
|
} |
|
} |
|
|
|
// Reply-to author's inboxes |
|
if (replyToAuthorPubkey && replyToAuthorPubkey !== opAuthorPubkey) { |
|
try { |
|
// Add timeout to prevent hanging - 2 seconds max |
|
const relayListPromise = client.fetchRelayList(replyToAuthorPubkey) |
|
const timeoutPromise = new Promise<null>((resolve) => { |
|
setTimeout(() => resolve(null), 2000) |
|
}) |
|
const replyToRelayList = await Promise.race([relayListPromise, timeoutPromise]) |
|
|
|
if (replyToRelayList) { |
|
const replyToInboxes = (replyToRelayList.read || []).slice(0, 10) |
|
replyToInboxes.forEach(addRelay) |
|
} |
|
} catch (error) { |
|
logger.debug('[RelayListBuilder] Failed to fetch reply-to author relay list', { error }) |
|
} |
|
} |
|
|
|
// User's outboxes |
|
if (userPubkey) { |
|
try { |
|
// Add timeout to prevent hanging - 2 seconds max |
|
const relayListPromise = client.fetchRelayList(userPubkey) |
|
const timeoutPromise = new Promise<null>((resolve) => { |
|
setTimeout(() => resolve(null), 2000) |
|
}) |
|
const userRelayList = await Promise.race([relayListPromise, timeoutPromise]) |
|
|
|
if (userRelayList) { |
|
const userOutboxes = (userRelayList.write || []).slice(0, 10) |
|
userOutboxes.forEach(addRelay) |
|
} |
|
|
|
// User's local relay (kind 10432) |
|
const localRelays = await getCacheRelayUrls(userPubkey) |
|
localRelays.forEach(addRelay) |
|
} catch (error) { |
|
logger.debug('[RelayListBuilder] Failed to fetch user relay list', { error }) |
|
} |
|
} |
|
|
|
// Fast write relays as fallback |
|
FAST_WRITE_RELAY_URLS.forEach(addRelay) |
|
|
|
return Array.from(relayUrls) |
|
}
|
|
|