feat(nestsClient): NestsListener facade + connect orchestrator

Phase 3d-2 of the Clubhouse/nests integration. Ties HTTP auth (3a) +
WebTransport (3b-1 stub) + MoQ session (3c) under a single
`connectNestsListener()` entry point with an observable state
machine, so audio-room callers see one resource and one StateFlow
instead of three layered handshakes. Pure commonMain — fully tested
against fakes; no network or Android needed.

commonMain (nestsclient):
- `NestsListener` interface — `state: StateFlow<NestsListenerState>`,
  `subscribeSpeaker(pubkeyHex)`, `close()`. nests namespaces each
  speaker's track as `["nests", <roomId>]` with the speaker's pubkey
  hex as track name; subscribeSpeaker fills that in.
- `NestsListenerState` sealed class:
  * Idle
  * Connecting(step = ResolvingRoom | OpeningTransport | MoqHandshake)
  * Connected(roomInfo, negotiatedMoqVersion)
  * Failed(reason, cause?)
  * Closed
- `DefaultNestsListener` — straight delegation to MoqSession once
  connected.
- `connectNestsListener(httpClient, transport, scope, serviceBase,
  roomId, signer, supportedMoqVersions)` — walks the three handshake
  steps. Each failure short-circuits to Failed with a clear reason
  string and the underlying cause attached; transport is torn down on
  partial-handshake failure.
- `parseEndpoint(url)` — hand-rolled URL splitter so commonMain stays
  free of a URL-library dependency. Handles default-port stripping,
  rejects userinfo, preserves query strings.

commonTest:
- `NestsConnectTest` (5 cases, all using fakes):
  * Happy path: walks resolveRoom -> transport.connect -> MoQ SETUP,
    asserts state lands on Connected with the right roomInfo +
    negotiated version, and verifies the transport saw the parsed
    authority/path/bearer.
  * resolveRoom failure short-circuits without touching transport.
  * Transport handshake failure short-circuits with the right kind in
    the reason.
  * Malformed endpoint URL short-circuits.
  * parseEndpoint covers default-port stripping, explicit port,
    pathless URL, query string preservation.

This completes the pure-Kotlin integration. The only seam left
between `connectNestsListener()` and audible audio is the Kwik-backed
WebTransportFactory implementation (Phase 3b-2). When that lands, an
Amethyst AudioRoomViewModel can wire the existing AudioRoomStage to:

  state = MutableStateFlow(NestsListenerState.Idle)
  listener = connectNestsListener(...)
  for each speaker p: AudioRoomPlayer(MediaCodecOpusDecoder(),
      AudioTrackPlayer(), scope).play(listener.subscribeSpeaker(p).objects)

…and audio plays.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
This commit is contained in:
Claude
2026-04-22 07:21:38 +00:00
parent 6e6fc4cabb
commit c1355f1dd8
3 changed files with 573 additions and 0 deletions
@@ -0,0 +1,194 @@
/*
* 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.nestsclient
import com.vitorpamplona.nestsclient.moq.MoqSession
import com.vitorpamplona.nestsclient.moq.MoqVersion
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
import com.vitorpamplona.nestsclient.moq.TrackNamespace
import com.vitorpamplona.nestsclient.transport.WebTransportException
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Walk the full join-as-listener handshake against a nests-compatible audio
* server:
*
* 1. Resolve the room — POST/GET `<serviceBase>/<roomId>` with NIP-98 auth,
* returning [NestsRoomInfo] (the MoQ endpoint + bearer token).
* 2. Open a [com.vitorpamplona.nestsclient.transport.WebTransportSession]
* against the endpoint via [transport].
* 3. Run the MoQ SETUP handshake.
*
* The returned [NestsListener] is in state [NestsListenerState.Connected];
* if any step fails, the listener is returned in
* [NestsListenerState.Failed] with the underlying cause attached and the
* transport torn down.
*
* @param signer NIP-98 signer for the resolveRoom HTTP call.
* @param scope where the [MoqSession] pumps live (typically the caller's
* ViewModel scope so they cancel when the screen leaves).
* @param supportedMoqVersions in preference order; defaults to draft-17.
*/
suspend fun connectNestsListener(
httpClient: NestsClient,
transport: WebTransportFactory,
scope: CoroutineScope,
serviceBase: String,
roomId: String,
signer: NostrSigner,
supportedMoqVersions: List<Long> = listOf(MoqVersion.DRAFT_17),
): NestsListener {
val state =
MutableStateFlow<NestsListenerState>(
NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.ResolvingRoom),
)
val roomInfo =
try {
httpClient.resolveRoom(serviceBase = serviceBase, roomId = roomId, signer = signer)
} catch (t: NestsException) {
state.value = NestsListenerState.Failed("Room resolution failed: ${t.message}", t)
return failedListener(state)
}
state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.OpeningTransport)
val (authority, path) =
try {
parseEndpoint(roomInfo.endpoint)
} catch (t: Throwable) {
state.value =
NestsListenerState.Failed(
"Malformed MoQ endpoint URL '${roomInfo.endpoint}': ${t.message}",
t,
)
return failedListener(state)
}
val webTransport =
try {
transport.connect(authority = authority, path = path, bearerToken = roomInfo.token)
} catch (t: WebTransportException) {
state.value =
NestsListenerState.Failed(
"WebTransport ${t.kind.name}: ${t.message}",
t,
)
return failedListener(state)
}
state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.MoqHandshake)
val moq =
try {
MoqSession.client(webTransport, scope).also { it.setup(supportedMoqVersions) }
} catch (t: Throwable) {
runCatching { webTransport.close(0, "moq setup failed") }
state.value = NestsListenerState.Failed("MoQ handshake failed: ${t.message}", t)
return failedListener(state)
}
val negotiatedVersion =
moq.selectedVersion ?: run {
runCatching { moq.close() }
state.value = NestsListenerState.Failed("MoQ session reported no negotiated version")
return failedListener(state)
}
state.value = NestsListenerState.Connected(roomInfo, negotiatedVersion)
return DefaultNestsListener(
session = moq,
roomNamespace = TrackNamespace.of("nests", roomId),
mutableState = state,
)
}
/**
* Build a no-op [NestsListener] in a Failed state for callers that want a
* uniform return type even on early-failure paths.
*/
private fun failedListener(state: MutableStateFlow<NestsListenerState>): NestsListener =
object : NestsListener {
override val state = state
override suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle = error("listener never connected: ${state.value}")
override suspend fun close() {
if (state.value !is NestsListenerState.Closed) {
state.value = NestsListenerState.Closed
}
}
}
/**
* Split a typical nests endpoint URL such as `https://relay.example.com/moq`
* or `https://relay.example.com:4443/api/v1/moq?room=abc` into the
* WebTransport `(authority, path)` pair. WebTransport authority is
* `host[:port]` (port omitted when it's the protocol default); path is
* everything after the authority including any query string. Defaults to
* `/` when the URL has no path.
*
* Hand-rolled rather than pulling in a URL library so this stays in
* commonMain with no extra dependency.
*/
internal fun parseEndpoint(endpoint: String): Pair<String, String> {
val schemeEnd = endpoint.indexOf("://")
require(schemeEnd > 0) { "endpoint must include a scheme (got '$endpoint')" }
val scheme = endpoint.substring(0, schemeEnd).lowercase()
val rest = endpoint.substring(schemeEnd + 3)
val pathSep = rest.indexOf('/')
val (authorityRaw, pathRaw) =
if (pathSep < 0) {
rest to "/"
} else {
rest.substring(0, pathSep) to rest.substring(pathSep)
}
require(authorityRaw.isNotEmpty()) { "endpoint must include an authority (got '$endpoint')" }
val portSep = authorityRaw.lastIndexOf(':')
val hasUserInfo = authorityRaw.contains('@')
require(!hasUserInfo) { "endpoint must not include userinfo (got '$endpoint')" }
// Strip the port if it's the scheme default so the on-the-wire authority is
// canonical.
val authority =
if (portSep >= 0) {
val host = authorityRaw.substring(0, portSep)
val portStr = authorityRaw.substring(portSep + 1)
val port = portStr.toIntOrNull() ?: error("malformed port '$portStr' in '$endpoint'")
val defaultPort =
when (scheme) {
"https", "wss" -> 443
"http", "ws" -> 80
else -> -1
}
if (port == defaultPort) host else "$host:$port"
} else {
authorityRaw
}
return authority to pathRaw
}
@@ -0,0 +1,132 @@
/*
* 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.nestsclient
import com.vitorpamplona.nestsclient.moq.MoqSession
import com.vitorpamplona.nestsclient.moq.SubscribeFilter
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
import com.vitorpamplona.nestsclient.moq.TrackNamespace
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* High-level listener handle for an audio-room. Hides the layered HTTP +
* WebTransport + MoQ wiring under one observable state machine so UI code
* (and tests) can reason about the room as a single resource.
*
* Open one [NestsListener] per audio-room screen. To listen to multiple
* speakers in the room, call [subscribeSpeaker] once per speaker pubkey.
*/
interface NestsListener {
/** Live connection state — the UI typically shows a chip / spinner derived from this. */
val state: StateFlow<NestsListenerState>
/**
* Subscribe to one speaker's audio track. nests publishes each speaker's
* Opus stream under the namespace `["nests", <roomId>]` with the
* speaker's pubkey hex as the track name.
*
* @throws com.vitorpamplona.nestsclient.moq.MoqProtocolException if the
* publisher rejects the subscription.
* @throws IllegalStateException if the listener is not in [NestsListenerState.Connected].
*/
suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle
/** Tear down the MoQ session + underlying transport. Idempotent. */
suspend fun close()
}
/**
* Lifecycle states of a [NestsListener].
*
* Resolved/connected information (room metadata, MoQ version) is carried on
* [NestsListenerState.Connected] so the UI can render details without
* threading them separately.
*/
sealed class NestsListenerState {
/** No connection attempt has started yet. */
data object Idle : NestsListenerState()
/** A connect call is in flight. [step] indicates which substage. */
data class Connecting(
val step: ConnectStep,
) : NestsListenerState() {
enum class ConnectStep {
/** Calling `<service>/<roomId>` to obtain the MoQ endpoint + token. */
ResolvingRoom,
/** Opening the WebTransport (Kwik QUIC + Extended CONNECT). */
OpeningTransport,
/** Running the MoQ CLIENT_SETUP / SERVER_SETUP exchange. */
MoqHandshake,
}
}
/** Connection is live. [roomInfo] reflects the resolved server metadata. */
data class Connected(
val roomInfo: NestsRoomInfo,
val negotiatedMoqVersion: Long,
) : NestsListenerState()
/** A connect attempt or live session failed. UI shows [reason] to the user. */
data class Failed(
val reason: String,
val cause: Throwable? = null,
) : NestsListenerState()
/** [close] has been called. Terminal. */
data object Closed : NestsListenerState()
}
/**
* Default [NestsListener] implementation that delegates to a [MoqSession]
* already set up on a [com.vitorpamplona.nestsclient.transport.WebTransportSession].
*
* Construction does NOT open the transport — call [connectNestsListener] to
* walk the full HTTP + transport + MoQ handshake; this class just owns the
* resulting session and exposes the listener API on top.
*/
class DefaultNestsListener internal constructor(
private val session: MoqSession,
private val roomNamespace: TrackNamespace,
private val mutableState: MutableStateFlow<NestsListenerState>,
) : NestsListener {
override val state: StateFlow<NestsListenerState> = mutableState.asStateFlow()
override suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle {
check(state.value is NestsListenerState.Connected) {
"NestsListener.subscribeSpeaker requires Connected state, was ${state.value}"
}
return session.subscribe(
namespace = roomNamespace,
trackName = speakerPubkeyHex.encodeToByteArray(),
filter = SubscribeFilter.LatestGroup,
)
}
override suspend fun close() {
if (state.value is NestsListenerState.Closed) return
runCatching { session.close() }
mutableState.value = NestsListenerState.Closed
}
}
@@ -0,0 +1,247 @@
/*
* 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.nestsclient
import com.vitorpamplona.nestsclient.moq.ClientSetup
import com.vitorpamplona.nestsclient.moq.MoqCodec
import com.vitorpamplona.nestsclient.moq.MoqVersion
import com.vitorpamplona.nestsclient.moq.ServerSetup
import com.vitorpamplona.nestsclient.transport.FakeWebTransport
import com.vitorpamplona.nestsclient.transport.WebTransportException
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.nestsclient.transport.WebTransportSession
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.test.fail
class NestsConnectTest {
@Test
fun connect_walks_resolveRoom_then_transport_then_moq_handshake() =
runTest {
val (clientSide, serverSide) = FakeWebTransport.pair()
val httpClient =
FakeNestsClient(
NestsRoomInfo(
endpoint = "https://relay.example.com/moq",
token = "tok-abc",
),
)
val transport = ConstantWebTransportFactory(clientSide)
// Server-side raw peer answers SETUP.
val server =
async {
val control = serverSide.peerOpenedBidiStreams().first()
val cs = MoqCodec.decode(control.incoming().first())!!.message as ClientSetup
control.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first())))
}
val listener =
connectNestsListener(
httpClient = httpClient,
transport = transport,
scope = this,
serviceBase = "https://relay.example.com/api/v1/nests",
roomId = "abc",
signer = NostrSignerInternal(KeyPair()),
)
server.await()
val connected = assertIs<NestsListenerState.Connected>(listener.state.value)
assertEquals("https://relay.example.com/moq", connected.roomInfo.endpoint)
assertEquals(MoqVersion.DRAFT_17, connected.negotiatedMoqVersion)
assertEquals("relay.example.com", transport.lastConnectedAuthority)
assertEquals("/moq", transport.lastConnectedPath)
assertEquals("tok-abc", transport.lastBearer)
listener.close()
assertIs<NestsListenerState.Closed>(listener.state.value)
}
@Test
fun resolveRoom_failure_short_circuits_to_Failed() =
runTest {
val httpClient =
ThrowingNestsClient(
NestsException("server returned 500", status = 500),
)
val transport = NeverConnectFactory()
val listener =
connectNestsListener(
httpClient = httpClient,
transport = transport,
scope = this,
serviceBase = "https://relay.example.com/api/v1/nests",
roomId = "abc",
signer = NostrSignerInternal(KeyPair()),
)
val failed = assertIs<NestsListenerState.Failed>(listener.state.value)
assertTrue("Room resolution failed" in failed.reason)
assertTrue("500" in failed.reason || (failed.cause as? NestsException)?.status == 500)
assertEquals(0, transport.connectCallCount, "transport must not be reached")
}
@Test
fun transport_handshake_failure_short_circuits_to_Failed() =
runTest {
val httpClient =
FakeNestsClient(NestsRoomInfo(endpoint = "https://relay.example.com/moq"))
val transport =
ThrowingTransportFactory(
WebTransportException(
WebTransportException.Kind.HandshakeFailed,
"QUIC handshake refused",
),
)
val listener =
connectNestsListener(
httpClient = httpClient,
transport = transport,
scope = this,
serviceBase = "https://relay.example.com/api/v1/nests",
roomId = "abc",
signer = NostrSignerInternal(KeyPair()),
)
val failed = assertIs<NestsListenerState.Failed>(listener.state.value)
assertTrue("HandshakeFailed" in failed.reason, "got: ${failed.reason}")
}
@Test
fun malformed_endpoint_url_short_circuits_to_Failed() =
runTest {
val httpClient =
FakeNestsClient(NestsRoomInfo(endpoint = "not-a-url"))
val listener =
connectNestsListener(
httpClient = httpClient,
transport = NeverConnectFactory(),
scope = this,
serviceBase = "https://relay.example.com/api/v1/nests",
roomId = "abc",
signer = NostrSignerInternal(KeyPair()),
)
val failed = assertIs<NestsListenerState.Failed>(listener.state.value)
assertTrue("Malformed MoQ endpoint" in failed.reason, "got: ${failed.reason}")
}
@Test
fun parseEndpoint_drops_default_port_keeps_explicit_port_and_path() {
assertEquals(
"relay.example.com" to "/moq",
parseEndpoint("https://relay.example.com/moq"),
)
assertEquals(
"relay.example.com:4443" to "/moq",
parseEndpoint("https://relay.example.com:4443/moq"),
)
assertEquals(
"relay.example.com" to "/",
parseEndpoint("https://relay.example.com"),
)
assertEquals(
"relay.example.com" to "/api/v1/moq?room=abc",
parseEndpoint("https://relay.example.com/api/v1/moq?room=abc"),
)
}
// ---------------------------------------------------------- fakes
private class FakeNestsClient(
private val info: NestsRoomInfo,
) : NestsClient {
override suspend fun resolveRoom(
serviceBase: String,
roomId: String,
signer: NostrSigner,
): NestsRoomInfo = info
}
private class ThrowingNestsClient(
private val toThrow: NestsException,
) : NestsClient {
override suspend fun resolveRoom(
serviceBase: String,
roomId: String,
signer: NostrSigner,
): NestsRoomInfo = throw toThrow
}
private class ConstantWebTransportFactory(
private val session: WebTransportSession,
) : WebTransportFactory {
var lastConnectedAuthority: String? = null
private set
var lastConnectedPath: String? = null
private set
var lastBearer: String? = null
private set
override suspend fun connect(
authority: String,
path: String,
bearerToken: String?,
): WebTransportSession {
lastConnectedAuthority = authority
lastConnectedPath = path
lastBearer = bearerToken
return session
}
}
private class ThrowingTransportFactory(
private val toThrow: WebTransportException,
) : WebTransportFactory {
override suspend fun connect(
authority: String,
path: String,
bearerToken: String?,
): WebTransportSession = throw toThrow
}
private class NeverConnectFactory : WebTransportFactory {
var connectCallCount = 0
private set
override suspend fun connect(
authority: String,
path: String,
bearerToken: String?,
): WebTransportSession {
connectCallCount++
fail("transport.connect must not be called")
}
}
}