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/, '') || '/'
}
}
},