Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce incubating WebSocketEngine #5676

Merged
merged 5 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build-logic/src/main/kotlin/Mpp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ fun Project.configureMpp(
if (withWasm) {
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
/**
* See https://youtrack.jetbrains.com/issue/KT-63014
*/
nodejs()
}
}
Expand Down
34 changes: 34 additions & 0 deletions build-logic/src/main/kotlin/api.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@

import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.tasks.TaskProvider
import org.gradle.jvm.tasks.Jar
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin
import org.jetbrains.kotlin.gradle.targets.js.npm.tasks.KotlinNpmInstallTask

fun Project.apolloLibrary(
javaModuleName: String?,
Expand Down Expand Up @@ -83,3 +89,31 @@ fun Project.apolloTest(
)
}
}

fun Project.apolloRoot(ciBuild: TaskProvider<Task>) {
configureWasmCompatibleNode()
rootSetup(ciBuild)
}

/**
* See https://youtrack.jetbrains.com/issue/KT-63014
*/
private fun Project.configureWasmCompatibleNode() {
check(this == rootProject) {
"Must only be called in root project"
}
plugins.withType(NodeJsRootPlugin::class.java).configureEach {
extensions.getByType(NodeJsRootExtension::class.java).apply {
version = "21.0.0-v8-canary202309143a48826a08"
downloadBaseUrl = "https://nodejs.org/download/v8-canary"
}

tasks.withType(KotlinNpmInstallTask::class.java).configureEach {
args.add("--ignore-engines")
}
}
}

fun Project.apolloTestRoot() {
configureWasmCompatibleNode()
}
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,4 @@ tasks.register("rmbuild") {
}
}

rootSetup(ciBuild)
apolloRoot(ciBuild)
2 changes: 2 additions & 0 deletions gradle/libraries.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ rx-java3 = "3.1.6"
sqldelight = "2.0.1"
truth = "1.1.3"
moshix = "0.14.1"
node-ws = "8.14.2"

[libraries]
android-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "android-plugin" }
Expand Down Expand Up @@ -74,6 +75,7 @@ apollo-normalizedcache-sqlite-incubating = { group = "com.apollographql.apollo3"
apollo-rx2-support-java = { group = "com.apollographql.apollo3", name = "apollo-rx2-support-java", version.ref = "apollo" }
apollo-plugin = { group = "com.apollographql.apollo3", name = "apollo-gradle-plugin", version.ref = "apollo" }
apollo-runtime = { group = "com.apollographql.apollo3", name = "apollo-runtime", version.ref = "apollo" }
apollo-websocket-network-transport-incubating = { group = "com.apollographql.apollo3", name = "apollo-websocket-network-transport-incubating", version.ref = "apollo" }
apollo-compiler = { group = "com.apollographql.apollo3", name = "apollo-compiler", version.ref = "apollo" }
apollo-execution = { group = "com.apollographql.apollo3", name = "apollo-execution-incubating", version.ref = "apollo" }
# Used by the apollo-tooling project which uses a published version of Apollo
Expand Down
5 changes: 5 additions & 0 deletions kotlin-js-store-2.0/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,11 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==

ws@8.14.2:
version "8.14.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f"
integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==

ws@8.5.0:
version "8.5.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,7 @@ actual fun runTest(
after: suspend CoroutineScope.() -> Unit,
block: suspend CoroutineScope.() -> Unit,
): ApolloTestResult {
TODO()
}
return Promise.resolve(empty)
}

val empty: JsAny = js("({})")
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Module apollo-websocket-network-transport-incubating

An incubating network transport for websockets. See https://github.com/apollographql/apollo-kotlin/issues/5648 for more details

Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
plugins {
id("org.jetbrains.kotlin.multiplatform")
}

apolloLibrary(
javaModuleName = "com.apollographql.apollo3.network.websocket",
withLinux = false,
publish = false
)

kotlin {
sourceSets {
findByName("commonMain")?.apply {
dependencies {
api(project(":apollo-runtime"))
api(project(":apollo-mpp-utils"))
api(libs.atomicfu)
}
}

findByName("commonTest")?.apply {
dependencies {
implementation(project(":apollo-mockserver"))
implementation(project(":apollo-testing-support")) {
because("runTest")
}
}
}

findByName("jsMain")?.apply {
dependencies {
/**
* WebSocket implementation for node
*/
api(npm("ws", libs.versions.node.ws.get()))
/**
* Kotlin Node declarations
*
* The situation is a bit weird because jsMain has both browser and node dependencies but
* there is not much we can do about it
* See https://youtrack.jetbrains.com/issue/KT-47038
*/
implementation(libs.kotlin.node)
}
}
}
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
POM_ARTIFACT_ID=apollo-websocket-network-transport-incubating
POM_NAME=apollo-websocket-network-transport-incubating
POM_DESCRIPTION=apollo-websocket-network-transport-incubating
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package com.apollographql.apollo3.network.ws.incubating

import com.apollographql.apollo3.api.http.HttpHeader
import com.apollographql.apollo3.exception.DefaultApolloException
import com.apollographql.apollo3.network.toNSData
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.locks.reentrantLock
import kotlinx.atomicfu.locks.withLock
import kotlinx.cinterop.convert
import okio.ByteString.Companion.toByteString
import platform.Foundation.NSData
import platform.Foundation.NSMutableURLRequest
import platform.Foundation.NSURL
import platform.Foundation.NSURLSession
import platform.Foundation.NSURLSessionConfiguration
import platform.Foundation.NSURLSessionWebSocketCloseCode
import platform.Foundation.NSURLSessionWebSocketDelegateProtocol
import platform.Foundation.NSURLSessionWebSocketMessage
import platform.Foundation.NSURLSessionWebSocketMessageTypeData
import platform.Foundation.NSURLSessionWebSocketMessageTypeString
import platform.Foundation.NSURLSessionWebSocketTask
import platform.Foundation.setHTTPMethod
import platform.Foundation.setValue
import platform.darwin.NSObject

internal class AppleWebSocketEngine : WebSocketEngine {
private val delegate = Delegate()
private val nsUrlSession = NSURLSession.sessionWithConfiguration(
configuration = NSURLSessionConfiguration.defaultSessionConfiguration,
delegate = delegate,
delegateQueue = null
)

override fun newWebSocket(url: String, headers: List<HttpHeader>, listener: WebSocketListener): WebSocket {
val serverUrl = NSURL(string = url)

val request = NSMutableURLRequest.requestWithURL(serverUrl).apply {
headers.forEach { setValue(it.value, forHTTPHeaderField = it.name) }
setHTTPMethod("GET")
}

val task = nsUrlSession.webSocketTaskWithRequest(request)
val webSocket = AppleWebSocket(task, listener)
delegate.associate(task, webSocket)
webSocket.connect()

return webSocket
}

override fun close() {
delegate.close()
nsUrlSession.invalidateAndCancel()
}
}


private class Delegate: NSObject(), NSURLSessionWebSocketDelegateProtocol {
private val lock = reentrantLock()
private val map = mutableMapOf<NSURLSessionWebSocketTask, AppleWebSocket>()

fun associate(webSocketTask: NSURLSessionWebSocketTask, webSocket: AppleWebSocket) {
lock.withLock {
map.put(webSocketTask, webSocket)
}
}

override fun URLSession(session: NSURLSession, webSocketTask: NSURLSessionWebSocketTask, didOpenWithProtocol: String?) {
val webSocket = lock.withLock {
map.get(webSocketTask)
}
webSocket?.onOpen()
}

override fun URLSession(
session: NSURLSession,
webSocketTask: NSURLSessionWebSocketTask,
didCloseWithCode: NSURLSessionWebSocketCloseCode,
reason: NSData?,
) {
val webSocket = lock.withLock {
val ws = map.get(webSocketTask)

map.remove(webSocketTask)
ws
}
webSocket?.onClosed(didCloseWithCode.convert(), reason?.toByteString()?.utf8())
}

fun close() {
lock.withLock {
map.clear()
}
}
}

/**
* Peculiarities of NSURLSesssionWebSocketTask:
* - cancelWithCloseCode(code) calls didCloseWithCode with the same client code, making it impossible to detect the server close code
* - sometimes cancelWithCloseCode(code) doesn't send the close frame to the server (https://developer.apple.com/forums/thread/679446)
* - when the server close frame is received, the received completion handler is called first with an error, making it quite difficult
* to detect server close
*/
internal class AppleWebSocket(
private val nsurlSessionWebSocketTask: NSURLSessionWebSocketTask,
private val listener: WebSocketListener,
) : WebSocket {
private val disposed = atomic(false)

internal fun connect() {
nsurlSessionWebSocketTask.resume()
receiveNext()
}

fun onOpen() {
listener.onOpen()
}

fun onClosed(code: Int?, reason: String?) {
if (disposed.compareAndSet(expect = false, update = true)) {
listener.onClosed(code, reason)
}
}

private fun receiveNext() {
nsurlSessionWebSocketTask.receiveMessageWithCompletionHandler { message, nsError ->
if (nsError != null) {
if (disposed.compareAndSet(expect = false, update = true)) {
listener.onError(DefaultApolloException("Error reading websocket: ${nsError.localizedDescription}"))
}
} else if (message != null) {
when (message.type) {
NSURLSessionWebSocketMessageTypeData -> {
listener.onMessage(message.data!!.toByteString().toByteArray())
}

NSURLSessionWebSocketMessageTypeString -> {
listener.onMessage(message.string!!)
}
}

receiveNext()
}
}
}

override fun send(data: ByteArray) {
nsurlSessionWebSocketTask.sendMessage(NSURLSessionWebSocketMessage(data = data.toNSData())) {}
}

override fun send(text: String) {
nsurlSessionWebSocketTask.sendMessage(NSURLSessionWebSocketMessage(string = text)) {}
}

override fun close(code: Int, reason: String) {
if (disposed.compareAndSet(expect = false, update = true)) {
nsurlSessionWebSocketTask.cancelWithCloseCode(code.convert(), reason.encodeToByteArray().toNSData())
}
}
}

actual fun WebSocketEngine(): WebSocketEngine = AppleWebSocketEngine()
Loading
Loading