From 5346d15a9fc04abec3ebc1ecaa7c9fdcc1e26276 Mon Sep 17 00:00:00 2001 From: sergeych Date: Thu, 2 Apr 2026 19:23:46 +0300 Subject: [PATCH] Add KMP networking backends --- docs/lyng.io.http.md | 16 +- docs/lyng.io.net.md | 5 +- docs/lyng.io.ws.md | 35 +- gradle/libs.versions.toml | 4 + lyngio/build.gradle.kts | 26 +- .../sergeych/lyngio/http/PlatformAndroid.kt | 58 +++ .../sergeych/lyngio/net/PlatformAndroid.kt | 205 ++++++++- .../net/sergeych/lyngio/ws/PlatformAndroid.kt | 91 +++- .../sergeych/lyng/io/http/LyngHttpModule.kt | 2 +- .../net/sergeych/lyngio/http/LyngHttp.kt | 59 +-- .../sergeych/lyngio/http/PlatformDarwin.kt | 58 +++ .../net/sergeych/lyngio/ws/PlatformDarwin.kt | 91 ++++ .../net/sergeych/lyngio/http/PlatformJs.kt | 58 +++ .../net/sergeych/lyngio/net/PlatformJs.kt | 421 +++++++++++++++++- .../net/sergeych/lyngio/ws/PlatformJs.kt | 90 +++- .../lyng/io/net/LyngNetModuleJsNodeTest.kt | 117 +++++ .../net/sergeych/lyngio/NetJsNodeTest.kt | 70 +++ .../lyngio/PlatformCapabilityJsTest.kt | 14 + .../net/sergeych/lyngio/http/PlatformJvm.kt | 58 +++ .../lyng/io/http/LyngHttpModuleTest.kt | 40 +- .../net/sergeych/lyngio/http/PlatformLinux.kt | 58 +++ .../net/sergeych/lyngio/ws/PlatformLinux.kt | 91 ++++ .../net/sergeych/lyngio/http/PlatformMingw.kt | 58 +++ .../net/sergeych/lyngio/ws/PlatformMingw.kt | 91 ++++ .../net/sergeych/lyngio/ws/PlatformNative.kt | 3 - 25 files changed, 1744 insertions(+), 75 deletions(-) create mode 100644 lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt create mode 100644 lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt create mode 100644 lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/ws/PlatformDarwin.kt create mode 100644 lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt create mode 100644 lyngio/src/jsTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleJsNodeTest.kt create mode 100644 lyngio/src/jsTest/kotlin/net/sergeych/lyngio/NetJsNodeTest.kt create mode 100644 lyngio/src/jsTest/kotlin/net/sergeych/lyngio/PlatformCapabilityJsTest.kt create mode 100644 lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt create mode 100644 lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt create mode 100644 lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/ws/PlatformLinux.kt create mode 100644 lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt create mode 100644 lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/ws/PlatformMingw.kt delete mode 100644 lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/ws/PlatformNative.kt diff --git a/docs/lyng.io.http.md b/docs/lyng.io.http.md index 7b67ac4..795ba58 100644 --- a/docs/lyng.io.http.md +++ b/docs/lyng.io.http.md @@ -153,10 +153,15 @@ val allowLocalOnly = object : HttpAccessPolicy { override suspend fun check(op: HttpAccessOp, ctx: AccessContext): AccessDecision = when (op) { is HttpAccessOp.Request -> - if (op.url.startsWith("http://127.0.0.1:") || op.url.startsWith("http://localhost:")) + if ( + op.url.startsWith("http://127.0.0.1:") || + op.url.startsWith("https://127.0.0.1:") || + op.url.startsWith("http://localhost:") || + op.url.startsWith("https://localhost:") + ) AccessDecision(Decision.Allow) else - AccessDecision(Decision.Deny, "only local HTTP requests are allowed") + AccessDecision(Decision.Deny, "only local HTTP/HTTPS requests are allowed") } } ``` @@ -166,4 +171,9 @@ val allowLocalOnly = object : HttpAccessPolicy { #### Platform support - **JVM:** supported -- **Other targets:** implementation may be added later; use `Http.isSupported()` before relying on it +- **Android:** supported via the Ktor CIO client backend +- **JS:** supported via the Ktor JS client backend +- **Linux native:** supported via the Ktor Curl client backend +- **Windows native:** supported via the Ktor WinHttp client backend +- **Apple native:** supported via the Ktor Darwin client backend +- **Other targets:** may report unsupported; use `Http.isSupported()` before relying on it diff --git a/docs/lyng.io.net.md b/docs/lyng.io.net.md index 600d0d2..f0117c2 100644 --- a/docs/lyng.io.net.md +++ b/docs/lyng.io.net.md @@ -1,6 +1,6 @@ ### lyng.io.net — TCP and UDP sockets for Lyng scripts -This module provides minimal raw transport networking for Lyng scripts. It is implemented in `lyngio` and currently backed by Ktor sockets on the JVM. +This module provides minimal raw transport networking for Lyng scripts. It is implemented in `lyngio` and backed by Ktor sockets on the JVM and by Node networking APIs on JS/Node runtimes. > **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes. @@ -161,4 +161,7 @@ The module uses `NetAccessPolicy` to authorize network operations before they ar #### Platform support - **JVM:** supported +- **Android:** supported via the Ktor CIO and Ktor sockets backends +- **JS/Node:** supported for `resolve`, TCP client/server, and UDP +- **JS/browser:** unsupported; capability checks report unavailable - **Other targets:** currently report unsupported; use capability checks before relying on raw sockets diff --git a/docs/lyng.io.ws.md b/docs/lyng.io.ws.md index 9d10f45..9dc5bff 100644 --- a/docs/lyng.io.ws.md +++ b/docs/lyng.io.ws.md @@ -107,9 +107,42 @@ The module uses `WsAccessPolicy` to authorize websocket operations. - `WsAccessOp.Send(url, bytes, isText)` - `WsAccessOp.Receive(url)` +Example restricted policy in Kotlin: + +```kotlin +import net.sergeych.lyngio.fs.security.AccessContext +import net.sergeych.lyngio.fs.security.AccessDecision +import net.sergeych.lyngio.fs.security.Decision +import net.sergeych.lyngio.ws.security.WsAccessOp +import net.sergeych.lyngio.ws.security.WsAccessPolicy + +val allowLocalOnly = object : WsAccessPolicy { + override suspend fun check(op: WsAccessOp, ctx: AccessContext): AccessDecision = + when (op) { + is WsAccessOp.Connect -> + if ( + op.url.startsWith("ws://127.0.0.1:") || + op.url.startsWith("wss://127.0.0.1:") || + op.url.startsWith("ws://localhost:") || + op.url.startsWith("wss://localhost:") + ) + AccessDecision(Decision.Allow) + else + AccessDecision(Decision.Deny, "only local ws/wss connections are allowed") + + else -> AccessDecision(Decision.Allow) + } +} +``` + --- #### Platform support - **JVM:** supported -- **Other targets:** currently report unsupported; use `Ws.isSupported()` before relying on websocket client access +- **Android:** supported via the Ktor CIO websocket client backend +- **JS:** supported via the Ktor JS websocket client backend +- **Linux native:** supported via the Ktor Curl websocket client backend +- **Windows native:** supported via the Ktor WinHttp websocket client backend +- **Apple native:** supported via the Ktor Darwin websocket client backend +- **Other targets:** may report unsupported; use `Ws.isSupported()` before relying on websocket client access diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 084e6cc..35836bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,10 @@ okio-nodefilesystem = { module = "com.squareup.okio:okio-nodefilesystem", versio compiler = { group = "androidx.databinding", name = "compiler", version.ref = "compiler" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } +ktor-client-winhttp = { module = "io.ktor:ktor-client-winhttp", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" } diff --git a/lyngio/build.gradle.kts b/lyngio/build.gradle.kts index 38205e2..8e9230c 100644 --- a/lyngio/build.gradle.kts +++ b/lyngio/build.gradle.kts @@ -81,28 +81,40 @@ kotlin { api(libs.kotlinx.coroutines.core) api(libs.mordant.core) api(libs.ktor.client.core) - implementation(libs.ktor.client.cio) implementation(libs.ktor.client.websockets) } } val nativeMain by creating { dependsOn(commonMain) } - val iosMain by creating { + val darwinMain by creating { dependsOn(nativeMain) + dependencies { + implementation(libs.ktor.client.darwin) + } + } + val iosMain by creating { + dependsOn(darwinMain) } val linuxMain by creating { dependsOn(nativeMain) + dependencies { + implementation(libs.ktor.client.curl) + } } val macosMain by creating { - dependsOn(nativeMain) + dependsOn(darwinMain) } val mingwMain by creating { dependsOn(nativeMain) + dependencies { + implementation(libs.ktor.client.winhttp) + } } val commonTest by getting { dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } } val iosX64Main by getting { dependsOn(iosMain) } @@ -119,6 +131,13 @@ kotlin { api(libs.okio) implementation(libs.okio.fakefilesystem) implementation("com.squareup.okio:okio-nodefilesystem:${libs.versions.okioVersion.get()}") + implementation(libs.ktor.client.js) + } + } + val androidMain by getting { + dependencies { + implementation(libs.ktor.client.cio) + implementation(libs.ktor.network) } } val jvmMain by getting { @@ -126,6 +145,7 @@ kotlin { implementation(libs.mordant.jvm.jna) implementation("org.jline:jline-reader:3.29.0") implementation("org.jline:jline-terminal:3.29.0") + implementation(libs.ktor.client.cio) implementation(libs.ktor.network) } } diff --git a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt new file mode 100644 index 0000000..3643975 --- /dev/null +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt @@ -0,0 +1,58 @@ +package net.sergeych.lyngio.http + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.timeout +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.http.HttpMethod +import io.ktor.http.headers +import io.ktor.http.takeFrom + +actual fun getSystemHttpEngine(): LyngHttpEngine = AndroidKtorLyngHttpEngine + +private object AndroidKtorLyngHttpEngine : LyngHttpEngine { + private val clientResult by lazy { + runCatching { + HttpClient(CIO) { + expectSuccess = false + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun request(request: LyngHttpRequest): LyngHttpResponse { + val httpClient = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "HTTP client is not supported") + } + + val response = httpClient.request { + applyRequest(request) + } + return LyngHttpResponse( + status = response.status.value, + statusText = response.status.description, + headers = response.headers.entries().associate { it.key to it.value.toList() }, + bodyBytes = response.body(), + ) + } + + private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) { + method = HttpMethod.parse(request.method.uppercase()) + url.takeFrom(request.url) + headers { + request.headers.forEach { (name, value) -> append(name, value) } + } + request.timeoutMillis?.let { timeout { requestTimeoutMillis = it } } + when { + request.bodyBytes != null && request.bodyText != null -> + throw IllegalArgumentException("Only one of bodyText or bodyBytes may be set") + request.bodyBytes != null -> setBody(request.bodyBytes) + request.bodyText != null -> setBody(request.bodyText) + } + } +} diff --git a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/net/PlatformAndroid.kt b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/net/PlatformAndroid.kt index 7a10ec5..163efd3 100644 --- a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/net/PlatformAndroid.kt +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/net/PlatformAndroid.kt @@ -1,3 +1,206 @@ package net.sergeych.lyngio.net -actual fun getSystemNetEngine(): LyngNetEngine = UnsupportedLyngNetEngine +import io.ktor.network.selector.ActorSelectorManager +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.BoundDatagramSocket +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.ServerSocket +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.aSocket +import io.ktor.network.sockets.isClosed +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.network.sockets.toJavaAddress +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.readAvailable +import io.ktor.utils.io.readUTF8Line +import io.ktor.utils.io.writeFully +import io.ktor.utils.io.writeStringUtf8 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress + +actual fun getSystemNetEngine(): LyngNetEngine = AndroidKtorNetEngine + +private object AndroidKtorNetEngine : LyngNetEngine { + private val selectorManager: SelectorManager by lazy { ActorSelectorManager(Dispatchers.IO) } + + override val isSupported: Boolean = true + override val isTcpAvailable: Boolean = true + override val isTcpServerAvailable: Boolean = true + override val isUdpAvailable: Boolean = true + + override suspend fun resolve(host: String, port: Int): List = withContext(Dispatchers.IO) { + InetAddress.getAllByName(host).map { address -> + address.toLyngSocketAddress(port = port, resolved = true) + } + } + + override suspend fun tcpConnect( + host: String, + port: Int, + timeoutMillis: Long?, + noDelay: Boolean, + ): LyngTcpSocket { + val connectBlock: suspend () -> Socket = { + aSocket(selectorManager).tcp().connect(host, port) { + this.noDelay = noDelay + } + } + val socket = if (timeoutMillis != null) withTimeout(timeoutMillis) { connectBlock() } else connectBlock() + return AndroidLyngTcpSocket(socket) + } + + override suspend fun tcpListen( + host: String?, + port: Int, + backlog: Int, + reuseAddress: Boolean, + ): LyngTcpServer { + val bindHost = host ?: "0.0.0.0" + val server = aSocket(selectorManager).tcp().bind(bindHost, port) { + backlogSize = backlog + this.reuseAddress = reuseAddress + } + return AndroidLyngTcpServer(server) + } + + override suspend fun udpBind(host: String?, port: Int, reuseAddress: Boolean): LyngUdpSocket { + val bindHost = host ?: "0.0.0.0" + val socket = aSocket(selectorManager).udp().bind(bindHost, port) { + this.reuseAddress = reuseAddress + } + return AndroidLyngUdpSocket(socket) + } +} + +private class AndroidLyngTcpSocket( + private val socket: Socket, +) : LyngTcpSocket { + private val input: ByteReadChannel by lazy { socket.openReadChannel() } + private val output: ByteWriteChannel by lazy { socket.openWriteChannel(autoFlush = true) } + + override fun isOpen(): Boolean = !socket.isClosed + + override fun localAddress(): LyngSocketAddress = socket.localAddress.toLyngSocketAddress(resolved = true) + + override fun remoteAddress(): LyngSocketAddress = socket.remoteAddress.toLyngSocketAddress(resolved = true) + + override suspend fun read(maxBytes: Int): ByteArray? { + if (!input.awaitContent(1)) return null + val buffer = ByteArray(maxBytes) + val count = input.readAvailable(buffer, 0, maxBytes) + return when { + count <= 0 -> null + count == maxBytes -> buffer + else -> buffer.copyOf(count) + } + } + + override suspend fun readLine(): String? = input.readUTF8Line() + + override suspend fun write(data: ByteArray) { + output.writeFully(data, 0, data.size) + } + + override suspend fun writeUtf8(text: String) { + output.writeStringUtf8(text) + } + + override suspend fun flush() { + output.flush() + } + + override fun close() { + socket.close() + } +} + +private class AndroidLyngTcpServer( + private val server: ServerSocket, +) : LyngTcpServer { + override fun isOpen(): Boolean = !server.isClosed + + override fun localAddress(): LyngSocketAddress = server.localAddress.toLyngSocketAddress(resolved = true) + + override suspend fun accept(): LyngTcpSocket = AndroidLyngTcpSocket(server.accept()) + + override fun close() { + server.close() + } +} + +private class AndroidLyngUdpSocket( + private val socket: BoundDatagramSocket, +) : LyngUdpSocket { + override fun isOpen(): Boolean = !socket.isClosed + + override fun localAddress(): LyngSocketAddress = socket.localAddress.toLyngSocketAddress(resolved = true) + + override suspend fun receive(maxBytes: Int): LyngDatagram? { + val datagram = try { + socket.receive() + } catch (e: Throwable) { + if (!isOpen()) return null + throw e + } + val bytes = datagram.packet.readByteArray().let { + if (it.size <= maxBytes) it else it.copyOf(maxBytes) + } + return LyngDatagram(bytes, datagram.address.toLyngSocketAddress(resolved = true)) + } + + override suspend fun send(data: ByteArray, host: String, port: Int) { + val packet = Buffer() + packet.write(data) + socket.send(io.ktor.network.sockets.Datagram(packet, InetSocketAddress(host, port))) + } + + override fun close() { + socket.close() + } +} + +private fun io.ktor.network.sockets.SocketAddress.toLyngSocketAddress( + port: Int? = null, + resolved: Boolean, +): LyngSocketAddress { + val javaAddress = this.toJavaAddress() + val inetSocket = javaAddress as? java.net.InetSocketAddress + if (inetSocket != null) { + val inetAddress = inetSocket.address + val host = inetAddress?.hostAddress ?: inetSocket.hostString + val actualPort = port ?: inetSocket.port + val version = when (inetAddress) { + is Inet6Address -> LyngIpVersion.IPV6 + is Inet4Address -> LyngIpVersion.IPV4 + else -> if (host.contains(':')) LyngIpVersion.IPV6 else LyngIpVersion.IPV4 + } + return LyngSocketAddress(host = host, port = actualPort, ipVersion = version, resolved = resolved) + } + + val rendered = toString() + return LyngSocketAddress( + host = rendered, + port = port ?: 0, + ipVersion = if (rendered.contains(':')) LyngIpVersion.IPV6 else LyngIpVersion.IPV4, + resolved = resolved, + ) +} + +private fun InetAddress.toLyngSocketAddress(port: Int, resolved: Boolean): LyngSocketAddress = + LyngSocketAddress( + host = hostAddress ?: hostName ?: "0.0.0.0", + port = port, + ipVersion = when (this) { + is Inet6Address -> LyngIpVersion.IPV6 + else -> LyngIpVersion.IPV4 + }, + resolved = resolved, + ) diff --git a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/ws/PlatformAndroid.kt b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/ws/PlatformAndroid.kt index e9bc921..913d04f 100644 --- a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/ws/PlatformAndroid.kt +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/ws/PlatformAndroid.kt @@ -1,3 +1,92 @@ package net.sergeych.lyngio.ws -actual fun getSystemWsEngine(): LyngWsEngine = UnsupportedLyngWsEngine +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.websocket.CloseReason +import io.ktor.websocket.DefaultWebSocketSession +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.channels.ClosedReceiveChannelException + +actual fun getSystemWsEngine(): LyngWsEngine = AndroidKtorWsEngine + +private object AndroidKtorWsEngine : LyngWsEngine { + private val clientResult by lazy { + runCatching { + HttpClient(CIO) { + install(WebSockets) + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun connect(url: String, headers: Map): LyngWsSession { + val client = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "WebSocket client is not supported") + } + val session = client.webSocketSession { + url(url) + headers.forEach { (name, value) -> header(name, value) } + } + return AndroidLyngWsSession(url, session) + } +} + +private class AndroidLyngWsSession( + private val targetUrl: String, + private val session: DefaultWebSocketSession, +) : LyngWsSession { + @Volatile + private var closed = false + + override fun isOpen(): Boolean = !closed + + override fun url(): String = targetUrl + + override suspend fun sendText(text: String) { + ensureOpen() + session.send(text) + } + + override suspend fun sendBytes(data: ByteArray) { + ensureOpen() + session.send(data) + } + + override suspend fun receive(): LyngWsMessage? { + if (closed) return null + val frame = try { + session.incoming.receive() + } catch (_: ClosedReceiveChannelException) { + closed = true + return null + } + return when (frame) { + is Frame.Text -> LyngWsMessage(isText = true, text = frame.readText()) + is Frame.Binary -> LyngWsMessage(isText = false, data = frame.data.copyOf()) + is Frame.Close -> { + closed = true + null + } + else -> receive() + } + } + + override suspend fun close(code: Int, reason: String) { + if (closed) return + closed = true + session.close(CloseReason(code.toShort(), reason)) + } + + private fun ensureOpen() { + if (closed) throw IllegalStateException("websocket session is closed") + } +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt index a3a667b..baa3765 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt @@ -332,7 +332,7 @@ private class ObjHttpResponse( fun from(response: LyngHttpResponse): ObjHttpResponse { val single = linkedMapOf() response.headers.forEach { (name, values) -> - if (values.isNotEmpty()) single.putIfAbsent(name, values.first()) + if (values.isNotEmpty() && name !in single) single[name] = values.first() } return ObjHttpResponse( status = response.status.toLong(), diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt index dc908a6..a0da774 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt @@ -17,17 +17,6 @@ package net.sergeych.lyngio.http -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.timeout -import io.ktor.client.request.HttpRequestBuilder -import io.ktor.client.request.request -import io.ktor.client.request.setBody -import io.ktor.http.HttpMethod -import io.ktor.http.headers -import io.ktor.http.takeFrom - data class LyngHttpRequest( val method: String, val url: String, @@ -49,7 +38,7 @@ interface LyngHttpEngine { suspend fun request(request: LyngHttpRequest): LyngHttpResponse } -private object UnsupportedHttpEngine : LyngHttpEngine { +internal object UnsupportedHttpEngine : LyngHttpEngine { override val isSupported: Boolean = false override suspend fun request(request: LyngHttpRequest): LyngHttpResponse { @@ -57,48 +46,4 @@ private object UnsupportedHttpEngine : LyngHttpEngine { } } -private object KtorLyngHttpEngine : LyngHttpEngine { - private val clientResult by lazy { - runCatching { - HttpClient(CIO) { - expectSuccess = false - } - } - } - - override val isSupported: Boolean - get() = clientResult.isSuccess - - override suspend fun request(request: LyngHttpRequest): LyngHttpResponse { - val httpClient = clientResult.getOrElse { - throw UnsupportedOperationException(it.message ?: "HTTP client is not supported") - } - - val response = httpClient.request { - applyRequest(request) - } - return LyngHttpResponse( - status = response.status.value, - statusText = response.status.description, - headers = response.headers.entries().associate { it.key to it.value.toList() }, - bodyBytes = response.body(), - ) - } - - private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) { - method = HttpMethod.parse(request.method.uppercase()) - url.takeFrom(request.url) - headers { - request.headers.forEach { (name, value) -> append(name, value) } - } - request.timeoutMillis?.let { timeout { requestTimeoutMillis = it } } - when { - request.bodyBytes != null && request.bodyText != null -> - throw IllegalArgumentException("Only one of bodyText or bodyBytes may be set") - request.bodyBytes != null -> setBody(request.bodyBytes) - request.bodyText != null -> setBody(request.bodyText) - } - } -} - -fun getSystemHttpEngine(): LyngHttpEngine = KtorLyngHttpEngine +expect fun getSystemHttpEngine(): LyngHttpEngine diff --git a/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt b/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt new file mode 100644 index 0000000..1b55e80 --- /dev/null +++ b/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt @@ -0,0 +1,58 @@ +package net.sergeych.lyngio.http + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.darwin.Darwin +import io.ktor.client.plugins.timeout +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.http.HttpMethod +import io.ktor.http.headers +import io.ktor.http.takeFrom + +actual fun getSystemHttpEngine(): LyngHttpEngine = DarwinLyngHttpEngine + +private object DarwinLyngHttpEngine : LyngHttpEngine { + private val clientResult by lazy { + runCatching { + HttpClient(Darwin) { + expectSuccess = false + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun request(request: LyngHttpRequest): LyngHttpResponse { + val httpClient = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "HTTP client is not supported") + } + + val response = httpClient.request { + applyRequest(request) + } + return LyngHttpResponse( + status = response.status.value, + statusText = response.status.description, + headers = response.headers.entries().associate { it.key to it.value.toList() }, + bodyBytes = response.body(), + ) + } + + private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) { + method = HttpMethod.parse(request.method.uppercase()) + url.takeFrom(request.url) + headers { + request.headers.forEach { (name, value) -> append(name, value) } + } + request.timeoutMillis?.let { timeout { requestTimeoutMillis = it } } + when { + request.bodyBytes != null && request.bodyText != null -> + throw IllegalArgumentException("Only one of bodyText or bodyBytes may be set") + request.bodyBytes != null -> setBody(request.bodyBytes) + request.bodyText != null -> setBody(request.bodyText) + } + } +} diff --git a/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/ws/PlatformDarwin.kt b/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/ws/PlatformDarwin.kt new file mode 100644 index 0000000..57e05c9 --- /dev/null +++ b/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/ws/PlatformDarwin.kt @@ -0,0 +1,91 @@ +package net.sergeych.lyngio.ws + +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.websocket.CloseReason +import io.ktor.websocket.DefaultWebSocketSession +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.channels.ClosedReceiveChannelException + +actual fun getSystemWsEngine(): LyngWsEngine = DarwinKtorWsEngine + +private object DarwinKtorWsEngine : LyngWsEngine { + private val clientResult by lazy { + runCatching { + HttpClient(Darwin) { + install(WebSockets) + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun connect(url: String, headers: Map): LyngWsSession { + val client = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "WebSocket client is not supported") + } + val session = client.webSocketSession { + url(url) + headers.forEach { (name, value) -> header(name, value) } + } + return DarwinLyngWsSession(url, session) + } +} + +private class DarwinLyngWsSession( + private val targetUrl: String, + private val session: DefaultWebSocketSession, +) : LyngWsSession { + private var closed = false + + override fun isOpen(): Boolean = !closed + + override fun url(): String = targetUrl + + override suspend fun sendText(text: String) { + ensureOpen() + session.send(text) + } + + override suspend fun sendBytes(data: ByteArray) { + ensureOpen() + session.send(data) + } + + override suspend fun receive(): LyngWsMessage? { + if (closed) return null + val frame = try { + session.incoming.receive() + } catch (_: ClosedReceiveChannelException) { + closed = true + return null + } + return when (frame) { + is Frame.Text -> LyngWsMessage(isText = true, text = frame.readText()) + is Frame.Binary -> LyngWsMessage(isText = false, data = frame.data.copyOf()) + is Frame.Close -> { + closed = true + null + } + else -> receive() + } + } + + override suspend fun close(code: Int, reason: String) { + if (closed) return + closed = true + session.close(CloseReason(code.toShort(), reason)) + } + + private fun ensureOpen() { + if (closed) throw IllegalStateException("websocket session is closed") + } +} diff --git a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt new file mode 100644 index 0000000..05ff5ec --- /dev/null +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt @@ -0,0 +1,58 @@ +package net.sergeych.lyngio.http + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.js.Js +import io.ktor.client.plugins.timeout +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.http.HttpMethod +import io.ktor.http.headers +import io.ktor.http.takeFrom + +actual fun getSystemHttpEngine(): LyngHttpEngine = JsKtorLyngHttpEngine + +private object JsKtorLyngHttpEngine : LyngHttpEngine { + private val clientResult by lazy { + runCatching { + HttpClient(Js) { + expectSuccess = false + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun request(request: LyngHttpRequest): LyngHttpResponse { + val httpClient = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "HTTP client is not supported") + } + + val response = httpClient.request { + applyRequest(request) + } + return LyngHttpResponse( + status = response.status.value, + statusText = response.status.description, + headers = response.headers.entries().associate { it.key to it.value.toList() }, + bodyBytes = response.body(), + ) + } + + private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) { + method = HttpMethod.parse(request.method.uppercase()) + url.takeFrom(request.url) + headers { + request.headers.forEach { (name, value) -> append(name, value) } + } + request.timeoutMillis?.let { timeout { requestTimeoutMillis = it } } + when { + request.bodyBytes != null && request.bodyText != null -> + throw IllegalArgumentException("Only one of bodyText or bodyBytes may be set") + request.bodyBytes != null -> setBody(request.bodyBytes) + request.bodyText != null -> setBody(request.bodyText) + } + } +} diff --git a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/net/PlatformJs.kt b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/net/PlatformJs.kt index 7a10ec5..1a31dd8 100644 --- a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/net/PlatformJs.kt +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/net/PlatformJs.kt @@ -1,3 +1,422 @@ +@file:Suppress("UnsafeCastFromDynamic", "SpellCheckingInspection") + package net.sergeych.lyngio.net -actual fun getSystemNetEngine(): LyngNetEngine = UnsupportedLyngNetEngine +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.js.json +import org.khronos.webgl.Uint8Array + +actual fun getSystemNetEngine(): LyngNetEngine = jsNodeNetEngineOrNull ?: UnsupportedLyngNetEngine + +private val jsNodeNetEngineOrNull: LyngNetEngine? by lazy { + if (!isNodeRuntime()) return@lazy null + val net = requireNodeModule("net") ?: return@lazy null + val dgram = requireNodeModule("dgram") ?: return@lazy null + val dns = requireNodeModule("dns") ?: return@lazy null + JsNodeNetEngine(net, dgram, dns) +} + +private class JsNodeNetEngine( + private val netModule: dynamic, + private val dgramModule: dynamic, + private val dnsModule: dynamic, +) : LyngNetEngine { + override val isSupported: Boolean = true + override val isTcpAvailable: Boolean = true + override val isTcpServerAvailable: Boolean = true + override val isUdpAvailable: Boolean = true + + override suspend fun resolve(host: String, port: Int): List { + val family = netModule.isIP(host) as Int + if (family == 4 || family == 6) { + return listOf( + LyngSocketAddress( + host = host, + port = port, + ipVersion = if (family == 6) LyngIpVersion.IPV6 else LyngIpVersion.IPV4, + resolved = true, + ) + ) + } + return suspendCancellableCoroutine { cont -> + dnsModule.lookup(host, json("all" to true), { error: dynamic, result: dynamic -> + if (!cont.isActive) return@lookup + if (error != null) { + cont.resumeWithException(IllegalStateException(error.message?.unsafeCast() ?: "DNS lookup failed")) + return@lookup + } + val addresses = mutableListOf() + val items = result.unsafeCast>() + for (item in items) { + val address = item.address?.unsafeCast() ?: continue + val itemFamily = item.family?.unsafeCast() ?: if (address.contains(':')) 6 else 4 + addresses += LyngSocketAddress( + host = address, + port = port, + ipVersion = if (itemFamily == 6) LyngIpVersion.IPV6 else LyngIpVersion.IPV4, + resolved = true, + ) + } + cont.resume(addresses) + }) + } + } + + override suspend fun tcpConnect( + host: String, + port: Int, + timeoutMillis: Long?, + noDelay: Boolean, + ): LyngTcpSocket { + var socket: dynamic = null + return try { + val connected = suspend { + suspendCancellableCoroutine { cont -> + socket = netModule.createConnection(json("host" to host, "port" to port)) { + if (cont.isActive) cont.resume(socket) + } + socket.once("error", { error: dynamic -> + if (cont.isActive) { + cont.resumeWithException( + IllegalStateException(error.message?.unsafeCast() ?: "TCP connect failed") + ) + } + }) + } + } + val connectedSocket = if (timeoutMillis != null) withTimeout(timeoutMillis) { connected() } else connected() + connectedSocket.setNoDelay(noDelay) + JsNodeTcpSocket(connectedSocket) + } catch (e: Throwable) { + if (socket != null) socket.destroy() + throw e + } + } + + override suspend fun tcpListen( + host: String?, + port: Int, + backlog: Int, + reuseAddress: Boolean, + ): LyngTcpServer { + val accepted = Channel(Channel.UNLIMITED) + val server = netModule.createServer({ socket: dynamic -> + accepted.trySend(JsNodeTcpSocket(socket)) + }) + server.on("error", { _: dynamic -> }) + val listenHost = host ?: "0.0.0.0" + val options = json( + "host" to listenHost, + "port" to port, + "backlog" to backlog, + "exclusive" to !reuseAddress, + ) + suspendCancellableCoroutine { cont -> + server.once("error", { error: dynamic -> + if (cont.isActive) { + cont.resumeWithException(IllegalStateException(error.message?.unsafeCast() ?: "TCP listen failed")) + } + }) + server.listen(options) { + if (cont.isActive) cont.resume(Unit) + } + } + return JsNodeTcpServer(server, accepted) + } + + override suspend fun udpBind(host: String?, port: Int, reuseAddress: Boolean): LyngUdpSocket { + val socketType = if ((host ?: "").contains(':')) "udp6" else "udp4" + val socket = dgramModule.createSocket(json("type" to socketType, "reuseAddr" to reuseAddress)) + val incoming = Channel(Channel.UNLIMITED) + socket.on("message", { msg: dynamic, rinfo: dynamic -> + incoming.trySend( + LyngDatagram( + data = dynamicToByteArray(msg), + address = rinfoToAddress(rinfo), + ) + ) + }) + socket.on("error", { _: dynamic -> }) + suspendCancellableCoroutine { cont -> + socket.once("error", { error: dynamic -> + if (cont.isActive) { + cont.resumeWithException(IllegalStateException(error.message?.unsafeCast() ?: "UDP bind failed")) + } + }) + socket.bind(port, host ?: "0.0.0.0") { + if (cont.isActive) cont.resume(Unit) + } + } + return JsNodeUdpSocket(socket, incoming) + } +} + +private class JsNodeTcpSocket( + private val socket: dynamic, +) : LyngTcpSocket { + private val incoming = Channel(Channel.UNLIMITED) + private val buffered = ArrayDeque() + private var closed = false + private var failure: Throwable? = null + + init { + socket.on("data", { chunk: dynamic -> + incoming.trySend(dynamicToByteArray(chunk)) + }) + socket.on("end", { + closed = true + incoming.trySend(null) + }) + socket.on("close", { + closed = true + incoming.trySend(null) + }) + socket.on("error", { error: dynamic -> + failure = IllegalStateException(error.message?.unsafeCast() ?: "TCP socket failed") + closed = true + incoming.trySend(null) + }) + } + + override fun isOpen(): Boolean = !closed && socket.destroyed != true + + override fun localAddress(): LyngSocketAddress = socketAddress( + host = socket.localAddress?.unsafeCast() ?: "0.0.0.0", + port = socket.localPort?.unsafeCast() ?: 0, + family = socket.localFamily, + resolved = true, + ) + + override fun remoteAddress(): LyngSocketAddress = socketAddress( + host = socket.remoteAddress?.unsafeCast() ?: "0.0.0.0", + port = socket.remotePort?.unsafeCast() ?: 0, + family = socket.remoteFamily, + resolved = true, + ) + + override suspend fun read(maxBytes: Int): ByteArray? { + if (!ensureBuffered()) return null + val count = minOf(maxBytes, buffered.size) + return ByteArray(count) { buffered.removeFirst() } + } + + override suspend fun readLine(): String? { + while (true) { + val newlineIndex = buffered.indexOfFirst { it == '\n'.code.toByte() } + if (newlineIndex >= 0) { + val raw = takeBuffered(newlineIndex + 1) + val trimmed = if (raw.lastOrNull() == '\n'.code.toByte()) raw.dropLast(1) else raw + val withoutCr = if (trimmed.lastOrNull() == '\r'.code.toByte()) trimmed.dropLast(1) else trimmed + return withoutCr.toByteArray().decodeToString() + } + if (!fillBuffer()) break + } + if (buffered.isEmpty()) { + failure?.let { throw it } + return null + } + return takeBuffered(buffered.size).toByteArray().decodeToString() + } + + override suspend fun write(data: ByteArray) { + ensureOpen() + suspendCancellableCoroutine { cont -> + socket.write(byteArrayToUint8Array(data), { error: dynamic -> + if (!cont.isActive) return@write + if (error != null) { + cont.resumeWithException(IllegalStateException(error.message?.unsafeCast() ?: "TCP write failed")) + } else { + cont.resume(Unit) + } + }) + } + } + + override suspend fun writeUtf8(text: String) { + ensureOpen() + suspendCancellableCoroutine { cont -> + socket.write(text, "utf8", { error: dynamic -> + if (!cont.isActive) return@write + if (error != null) { + cont.resumeWithException(IllegalStateException(error.message?.unsafeCast() ?: "TCP write failed")) + } else { + cont.resume(Unit) + } + }) + } + } + + override suspend fun flush() { + ensureOpen() + if (socket.writableNeedDrain == true) { + withTimeoutOrNull(5_000) { + awaitNodeEvent(socket, "drain") + } + } + } + + override fun close() { + if (closed) return + closed = true + if (socket.destroyed == true) { + incoming.trySend(null) + return + } + if (socket.writable == true) socket.end() else socket.destroy() + } + + private suspend fun ensureBuffered(): Boolean { + if (buffered.isNotEmpty()) return true + return fillBuffer() + } + + private suspend fun fillBuffer(): Boolean { + while (buffered.isEmpty()) { + val chunk = incoming.receive() + if (chunk == null) { + failure?.let { if (buffered.isEmpty()) throw it } + return buffered.isNotEmpty() + } + chunk.forEach { buffered.addLast(it) } + } + return true + } + + private fun takeBuffered(count: Int): List = List(count) { buffered.removeFirst() } + + private fun ensureOpen() { + if (!isOpen()) throw IllegalStateException("tcp socket is closed") + } +} + +private class JsNodeTcpServer( + private val server: dynamic, + private val accepted: Channel, +) : LyngTcpServer { + private var closed = false + + override fun isOpen(): Boolean = !closed && server.listening == true + + override fun localAddress(): LyngSocketAddress { + val info = server.address() + return socketAddress( + host = info.address?.unsafeCast() ?: "0.0.0.0", + port = info.port?.unsafeCast() ?: 0, + family = info.family, + resolved = true, + ) + } + + override suspend fun accept(): LyngTcpSocket = accepted.receive() + + override fun close() { + if (closed) return + closed = true + server.close() + accepted.close() + } +} + +private class JsNodeUdpSocket( + private val socket: dynamic, + private val incoming: Channel, +) : LyngUdpSocket { + private var closed = false + + override fun isOpen(): Boolean = !closed + + override fun localAddress(): LyngSocketAddress = rinfoToAddress(socket.address()) + + override suspend fun receive(maxBytes: Int): LyngDatagram? { + val datagram = incoming.receiveCatching().getOrNull() ?: return null + return if (datagram.data.size <= maxBytes) datagram else datagram.copy(data = datagram.data.copyOf(maxBytes)) + } + + override suspend fun send(data: ByteArray, host: String, port: Int) { + if (closed) throw IllegalStateException("udp socket is closed") + suspendCancellableCoroutine { cont -> + socket.send(byteArrayToUint8Array(data), port, host, { error: dynamic -> + if (!cont.isActive) return@send + if (error != null) { + cont.resumeWithException(IllegalStateException(error.message?.unsafeCast() ?: "UDP send failed")) + } else { + cont.resume(Unit) + } + }) + } + } + + override fun close() { + if (closed) return + closed = true + socket.close() + incoming.close() + } +} + +private suspend fun awaitNodeEvent(target: dynamic, name: String) { + suspendCancellableCoroutine { cont -> + target.once("error", { error: dynamic -> + if (cont.isActive) { + cont.resumeWithException(IllegalStateException(error.message?.unsafeCast() ?: "Node operation failed")) + } + }) + target.once(name) { + if (cont.isActive) cont.resume(Unit) + } + } +} + +private fun socketAddress(host: String, port: Int, family: dynamic, resolved: Boolean): LyngSocketAddress = + LyngSocketAddress( + host = host, + port = port, + ipVersion = when (family?.toString()) { + "IPv6", "6" -> LyngIpVersion.IPV6 + else -> if (host.contains(':')) LyngIpVersion.IPV6 else LyngIpVersion.IPV4 + }, + resolved = resolved, + ) + +private fun rinfoToAddress(rinfo: dynamic): LyngSocketAddress = socketAddress( + host = rinfo.address?.unsafeCast() ?: "0.0.0.0", + port = rinfo.port?.unsafeCast() ?: 0, + family = rinfo.family, + resolved = true, +) + +private fun isNodeRuntime(): Boolean = js( + """ + typeof process !== "undefined" && + process != null && + process.versions != null && + process.versions.node != null + """ +).unsafeCast() + +private fun requireNodeModule(name: String): dynamic { + val requireFn = js("typeof require !== 'undefined' ? require : undefined") + if (requireFn == js("undefined")) return null + return try { + requireFn(name) + } catch (_: Throwable) { + null + } +} + +private fun dynamicToByteArray(value: dynamic): ByteArray { + val source = js("new Uint8Array(value)").unsafeCast() + val size = source.length + return ByteArray(size) { index -> source.asDynamic()[index].unsafeCast() } +} + +private fun byteArrayToUint8Array(value: ByteArray): Uint8Array { + val out = Uint8Array(value.size) + value.forEachIndexed { index, byte -> out.asDynamic()[index] = byte.toInt() and 0xff } + return out +} diff --git a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/ws/PlatformJs.kt b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/ws/PlatformJs.kt index e9bc921..a23faeb 100644 --- a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/ws/PlatformJs.kt +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/ws/PlatformJs.kt @@ -1,3 +1,91 @@ package net.sergeych.lyngio.ws -actual fun getSystemWsEngine(): LyngWsEngine = UnsupportedLyngWsEngine +import io.ktor.client.HttpClient +import io.ktor.client.engine.js.Js +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.websocket.CloseReason +import io.ktor.websocket.DefaultWebSocketSession +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.channels.ClosedReceiveChannelException + +actual fun getSystemWsEngine(): LyngWsEngine = JsKtorWsEngine + +private object JsKtorWsEngine : LyngWsEngine { + private val clientResult by lazy { + runCatching { + HttpClient(Js) { + install(WebSockets) + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun connect(url: String, headers: Map): LyngWsSession { + val client = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "WebSocket client is not supported") + } + val session = client.webSocketSession { + url(url) + headers.forEach { (name, value) -> header(name, value) } + } + return JsLyngWsSession(url, session) + } +} + +private class JsLyngWsSession( + private val targetUrl: String, + private val session: DefaultWebSocketSession, +) : LyngWsSession { + private var closed = false + + override fun isOpen(): Boolean = !closed + + override fun url(): String = targetUrl + + override suspend fun sendText(text: String) { + ensureOpen() + session.send(text) + } + + override suspend fun sendBytes(data: ByteArray) { + ensureOpen() + session.send(data) + } + + override suspend fun receive(): LyngWsMessage? { + if (closed) return null + val frame = try { + session.incoming.receive() + } catch (_: ClosedReceiveChannelException) { + closed = true + return null + } + return when (frame) { + is Frame.Text -> LyngWsMessage(isText = true, text = frame.readText()) + is Frame.Binary -> LyngWsMessage(isText = false, data = frame.data.copyOf()) + is Frame.Close -> { + closed = true + null + } + else -> receive() + } + } + + override suspend fun close(code: Int, reason: String) { + if (closed) return + closed = true + session.close(CloseReason(code.toShort(), reason)) + } + + private fun ensureOpen() { + if (closed) throw IllegalStateException("websocket session is closed") + } +} diff --git a/lyngio/src/jsTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleJsNodeTest.kt b/lyngio/src/jsTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleJsNodeTest.kt new file mode 100644 index 0000000..1618bb2 --- /dev/null +++ b/lyngio/src/jsTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleJsNodeTest.kt @@ -0,0 +1,117 @@ +package net.sergeych.lyng.io.net + +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.promise +import net.sergeych.lyng.Compiler +import net.sergeych.lyng.ExecutionError +import net.sergeych.lyng.Script +import net.sergeych.lyngio.fs.security.AccessContext +import net.sergeych.lyngio.fs.security.AccessDecision +import net.sergeych.lyngio.fs.security.Decision +import net.sergeych.lyngio.net.getSystemNetEngine +import net.sergeych.lyngio.net.security.NetAccessOp +import net.sergeych.lyngio.net.security.NetAccessPolicy +import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +@OptIn(DelicateCoroutinesApi::class) +class LyngNetModuleJsNodeTest { + @Test + fun testResolveAndCapabilities() = GlobalScope.promise { + val scope = Script.newScope() + createNetModule(PermitAllNetAccessPolicy, scope) + + val code = """ + import lyng.io.net + + val a: SocketAddress = Net.resolve("127.0.0.1", 4040)[0] + [Net.isSupported(), Net.isTcpAvailable(), Net.isTcpServerAvailable(), Net.isUdpAvailable(), a.toString(), a.resolved, a.ipVersion == IpVersion.IPV4] + """.trimIndent() + + val result = Compiler.compile(code).execute(scope).inspect(scope) + assertTrue(result.contains("true,true,true,true"), result) + assertTrue(result.contains("127.0.0.1:4040"), result) + } + + @Test + fun testTcpConnectConvenience() = GlobalScope.promise { + val engine = getSystemNetEngine() + val server = engine.tcpListen(host = "127.0.0.1", port = 0, backlog = 8, reuseAddress = true) + val serverPort = server.localAddress().port + val worker = async { + val client = server.accept() + val line = client.read(4)?.decodeToString() + client.writeUtf8("reply:$line") + client.flush() + client.close() + } + + val scope = Script.newScope() + createNetModule(PermitAllNetAccessPolicy, scope) + val code = """ + import lyng.buffer + import lyng.io.net + + val socket = Net.tcpConnect("127.0.0.1", $serverPort) + socket.writeUtf8("ping") + socket.flush() + val reply = (socket.read(16) as Buffer).decodeUtf8() + val localPort = socket.localAddress().port + val remotePort = socket.remoteAddress().port + socket.close() + [reply, localPort > 0, remotePort == $serverPort] + """.trimIndent() + + val result = Compiler.compile(code).execute(scope).inspect(scope) + worker.await() + server.close() + assertTrue(result.contains("reply:ping"), result) + assertTrue(result.contains("true,true"), result) + } + + @Test + fun testUdpLoopback() = GlobalScope.promise { + val scope = Script.newScope() + createNetModule(PermitAllNetAccessPolicy, scope) + + val code = """ + import lyng.buffer + import lyng.io.net + + val server = Net.udpBind(0, "127.0.0.1") + val client = Net.udpBind(0, "127.0.0.1") + client.send(Buffer("ping"), "127.0.0.1", server.localAddress().port) + val d = server.receive() + client.close() + server.close() + [d.data.decodeUtf8(), d.address.port > 0] + """.trimIndent() + + val result = Compiler.compile(code).execute(scope).inspect(scope) + assertTrue(result.contains("[ping,true]"), result) + } + + @Test + fun testPolicyDenialSurfacesAsLyngError() = GlobalScope.promise { + val scope = Script.newScope() + val denyAll = object : NetAccessPolicy { + override suspend fun check(op: NetAccessOp, ctx: AccessContext): AccessDecision = + AccessDecision(Decision.Deny, "blocked by test policy") + } + createNetModule(denyAll, scope) + + val code = """ + import lyng.io.net + Net.tcpConnect("127.0.0.1", 1) + """.trimIndent() + + val error = assertFailsWith { + Compiler.compile(code).execute(scope) + } + assertTrue(error.errorMessage.isNotBlank()) + } +} diff --git a/lyngio/src/jsTest/kotlin/net/sergeych/lyngio/NetJsNodeTest.kt b/lyngio/src/jsTest/kotlin/net/sergeych/lyngio/NetJsNodeTest.kt new file mode 100644 index 0000000..1dfc94c --- /dev/null +++ b/lyngio/src/jsTest/kotlin/net/sergeych/lyngio/NetJsNodeTest.kt @@ -0,0 +1,70 @@ +package net.sergeych.lyngio + +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.promise +import net.sergeych.lyngio.net.LyngIpVersion +import net.sergeych.lyngio.net.getSystemNetEngine +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(DelicateCoroutinesApi::class) +class NetJsNodeTest { + @Test + fun testNodeNetCapabilitiesAndResolve() = GlobalScope.promise { + val engine = getSystemNetEngine() + assertTrue(engine.isSupported) + assertTrue(engine.isTcpAvailable) + assertTrue(engine.isTcpServerAvailable) + assertTrue(engine.isUdpAvailable) + + val resolved = engine.resolve("127.0.0.1", 4040) + assertTrue(resolved.isNotEmpty()) + assertEquals(4040, resolved.first().port) + assertEquals(LyngIpVersion.IPV4, resolved.first().ipVersion) + } + + @Test + fun testNodeTcpLoopback() = GlobalScope.promise { + val engine = getSystemNetEngine() + val server = engine.tcpListen(host = "127.0.0.1", port = 0, backlog = 8, reuseAddress = true) + val accepted = async { + val socket = server.accept() + val line = socket.readLine() + socket.writeUtf8("echo:$line\n") + socket.flush() + socket.close() + line + } + + val client = engine.tcpConnect("127.0.0.1", server.localAddress().port, timeoutMillis = null, noDelay = true) + client.writeUtf8("ping\n") + client.flush() + val reply = client.readLine() + client.close() + server.close() + + assertEquals("ping", accepted.await()) + assertEquals("echo:ping", reply) + } + + @Test + fun testNodeUdpLoopback() = GlobalScope.promise { + val engine = getSystemNetEngine() + val server = engine.udpBind(host = "127.0.0.1", port = 0, reuseAddress = true) + val client = engine.udpBind(host = "127.0.0.1", port = 0, reuseAddress = true) + + client.send("ping".encodeToByteArray(), "127.0.0.1", server.localAddress().port) + val received = server.receive(1024) + + client.close() + server.close() + + assertNotNull(received) + assertEquals("ping", received.data.decodeToString()) + assertTrue(received.address.port > 0) + } +} diff --git a/lyngio/src/jsTest/kotlin/net/sergeych/lyngio/PlatformCapabilityJsTest.kt b/lyngio/src/jsTest/kotlin/net/sergeych/lyngio/PlatformCapabilityJsTest.kt new file mode 100644 index 0000000..7e7890d --- /dev/null +++ b/lyngio/src/jsTest/kotlin/net/sergeych/lyngio/PlatformCapabilityJsTest.kt @@ -0,0 +1,14 @@ +package net.sergeych.lyngio + +import net.sergeych.lyngio.http.getSystemHttpEngine +import net.sergeych.lyngio.ws.getSystemWsEngine +import kotlin.test.Test +import kotlin.test.assertTrue + +class PlatformCapabilityJsTest { + @Test + fun testJsHttpAndWsCapabilitiesReportSupported() { + assertTrue(getSystemHttpEngine().isSupported, "JS HTTP engine should be available") + assertTrue(getSystemWsEngine().isSupported, "JS websocket engine should be available") + } +} diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt new file mode 100644 index 0000000..2cbdb56 --- /dev/null +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt @@ -0,0 +1,58 @@ +package net.sergeych.lyngio.http + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.timeout +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.http.HttpMethod +import io.ktor.http.headers +import io.ktor.http.takeFrom + +actual fun getSystemHttpEngine(): LyngHttpEngine = JvmKtorLyngHttpEngine + +private object JvmKtorLyngHttpEngine : LyngHttpEngine { + private val clientResult by lazy { + runCatching { + HttpClient(CIO) { + expectSuccess = false + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun request(request: LyngHttpRequest): LyngHttpResponse { + val httpClient = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "HTTP client is not supported") + } + + val response = httpClient.request { + applyRequest(request) + } + return LyngHttpResponse( + status = response.status.value, + statusText = response.status.description, + headers = response.headers.entries().associate { it.key to it.value.toList() }, + bodyBytes = response.body(), + ) + } + + private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) { + method = HttpMethod.parse(request.method.uppercase()) + url.takeFrom(request.url) + headers { + request.headers.forEach { (name, value) -> append(name, value) } + } + request.timeoutMillis?.let { timeout { requestTimeoutMillis = it } } + when { + request.bodyBytes != null && request.bodyText != null -> + throw IllegalArgumentException("Only one of bodyText or bodyBytes may be set") + request.bodyBytes != null -> setBody(request.bodyBytes) + request.bodyText != null -> setBody(request.bodyText) + } + } +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/LyngHttpModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/LyngHttpModuleTest.kt index d899316..bff98c7 100644 --- a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/LyngHttpModuleTest.kt +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/LyngHttpModuleTest.kt @@ -19,10 +19,13 @@ package net.sergeych.lyng.io.http import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpServer +import com.sun.net.httpserver.HttpsConfigurator +import com.sun.net.httpserver.HttpsServer import kotlinx.coroutines.runBlocking import net.sergeych.lyng.Compiler import net.sergeych.lyng.ExecutionError import net.sergeych.lyng.Script +import net.sergeych.lyng.io.testtls.TlsTestMaterial import net.sergeych.lyngio.fs.security.AccessContext import net.sergeych.lyngio.fs.security.AccessDecision import net.sergeych.lyngio.fs.security.Decision @@ -101,6 +104,33 @@ class LyngHttpModuleTest { } } + @Test + fun testHttpsGet() = runBlocking { + TlsTestMaterial.installJvmClientTrust() + val server = newServer(secure = true) { exchange -> + exchange.responseHeaders.add("Content-Type", "text/plain; charset=utf-8") + writeResponse(exchange, 200, "hello over tls") + } + try { + val scope = Script.newScope() + createHttpModule(PermitAllHttpAccessPolicy, scope) + + val code = """ + import lyng.io.http + + val r = Http.get("https://127.0.0.1:${server.address.port}/hello") + [r.status, r.text()] + """.trimIndent() + + val result = Compiler.compile(code).execute(scope) + val rendered = result.inspect(scope) + assertTrue(rendered.contains("200"), rendered) + assertTrue(rendered.contains("hello over tls"), rendered) + } finally { + server.stop(0) + } + } + @Test fun testPolicyDenialSurfacesAsLyngError() = runBlocking { val scope = Script.newScope() @@ -121,8 +151,14 @@ class LyngHttpModuleTest { assertTrue(error.errorMessage.isNotBlank()) } - private fun newServer(handler: (HttpExchange) -> Unit): HttpServer { - val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + private fun newServer(secure: Boolean = false, handler: (HttpExchange) -> Unit): HttpServer { + val server = if (secure) { + HttpsServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + httpsConfigurator = HttpsConfigurator(TlsTestMaterial.sslContext) + } + } else { + HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + } server.createContext("/") { exchange -> handler(exchange) } diff --git a/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt new file mode 100644 index 0000000..2ddbceb --- /dev/null +++ b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt @@ -0,0 +1,58 @@ +package net.sergeych.lyngio.http + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.curl.Curl +import io.ktor.client.plugins.timeout +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.http.HttpMethod +import io.ktor.http.headers +import io.ktor.http.takeFrom + +actual fun getSystemHttpEngine(): LyngHttpEngine = LinuxLyngHttpEngine + +private object LinuxLyngHttpEngine : LyngHttpEngine { + private val clientResult by lazy { + runCatching { + HttpClient(Curl) { + expectSuccess = false + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun request(request: LyngHttpRequest): LyngHttpResponse { + val httpClient = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "HTTP client is not supported") + } + + val response = httpClient.request { + applyRequest(request) + } + return LyngHttpResponse( + status = response.status.value, + statusText = response.status.description, + headers = response.headers.entries().associate { it.key to it.value.toList() }, + bodyBytes = response.body(), + ) + } + + private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) { + method = HttpMethod.parse(request.method.uppercase()) + url.takeFrom(request.url) + headers { + request.headers.forEach { (name, value) -> append(name, value) } + } + request.timeoutMillis?.let { timeout { requestTimeoutMillis = it } } + when { + request.bodyBytes != null && request.bodyText != null -> + throw IllegalArgumentException("Only one of bodyText or bodyBytes may be set") + request.bodyBytes != null -> setBody(request.bodyBytes) + request.bodyText != null -> setBody(request.bodyText) + } + } +} diff --git a/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/ws/PlatformLinux.kt b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/ws/PlatformLinux.kt new file mode 100644 index 0000000..4395553 --- /dev/null +++ b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/ws/PlatformLinux.kt @@ -0,0 +1,91 @@ +package net.sergeych.lyngio.ws + +import io.ktor.client.HttpClient +import io.ktor.client.engine.curl.Curl +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.websocket.CloseReason +import io.ktor.websocket.DefaultWebSocketSession +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.channels.ClosedReceiveChannelException + +actual fun getSystemWsEngine(): LyngWsEngine = LinuxKtorWsEngine + +private object LinuxKtorWsEngine : LyngWsEngine { + private val clientResult by lazy { + runCatching { + HttpClient(Curl) { + install(WebSockets) + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun connect(url: String, headers: Map): LyngWsSession { + val client = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "WebSocket client is not supported") + } + val session = client.webSocketSession { + url(url) + headers.forEach { (name, value) -> header(name, value) } + } + return LinuxLyngWsSession(url, session) + } +} + +private class LinuxLyngWsSession( + private val targetUrl: String, + private val session: DefaultWebSocketSession, +) : LyngWsSession { + private var closed = false + + override fun isOpen(): Boolean = !closed + + override fun url(): String = targetUrl + + override suspend fun sendText(text: String) { + ensureOpen() + session.send(text) + } + + override suspend fun sendBytes(data: ByteArray) { + ensureOpen() + session.send(data) + } + + override suspend fun receive(): LyngWsMessage? { + if (closed) return null + val frame = try { + session.incoming.receive() + } catch (_: ClosedReceiveChannelException) { + closed = true + return null + } + return when (frame) { + is Frame.Text -> LyngWsMessage(isText = true, text = frame.readText()) + is Frame.Binary -> LyngWsMessage(isText = false, data = frame.data.copyOf()) + is Frame.Close -> { + closed = true + null + } + else -> receive() + } + } + + override suspend fun close(code: Int, reason: String) { + if (closed) return + closed = true + session.close(CloseReason(code.toShort(), reason)) + } + + private fun ensureOpen() { + if (closed) throw IllegalStateException("websocket session is closed") + } +} diff --git a/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt new file mode 100644 index 0000000..e26b12e --- /dev/null +++ b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt @@ -0,0 +1,58 @@ +package net.sergeych.lyngio.http + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.winhttp.WinHttp +import io.ktor.client.plugins.timeout +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.http.HttpMethod +import io.ktor.http.headers +import io.ktor.http.takeFrom + +actual fun getSystemHttpEngine(): LyngHttpEngine = MingwLyngHttpEngine + +private object MingwLyngHttpEngine : LyngHttpEngine { + private val clientResult by lazy { + runCatching { + HttpClient(WinHttp) { + expectSuccess = false + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun request(request: LyngHttpRequest): LyngHttpResponse { + val httpClient = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "HTTP client is not supported") + } + + val response = httpClient.request { + applyRequest(request) + } + return LyngHttpResponse( + status = response.status.value, + statusText = response.status.description, + headers = response.headers.entries().associate { it.key to it.value.toList() }, + bodyBytes = response.body(), + ) + } + + private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) { + method = HttpMethod.parse(request.method.uppercase()) + url.takeFrom(request.url) + headers { + request.headers.forEach { (name, value) -> append(name, value) } + } + request.timeoutMillis?.let { timeout { requestTimeoutMillis = it } } + when { + request.bodyBytes != null && request.bodyText != null -> + throw IllegalArgumentException("Only one of bodyText or bodyBytes may be set") + request.bodyBytes != null -> setBody(request.bodyBytes) + request.bodyText != null -> setBody(request.bodyText) + } + } +} diff --git a/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/ws/PlatformMingw.kt b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/ws/PlatformMingw.kt new file mode 100644 index 0000000..186b733 --- /dev/null +++ b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/ws/PlatformMingw.kt @@ -0,0 +1,91 @@ +package net.sergeych.lyngio.ws + +import io.ktor.client.HttpClient +import io.ktor.client.engine.winhttp.WinHttp +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.websocket.CloseReason +import io.ktor.websocket.DefaultWebSocketSession +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.channels.ClosedReceiveChannelException + +actual fun getSystemWsEngine(): LyngWsEngine = MingwKtorWsEngine + +private object MingwKtorWsEngine : LyngWsEngine { + private val clientResult by lazy { + runCatching { + HttpClient(WinHttp) { + install(WebSockets) + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun connect(url: String, headers: Map): LyngWsSession { + val client = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "WebSocket client is not supported") + } + val session = client.webSocketSession { + url(url) + headers.forEach { (name, value) -> header(name, value) } + } + return MingwLyngWsSession(url, session) + } +} + +private class MingwLyngWsSession( + private val targetUrl: String, + private val session: DefaultWebSocketSession, +) : LyngWsSession { + private var closed = false + + override fun isOpen(): Boolean = !closed + + override fun url(): String = targetUrl + + override suspend fun sendText(text: String) { + ensureOpen() + session.send(text) + } + + override suspend fun sendBytes(data: ByteArray) { + ensureOpen() + session.send(data) + } + + override suspend fun receive(): LyngWsMessage? { + if (closed) return null + val frame = try { + session.incoming.receive() + } catch (_: ClosedReceiveChannelException) { + closed = true + return null + } + return when (frame) { + is Frame.Text -> LyngWsMessage(isText = true, text = frame.readText()) + is Frame.Binary -> LyngWsMessage(isText = false, data = frame.data.copyOf()) + is Frame.Close -> { + closed = true + null + } + else -> receive() + } + } + + override suspend fun close(code: Int, reason: String) { + if (closed) return + closed = true + session.close(CloseReason(code.toShort(), reason)) + } + + private fun ensureOpen() { + if (closed) throw IllegalStateException("websocket session is closed") + } +} diff --git a/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/ws/PlatformNative.kt b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/ws/PlatformNative.kt deleted file mode 100644 index e9bc921..0000000 --- a/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/ws/PlatformNative.kt +++ /dev/null @@ -1,3 +0,0 @@ -package net.sergeych.lyngio.ws - -actual fun getSystemWsEngine(): LyngWsEngine = UnsupportedLyngWsEngine