mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(quartz): NIP-66 check options — read-test filters, write-test event, cached NIP-11 fetcher
RelayProber.probe()/probeFlow() take a filters option choosing the check: LIVENESS_FILTERS (default, impossible-id REQ — EOSE proves liveness with no payload) or readTestFilter(limit = 1) — a REQ the relay must actually work for, querying and streaming real events, making Verdict.rttEoseMs a genuine read test. RelayProbeWriteTest.build() creates the write-check event: ephemeral kind 20166 (never stored by compliant relays) carrying a NIP-40 expiration tag 60s out as belt-and-braces for relays that store unknown ephemeral kinds. Publish it under the monitor key, time the OK for rtt-write, map rejection prefixes to R requirement tags — an OK false still proves the write path. Nip11Fetcher is the missing fetch seam for relay information documents, mirroring Nip05Fetcher: the interface lives in commonMain, OkHttpNip11Fetcher (jvmAndroid) does the Accept: application/nostr+json GET, and CachedNip11Fetcher wraps any implementation with a TTL cache — successes trusted for a day, failures remembered for five minutes so a census doesn't hammer hosts that just refused. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
This commit is contained in:
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip11RelayInfo
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* TTL cache around any [Nip11Fetcher]. NIP-11 documents change rarely, so a
|
||||
* successful fetch is served from memory for [ttlSeconds]; a FAILED fetch is
|
||||
* also remembered — for the shorter [errorTtlSeconds] — so a mass census does
|
||||
* not hammer a host that just refused, while still retrying it soon.
|
||||
*
|
||||
* Concurrent first fetches of the same relay are not deduplicated: both hit the
|
||||
* network and the second result wins the cache slot. That is harmless (the
|
||||
* document is idempotent) and keeps this class lock-free.
|
||||
*/
|
||||
class CachedNip11Fetcher(
|
||||
private val delegate: Nip11Fetcher,
|
||||
private val ttlSeconds: Long = DEFAULT_TTL_SECONDS,
|
||||
private val errorTtlSeconds: Long = DEFAULT_ERROR_TTL_SECONDS,
|
||||
maxEntries: Int = 1000,
|
||||
private val now: () -> Long = { TimeUtils.now() },
|
||||
) : Nip11Fetcher {
|
||||
private sealed interface Cached {
|
||||
val at: Long
|
||||
}
|
||||
|
||||
private class Hit(
|
||||
val info: Nip11RelayInformation,
|
||||
override val at: Long,
|
||||
) : Cached
|
||||
|
||||
private class Miss(
|
||||
val message: String?,
|
||||
override val at: Long,
|
||||
) : Cached
|
||||
|
||||
private val cache = LruCache<NormalizedRelayUrl, Cached>(maxEntries)
|
||||
|
||||
/** The cached document if present and fresh; null otherwise. Never touches the network. */
|
||||
fun cachedOrNull(relay: NormalizedRelayUrl): Nip11RelayInformation? {
|
||||
val hit = cache[relay] as? Hit ?: return null
|
||||
return if (now() - hit.at < ttlSeconds) hit.info else null
|
||||
}
|
||||
|
||||
/** Drops the cache entry (success or failure) so the next [fetch] is fresh. */
|
||||
fun invalidate(relay: NormalizedRelayUrl) {
|
||||
cache.remove(relay)
|
||||
}
|
||||
|
||||
override suspend fun fetch(relay: NormalizedRelayUrl): Nip11RelayInformation {
|
||||
when (val cached = cache[relay]) {
|
||||
is Hit -> if (now() - cached.at < ttlSeconds) return cached.info
|
||||
is Miss ->
|
||||
if (now() - cached.at < errorTtlSeconds) {
|
||||
throw Nip11FetchException(cached.message ?: "cached NIP-11 failure for ${relay.url}")
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
return try {
|
||||
delegate.fetch(relay).also { cache.put(relay, Hit(it, now())) }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
cache.put(relay, Miss(e.message, now()))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Documents are near-static: trust a success for a day. */
|
||||
const val DEFAULT_TTL_SECONDS = 24L * 60 * 60
|
||||
|
||||
/** Failures are often transient: retry after five minutes. */
|
||||
const val DEFAULT_ERROR_TTL_SECONDS = 5L * 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip11RelayInfo
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Fetches and parses a relay's NIP-11 information document (the
|
||||
* `application/nostr+json` answer on the relay's https url). Mirrors the
|
||||
* [com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Fetcher] seam: the HTTP
|
||||
* transport lives in a platform implementation (OkHttpNip11Fetcher on
|
||||
* JVM/Android), so common code — probes, monitors, the CLI — depends only on
|
||||
* this interface. Wrap any implementation in [CachedNip11Fetcher] to add a TTL
|
||||
* cache.
|
||||
*
|
||||
* Throws [Nip11FetchException] (or a transport exception) when the document is
|
||||
* unavailable or unparseable.
|
||||
*/
|
||||
interface Nip11Fetcher {
|
||||
suspend fun fetch(relay: NormalizedRelayUrl): Nip11RelayInformation
|
||||
}
|
||||
|
||||
/** The relay answered, but not with a usable NIP-11 document (bad status, not JSON). */
|
||||
class Nip11FetchException(
|
||||
message: String,
|
||||
) : Exception(message)
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip66RelayMonitor.reachability
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* The event a NIP-66 monitor publishes to measure a relay's WRITE path: publish
|
||||
* one of these (signed with the monitor key), time the `OK`, and read the
|
||||
* rejection prefix when refused (`auth-required:`/`restricted:`/`pow:` map to
|
||||
* the discovery record's `R` requirement tags; a timed acceptance is `rtt-write`).
|
||||
*
|
||||
* The kind is EPHEMERAL (20000–29999 per NIP-01), so a compliant relay serves it
|
||||
* to current subscribers and never stores it — the probe leaves nothing behind.
|
||||
* [KIND] 20166 is this library's convention (30166 discovery minus the
|
||||
* addressable range), not something NIP-66 standardizes; any ephemeral kind
|
||||
* works. Belt-and-braces, the template also carries a NIP-40 `expiration` tag
|
||||
* [EXPIRATION_SECONDS] out, so a relay that stores unknown ephemeral kinds
|
||||
* anyway purges it promptly.
|
||||
*
|
||||
* A rejection is still a MEASUREMENT: an `OK false` proves the write path works
|
||||
* and documents the relay's policy. Only silence is a failed write test.
|
||||
*/
|
||||
object RelayProbeWriteTest {
|
||||
const val KIND = 20166
|
||||
|
||||
/** Storage-window ceiling for non-compliant relays that store ephemeral events. */
|
||||
const val EXPIRATION_SECONDS = 60L
|
||||
|
||||
fun build(
|
||||
content: String = "NIP-66 write probe",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): EventTemplate<Event> =
|
||||
eventTemplate(KIND, content, createdAt) {
|
||||
add(ExpirationTag.assemble(createdAt + EXPIRATION_SECONDS))
|
||||
}
|
||||
}
|
||||
+29
-7
@@ -100,18 +100,24 @@ class RelayProber(
|
||||
/**
|
||||
* Probe every relay in [relays], [waveSize] at a time, giving each wave up to
|
||||
* [timeoutMs] to reach terminals. Returns one [Verdict] per input relay.
|
||||
*
|
||||
* [filters] is the REQ each relay is asked to answer. The default
|
||||
* [LIVENESS_FILTERS] matches nothing, so an EOSE proves liveness without
|
||||
* streaming a payload; pass [readTestFilter] to make [Verdict.rttEoseMs] a
|
||||
* real read test instead (the relay must query and stream an actual event).
|
||||
*/
|
||||
suspend fun probe(
|
||||
relays: Collection<NormalizedRelayUrl>,
|
||||
timeoutMs: Long = 15_000,
|
||||
waveSize: Int = 1000,
|
||||
filters: List<Filter> = LIVENESS_FILTERS,
|
||||
): Result {
|
||||
val mark = TimeSource.Monotonic.markNow()
|
||||
val all = ArrayList<Verdict>(relays.size)
|
||||
val distinct = relays.toSet()
|
||||
var done = 0
|
||||
for (wave in distinct.chunked(waveSize.coerceAtLeast(1))) {
|
||||
probeWave(wave, timeoutMs) { all += it }
|
||||
probeWave(wave, timeoutMs, filters) { all += it }
|
||||
done += wave.size
|
||||
if (distinct.size > wave.size) {
|
||||
val liveSoFar = all.count { it.reachable }
|
||||
@@ -127,6 +133,9 @@ class RelayProber(
|
||||
* as its EOSE/CLOSED/connect-failure lands, not when the whole census ends. Only
|
||||
* relays that stay silent wait for their wave's [timeoutMs] deadline.
|
||||
*
|
||||
* [filters] picks the check, as in [probe]: [LIVENESS_FILTERS] (default) or
|
||||
* [readTestFilter].
|
||||
*
|
||||
* Probing starts when the flow is collected and pauses between waves while the
|
||||
* collector is busy (emission is sequential). Pair each verdict with
|
||||
* [toDiscoveryEventTemplate] to turn the stream into signable NIP-66 kind:30166
|
||||
@@ -136,16 +145,18 @@ class RelayProber(
|
||||
relays: Collection<NormalizedRelayUrl>,
|
||||
timeoutMs: Long = 15_000,
|
||||
waveSize: Int = 1000,
|
||||
filters: List<Filter> = LIVENESS_FILTERS,
|
||||
): Flow<Verdict> =
|
||||
flow {
|
||||
for (wave in relays.toSet().chunked(waveSize.coerceAtLeast(1))) {
|
||||
probeWave(wave, timeoutMs) { emit(it) }
|
||||
probeWave(wave, timeoutMs, filters) { emit(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun probeWave(
|
||||
wave: List<NormalizedRelayUrl>,
|
||||
timeoutMs: Long,
|
||||
filters: List<Filter>,
|
||||
onVerdict: suspend (Verdict) -> Unit,
|
||||
) {
|
||||
val mark = TimeSource.Monotonic.markNow()
|
||||
@@ -230,7 +241,7 @@ class RelayProber(
|
||||
|
||||
client.addConnectionListener(connListener)
|
||||
try {
|
||||
client.subscribe(subId, wave.associateWith { PROBE_FILTERS }, subListener)
|
||||
client.subscribe(subId, wave.associateWith { filters }, subListener)
|
||||
val remaining = wave.toMutableSet()
|
||||
while (remaining.isNotEmpty()) {
|
||||
val left = timeoutMs - mark.elapsedNow().inWholeMilliseconds
|
||||
@@ -251,10 +262,21 @@ class RelayProber(
|
||||
}
|
||||
|
||||
companion object {
|
||||
// A filter no event can match (ids are 64-hex of a hash): the relay answers
|
||||
// with an immediate EOSE and never streams a payload. Same trick as the
|
||||
// crawler's warm pool.
|
||||
private val PROBE_FILTERS = listOf(Filter(ids = listOf("0".repeat(64))))
|
||||
/**
|
||||
* A filter no event can match (ids are 64-hex of a hash): the relay answers
|
||||
* with an immediate EOSE and never streams a payload. Same trick as the
|
||||
* crawler's warm pool. This is the default check — pure liveness.
|
||||
*/
|
||||
val LIVENESS_FILTERS = listOf(Filter(ids = listOf("0".repeat(64))))
|
||||
|
||||
/**
|
||||
* A REQ the relay must actually WORK for: query its store and stream up to
|
||||
* [limit] real events before the EOSE. Pass to [probe]/[probeFlow] as
|
||||
* [filters] to turn [Verdict.rttEoseMs] into a genuine read test rather
|
||||
* than a liveness ping — the time still counts from the wave start (dial
|
||||
* included), so compare it against [Verdict.rttOpenMs], not across waves.
|
||||
*/
|
||||
fun readTestFilter(limit: Int = 1) = listOf(Filter(limit = limit))
|
||||
|
||||
/**
|
||||
* The relay universe the local store knows: every read/write relay advertised
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip11RelayInfo
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class CachedNip11FetcherTest {
|
||||
private val relay = RelayUrlNormalizer.normalize("wss://nostr.example.com")
|
||||
|
||||
/** Counts network hits; serves [doc] or throws when [failing]. */
|
||||
private class FakeFetcher : Nip11Fetcher {
|
||||
var calls = 0
|
||||
var failing = false
|
||||
var doc = Nip11RelayInformation(name = "v1")
|
||||
|
||||
override suspend fun fetch(relay: NormalizedRelayUrl): Nip11RelayInformation {
|
||||
calls++
|
||||
if (failing) throw Nip11FetchException("boom")
|
||||
return doc
|
||||
}
|
||||
}
|
||||
|
||||
private fun cached(
|
||||
delegate: FakeFetcher,
|
||||
clock: () -> Long,
|
||||
) = CachedNip11Fetcher(delegate, ttlSeconds = 100, errorTtlSeconds = 10, now = clock)
|
||||
|
||||
@Test
|
||||
fun freshSuccessIsServedFromCache() =
|
||||
runBlocking {
|
||||
val net = FakeFetcher()
|
||||
var now = 0L
|
||||
val fetcher = cached(net) { now }
|
||||
|
||||
assertEquals("v1", fetcher.fetch(relay).name)
|
||||
now = 99
|
||||
assertEquals("v1", fetcher.fetch(relay).name)
|
||||
assertEquals(1, net.calls, "second fetch inside the TTL must not touch the network")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successExpiresAfterTtl() =
|
||||
runBlocking {
|
||||
val net = FakeFetcher()
|
||||
var now = 0L
|
||||
val fetcher = cached(net) { now }
|
||||
|
||||
fetcher.fetch(relay)
|
||||
net.doc = Nip11RelayInformation(name = "v2")
|
||||
now = 100
|
||||
assertEquals("v2", fetcher.fetch(relay).name)
|
||||
assertEquals(2, net.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failureIsCachedForItsOwnShorterTtl() =
|
||||
runBlocking {
|
||||
val net = FakeFetcher().apply { failing = true }
|
||||
var now = 0L
|
||||
val fetcher = cached(net) { now }
|
||||
|
||||
assertFailsWith<Nip11FetchException> { fetcher.fetch(relay) }
|
||||
now = 9
|
||||
assertFailsWith<Nip11FetchException> { fetcher.fetch(relay) }
|
||||
assertEquals(1, net.calls, "a fresh failure must be served from cache, not re-fetched")
|
||||
|
||||
now = 10
|
||||
net.failing = false
|
||||
assertEquals("v1", fetcher.fetch(relay).name)
|
||||
assertEquals(2, net.calls, "an expired failure must be retried")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidateForcesAFreshFetch() =
|
||||
runBlocking {
|
||||
val net = FakeFetcher()
|
||||
val fetcher = cached(net) { 0 }
|
||||
|
||||
fetcher.fetch(relay)
|
||||
fetcher.invalidate(relay)
|
||||
fetcher.fetch(relay)
|
||||
assertEquals(2, net.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cachedOrNullNeverTouchesTheNetwork() =
|
||||
runBlocking {
|
||||
val net = FakeFetcher()
|
||||
var now = 0L
|
||||
val fetcher = cached(net) { now }
|
||||
|
||||
assertNull(fetcher.cachedOrNull(relay))
|
||||
assertEquals(0, net.calls)
|
||||
|
||||
fetcher.fetch(relay)
|
||||
assertEquals("v1", fetcher.cachedOrNull(relay)?.name)
|
||||
now = 100
|
||||
assertNull(fetcher.cachedOrNull(relay), "a stale hit must not be served")
|
||||
assertEquals(1, net.calls)
|
||||
}
|
||||
}
|
||||
+51
@@ -49,6 +49,7 @@ class RelayProberFlowTest {
|
||||
/** Captures the probe subscription so the test can play the relays. */
|
||||
private class ScriptedClient : INostrClient by EmptyNostrClient() {
|
||||
var listener: SubscriptionListener? = null
|
||||
var sentFilters: Map<NormalizedRelayUrl, List<Filter>>? = null
|
||||
|
||||
override fun subscribe(
|
||||
subId: String,
|
||||
@@ -56,6 +57,7 @@ class RelayProberFlowTest {
|
||||
listener: SubscriptionListener?,
|
||||
) {
|
||||
this.listener = listener
|
||||
this.sentFilters = filters
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +141,55 @@ class RelayProberFlowTest {
|
||||
assertTrue(at < 1_000, "a failed dial must not wait for the deadline, arrived at ${at}ms")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Check options — liveness default, read-test override, write-test event
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun livenessFilterIsTheDefaultCheck() =
|
||||
runTest {
|
||||
val client = ScriptedClient()
|
||||
val collector =
|
||||
launch {
|
||||
RelayProber(client).probeFlow(listOf(fast), timeoutMs = 1_000).collect {}
|
||||
}
|
||||
launch {
|
||||
delay(10)
|
||||
assertEquals(RelayProber.LIVENESS_FILTERS, client.sentFilters!![fast])
|
||||
client.listener!!.onEose(fast, null)
|
||||
}
|
||||
collector.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readTestFilterIsSentWhenChosen() =
|
||||
runTest {
|
||||
val client = ScriptedClient()
|
||||
val collector =
|
||||
launch {
|
||||
RelayProber(client)
|
||||
.probeFlow(listOf(fast), timeoutMs = 1_000, filters = RelayProber.readTestFilter())
|
||||
.collect {}
|
||||
}
|
||||
launch {
|
||||
delay(10)
|
||||
val sent = client.sentFilters!![fast]!!.single()
|
||||
assertEquals(1, sent.limit, "read test defaults to limit 1")
|
||||
assertNull(sent.ids, "read test must query real events, not the impossible id")
|
||||
client.listener!!.onEose(fast, null)
|
||||
}
|
||||
collector.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writeTestEventIsEphemeralAndSelfExpiring() {
|
||||
val template = RelayProbeWriteTest.build(createdAt = 5000)
|
||||
|
||||
assertEquals(20166, template.kind)
|
||||
assertTrue(template.kind in 20000..29999, "the write probe must be an ephemeral kind")
|
||||
assertTrue(listOf("expiration", "5060") in template.tags.map { it.toList() })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// toDiscoveryEventTemplate — only observed facts become tags
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip11RelayInfo
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
|
||||
/**
|
||||
* OkHttp-backed [Nip11Fetcher]: GETs the relay's https url with
|
||||
* `Accept: application/nostr+json` and parses the document. The client is
|
||||
* resolved per relay so callers can route Tor/proxy relays through a different
|
||||
* OkHttp instance (the same seam Amethyst's Nip11Retriever uses).
|
||||
*/
|
||||
class OkHttpNip11Fetcher(
|
||||
private val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient,
|
||||
) : Nip11Fetcher {
|
||||
override suspend fun fetch(relay: NormalizedRelayUrl): Nip11RelayInformation =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(relay.toHttp())
|
||||
.build()
|
||||
|
||||
okHttpClient(relay).newCall(request).executeAsync().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw Nip11FetchException("HTTP ${response.code} fetching NIP-11 from ${relay.url}")
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (!body.startsWith("{")) {
|
||||
throw Nip11FetchException("Not a NIP-11 document from ${relay.url}")
|
||||
}
|
||||
Nip11RelayInformation.fromJson(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user