Skip to content

Commit

Permalink
Merge pull request #24 from kitakkun/feature/websocket
Browse files Browse the repository at this point in the history
Implement WebSocket server and client with Ktor
  • Loading branch information
kitakkun authored Apr 7, 2024
2 parents a25a83b + bcc744a commit fc2843e
Show file tree
Hide file tree
Showing 13 changed files with 474 additions and 2 deletions.
30 changes: 30 additions & 0 deletions backintime-websocket/client/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
}

kotlin {
jvm()
androidTarget()

iosX64()
iosArm64()
iosSimulatorArm64()

jvmToolchain(17)

sourceSets {
commonMain.dependencies {
implementation(project(":backintime-websocket:event"))
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.serialization.kotlinx.json)
}
}
}

android {
namespace = "com.github.kitakkun.backintime.websocket.client"
compileSdk = 34
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.github.kitakkun.backintime.websocket.client

import com.github.kitakkun.backintime.websocket.event.AppToDebuggerEvent
import com.github.kitakkun.backintime.websocket.event.DebuggerToAppEvent
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.coroutines.CoroutineContext

class BackInTimeWebSocketClient : CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob()

private val client = HttpClient {
install(WebSockets) {
maxFrameSize = Long.MAX_VALUE
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
}

private var session: DefaultClientWebSocketSession? = null
val connected: Boolean get() = session != null

private val mutableReceivedEventFlow = MutableSharedFlow<DebuggerToAppEvent>()
val receivedEventFlow = mutableReceivedEventFlow.asSharedFlow()

fun connect(host: String, port: Int) {
launch {
client.webSocket(
host = host,
port = port,
path = "/backintime",
) {
session = this

incoming
.consumeAsFlow()
.filterIsInstance<Frame.Text>()
.map { Json.decodeFromString<DebuggerToAppEvent>(it.readText()) }
.collect {
mutableReceivedEventFlow.emit(it)
}
}
}
}

fun send(event: AppToDebuggerEvent) {
launch {
session?.outgoing?.send(Frame.Text(Json.encodeToString(event)))
}
}

fun close() {
launch {
session?.close()
}
session = null
}
}
27 changes: 27 additions & 0 deletions backintime-websocket/event/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidLibrary)
}

kotlin {
jvm()
androidTarget()

iosX64()
iosArm64()
iosSimulatorArm64()

jvmToolchain(17)

sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.serialization.json)
}
}
}

android {
namespace = "com.github.kitakkun.backintime.websocket.event"
compileSdk = 34
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.github.kitakkun.backintime.websocket.event

import com.github.kitakkun.backintime.websocket.event.model.MethodInfo
import com.github.kitakkun.backintime.websocket.event.model.PropertyInfo
import kotlinx.serialization.Serializable

@Serializable
sealed interface AppToDebuggerEvent {
@Serializable
data object Ping : AppToDebuggerEvent

@Serializable
data class RegisterInstance(
val instanceId: String,
val className: String,
) : AppToDebuggerEvent

@Serializable
data class RegisterClassInfo(
val className: String,
val superClassName: String,
val methods: List<MethodInfo>,
val properties: List<PropertyInfo>,
) : AppToDebuggerEvent

@Serializable
data class MethodCall(
val instanceId: String,
val methodName: String,
val arguments: List<String>,
) : AppToDebuggerEvent

@Serializable
data class UpdatePropertyValue(
val instanceId: String,
val propertyName: String,
val newValue: String,
) : AppToDebuggerEvent

@Serializable
data class UpdateInstanceState(
val instanceId: String,
val alive: Boolean,
) : AppToDebuggerEvent
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.github.kitakkun.backintime.websocket.event

import kotlinx.serialization.Serializable

@Serializable
sealed interface DebuggerToAppEvent {
@Serializable
data object Ping : DebuggerToAppEvent

@Serializable
data class ForceSetProperty(
val propertyName: String,
val jsonValue: String,
) : DebuggerToAppEvent

@Serializable
data class RequestGetInstanceState(
val instanceIds: List<String>,
) : DebuggerToAppEvent
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.github.kitakkun.backintime.websocket.event.model

import kotlinx.serialization.Serializable

@Serializable
data class MethodInfo(
val name: String,
val arguments: Map<String, String>,
val returnType: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.github.kitakkun.backintime.websocket.event.model

import kotlinx.serialization.Serializable

@Serializable
data class PropertyInfo(
val name: String,
val type: String,
val valueType: String,
val isNullable: Boolean,
)
31 changes: 31 additions & 0 deletions backintime-websocket/server/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
}

kotlin {
jvm()
androidTarget()

iosArm64()
iosX64()
iosSimulatorArm64()

jvmToolchain(17)

sourceSets {
commonMain.dependencies {
implementation(project(":backintime-websocket:event"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.websockets)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.uuid)
}
}
}

android {
namespace = "com.github.kitakkun.backintime.websocket.server"
compileSdk = 34
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.github.kitakkun.backintime.websocket.server

import com.benasher44.uuid.uuid4
import com.github.kitakkun.backintime.websocket.event.AppToDebuggerEvent
import com.github.kitakkun.backintime.websocket.event.DebuggerToAppEvent
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.ApplicationEngine
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.coroutines.CoroutineContext

data class Connection(
val session: DefaultWebSocketServerSession,
val id: String = uuid4().toString(),
)

class BackInTimeWebSocketServer : CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob()
private var server: ApplicationEngine? = null

suspend fun getActualPort() = server?.resolvedConnectors()?.firstOrNull()?.port
suspend fun getActualHost() = server?.resolvedConnectors()?.firstOrNull()?.host

val isRunning: Boolean get() = server?.application?.isActive == true
private val connections = mutableListOf<Connection>()

private val mutableConnectionEstablishedFlow = MutableSharedFlow<String>()
val connectionEstablishedFlow = mutableConnectionEstablishedFlow.asSharedFlow()

private val mutableReceivedEventFlow = MutableSharedFlow<Pair<String, AppToDebuggerEvent>>()
val receivedEventFlow = mutableReceivedEventFlow.asSharedFlow()

fun start(port: Int) {
server = embeddedServer(
factory = CIO,
port = port,
host = "127.0.0.1",
) {
install(WebSockets) {
timeoutMillis = 1000 * 60 * 10
}
routing {
webSocket("/backintime") {
val connection = Connection(this)
connections += connection

mutableConnectionEstablishedFlow.emit(connection.id)

incoming
.consumeAsFlow()
.filterIsInstance<Frame.Text>()
.map { Json.decodeFromString<AppToDebuggerEvent>(it.readText()) }
.collect {
mutableReceivedEventFlow.emit(connection.id to it)
}
}
}
}

server?.start()
}

fun send(event: DebuggerToAppEvent) {
launch {
connections.forEach {
it.session.send(Json.encodeToString(event))
}
}
}

fun stop() {
server?.stop()
server = null
}
}
33 changes: 33 additions & 0 deletions backintime-websocket/test/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
}

kotlin {
jvm()
androidTarget()

iosX64()
iosArm64()
iosSimulatorArm64()

jvmToolchain(17)

sourceSets {
commonMain.dependencies {
implementation(project(":backintime-websocket:event"))
implementation(project(":backintime-websocket:server"))
implementation(project(":backintime-websocket:client"))
}

commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
}
}

android {
namespace = "com.github.kitakkun.backintime.websocket.test"
compileSdk = 34
}
Loading

0 comments on commit fc2843e

Please sign in to comment.