From a3728659447daa1e537bdcb0e9ccba6a591ddd74 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 30 Mar 2026 12:25:21 +0200 Subject: [PATCH] bug-fixes --- src/components/NoteList/index.tsx | 13 +- src/components/Relay/index.tsx | 57 ++++++- src/constants.ts | 20 +++ src/i18n/locales/de.ts | 6 + src/i18n/locales/en.ts | 6 + src/lib/nostr-relay-auth-patch.ts | 91 +++++++++++ src/lib/relay-auth-sign-queue.ts | 15 ++ src/lib/relay-nip42-auth.ts | 48 ++++++ src/lib/relay-nip42-tuning.ts | 8 + src/pages/primary/NoteListPage/RelaysFeed.tsx | 3 +- src/pages/primary/RelayPage/index.tsx | 4 +- src/pages/secondary/RelayPage/index.tsx | 8 +- src/providers/NostrProvider/index.tsx | 8 +- src/services/client-query.service.ts | 48 ++++-- src/services/client.service.ts | 142 +++++++++++++++--- 15 files changed, 428 insertions(+), 49 deletions(-) create mode 100644 src/lib/nostr-relay-auth-patch.ts create mode 100644 src/lib/relay-auth-sign-queue.ts create mode 100644 src/lib/relay-nip42-auth.ts create mode 100644 src/lib/relay-nip42-tuning.ts diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index b9568dde..70f8191e 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1,5 +1,5 @@ import NewNotesButton from '@/components/NewNotesButton' -import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS } from '@/constants' +import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS, SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { collectEmbeddedEventPrefetchTargets, getReplaceableCoordinateFromEvent, @@ -78,7 +78,7 @@ import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' const LIMIT = 100 // Increased from 200 to load more events per request const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds /** Single-relay explore: kindless REQ cap (relay returns whatever it has, up to this many). */ -const RELAY_EXPLORE_LIMIT = 200 +const RELAY_EXPLORE_LIMIT = SINGLE_RELAY_KINDLESS_REQ_LIMIT /** * Vite HMR replaces this module and remounts NoteList; timeline refs reset while the subscription can briefly look @@ -1738,6 +1738,13 @@ const NoteList = forwardRef( // Do not toast until merged timeline reports first paint or all shards EOSE (see subscribeTimeline // `allEosed`); `loading` is cleared earlier when the subscribe promise resolves. if (!feedPaintLiveRelayDoneRef.current) return + /** + * Outcomes are cleared in layout when the subscription key changes; `onRelaySubscribeWaveComplete` + * runs only after every shard’s relay batch ends (often 10–30s on slow / NIP-42 relays). Without this + * guard, `uiStatuses.length === 0` and the toast fires ~900ms after the first empty paint — not after + * relays actually respond. One-shot fetches never populate outcomes; they are excluded here. + */ + if (!oneShotFetch && feedSubscribeRelayOutcomes.length === 0) return const toastKey = `${timelineSubscriptionKey}|${refreshCount}` const debounceMs = 900 @@ -1746,6 +1753,7 @@ const NoteList = forwardRef( if (eventsRef.current.length > 0) return if (!subRequestsRef.current.length) return if (!feedPaintLiveRelayDoneRef.current) return + if (!oneShotFetch && feedSubscribeRelayOutcomes.length === 0) return if (feedRelayReturnedAnyEventRef.current) return if (Date.now() < suppressRelayEmptyFeedToastUntilMs) return if (emptyRelayNoHitsToastKeyRef.current === toastKey) return @@ -1789,6 +1797,7 @@ const NoteList = forwardRef( refreshCount, feedEmptyToastGateTick, feedSubscribeRelayOutcomes, + oneShotFetch, t ]) diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx index 03aa2f84..2f6ea503 100644 --- a/src/components/Relay/index.tsx +++ b/src/components/Relay/index.tsx @@ -4,9 +4,12 @@ import RelayInfo from '@/components/RelayInfo' import SearchInput from '@/components/SearchInput' import { useFetchRelayInfo } from '@/hooks' import type { TPrimaryPageName } from '@/PageManager' +import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { normalizeUrl } from '@/lib/url' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' -import { forwardRef, useEffect, useMemo, useRef, useState } from 'react' +import client, { JUMBLE_SESSION_RELAY_STRIKES_CHANGED } from '@/services/client.service' +import type { TFeedSubRequest } from '@/types' +import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import NotFound from '../NotFound' @@ -23,6 +26,23 @@ const Relay = forwardRef< const internalNoteListRef = useRef(null) const noteListRef = ref ?? internalNoteListRef + const strikeThreshold = client.getSessionRelayFailureStrikeThreshold() + const readStrikeCount = useCallback(() => { + if (!normalizedUrl) return 0 + return client.getSessionRelayStrikeCountForUrl(normalizedUrl) + }, [normalizedUrl]) + const [strikeCount, setStrikeCount] = useState(0) + + useEffect(() => { + setStrikeCount(readStrikeCount()) + }, [readStrikeCount]) + + useEffect(() => { + const sync = () => setStrikeCount(readStrikeCount()) + window.addEventListener(JUMBLE_SESSION_RELAY_STRIKES_CHANGED, sync) + return () => window.removeEventListener(JUMBLE_SESSION_RELAY_STRIKES_CHANGED, sync) + }, [readStrikeCount]) + useEffect(() => { if (normalizedUrl) { addRelayUrls([normalizedUrl]) @@ -62,6 +82,19 @@ const Relay = forwardRef< } }, [normalizedUrl, noteListRef]) + const relayFeedSubRequests = useMemo(() => { + if (!normalizedUrl) return [] + const q = debouncedInput.trim() + return [ + { + urls: [normalizedUrl], + filter: q + ? { search: q, limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } + : { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } + } + ] + }, [normalizedUrl, debouncedInput]) + if (!normalizedUrl) { return } @@ -69,6 +102,24 @@ const Relay = forwardRef< return (
+ {strikeCount > 0 ? ( +
+

+ {strikeCount >= strikeThreshold + ? t('relaySessionStrikes.bannerSkipped', { threshold: strikeThreshold }) + : t('relaySessionStrikes.bannerWarning', { + count: strikeCount, + threshold: strikeThreshold + })} +

+

+ {t('relaySessionStrikes.refreshHint', { refresh: t('Refresh') })} +

+
+ ) : null} {relayInfo?.supported_nips?.includes(50) && (
void + reject: (e: unknown) => void + timeout: ReturnType +} + +/** Duck-type nostr-tools internals (class typings mark several fields private). */ +type RelayInternals = { + url: string + connectionPromise?: Promise + openEventPublishes: Map + authPromise?: Promise +} + +let patched = false + +function asRelayInternals(relay: AbstractRelay): RelayInternals { + return relay as unknown as RelayInternals +} + +function abortPendingAuthForDeadSocket(relay: RelayInternals, message: string) { + const i = message.indexOf('{') + const j = message.lastIndexOf('}') + if (i === -1 || j <= i) return + let parsed: { id?: string } + try { + parsed = JSON.parse(message.slice(i, j + 1)) as { id?: string } + } catch { + return + } + const id = parsed.id + if (!id) return + const ep = relay.openEventPublishes.get(id) + if (!ep) { + relay.authPromise = undefined + return + } + clearTimeout(ep.timeout) + relay.openEventPublishes.delete(id) + ep.reject(new Error('relay connection closed before AUTH could be sent')) + relay.authPromise = undefined +} + +/** + * Mitigate races between nostr-tools NIP-42 `AUTH`, WebSocket teardown (e.g. connect timeout while NIP-07 + * queues `signEvent`), and `send()` throwing {@link SendingOnClosedConnection} without a handler. + */ +export function installNostrRelayAuthRaceMitigation(): void { + if (patched) return + patched = true + + const origSend = AbstractRelay.prototype.send + const origAuth = AbstractRelay.prototype.auth + + AbstractRelay.prototype.send = function (this: AbstractRelay, message: string) { + 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)', { + url: r.url + }) + return Promise.resolve() + } + return origSend.call(this, message) as Promise + } + + AbstractRelay.prototype.auth = function ( + this: AbstractRelay, + signAuthEvent: (evt: EventTemplate) => Promise + ) { + const r = asRelayInternals(this) + return (origAuth.call(this, signAuthEvent) as Promise).catch((err: Error) => { + const msg = err?.message ?? '' + /** Hard close while `auth()` is in flight rejects open publish/auth waiters with this reason. */ + const benignRace = + err?.name === 'SendingOnClosedConnection' || + 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 }) + r.authPromise = undefined + return '' + } + throw err + }) + } +} diff --git a/src/lib/relay-auth-sign-queue.ts b/src/lib/relay-auth-sign-queue.ts new file mode 100644 index 00000000..4fb4431d --- /dev/null +++ b/src/lib/relay-auth-sign-queue.ts @@ -0,0 +1,15 @@ +let chain: Promise = Promise.resolve() + +/** + * Serialize relay NIP-42 AUTH `signEvent` work. Browser extensions (NIP-07) process one request at a time; + * parallel challenges from many relays otherwise queue past nostr-tools’ default AUTH ACK window and can race + * closed sockets before `AUTH` is sent. + */ +export function queueRelayAuthSign(fn: () => Promise): Promise { + const run = chain.then(() => fn()) + chain = run.then( + () => undefined, + () => undefined + ) + return run +} diff --git a/src/lib/relay-nip42-auth.ts b/src/lib/relay-nip42-auth.ts new file mode 100644 index 00000000..51a783fe --- /dev/null +++ b/src/lib/relay-nip42-auth.ts @@ -0,0 +1,48 @@ +import type { AbstractRelay } from 'nostr-tools/abstract-relay' +import type { EventTemplate, VerifiedEvent } from 'nostr-tools' + +function readNip42Challenge(relay: AbstractRelay): string | undefined { + return (relay as unknown as { challenge?: string }).challenge +} + +/** + * Relays send `CLOSED` with an `auth-required` prefix when NIP-42 authentication is needed. + * Match upstream jumble `master`: `reason.startsWith('auth-required')` — do **not** require `:`; + * some relays omit it. + */ +export function isRelayAuthRequiredCloseReason(reason: string): boolean { + return reason.trim().toLowerCase().startsWith('auth-required') +} + +/** Publish / pool errors when the relay requires NIP-42 before accepting EVENT. */ +export function isRelayAuthRequiredErrorMessage(message: string): boolean { + return /auth-required/i.test(message) +} + +/** nostr-tools default when {@link Subscription.close} runs from the client. */ +export function isRelaySubscriptionClosedByCaller(reason: string): boolean { + return reason.trim() === 'closed by caller' +} + +/** + * Some relays send `CLOSED` (auth-required) in the same tick as or slightly before the `AUTH` challenge + * is applied; {@link AbstractRelay.auth} throws if `challenge` is still empty. Wait briefly for the frame. + */ +export async function authenticateNip42Relay( + relay: AbstractRelay, + signAuthEvent: (evt: EventTemplate) => Promise, + options?: { challengeWaitMs?: number; pollMs?: number } +): Promise { + const challengeWaitMs = options?.challengeWaitMs ?? 4000 + const pollMs = options?.pollMs ?? 25 + const deadline = Date.now() + challengeWaitMs + while (!readNip42Challenge(relay) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, pollMs)) + } + if (!readNip42Challenge(relay)) { + throw new Error( + "can't perform auth, no challenge was received (timed out waiting for relay AUTH message)" + ) + } + return relay.auth(signAuthEvent) +} diff --git a/src/lib/relay-nip42-tuning.ts b/src/lib/relay-nip42-tuning.ts new file mode 100644 index 00000000..b3ed06eb --- /dev/null +++ b/src/lib/relay-nip42-tuning.ts @@ -0,0 +1,8 @@ +import type { AbstractRelay } from 'nostr-tools/abstract-relay' + +import { RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS } from '@/constants' + +/** Set nostr-tools ACK wait so NIP-42 AUTH is not rejected while the relay (or extension) is slow. */ +export function applyRelayNip42AckTimeout(relay: AbstractRelay): void { + relay.publishTimeout = RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS +} diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index d3b45855..214740ff 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -1,5 +1,6 @@ import NormalFeed from '@/components/NormalFeed' import type { TNoteListRef } from '@/components/NoteList' +import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { checkAlgoRelay } from '@/lib/relay' import { normalizeUrl } from '@/lib/url' import { useFeed } from '@/providers/FeedProvider' @@ -100,7 +101,7 @@ const RelaysFeed = forwardRef< const subRequests = useMemo(() => { if (!canRenderFeed) return [] if (singleRelayKindlessExplore) { - return [{ urls: relayUrls, filter: {} }] + return [{ urls: relayUrls, filter: { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } }] } return [ { diff --git a/src/pages/primary/RelayPage/index.tsx b/src/pages/primary/RelayPage/index.tsx index 9f73c212..f71833df 100644 --- a/src/pages/primary/RelayPage/index.tsx +++ b/src/pages/primary/RelayPage/index.tsx @@ -4,6 +4,7 @@ import Relay from '@/components/Relay' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { TPageRef } from '@/types' import { normalizeUrl, simplifyUrl } from '@/lib/url' +import client from '@/services/client.service' import { Server } from 'lucide-react' import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react' @@ -13,8 +14,9 @@ const RelayPage = forwardRef(({ url }, ref) => { const feedRef = useRef(null) const runRefresh = useCallback(() => { + if (normalizedUrl) client.clearSessionRelayStrikeForUrl(normalizedUrl) feedRef.current?.refresh() - }, []) + }, [normalizedUrl]) useImperativeHandle( ref, diff --git a/src/pages/secondary/RelayPage/index.tsx b/src/pages/secondary/RelayPage/index.tsx index 5cfffe0e..7b00cdd1 100644 --- a/src/pages/secondary/RelayPage/index.tsx +++ b/src/pages/secondary/RelayPage/index.tsx @@ -4,17 +4,21 @@ import { RefreshButton } from '@/components/RefreshButton' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { normalizeUrl, simplifyUrl } from '@/lib/url' +import client from '@/services/client.service' import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' import NotFoundPage from '../NotFoundPage' const RelayPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => { const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const feedRef = useRef(null) - const bumpFeed = useCallback(() => feedRef.current?.refresh(), []) - const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) const title = useMemo(() => (url ? simplifyUrl(url) : undefined), [url]) + const bumpFeed = useCallback(() => { + if (normalizedUrl) client.clearSessionRelayStrikeForUrl(normalizedUrl) + feedRef.current?.refresh() + }, [normalizedUrl]) + useEffect(() => { if (!hideTitlebar) { registerPrimaryPanelRefresh(null) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 03af7990..3eca1c1f 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -784,12 +784,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { }, [account]) useEffect(() => { - if (signer) { - client.signer = signer - } else { - client.signer = undefined - } - client.signerType = account?.signerType + /** Use `client.setSigner` so the client, QueryService, and scoped NIP-42 pool auth stay aligned. */ + client.setSigner(signer ?? undefined, account?.signerType) }, [signer, account?.signerType]) useEffect(() => { diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 4ab739a0..d63c0edd 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -5,9 +5,17 @@ import { SOCIAL_KIND_BLOCKED_RELAY_URLS, MAX_CONCURRENT_RELAY_CONNECTIONS, MAX_CONCURRENT_SUBS_PER_RELAY, + RELAY_POOL_CONNECTION_TIMEOUT_MS, SEARCHABLE_RELAY_URLS } from '@/constants' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' +import { queueRelayAuthSign } from '@/lib/relay-auth-sign-queue' +import { + authenticateNip42Relay, + isRelayAuthRequiredCloseReason, + isRelaySubscriptionClosedByCaller +} from '@/lib/relay-nip42-auth' +import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { isIndexRelayTransportFailure, queryIndexRelay } from '@/lib/index-relay-http' import logger from '@/lib/logger' import { isHttpRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' @@ -526,6 +534,9 @@ export class QueryService { : undefined const subs: { relayKey: string; close: () => void }[] = [] + const nip42ResubscribePending = new Set() + /** Same idea as `master` subscribe: only one successful auth+resubscribe cycle per relay slot. */ + const nip42HasAuthedOnce = new Set() const allOpened = Promise.all( groupedRequests.map(async ({ url, filters: relayFilters }, i) => { await this.acquireGlobalRelayConnectionSlot() @@ -556,23 +567,35 @@ export class QueryService { onevent: (evt: NEvent) => forwardOnevent?.(evt), oneose: () => handleEose(i), onclose: (reason: string) => { + if (isRelaySubscriptionClosedByCaller(reason) && nip42ResubscribePending.has(i)) { + return + } releaseOnce() - if (reason.startsWith('auth-required: ') && this.canSignerAuthenticateRelay()) { - relay - .auth(async (authEvt: EventTemplate) => { - const evt = await this.signer!.signEvent(authEvt) - if (!evt) throw new Error('sign event failed') - return evt as VerifiedEvent - }) + if ( + isRelayAuthRequiredCloseReason(reason) && + this.canSignerAuthenticateRelay() && + !nip42HasAuthedOnce.has(i) + ) { + nip42ResubscribePending.add(i) + applyRelayNip42AckTimeout(relay) + authenticateNip42Relay(relay, async (authEvt: EventTemplate) => { + const evt = await queueRelayAuthSign(() => this.signer!.signEvent(authEvt)) + if (!evt) throw new Error('sign event failed') + return evt as VerifiedEvent + }) .then(async () => { + nip42HasAuthedOnce.add(i) await this.acquireGlobalRelayConnectionSlot() try { await this.acquireSubSlot(relayKey) let liveRelay: AbstractRelay try { - liveRelay = await this.pool.ensureRelay(url, { connectionTimeout: 5000 }) + liveRelay = await this.pool.ensureRelay(url, { + connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS + }) patchRelayNoticeForFetchFailures(liveRelay, relayKey, this.onRelayNoticeStrike) } catch (err) { + nip42ResubscribePending.delete(i) this.onRelayConnectionFailure?.(relayKey) this.releaseSubSlot(relayKey) handleClose(i, (err as Error)?.message ?? String(err)) @@ -604,7 +627,9 @@ export class QueryService { sub2.close() } }) + nip42ResubscribePending.delete(i) } catch (err) { + nip42ResubscribePending.delete(i) releaseSlot2() handleClose(i, (err as Error)?.message ?? String(err)) } @@ -612,12 +637,13 @@ export class QueryService { this.releaseGlobalRelayConnectionSlot() } }) - .catch((err) => { - handleClose(i, `auth failed: ${(err as Error)?.message ?? err}`) + .catch(() => { + nip42ResubscribePending.delete(i) + handleClose(i, reason) }) return } - if (reason.startsWith('auth-required: ')) { + if (isRelayAuthRequiredCloseReason(reason)) { callbacks.startLogin?.() } handleClose(i, reason) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 506b9bbb..91303f66 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -7,6 +7,8 @@ import { relayFilterIncludesSocialKindBlockedKind, SOCIAL_KIND_BLOCKED_RELAY_URLS, MAX_PUBLISH_RELAYS, + RELAY_POOL_CONNECTION_TIMEOUT_MS, + RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS, TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY, OUTBOX_PUBLISH_RETRY_DELAY_MS, NIP66_DISCOVERY_RELAY_URLS, @@ -30,6 +32,15 @@ function canonicalSeenOnEventId(eventId: string): string { import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' +import { installNostrRelayAuthRaceMitigation } from '@/lib/nostr-relay-auth-patch' +import { queueRelayAuthSign } from '@/lib/relay-auth-sign-queue' +import { + authenticateNip42Relay, + isRelayAuthRequiredCloseReason, + isRelayAuthRequiredErrorMessage, + isRelaySubscriptionClosedByCaller +} from '@/lib/relay-nip42-auth' +import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events' import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' @@ -84,9 +95,14 @@ import { RelaySubscribeOpBatch } from '@/services/relay-operation-log.service' import { QueryService } from './client-query.service' +import { EventService } from './client-events.service' +import { ReplaceableEventService } from './client-replaceable-events.service' +import { MacroService, createBookstrService } from './client-macro.service' -/** Live timeline REQ: dead relays fail fast; EOSE caps “connected but silent” relays. */ -const SUBSCRIBE_RELAY_CONNECTION_TIMEOUT_MS = 2800 +/** Fired on `window` when session relay strike counts change (subscribe in single-relay UI). */ +export const JUMBLE_SESSION_RELAY_STRIKES_CHANGED = 'jumble:session-relay-strikes-changed' as const + +/** Live timeline REQ: EOSE caps “connected but silent” relays. */ const SUBSCRIBE_RELAY_EOSE_TIMEOUT_MS = 4800 /** @@ -111,6 +127,10 @@ function summarizeFiltersForRelayLog(filters: Filter[]): Record return out } +const READ_ONLY_RELAY_CONNECT_BOOST_URLS = new Set( + READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u) +) + /** Hostname (+ path when not "/") for readable publish / retry console lines. */ function relayHostForUserLog(url: string): string { const n = normalizeUrl(url) || url @@ -122,9 +142,6 @@ function relayHostForUserLog(url: string): string { return n } } -import { EventService } from './client-events.service' -import { ReplaceableEventService } from './client-replaceable-events.service' -import { MacroService, createBookstrService } from './client-macro.service' type TTimelineRef = [string, number] @@ -187,7 +204,7 @@ 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() - private static readonly SESSION_RELAY_FAILURE_STRIKE_THRESHOLD = 2 + public static readonly SESSION_RELAY_FAILURE_STRIKE_THRESHOLD = 2 /** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */ private sessionRelayPublishStats = new Map() @@ -200,8 +217,26 @@ class ClientService extends EventTarget { constructor() { super() + installNostrRelayAuthRaceMitigation() this.pool = new SimplePool() this.pool.trackRelays = true + const rawEnsureRelay = this.pool.ensureRelay.bind(this.pool) + this.pool.ensureRelay = async ( + url: string, + params?: { connectionTimeout?: number; abort?: AbortSignal } + ) => { + const n = normalizeUrl(url) || url + const base = params?.connectionTimeout ?? RELAY_POOL_CONNECTION_TIMEOUT_MS + const connectionTimeout = READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n) + ? Math.max(base, RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS) + : base + const relay = await rawEnsureRelay(url, { + ...params, + connectionTimeout + }) + applyRelayNip42AckTimeout(relay) + return relay + } // Initialize sub-services this.queryService = new QueryService(this.pool, { @@ -288,6 +323,23 @@ class ClientService extends EventTarget { this.signer = signer this.signerType = signerType this.queryService.setSigner(signer, signerType) + /** + * NIP-42: answer `AUTH` on the wire only for read-only aggregators (`READ_ONLY_RELAY_URLS`, e.g. aggr). + * They often require AUTH before REQ; `master`-style auth only on `CLOSED` is too late. Other relays stay + * on reactive `relay.auth()` after `auth-required` to avoid double-sign races with the wider pool. + */ + if (signer && signerType !== 'npub') { + this.pool.automaticallyAuth = (relayURL: string) => { + const n = normalizeUrl(relayURL) || relayURL + if (!READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n)) return null + return async (event: EventTemplate) => { + const evt = await queueRelayAuthSign(() => signer.signEvent(event)) + return evt as VerifiedEvent + } + } + } else { + this.pool.automaticallyAuth = undefined + } } /** NIP-66: fetch relay discovery events (30166) in background to supplement search/NIP support. */ @@ -843,6 +895,30 @@ class ClientService extends EventTarget { /** One failed publish or subscribe connection per normalized URL (accumulates until {@link SESSION_RELAY_FAILURE_STRIKE_THRESHOLD}). */ /** NOTICE "failed to fetch events" (relay DB/backend) — same session strike as a failed connection. */ + private notifySessionRelayStrikesChanged(affectedUrl?: string): void { + if (typeof window === 'undefined') return + window.dispatchEvent( + new CustomEvent(JUMBLE_SESSION_RELAY_STRIKES_CHANGED, { + detail: { url: affectedUrl } + }) + ) + } + + /** Strikes accumulated this session for this relay (connection / NOTICE failures). */ + getSessionRelayStrikeCountForUrl(url: string): number { + const n = normalizeAnyRelayUrl(url) || url + return this.publishStrikeCount.get(n) ?? 0 + } + + getSessionRelayFailureStrikeThreshold(): number { + return ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD + } + + /** True when this relay is skipped for reads/publishes until strikes are cleared. */ + isSessionRelayStrikedForReads(url: string): boolean { + return this.getSessionRelayStrikeCountForUrl(url) >= this.getSessionRelayFailureStrikeThreshold() + } + private recordRelayNoticeFetchFailure(url: string, noticeMessage: string) { const n = normalizeAnyRelayUrl(url) || url if (!n) return @@ -872,6 +948,7 @@ class ClientService extends EventTarget { strikes: count }) } + this.notifySessionRelayStrikesChanged(n) } private filterSessionStrikedRelays(urls: string[]): string[] { @@ -888,6 +965,7 @@ class ClientService extends EventTarget { if (this.publishStrikeCount.size === 0) return logger.info('[Relay] Session relay strikes cleared', { relayCount: this.publishStrikeCount.size }) this.publishStrikeCount.clear() + this.notifySessionRelayStrikesChanged() } /** @@ -900,6 +978,7 @@ class ClientService extends EventTarget { const had = this.publishStrikeCount.delete(n) if (had) { logger.info('[Relay] Session strikes cleared for relay (manual)', { url: n }) + this.notifySessionRelayStrikesChanged(n) } return had } @@ -926,6 +1005,7 @@ class ClientService extends EventTarget { batchUrlCount: unique.length, strikeEntriesCleared: cleared }) + this.notifySessionRelayStrikesChanged() return this.filterSessionStrikedRelays(unique) } return filtered @@ -1278,12 +1358,14 @@ class ClientService extends EventTarget { logger.warn(`[PublishEvent] Publish failed, checking if auth required`, { url, error: error.message }) if ( error instanceof Error && - error.message.startsWith('auth-required') && + isRelayAuthRequiredErrorMessage(error.message) && that.canSignerAuthenticateRelay() ) { logger.debug(`[PublishEvent] Auth required, attempting authentication`, { url }) - return relay - .auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt)) + 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) @@ -1852,6 +1934,9 @@ class ClientService extends EventTarget { : undefined const subs: { relayKey: string; close: () => void }[] = [] + /** Ignore a follow-up `closed by caller` while NIP-42 auth + resubscribe is in flight (parent `close()` must not finalize the batch early). */ + const nip42ResubscribePending = new Set() + const nip42HasAuthedOnce = new Set() const allOpened = Promise.all( groupedRequests.map(async ({ url, filters: relayFilters }, i) => { await that.queryService.acquireGlobalRelayConnectionSlot() @@ -1860,7 +1945,7 @@ class ClientService extends EventTarget { await that.queryService.acquireSubSlot(relayKey) let relay: AbstractRelay try { - relay = await that.pool.ensureRelay(url, { connectionTimeout: SUBSCRIBE_RELAY_CONNECTION_TIMEOUT_MS }) + relay = await that.pool.ensureRelay(url, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS }) patchRelayNoticeForFetchFailures(relay, relayKey, (u, m) => that.recordRelayNoticeFetchFailure(u, m) ) @@ -1888,15 +1973,24 @@ class ClientService extends EventTarget { }, oneose: () => handleEose(i), onclose: (reason: string) => { + if (isRelaySubscriptionClosedByCaller(reason) && nip42ResubscribePending.has(i)) { + return + } releaseOnce() - if (reason.startsWith('auth-required: ') && that.canSignerAuthenticateRelay()) { - relay - .auth(async (authEvt: EventTemplate) => { - const evt = await that.signer!.signEvent(authEvt) - if (!evt) throw new Error('sign event failed') - return evt as VerifiedEvent - }) + if ( + isRelayAuthRequiredCloseReason(reason) && + that.canSignerAuthenticateRelay() && + !nip42HasAuthedOnce.has(i) + ) { + nip42ResubscribePending.add(i) + applyRelayNip42AckTimeout(relay) + authenticateNip42Relay(relay, async (authEvt: EventTemplate) => { + const evt = await queueRelayAuthSign(() => that.signer!.signEvent(authEvt)) + if (!evt) throw new Error('sign event failed') + return evt as VerifiedEvent + }) .then(async () => { + nip42HasAuthedOnce.add(i) await that.queryService.acquireGlobalRelayConnectionSlot() try { await that.queryService.acquireSubSlot(relayKey) @@ -1905,12 +1999,13 @@ class ClientService extends EventTarget { let liveRelay: AbstractRelay try { liveRelay = await that.pool.ensureRelay(url, { - connectionTimeout: SUBSCRIBE_RELAY_CONNECTION_TIMEOUT_MS + connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS }) patchRelayNoticeForFetchFailures(liveRelay, relayKey, (u, m) => that.recordRelayNoticeFetchFailure(u, m) ) } catch (err) { + nip42ResubscribePending.delete(i) that.recordSessionRelayFailure(url) that.queryService.releaseSubSlot(relayKey) handleClose(i, (err as Error)?.message ?? String(err)) @@ -1952,7 +2047,9 @@ class ClientService extends EventTarget { sub2.close() } }) + nip42ResubscribePending.delete(i) } catch (err) { + nip42ResubscribePending.delete(i) releaseSlot2() handleClose(i, (err as Error)?.message ?? String(err)) } @@ -1960,12 +2057,13 @@ class ClientService extends EventTarget { that.queryService.releaseGlobalRelayConnectionSlot() } }) - .catch((err) => { - handleClose(i, `auth failed: ${(err as Error)?.message ?? err}`) + .catch(() => { + nip42ResubscribePending.delete(i) + handleClose(i, reason) }) return } - if (reason.startsWith('auth-required: ')) { + if (isRelayAuthRequiredCloseReason(reason)) { startLogin?.() } handleClose(i, reason) @@ -2427,7 +2525,7 @@ class ClientService extends EventTarget { } const relayForConn = usableAfterStrikes[0]! try { - await this.pool.ensureRelay(relayForConn, { connectionTimeout: 12_000 }) + await this.pool.ensureRelay(relayForConn, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS }) } catch (e) { this.recordSessionRelayFailure(relayForConn) const msg = e instanceof Error ? e.message : String(e)