You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

306 lines
11 KiB

/**
* 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 { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import logger from '@/lib/logger'
import { devProxyLoopbackHttpRelayBase, normalizeHttpRelayUrl } from '@/lib/url'
import type { Filter, Event as NEvent } from 'nostr-tools'
import { verifyEvent } from 'nostr-tools'
function trimSlash(base: string): string {
return base.replace(/\/+$/, '')
}
function indexRelayFilterUrl(baseUrl: string): string {
return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events/filter`
}
function indexRelayPublishUrl(baseUrl: string): string {
return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events`
}
/** Map a Nostr filter to gc_index_relay POST body (requires `limit` 1–100; strips unsupported keys). */
function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> {
const body: Record<string, unknown> = {}
const lim = f.limit
const capped = lim == null || lim < 1 ? 100 : Math.min(100, lim)
body.limit = capped
if (f.ids?.length) body.ids = f.ids
if (f.authors?.length) body.authors = f.authors
if (f.kinds?.length) body.kinds = f.kinds
if (f.since != null) body.since = f.since
if (f.until != null) body.until = f.until
/** Index relays expect NIP-01 lowercase single-letter tag keys (`#e` not `#E`). */
const tagBuckets = new Map<string, string[]>()
for (const key of Object.keys(f)) {
if (key.length !== 2 || !key.startsWith('#')) continue
const v = (f as Record<string, unknown>)[key]
if (!Array.isArray(v) || v.length === 0) continue
const normKey = `#${key[1].toLowerCase()}`
const cur = tagBuckets.get(normKey) ?? []
for (const item of v) {
if (item != null && String(item).length > 0) cur.push(String(item))
}
tagBuckets.set(normKey, cur)
}
for (const [k, vals] of tagBuckets) {
if (vals.length === 0) continue
body[k] = [...new Set(vals)]
}
return body
}
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
const DEV_INDEX_RELAY_HTTP_ERROR_HINT_MS = 60_000
let lastDevIndexRelayHttpErrorHintAt = 0
function warnIndexRelayHttpThrottled(endpoint: string, message: string, meta: Record<string, unknown>) {
const now = Date.now()
const prev = lastIndexRelayHttpWarnAtByEndpoint.get(endpoint) ?? 0
if (now - prev < INDEX_RELAY_HTTP_WARN_COOLDOWN_MS) return
lastIndexRelayHttpWarnAtByEndpoint.set(endpoint, now)
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.debug(
'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.'
)
}
/** Server responded (proxy works) but returned 5xx — distinct from connection refused / down relay. */
function maybeLogDevIndexRelayHttpErrorHint(status: number, detail?: string): void {
if (import.meta.env.PROD || typeof window === 'undefined') return
const now = Date.now()
if (now - lastDevIndexRelayHttpErrorHintAt < DEV_INDEX_RELAY_HTTP_ERROR_HINT_MS) return
lastDevIndexRelayHttpErrorHintAt = now
const msg =
`[IndexRelayHttp] Dev index relay returned HTTP ${status} for POST /api/events/filter. ` +
'The process behind VITE_DEV_INDEX_RELAY_TARGET (default http://127.0.0.1:4000) is reachable but errored — inspect that server’s logs, database, and version (expected: gc_index_relay-style API). ' +
'To use a different relay, set VITE_DEV_INDEX_RELAY_TARGET in .env.local.'
if (detail) {
logger.warn(msg, { responseSnippet: detail })
} else {
logger.warn(msg)
}
}
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
const pubkey = raw.pubkey
const created_at = raw.created_at
const kind = raw.kind
const tags = raw.tags
const content = raw.content
const sig = raw.sig
if (
typeof id !== 'string' ||
typeof pubkey !== 'string' ||
typeof created_at !== 'number' ||
typeof kind !== 'number' ||
!Array.isArray(tags) ||
typeof content !== 'string' ||
typeof sig !== 'string'
) {
return null
}
const ev = { id, pubkey, created_at, kind, tags, content, sig } as NEvent
return verifyEvent(ev) ? ev : null
} catch {
return null
}
}
/**
* Query one HTTP index relay. Runs one POST per filter when given an array.
* When every filter attempt fails (HTTP error or network) and no events are returned,
* {@link options.onHardFailure} runs once (used for session strike parity with WebSocket relays).
*/
export async function queryIndexRelay(
baseUrl: string,
filter: Filter | Filter[],
options?: { signal?: AbortSignal; onHardFailure?: () => void }
): Promise<NEvent[]> {
const base = devProxyLoopbackHttpRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl)
const endpoint = indexRelayFilterUrl(base)
const filters = Array.isArray(filter) ? filter : [filter]
const out: NEvent[] = []
const seen = new Set<string>()
let sawHardFailure = false
for (const f of filters) {
const body = nostrFilterToIndexRelayBody(filterForIndexRelay(f))
try {
const res = await fetchWithTimeout(endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(body),
signal: options?.signal,
timeoutMs: 25_000
})
if (!res.ok) {
sawHardFailure = true
if (isDevViteIndexRelayProxyPath(endpoint)) {
let detail = ''
try {
detail = (await res.text()).trim().slice(0, 400)
} catch {
/* ignore */
}
if (res.status >= 500 && res.status <= 599) {
maybeLogDevIndexRelayHttpErrorHint(res.status, detail || undefined)
} else {
logger.debug('[IndexRelayHttp] filter HTTP response', {
endpoint,
status: res.status,
detail: detail || undefined
})
}
} else {
warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request failed', {
endpoint,
status: res.status
})
}
continue
}
const json = (await res.json()) as { data?: unknown }
const data = json.data
if (!Array.isArray(data)) continue
for (const item of data) {
if (!item || typeof item !== 'object') continue
const ev = rawToVerifiedEvent(item as Record<string, unknown>)
if (ev && !seen.has(ev.id)) {
seen.add(ev.id)
out.push(ev)
}
}
} catch (e) {
if ((e as Error).name === 'AbortError') throw e
sawHardFailure = true
if (isIndexRelayTransportFailure(e)) {
handleFilterTransportFailure(endpoint, e)
} else {
warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', { endpoint, error: e })
}
}
}
if (sawHardFailure && out.length === 0 && filters.length > 0) {
// In dev, transport failures on the Vite loopback proxy (relay unreachable / proxy not yet ready)
// should not record session strikes — the relay may be temporarily down or the dev server
// needs a restart. Only real application errors (4xx/5xx from a live relay) trigger strikes in dev.
if (!isDevViteIndexRelayProxyPath(endpoint)) {
options?.onHardFailure?.()
}
}
return out
}
function filterForIndexRelay(f: Filter): Filter {
const rest = { ...f } as Filter & { search?: unknown }
delete rest.search
return rest as Filter
}
export async function publishEventToHttpRelay(
baseUrl: string,
event: NEvent,
options?: { signal?: AbortSignal }
): Promise<void> {
const base = devProxyLoopbackHttpRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl)
const endpoint = indexRelayPublishUrl(base)
try {
const res = await fetchWithTimeout(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,
timeoutMs: 25_000
})
if (!res.ok) {
// 409 Conflict means the relay already has this event — treat as success.
if (res.status === 409) return
if (isDevViteIndexRelayProxyPath(endpoint) && res.status === 500) {
throw new IndexRelayTransportError()
}
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
}
}