Skip to content

Commit

Permalink
Upgrade to Ktor 3 and add krossbow-websocket-ktor-legacy module for K…
Browse files Browse the repository at this point in the history
…tor 2

Resolves: #613
Resolves: #615
  • Loading branch information
joffrey-bion committed Jan 26, 2025
1 parent d46f089 commit 5bdc2ed
Show file tree
Hide file tree
Showing 31 changed files with 565 additions and 15 deletions.
3 changes: 2 additions & 1 deletion docs/artifacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ web socket implementations:

| Artifact | Description |
|-------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| <pre>krossbow-websocket-builtin</pre> | A multiplatform `WebSocketClient` implementation that adapts the built-in client for each supported platform without transitive dependency. |
| <pre>krossbow-websocket-builtin</pre> | A multiplatform `WebSocketClient` implementation that adapts the built-in client present in some platforms without transitive dependency. |
| <pre>krossbow-websocket-ktor</pre> | A multiplatform `WebSocketClient` implementation based on Ktor {{ versions.ktor }}'s `HttpClient`. |
| <pre>krossbow-websocket-ktor-legacy</pre> | A multiplatform `WebSocketClient` implementation based on Ktor {{ versions.ktorLegacy }}'s `HttpClient`. |
| <pre>krossbow-websocket-okhttp</pre> | A JVM implementation of the web socket API using OkHttp's client. |
| <pre>krossbow-websocket-sockjs</pre> | 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). |
| <pre>krossbow-websocket-spring</pre> | A JVM 8+ implementation of the web socket API using Spring's WebSocketClient. Provides both a normal WebSocket client and a SockJS one. |
Expand Down
7 changes: 7 additions & 0 deletions docs/migration-guides.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 7 additions & 2 deletions docs/websocket/ktor.md
Original file line number Diff line number Diff line change
@@ -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`:
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ kotlinx-atomicfu = "0.27.0"
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"
Expand Down Expand Up @@ -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" }
Expand Down
16 changes: 11 additions & 5 deletions gradle/plugins/src/main/kotlin/Targets.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ kotlin {
withApple()
}
}
group("wasm") {
withWasmJs()
withWasmWasi()
group("jsAndWasm") {
withJs()
group("wasm") {
withWasmJs()
withWasmWasi()
}
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions kotlin-js-store/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions krossbow-websocket-ktor-legacy/README.md
Original file line number Diff line number Diff line change
@@ -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/).
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
public final class org/hildan/krossbow/websocket/ktor/KtorWebSocketClient : org/hildan/krossbow/websocket/WebSocketClient {
public fun <init> ()V
public fun <init> (Lio/ktor/client/HttpClient;)V
public synthetic fun <init> (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
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
public final class org/hildan/krossbow/websocket/ktor/KtorWebSocketClient : org/hildan/krossbow/websocket/WebSocketClient {
public fun <init> ()V
public fun <init> (Lio/ktor/client/HttpClient;)V
public synthetic fun <init> (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
}

70 changes: 70 additions & 0 deletions krossbow-websocket-ktor-legacy/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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",
)
Original file line number Diff line number Diff line change
@@ -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
}
*/
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
},
)
}
Original file line number Diff line number Diff line change
@@ -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
)
Loading

0 comments on commit 5bdc2ed

Please sign in to comment.