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.
 
 
 
 

369 lines
12 KiB

/**
* Nostr Archives REST client (https://nostrarchives.com/docs).
*
* Cross-cutting rules:
* - Never throw to callers; return {@link TArchivesApiResult} and let UI use relay/local fallbacks.
* - Circuit breaker after repeated failures so offline/API-down does not spam requests.
* - Verified events from responses are written to session cache + IndexedDB via {@link persistArchivesPayloadEvents}.
*/
import {
NOSTR_ARCHIVES_API_BASE_URL,
NOSTR_ARCHIVES_API_RATE_LIMIT_PER_MIN
} from '@/constants'
import { persistArchivesPayloadEvents, persistArchivesEventsIfNew } from '@/lib/nostr-archives-ingest'
import { archivesJsonToVerifiedEvent } from '@/lib/nostr-archives-event'
import logger from '@/lib/logger'
import storage from '@/services/local-storage.service'
import type {
TArchivesApiResult,
TArchivesInteractionCounts,
TArchivesNotePageBundle,
TArchivesProfileMetadata,
TArchivesSocialGraph
} from '@/types/nostr-archives'
import type { Event } from 'nostr-tools'
const CIRCUIT_FAILURE_THRESHOLD = 2
const CIRCUIT_COOLDOWN_MS = 60_000
const REQUEST_TIMEOUT_MS = 12_000
class NostrArchivesApiService {
static instance: NostrArchivesApiService
private requestTimestamps: number[] = []
private consecutiveFailures = 0
private circuitOpenUntil = 0
private availabilityListeners = new Set<() => void>()
constructor() {
if (!NostrArchivesApiService.instance) {
NostrArchivesApiService.instance = this
}
return NostrArchivesApiService.instance
}
isEnabled(): boolean {
return storage.getUseNostrArchivesApi()
}
/** False when disabled, circuit open, or recent failures — callers should hide Archives-only UI. */
isAvailable(): boolean {
if (!this.isEnabled()) return false
if (Date.now() < this.circuitOpenUntil) return false
return true
}
subscribeAvailability(listener: () => void): () => void {
this.availabilityListeners.add(listener)
return () => this.availabilityListeners.delete(listener)
}
private notifyAvailability(): void {
this.availabilityListeners.forEach((l) => l())
}
private recordSuccess(): void {
const wasUnavailable = !this.isAvailable()
this.consecutiveFailures = 0
this.circuitOpenUntil = 0
if (wasUnavailable) this.notifyAvailability()
}
private recordFailure(): void {
this.consecutiveFailures += 1
if (this.consecutiveFailures >= CIRCUIT_FAILURE_THRESHOLD) {
this.circuitOpenUntil = Date.now() + CIRCUIT_COOLDOWN_MS
logger.info('[nostr-archives] API circuit open', { cooldownMs: CIRCUIT_COOLDOWN_MS })
this.notifyAvailability()
}
}
private consumeRateLimit(): boolean {
const now = Date.now()
const windowStart = now - 60_000
this.requestTimestamps = this.requestTimestamps.filter((t) => t >= windowStart)
if (this.requestTimestamps.length >= NOSTR_ARCHIVES_API_RATE_LIMIT_PER_MIN) {
return false
}
this.requestTimestamps.push(now)
return true
}
private async fetchJson(path: string, init?: RequestInit): Promise<TArchivesApiResult<unknown>> {
if (!this.isEnabled()) {
return { ok: false, reason: 'disabled' }
}
if (Date.now() < this.circuitOpenUntil) {
return { ok: false, reason: 'circuit_open' }
}
if (!this.consumeRateLimit()) {
return { ok: false, reason: 'rate_limited' }
}
const url = `${NOSTR_ARCHIVES_API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`
const ac = new AbortController()
const timer = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS)
try {
const res = await fetch(url, {
...init,
signal: ac.signal,
headers: {
Accept: 'application/json',
...(init?.headers ?? {})
}
})
clearTimeout(timer)
if (!res.ok) {
this.recordFailure()
return { ok: false, reason: 'http', status: res.status }
}
const text = await res.text()
try {
const data = text ? JSON.parse(text) : null
this.recordSuccess()
return { ok: true, data }
} catch {
this.recordFailure()
return { ok: false, reason: 'parse', status: res.status }
}
} catch (err) {
clearTimeout(timer)
this.recordFailure()
const aborted = err instanceof Error && err.name === 'AbortError'
logger.debug('[nostr-archives] fetch failed', {
path,
aborted,
message: err instanceof Error ? err.message : String(err)
})
return { ok: false, reason: 'network' }
}
}
private async getAndPersist(path: string): Promise<TArchivesApiResult<unknown>> {
const res = await this.fetchJson(path)
if (res.ok) {
void persistArchivesPayloadEvents(res.data).catch(() => {})
}
return res
}
async getEventInteractions(eventId: string): Promise<TArchivesApiResult<TArchivesInteractionCounts>> {
const hex = eventId.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(hex)) {
return { ok: false, reason: 'parse' }
}
const res = await this.fetchJson(`/v1/events/${hex}/interactions`)
if (!res.ok) return res
const d = res.data as Record<string, unknown>
return {
ok: true,
data: {
reactions: Number(d.reactions) || 0,
replies: Number(d.replies) || 0,
reposts: Number(d.reposts) || 0,
zap_sats: Number(d.zap_sats) || 0
}
}
}
async getSocialGraph(
pubkey: string,
opts?: { followsLimit?: number; followersLimit?: number; followsOffset?: number; followersOffset?: number }
): Promise<TArchivesApiResult<TArchivesSocialGraph>> {
const pk = pubkey.trim()
const q = new URLSearchParams()
q.set('follows_limit', String(opts?.followsLimit ?? 0))
q.set('followers_limit', String(opts?.followersLimit ?? 0))
q.set('follows_offset', String(opts?.followsOffset ?? 0))
q.set('followers_offset', String(opts?.followersOffset ?? 0))
const res = await this.fetchJson(`/v1/social/${encodeURIComponent(pk)}?${q}`)
if (!res.ok) return res
const d = res.data as Record<string, unknown>
const mapList = (part: unknown): { count: number; pubkeys: string[] } => {
const p = part as Record<string, unknown> | undefined
const pubkeys = Array.isArray(p?.pubkeys)
? (p.pubkeys as unknown[]).filter((x): x is string => typeof x === 'string')
: []
return { count: Number(p?.count) || pubkeys.length, pubkeys }
}
return {
ok: true,
data: {
pubkey: typeof d.pubkey === 'string' ? d.pubkey : pk,
follows: mapList(d.follows),
followers: mapList(d.followers)
}
}
}
async getEventById(eventId: string): Promise<TArchivesApiResult<Event>> {
const hex = eventId.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(hex)) {
return { ok: false, reason: 'parse' }
}
const res = await this.getAndPersist(`/v1/events/${hex}`)
if (!res.ok) return res
const ev = archivesJsonToVerifiedEvent(res.data)
if (!ev) return { ok: false, reason: 'parse' }
return { ok: true, data: ev }
}
async getNotePage(eventId: string, limit = 50): Promise<TArchivesApiResult<TArchivesNotePageBundle>> {
const hex = eventId.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(hex)) {
return { ok: false, reason: 'parse' }
}
const res = await this.getAndPersist(`/v1/pages/note/${hex}?limit=${Math.min(200, Math.max(1, limit))}`)
if (!res.ok) return res
const d = res.data as Record<string, unknown>
const event = archivesJsonToVerifiedEvent(d.event)
if (!event) return { ok: false, reason: 'parse' }
const replies: Event[] = []
if (Array.isArray(d.replies)) {
for (const r of d.replies) {
const ev = archivesJsonToVerifiedEvent(r)
if (ev) replies.push(ev)
}
}
await persistArchivesEventsIfNew([event, ...replies])
const interactionsRaw = (d.interactions ?? d) as Record<string, unknown>
const profiles =
d.profiles && typeof d.profiles === 'object'
? (d.profiles as Record<string, TArchivesProfileMetadata>)
: {}
return {
ok: true,
data: {
event,
replies,
profiles,
interactions: {
reactions: Number(interactionsRaw.reactions) || 0,
replies: Number(interactionsRaw.replies) || 0,
reposts: Number(interactionsRaw.reposts) || 0,
zap_sats: Number(interactionsRaw.zap_sats) || 0
}
}
}
}
async searchNotes(params: {
q?: string
limit?: number
offset?: number
order?: 'newest' | 'oldest' | 'engagement'
author?: string
}): Promise<TArchivesApiResult<{ notes: Event[]; total: number }>> {
const q = new URLSearchParams()
if (params.q?.trim()) q.set('q', params.q.trim())
q.set('limit', String(Math.min(100, Math.max(1, params.limit ?? 20))))
q.set('offset', String(Math.max(0, params.offset ?? 0)))
if (params.order) q.set('order', params.order)
if (params.author?.trim()) q.set('author', params.author.trim())
const res = await this.getAndPersist(`/v1/notes/search?${q}`)
if (!res.ok) return res
const d = res.data as Record<string, unknown>
const notes: Event[] = []
if (Array.isArray(d.notes)) {
for (const n of d.notes) {
const ev = archivesJsonToVerifiedEvent(
n && typeof n === 'object' && 'event' in (n as object) ? (n as { event: unknown }).event : n
)
if (ev) notes.push(ev)
}
}
return { ok: true, data: { notes, total: Number(d.total) || notes.length } }
}
async searchGeneral(params: {
q: string
type?: 'all' | 'profiles' | 'notes'
limit?: number
offset?: number
}): Promise<
TArchivesApiResult<{
profiles: TArchivesProfileMetadata[]
notes: Event[]
resolved?: unknown
}>
> {
const q = new URLSearchParams()
q.set('q', params.q.trim())
if (params.type) q.set('type', params.type)
q.set('limit', String(Math.min(100, Math.max(1, params.limit ?? 20))))
q.set('offset', String(Math.max(0, params.offset ?? 0)))
const res = await this.getAndPersist(`/v1/search?${q}`)
if (!res.ok) return res
const d = res.data as Record<string, unknown>
if (d.resolved != null) {
return { ok: true, data: { profiles: [], notes: [], resolved: d.resolved } }
}
const profiles = Array.isArray(d.profiles)
? (d.profiles as TArchivesProfileMetadata[])
: []
const notes: Event[] = []
if (Array.isArray(d.notes)) {
for (const n of d.notes) {
const ev = archivesJsonToVerifiedEvent(n)
if (ev) notes.push(ev)
}
}
return { ok: true, data: { profiles, notes } }
}
async searchSuggest(
q: string,
limit = 5
): Promise<TArchivesApiResult<{ suggestions: TArchivesProfileMetadata[] }>> {
const trimmed = q.trim()
if (trimmed.length < 2) {
return { ok: true, data: { suggestions: [] } }
}
const res = await this.fetchJson(
`/v1/search/suggest?${new URLSearchParams({ q: trimmed, limit: String(Math.min(10, Math.max(1, limit))) })}`
)
if (!res.ok) return res
const d = res.data as Record<string, unknown>
const suggestions = Array.isArray(d.suggestions)
? (d.suggestions as TArchivesProfileMetadata[])
: []
return { ok: true, data: { suggestions } }
}
async fetchProfilesMetadata(
pubkeys: readonly string[]
): Promise<TArchivesApiResult<{ profiles: TArchivesProfileMetadata[] }>> {
const list = [...new Set(pubkeys.map((p) => p.trim().toLowerCase()).filter((p) => /^[0-9a-f]{64}$/.test(p)))]
if (list.length === 0) {
return { ok: true, data: { profiles: [] } }
}
const merged: TArchivesProfileMetadata[] = []
for (let i = 0; i < list.length; i += 500) {
const chunk = list.slice(i, i + 500)
const res = await this.fetchJson('/v1/profiles/metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkeys: chunk })
})
if (!res.ok) return res
const d = res.data as Record<string, unknown>
if (Array.isArray(d.profiles)) {
merged.push(...(d.profiles as TArchivesProfileMetadata[]))
}
}
return { ok: true, data: { profiles: merged } }
}
}
const nostrArchivesApi = new NostrArchivesApiService()
export default nostrArchivesApi