diff --git a/docs/artifacts.md b/docs/artifacts.md index d5b0afe7d..f07c2fdba 100644 --- a/docs/artifacts.md +++ b/docs/artifacts.md @@ -33,8 +33,9 @@ web socket implementations: | Artifact | Description | |-------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -|
krossbow-websocket-builtin
| A multiplatform `WebSocketClient` implementation that adapts the built-in client for each supported platform without transitive dependency. | +|
krossbow-websocket-builtin
| A multiplatform `WebSocketClient` implementation that adapts the built-in client present in some platforms without transitive dependency. | |
krossbow-websocket-ktor
| A multiplatform `WebSocketClient` implementation based on Ktor {{ versions.ktor }}'s `HttpClient`. | +|
krossbow-websocket-ktor-legacy
| A multiplatform `WebSocketClient` implementation based on Ktor {{ versions.ktorLegacy }}'s `HttpClient`. | |
krossbow-websocket-okhttp
| A JVM implementation of the web socket API using OkHttp's client. | |
krossbow-websocket-sockjs
| A multiplatform `WebSocketClient` implementation for use with SockJS servers. It uses Spring's SockJSClient on JVM, and npm `sockjs-client` for JavaScript (NodeJS and browser). | |
krossbow-websocket-spring
| A JVM 8+ implementation of the web socket API using Spring's WebSocketClient. Provides both a normal WebSocket client and a SockJS one. | diff --git a/docs/migration-guides.md b/docs/migration-guides.md index e11dd7910..95828aaaf 100644 --- a/docs/migration-guides.md +++ b/docs/migration-guides.md @@ -1,5 +1,12 @@ Here are some details about how to migrate from one major version to another. +## From 8.x to 9.x + +### Ktor 2 moved to legacy module + +The `krossbow-websocket-ktor` artifact is now updated to Ktor 3 to get the performance improvements and WASM support. +If you have to stick to Ktor 2, please replace your `krossbow-websocket-ktor` with `krossbow-websocket-ktor-legacy`. + ## From 7.x to 8.x ### Headers rework diff --git a/docs/websocket/ktor.md b/docs/websocket/ktor.md index e7e419aea..b7fa4b21f 100644 --- a/docs/websocket/ktor.md +++ b/docs/websocket/ktor.md @@ -1,12 +1,17 @@ # Krossbow with Ktor -Krossbow allows you to use [Ktor's web socket](https://ktor.io/clients/websockets.html) as transport for STOMP. +Krossbow allows you to use [Ktor's web socket](https://ktor.io/docs/client-websockets.html) as transport for STOMP. Ktor's implementation supports a variety of platforms and is very popular in the Kotlin world, especially in Kotlin multiplatform. The `krossbow-websocket-ktor` module provides the `KtorWebSocketClient`, which adapts Ktor {{ versions.ktor }}'s `HttpClient` to Krossbow's web socket interface. +!!! info "Stuck with Ktor 2?" + Krossbow updated to Ktor 3 to benefit from all the performance improvements and the new WASM platform support. + If you are stuck with Ktor 2 for some reason, use the `krossbow-websocket-ktor-legacy` artifact instead. + You can then add Ktor dependencies in version {{ versions.ktorLegacy }}. + ## Usage with StompClient To use the `KtorWebSocketClient` pass an instance of it when creating your `StompClient`: @@ -35,7 +40,7 @@ You will need to declare the following Gradle dependency to use the `KtorWebSock implementation("org.hildan.krossbow:krossbow-websocket-ktor:{{ git.short_tag }}") ``` -Ktor uses [pluggable engines](https://ktor.io/clients/http-client/engines.html) to perform the platform-specific +Ktor uses [pluggable engines](https://ktor.io/docs/client-engines.html) to perform the platform-specific network operations (just like Krossbow uses different web socket implementations). You need to pick an engine that supports web sockets in order to use Ktor's `HttpClient` with web sockets. Follow Ktor's documentation to find out more about how to use engines. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 407212d5f..5fbe49dbf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,7 +25,8 @@ kotlinx-atomicfu = "0.26.1" kotlinx-coroutines = "1.10.1" kotlinx-io = "0.6.0" kotlinx-serialization = "1.8.0" -ktor = "2.3.12" +ktor = "3.0.3" +ktor-legacy = "2.3.12" moshi = "1.15.2" nexus-publish-plugin = "2.0.0" okhttp = "4.12.0" @@ -68,6 +69,16 @@ ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "kto ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-client-winhttp = { module = "io.ktor:ktor-client-winhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktorLegacy-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor-legacy" } +ktorLegacy-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor-legacy" } +ktorLegacy-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor-legacy" } +ktorLegacy-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor-legacy" } +ktorLegacy-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor-legacy" } +ktorLegacy-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor-legacy" } +ktorLegacy-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor-legacy" } +ktorLegacy-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor-legacy" } +ktorLegacy-client-winhttp = { module = "io.ktor:ktor-client-winhttp", version.ref = "ktor-legacy" } +ktorLegacy-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-legacy" } moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshiKotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } diff --git a/gradle/plugins/src/main/kotlin/Targets.kt b/gradle/plugins/src/main/kotlin/Targets.kt index 771b60b0d..4a9deba9a 100644 --- a/gradle/plugins/src/main/kotlin/Targets.kt +++ b/gradle/plugins/src/main/kotlin/Targets.kt @@ -3,19 +3,25 @@ import org.jetbrains.kotlin.gradle.dsl.* @OptIn(ExperimentalWasmDsl::class) fun KotlinMultiplatformExtension.allTargets() { - ktorTargets() + ktor3Targets() - wasmJs { - browser() + wasmWasi { nodejs() } - wasmWasi { +} + +@OptIn(ExperimentalWasmDsl::class) +fun KotlinMultiplatformExtension.ktor3Targets() { + ktor2Targets() + + wasmJs { + browser() nodejs() } } @OptIn(ExperimentalKotlinGradlePluginApi::class) -fun KotlinMultiplatformExtension.ktorTargets() { +fun KotlinMultiplatformExtension.ktor2Targets() { jvm { compilerOptions { freeCompilerArgs.add("-Xjvm-default=all-compatibility") diff --git a/gradle/plugins/src/main/kotlin/krossbow-multiplatform.gradle.kts b/gradle/plugins/src/main/kotlin/krossbow-multiplatform.gradle.kts index 0df024360..79dced0ef 100644 --- a/gradle/plugins/src/main/kotlin/krossbow-multiplatform.gradle.kts +++ b/gradle/plugins/src/main/kotlin/krossbow-multiplatform.gradle.kts @@ -14,9 +14,12 @@ kotlin { withApple() } } - group("wasm") { - withWasmJs() - withWasmWasi() + group("jsAndWasm") { + withJs() + group("wasm") { + withWasmJs() + withWasmWasi() + } } } } diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index 84dd0eab4..884b83a93 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -1990,6 +1990,11 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +ws@8.18.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" + integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== + ws@8.5.0: version "8.5.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f" diff --git a/krossbow-websocket-ktor-legacy/README.md b/krossbow-websocket-ktor-legacy/README.md new file mode 100644 index 000000000..1d89b9f08 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/README.md @@ -0,0 +1,3 @@ +# Krossbow Web Socket Ktor + +See the documentation for this module [on the project's website](https://joffrey-bion.github.io/krossbow/websocket/ktor/). diff --git a/krossbow-websocket-ktor-legacy/api/krossbow-websocket-ktor-legacy.api b/krossbow-websocket-ktor-legacy/api/krossbow-websocket-ktor-legacy.api new file mode 100644 index 000000000..84c3f76c8 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/api/krossbow-websocket-ktor-legacy.api @@ -0,0 +1,8 @@ +public final class org/hildan/krossbow/websocket/ktor/KtorWebSocketClient : org/hildan/krossbow/websocket/WebSocketClient { + public fun ()V + public fun (Lio/ktor/client/HttpClient;)V + public synthetic fun (Lio/ktor/client/HttpClient;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun connect (Ljava/lang/String;Ljava/util/List;Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getSupportsCustomHeaders ()Z +} + diff --git a/krossbow-websocket-ktor-legacy/api/krossbow-websocket-ktor.api b/krossbow-websocket-ktor-legacy/api/krossbow-websocket-ktor.api new file mode 100644 index 000000000..84c3f76c8 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/api/krossbow-websocket-ktor.api @@ -0,0 +1,8 @@ +public final class org/hildan/krossbow/websocket/ktor/KtorWebSocketClient : org/hildan/krossbow/websocket/WebSocketClient { + public fun ()V + public fun (Lio/ktor/client/HttpClient;)V + public synthetic fun (Lio/ktor/client/HttpClient;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun connect (Ljava/lang/String;Ljava/util/List;Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getSupportsCustomHeaders ()Z +} + diff --git a/krossbow-websocket-ktor-legacy/build.gradle.kts b/krossbow-websocket-ktor-legacy/build.gradle.kts new file mode 100644 index 000000000..fa56ff382 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + id("krossbow-multiplatform") + id("krossbow-publish") + alias(libs.plugins.kotlin.atomicfu) + id("websocket-test-server") +} + +description = "Multiplatform implementation of Krossbow's WebSocket API using Ktor's web sockets." + +kotlin { + ktor2Targets() + + sourceSets { + all { + languageSettings.optIn("org.hildan.krossbow.io.InternalKrossbowIoApi") + } + val commonMain by getting { + dependencies { + api(projects.krossbowWebsocketCore) + api(libs.ktorLegacy.client.websockets) + implementation(projects.krossbowIo) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(projects.krossbowWebsocketTest) + } + } + val cioSupportTest by creating { + dependsOn(commonTest) + dependencies { + implementation(libs.ktorLegacy.client.cio) + } + } + val jsMain by getting { + dependencies { + // workaround for https://youtrack.jetbrains.com/issue/KT-57235 + implementation(libs.kotlinx.atomicfu.runtime) + } + } + val jvmTest by getting { + dependsOn(cioSupportTest) + dependencies { + implementation(libs.ktorLegacy.client.java) + implementation(libs.ktorLegacy.client.okhttp) + implementation(libs.slf4j.simple) + } + } + val linuxX64Test by getting { + dependsOn(cioSupportTest) + } + val mingwX64Test by getting { + dependencies { + implementation(libs.ktorLegacy.client.winhttp) + } + } + val appleTest by getting { + dependsOn(cioSupportTest) + dependencies { + implementation(libs.ktorLegacy.client.darwin) + } + } + } +} + +dokkaExternalDocLink( + docsUrl = "https://api.ktor.io/ktor-client/", + packageListUrl = "https://api.ktor.io/package-list", +) diff --git a/krossbow-websocket-ktor-legacy/src/appleMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsDarwin.kt b/krossbow-websocket-ktor-legacy/src/appleMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsDarwin.kt new file mode 100644 index 000000000..0f7c76819 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/appleMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsDarwin.kt @@ -0,0 +1,30 @@ +package org.hildan.krossbow.websocket.ktor + +internal actual fun extractHandshakeFailureDetails(handshakeException: Exception): HandshakeFailureDetails = + genericFailureDetails(handshakeException) + +/* +We cannot extract any response code from the exception. + +The original error is almost the same for all response codes: +io.ktor.client.engine.darwin.DarwinHttpRequestException: Exception in http request: Error Domain=NSURLErrorDomain Code=-1011 "There was a bad response from the server." + +NSError object attached to the DarwinHttpRequestException: +{ + code = -1011 + description = Error Domain=NSURLErrorDomain Code=-1011 "There was a bad response from the server." UserInfo={NSErrorFailingURLStringKey=ws://localhost:49504/failHandshakeWithStatusCode/200, NSErrorFailingURLKey=ws://localhost:49504/failHandshakeWithStatusCode/200, _NSURLErrorWebSocketHandshakeFailureReasonKey=0, NSLocalizedDescription=There was a bad response from the server.} + userInfo = { + NSErrorFailingURLStringKey = ws://localhost:49347/failHandshakeWithStatusCode/200, + NSErrorFailingURLKey = ws://localhost:49347/failHandshakeWithStatusCode/200, + _NSURLErrorWebSocketHandshakeFailureReasonKey = 0, // sometimes different for tvOS + _NSURLErrorRelatedURLSessionTaskErrorKey = ("LocalWebSocketTask <26F4D5BA-7104-4506-A521-DBC19B1CC2B0>.<1>"), + _NSURLErrorFailingURLSessionTaskErrorKey = LocalWebSocketTask <26F4D5BA-7104-4506-A521-DBC19B1CC2B0>.<1>, + NSLocalizedDescription=There was a bad response from the server. + } + underlyingErrors = [] + localizedFailureReason = null + localizedRecoveryOptions = null + helpAnchor = null + recoveryAttempter = null +} + */ diff --git a/krossbow-websocket-ktor-legacy/src/appleTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorDarwinWebSocketClientTest.kt b/krossbow-websocket-ktor-legacy/src/appleTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorDarwinWebSocketClientTest.kt new file mode 100644 index 000000000..23eea1bbd --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/appleTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorDarwinWebSocketClientTest.kt @@ -0,0 +1,12 @@ +package org.hildan.krossbow.websocket.ktor + +import io.ktor.client.engine.* +import io.ktor.client.engine.darwin.* + +class KtorDarwinWebSocketClientTest : KtorClientTestSuite( + supportsStatusCodes = false, + // See https://youtrack.jetbrains.com/issue/KTOR-6970 + shouldTestNegotiatedSubprotocol = false, +) { + override fun provideEngine(): HttpClientEngineFactory<*> = Darwin +} diff --git a/krossbow-websocket-ktor-legacy/src/cioSupportTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorCioWebSocketClientTest.kt b/krossbow-websocket-ktor-legacy/src/cioSupportTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorCioWebSocketClientTest.kt new file mode 100644 index 000000000..ce1ff58a9 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/cioSupportTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorCioWebSocketClientTest.kt @@ -0,0 +1,21 @@ +package org.hildan.krossbow.websocket.ktor + +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.websocket.* +import org.hildan.krossbow.websocket.* +import org.hildan.krossbow.websocket.test.* + +class KtorCioWebSocketClientTest : WebSocketClientTestSuite() { + + override fun provideClient(): WebSocketClient = KtorWebSocketClient( + HttpClient(CIO) { + // The CIO engine seems to follow 301 redirects by default, but our test server doesn't provide a Location + // header with the URL to redirect to, so the client retries the same URL indefinitely. + // To avoid a SendCountExceedException in status code tests, we disable redirect-following explicitly here. + followRedirects = false + + install(WebSockets) + }, + ) +} diff --git a/krossbow-websocket-ktor-legacy/src/commonMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptions.kt b/krossbow-websocket-ktor-legacy/src/commonMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptions.kt new file mode 100644 index 000000000..cb23f6907 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/commonMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptions.kt @@ -0,0 +1,24 @@ +package org.hildan.krossbow.websocket.ktor + +internal data class HandshakeFailureDetails(val statusCode: Int?, val additionalInfo: String?) + +// This is the message for invalid status codes on CIO engine +private val wrongStatusExceptionMessageRegex = Regex("""Handshake exception, expected status code 101 but was (\d{3})""") + +internal fun extractKtorHandshakeFailureDetails(handshakeException: Exception): HandshakeFailureDetails { + val message = handshakeException.message + ?: return extractHandshakeFailureDetails(handshakeException) + val match = wrongStatusExceptionMessageRegex.matchEntire(message) + ?: return extractHandshakeFailureDetails(handshakeException) + return HandshakeFailureDetails( + statusCode = match.groupValues[1].toInt(), + additionalInfo = message, + ) +} + +internal expect fun extractHandshakeFailureDetails(handshakeException: Exception): HandshakeFailureDetails + +internal fun genericFailureDetails(handshakeException: Exception) = HandshakeFailureDetails( + statusCode = null, + additionalInfo = handshakeException.toString(), // not only the message because the exception name is useful +) diff --git a/krossbow-websocket-ktor-legacy/src/commonMain/kotlin/org/hildan/krossbow/websocket/ktor/KtorWebSocketClient.kt b/krossbow-websocket-ktor-legacy/src/commonMain/kotlin/org/hildan/krossbow/websocket/ktor/KtorWebSocketClient.kt new file mode 100644 index 000000000..4cdd776c6 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/commonMain/kotlin/org/hildan/krossbow/websocket/ktor/KtorWebSocketClient.kt @@ -0,0 +1,130 @@ +package org.hildan.krossbow.websocket.ktor + +import io.ktor.client.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.websocket.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.util.* +import io.ktor.websocket.* +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.io.bytestring.* +import kotlinx.io.bytestring.unsafe.* +import org.hildan.krossbow.io.* +import org.hildan.krossbow.websocket.* +import org.hildan.krossbow.websocket.WebSocketException + +class KtorWebSocketClient( + private val httpClient: HttpClient = HttpClient { install(WebSockets) } +) : WebSocketClient { + + override val supportsCustomHeaders: Boolean = !PlatformUtils.IS_BROWSER + + override suspend fun connect(url: String, protocols: List, headers: Map): WebSocketConnectionWithPingPong { + require(headers.isEmpty() || supportsCustomHeaders) { + "Custom web socket handshake headers are not supported in this Ktor engine " + + "(${httpClient.engine::class.simpleName}) on this platform (${PlatformUtils.platform})" + } + try { + val wsKtorSession = httpClient.webSocketSession(url) { + // Ktor doesn't support comma-separated protocols in a single header, so we send a repeated header + // instead (see https://youtrack.jetbrains.com/issue/KTOR-6971) + protocols.forEach { + header(HttpHeaders.SecWebSocketProtocol, it) + } + headers.forEach { (name, value) -> + header(name, value) + } + } + return KtorWebSocketConnectionAdapter(wsKtorSession) + } catch (e: CancellationException) { + throw e // this is an upstream exception that we don't want to wrap here + } catch (e: ResponseException) { + throw WebSocketConnectionException(url, httpStatusCode = e.response.status.value, cause = e) + } catch (e: Exception) { + val (statusCode, additionalInfo) = extractKtorHandshakeFailureDetails(e) + throw WebSocketConnectionException(url, httpStatusCode = statusCode, additionalInfo = additionalInfo, cause = e) + } + } +} + +private class KtorWebSocketConnectionAdapter( + private val wsSession: DefaultClientWebSocketSession +) : WebSocketConnectionWithPingPong { + + override val url: String = wsSession.call.request.url.toString() + + override val protocol: String? = wsSession.call.response.headers[HttpHeaders.SecWebSocketProtocol] + + @OptIn(DelicateCoroutinesApi::class) // for isClosedForSend + override val canSend: Boolean + get() = !wsSession.outgoing.isClosedForSend + + private val emittedCloseFrame = atomic(false) + + override val incomingFrames: Flow = + wsSession.incoming.receiveAsFlow() + .map { it.toKrossbowFrame() } + .onEach { + // We don't need our fake Close frame if there is one (in JS engine it seems there is) + if (it is WebSocketFrame.Close) { + emittedCloseFrame.getAndSet(true) + } + } + .onCompletion { error -> + // Ktor just closes the channel without sending the close frame, so we build it ourselves here. + // Clients could collect the flow multiple times, which calls onCompletion each time, but we only want + // to emit the Close frame once, as if it were in the channel like the other frames. + if (error == null && !emittedCloseFrame.getAndSet(true)) { + buildCloseFrame()?.let { emit(it) } + } + } + .catch { th -> + throw WebSocketException("error in Ktor's websocket: $th", cause = th) + } + + private suspend fun buildCloseFrame(): WebSocketFrame.Close? = wsSession.closeReason.await()?.let { reason -> + WebSocketFrame.Close(reason.code.toInt(), reason.message) + } + + override suspend fun sendText(frameText: String) { + wsSession.outgoing.send(Frame.Text(frameText)) + } + + @OptIn(UnsafeByteStringApi::class) + override suspend fun sendBinary(frameData: ByteString) { + wsSession.outgoing.send(Frame.Binary(fin = true, data = frameData.unsafeBackingByteArray())) + } + + @OptIn(UnsafeByteStringApi::class) + override suspend fun sendPing(frameData: ByteString) { + wsSession.outgoing.send(Frame.Ping(frameData.unsafeBackingByteArray())) + } + + @OptIn(UnsafeByteStringApi::class) + override suspend fun sendPong(frameData: ByteString) { + wsSession.outgoing.send(Frame.Pong(frameData.unsafeBackingByteArray())) + } + + override suspend fun close(code: Int, reason: String?) { + wsSession.close(CloseReason(code.toShort(), reason ?: "")) + } +} + +@OptIn(UnsafeByteStringApi::class) +private fun Frame.toKrossbowFrame(): WebSocketFrame = when (this) { + is Frame.Text -> WebSocketFrame.Text(readText()) + is Frame.Binary -> WebSocketFrame.Binary(readBytes().asByteString()) + is Frame.Ping -> WebSocketFrame.Ping(readBytes().asByteString()) + is Frame.Pong -> WebSocketFrame.Pong(readBytes().asByteString()) + is Frame.Close -> toKrossbowCloseFrame() + else -> error("Unknown frame type ${this::class.simpleName}") +} + +private fun Frame.Close.toKrossbowCloseFrame(): WebSocketFrame.Close { + val reason = readReason() + val code = reason?.code?.toInt() ?: WebSocketCloseCodes.NO_STATUS_CODE + return WebSocketFrame.Close(code, reason?.message) +} diff --git a/krossbow-websocket-ktor-legacy/src/commonTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorClientTestSuite.kt b/krossbow-websocket-ktor-legacy/src/commonTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorClientTestSuite.kt new file mode 100644 index 000000000..410507a00 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/commonTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorClientTestSuite.kt @@ -0,0 +1,19 @@ +package org.hildan.krossbow.websocket.ktor + +import io.ktor.client.* +import io.ktor.client.engine.* +import io.ktor.client.plugins.websocket.* +import org.hildan.krossbow.websocket.* +import org.hildan.krossbow.websocket.test.* + +abstract class KtorClientTestSuite( + supportsStatusCodes: Boolean, + shouldTestNegotiatedSubprotocol: Boolean = true, +) : WebSocketClientTestSuite(supportsStatusCodes, shouldTestNegotiatedSubprotocol) { + + override fun provideClient(): WebSocketClient = KtorWebSocketClient( + HttpClient(provideEngine()) { install(WebSockets) }, + ) + + abstract fun provideEngine(): HttpClientEngineFactory<*> +} diff --git a/krossbow-websocket-ktor-legacy/src/commonTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorMppWebSocketClientTest.kt b/krossbow-websocket-ktor-legacy/src/commonTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorMppWebSocketClientTest.kt new file mode 100644 index 000000000..6f116ba3b --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/commonTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorMppWebSocketClientTest.kt @@ -0,0 +1,34 @@ +package org.hildan.krossbow.websocket.ktor + +import io.ktor.client.* +import io.ktor.client.plugins.websocket.* +import org.hildan.krossbow.websocket.* +import org.hildan.krossbow.websocket.test.* + +// WinHttp: error is too generic and doesn't differ per status code +// JS browser: cannot support status codes for security reasons +// JS node: supports status codes since Kotlin 2.0 +// Other: currently the other platforms use the CIO engine because of classpath order, and CIO supports status codes +private val Platform.supportsStatusCodes: Boolean + get() = this !is Platform.Windows && this !is Platform.Js.Browser + +// This test is somewhat redundant with the tests on specific platforms, but it ensures that we don't forget to test +// new Ktor-supported platforms when they are added to the Krossbow projects. +// Also, it covers cases of dynamically-selected implementations. +class KtorMppWebSocketClientTest : WebSocketClientTestSuite( + supportsStatusCodes = currentPlatform().supportsStatusCodes, + // Just to be sure we don't attempt to test this with the Java or JS engines + // See https://youtrack.jetbrains.com/issue/KTOR-6970 + shouldTestNegotiatedSubprotocol = false, +) { + override fun provideClient(): WebSocketClient = KtorWebSocketClient( + HttpClient { + // The CIO engine seems to follow 301 redirects by default, but our test server doesn't provide a Location + // header with the URL to redirect to, so the client retries the same URL indefinitely. + // To avoid a SendCountExceedException in status code tests, we disable redirect-following explicitly here. + followRedirects = false + + install(WebSockets) + }, + ) +} diff --git a/krossbow-websocket-ktor/src/jsMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsJs.kt b/krossbow-websocket-ktor-legacy/src/jsMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsJs.kt similarity index 100% rename from krossbow-websocket-ktor/src/jsMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsJs.kt rename to krossbow-websocket-ktor-legacy/src/jsMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsJs.kt diff --git a/krossbow-websocket-ktor-legacy/src/jsTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorJsWebSocketClientTest.kt b/krossbow-websocket-ktor-legacy/src/jsTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorJsWebSocketClientTest.kt new file mode 100644 index 000000000..c955775fe --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/jsTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorJsWebSocketClientTest.kt @@ -0,0 +1,14 @@ +package org.hildan.krossbow.websocket.ktor + +import io.ktor.client.engine.* +import io.ktor.client.engine.js.* +import org.hildan.krossbow.websocket.test.* + +class KtorJsWebSocketClientTest : KtorClientTestSuite( + // JS browser: cannot support status codes for security reasons + supportsStatusCodes = currentJsPlatform() !is Platform.Js.Browser, + // See https://youtrack.jetbrains.com/issue/KTOR-6970 + shouldTestNegotiatedSubprotocol = false, +) { + override fun provideEngine(): HttpClientEngineFactory<*> = Js +} diff --git a/krossbow-websocket-ktor-legacy/src/jvmMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsJvm.kt b/krossbow-websocket-ktor-legacy/src/jvmMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsJvm.kt new file mode 100644 index 000000000..809c22b54 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/jvmMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsJvm.kt @@ -0,0 +1,54 @@ +package org.hildan.krossbow.websocket.ktor + +import java.net.ProtocolException +import java.net.UnknownHostException +import java.net.http.WebSocketHandshakeException +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +internal actual fun extractHandshakeFailureDetails(handshakeException: Exception): HandshakeFailureDetails = when { + // no status code if we can't even contact the host + handshakeException is UnknownHostException -> genericFailureDetails(handshakeException) + // with OkHttp engine, we get ProtocolException with itself as cause - we can only parse the message + handshakeException is ProtocolException -> extractHandshakeFailureDetails(handshakeException) + handshakeException.safeIs() -> extractHandshakeFailureDetails(handshakeException) + else -> genericFailureDetails(handshakeException) +} + +private const val okhttp407WithoutProxyMessage = "Received HTTP_PROXY_AUTH (407) code while not using proxy" +private val okhttpInvalidStatusCodeMessageRegex = Regex("""Expected HTTP 101 response but was '(\d{3}) ([^']*)'""") + +private fun extractHandshakeFailureDetails(handshakeException: ProtocolException): HandshakeFailureDetails { + val message = handshakeException.message ?: return genericFailureDetails(handshakeException) + if (message == okhttp407WithoutProxyMessage) { + return HandshakeFailureDetails(statusCode = 407, additionalInfo = message) + } + val match = okhttpInvalidStatusCodeMessageRegex.matchEntire(message) + return HandshakeFailureDetails( + statusCode = match?.groupValues?.get(1)?.toInt(), + additionalInfo = (match?.groupValues?.get(2) ?: handshakeException.message)?.takeIf { it.isNotBlank() }, + ) +} + +private fun extractHandshakeFailureDetails(webSocketHandshakeException: WebSocketHandshakeException) = + HandshakeFailureDetails( + statusCode = webSocketHandshakeException.response.statusCode(), + additionalInfo = (webSocketHandshakeException.response.body() as? String)?.takeIf { it.isNotBlank() }, + ) + +/** + * Returns true if [C] is on the classpath and `this` is an instance of [C]. + * + * Doesn't fail with [NoClassDefFoundError] if [C] is not present. + */ +@OptIn(ExperimentalContracts::class) +private inline fun Any.safeIs(): Boolean { + contract { + returns(true) implies(this@safeIs is C) + } + return try { + this is C + } catch (e: NoClassDefFoundError) { + false + } +} diff --git a/krossbow-websocket-ktor-legacy/src/jvmTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorJavaWebSocketClientTest.kt b/krossbow-websocket-ktor-legacy/src/jvmTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorJavaWebSocketClientTest.kt new file mode 100644 index 000000000..7cba725c8 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/jvmTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorJavaWebSocketClientTest.kt @@ -0,0 +1,13 @@ +package org.hildan.krossbow.websocket.ktor + +import io.ktor.client.engine.* +import io.ktor.client.engine.java.* + +class KtorJavaWebSocketClientTest : KtorClientTestSuite( + supportsStatusCodes = true, + // See https://youtrack.jetbrains.com/issue/KTOR-6970 + shouldTestNegotiatedSubprotocol = false, +) { + + override fun provideEngine(): HttpClientEngineFactory<*> = Java +} diff --git a/krossbow-websocket-ktor-legacy/src/jvmTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorOkHttpWebSocketClientTest.kt b/krossbow-websocket-ktor-legacy/src/jvmTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorOkHttpWebSocketClientTest.kt new file mode 100644 index 000000000..1373e081d --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/jvmTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorOkHttpWebSocketClientTest.kt @@ -0,0 +1,9 @@ +package org.hildan.krossbow.websocket.ktor + +import io.ktor.client.engine.* +import io.ktor.client.engine.okhttp.* + +class KtorOkHttpWebSocketClientTest : KtorClientTestSuite(supportsStatusCodes = true) { + + override fun provideEngine(): HttpClientEngineFactory<*> = OkHttp +} diff --git a/krossbow-websocket-ktor-legacy/src/linuxMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsLinux.kt b/krossbow-websocket-ktor-legacy/src/linuxMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsLinux.kt new file mode 100644 index 000000000..6eb72dfc4 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/linuxMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsLinux.kt @@ -0,0 +1,5 @@ +package org.hildan.krossbow.websocket.ktor + +// TODO find out the exception for linux platform on non-CIO engines +internal actual fun extractHandshakeFailureDetails(handshakeException: Exception): HandshakeFailureDetails = + genericFailureDetails(handshakeException) diff --git a/krossbow-websocket-ktor-legacy/src/mingwMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsMingw.kt b/krossbow-websocket-ktor-legacy/src/mingwMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsMingw.kt new file mode 100644 index 000000000..ac1f7aedf --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/mingwMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsMingw.kt @@ -0,0 +1,19 @@ +package org.hildan.krossbow.websocket.ktor + +internal actual fun extractHandshakeFailureDetails(handshakeException: Exception): HandshakeFailureDetails = + when (handshakeException) { + is IllegalStateException -> extractHandshakeFailureDetails(handshakeException) + else -> genericFailureDetails(handshakeException) + } + +/** + * Strips out the exception name because IllegalStateException is not useful info. + * + * We keep the original message because there is nothing more we can do (especially no status code detection): + * * Same message for any invalid response status code: + * `Unable to upgrade websocket: The operation identifier is not valid. Error 4317 (0x800710dd)` + * * Message for unresolved host: + * `Failed to send request: The server name or address could not be resolved. Error 12007 (0x80072ee7)` + */ +private fun extractHandshakeFailureDetails(exception: IllegalStateException): HandshakeFailureDetails = + HandshakeFailureDetails(statusCode = null, additionalInfo = exception.message) diff --git a/krossbow-websocket-ktor-legacy/src/mingwTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorWinHttpClientTest.kt b/krossbow-websocket-ktor-legacy/src/mingwTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorWinHttpClientTest.kt new file mode 100644 index 000000000..25f263579 --- /dev/null +++ b/krossbow-websocket-ktor-legacy/src/mingwTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorWinHttpClientTest.kt @@ -0,0 +1,11 @@ +package org.hildan.krossbow.websocket.ktor + +import io.ktor.client.engine.* +import io.ktor.client.engine.winhttp.* + +class KtorWinHttpClientTest : KtorClientTestSuite( + // WinHttp: error is too generic and doesn't differ per status code + supportsStatusCodes = false, +) { + override fun provideEngine(): HttpClientEngineFactory<*> = WinHttp +} diff --git a/krossbow-websocket-ktor/build.gradle.kts b/krossbow-websocket-ktor/build.gradle.kts index 9b8d4e49f..8197b65d1 100644 --- a/krossbow-websocket-ktor/build.gradle.kts +++ b/krossbow-websocket-ktor/build.gradle.kts @@ -8,7 +8,7 @@ plugins { description = "Multiplatform implementation of Krossbow's WebSocket API using Ktor's web sockets." kotlin { - ktorTargets() + ktor3Targets() sourceSets { all { diff --git a/krossbow-websocket-ktor/src/commonTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorMppWebSocketClientTest.kt b/krossbow-websocket-ktor/src/commonTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorMppWebSocketClientTest.kt index 6f116ba3b..537e994e3 100644 --- a/krossbow-websocket-ktor/src/commonTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorMppWebSocketClientTest.kt +++ b/krossbow-websocket-ktor/src/commonTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorMppWebSocketClientTest.kt @@ -4,13 +4,14 @@ import io.ktor.client.* import io.ktor.client.plugins.websocket.* import org.hildan.krossbow.websocket.* import org.hildan.krossbow.websocket.test.* +import kotlin.test.Test // WinHttp: error is too generic and doesn't differ per status code // JS browser: cannot support status codes for security reasons // JS node: supports status codes since Kotlin 2.0 // Other: currently the other platforms use the CIO engine because of classpath order, and CIO supports status codes private val Platform.supportsStatusCodes: Boolean - get() = this !is Platform.Windows && this !is Platform.Js.Browser + get() = this !is Platform.Windows && this !is Platform.Js.Browser && this !is Platform.WasmJs.Browser // This test is somewhat redundant with the tests on specific platforms, but it ensures that we don't forget to test // new Ktor-supported platforms when they are added to the Krossbow projects. @@ -31,4 +32,7 @@ class KtorMppWebSocketClientTest : WebSocketClientTestSuite( install(WebSockets) }, ) + + @Test + fun ets() {} } diff --git a/krossbow-websocket-ktor/src/jsAndWasmMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsJs.kt b/krossbow-websocket-ktor/src/jsAndWasmMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsJs.kt new file mode 100644 index 000000000..323c36811 --- /dev/null +++ b/krossbow-websocket-ktor/src/jsAndWasmMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsJs.kt @@ -0,0 +1,22 @@ +package org.hildan.krossbow.websocket.ktor + +// "unnecessary" escapes are necessary on JS (https://youtrack.jetbrains.com/issue/KTIJ-19147) +@Suppress("RegExpRedundantEscape") +private val jsonExceptionMessageRegex = Regex("""\{"message":"(.*?)","target":\{\},"type":"error"\}""") + +private val unexpectedServerResponseMessageRegex = Regex("""Unexpected server response: (\d{3})""") + +internal actual fun extractHandshakeFailureDetails(handshakeException: Exception): HandshakeFailureDetails { + val json = handshakeException.message ?: return genericFailureDetails(handshakeException) + // we use regex instead of eval() for security reasons + val actualMessage = jsonExceptionMessageRegex.matchEntire(json)?.groupValues?.get(1) + ?: return HandshakeFailureDetails(statusCode = null, additionalInfo = json) + + val match = unexpectedServerResponseMessageRegex.matchEntire(actualMessage) + ?: return HandshakeFailureDetails(statusCode = null, additionalInfo = actualMessage) + + return HandshakeFailureDetails( + statusCode = match.groupValues[1].toInt(), + additionalInfo = actualMessage, + ) +} diff --git a/mkdocs.yml b/mkdocs.yml index ed53e4273..b21d08932 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -90,7 +90,8 @@ extra: name: "Joffrey on Twitter" versions: jackson: 2.18.2 - ktor: 2.3.12 + ktor: 3.0.3 + ktorLegacy: 2.3.12 kotlinxSerialization: 1.7.3 moshi: 1.15.2 tyrus: 2.2.0 diff --git a/settings.gradle.kts b/settings.gradle.kts index 11e169948..833cb5e18 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,7 @@ include("krossbow-stomp-moshi") include("krossbow-websocket-core") include("krossbow-websocket-builtin") include("krossbow-websocket-ktor") +include("krossbow-websocket-ktor-legacy") include("krossbow-websocket-okhttp") include("krossbow-websocket-sockjs") include("krossbow-websocket-spring")