All Downloads are FREE. Search and download functionalities are using the official Maven repository.

appleMain.org.hildan.krossbow.websocket.darwin.DarwinWebSocketClient.kt Maven / Gradle / Ivy

Go to download

Multiplatform implementation of Krossbow's WebSocket API adapting the platforms' built-in implementations (JS browser's WebSocket, JDK11 client on JVM, NSURLSession on Apple targets).

There is a newer version: 7.3.0
Show newest version
@file:OptIn(UnsafeNumber::class)

package org.hildan.krossbow.websocket.darwin

import kotlinx.cinterop.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import kotlinx.coroutines.channels.Channel.Factory.BUFFERED
import kotlinx.coroutines.flow.*
import kotlinx.io.bytestring.*
import org.hildan.krossbow.io.*
import org.hildan.krossbow.websocket.*
import platform.Foundation.*
import platform.darwin.*
import kotlin.coroutines.*

/**
 * Error code received in some callbacks when the connection is actually just closed normally.
 */
private const val ERROR_CODE_SOCKET_NOT_CONNECTED = 57

/**
 * An implementation of [WebSocketClient] using darwin's native [NSURLSessionWebSocketTask].
 * This is only available is iOS 13.0+, tvOS 13.0+, watchOS 6.0+, macOS 10.15+
 * (see [documentation](https://developer.apple.com/documentation/foundation/urlsessionwebsockettask))
 *
 * A custom [sessionConfig] can be passed to customize the behaviour of the connection.
 * Also, if a non-null [maximumMessageSize] if provided, it will be used to configure the web socket.
 */
class DarwinWebSocketClient(
    private val sessionConfig: NSURLSessionConfiguration = NSURLSessionConfiguration.defaultSessionConfiguration(),
    private val maximumMessageSize: Long? = null,
) : WebSocketClient {

    override val supportsCustomHeaders: Boolean = true

    @OptIn(ExperimentalForeignApi::class)
    override suspend fun connect(url: String, protocols: List, headers: Map): WebSocketConnection {
        val socketEndpoint = NSURL.URLWithString(url)!!

        return suspendCancellableCoroutine { cont ->
            val incomingFrames: Channel = Channel(BUFFERED)

            if (headers.isNotEmpty()) {
                sessionConfig.HTTPAdditionalHeaders = sessionConfig.HTTPAdditionalHeaders.orEmpty() + headers
            }
            val urlSession = NSURLSession.sessionWithConfiguration(
                configuration = sessionConfig,
                delegate = DarwinWebSocketListener(url, cont, incomingFrames),
                delegateQueue = NSOperationQueue.currentQueue()
            )
            // The NSURLSession sends an empty `Sec-WebSocket-Protocol` header if we pass an empty list, which is not
            // supposed to be valid, and might break on some servers.
            // As per the RFC 6455 section 4.1 (https://datatracker.ietf.org/doc/html/rfc6455#section-4.1):
            // "The elements that comprise this value MUST be non-empty strings with characters in the range U+0021 to
            // U+007E not including separator characters as defined in [RFC2616] and MUST all be unique strings."
            val webSocket = if (protocols.isEmpty()) {
                urlSession.webSocketTaskWithURL(socketEndpoint)
            } else {
                urlSession.webSocketTaskWithURL(socketEndpoint, protocols = protocols)
            }
            maximumMessageSize?.let { webSocket.setMaximumMessageSize(it.convert()) }
            webSocket.forwardNextIncomingMessagesAsyncTo(incomingFrames)
            webSocket.resume()
            cont.invokeOnCancellation {
                webSocket.cancel()
            }
        }
    }
}

private class DarwinWebSocketListener(
    private val url: String,
    private var connectionContinuation: Continuation?,
    private val incomingFrames: Channel,
) : NSObject(), NSURLSessionWebSocketDelegateProtocol {
    private var isConnecting = true

    private inline fun completeConnection(resume: Continuation.() -> Unit) {
        val cont = connectionContinuation ?: error("web socket connection continuation already consumed")
        connectionContinuation = null // avoid leaking the continuation
        isConnecting = false
        cont.resume()
    }

    override fun URLSession(session: NSURLSession, webSocketTask: NSURLSessionWebSocketTask, didOpenWithProtocol: String?) {
        completeConnection {
            resume(DarwinWebSocketConnection(url, didOpenWithProtocol, incomingFrames.receiveAsFlow(), webSocketTask))
        }
    }

    override fun URLSession(
        session: NSURLSession,
        webSocketTask: NSURLSessionWebSocketTask,
        didCloseWithCode: NSURLSessionWebSocketCloseCode,
        reason: NSData?
    ) {
        passCloseFrameThroughChannel(didCloseWithCode.toInt(), reason?.decodeToString())
    }

    override fun URLSession(
        session: NSURLSession,
        task: NSURLSessionTask,
        didCompleteWithError: NSError?
    ) {
        if (isConnecting) {
            val ex = createConnectionException(task, didCompleteWithError)
            completeConnection {
                resumeWithException(ex)
            }
            return
        }

        // The error is null in case of server-side errors
        if (didCompleteWithError == null) {
            incomingFrames.close(WebSocketException("NSURLSession failed with unknown server-side error"))
            return
        }

        // For some reason, sometimes we get this error 57 "Socket is closed" instead of didCloseWithCode callback
        if (didCompleteWithError.code.toInt() == ERROR_CODE_SOCKET_NOT_CONNECTED) {
            passCloseFrameThroughChannel(
                code = WebSocketCloseCodes.NO_STATUS_CODE,
                reason = "fake CLOSE frame - got error 57 'Socket is closed' on NSURLSession",
            )
            return
        }

        incomingFrames.close(DarwinWebSocketException(nsError = didCompleteWithError))
    }

    private fun createConnectionException(
        task: NSURLSessionTask,
        didCompleteWithError: NSError?,
    ) = WebSocketConnectionException(
        url = url,
        httpStatusCode = task.response?.httpStatusCode,
        cause = didCompleteWithError?.let { DarwinWebSocketException(it) },
    )

    private fun passCloseFrameThroughChannel(code: Int, reason: String?) {
        val closeResult = incomingFrames.trySend(WebSocketFrame.Close(code, reason))
        if (closeResult.isFailure) {
            val closeException = WebSocketException("Could not pass CLOSE frame through channel", cause = closeResult.exceptionOrNull())
            incomingFrames.close(closeException)
            // still throw because no one might be listening to this channel (especially since the buffer is likely full)
            throw closeException
        }
        incomingFrames.close()
    }
}

private val NSURLResponse.httpStatusCode: Int?
    get() = (this as? NSHTTPURLResponse)?.statusCode?.toInt()

private class DarwinWebSocketConnection(
    override val url: String,
    override val protocol: String?,
    override val incomingFrames: Flow,
    private val webSocket: NSURLSessionWebSocketTask,
) : WebSocketConnectionWithPing {

    // no clear way to know if the websocket was closed by the peer, and we can't even fail in sendMessage reliably
    override val canSend: Boolean = true

    override suspend fun sendText(frameText: String) {
        sendMessage(NSURLSessionWebSocketMessage(frameText))
    }

    override suspend fun sendBinary(frameData: ByteString) {
        sendMessage(NSURLSessionWebSocketMessage(frameData.toNSData()))
    }

    private fun sendMessage(message: NSURLSessionWebSocketMessage) {
        // We can't rely on the callback for suspension because it is sometimes not called by iOS
        // (for instance when the web socket is closing at the same time).
        // To avoid suspending forever in those cases, we just never suspend.
        webSocket.sendMessage(message) { err ->
            if (err != null) {
                println("Error while sending websocket message: $err")
            }
        }
    }

    override suspend fun sendPing(frameData: ByteString) {
        webSocket.sendPingWithPongReceiveHandler { err ->
            if (err != null) {
                println("Error while sending websocket ping: $err")
            }
        }
    }

    @OptIn(ExperimentalForeignApi::class)
    override suspend fun close(code: Int, reason: String?) {
        webSocket.cancelWithCloseCode(code.convert(), reason?.encodeToNSData())
    }
}

/**
 * Listens to the incoming messages on this web socket and forwards them to the given [incomingFrames] channel.
 *
 * This method is implemented with recursion due to the peculiar design of NSURLSessionWebSocketTask.
 * There is currently no way to register a callback for all new messages - only to listen to one single "next" message.
 */
private fun NSURLSessionWebSocketTask.forwardNextIncomingMessagesAsyncTo(incomingFrames: SendChannel) {
    receiveMessageWithCompletionHandler { message, nsError ->
        when {
            nsError != null -> {
                // We sometimes get this error: Domain=NSPOSIXErrorDomain Code=57 "Socket is not connected"
                // It happens when the websocket is closed normally, in which case we just don't fail here, and the
                // channel will be closed in the actual NSURLSession close callback.
                if (nsError.code.toInt() == ERROR_CODE_SOCKET_NOT_CONNECTED) {
                    // If the channel is not closed, it might be worth continuing to forward messages (maybe we got this
                    // error for another reason, like the websocket was not connected *yet*) - it shouldn't hurt anyway.
                    // TODO check if we actually do get this error in this case, and thus continuing to forward new
                    //  messages is indeed useful
                    if (!incomingFrames.isClosedForSend) {
                        forwardNextIncomingMessagesAsyncTo(incomingFrames)
                    }
                    return@receiveMessageWithCompletionHandler
                }
                incomingFrames.close(DarwinWebSocketException(nsError))
                // No recursive call here, so we stop listening to messages in a closed or failed web socket
            }
            message != null -> {
                val result = incomingFrames.trySend(message.toWebSocketFrame())
                if (result.isFailure) {
                    val closeException = WebSocketException("Could not pass message frame through channel", cause = result.exceptionOrNull())
                    incomingFrames.close(closeException)
                    // still throw because no one might be listening to this channel (especially since the buffer is likely full)
                    throw closeException
                }
                if (result.isClosed) {
                    // TODO should we throw here instead? In which cases exactly this can happen?
                    // if the channel is already closed, maybe it is just a race with the closing handshake
                    // and we can simply ignore the extra message
                    return@receiveMessageWithCompletionHandler
                }
                // it's ok to use recursion since the call is asynchronous anyway, we won't blow the stack
                forwardNextIncomingMessagesAsyncTo(incomingFrames)
            }
        }
    }
}

private fun NSURLSessionWebSocketMessage.toWebSocketFrame(): WebSocketFrame = when (type) {
    NSURLSessionWebSocketMessageTypeData -> WebSocketFrame.Binary(
        bytes = data?.toByteString() ?: error("Message of type NSURLSessionWebSocketMessageTypeData has null value for 'data'")
    )
    NSURLSessionWebSocketMessageTypeString -> WebSocketFrame.Text(
        text = string ?: error("Message of type NSURLSessionWebSocketMessageTypeString has null value for 'string'")
    )
    else -> error("Unknown NSURLSessionWebSocketMessage type: $type")
}

@Suppress("CAST_NEVER_SUCCEEDS")
private fun String.encodeToNSData(): NSData? = (this as NSString).dataUsingEncoding(NSUTF8StringEncoding)

@Suppress("CAST_NEVER_SUCCEEDS")
private fun NSData.decodeToString(): String = NSString.create(this, NSUTF8StringEncoding) as String

/**
 * A [WebSocketException] caused by a darwin [NSError].
 * It contains details about the actual error cause.
 */
class DarwinWebSocketException(
    val nsError: NSError,
) : WebSocketException(nsError.description ?: nsError.localizedDescription)




© 2015 - 2024 Weber Informatics LLC | Privacy Policy