diff --git a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx index bdc532ed..05ed38df 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx @@ -29,7 +29,7 @@ import { buildLabLanguageToolPreferenceList } from '@/lib/trinity-languages' import { parseLabSlice, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect' import { - fetchTranslateLanguages, + warmTranslateLanguagesOnce, isTranslateConfigured, translateApiLanguageCode, type TranslateLanguageOption @@ -555,7 +555,7 @@ export default function AdvancedEventLabDialog({ } let cancelled = false setTranslateLoad('loading') - void fetchTranslateLanguages() + void warmTranslateLanguagesOnce() .then((list) => { if (cancelled) return const resolved = buildResolvedTranslateMenuLanguageOptions(list) diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index 013f08b4..3ae5368d 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -7,7 +7,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { ExtendedKind, NIP71_VIDEO_KINDS, PROFILE_FEED_KINDS } from '@/constants' import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities' import { cn } from '@/lib/utils' -import { useKindFilter } from '@/providers/KindFilterProvider' +import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { ListFilter } from 'lucide-react' import { kinds } from 'nostr-tools' @@ -65,7 +65,7 @@ export default function KindFilter({ feedKindFilterBypass, updateShowKinds, updateFeedKindFilterBypass - } = useKindFilter() + } = useKindFilterOrDefaults() const [open, setOpen] = useState(false) const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds) const [temporaryShowKind1OPs, setTemporaryShowKind1OPs] = useState(savedShowKind1OPs) @@ -224,7 +224,8 @@ export default function KindFilter({

kind {KIND_1111}

{KIND_FILTER_OPTIONS.map(({ kindGroup, label }) => { - const checked = kindGroup.every((k) => temporaryShowKinds.includes(k)) + /** `some` not `every`: saved kinds may include e.g. only 30311 while the row lists 30311–30313; `every` made the box look off while 30311 still matched the feed. */ + const checked = kindGroup.some((k) => temporaryShowKinds.includes(k)) return (
(() => { const storedMode = storage.getNoteListMode() if (isMainFeed) { diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index cfaed3ca..4c1bd2c3 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -3453,6 +3453,9 @@ const NoteList = forwardRef( const listSourceEvents = timelineEventsForFilter const feedFullSearchActive = feedFullSearchEvents !== null const progressiveWarmupTrimmed = progressiveWarmupQuery?.trim() + // Relay-op rows arrive only after every relay in the wave reports terminal state. A slow or + // wedged connection (e.g. NIP-42 re-auth) can delay that indefinitely while events already stream + // in — without this guard the "Looking for more events…" banner never clears. const showRelaySubscribeWavePendingBanner = !oneShotFetch && !feedFullSearchActive && @@ -3460,7 +3463,8 @@ const NoteList = forwardRef( relayCapabilityReady && timelineKey != null && feedSubscribeRelayOutcomes.length === 0 && - feedTimelineEmptyUiReady + feedTimelineEmptyUiReady && + timelineEventsForFilter.length === 0 const showProgressiveLayersPendingBanner = Boolean(progressiveWarmupTrimmed) && progressiveLayersSearching && !feedFullSearchActive const showLookingForMoreEventsBanner = diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 1afc3fbd..4a5ff337 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -62,8 +62,8 @@ import { translateNoteAndRelatedForDisplay } from '@/lib/translate-note-for-menu' import { - fetchTranslateLanguages, isTranslateConfigured, + warmTranslateLanguagesOnce, type TranslateLanguageOption } from '@/lib/translate-client' import { @@ -186,7 +186,7 @@ export function useMenuActions({ return } let cancelled = false - void fetchTranslateLanguages() + void warmTranslateLanguagesOnce() .then((list) => { if (cancelled) return setTranslateMenuOptions(buildResolvedTranslateMenuLanguageOptions(list)) diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx index 716d3b1c..054c8ad6 100644 --- a/src/components/Profile/ProfileFeedWithPins.tsx +++ b/src/components/Profile/ProfileFeedWithPins.tsx @@ -8,7 +8,7 @@ import { useProfilePins } from '@/hooks/useProfilePins' import { useProfileTimeline } from '@/hooks/useProfileTimeline' import { useProfileZapPollParticipation } from '@/hooks/useProfileZapPollParticipation' import { useDeletedEvent } from '@/providers/DeletedEventProvider' -import { useKindFilter } from '@/providers/KindFilterProvider' +import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useZap } from '@/providers/ZapProvider' import client from '@/services/client.service' import storage from '@/services/local-storage.service' @@ -42,7 +42,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string const { t } = useTranslation() const { isEventDeleted } = useDeletedEvent() const { zapReplyThreshold } = useZap() - const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter() + const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults() /** Profile timelines always show reposts; global kind filter still applies to other kinds. */ const profileTimelineShowKinds = useMemo(() => { if (showKinds.includes(kinds.Repost) && showKinds.includes(ExtendedKind.GENERIC_REPOST)) { diff --git a/src/constants.ts b/src/constants.ts index a4f34c5f..d470b813 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -122,7 +122,18 @@ export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000 * Without this, a stuck `fetchReplaceableEventsFromProfileFetchRelays` can block the UI even when kind * 10002 is already in IndexedDB (the 30s publish timeout only runs after targets are resolved). */ -export const PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS = 12_000 +/** + * Cold `fetchRelayLists` runs NIP-65 (10002) + HTTP list (10243) fetches and often a 10432 pass; those used + * to run strictly in series under one race, so 32s was routinely shorter than real wall time → prioritize + * publish timed out and the UI looked stuck. + */ +export const PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS = 60_000 + +/** + * {@link ClientService.prioritizePublishUrlListWithTimeout}: must exceed {@link PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS} + * so one full `fetchRelayLists` can finish before we fall back to “deduped order without inbox fetch”. + */ +export const PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS = PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS + 25_000 /** Max merged URLs per REQ / timeline relay list (see `relay-url-priority`). */ export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS @@ -409,7 +420,6 @@ export const GIF_RELAY_URLS = [ ] export const SEARCHABLE_RELAY_URLS = [ - 'wss://freelay.sovbit.host', 'wss://search.nos.today', 'wss://nostr.wine', 'wss://orly-relay.imwald.eu', @@ -417,18 +427,12 @@ export const SEARCHABLE_RELAY_URLS = [ 'wss://thecitadel.nostr1.com', 'wss://relay.primal.net', 'wss://relay.damus.io', - 'wss://relay.snort.social', 'wss://nos.lol', 'wss://nostr.mom', 'wss://relay.noswhere.com', 'wss://relay.wikifreedia.xyz', 'wss://nostr.einundzwanzig.space', - 'wss://nostrelites.org', - 'wss://spatia-arcana.com', - 'wss://nostr-pub.wellorder.net', - 'wss://pyramid.fiatjaf.com/', - 'wss://nostr.lopp.social/', - 'wss://relay.dergigi.com/' + 'wss://nostr-pub.wellorder.net' ] export const PROFILE_RELAY_URLS = [ diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts index bc0e6b2d..dbb84c14 100644 --- a/src/lib/index-relay-http.ts +++ b/src/lib/index-relay-http.ts @@ -4,11 +4,16 @@ * * **Local dev:** loopback bases (`http://localhost:*` / `http://127.0.0.1:*`) are automatically fetched via * the Vite same-origin proxy `/dev-index-relay` → `VITE_DEV_INDEX_RELAY_TARGET` (default in `vite.config.ts`). - * Production and remote HTTPS relays are unchanged; those need CORS on the relay or a real reverse proxy. + * Known broken CORS HTTPS hosts (e.g. nos.lol) use `/dev-cors-index-relay` (see `vite.config.ts` + `url.ts`). + * Production and other remote HTTPS relays still need CORS or your own reverse proxy. */ import { fetchWithTimeout } from '@/lib/fetch-with-timeout' import logger from '@/lib/logger' -import { devProxyLoopbackHttpRelayBase, normalizeHttpRelayUrl } from '@/lib/url' +import { + devProxyCorsProblematicHttpsIndexRelayBase, + devProxyLoopbackHttpRelayBase, + normalizeHttpRelayUrl +} from '@/lib/url' import type { Filter, Event as NEvent } from 'nostr-tools' import { verifyEvent } from 'nostr-tools' @@ -55,7 +60,7 @@ function nostrFilterToIndexRelayBody(f: Filter): Record { return body } -const INDEX_RELAY_HTTP_WARN_COOLDOWN_MS = 5000 +const INDEX_RELAY_HTTP_WARN_COOLDOWN_MS = 25_000 const lastIndexRelayHttpWarnAtByEndpoint = new Map() const DEV_INDEX_RELAY_TRANSPORT_HINT_MS = 60_000 @@ -96,7 +101,10 @@ export class IndexRelayTransportError extends Error { } function isDevViteIndexRelayProxyPath(endpoint: string): boolean { - return import.meta.env.DEV && endpoint.includes('/dev-index-relay') + return ( + import.meta.env.DEV && + (endpoint.includes('/dev-index-relay') || endpoint.includes('/dev-cors-index-relay')) + ) } function maybeLogDevIndexRelayUnreachableHint(): void { @@ -167,20 +175,27 @@ function rawToVerifiedEvent(raw: Record): NEvent | null { /** * Query one HTTP index relay. Runs one POST per filter when given an array. - * When every filter attempt fails (HTTP error or network) and no events are returned, - * {@link options.onHardFailure} runs once (used for session strike parity with WebSocket relays). + * When every filter attempt fails with an HTTP response or a non-transport error and no events are returned, + * {@link options.onHardFailure} runs once (session strike parity with WebSocket relays). Pure browser transport + * failures (e.g. CORS on direct `https://` index POST) do not call it. */ +function devHttpIndexRelayBaseForFetch(baseUrl: string): string { + const n = normalizeHttpRelayUrl(baseUrl) || baseUrl + return devProxyCorsProblematicHttpsIndexRelayBase(devProxyLoopbackHttpRelayBase(n)) +} + export async function queryIndexRelay( baseUrl: string, filter: Filter | Filter[], options?: { signal?: AbortSignal; onHardFailure?: () => void } ): Promise { - const base = devProxyLoopbackHttpRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl) + const base = devHttpIndexRelayBaseForFetch(baseUrl) const endpoint = indexRelayFilterUrl(base) const filters = Array.isArray(filter) ? filter : [filter] const out: NEvent[] = [] const seen = new Set() - let sawHardFailure = false + /** Only set when the server returned HTTP (!ok) or a non-transport exception — not browser-only CORS / “failed to fetch”. */ + let strikeWorthyHttpFailure = false for (const f of filters) { const body = nostrFilterToIndexRelayBody(filterForIndexRelay(f)) try { @@ -195,7 +210,7 @@ export async function queryIndexRelay( timeoutMs: 25_000 }) if (!res.ok) { - sawHardFailure = true + strikeWorthyHttpFailure = true if (isDevViteIndexRelayProxyPath(endpoint)) { let detail = '' try { @@ -233,15 +248,15 @@ export async function queryIndexRelay( } } catch (e) { if ((e as Error).name === 'AbortError') throw e - sawHardFailure = true if (isIndexRelayTransportFailure(e)) { handleFilterTransportFailure(endpoint, e) } else { + strikeWorthyHttpFailure = true warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', { endpoint, error: e }) } } } - if (sawHardFailure && out.length === 0 && filters.length > 0) { + if (strikeWorthyHttpFailure && out.length === 0 && filters.length > 0) { // In dev, transport failures on the Vite loopback proxy (relay unreachable / proxy not yet ready) // should not record session strikes — the relay may be temporarily down or the dev server // needs a restart. Only real application errors (4xx/5xx from a live relay) trigger strikes in dev. @@ -263,7 +278,7 @@ export async function publishEventToHttpRelay( event: NEvent, options?: { signal?: AbortSignal } ): Promise { - const base = devProxyLoopbackHttpRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl) + const base = devHttpIndexRelayBaseForFetch(baseUrl) const endpoint = indexRelayPublishUrl(base) try { const res = await fetchWithTimeout(endpoint, { diff --git a/src/lib/nostr-relay-auth-patch.ts b/src/lib/nostr-relay-auth-patch.ts index c53be8ef..bec0248e 100644 --- a/src/lib/nostr-relay-auth-patch.ts +++ b/src/lib/nostr-relay-auth-patch.ts @@ -65,7 +65,7 @@ export function patchPoolRelayAuthRaceAndFeedback(relay: object): void { const r = asRelayInternals(this) if (!r.connectionPromise && typeof message === 'string' && message.startsWith('["AUTH"')) { abortPendingAuthForDeadSocket(r, message) - logger.warn('[RelayOp] Dropped AUTH (socket already closed; connect timeout vs signing race)', { + logger.debug('[RelayOp] Dropped AUTH (socket already closed; connect timeout vs signing race)', { url: r.url }) return Promise.resolve() @@ -91,7 +91,7 @@ export function patchPoolRelayAuthRaceAndFeedback(relay: object): void { msg.includes('relay connection closed before AUTH') || /relay connection closed/i.test(msg) if (benignRace) { - logger.warn('[RelayOp] Relay AUTH aborted (benign race)', { url: r.url, detail: msg }) + logger.debug('[RelayOp] Relay AUTH aborted (benign race)', { url: r.url, detail: msg }) r.authPromise = undefined return '' } diff --git a/src/lib/translate-client.ts b/src/lib/translate-client.ts index 9c93e752..b550f709 100644 --- a/src/lib/translate-client.ts +++ b/src/lib/translate-client.ts @@ -84,12 +84,30 @@ export function translateServerSupportsLogicalTarget(targetCode: string): boolea let languagesCache: { list: TranslateLanguageOption[]; at: number; fromFailure?: boolean } | null = null const LANGUAGES_CACHE_TTL_MS = 60_000 -/** After HTTP/parse failure, cache empty so each {@link useMenuActions} mount does not re-request. */ -const LANGUAGES_FAILURE_CACHE_TTL_MS = 120_000 +/** After HTTP/parse failure, avoid hammering a broken `/api/translate` proxy (dev 500s); 2m was still noisy with many remounts. */ +const LANGUAGES_FAILURE_CACHE_TTL_MS = 86_400_000 let languagesFetchInFlight: Promise | null = null let lastLanguagesFailureLogAt = 0 +/** + * Dedupes `/languages` across the whole app while a fetch is in flight. Cleared in `finally` so later + * mounts hit {@link fetchTranslateLanguages} again and get the memory cache without holding a stale Promise. + */ +let warmTranslateLanguagesPromise: Promise | null = null + +/** One shared `/languages` request per flight; safe to call from every note’s menu hook. */ +export function warmTranslateLanguagesOnce(): Promise { + if (!TRANSLATE_URL.trim()) return Promise.resolve([]) + if (warmTranslateLanguagesPromise) return warmTranslateLanguagesPromise + const p = fetchTranslateLanguages() + warmTranslateLanguagesPromise = p + void p.finally(() => { + warmTranslateLanguagesPromise = null + }) + return p +} + function parseLanguagesResponse(data: unknown): TranslateLanguageOption[] { if (!Array.isArray(data)) return [] const out: TranslateLanguageOption[] = [] @@ -168,6 +186,7 @@ export async function fetchTranslateLanguages(): Promise(function SpellsPage( showKind1OPs, showKind1Replies, showKind1111 - } = useKindFilter() + } = useKindFilterOrDefaults() const hideRepliesFollowing = useNoteListHideReplies() const [spells, setSpells] = useState([]) /** Ordered spell event ids (newest star first). Drives picker order + bookmark list sync when logged in. */ diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 333f1893..8ba9a57d 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -644,7 +644,9 @@ export class QueryService { await this.acquireSubSlot(relayKey) let relay: AbstractRelay try { - relay = await this.pool.ensureRelay(url, { connectionTimeout: 5000 }) + relay = await this.pool.ensureRelay(url, { + connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS + }) patchRelayNoticeForFetchFailures(relay, relayKey, this.onRelayNoticeStrike) } catch (err) { this.onRelayConnectionFailure?.(relayKey) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index d9f5488b..bdce4f31 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -12,12 +12,16 @@ import { relayFilterIncludesSocialKindBlockedKind, relaysAfterSocialKindBlockedStrip, SOCIAL_KIND_BLOCKED_RELAY_URLS, + MAX_CONCURRENT_RELAY_CONNECTIONS, MAX_PUBLISH_RELAYS, + PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS, + RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS, RELAY_POOL_CONNECTION_TIMEOUT_MS, RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS, TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY, OUTBOX_PUBLISH_RETRY_DELAY_MS, + DEFAULT_FAVORITE_RELAYS, NIP66_DISCOVERY_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, READ_ONLY_RELAY_URLS, @@ -115,7 +119,15 @@ import { relayUrlsStripExtendedTagReqBlocked } from '@/lib/relay-extended-tag-req-blocks' import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize' -import { isHttpRelayUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl, simplifyUrl } from '@/lib/url' +import { + canonicalRelayStrikeKey, + isHttpRelayUrl, + isLocalNetworkUrl, + normalizeAnyRelayUrl, + normalizeHttpRelayUrl, + normalizeUrl, + simplifyUrl +} from '@/lib/url' import { isSafari } from '@/lib/utils' import { ISigner, @@ -270,7 +282,10 @@ class ClientService extends EventTarget { * {@link ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD} strikes we skip that relay for reads and publishes until reload. */ private publishStrikeCount = new Map() - public static readonly SESSION_RELAY_FAILURE_STRIKE_THRESHOLD = 2 + /** Many shards / parallel REQs used to hit the strike threshold instantly on one dead relay; only one increment per window. */ + private sessionRelayFailureLastIncrementAt = new Map() + public static readonly SESSION_RELAY_FAILURE_STRIKE_THRESHOLD = 4 + private static readonly SESSION_RELAY_FAILURE_INCREMENT_DEBOUNCE_MS = 12_000 /** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */ private sessionRelayPublishStats = new Map() @@ -313,7 +328,8 @@ class ClientService extends EventTarget { // Initialize sub-services this.queryService = new QueryService(this.pool, { shouldSkipRelayForSession: (url) => { - const key = normalizeAnyRelayUrl(url) || url + const key = canonicalRelayStrikeKey(url) + if (!key) return false return ( (this.publishStrikeCount.get(key) ?? 0) >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD @@ -602,33 +618,39 @@ class ClientService extends EventTarget { event: NEvent, favoriteRelayUrls: string[] = [] ): Promise { - let userWriteSet = new Set() + const ctx = this.collectReplyAndMentionPubkeys(event) + /** One `fetchRelayLists` round-trip (author first) — avoids two back-to-back relay-list budgets that always lost the outer race. */ + const pubkeyOrder = Array.from(new Set([event.pubkey, ...ctx])) + let lists: TRelayList[] = [] try { - const rl = await this.fetchRelayList(event.pubkey) + lists = await this.fetchRelayLists(pubkeyOrder) + } catch { + lists = [] + } + + let userWriteSet = new Set() + const authorRl = lists[0] + if (authorRl) { userWriteSet = new Set([ - ...(rl?.write ?? []).map((u) => normalizeUrl(u) || u).filter((u): u is string => !!u), - ...(rl?.httpWrite ?? []).map((u) => normalizeHttpRelayUrl(u) || u).filter((u): u is string => !!u) + ...(authorRl.write ?? []).map((u) => normalizeUrl(u) || u).filter((u): u is string => !!u), + ...(authorRl.httpWrite ?? []).map((u) => normalizeHttpRelayUrl(u) || u).filter((u): u is string => !!u) ]) - } catch { - // ignore } - const ctx = this.collectReplyAndMentionPubkeys(event) let authorReadSet = new Set() - if (ctx.length > 0) { - const lists = await this.fetchRelayLists(ctx) - for (const list of lists) { - for (const u of list?.read ?? []) { - const n = normalizeUrl(u) || u - if (n) authorReadSet.add(n) - } - for (const u of list?.httpRead ?? []) { - const n = normalizeHttpRelayUrl(u) || u - if (n) authorReadSet.add(n) - } + for (let i = 1; i < lists.length; i++) { + const list = lists[i] + if (!list) continue + for (const u of list.read ?? []) { + const n = normalizeUrl(u) || u + if (n) authorReadSet.add(n) + } + for (const u of list.httpRead ?? []) { + const n = normalizeHttpRelayUrl(u) || u + if (n) authorReadSet.add(n) } - authorReadSet = new Set(filterContextAuthorReadRelaysForPublish([...authorReadSet])) } + authorReadSet = new Set(filterContextAuthorReadRelaysForPublish([...authorReadSet])) const favSet = new Set( favoriteRelayUrls.map((f) => normalizeUrl(f) || f).filter((u): u is string => !!u) @@ -691,7 +713,7 @@ class ClientService extends EventTarget { relayCount: relayUrls.length }) resolve(fallbackOrder()) - }, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS) + }, PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS) ) ]) } @@ -1087,7 +1109,8 @@ class ClientService extends EventTarget { /** Strikes accumulated this session for this relay (connection / NOTICE failures). */ getSessionRelayStrikeCountForUrl(url: string): number { - const n = normalizeAnyRelayUrl(url) || url + const n = canonicalRelayStrikeKey(url) + if (!n) return 0 return this.publishStrikeCount.get(n) ?? 0 } @@ -1101,7 +1124,7 @@ class ClientService extends EventTarget { } private recordRelayNoticeFetchFailure(url: string, noticeMessage: string) { - const n = normalizeAnyRelayUrl(url) || url + const n = canonicalRelayStrikeKey(url) if (!n) return const prev = this.publishStrikeCount.get(n) ?? 0 if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { @@ -1115,12 +1138,21 @@ class ClientService extends EventTarget { } private recordSessionRelayFailure(url: string) { - const n = normalizeAnyRelayUrl(url) || url + const n = canonicalRelayStrikeKey(url) if (!n) return + if (isLocalNetworkUrl(n)) { + return + } const prev = this.publishStrikeCount.get(n) ?? 0 if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { return } + const now = Date.now() + const lastInc = this.sessionRelayFailureLastIncrementAt.get(n) ?? 0 + if (now - lastInc < ClientService.SESSION_RELAY_FAILURE_INCREMENT_DEBOUNCE_MS) { + return + } + this.sessionRelayFailureLastIncrementAt.set(n, now) const count = prev + 1 this.publishStrikeCount.set(n, count) if (count === ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { @@ -1134,7 +1166,8 @@ class ClientService extends EventTarget { private filterSessionStrikedRelays(urls: string[]): string[] { return urls.filter((u) => { - const n = normalizeAnyRelayUrl(u) || u + const n = canonicalRelayStrikeKey(u) + if (!n) return true return (this.publishStrikeCount.get(n) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD }) } @@ -1143,9 +1176,10 @@ class ClientService extends EventTarget { * If every URL was session-striked, clear strikes once so reads/publishes can retry (mobile WebSocket churn). */ clearSessionRelayStrikes(): void { - if (this.publishStrikeCount.size === 0) return + if (this.publishStrikeCount.size === 0 && this.sessionRelayFailureLastIncrementAt.size === 0) return logger.info('[Relay] Session relay strikes cleared', { relayCount: this.publishStrikeCount.size }) this.publishStrikeCount.clear() + this.sessionRelayFailureLastIncrementAt.clear() this.notifySessionRelayStrikesChanged() } @@ -1154,9 +1188,10 @@ class ClientService extends EventTarget { * until new failures accrue (same counter as {@link clearSessionRelayStrikes}). */ clearSessionRelayStrikeForUrl(url: string): boolean { - const n = normalizeAnyRelayUrl(url) || url + const n = canonicalRelayStrikeKey(url) if (!n) return false const had = this.publishStrikeCount.delete(n) + this.sessionRelayFailureLastIncrementAt.delete(n) if (had) { logger.info('[Relay] Session strikes cleared for relay (manual)', { url: n }) this.notifySessionRelayStrikesChanged(n) @@ -1170,9 +1205,12 @@ class ClientService extends EventTarget { clearSessionRelayStrikesForUrls(urls: string[]): number { let cleared = 0 for (const url of urls) { - const n = normalizeAnyRelayUrl(url) || url + const n = canonicalRelayStrikeKey(url) if (!n) continue - if (this.publishStrikeCount.delete(n)) cleared += 1 + if (this.publishStrikeCount.delete(n)) { + cleared += 1 + this.sessionRelayFailureLastIncrementAt.delete(n) + } } if (cleared > 0) { logger.info('[Relay] Session strikes cleared for relays (added to publish selection)', { @@ -1195,11 +1233,11 @@ class ClientService extends EventTarget { if (filtered.length === 0 && unique.length > 0) { let cleared = 0 for (const u of unique) { - // HTTP index relays (CORS down, wrong origin) do not recover like WebSockets; clearing their strikes - // here caused retry storms with many parallel fetchEvents hitting the same dead endpoint. - if (isHttpRelayUrl(u)) continue - const n = normalizeAnyRelayUrl(u) || u - if (n && this.publishStrikeCount.delete(n)) cleared += 1 + const n = canonicalRelayStrikeKey(u) + if (n && this.publishStrikeCount.delete(n)) { + cleared += 1 + this.sessionRelayFailureLastIncrementAt.delete(n) + } } if (cleared === 0) return filtered logger.info('[Relay] Batch was all session-striked — cleared strikes for this batch only', { @@ -1214,7 +1252,8 @@ class ClientService extends EventTarget { /** Record a successful publish and its latency for session-based preference when selecting random relays. */ recordPublishSuccess(url: string, latencyMs: number) { - const n = normalizeAnyRelayUrl(url) || url + const n = canonicalRelayStrikeKey(url) + if (!n) return const cur = this.sessionRelayPublishStats.get(n) if (cur) { cur.successCount += 1 @@ -1233,7 +1272,7 @@ class ClientService extends EventTarget { const out: string[] = [] for (const [url, stats] of this.sessionRelayPublishStats.entries()) { if (stats.successCount < 1) continue - const n = normalizeAnyRelayUrl(url) || url + const n = canonicalRelayStrikeKey(url) if (!n || readOnlySet.has(n)) continue if ((this.publishStrikeCount.get(n) ?? 0) >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) continue out.push(n) @@ -1258,9 +1297,14 @@ class ClientService extends EventTarget { presetStriked: string[] } { const presetSet = new Set() - for (const u of [...FAST_WRITE_RELAY_URLS, ...FAST_READ_RELAY_URLS]) { + for (const u of [ + ...FAST_WRITE_RELAY_URLS, + ...FAST_READ_RELAY_URLS, + ...DEFAULT_FAVORITE_RELAYS, + ...SEARCHABLE_RELAY_URLS + ]) { const n = normalizeUrl(u) || u - if (n) presetSet.add(n) + if (n) presetSet.add(canonicalRelayStrikeKey(n)) } const preset = Array.from(presetSet) const strikedUrls = Array.from(this.publishStrikeCount.entries()) @@ -1291,19 +1335,23 @@ class ClientService extends EventTarget { .map((u) => normalizeAnyRelayUrl(u) || u) .filter((n) => n && !readOnlySet.has(n)) const unique = Array.from(new Set(normalizedCandidates)) - const notStruckOut = unique.filter( - (n) => (this.publishStrikeCount.get(n) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD - ) + const notStruckOut = unique.filter((u) => { + const n = canonicalRelayStrikeKey(u) + if (!n) return false + return (this.publishStrikeCount.get(n) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD + }) const preferred: string[] = [] const rest: string[] = [] for (const url of notStruckOut) { - const stats = this.sessionRelayPublishStats.get(url) + const sk = canonicalRelayStrikeKey(url) + const stats = sk ? this.sessionRelayPublishStats.get(sk) : undefined if (stats && stats.successCount >= 1) preferred.push(url) else rest.push(url) } preferred.sort((a, b) => { - const sa = this.sessionRelayPublishStats.get(a)! - const sb = this.sessionRelayPublishStats.get(b)! + const sa = this.sessionRelayPublishStats.get(canonicalRelayStrikeKey(a)) + const sb = this.sessionRelayPublishStats.get(canonicalRelayStrikeKey(b)) + if (!sa || !sb) return 0 if (sb.successCount !== sa.successCount) return sb.successCount - sa.successCount const avgA = sa.sumLatencyMs / sa.successCount const avgB = sb.sumLatencyMs / sb.successCount @@ -1436,10 +1484,31 @@ class ClientService extends EventTarget { publishOpBatch.logEnd(status) } - logger.debug('[PublishEvent] Setting up global timeout (30 seconds)') + /** + * Publish intentionally does **not** use {@link QueryService.acquireGlobalRelayConnectionSlot}: feed + * REQ setup can hold every slot for hung `ensureRelay` handshakes, which left `finishedCount === 0` + * until the global timeout (user saw “Request timed out” on every relay). Publish is already bounded + * by {@link MAX_PUBLISH_RELAYS}. Budget still scales with relay count as a rough upper bound. + */ + const slotCap = Math.max(1, MAX_CONCURRENT_RELAY_CONNECTIONS) + const publishWaves = Math.max(1, Math.ceil(uniqueRelayUrls.length / slotCap)) + const perWaveBudgetMs = + RELAY_POOL_CONNECTION_TIMEOUT_MS + RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS + 10_000 + const publishGlobalDeadlineMs = Math.min( + 600_000, + Math.max( + RELAY_POOL_CONNECTION_TIMEOUT_MS + RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS + 30_000, + publishWaves * perWaveBudgetMs + 25_000 + ) + ) + logger.debug('[PublishEvent] Setting up global timeout', { + publishGlobalDeadlineMs, + publishWaves, + relayCount: uniqueRelayUrls.length, + slotCap + }) let hasResolved = false - // Add a global timeout to prevent hanging - use 30 seconds for faster feedback const globalTimeout = setTimeout(() => { if (hasResolved) { logger.debug('[PublishEvent] Already resolved, ignoring timeout') @@ -1481,25 +1550,29 @@ class ClientService extends EventTarget { totalCount: uniqueRelayUrls.length }) } - }, 30_000) // 30 seconds global timeout (reduced from 2 minutes) + }, publishGlobalDeadlineMs) logger.debug('[PublishEvent] Starting Promise.allSettled for all relays') const relayPublishAllSettled = Promise.allSettled( uniqueRelayUrls.map(async (url, index) => { // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this - await that.queryService.acquireGlobalRelayConnectionSlot() const startMs = Date.now() logger.debug(`[PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url }) const isLocal = isLocalNetworkUrl(url) - const connectionTimeout = isLocal ? 5_000 : 8_000 // 5s for local, 8s for remote - const publishTimeout = isLocal ? 5_000 : 8_000 // 5s for local, 8s for remote - + /** Match pool handshake budget; a shorter outer race used to abort `ensureRelay` at 8s while the pool allowed 20s — slow TLS never won. */ + const connectionTimeout = isLocal ? 5_000 : RELAY_POOL_CONNECTION_TIMEOUT_MS + /** ACK wait: {@link applyRelayNip42AckTimeout} already sets relay.publishTimeout; do not override with a few seconds (extension signers + slow relays). */ + const publishAckBudgetMs = isLocal ? 5_000 : RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS + const httpPublishBudgetMs = isLocal ? 5_000 : 8_000 + // Set up a per-relay timeout to ensure we always reach the finally block const relayTimeout = setTimeout(() => { - logger.warn(`[PublishEvent] Per-relay timeout for ${url}`, { connectionTimeout, publishTimeout }) - // This will be caught in the catch block if the promise is still pending - }, connectionTimeout + publishTimeout + 2_000) // Add 2s buffer + logger.warn(`[PublishEvent] Per-relay watchdog fired for ${url}`, { + connectionTimeout, + publishAckBudgetMs + }) + }, connectionTimeout + publishAckBudgetMs + 2_000) try { if (isHttpRelayUrl(url)) { @@ -1508,7 +1581,10 @@ class ClientService extends EventTarget { await Promise.race([ publishEventToHttpRelay(base, event), new Promise((_, reject) => - setTimeout(() => reject(new Error(`HTTP publish timeout after ${publishTimeout}ms`)), publishTimeout) + setTimeout( + () => reject(new Error(`HTTP publish timeout after ${httpPublishBudgetMs}ms`)), + httpPublishBudgetMs + ) ) ]) that.recordPublishSuccess(url, Date.now() - startMs) @@ -1518,89 +1594,120 @@ class ClientService extends EventTarget { return } - // For local relays, add a connection timeout let relay: Relay - logger.debug(`[PublishEvent] Ensuring relay connection`, { url, isLocal, connectionTimeout }) + for (let wsAttempt = 0; wsAttempt < 2; wsAttempt++) { + try { + logger.debug(`[PublishEvent] Ensuring relay connection`, { + url, + isLocal, + connectionTimeout, + wsAttempt + }) - const connectionPromise = isLocal - ? Promise.race([ - this.pool.ensureRelay(url), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Local relay connection timeout')), connectionTimeout) - ) - ]) - : Promise.race([ - this.pool.ensureRelay(url), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Remote relay connection timeout')), connectionTimeout) - ) - ]) + const ensureOpts = { connectionTimeout } + const connectionPromise = isLocal + ? Promise.race([ + this.pool.ensureRelay(url, ensureOpts), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Local relay connection timeout')), connectionTimeout) + ) + ]) + : Promise.race([ + this.pool.ensureRelay(url, ensureOpts), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Remote relay connection timeout')), connectionTimeout) + ) + ]) + + relay = await connectionPromise + logger.debug(`[PublishEvent] Relay connected`, { url }) + const relayKeyPub = normalizeUrl(url) || url + patchRelayNoticeForFetchFailures(relay as unknown as AbstractRelay, relayKeyPub, (u, m) => + that.recordRelayNoticeFetchFailure(u, m) + ) - relay = await connectionPromise - logger.debug(`[PublishEvent] Relay connected`, { url }) - const relayKeyPub = normalizeUrl(url) || url - patchRelayNoticeForFetchFailures(relay as unknown as AbstractRelay, relayKeyPub, (u, m) => - that.recordRelayNoticeFetchFailure(u, m) - ) + applyRelayNip42AckTimeout(relay as unknown as AbstractRelay) - relay.publishTimeout = publishTimeout - - logger.debug(`[PublishEvent] Publishing to relay`, { url }) - - // Wrap publish in a timeout promise - const publishPromise = relay - .publish(event) - .then(() => { - logger.debug(`[PublishEvent] Successfully published to relay`, { url }) - that.recordPublishSuccess(url, Date.now() - startMs) - this.trackEventSeenOn(event.id, relay) - successCount++ - relayStatuses.push({ url, success: true }) - }) - .catch((error) => { - logger.warn(`[PublishEvent] Publish failed, checking if auth required`, { url, error: error.message }) - if ( - error instanceof Error && - isRelayAuthRequiredErrorMessage(error.message) && - that.canSignerAuthenticateRelay() - ) { - logger.debug(`[PublishEvent] Auth required, attempting authentication`, { url }) - applyRelayNip42AckTimeout(relay) - return authenticateNip42Relay(relay, (authEvt: EventTemplate) => - queueRelayAuthSign(() => that.signer!.signEvent(authEvt)) - ) - .then(() => { - logger.debug(`[PublishEvent] Auth successful, retrying publish`, { url }) - return relay.publish(event) - }) - .then(() => { - logger.debug(`[PublishEvent] Successfully published after auth`, { url }) - that.recordPublishSuccess(url, Date.now() - startMs) - this.trackEventSeenOn(event.id, relay) - successCount++ - relayStatuses.push({ url, success: true }) + logger.debug(`[PublishEvent] Publishing to relay`, { url }) + + const publishPromise = relay + .publish(event) + .then(() => { + logger.debug(`[PublishEvent] Successfully published to relay`, { url }) + that.recordPublishSuccess(url, Date.now() - startMs) + this.trackEventSeenOn(event.id, relay) + successCount++ + relayStatuses.push({ url, success: true }) + }) + .catch((error) => { + logger.warn(`[PublishEvent] Publish failed, checking if auth required`, { + url, + error: error.message }) - .catch((authError) => { - logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authError.message }) - errors.push({ url, error: authError }) - relayStatuses.push({ url, success: false, error: authError.message }) + if ( + error instanceof Error && + isRelayAuthRequiredErrorMessage(error.message) && + that.canSignerAuthenticateRelay() + ) { + logger.debug(`[PublishEvent] Auth required, attempting authentication`, { url }) + applyRelayNip42AckTimeout(relay as unknown as AbstractRelay) + return authenticateNip42Relay(relay, (authEvt: EventTemplate) => + queueRelayAuthSign(() => that.signer!.signEvent(authEvt)) + ) + .then(() => { + logger.debug(`[PublishEvent] Auth successful, retrying publish`, { url }) + return relay.publish(event) + }) + .then(() => { + logger.debug(`[PublishEvent] Successfully published after auth`, { url }) + that.recordPublishSuccess(url, Date.now() - startMs) + this.trackEventSeenOn(event.id, relay) + successCount++ + relayStatuses.push({ url, success: true }) + }) + .catch((authError) => { + logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authError.message }) + errors.push({ url, error: authError }) + relayStatuses.push({ url, success: false, error: authError.message }) + that.recordSessionRelayFailure(url) + }) + } else { + logger.error(`[PublishEvent] Publish failed`, { url, error: error.message }) + errors.push({ url, error }) + relayStatuses.push({ url, success: false, error: error.message }) that.recordSessionRelayFailure(url) - }) - } else { - logger.error(`[PublishEvent] Publish failed`, { url, error: error.message }) - errors.push({ url, error }) - relayStatuses.push({ url, success: false, error: error.message }) - that.recordSessionRelayFailure(url) + } + }) + + await Promise.race([ + publishPromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Publish timeout after ${publishAckBudgetMs}ms`)), + publishAckBudgetMs + ) + ) + ]) + break + } catch (wsErr) { + const msg = wsErr instanceof Error ? wsErr.message : String(wsErr) + const retriable = + wsAttempt === 0 && + /Remote relay connection timeout|Local relay connection timeout|Publish timeout after|publish timed out|websocket closed|connection failed|relay connection closed|SendingOnClosedConnection/i.test( + msg + ) + if (!retriable) { + throw wsErr } - }) - - // Add a timeout wrapper for the entire publish operation - await Promise.race([ - publishPromise, - new Promise((_, reject) => - setTimeout(() => reject(new Error(`Publish timeout after ${publishTimeout}ms`)), publishTimeout) - ) - ]) + logger.info('[PublishEvent] Closing pooled relay and retrying publish once', { url, msg }) + try { + this.pool.close([url]) + } catch { + /* ignore */ + } + await new Promise((r) => setTimeout(r, 400)) + } + } } catch (error) { const softHttpDown = isHttpRelayUrl(url) && @@ -1624,7 +1731,6 @@ class ClientService extends EventTarget { }) that.recordSessionRelayFailure(url) } finally { - that.queryService.releaseGlobalRelayConnectionSlot() clearTimeout(relayTimeout) const currentFinished = ++finishedCount logger.debug(`[PublishEvent] Relay finished`, { @@ -3650,21 +3756,74 @@ class ClientService extends EventTarget { ) const budgetMs = PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS + /** True only when *every* pubkey in this batch already has kind 10002 in IDB (not just you). */ const allHaveKind10002 = pubkeys.every((_, i) => storedRelayEvents[i] != null) - const networkBundle = async (): Promise<{ + /** + * Fill gaps from the network: start from IDB rows, then fetch kind 10002 + 10243 **only for pubkeys + * missing 10002** (and 10243-only where 10002 exists but HTTP list does not). Avoids re-downloading + * your relay list on every reply just because the parent author’s 10002 was never cached. + */ + const hydrateRelayListsFromNetwork = async (): Promise<{ relayEvents: (NEvent | null | undefined)[] httpRelayEvents: (NEvent | null | undefined)[] cacheRelayEvents: (NEvent | null | undefined)[] }> => { - const relayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( - pubkeys, - kinds.RelayList + const relayEvents: (NEvent | null | undefined)[] = pubkeys.map((_, i) => + storedRelayEvents[i] != null ? (storedRelayEvents[i] as NEvent) : undefined ) - const httpRelayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( - pubkeys, - ExtendedKind.HTTP_RELAY_LIST + const httpRelayEvents: (NEvent | null | undefined)[] = pubkeys.map((_, i) => + storedHttpRelayEvents[i] != null ? (storedHttpRelayEvents[i] as NEvent) : undefined + ) + + const missing10002Pubkeys = pubkeys.filter((_pk, i) => storedRelayEvents[i] == null) + if (missing10002Pubkeys.length > 0) { + logger.debug( + '[FetchRelayLists] Kind 10002 missing in IndexedDB for some pubkeys; fetching only those over the network', + { + batchSize: pubkeys.length, + missingCount: missing10002Pubkeys.length, + missingPubkeyPrefixes: missing10002Pubkeys.map((p) => p.slice(0, 12)) + } + ) + const [relFetched, httpFetched] = await Promise.all([ + this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( + missing10002Pubkeys, + kinds.RelayList + ), + this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( + missing10002Pubkeys, + ExtendedKind.HTTP_RELAY_LIST + ) + ]) + let j = 0 + for (let i = 0; i < pubkeys.length; i++) { + if (storedRelayEvents[i] == null) { + relayEvents[i] = relFetched[j] ?? undefined + httpRelayEvents[i] = httpFetched[j] ?? undefined + j++ + } + } + } + + const missingHttpOnlyPubkeys = pubkeys.filter( + (_pk, i) => storedRelayEvents[i] != null && storedHttpRelayEvents[i] == null ) + if (missingHttpOnlyPubkeys.length > 0) { + const httpOnlyFetched = + await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( + missingHttpOnlyPubkeys, + ExtendedKind.HTTP_RELAY_LIST + ) + let j = 0 + for (let i = 0; i < pubkeys.length; i++) { + if (storedRelayEvents[i] != null && storedHttpRelayEvents[i] == null) { + httpRelayEvents[i] = httpOnlyFetched[j] ?? undefined + j++ + } + } + } + const cacheRelayEvents = await this.fetchCacheRelayEventsFromMultipleSources( pubkeys, relayEvents, @@ -3697,10 +3856,11 @@ class ClientService extends EventTarget { } const raced = await Promise.race([ - networkBundle(), + hydrateRelayListsFromNetwork(), new Promise((resolve) => setTimeout(() => resolve(null), budgetMs)) ]) if (raced != null) { + this.refreshRelayListsFromNetwork(pubkeys, storedRelayEvents) return this.mergeRelayListsBundle( pubkeys, raced.relayEvents, diff --git a/vite.config.ts b/vite.config.ts index b2b41af1..6868e962 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -157,6 +157,17 @@ export default defineConfig(({ mode }) => { target: devIndexRelayTarget, changeOrigin: true, rewrite: (p) => p.replace(/^\/dev-index-relay/, '') || '/' + }, + /** + * Some public index relays (e.g. nos.lol) omit `Content-Type` from CORS preflight + * `Access-Control-Allow-Headers`, so browser POST /api/events/filter fails from localhost. + * Same-origin proxy only — allowlisted hosts in {@link devProxyCorsProblematicHttpsIndexRelayBase}. + */ + '/dev-cors-index-relay': { + target: 'https://nos.lol', + changeOrigin: true, + secure: true, + rewrite: (p) => p.replace(/^\/dev-cors-index-relay/, '') || '/' } } },