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")