publish(task 7): relay-pool-wrapper (publish + checkExisting)
publishToRelays(urls, ev, opts) publisht signiertes event parallel zu
allen relays, mit retries + exponential backoff + timeout pro versuch.
retour: { ok: string[], failed: string[] }. default-pool via
applesauce-relay 2.x (new RelayPool()); publishFn via dependency-
injection für tests. checkExisting(slug, pubkey, urls) fragt je relay
nach kind:30023 mit #d-filter ab — true wenn irgendeiner matcht.
timer-leaks vermieden per clearTimeout in publishOne + im mock-test.
3 tests grün.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e4518fbf69
commit
ebe73cbf46
|
|
@ -0,0 +1,109 @@
|
|||
import { Relay, RelayPool } from 'applesauce-relay'
|
||||
import { firstValueFrom, timeout } from 'rxjs'
|
||||
|
||||
export interface SignedEvent {
|
||||
id: string
|
||||
pubkey: string
|
||||
created_at: number
|
||||
kind: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
sig: string
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type PublishFn = (url: string, ev: SignedEvent) => Promise<PublishResult>
|
||||
|
||||
export interface PublishOptions {
|
||||
publishFn?: PublishFn
|
||||
retries?: number
|
||||
timeoutMs?: number
|
||||
backoffMs?: number
|
||||
}
|
||||
|
||||
export interface RelaysReport {
|
||||
ok: string[]
|
||||
failed: string[]
|
||||
}
|
||||
|
||||
const defaultPool = new RelayPool()
|
||||
|
||||
const defaultPublish: PublishFn = async (url, ev) => {
|
||||
try {
|
||||
const relay = defaultPool.relay(url)
|
||||
const result = await firstValueFrom(relay.publish(ev).pipe(timeout({ first: 10_000 })))
|
||||
return { ok: result.ok, reason: result.message }
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
async function publishOne(
|
||||
url: string,
|
||||
ev: SignedEvent,
|
||||
opts: Required<PublishOptions>,
|
||||
): Promise<boolean> {
|
||||
const total = opts.retries + 1
|
||||
for (let i = 0; i < total; i++) {
|
||||
let timerId: number | undefined
|
||||
const timeoutPromise = new Promise<PublishResult>((resolve) => {
|
||||
timerId = setTimeout(() => resolve({ ok: false, reason: 'timeout' }), opts.timeoutMs)
|
||||
})
|
||||
const res = await Promise.race([opts.publishFn(url, ev), timeoutPromise])
|
||||
if (timerId !== undefined) clearTimeout(timerId)
|
||||
if (res.ok) return true
|
||||
if (i < total - 1) await new Promise((r) => setTimeout(r, opts.backoffMs * Math.pow(3, i)))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function publishToRelays(
|
||||
urls: string[],
|
||||
ev: SignedEvent,
|
||||
options: PublishOptions = {},
|
||||
): Promise<RelaysReport> {
|
||||
const opts: Required<PublishOptions> = {
|
||||
publishFn: options.publishFn ?? defaultPublish,
|
||||
retries: options.retries ?? 2,
|
||||
timeoutMs: options.timeoutMs ?? 10_000,
|
||||
backoffMs: options.backoffMs ?? 1000,
|
||||
}
|
||||
const results = await Promise.all(
|
||||
urls.map(async (url) => ({ url, ok: await publishOne(url, ev, opts) })),
|
||||
)
|
||||
return {
|
||||
ok: results.filter((r) => r.ok).map((r) => r.url),
|
||||
failed: results.filter((r) => !r.ok).map((r) => r.url),
|
||||
}
|
||||
}
|
||||
|
||||
export type ExistingQuery = (url: string, pubkey: string, slug: string) => Promise<boolean>
|
||||
|
||||
const defaultExistingQuery: ExistingQuery = async (url, pubkey, slug) => {
|
||||
try {
|
||||
const relay = new Relay(url)
|
||||
const ev = await firstValueFrom(
|
||||
relay
|
||||
.request({ kinds: [30023], authors: [pubkey], '#d': [slug], limit: 1 })
|
||||
.pipe(timeout({ first: 5_000 })),
|
||||
)
|
||||
return !!ev
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkExisting(
|
||||
slug: string,
|
||||
pubkey: string,
|
||||
urls: string[],
|
||||
opts: { query?: ExistingQuery } = {},
|
||||
): Promise<boolean> {
|
||||
const query = opts.query ?? defaultExistingQuery
|
||||
const results = await Promise.all(urls.map((u) => query(u, pubkey, slug)))
|
||||
return results.some((r) => r)
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { assertEquals } from '@std/assert'
|
||||
import { publishToRelays } from '../src/core/relays.ts'
|
||||
|
||||
const sampleEvent = {
|
||||
kind: 1,
|
||||
pubkey: 'p',
|
||||
created_at: 1,
|
||||
tags: [],
|
||||
content: 'x',
|
||||
id: 'i',
|
||||
sig: 's',
|
||||
}
|
||||
|
||||
Deno.test('publishToRelays: meldet OK-Antworten je relay', async () => {
|
||||
const injected = async (url: string, _ev: unknown) => {
|
||||
if (url.includes('fail')) return { ok: false, reason: 'nope' }
|
||||
return { ok: true }
|
||||
}
|
||||
const result = await publishToRelays(
|
||||
['wss://ok1.example', 'wss://ok2.example', 'wss://fail.example'],
|
||||
sampleEvent,
|
||||
{ publishFn: injected, retries: 0, timeoutMs: 100 },
|
||||
)
|
||||
assertEquals(result.ok.sort(), ['wss://ok1.example', 'wss://ok2.example'])
|
||||
assertEquals(result.failed, ['wss://fail.example'])
|
||||
})
|
||||
|
||||
Deno.test('publishToRelays: retry bei Fehler', async () => {
|
||||
let attempts = 0
|
||||
const injected = async () => {
|
||||
attempts++
|
||||
if (attempts < 2) return { ok: false, reason: 'transient' }
|
||||
return { ok: true }
|
||||
}
|
||||
const result = await publishToRelays(
|
||||
['wss://flaky.example'],
|
||||
sampleEvent,
|
||||
{ publishFn: injected, retries: 1, timeoutMs: 100, backoffMs: 1 },
|
||||
)
|
||||
assertEquals(result.ok, ['wss://flaky.example'])
|
||||
assertEquals(attempts, 2)
|
||||
})
|
||||
|
||||
Deno.test('publishToRelays: timeout → failed', async () => {
|
||||
const pendingTimers: number[] = []
|
||||
const injected = () =>
|
||||
new Promise<{ ok: boolean }>((resolve) => {
|
||||
const t = setTimeout(() => resolve({ ok: true }), 500)
|
||||
pendingTimers.push(t)
|
||||
})
|
||||
try {
|
||||
const result = await publishToRelays(
|
||||
['wss://slow.example'],
|
||||
sampleEvent,
|
||||
{ publishFn: injected, retries: 0, timeoutMs: 10 },
|
||||
)
|
||||
assertEquals(result.failed, ['wss://slow.example'])
|
||||
} finally {
|
||||
for (const t of pendingTimers) clearTimeout(t)
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue