feat(snapshot): relay-loader (kind:10002 + event-fetch)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-28 08:23:25 +02:00
parent 10cb0d947d
commit d8a29ca389
2 changed files with 132 additions and 0 deletions

View File

@ -0,0 +1,99 @@
import { Relay } from 'applesauce-relay'
import { firstValueFrom, timeout } from 'rxjs'
import type { SignedEvent } from './types.ts'
export type RelayListLoader = (
bootstrapRelay: string,
authorPubkey: string,
) => Promise<SignedEvent | undefined>
export const FALLBACK_READ_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
'wss://relay.tchncs.de',
'wss://relay.edufeed.org',
]
export function extractReadRelays(kind10002: SignedEvent): string[] {
const out: string[] = []
for (const tag of kind10002.tags) {
if (tag[0] !== 'r' || !tag[1]) continue
const marker = tag[2]
if (marker === 'write') continue
out.push(tag[1])
}
return out
}
export const defaultRelayListLoader: RelayListLoader = async (bootstrap, pubkey) => {
try {
const relay = new Relay(bootstrap)
const ev = await firstValueFrom(
relay.request({ kinds: [10002], authors: [pubkey], limit: 1 })
.pipe(timeout({ first: 5_000 })),
)
return ev as SignedEvent
} catch {
return undefined
}
}
export async function loadReadRelays(
bootstrapRelay: string,
authorPubkey: string,
loader: RelayListLoader = defaultRelayListLoader,
fallback: string[] = FALLBACK_READ_RELAYS,
): Promise<string[]> {
const ev = await loader(bootstrapRelay, authorPubkey)
if (!ev) return fallback
const list = extractReadRelays(ev)
return list.length > 0 ? list : fallback
}
export interface FetchEventsResult {
events: SignedEvent[]
responded: string[]
queried: string[]
}
export type EventFetcher = (relay: string, pubkey: string) => Promise<SignedEvent[]>
export const defaultEventFetcher: EventFetcher = async (relay, pubkey) => {
const out: SignedEvent[] = []
const r = new Relay(relay)
return await new Promise<SignedEvent[]>((resolve) => {
const sub = r.request({ kinds: [30023, 5], authors: [pubkey] })
.pipe(timeout({ first: 10_000 }))
.subscribe({
next: (ev) => out.push(ev as SignedEvent),
error: () => resolve(out),
complete: () => resolve(out),
})
setTimeout(() => sub.unsubscribe(), 11_000)
})
}
export async function fetchEvents(
relays: string[],
authorPubkey: string,
fetcher: EventFetcher = defaultEventFetcher,
): Promise<FetchEventsResult> {
const results = await Promise.all(
relays.map(async (url) => {
try {
const events = await fetcher(url, authorPubkey)
return { url, ok: true as const, events }
} catch {
return { url, ok: false as const, events: [] as SignedEvent[] }
}
}),
)
const events: SignedEvent[] = []
for (const r of results) events.push(...r.events)
return {
events,
responded: results.filter((r) => r.ok).map((r) => r.url),
queried: relays,
}
}

View File

@ -0,0 +1,33 @@
import { assertEquals } from '@std/assert'
import { extractReadRelays, type RelayListLoader, loadReadRelays } from '../src/core/relays.ts'
import type { SignedEvent } from '../src/core/types.ts'
const KIND_10002: SignedEvent = {
id: 'r', pubkey: 'P', created_at: 1, kind: 10002, sig: 's', content: '',
tags: [
['r', 'wss://relay.damus.io'],
['r', 'wss://nos.lol', 'read'],
['r', 'wss://relay.write-only.example', 'write'],
],
}
Deno.test('extractReadRelays: ohne marker = read+write, "read" = read, "write" = nicht', () => {
assertEquals(extractReadRelays(KIND_10002), [
'wss://relay.damus.io',
'wss://nos.lol',
])
})
Deno.test('loadReadRelays: nutzt fallback wenn kein kind:10002', async () => {
const loader: RelayListLoader = async () => undefined
const relays = await loadReadRelays('wss://bootstrap', 'P', loader, [
'wss://fallback1', 'wss://fallback2',
])
assertEquals(relays, ['wss://fallback1', 'wss://fallback2'])
})
Deno.test('loadReadRelays: nutzt kind:10002 wenn vorhanden', async () => {
const loader: RelayListLoader = async () => KIND_10002
const relays = await loadReadRelays('wss://bootstrap', 'P', loader, ['wss://fallback'])
assertEquals(relays, ['wss://relay.damus.io', 'wss://nos.lol'])
})