From f0ac2f0c263c84948da32664a0b469fad9a85253 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 28 Mar 2026 11:45:54 +0100 Subject: [PATCH] fix proxying and reactions in thread --- src/lib/index-relay-http.ts | 150 +++++++++++++++++++++----- src/lib/relay-url-priority.ts | 12 ++- src/services/client-events.service.ts | 23 ++++ src/services/client-query.service.ts | 8 +- src/services/client.service.ts | 29 +++-- src/services/note-stats.service.ts | 15 ++- vite.config.ts | 92 ++++++++++++---- 7 files changed, 267 insertions(+), 62 deletions(-) diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts index 7cc2cf31..0ee51237 100644 --- a/src/lib/index-relay-http.ts +++ b/src/lib/index-relay-http.ts @@ -1,6 +1,10 @@ /** * HTTP JSON API for index-style relays (e.g. gc_index_relay: POST /api/events/filter, POST /api/events). * @see gc_index_relay lib/gc_index_relay_web/router.ex + * + * **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. */ import logger from '@/lib/logger' import { normalizeHttpRelayUrl } from '@/lib/url' @@ -11,6 +15,24 @@ function trimSlash(base: string): string { return base.replace(/\/+$/, '') } +/** + * Avoid browser CORS in dev: `http://localhost:1122/api/...` becomes same-origin `…/dev-index-relay/api/…` + * and Vite forwards to the real relay (see `vite.config.ts`). + */ +function devProxyLoopbackIndexRelayBase(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 !== 'http:') return normalizedBase + const h = u.hostname + if (h !== 'localhost' && h !== '127.0.0.1') return normalizedBase + return `${window.location.origin}/dev-index-relay` +} + export function indexRelayFilterUrl(baseUrl: string): string { return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events/filter` } @@ -42,6 +64,9 @@ export function nostrFilterToIndexRelayBody(f: Filter): Record const INDEX_RELAY_HTTP_WARN_COOLDOWN_MS = 5000 const lastIndexRelayHttpWarnAtByEndpoint = new Map() +const DEV_INDEX_RELAY_TRANSPORT_HINT_MS = 60_000 +let lastDevIndexRelayTransportHintAt = 0 + function warnIndexRelayHttpThrottled(endpoint: string, message: string, meta: Record) { const now = Date.now() const prev = lastIndexRelayHttpWarnAtByEndpoint.get(endpoint) ?? 0 @@ -50,6 +75,55 @@ function warnIndexRelayHttpThrottled(endpoint: string, message: string, meta: Re logger.warn(message, meta) } +/** True when the relay cannot be reached (down, DNS, browser blocked, etc.). Not HTTP 4xx/5xx from a live server. */ +export function isIndexRelayTransportFailure(err: unknown): boolean { + if (err == null || typeof err !== 'object') return false + const e = err as Error & { name?: string; cause?: unknown } + if (e.name === 'AbortError') return false + if (e instanceof TypeError) { + const m = e.message || '' + if (/failed to fetch|load failed|networkerror when attempting to fetch resource/i.test(m)) return true + } + const msg = String((e as Error).message || err) + if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ERR_CONNECTION|network request failed|fetch failed/i.test(msg)) return true + if (e.cause != null && isIndexRelayTransportFailure(e.cause)) return true + return false +} + +export class IndexRelayTransportError extends Error { + constructor(cause?: unknown) { + super('Index relay unreachable') + this.name = 'IndexRelayTransportError' + if (cause !== undefined) (this as Error & { cause?: unknown }).cause = cause + } +} + +function isDevViteIndexRelayProxyPath(endpoint: string): boolean { + return import.meta.env.DEV && endpoint.includes('/dev-index-relay') +} + +function maybeLogDevIndexRelayUnreachableHint(): void { + if (import.meta.env.PROD || typeof window === 'undefined') return + const now = Date.now() + if (now - lastDevIndexRelayTransportHintAt < DEV_INDEX_RELAY_TRANSPORT_HINT_MS) return + lastDevIndexRelayTransportHintAt = now + logger.info( + 'HTTP index relay is unreachable in dev. Start the relay, or set VITE_DEV_INDEX_RELAY_TARGET if it is not on the default URL.' + ) +} + +function handleFilterTransportFailure(endpoint: string, err?: unknown): void { + if (import.meta.env.DEV && isDevViteIndexRelayProxyPath(endpoint)) { + logger.debug('[IndexRelayHttp] filter unreachable', { endpoint }) + maybeLogDevIndexRelayUnreachableHint() + return + } + warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', { + endpoint, + error: err ?? 'unreachable' + }) +} + function rawToVerifiedEvent(raw: Record): NEvent | null { try { const id = raw.id @@ -87,7 +161,7 @@ export async function queryIndexRelay( filter: Filter | Filter[], options?: { signal?: AbortSignal; onHardFailure?: () => void } ): Promise { - const base = normalizeHttpRelayUrl(baseUrl) || baseUrl + const base = devProxyLoopbackIndexRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl) const endpoint = indexRelayFilterUrl(base) const filters = Array.isArray(filter) ? filter : [filter] const out: NEvent[] = [] @@ -107,10 +181,14 @@ export async function queryIndexRelay( }) if (!res.ok) { sawHardFailure = true - warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request failed', { - endpoint, - status: res.status - }) + if (isDevViteIndexRelayProxyPath(endpoint) && res.status === 500) { + handleFilterTransportFailure(endpoint, `HTTP ${res.status}`) + } else { + warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request failed', { + endpoint, + status: res.status + }) + } continue } const json = (await res.json()) as { data?: unknown } @@ -127,7 +205,11 @@ export async function queryIndexRelay( } catch (e) { if ((e as Error).name === 'AbortError') throw e sawHardFailure = true - warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', { endpoint, error: e }) + if (isIndexRelayTransportFailure(e)) { + handleFilterTransportFailure(endpoint, e) + } else { + warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', { endpoint, error: e }) + } } } if (sawHardFailure && out.length === 0 && filters.length > 0) { @@ -146,29 +228,41 @@ export async function publishEventToIndexRelay( event: NEvent, options?: { signal?: AbortSignal } ): Promise { - const base = normalizeHttpRelayUrl(baseUrl) || baseUrl + const base = devProxyLoopbackIndexRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl) const endpoint = indexRelayPublishUrl(base) - const res = await fetch(endpoint, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - event: { - id: event.id, - pubkey: event.pubkey, - created_at: event.created_at, - kind: event.kind, - tags: event.tags, - content: event.content, - sig: event.sig + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event: { + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig + } + }), + signal: options?.signal + }) + if (!res.ok) { + if (isDevViteIndexRelayProxyPath(endpoint) && res.status === 500) { + throw new IndexRelayTransportError() } - }), - signal: options?.signal - }) - if (!res.ok) { - const text = await res.text().catch(() => '') - throw new Error(`HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`) + const text = await res.text().catch(() => '') + throw new Error(`HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`) + } + } catch (e) { + if (e instanceof IndexRelayTransportError) throw e + if ((e as Error).name === 'AbortError') throw e + if (isIndexRelayTransportFailure(e)) { + throw new IndexRelayTransportError(e) + } + throw e } } diff --git a/src/lib/relay-url-priority.ts b/src/lib/relay-url-priority.ts index 967c2cb4..f0f74737 100644 --- a/src/lib/relay-url-priority.ts +++ b/src/lib/relay-url-priority.ts @@ -35,7 +35,9 @@ export function relayUrlsLocalsFirst(urls: string[]): string[] { } function blockedNormSet(blockedRelays: string[] | undefined): Set { - return new Set((blockedRelays ?? []).map((b) => normalizeUrl(b) || b).filter(Boolean)) + return new Set( + (blockedRelays ?? []).map((b) => normalizeAnyRelayUrl(b) || b.trim()).filter(Boolean) + ) } let socialKindBlockedNormCache: Set | undefined @@ -70,7 +72,8 @@ export function mergeRelayPriorityLayers( const out: string[] = [] for (const layer of layers) { for (const u of layer) { - const n = normalizeUrl(u) || u + // Must not use {@link normalizeUrl}: it turns http(s) index relays into ws(s), which then hit the WS pool. + const n = normalizeAnyRelayUrl(u) || u.trim() if (!n || blocked.has(n) || socialBlocked.has(n) || seen.has(n)) continue seen.add(n) out.push(n) @@ -100,7 +103,10 @@ export function buildReadRelayPriorityLayers(opts: { favoriteRelays: string[] }): string[][] { const userWrite = opts.userWriteRelays ?? [] - const writeLocals = userWrite.filter((u) => isLocalNetworkUrl(normalizeUrl(u) || u)) + const writeLocals = userWrite.filter((u) => { + const n = normalizeAnyRelayUrl(u) || u.trim() + return n && isLocalNetworkUrl(n) + }) const userReadOrdered = relayUrlsLocalsFirst(opts.userReadRelays) const tier1 = dedupeNormalizeRelayUrlsOrdered([...writeLocals, ...userReadOrdered]) const tier2 = dedupeNormalizeRelayUrlsOrdered(opts.authorWriteRelays ?? []) diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index eb6310d0..4803a285 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -21,6 +21,7 @@ import type { QueryService } from './client-query.service' import client from './client.service' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' +import { normalizeUrl } from '@/lib/url' /** * Build comprehensive relay list for event-by-id fetch: user's inboxes (+ cache), relay hints, @@ -450,6 +451,28 @@ export class EventService { return out } + /** + * WebSocket relay URLs from `e`-tag position 3 on session-cached events that reference this hex id. + * Reactions often carry the publisher’s relay hint; without it, note-stats may miss kind 7 that never reached index relays. + */ + getSessionRelayHintsForHexTarget(targetHexId: string): string[] { + const id = targetHexId.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(id)) return [] + const hints = new Set() + for (const [, event] of this.sessionEventCache.entries()) { + if (shouldDropEventOnIngest(event)) continue + for (const t of event.tags) { + if (t[0] !== 'e' && t[0] !== 'E') continue + if (t[1]?.toLowerCase() !== id) continue + const raw = t[2]?.trim() + if (!raw) continue + const n = normalizeUrl(raw) + if (n) hints.add(n) + } + } + return [...hints] + } + /** * Reply-shaped events already in the session LRU for this thread (notes, kind 1111, voice comments, zaps), * found by BFS over e/E/q and (for `a`-root threads) a-tag links. Merges with relay fetches via ReplyProvider. diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 2d116f31..4ab739a0 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -8,7 +8,7 @@ import { SEARCHABLE_RELAY_URLS } from '@/constants' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' -import { queryIndexRelay } from '@/lib/index-relay-http' +import { isIndexRelayTransportFailure, queryIndexRelay } from '@/lib/index-relay-http' import logger from '@/lib/logger' import { isHttpRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' import { RelaySubscribeOpBatch } from '@/services/relay-operation-log.service' @@ -265,7 +265,11 @@ export class QueryService { } } catch (e) { if ((e as Error).name === 'AbortError') return - logger.warn('[QueryService] HTTP index relay query failed', { base, error: e }) + if (isIndexRelayTransportFailure(e)) { + logger.debug('[QueryService] HTTP index relay unreachable', { base, error: e }) + } else { + logger.warn('[QueryService] HTTP index relay query failed', { base, error: e }) + } } }) ).then(() => {}) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 6a6e6799..0410d627 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -39,7 +39,11 @@ import { mergeRelayPriorityLayers, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' -import { publishEventToIndexRelay } from '@/lib/index-relay-http' +import { + IndexRelayTransportError, + isIndexRelayTransportFailure, + publishEventToIndexRelay +} from '@/lib/index-relay-http' import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize' import { isHttpRelayUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl, simplifyUrl } from '@/lib/url' import { isSafari } from '@/lib/utils' @@ -1308,12 +1312,25 @@ class ClientService extends EventTarget { ) ]) } catch (error) { - logger.error(`[PublishEvent] Connection or setup failed`, { url, error: error instanceof Error ? error.message : String(error) }) + const softHttpDown = + isHttpRelayUrl(url) && + (error instanceof IndexRelayTransportError || isIndexRelayTransportFailure(error)) + if (softHttpDown) { + logger.debug('[PublishEvent] HTTP index relay unreachable', { + url, + error: error instanceof Error ? error.message : String(error) + }) + } else { + logger.error(`[PublishEvent] Connection or setup failed`, { + url, + error: error instanceof Error ? error.message : String(error) + }) + } errors.push({ url, error }) - relayStatuses.push({ - url, - success: false, - error: error instanceof Error ? error.message : 'Connection failed' + relayStatuses.push({ + url, + success: false, + error: error instanceof Error ? error.message : 'Connection failed' }) that.recordSessionRelayFailure(url) } finally { diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 49ffe93b..b32290a3 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -240,7 +240,8 @@ class NoteStatsService { } /** - * Build relay list for note stats: SEARCHABLE + FAST_READ + optional user favorites + seen relays + author NIP-65 read (slice 10). + * Build relay list for note stats: SEARCHABLE + FAST_READ + optional user favorites + seen relays + + * `e`-tag hints on the note + hints from session-cached referrers + author NIP-65 read (slice 10). * Excludes E_TAG_FILTER_BLOCKED_RELAY_URLS (stats use #e filters). */ private async buildNoteStatsRelayList(event: Event, favoriteRelays?: string[] | null): Promise { @@ -268,7 +269,17 @@ class NoteStatsService { // 4. Relay(s) where the event was seen client.getSeenEventRelayUrls(event.id).forEach(add) - // 5. Author's inboxes (read relays from kind 10002) + // 5. NIP-10 `e`-tag relay hints on the note itself (often where replies/reactions to it were published) + for (const t of event.tags) { + if ((t[0] === 'e' || t[0] === 'E') && t[2]?.trim()) { + add(t[2]) + } + } + + // 6. Session cache (e.g. notifications): events that reference this id with a relay hint + client.eventService.getSessionRelayHintsForHexTarget(event.id).forEach(add) + + // 7. Author's inboxes (read relays from kind 10002) try { const relayList = await Promise.race([ client.fetchRelayList(event.pubkey), diff --git a/vite.config.ts b/vite.config.ts index b7c44d37..e3a7c144 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import react from '@vitejs/plugin-react' import { execSync } from 'child_process' import path from 'path' import type { Plugin } from 'vite' +import { loadEnv } from 'vite' import { defineConfig } from 'vitest/config' import { VitePWA } from 'vite-plugin-pwa' import packageJson from './package.json' @@ -44,29 +45,76 @@ function fullReloadOnProvidersAndPages(): Plugin { } } -// https://vite.dev/config/ -export default defineConfig({ - base: '/', - define: { - 'import.meta.env.GIT_COMMIT': getGitHash(), - 'import.meta.env.APP_VERSION': getAppVersion() - }, - resolve: { - alias: { - '@': path.resolve(__dirname, './src') - } - }, - server: { - // OG/link preview uses `/sites/?url=…`. Without this, Vite serves `index.html` and WebService parses the app shell. - // Run the scraper on 8090 per PROXY_SETUP.md, or rely on allorigins fallback in dev (web.service.ts). - proxy: { - '/sites': { - target: 'http://127.0.0.1:8090', - changeOrigin: true +/** + * Default proxy logs one multiline error + stack per failed request when the index relay is down. + * Throttle to one hint: match `/api/events` paths (dev-index-relay), not other proxies like `/sites`. + */ +function quietDevIndexRelayProxyErrors(devIndexRelayTarget: string): Plugin { + let lastSuppressedLog = 0 + const COOLDOWN_MS = 60_000 + + return { + name: 'quiet-dev-index-relay-proxy-errors', + apply: 'serve', + configResolved(config) { + const prevError = config.logger.error.bind(config.logger) + config.logger.error = (msg, options) => { + const text = typeof msg === 'string' ? msg : '' + if ( + text.includes('http proxy error') && + text.includes('ECONNREFUSED') && + text.includes('/api/events') + ) { + const now = Date.now() + if (now - lastSuppressedLog >= COOLDOWN_MS) { + lastSuppressedLog = now + config.logger.warn( + `[vite] Dev index relay not reachable (${devIndexRelayTarget}). Start it or set VITE_DEV_INDEX_RELAY_TARGET. Suppressing duplicate proxy errors for ${COOLDOWN_MS / 1000}s.` + ) + } + return + } + prevError(msg, options) } } - }, - build: { + } +} + +// https://vite.dev/config/ +export default defineConfig(({ mode }) => { + // `.env.local` is not on `process.env` when this file is evaluated unless we load it. + const env = loadEnv(mode, process.cwd(), '') + const devIndexRelayTarget = + env.VITE_DEV_INDEX_RELAY_TARGET?.trim() || 'http://127.0.0.1:1122' + + return { + base: '/', + define: { + 'import.meta.env.GIT_COMMIT': getGitHash(), + 'import.meta.env.APP_VERSION': getAppVersion() + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + }, + server: { + // OG/link preview uses `/sites/?url=…`. Without this, Vite serves `index.html` and WebService parses the app shell. + // Run the scraper on 8090 per PROXY_SETUP.md, or rely on allorigins fallback in dev (web.service.ts). + proxy: { + '/sites': { + target: 'http://127.0.0.1:8090', + changeOrigin: true + }, + // Loopback HTTP index relay: `import.meta.env.DEV` rewrites kind 10243 URLs through this path. + '/dev-index-relay': { + target: devIndexRelayTarget, + changeOrigin: true, + rewrite: (p) => p.replace(/^\/dev-index-relay/, '') || '/' + } + } + }, + build: { rollupOptions: { output: { manualChunks(id) { @@ -203,6 +251,7 @@ export default defineConfig({ plugins: [ react(), fullReloadOnProvidersAndPages(), + quietDevIndexRelayProxyErrors(devIndexRelayTarget), VitePWA({ registerType: 'autoUpdate', // Use public/manifest.webmanifest and index.html only; avoid duplicate manifest link in build @@ -286,4 +335,5 @@ export default defineConfig({ } }) ] + } })