diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt new file mode 100644 index 000000000..57eb412b5 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt @@ -0,0 +1,148 @@ +/* + * 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.transport + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * In-memory [WebTransportSession] used by tests for MoQ framing / session logic. + * + * A pair of fakes is connected via [pair] — anything written on one side is + * delivered on the other. This deliberately simulates *success* semantics + * only (no packet loss, no congestion); the real Kwik-backed transport will + * exercise those codepaths separately. + * + * [incomingDatagrams] and [FakeBidiStream.incoming] use [consumeAsFlow] + * semantics and therefore may only be collected once per channel. + */ +class FakeWebTransport private constructor( + private val outboundDatagrams: Channel, + private val inboundDatagrams: Channel, + private val outboundBidiStreams: Channel, + private val inboundBidiStreams: Channel, + private val inboundUniStreams: Channel, +) : WebTransportSession { + private val stateLock = Mutex() + private var open = true + + override val isOpen: Boolean get() = open + + override suspend fun openBidiStream(): WebTransportBidiStream { + stateLock.withLock { check(open) { "session closed" } } + val localToPeer = Channel(Channel.BUFFERED) + val peerToLocal = Channel(Channel.BUFFERED) + val local = FakeBidiStream(write = localToPeer, read = peerToLocal) + val peer = FakeBidiStream(write = peerToLocal, read = localToPeer) + // Send *peer* half to the other side's inbound queue. + outboundBidiStreams.send(peer) + return local + } + + override fun incomingUniStreams(): Flow = inboundUniStreams.consumeAsFlow() + + override suspend fun sendDatagram(payload: ByteArray): Boolean { + if (!open) return false + outboundDatagrams.send(payload) + return true + } + + override fun incomingDatagrams(): Flow = inboundDatagrams.consumeAsFlow() + + override suspend fun close( + code: Int, + reason: String, + ) { + stateLock.withLock { + if (!open) return + open = false + } + outboundDatagrams.close() + inboundDatagrams.close() + outboundBidiStreams.close() + inboundBidiStreams.close() + inboundUniStreams.close() + } + + /** + * Flow of peer-opened bidi streams (i.e. the local endpoint of a stream the + * other side created via [openBidiStream]). + */ + fun peerOpenedBidiStreams(): Flow = inboundBidiStreams.consumeAsFlow() + + companion object { + /** + * Create two linked fakes that act as "client" and "server" endpoints of + * the same virtual WebTransport session. Datagrams and streams opened on + * one side appear on the other. + */ + fun pair(): Pair { + val aToBDatagrams = Channel(Channel.BUFFERED) + val bToADatagrams = Channel(Channel.BUFFERED) + val aToBBidi = Channel(Channel.BUFFERED) + val bToABidi = Channel(Channel.BUFFERED) + val aToBUni = Channel(Channel.BUFFERED) + val bToAUni = Channel(Channel.BUFFERED) + + val a = + FakeWebTransport( + outboundDatagrams = aToBDatagrams, + inboundDatagrams = bToADatagrams, + outboundBidiStreams = aToBBidi, + inboundBidiStreams = bToABidi, + inboundUniStreams = bToAUni, + ) + val b = + FakeWebTransport( + outboundDatagrams = bToADatagrams, + inboundDatagrams = aToBDatagrams, + outboundBidiStreams = bToABidi, + inboundBidiStreams = aToBBidi, + inboundUniStreams = aToBUni, + ) + return a to b + } + } +} + +class FakeBidiStream internal constructor( + private val write: Channel, + private val read: Channel, +) : WebTransportBidiStream { + override fun incoming(): Flow = read.consumeAsFlow() + + override suspend fun write(chunk: ByteArray) { + write.send(chunk) + } + + override suspend fun finish() { + write.close() + } +} + +class FakeReadStream internal constructor( + private val read: Channel, +) : WebTransportReadStream { + override fun incoming(): Flow = read.consumeAsFlow() +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt new file mode 100644 index 000000000..1282b0f2b --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt @@ -0,0 +1,126 @@ +/* + * 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.transport + +import kotlinx.coroutines.flow.Flow + +/** + * Platform-agnostic WebTransport session, as produced by a successful Extended + * CONNECT (RFC 9220) handshake. + * + * The MoQ layer (Phase 3c) talks to this interface; the real Kwik-based + * implementation sits behind [WebTransportFactory] in jvmAndroid. Keeping this + * abstract lets us: + * - unit-test the MoQ framing layer with an in-memory fake, + * - swap transport implementations (Cronet on Android, a browser-backed + * WebView bridge as a contingency, Kwik on JVM desktop) without touching + * audio/UI code. + * + * Lifecycle: the session is opened via [WebTransportFactory.connect] and must + * be closed with [close] to release the underlying QUIC connection. + */ +interface WebTransportSession { + /** True once the Extended CONNECT exchange has returned 2xx and before [close] is called. */ + val isOpen: Boolean + + /** + * Open a new bidirectional WebTransport stream. The returned [WebTransportBidiStream] + * is writable + readable and closes when either peer half-closes. + */ + suspend fun openBidiStream(): WebTransportBidiStream + + /** + * Flow of inbound unidirectional streams initiated by the peer. + * + * The flow completes when [close] is called or the peer tears down the session. + */ + fun incomingUniStreams(): Flow + + /** Send a QUIC datagram; returns false if the datagram was dropped by congestion control. */ + suspend fun sendDatagram(payload: ByteArray): Boolean + + /** Flow of inbound datagrams. */ + fun incomingDatagrams(): Flow + + /** Gracefully close the session with an optional application-level code + reason. */ + suspend fun close( + code: Int = 0, + reason: String = "", + ) +} + +/** Writable + readable half of a bidirectional WebTransport stream. */ +interface WebTransportBidiStream : + WebTransportReadStream, + WebTransportWriteStream + +/** Read-only WebTransport stream (either a received uni stream or the read half of a bidi). */ +interface WebTransportReadStream { + /** Flow of chunks as they arrive. Completes when the peer closes its write side. */ + fun incoming(): Flow +} + +/** Write-only WebTransport stream. */ +interface WebTransportWriteStream { + suspend fun write(chunk: ByteArray) + + /** Half-close the write side (FIN). No further writes after this call. */ + suspend fun finish() +} + +/** + * Factory that opens a [WebTransportSession] against a nests MoQ endpoint. + * + * [authority] is the `host:port` of the WT server, [path] is the URL path + * (nests defaults to `/moq`), and [bearerToken] is typically the token + * returned by the `/api/v1/nests/` HTTP call in Phase 3a. + */ +interface WebTransportFactory { + suspend fun connect( + authority: String, + path: String, + bearerToken: String? = null, + ): WebTransportSession +} + +/** + * Single transport-layer exception type, so UI code only has to differentiate + * on [kind] rather than catching library-specific classes. + */ +class WebTransportException( + val kind: Kind, + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) { + enum class Kind { + /** DNS / TCP / TLS / QUIC handshake failed. */ + HandshakeFailed, + + /** WebTransport Extended CONNECT returned a non-2xx response. */ + ConnectRejected, + + /** Peer closed the session or a stream unexpectedly. */ + PeerClosed, + + /** The implementation does not yet support the requested operation. */ + NotImplemented, + } +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransportTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransportTest.kt new file mode 100644 index 000000000..580306a05 --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransportTest.kt @@ -0,0 +1,79 @@ +/* + * 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.transport + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FakeWebTransportTest { + @Test + fun pair_starts_open() { + val (a, b) = FakeWebTransport.pair() + assertTrue(a.isOpen) + assertTrue(b.isOpen) + } + + @Test + fun datagrams_from_a_arrive_on_b() = + runTest { + val (a, b) = FakeWebTransport.pair() + assertTrue(a.sendDatagram(byteArrayOf(1, 2, 3))) + assertContentEquals(byteArrayOf(1, 2, 3), b.incomingDatagrams().first()) + } + + @Test + fun datagrams_from_b_arrive_on_a() = + runTest { + val (a, b) = FakeWebTransport.pair() + assertTrue(b.sendDatagram(byteArrayOf(4, 5, 6))) + assertContentEquals(byteArrayOf(4, 5, 6), a.incomingDatagrams().first()) + } + + @Test + fun bidi_stream_write_is_visible_on_the_peer_side() = + runTest { + val (a, b) = FakeWebTransport.pair() + + val clientStream = a.openBidiStream() + clientStream.write(byteArrayOf(0xAA.toByte())) + clientStream.finish() + + val serverStream = b.peerOpenedBidiStreams().first() + val received = serverStream.incoming().toList() + assertEquals(1, received.size) + assertContentEquals(byteArrayOf(0xAA.toByte()), received.single()) + } + + @Test + fun close_flips_isOpen_and_drops_further_datagrams() = + runTest { + val (a, _) = FakeWebTransport.pair() + a.close() + assertFalse(a.isOpen) + assertFalse(a.sendDatagram(byteArrayOf(9))) + } +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportExceptionTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportExceptionTest.kt new file mode 100644 index 000000000..0d0a9e10e --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportExceptionTest.kt @@ -0,0 +1,50 @@ +/* + * 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.transport + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class WebTransportExceptionTest { + @Test + fun exception_carries_kind_and_message() { + val ex = + WebTransportException( + kind = WebTransportException.Kind.HandshakeFailed, + message = "cert expired", + ) + assertEquals(WebTransportException.Kind.HandshakeFailed, ex.kind) + assertEquals("cert expired", ex.message) + } + + @Test + fun exception_preserves_cause() { + val root = RuntimeException("root") + val ex = + WebTransportException( + kind = WebTransportException.Kind.PeerClosed, + message = "peer went away", + cause = root, + ) + assertSame(root, ex.cause) + } +} diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactory.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactory.kt new file mode 100644 index 000000000..424140bb0 --- /dev/null +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactory.kt @@ -0,0 +1,54 @@ +/* + * 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.transport + +/** + * JVM + Android [WebTransportFactory] that will wrap the Kwik QUIC library + * (plus Flupke for HTTP/3) once the Extended CONNECT handshake lands. + * + * **Status:** Phase 3b-1 only ships the interface contract — calling + * [connect] throws [WebTransportException] with + * [WebTransportException.Kind.NotImplemented]. This lets the Phase 3c MoQ + * framing layer develop and test against a stable [WebTransportSession] + * abstraction (see [FakeWebTransport]) while the real Kwik integration is + * delivered in Phase 3b-2. + * + * The expected implementation sequence: + * 1. QUIC dial via Kwik with ALPN "h3" to `authority`. + * 2. HTTP/3 SETTINGS exchange including `SETTINGS_ENABLE_CONNECT_PROTOCOL=1` + * and the WebTransport draft's `SETTINGS_ENABLE_WEBTRANSPORT=1`. + * 3. Extended CONNECT request: `:method=CONNECT :protocol=webtransport + * :scheme=https :authority= :path=` plus + * `Authorization: Bearer ` when supplied. + * 4. On 2xx, wrap the resulting bidi streams / datagrams in WebTransport + * framing (stream type 0x54 for client-initiated bidi). + */ +class KwikWebTransportFactory : WebTransportFactory { + override suspend fun connect( + authority: String, + path: String, + bearerToken: String?, + ): WebTransportSession = + throw WebTransportException( + kind = WebTransportException.Kind.NotImplemented, + message = "Kwik-backed WebTransport handshake lands in Phase 3b-2", + ) +} diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactoryTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactoryTest.kt new file mode 100644 index 000000000..606ed742e --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactoryTest.kt @@ -0,0 +1,43 @@ +/* + * 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.transport + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class KwikWebTransportFactoryTest { + @Test + fun connect_throws_NotImplemented_until_phase_3b_2() = + runTest { + val factory = KwikWebTransportFactory() + val ex = + assertFailsWith { + factory.connect( + authority = "nostrnests.com", + path = "/moq", + bearerToken = "ignored", + ) + } + assertEquals(WebTransportException.Kind.NotImplemented, ex.kind) + } +}