Browse Source

fix proxying and reactions in thread

imwald
Silberengel 1 month ago
parent
commit
f0ac2f0c26
  1. 150
      src/lib/index-relay-http.ts
  2. 12
      src/lib/relay-url-priority.ts
  3. 23
      src/services/client-events.service.ts
  4. 8
      src/services/client-query.service.ts
  5. 29
      src/services/client.service.ts
  6. 15
      src/services/note-stats.service.ts
  7. 92
      vite.config.ts

150
src/lib/index-relay-http.ts

@ -1,6 +1,10 @@ @@ -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 { @@ -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<string, unknown> @@ -42,6 +64,9 @@ export function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown>
const INDEX_RELAY_HTTP_WARN_COOLDOWN_MS = 5000
const lastIndexRelayHttpWarnAtByEndpoint = new Map<string, number>()
const DEV_INDEX_RELAY_TRANSPORT_HINT_MS = 60_000
let lastDevIndexRelayTransportHintAt = 0
function warnIndexRelayHttpThrottled(endpoint: string, message: string, meta: Record<string, unknown>) {
const now = Date.now()
const prev = lastIndexRelayHttpWarnAtByEndpoint.get(endpoint) ?? 0
@ -50,6 +75,55 @@ function warnIndexRelayHttpThrottled(endpoint: string, message: string, meta: Re @@ -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<string, unknown>): NEvent | null {
try {
const id = raw.id
@ -87,7 +161,7 @@ export async function queryIndexRelay( @@ -87,7 +161,7 @@ export async function queryIndexRelay(
filter: Filter | Filter[],
options?: { signal?: AbortSignal; onHardFailure?: () => void }
): Promise<NEvent[]> {
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( @@ -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( @@ -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( @@ -146,29 +228,41 @@ export async function publishEventToIndexRelay(
event: NEvent,
options?: { signal?: AbortSignal }
): Promise<void> {
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
}
}

12
src/lib/relay-url-priority.ts

@ -35,7 +35,9 @@ export function relayUrlsLocalsFirst(urls: string[]): string[] { @@ -35,7 +35,9 @@ export function relayUrlsLocalsFirst(urls: string[]): string[] {
}
function blockedNormSet(blockedRelays: string[] | undefined): Set<string> {
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<string> | undefined
@ -70,7 +72,8 @@ export function mergeRelayPriorityLayers( @@ -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: { @@ -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 ?? [])

23
src/services/client-events.service.ts

@ -21,6 +21,7 @@ import type { QueryService } from './client-query.service' @@ -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 { @@ -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 publishers 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<string>()
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.

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

@ -8,7 +8,7 @@ import { @@ -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 { @@ -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(() => {})

29
src/services/client.service.ts

@ -39,7 +39,11 @@ import { @@ -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 { @@ -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 {

15
src/services/note-stats.service.ts

@ -240,7 +240,8 @@ class NoteStatsService { @@ -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<string[]> {
@ -268,7 +269,17 @@ class NoteStatsService { @@ -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),

92
vite.config.ts

@ -2,6 +2,7 @@ import react from '@vitejs/plugin-react' @@ -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 { @@ -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({ @@ -203,6 +251,7 @@ export default defineConfig({
plugins: [
react(),
fullReloadOnProvidersAndPages(),
quietDevIndexRelayProxyErrors(devIndexRelayTarget),
VitePWA({
registerType: 'autoUpdate',
// Use public/manifest.webmanifest and index.html <link> only; avoid duplicate manifest link in build
@ -286,4 +335,5 @@ export default defineConfig({ @@ -286,4 +335,5 @@ export default defineConfig({
}
})
]
}
})

Loading…
Cancel
Save