Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
a372865944
  1. 13
      src/components/NoteList/index.tsx
  2. 57
      src/components/Relay/index.tsx
  3. 20
      src/constants.ts
  4. 6
      src/i18n/locales/de.ts
  5. 6
      src/i18n/locales/en.ts
  6. 91
      src/lib/nostr-relay-auth-patch.ts
  7. 15
      src/lib/relay-auth-sign-queue.ts
  8. 48
      src/lib/relay-nip42-auth.ts
  9. 8
      src/lib/relay-nip42-tuning.ts
  10. 3
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  11. 4
      src/pages/primary/RelayPage/index.tsx
  12. 8
      src/pages/secondary/RelayPage/index.tsx
  13. 8
      src/providers/NostrProvider/index.tsx
  14. 48
      src/services/client-query.service.ts
  15. 142
      src/services/client.service.ts

13
src/components/NoteList/index.tsx

@ -1,5 +1,5 @@ @@ -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' @@ -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( @@ -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 shards relay batch ends (often 1030s 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( @@ -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( @@ -1789,6 +1797,7 @@ const NoteList = forwardRef(
refreshCount,
feedEmptyToastGateTick,
feedSubscribeRelayOutcomes,
oneShotFetch,
t
])

57
src/components/Relay/index.tsx

@ -4,9 +4,12 @@ import RelayInfo from '@/components/RelayInfo' @@ -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< @@ -23,6 +26,23 @@ const Relay = forwardRef<
const internalNoteListRef = useRef<TNoteListRef>(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< @@ -62,6 +82,19 @@ const Relay = forwardRef<
}
}, [normalizedUrl, noteListRef])
const relayFeedSubRequests = useMemo<TFeedSubRequest[]>(() => {
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 <NotFound />
}
@ -69,6 +102,24 @@ const Relay = forwardRef< @@ -69,6 +102,24 @@ const Relay = forwardRef<
return (
<div className={className}>
<RelayInfo url={normalizedUrl} className="pt-3" />
{strikeCount > 0 ? (
<div
className="mx-4 mb-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-foreground dark:border-amber-400/35"
role="status"
>
<p className="font-medium">
{strikeCount >= strikeThreshold
? t('relaySessionStrikes.bannerSkipped', { threshold: strikeThreshold })
: t('relaySessionStrikes.bannerWarning', {
count: strikeCount,
threshold: strikeThreshold
})}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('relaySessionStrikes.refreshHint', { refresh: t('Refresh') })}
</p>
</div>
) : null}
{relayInfo?.supported_nips?.includes(50) && (
<div className="px-4 py-2">
<SearchInput
@ -80,9 +131,7 @@ const Relay = forwardRef< @@ -80,9 +131,7 @@ const Relay = forwardRef<
)}
<NormalFeed
ref={noteListRef}
subRequests={[
{ urls: [normalizedUrl], filter: debouncedInput ? { search: debouncedInput } : {} }
]}
subRequests={relayFeedSubRequests}
useFilterAsIs
allowKindlessRelayExplore
showFeedClientFilter

20
src/constants.ts

@ -68,6 +68,21 @@ export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000 @@ -68,6 +68,21 @@ export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000
/** Max merged URLs per REQ / timeline relay list (see `relay-url-priority`). */
export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS
/** `SimplePool.ensureRelay` WebSocket handshake timeout (parallel multi-relay + slow TLS). */
export const RELAY_POOL_CONNECTION_TIMEOUT_MS = 20_000
/**
* Minimum `ensureRelay` connect timeout for `READ_ONLY_RELAY_URLS` (NIP-42 aggregators): must outlast queued
* extension `signEvent` when many relays send `AUTH` at once.
*/
export const RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS = 45_000
/**
* nostr-tools `AbstractRelay.publishTimeout`: EVENT publish ACK and NIP-42 AUTH OK wait.
* Default 4400ms is too tight when a browser extension queues many `signEvent` calls.
*/
export const RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS = 90_000
/** Multi-relay queries and timeline initial REQ: after the first event, wait this long then close (query) or finalize EOSE (live feed) while keeping the subscription open for new events. */
export const FIRST_RELAY_RESULT_GRACE_MS = 5000
@ -83,6 +98,11 @@ export const SPELL_FEED_FIRST_RELAY_GRACE_MS = SPELL_FEED_LOADING_MAX_MS @@ -83,6 +98,11 @@ export const SPELL_FEED_FIRST_RELAY_GRACE_MS = SPELL_FEED_LOADING_MAX_MS
*/
export const FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT = 200
/**
* Kindless single-relay page REQ: explicit `limit`, no `kinds` (see NoteList `allowKindlessRelayExplore`).
*/
export const SINGLE_RELAY_KINDLESS_REQ_LIMIT = 200
/**
* Minimum time between full account network hydrates (NostrProvider: relay + replaceable fetch from relays).
* IndexedDB cache still applies on every load; this only skips redundant network merges after a recent run.

6
src/i18n/locales/de.ts

@ -634,6 +634,12 @@ export default { @@ -634,6 +634,12 @@ export default {
'Session relays clear strike': 'Wieder zulassen',
'Session relays clear strike hint':
'Relay aus der Session-Sperrliste nehmen; es wird wieder genutzt, bis neue Verbindungsfehler auftreten.',
'relaySessionStrikes.bannerWarning':
'Dieses Relay hat {{count}} Session-Strike(s) (Limit {{threshold}}) nach Verbindungs- oder Abfragefehlern.',
'relaySessionStrikes.bannerSkipped':
'Dieses Relay hat die Session-Fehlergrenze ({{threshold}} Strikes) erreicht und wird in diesem Tab für Lesen und Publizieren übersprungen.',
'relaySessionStrikes.refreshHint':
'Mit {{refresh}} werden die Strikes für dieses Relay zurückgesetzt und der Feed erneut geladen.',
successes: 'Erfolge',
None: 'Keine',
'Cache & offline storage': 'Cache & Offline-Speicher',

6
src/i18n/locales/en.ts

@ -623,6 +623,12 @@ export default { @@ -623,6 +623,12 @@ export default {
'Session relays clear strike': 'Allow again',
'Session relays clear strike hint':
'Remove this relay from the session block list; it will be used again until new connection failures.',
'relaySessionStrikes.bannerWarning':
'This relay has {{count}} session strike(s) (limit {{threshold}}) after connection or query failures.',
'relaySessionStrikes.bannerSkipped':
'This relay has reached the session failure limit ({{threshold}} strikes) and is skipped for reads and publishes in this tab.',
'relaySessionStrikes.refreshHint':
'Use {{refresh}} to clear strikes for this relay and load the feed again.',
successes: 'successes',
None: 'None',
'Cache & offline storage': 'Cache & offline storage',

91
src/lib/nostr-relay-auth-patch.ts

@ -0,0 +1,91 @@ @@ -0,0 +1,91 @@
import logger from '@/lib/logger'
import type { EventTemplate, VerifiedEvent } from 'nostr-tools'
import { AbstractRelay } from 'nostr-tools/abstract-relay'
type EventPubWaiter = {
resolve: (v: unknown) => void
reject: (e: unknown) => void
timeout: ReturnType<typeof setTimeout>
}
/** Duck-type nostr-tools internals (class typings mark several fields private). */
type RelayInternals = {
url: string
connectionPromise?: Promise<unknown>
openEventPublishes: Map<string, EventPubWaiter>
authPromise?: Promise<string>
}
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<void>
}
AbstractRelay.prototype.auth = function (
this: AbstractRelay,
signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>
) {
const r = asRelayInternals(this)
return (origAuth.call(this, signAuthEvent) as Promise<string>).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
})
}
}

15
src/lib/relay-auth-sign-queue.ts

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
let chain: Promise<unknown> = 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<T>(fn: () => Promise<T>): Promise<T> {
const run = chain.then(() => fn())
chain = run.then(
() => undefined,
() => undefined
)
return run
}

48
src/lib/relay-nip42-auth.ts

@ -0,0 +1,48 @@ @@ -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<VerifiedEvent>,
options?: { challengeWaitMs?: number; pollMs?: number }
): Promise<string> {
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)
}

8
src/lib/relay-nip42-tuning.ts

@ -0,0 +1,8 @@ @@ -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
}

3
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -1,5 +1,6 @@ @@ -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< @@ -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 [
{

4
src/pages/primary/RelayPage/index.tsx

@ -4,6 +4,7 @@ import Relay from '@/components/Relay' @@ -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<TPageRef, { url?: string }>(({ url }, ref) => { @@ -13,8 +14,9 @@ const RelayPage = forwardRef<TPageRef, { url?: string }>(({ url }, ref) => {
const feedRef = useRef<TNoteListRef>(null)
const runRefresh = useCallback(() => {
if (normalizedUrl) client.clearSessionRelayStrikeForUrl(normalizedUrl)
feedRef.current?.refresh()
}, [])
}, [normalizedUrl])
useImperativeHandle(
ref,

8
src/pages/secondary/RelayPage/index.tsx

@ -4,17 +4,21 @@ import { RefreshButton } from '@/components/RefreshButton' @@ -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<TNoteListRef>(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)

8
src/providers/NostrProvider/index.tsx

@ -784,12 +784,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -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(() => {

48
src/services/client-query.service.ts

@ -5,9 +5,17 @@ import { @@ -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 { @@ -526,6 +534,9 @@ export class QueryService {
: undefined
const subs: { relayKey: string; close: () => void }[] = []
const nip42ResubscribePending = new Set<number>()
/** Same idea as `master` subscribe: only one successful auth+resubscribe cycle per relay slot. */
const nip42HasAuthedOnce = new Set<number>()
const allOpened = Promise.all(
groupedRequests.map(async ({ url, filters: relayFilters }, i) => {
await this.acquireGlobalRelayConnectionSlot()
@ -556,23 +567,35 @@ export class QueryService { @@ -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 { @@ -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 { @@ -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)

142
src/services/client.service.ts

@ -7,6 +7,8 @@ import { @@ -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 { @@ -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 { @@ -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<string, unknown> @@ -111,6 +127,10 @@ function summarizeFiltersForRelayLog(filters: Filter[]): Record<string, unknown>
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 { @@ -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 { @@ -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<string, number>()
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<string, { successCount: number; sumLatencyMs: number }>()
@ -200,8 +217,26 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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<number>()
const nip42HasAuthedOnce = new Set<number>()
const allOpened = Promise.all(
groupedRequests.map(async ({ url, filters: relayFilters }, i) => {
await that.queryService.acquireGlobalRelayConnectionSlot()
@ -1860,7 +1945,7 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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)

Loading…
Cancel
Save