Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
42bc465039
  1. 4
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  2. 7
      src/components/KindFilter/index.tsx
  3. 6
      src/components/NormalFeed/index.tsx
  4. 6
      src/components/NoteList/index.tsx
  5. 4
      src/components/NoteOptions/useMenuActions.tsx
  6. 4
      src/components/Profile/ProfileFeedWithPins.tsx
  7. 22
      src/constants.ts
  8. 39
      src/lib/index-relay-http.ts
  9. 4
      src/lib/nostr-relay-auth-patch.ts
  10. 23
      src/lib/translate-client.ts
  11. 44
      src/lib/url.ts
  12. 4
      src/pages/primary/SpellsPage/index.tsx
  13. 4
      src/services/client-query.service.ts
  14. 440
      src/services/client.service.ts
  15. 11
      vite.config.ts

4
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 { parseLabSlice, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect' import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect'
import { import {
fetchTranslateLanguages, warmTranslateLanguagesOnce,
isTranslateConfigured, isTranslateConfigured,
translateApiLanguageCode, translateApiLanguageCode,
type TranslateLanguageOption type TranslateLanguageOption
@ -555,7 +555,7 @@ export default function AdvancedEventLabDialog({
} }
let cancelled = false let cancelled = false
setTranslateLoad('loading') setTranslateLoad('loading')
void fetchTranslateLanguages() void warmTranslateLanguagesOnce()
.then((list) => { .then((list) => {
if (cancelled) return if (cancelled) return
const resolved = buildResolvedTranslateMenuLanguageOptions(list) const resolved = buildResolvedTranslateMenuLanguageOptions(list)

7
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 { ExtendedKind, NIP71_VIDEO_KINDS, PROFILE_FEED_KINDS } from '@/constants'
import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities' import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { ListFilter } from 'lucide-react' import { ListFilter } from 'lucide-react'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
@ -65,7 +65,7 @@ export default function KindFilter({
feedKindFilterBypass, feedKindFilterBypass,
updateShowKinds, updateShowKinds,
updateFeedKindFilterBypass updateFeedKindFilterBypass
} = useKindFilter() } = useKindFilterOrDefaults()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds) const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
const [temporaryShowKind1OPs, setTemporaryShowKind1OPs] = useState(savedShowKind1OPs) const [temporaryShowKind1OPs, setTemporaryShowKind1OPs] = useState(savedShowKind1OPs)
@ -224,7 +224,8 @@ export default function KindFilter({
<p className="text-muted-foreground text-xs">kind {KIND_1111}</p> <p className="text-muted-foreground text-xs">kind {KIND_1111}</p>
</div> </div>
{KIND_FILTER_OPTIONS.map(({ kindGroup, label }) => { {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 ( return (
<div <div
key={kindGroup.join('-')} key={kindGroup.join('-')}

6
src/components/NormalFeed/index.tsx

@ -1,7 +1,7 @@
import NoteList, { TNoteListRef } from '@/components/NoteList' import NoteList, { TNoteListRef } from '@/components/NoteList'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import Tabs, { TabDefinition } from '@/components/Tabs' import Tabs, { TabDefinition } from '@/components/Tabs'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { PROFILE_MEDIA_TAB_KINDS } from '@/constants' import { PROFILE_MEDIA_TAB_KINDS } from '@/constants'
@ -45,7 +45,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
withKindFilter?: boolean withKindFilter?: boolean
/** /**
* When true (relay explorer page), list shows the full relay batch. When omitted, uses KindFilter "All Events" * When true (relay explorer page), list shows the full relay batch. When omitted, uses KindFilter "All Events"
* ({@link useKindFilter} / persisted bypass) on home feeds. * ({@link useKindFilterOrDefaults} / persisted bypass) on home feeds.
*/ */
showAllKinds?: boolean showAllKinds?: boolean
/** /**
@ -102,7 +102,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
) { ) {
const { hideUntrustedNotes } = useUserTrust() const { hideUntrustedNotes } = useUserTrust()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } = const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } =
useKindFilter() useKindFilterOrDefaults()
const [listMode, setListMode] = useState<TNoteListMode>(() => { const [listMode, setListMode] = useState<TNoteListMode>(() => {
const storedMode = storage.getNoteListMode() const storedMode = storage.getNoteListMode()
if (isMainFeed) { if (isMainFeed) {

6
src/components/NoteList/index.tsx

@ -3453,6 +3453,9 @@ const NoteList = forwardRef(
const listSourceEvents = timelineEventsForFilter const listSourceEvents = timelineEventsForFilter
const feedFullSearchActive = feedFullSearchEvents !== null const feedFullSearchActive = feedFullSearchEvents !== null
const progressiveWarmupTrimmed = progressiveWarmupQuery?.trim() 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 = const showRelaySubscribeWavePendingBanner =
!oneShotFetch && !oneShotFetch &&
!feedFullSearchActive && !feedFullSearchActive &&
@ -3460,7 +3463,8 @@ const NoteList = forwardRef(
relayCapabilityReady && relayCapabilityReady &&
timelineKey != null && timelineKey != null &&
feedSubscribeRelayOutcomes.length === 0 && feedSubscribeRelayOutcomes.length === 0 &&
feedTimelineEmptyUiReady feedTimelineEmptyUiReady &&
timelineEventsForFilter.length === 0
const showProgressiveLayersPendingBanner = const showProgressiveLayersPendingBanner =
Boolean(progressiveWarmupTrimmed) && progressiveLayersSearching && !feedFullSearchActive Boolean(progressiveWarmupTrimmed) && progressiveLayersSearching && !feedFullSearchActive
const showLookingForMoreEventsBanner = const showLookingForMoreEventsBanner =

4
src/components/NoteOptions/useMenuActions.tsx

@ -62,8 +62,8 @@ import {
translateNoteAndRelatedForDisplay translateNoteAndRelatedForDisplay
} from '@/lib/translate-note-for-menu' } from '@/lib/translate-note-for-menu'
import { import {
fetchTranslateLanguages,
isTranslateConfigured, isTranslateConfigured,
warmTranslateLanguagesOnce,
type TranslateLanguageOption type TranslateLanguageOption
} from '@/lib/translate-client' } from '@/lib/translate-client'
import { import {
@ -186,7 +186,7 @@ export function useMenuActions({
return return
} }
let cancelled = false let cancelled = false
void fetchTranslateLanguages() void warmTranslateLanguagesOnce()
.then((list) => { .then((list) => {
if (cancelled) return if (cancelled) return
setTranslateMenuOptions(buildResolvedTranslateMenuLanguageOptions(list)) setTranslateMenuOptions(buildResolvedTranslateMenuLanguageOptions(list))

4
src/components/Profile/ProfileFeedWithPins.tsx

@ -8,7 +8,7 @@ import { useProfilePins } from '@/hooks/useProfilePins'
import { useProfileTimeline } from '@/hooks/useProfileTimeline' import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { useProfileZapPollParticipation } from '@/hooks/useProfileZapPollParticipation' import { useProfileZapPollParticipation } from '@/hooks/useProfileZapPollParticipation'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
@ -42,7 +42,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
const { t } = useTranslation() const { t } = useTranslation()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap() 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. */ /** Profile timelines always show reposts; global kind filter still applies to other kinds. */
const profileTimelineShowKinds = useMemo(() => { const profileTimelineShowKinds = useMemo(() => {
if (showKinds.includes(kinds.Repost) && showKinds.includes(ExtendedKind.GENERIC_REPOST)) { if (showKinds.includes(kinds.Repost) && showKinds.includes(ExtendedKind.GENERIC_REPOST)) {

22
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 * 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). * 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`). */ /** Max merged URLs per REQ / timeline relay list (see `relay-url-priority`). */
export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS
@ -409,7 +420,6 @@ export const GIF_RELAY_URLS = [
] ]
export const SEARCHABLE_RELAY_URLS = [ export const SEARCHABLE_RELAY_URLS = [
'wss://freelay.sovbit.host',
'wss://search.nos.today', 'wss://search.nos.today',
'wss://nostr.wine', 'wss://nostr.wine',
'wss://orly-relay.imwald.eu', 'wss://orly-relay.imwald.eu',
@ -417,18 +427,12 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://relay.snort.social',
'wss://nos.lol', 'wss://nos.lol',
'wss://nostr.mom', 'wss://nostr.mom',
'wss://relay.noswhere.com', 'wss://relay.noswhere.com',
'wss://relay.wikifreedia.xyz', 'wss://relay.wikifreedia.xyz',
'wss://nostr.einundzwanzig.space', 'wss://nostr.einundzwanzig.space',
'wss://nostrelites.org', 'wss://nostr-pub.wellorder.net'
'wss://spatia-arcana.com',
'wss://nostr-pub.wellorder.net',
'wss://pyramid.fiatjaf.com/',
'wss://nostr.lopp.social/',
'wss://relay.dergigi.com/'
] ]
export const PROFILE_RELAY_URLS = [ export const PROFILE_RELAY_URLS = [

39
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 * **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`). * 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 { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import logger from '@/lib/logger' 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 type { Filter, Event as NEvent } from 'nostr-tools'
import { verifyEvent } from 'nostr-tools' import { verifyEvent } from 'nostr-tools'
@ -55,7 +60,7 @@ function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> {
return body return body
} }
const INDEX_RELAY_HTTP_WARN_COOLDOWN_MS = 5000 const INDEX_RELAY_HTTP_WARN_COOLDOWN_MS = 25_000
const lastIndexRelayHttpWarnAtByEndpoint = new Map<string, number>() const lastIndexRelayHttpWarnAtByEndpoint = new Map<string, number>()
const DEV_INDEX_RELAY_TRANSPORT_HINT_MS = 60_000 const DEV_INDEX_RELAY_TRANSPORT_HINT_MS = 60_000
@ -96,7 +101,10 @@ export class IndexRelayTransportError extends Error {
} }
function isDevViteIndexRelayProxyPath(endpoint: string): boolean { 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 { function maybeLogDevIndexRelayUnreachableHint(): void {
@ -167,20 +175,27 @@ function rawToVerifiedEvent(raw: Record<string, unknown>): NEvent | null {
/** /**
* Query one HTTP index relay. Runs one POST per filter when given an array. * 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, * When every filter attempt fails with an HTTP response or a non-transport error and no events are returned,
* {@link options.onHardFailure} runs once (used for session strike parity with WebSocket relays). * {@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( export async function queryIndexRelay(
baseUrl: string, baseUrl: string,
filter: Filter | Filter[], filter: Filter | Filter[],
options?: { signal?: AbortSignal; onHardFailure?: () => void } options?: { signal?: AbortSignal; onHardFailure?: () => void }
): Promise<NEvent[]> { ): Promise<NEvent[]> {
const base = devProxyLoopbackHttpRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl) const base = devHttpIndexRelayBaseForFetch(baseUrl)
const endpoint = indexRelayFilterUrl(base) const endpoint = indexRelayFilterUrl(base)
const filters = Array.isArray(filter) ? filter : [filter] const filters = Array.isArray(filter) ? filter : [filter]
const out: NEvent[] = [] const out: NEvent[] = []
const seen = new Set<string>() const seen = new Set<string>()
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) { for (const f of filters) {
const body = nostrFilterToIndexRelayBody(filterForIndexRelay(f)) const body = nostrFilterToIndexRelayBody(filterForIndexRelay(f))
try { try {
@ -195,7 +210,7 @@ export async function queryIndexRelay(
timeoutMs: 25_000 timeoutMs: 25_000
}) })
if (!res.ok) { if (!res.ok) {
sawHardFailure = true strikeWorthyHttpFailure = true
if (isDevViteIndexRelayProxyPath(endpoint)) { if (isDevViteIndexRelayProxyPath(endpoint)) {
let detail = '' let detail = ''
try { try {
@ -233,15 +248,15 @@ export async function queryIndexRelay(
} }
} catch (e) { } catch (e) {
if ((e as Error).name === 'AbortError') throw e if ((e as Error).name === 'AbortError') throw e
sawHardFailure = true
if (isIndexRelayTransportFailure(e)) { if (isIndexRelayTransportFailure(e)) {
handleFilterTransportFailure(endpoint, e) handleFilterTransportFailure(endpoint, e)
} else { } else {
strikeWorthyHttpFailure = true
warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', { endpoint, error: e }) 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) // 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 // 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. // 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, event: NEvent,
options?: { signal?: AbortSignal } options?: { signal?: AbortSignal }
): Promise<void> { ): Promise<void> {
const base = devProxyLoopbackHttpRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl) const base = devHttpIndexRelayBaseForFetch(baseUrl)
const endpoint = indexRelayPublishUrl(base) const endpoint = indexRelayPublishUrl(base)
try { try {
const res = await fetchWithTimeout(endpoint, { const res = await fetchWithTimeout(endpoint, {

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

@ -65,7 +65,7 @@ export function patchPoolRelayAuthRaceAndFeedback(relay: object): void {
const r = asRelayInternals(this) const r = asRelayInternals(this)
if (!r.connectionPromise && typeof message === 'string' && message.startsWith('["AUTH"')) { if (!r.connectionPromise && typeof message === 'string' && message.startsWith('["AUTH"')) {
abortPendingAuthForDeadSocket(r, message) 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 url: r.url
}) })
return Promise.resolve() return Promise.resolve()
@ -91,7 +91,7 @@ export function patchPoolRelayAuthRaceAndFeedback(relay: object): void {
msg.includes('relay connection closed before AUTH') || msg.includes('relay connection closed before AUTH') ||
/relay connection closed/i.test(msg) /relay connection closed/i.test(msg)
if (benignRace) { 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 r.authPromise = undefined
return '' return ''
} }

23
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 let languagesCache: { list: TranslateLanguageOption[]; at: number; fromFailure?: boolean } | null = null
const LANGUAGES_CACHE_TTL_MS = 60_000 const LANGUAGES_CACHE_TTL_MS = 60_000
/** After HTTP/parse failure, cache empty so each {@link useMenuActions} mount does not re-request. */ /** 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 = 120_000 const LANGUAGES_FAILURE_CACHE_TTL_MS = 86_400_000
let languagesFetchInFlight: Promise<TranslateLanguageOption[]> | null = null let languagesFetchInFlight: Promise<TranslateLanguageOption[]> | null = null
let lastLanguagesFailureLogAt = 0 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<TranslateLanguageOption[]> | null = null
/** One shared `/languages` request per flight; safe to call from every note’s menu hook. */
export function warmTranslateLanguagesOnce(): Promise<TranslateLanguageOption[]> {
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[] { function parseLanguagesResponse(data: unknown): TranslateLanguageOption[] {
if (!Array.isArray(data)) return [] if (!Array.isArray(data)) return []
const out: TranslateLanguageOption[] = [] const out: TranslateLanguageOption[] = []
@ -168,6 +186,7 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption
export function clearTranslateLanguagesCache(): void { export function clearTranslateLanguagesCache(): void {
languagesCache = null languagesCache = null
advertisedTranslateApiCodes = null advertisedTranslateApiCodes = null
warmTranslateLanguagesPromise = null
} }
/** /**

44
src/lib/url.ts

@ -49,6 +49,30 @@ export function devProxyLoopbackHttpRelayBase(normalizedBase: string): string {
return `${window.location.origin}/dev-index-relay` return `${window.location.origin}/dev-index-relay`
} }
/**
* Hosts whose HTTPS index API breaks in the browser (CORS preflight rejects `Content-Type`, etc.).
* In dev, `index-relay-http` rewrites the base to same-origin `/dev-cors-index-relay` (see `vite.config.ts`).
* Keep this list tiny each entry needs a matching Vite `proxy` target.
*/
const DEV_HTTPS_INDEX_RELAY_CORS_PROXY_HOSTS = new Set(['nos.lol'])
/**
* Rewrite `https://nos.lol/...` index relay bases to the Vite dev proxy so POST /api/events/filter works.
* Chain after `devProxyLoopbackHttpRelayBase`: `devProxyCorsProblematicHttpsIndexRelayBase(devProxyLoopbackHttpRelayBase(url))`.
*/
export function devProxyCorsProblematicHttpsIndexRelayBase(normalizedBase: string): string {
if (import.meta.env.PROD || typeof window === 'undefined') return normalizedBase
let u: URL
try {
u = new URL(normalizedBase)
} catch {
return normalizedBase
}
if (u.protocol !== 'https:') return normalizedBase
if (!DEV_HTTPS_INDEX_RELAY_CORS_PROXY_HOSTS.has(u.hostname.toLowerCase())) return normalizedBase
return `${window.location.origin}/dev-cors-index-relay`
}
/** /**
* Normalize relay URL for deduplication: WebSocket URLs via {@link normalizeUrl}, HTTPS index relays via {@link normalizeHttpRelayUrl}. * Normalize relay URL for deduplication: WebSocket URLs via {@link normalizeUrl}, HTTPS index relays via {@link normalizeHttpRelayUrl}.
*/ */
@ -57,6 +81,26 @@ export function normalizeAnyRelayUrl(url: string): string {
return normalizeUrl(url) || '' return normalizeUrl(url) || ''
} }
/**
* Stable key for per-relay session counters (strikes, publish stats): HTTP NIP-86 bases map to the same hosts
* `wss://…` URL so `https://nos.lol` and `wss://nos.lol` share one bucket (fixes preset vs all striked mismatch).
*/
export function canonicalRelayStrikeKey(url: string): string {
const stepped = (normalizeAnyRelayUrl(url) || url.trim()).trim()
if (!stepped) return ''
if (isHttpRelayUrl(stepped)) {
const base = normalizeHttpRelayUrl(stepped) || stepped
try {
const u = new URL(base)
const host = u.hostname + (u.port ? `:${u.port}` : '')
return normalizeUrl(`wss://${host}`) || normalizeAnyRelayUrl(stepped) || base
} catch {
return normalizeAnyRelayUrl(stepped) || stepped
}
}
return stepped
}
// copy from nostr-tools/utils // copy from nostr-tools/utils
export function normalizeUrl(url: string): string { export function normalizeUrl(url: string): string {
try { try {

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

@ -27,7 +27,7 @@ import { showPublishingError } from '@/lib/publishing-feedback'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useBookmarks } from '@/providers/bookmarks-context' import { useBookmarks } from '@/providers/bookmarks-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@ -334,7 +334,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
showKind1OPs, showKind1OPs,
showKind1Replies, showKind1Replies,
showKind1111 showKind1111
} = useKindFilter() } = useKindFilterOrDefaults()
const hideRepliesFollowing = useNoteListHideReplies() const hideRepliesFollowing = useNoteListHideReplies()
const [spells, setSpells] = useState<Event[]>([]) const [spells, setSpells] = useState<Event[]>([])
/** Ordered spell event ids (newest star first). Drives picker order + bookmark list sync when logged in. */ /** Ordered spell event ids (newest star first). Drives picker order + bookmark list sync when logged in. */

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

@ -644,7 +644,9 @@ export class QueryService {
await this.acquireSubSlot(relayKey) await this.acquireSubSlot(relayKey)
let relay: AbstractRelay let relay: AbstractRelay
try { 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) patchRelayNoticeForFetchFailures(relay, relayKey, this.onRelayNoticeStrike)
} catch (err) { } catch (err) {
this.onRelayConnectionFailure?.(relayKey) this.onRelayConnectionFailure?.(relayKey)

440
src/services/client.service.ts

@ -12,12 +12,16 @@ import {
relayFilterIncludesSocialKindBlockedKind, relayFilterIncludesSocialKindBlockedKind,
relaysAfterSocialKindBlockedStrip, relaysAfterSocialKindBlockedStrip,
SOCIAL_KIND_BLOCKED_RELAY_URLS, SOCIAL_KIND_BLOCKED_RELAY_URLS,
MAX_CONCURRENT_RELAY_CONNECTIONS,
MAX_PUBLISH_RELAYS, MAX_PUBLISH_RELAYS,
PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS,
PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS,
RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS,
RELAY_POOL_CONNECTION_TIMEOUT_MS, RELAY_POOL_CONNECTION_TIMEOUT_MS,
RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS, RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS,
TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY, TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY,
OUTBOX_PUBLISH_RETRY_DELAY_MS, OUTBOX_PUBLISH_RETRY_DELAY_MS,
DEFAULT_FAVORITE_RELAYS,
NIP66_DISCOVERY_RELAY_URLS, NIP66_DISCOVERY_RELAY_URLS,
PROFILE_FETCH_RELAY_URLS, PROFILE_FETCH_RELAY_URLS,
READ_ONLY_RELAY_URLS, READ_ONLY_RELAY_URLS,
@ -115,7 +119,15 @@ import {
relayUrlsStripExtendedTagReqBlocked relayUrlsStripExtendedTagReqBlocked
} from '@/lib/relay-extended-tag-req-blocks' } from '@/lib/relay-extended-tag-req-blocks'
import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize' 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 { isSafari } from '@/lib/utils'
import { import {
ISigner, 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. * {@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 publishStrikeCount = new Map<string, number>()
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<string, number>()
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. */ /** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */
private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>() private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>()
@ -313,7 +328,8 @@ class ClientService extends EventTarget {
// Initialize sub-services // Initialize sub-services
this.queryService = new QueryService(this.pool, { this.queryService = new QueryService(this.pool, {
shouldSkipRelayForSession: (url) => { shouldSkipRelayForSession: (url) => {
const key = normalizeAnyRelayUrl(url) || url const key = canonicalRelayStrikeKey(url)
if (!key) return false
return ( return (
(this.publishStrikeCount.get(key) ?? 0) >= (this.publishStrikeCount.get(key) ?? 0) >=
ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
@ -602,33 +618,39 @@ class ClientService extends EventTarget {
event: NEvent, event: NEvent,
favoriteRelayUrls: string[] = [] favoriteRelayUrls: string[] = []
): Promise<string[]> { ): Promise<string[]> {
let userWriteSet = new Set<string>() 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 { try {
const rl = await this.fetchRelayList(event.pubkey) lists = await this.fetchRelayLists(pubkeyOrder)
} catch {
lists = []
}
let userWriteSet = new Set<string>()
const authorRl = lists[0]
if (authorRl) {
userWriteSet = new Set([ userWriteSet = new Set([
...(rl?.write ?? []).map((u) => normalizeUrl(u) || u).filter((u): u is string => !!u), ...(authorRl.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.httpWrite ?? []).map((u) => normalizeHttpRelayUrl(u) || u).filter((u): u is string => !!u)
]) ])
} catch {
// ignore
} }
const ctx = this.collectReplyAndMentionPubkeys(event)
let authorReadSet = new Set<string>() let authorReadSet = new Set<string>()
if (ctx.length > 0) { for (let i = 1; i < lists.length; i++) {
const lists = await this.fetchRelayLists(ctx) const list = lists[i]
for (const list of lists) { if (!list) continue
for (const u of list?.read ?? []) { for (const u of list.read ?? []) {
const n = normalizeUrl(u) || u const n = normalizeUrl(u) || u
if (n) authorReadSet.add(n) if (n) authorReadSet.add(n)
} }
for (const u of list?.httpRead ?? []) { for (const u of list.httpRead ?? []) {
const n = normalizeHttpRelayUrl(u) || u const n = normalizeHttpRelayUrl(u) || u
if (n) authorReadSet.add(n) if (n) authorReadSet.add(n)
}
} }
authorReadSet = new Set(filterContextAuthorReadRelaysForPublish([...authorReadSet]))
} }
authorReadSet = new Set(filterContextAuthorReadRelaysForPublish([...authorReadSet]))
const favSet = new Set( const favSet = new Set(
favoriteRelayUrls.map((f) => normalizeUrl(f) || f).filter((u): u is string => !!u) favoriteRelayUrls.map((f) => normalizeUrl(f) || f).filter((u): u is string => !!u)
@ -691,7 +713,7 @@ class ClientService extends EventTarget {
relayCount: relayUrls.length relayCount: relayUrls.length
}) })
resolve(fallbackOrder()) 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). */ /** Strikes accumulated this session for this relay (connection / NOTICE failures). */
getSessionRelayStrikeCountForUrl(url: string): number { getSessionRelayStrikeCountForUrl(url: string): number {
const n = normalizeAnyRelayUrl(url) || url const n = canonicalRelayStrikeKey(url)
if (!n) return 0
return this.publishStrikeCount.get(n) ?? 0 return this.publishStrikeCount.get(n) ?? 0
} }
@ -1101,7 +1124,7 @@ class ClientService extends EventTarget {
} }
private recordRelayNoticeFetchFailure(url: string, noticeMessage: string) { private recordRelayNoticeFetchFailure(url: string, noticeMessage: string) {
const n = normalizeAnyRelayUrl(url) || url const n = canonicalRelayStrikeKey(url)
if (!n) return if (!n) return
const prev = this.publishStrikeCount.get(n) ?? 0 const prev = this.publishStrikeCount.get(n) ?? 0
if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
@ -1115,12 +1138,21 @@ class ClientService extends EventTarget {
} }
private recordSessionRelayFailure(url: string) { private recordSessionRelayFailure(url: string) {
const n = normalizeAnyRelayUrl(url) || url const n = canonicalRelayStrikeKey(url)
if (!n) return if (!n) return
if (isLocalNetworkUrl(n)) {
return
}
const prev = this.publishStrikeCount.get(n) ?? 0 const prev = this.publishStrikeCount.get(n) ?? 0
if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
return 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 const count = prev + 1
this.publishStrikeCount.set(n, count) this.publishStrikeCount.set(n, count)
if (count === ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { if (count === ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
@ -1134,7 +1166,8 @@ class ClientService extends EventTarget {
private filterSessionStrikedRelays(urls: string[]): string[] { private filterSessionStrikedRelays(urls: string[]): string[] {
return urls.filter((u) => { 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 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). * If every URL was session-striked, clear strikes once so reads/publishes can retry (mobile WebSocket churn).
*/ */
clearSessionRelayStrikes(): void { 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 }) logger.info('[Relay] Session relay strikes cleared', { relayCount: this.publishStrikeCount.size })
this.publishStrikeCount.clear() this.publishStrikeCount.clear()
this.sessionRelayFailureLastIncrementAt.clear()
this.notifySessionRelayStrikesChanged() this.notifySessionRelayStrikesChanged()
} }
@ -1154,9 +1188,10 @@ class ClientService extends EventTarget {
* until new failures accrue (same counter as {@link clearSessionRelayStrikes}). * until new failures accrue (same counter as {@link clearSessionRelayStrikes}).
*/ */
clearSessionRelayStrikeForUrl(url: string): boolean { clearSessionRelayStrikeForUrl(url: string): boolean {
const n = normalizeAnyRelayUrl(url) || url const n = canonicalRelayStrikeKey(url)
if (!n) return false if (!n) return false
const had = this.publishStrikeCount.delete(n) const had = this.publishStrikeCount.delete(n)
this.sessionRelayFailureLastIncrementAt.delete(n)
if (had) { if (had) {
logger.info('[Relay] Session strikes cleared for relay (manual)', { url: n }) logger.info('[Relay] Session strikes cleared for relay (manual)', { url: n })
this.notifySessionRelayStrikesChanged(n) this.notifySessionRelayStrikesChanged(n)
@ -1170,9 +1205,12 @@ class ClientService extends EventTarget {
clearSessionRelayStrikesForUrls(urls: string[]): number { clearSessionRelayStrikesForUrls(urls: string[]): number {
let cleared = 0 let cleared = 0
for (const url of urls) { for (const url of urls) {
const n = normalizeAnyRelayUrl(url) || url const n = canonicalRelayStrikeKey(url)
if (!n) continue if (!n) continue
if (this.publishStrikeCount.delete(n)) cleared += 1 if (this.publishStrikeCount.delete(n)) {
cleared += 1
this.sessionRelayFailureLastIncrementAt.delete(n)
}
} }
if (cleared > 0) { if (cleared > 0) {
logger.info('[Relay] Session strikes cleared for relays (added to publish selection)', { 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) { if (filtered.length === 0 && unique.length > 0) {
let cleared = 0 let cleared = 0
for (const u of unique) { for (const u of unique) {
// HTTP index relays (CORS down, wrong origin) do not recover like WebSockets; clearing their strikes const n = canonicalRelayStrikeKey(u)
// here caused retry storms with many parallel fetchEvents hitting the same dead endpoint. if (n && this.publishStrikeCount.delete(n)) {
if (isHttpRelayUrl(u)) continue cleared += 1
const n = normalizeAnyRelayUrl(u) || u this.sessionRelayFailureLastIncrementAt.delete(n)
if (n && this.publishStrikeCount.delete(n)) cleared += 1 }
} }
if (cleared === 0) return filtered if (cleared === 0) return filtered
logger.info('[Relay] Batch was all session-striked — cleared strikes for this batch only', { 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. */ /** Record a successful publish and its latency for session-based preference when selecting random relays. */
recordPublishSuccess(url: string, latencyMs: number) { recordPublishSuccess(url: string, latencyMs: number) {
const n = normalizeAnyRelayUrl(url) || url const n = canonicalRelayStrikeKey(url)
if (!n) return
const cur = this.sessionRelayPublishStats.get(n) const cur = this.sessionRelayPublishStats.get(n)
if (cur) { if (cur) {
cur.successCount += 1 cur.successCount += 1
@ -1233,7 +1272,7 @@ class ClientService extends EventTarget {
const out: string[] = [] const out: string[] = []
for (const [url, stats] of this.sessionRelayPublishStats.entries()) { for (const [url, stats] of this.sessionRelayPublishStats.entries()) {
if (stats.successCount < 1) continue if (stats.successCount < 1) continue
const n = normalizeAnyRelayUrl(url) || url const n = canonicalRelayStrikeKey(url)
if (!n || readOnlySet.has(n)) continue if (!n || readOnlySet.has(n)) continue
if ((this.publishStrikeCount.get(n) ?? 0) >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) continue if ((this.publishStrikeCount.get(n) ?? 0) >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) continue
out.push(n) out.push(n)
@ -1258,9 +1297,14 @@ class ClientService extends EventTarget {
presetStriked: string[] presetStriked: string[]
} { } {
const presetSet = new Set<string>() const presetSet = new Set<string>()
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 const n = normalizeUrl(u) || u
if (n) presetSet.add(n) if (n) presetSet.add(canonicalRelayStrikeKey(n))
} }
const preset = Array.from(presetSet) const preset = Array.from(presetSet)
const strikedUrls = Array.from(this.publishStrikeCount.entries()) const strikedUrls = Array.from(this.publishStrikeCount.entries())
@ -1291,19 +1335,23 @@ class ClientService extends EventTarget {
.map((u) => normalizeAnyRelayUrl(u) || u) .map((u) => normalizeAnyRelayUrl(u) || u)
.filter((n) => n && !readOnlySet.has(n)) .filter((n) => n && !readOnlySet.has(n))
const unique = Array.from(new Set(normalizedCandidates)) const unique = Array.from(new Set(normalizedCandidates))
const notStruckOut = unique.filter( const notStruckOut = unique.filter((u) => {
(n) => (this.publishStrikeCount.get(n) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD const n = canonicalRelayStrikeKey(u)
) if (!n) return false
return (this.publishStrikeCount.get(n) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
})
const preferred: string[] = [] const preferred: string[] = []
const rest: string[] = [] const rest: string[] = []
for (const url of notStruckOut) { 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) if (stats && stats.successCount >= 1) preferred.push(url)
else rest.push(url) else rest.push(url)
} }
preferred.sort((a, b) => { preferred.sort((a, b) => {
const sa = this.sessionRelayPublishStats.get(a)! const sa = this.sessionRelayPublishStats.get(canonicalRelayStrikeKey(a))
const sb = this.sessionRelayPublishStats.get(b)! const sb = this.sessionRelayPublishStats.get(canonicalRelayStrikeKey(b))
if (!sa || !sb) return 0
if (sb.successCount !== sa.successCount) return sb.successCount - sa.successCount if (sb.successCount !== sa.successCount) return sb.successCount - sa.successCount
const avgA = sa.sumLatencyMs / sa.successCount const avgA = sa.sumLatencyMs / sa.successCount
const avgB = sb.sumLatencyMs / sb.successCount const avgB = sb.sumLatencyMs / sb.successCount
@ -1436,10 +1484,31 @@ class ClientService extends EventTarget {
publishOpBatch.logEnd(status) 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 let hasResolved = false
// Add a global timeout to prevent hanging - use 30 seconds for faster feedback
const globalTimeout = setTimeout(() => { const globalTimeout = setTimeout(() => {
if (hasResolved) { if (hasResolved) {
logger.debug('[PublishEvent] Already resolved, ignoring timeout') logger.debug('[PublishEvent] Already resolved, ignoring timeout')
@ -1481,25 +1550,29 @@ class ClientService extends EventTarget {
totalCount: uniqueRelayUrls.length totalCount: uniqueRelayUrls.length
}) })
} }
}, 30_000) // 30 seconds global timeout (reduced from 2 minutes) }, publishGlobalDeadlineMs)
logger.debug('[PublishEvent] Starting Promise.allSettled for all relays') logger.debug('[PublishEvent] Starting Promise.allSettled for all relays')
const relayPublishAllSettled = Promise.allSettled( const relayPublishAllSettled = Promise.allSettled(
uniqueRelayUrls.map(async (url, index) => { uniqueRelayUrls.map(async (url, index) => {
// eslint-disable-next-line @typescript-eslint/no-this-alias // eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this const that = this
await that.queryService.acquireGlobalRelayConnectionSlot()
const startMs = Date.now() const startMs = Date.now()
logger.debug(`[PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url }) logger.debug(`[PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url })
const isLocal = isLocalNetworkUrl(url) const isLocal = isLocalNetworkUrl(url)
const connectionTimeout = 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 publishTimeout = isLocal ? 5_000 : 8_000 // 5s for local, 8s for remote 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 // Set up a per-relay timeout to ensure we always reach the finally block
const relayTimeout = setTimeout(() => { const relayTimeout = setTimeout(() => {
logger.warn(`[PublishEvent] Per-relay timeout for ${url}`, { connectionTimeout, publishTimeout }) logger.warn(`[PublishEvent] Per-relay watchdog fired for ${url}`, {
// This will be caught in the catch block if the promise is still pending connectionTimeout,
}, connectionTimeout + publishTimeout + 2_000) // Add 2s buffer publishAckBudgetMs
})
}, connectionTimeout + publishAckBudgetMs + 2_000)
try { try {
if (isHttpRelayUrl(url)) { if (isHttpRelayUrl(url)) {
@ -1508,7 +1581,10 @@ class ClientService extends EventTarget {
await Promise.race([ await Promise.race([
publishEventToHttpRelay(base, event), publishEventToHttpRelay(base, event),
new Promise<never>((_, reject) => new Promise<never>((_, 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) that.recordPublishSuccess(url, Date.now() - startMs)
@ -1518,89 +1594,120 @@ class ClientService extends EventTarget {
return return
} }
// For local relays, add a connection timeout
let relay: Relay let relay: Relay
logger.debug(`[PublishEvent] Ensuring relay connection`, { url, isLocal, connectionTimeout }) for (let wsAttempt = 0; wsAttempt < 2; wsAttempt++) {
try {
const connectionPromise = isLocal logger.debug(`[PublishEvent] Ensuring relay connection`, {
? Promise.race([ url,
this.pool.ensureRelay(url), isLocal,
new Promise<Relay>((_, reject) => connectionTimeout,
setTimeout(() => reject(new Error('Local relay connection timeout')), connectionTimeout) wsAttempt
) })
])
: Promise.race([
this.pool.ensureRelay(url),
new Promise<Relay>((_, reject) =>
setTimeout(() => reject(new Error('Remote relay connection timeout')), connectionTimeout)
)
])
relay = await connectionPromise const ensureOpts = { connectionTimeout }
logger.debug(`[PublishEvent] Relay connected`, { url }) const connectionPromise = isLocal
const relayKeyPub = normalizeUrl(url) || url ? Promise.race([
patchRelayNoticeForFetchFailures(relay as unknown as AbstractRelay, relayKeyPub, (u, m) => this.pool.ensureRelay(url, ensureOpts),
that.recordRelayNoticeFetchFailure(u, m) new Promise<Relay>((_, reject) =>
) setTimeout(() => reject(new Error('Local relay connection timeout')), connectionTimeout)
)
])
: Promise.race([
this.pool.ensureRelay(url, ensureOpts),
new Promise<Relay>((_, 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.publishTimeout = publishTimeout applyRelayNip42AckTimeout(relay as unknown as AbstractRelay)
logger.debug(`[PublishEvent] Publishing to relay`, { url }) logger.debug(`[PublishEvent] Publishing to relay`, { url })
// Wrap publish in a timeout promise const publishPromise = relay
const publishPromise = relay .publish(event)
.publish(event) .then(() => {
.then(() => { logger.debug(`[PublishEvent] Successfully published to relay`, { url })
logger.debug(`[PublishEvent] Successfully published to relay`, { url }) that.recordPublishSuccess(url, Date.now() - startMs)
that.recordPublishSuccess(url, Date.now() - startMs) this.trackEventSeenOn(event.id, relay)
this.trackEventSeenOn(event.id, relay) successCount++
successCount++ relayStatuses.push({ url, success: true })
relayStatuses.push({ url, success: true }) })
}) .catch((error) => {
.catch((error) => { logger.warn(`[PublishEvent] Publish failed, checking if auth required`, {
logger.warn(`[PublishEvent] Publish failed, checking if auth required`, { url, error: error.message }) url,
if ( error: error.message
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 })
}) })
.catch((authError) => { if (
logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authError.message }) error instanceof Error &&
errors.push({ url, error: authError }) isRelayAuthRequiredErrorMessage(error.message) &&
relayStatuses.push({ url, success: false, error: authError.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) 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)
}
})
// Add a timeout wrapper for the entire publish operation await Promise.race([
await Promise.race([ publishPromise,
publishPromise, new Promise<void>((_, reject) =>
new Promise<void>((_, reject) => setTimeout(
setTimeout(() => reject(new Error(`Publish timeout after ${publishTimeout}ms`)), publishTimeout) () => 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
}
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) { } catch (error) {
const softHttpDown = const softHttpDown =
isHttpRelayUrl(url) && isHttpRelayUrl(url) &&
@ -1624,7 +1731,6 @@ class ClientService extends EventTarget {
}) })
that.recordSessionRelayFailure(url) that.recordSessionRelayFailure(url)
} finally { } finally {
that.queryService.releaseGlobalRelayConnectionSlot()
clearTimeout(relayTimeout) clearTimeout(relayTimeout)
const currentFinished = ++finishedCount const currentFinished = ++finishedCount
logger.debug(`[PublishEvent] Relay finished`, { logger.debug(`[PublishEvent] Relay finished`, {
@ -3650,21 +3756,74 @@ class ClientService extends EventTarget {
) )
const budgetMs = PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS 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 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 authors 10002 was never cached.
*/
const hydrateRelayListsFromNetwork = async (): Promise<{
relayEvents: (NEvent | null | undefined)[] relayEvents: (NEvent | null | undefined)[]
httpRelayEvents: (NEvent | null | undefined)[] httpRelayEvents: (NEvent | null | undefined)[]
cacheRelayEvents: (NEvent | null | undefined)[] cacheRelayEvents: (NEvent | null | undefined)[]
}> => { }> => {
const relayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( const relayEvents: (NEvent | null | undefined)[] = pubkeys.map((_, i) =>
pubkeys, storedRelayEvents[i] != null ? (storedRelayEvents[i] as NEvent) : undefined
kinds.RelayList
) )
const httpRelayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( const httpRelayEvents: (NEvent | null | undefined)[] = pubkeys.map((_, i) =>
pubkeys, storedHttpRelayEvents[i] != null ? (storedHttpRelayEvents[i] as NEvent) : undefined
ExtendedKind.HTTP_RELAY_LIST
) )
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( const cacheRelayEvents = await this.fetchCacheRelayEventsFromMultipleSources(
pubkeys, pubkeys,
relayEvents, relayEvents,
@ -3697,10 +3856,11 @@ class ClientService extends EventTarget {
} }
const raced = await Promise.race([ const raced = await Promise.race([
networkBundle(), hydrateRelayListsFromNetwork(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), budgetMs)) new Promise<null>((resolve) => setTimeout(() => resolve(null), budgetMs))
]) ])
if (raced != null) { if (raced != null) {
this.refreshRelayListsFromNetwork(pubkeys, storedRelayEvents)
return this.mergeRelayListsBundle( return this.mergeRelayListsBundle(
pubkeys, pubkeys,
raced.relayEvents, raced.relayEvents,

11
vite.config.ts

@ -157,6 +157,17 @@ export default defineConfig(({ mode }) => {
target: devIndexRelayTarget, target: devIndexRelayTarget,
changeOrigin: true, changeOrigin: true,
rewrite: (p) => p.replace(/^\/dev-index-relay/, '') || '/' 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/, '') || '/'
} }
} }
}, },

Loading…
Cancel
Save