-
-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Upgrade to Ktor 3 and add krossbow-websocket-ktor-legacy module for K…
…tor 2
- Loading branch information
1 parent
77f3a41
commit a3294bf
Showing
24 changed files
with
532 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/). |
8 changes: 8 additions & 0 deletions
8
krossbow-websocket-ktor-legacy/api/krossbow-websocket-ktor-legacy.api
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
|
8 changes: 8 additions & 0 deletions
8
krossbow-websocket-ktor-legacy/api/krossbow-websocket-ktor.api
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
) |
30 changes: 30 additions & 0 deletions
30
...gacy/src/appleMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptionsDarwin.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
*/ |
12 changes: 12 additions & 0 deletions
12
.../src/appleTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorDarwinWebSocketClientTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
21 changes: 21 additions & 0 deletions
21
...rc/cioSupportTest/kotlin/org/hildan/krossbow/websocket/ktor/KtorCioWebSocketClientTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}, | ||
) | ||
} |
24 changes: 24 additions & 0 deletions
24
...or-legacy/src/commonMain/kotlin/org/hildan/krossbow/websocket/ktor/HandshakeExceptions.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
130 changes: 130 additions & 0 deletions
130
...or-legacy/src/commonMain/kotlin/org/hildan/krossbow/websocket/ktor/KtorWebSocketClient.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
19 changes: 19 additions & 0 deletions
19
...or-legacy/src/commonTest/kotlin/org.hildan.krossbow.websocket.ktor/KtorClientTestSuite.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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<*> | ||
} |
Oops, something went wrong.