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
  • Loading branch information
joffrey-bion committed Jan 26, 2025
1 parent 77f3a41 commit a3294bf
Show file tree
Hide file tree
Showing 24 changed files with 532 additions and 4 deletions.
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.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"
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
9 changes: 7 additions & 2 deletions gradle/plugins/src/main/kotlin/Targets.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import org.jetbrains.kotlin.gradle.dsl.*

@OptIn(ExperimentalWasmDsl::class)
fun KotlinMultiplatformExtension.allTargets() {
ktorTargets()
ktor3Targets()
}

@OptIn(ExperimentalWasmDsl::class)
fun KotlinMultiplatformExtension.ktor3Targets() {
ktor2Targets()

wasmJs {
browser()
Expand All @@ -15,7 +20,7 @@ fun KotlinMultiplatformExtension.allTargets() {
}

@OptIn(ExperimentalKotlinGradlePluginApi::class)
fun KotlinMultiplatformExtension.ktorTargets() {
fun KotlinMultiplatformExtension.ktor2Targets() {
jvm {
compilerOptions {
freeCompilerArgs.add("-Xjvm-default=all-compatibility")
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
)
Original file line number Diff line number Diff line change
@@ -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<String>, headers: Map<String, String>): 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<WebSocketFrame> =
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)
}
Original file line number Diff line number Diff line change
@@ -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<*>
}
Loading

0 comments on commit a3294bf

Please sign in to comment.