diff --git a/docs/lyng.io.net.md b/docs/lyng.io.net.md index f0117c2..fc725a5 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 backed by Ktor sockets on the JVM and by Node networking APIs on JS/Node runtimes. +This module provides minimal raw transport networking for Lyng scripts. It is implemented in `lyngio` and backed by Ktor sockets on the JVM and Linux Native, 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. @@ -164,4 +164,6 @@ The module uses `NetAccessPolicy` to authorize network operations before they ar - **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 +- **Linux Native:** supported via Ktor sockets +- **Apple Native:** enabled via the shared native Ktor sockets backend; compile-verified, runtime not yet host-verified +- **Other native targets:** currently report unsupported; use capability checks before relying on raw sockets diff --git a/lyngio/build.gradle.kts b/lyngio/build.gradle.kts index 8e9230c..6bac104 100644 --- a/lyngio/build.gradle.kts +++ b/lyngio/build.gradle.kts @@ -86,6 +86,9 @@ kotlin { } val nativeMain by creating { dependsOn(commonMain) + dependencies { + implementation(libs.ktor.network) + } } val darwinMain by creating { dependsOn(nativeMain) @@ -117,6 +120,9 @@ kotlin { implementation(libs.kotlinx.coroutines.test) } } + val linuxTest by creating { + dependsOn(commonTest) + } val iosX64Main by getting { dependsOn(iosMain) } val iosArm64Main by getting { dependsOn(iosMain) } val iosSimulatorArm64Main by getting { dependsOn(iosMain) } @@ -124,6 +130,8 @@ kotlin { val mingwX64Main by getting { dependsOn(mingwMain) } val linuxX64Main by getting { dependsOn(linuxMain) } val linuxArm64Main by getting { dependsOn(linuxMain) } + val linuxX64Test by getting { dependsOn(linuxTest) } + val linuxArm64Test by getting { dependsOn(linuxTest) } // JS: use runtime detection in jsMain to select Node vs Browser implementation val jsMain by getting { diff --git a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt index 3643975..13df027 100644 --- a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt @@ -1,58 +1,5 @@ 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) - } - } -} +actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(CIO) 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 913d04f..fa1c252 100644 --- a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/ws/PlatformAndroid.kt +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/ws/PlatformAndroid.kt @@ -1,92 +1,5 @@ package net.sergeych.lyngio.ws -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") - } -} +actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(CIO) diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/KtorHttpEngine.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/KtorHttpEngine.kt new file mode 100644 index 0000000..ab0e9b5 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/KtorHttpEngine.kt @@ -0,0 +1,61 @@ +package net.sergeych.lyngio.http + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.HttpClientEngineConfig +import io.ktor.client.engine.HttpClientEngineFactory +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 + +internal fun createKtorHttpEngine( + engineFactory: HttpClientEngineFactory, +): LyngHttpEngine = KtorLyngHttpEngine(engineFactory) + +private class KtorLyngHttpEngine( + engineFactory: HttpClientEngineFactory, +) : LyngHttpEngine { + private val clientResult = runCatching { + HttpClient(engineFactory) { + 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/commonMain/kotlin/net/sergeych/lyngio/ws/KtorWsEngine.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/KtorWsEngine.kt new file mode 100644 index 0000000..5f86ac2 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/KtorWsEngine.kt @@ -0,0 +1,94 @@ +package net.sergeych.lyngio.ws + +import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngineConfig +import io.ktor.client.engine.HttpClientEngineFactory +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 + +internal fun createKtorWsEngine( + engineFactory: HttpClientEngineFactory, +): LyngWsEngine = KtorLyngWsEngine(engineFactory) + +private class KtorLyngWsEngine( + engineFactory: HttpClientEngineFactory, +) : LyngWsEngine { + private val clientResult = runCatching { + HttpClient(engineFactory) { + 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 KtorLyngWsSession(url, session) + } +} + +private class KtorLyngWsSession( + 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/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt b/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt index 1b55e80..61b2cee 100644 --- a/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt +++ b/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt @@ -1,58 +1,5 @@ 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) - } - } -} +actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(Darwin) diff --git a/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/net/PlatformDarwin.kt b/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/net/PlatformDarwin.kt new file mode 100644 index 0000000..d9cddfc --- /dev/null +++ b/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/net/PlatformDarwin.kt @@ -0,0 +1,8 @@ +package net.sergeych.lyngio.net + +actual fun getSystemNetEngine(): LyngNetEngine = createNativeKtorNetEngine( + isSupported = true, + isTcpAvailable = true, + isTcpServerAvailable = true, + isUdpAvailable = true, +) diff --git a/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/ws/PlatformDarwin.kt b/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/ws/PlatformDarwin.kt index 57e05c9..8b8d1cd 100644 --- a/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/ws/PlatformDarwin.kt +++ b/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/ws/PlatformDarwin.kt @@ -1,91 +1,5 @@ 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") - } -} +actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(Darwin) diff --git a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt index 05ff5ec..097c748 100644 --- a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt @@ -1,58 +1,5 @@ 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) - } - } -} +actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(Js) 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 a23faeb..2130056 100644 --- a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/ws/PlatformJs.kt +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/ws/PlatformJs.kt @@ -1,91 +1,5 @@ package net.sergeych.lyngio.ws -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") - } -} +actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(Js) diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt index 2cbdb56..13df027 100644 --- a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt @@ -1,58 +1,5 @@ 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) - } - } -} +actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(CIO) diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/ws/PlatformJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/ws/PlatformJvm.kt index 0f2b056..9f89040 100644 --- a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/ws/PlatformJvm.kt +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/ws/PlatformJvm.kt @@ -17,94 +17,6 @@ package net.sergeych.lyngio.ws -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 = JvmKtorWsEngine - -private object JvmKtorWsEngine : 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 JvmLyngWsSession(url, session) - } -} - -private class JvmLyngWsSession( - 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 - val safeCode = code.toShort() - session.close(CloseReason(safeCode, reason)) - } - - private fun ensureOpen() { - if (closed) throw IllegalStateException("websocket session is closed") - } -} +actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(CIO) diff --git a/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt index 2ddbceb..0b1ba9c 100644 --- a/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt +++ b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt @@ -1,58 +1,5 @@ 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) - } - } -} +actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(Curl) diff --git a/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/net/PlatformLinux.kt b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/net/PlatformLinux.kt new file mode 100644 index 0000000..d9cddfc --- /dev/null +++ b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/net/PlatformLinux.kt @@ -0,0 +1,8 @@ +package net.sergeych.lyngio.net + +actual fun getSystemNetEngine(): LyngNetEngine = createNativeKtorNetEngine( + isSupported = true, + isTcpAvailable = true, + isTcpServerAvailable = true, + isUdpAvailable = true, +) diff --git a/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/ws/PlatformLinux.kt b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/ws/PlatformLinux.kt index 4395553..fe86121 100644 --- a/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/ws/PlatformLinux.kt +++ b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/ws/PlatformLinux.kt @@ -1,91 +1,5 @@ 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") - } -} +actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(Curl) diff --git a/lyngio/src/linuxTest/kotlin/net/sergeych/lyngio/net/NetLinuxNativeTest.kt b/lyngio/src/linuxTest/kotlin/net/sergeych/lyngio/net/NetLinuxNativeTest.kt new file mode 100644 index 0000000..5cfad8e --- /dev/null +++ b/lyngio/src/linuxTest/kotlin/net/sergeych/lyngio/net/NetLinuxNativeTest.kt @@ -0,0 +1,90 @@ +package net.sergeych.lyngio.net + +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import net.sergeych.lyng.Compiler +import net.sergeych.lyng.Script +import net.sergeych.lyng.io.net.createNetModule +import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NetLinuxNativeTest { + + @Test + fun testLinuxNativeCapabilitiesAndResolve() = runBlocking { + val engine = getSystemNetEngine() + + assertTrue(engine.isSupported) + assertTrue(engine.isTcpAvailable) + assertTrue(engine.isTcpServerAvailable) + assertTrue(engine.isUdpAvailable) + + val resolved = engine.resolve("127.0.0.1", 4040) + assertEquals(1, resolved.size) + assertEquals("127.0.0.1", resolved.single().host) + assertEquals(4040, resolved.single().port) + assertEquals(LyngIpVersion.IPV4, resolved.single().ipVersion) + assertTrue(resolved.single().resolved) + } + + @Test + fun testLinuxNativeLyngModuleCapabilities() = runBlocking { + 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] + """.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 testLinuxNativeTcpAndUdpLoopback() = runBlocking { + val engine = getSystemNetEngine() + + withTimeout(5_000) { + val server = engine.tcpListen(host = "127.0.0.1", port = 0, backlog = 16, reuseAddress = true) + val accepted = async { + val client = server.accept() + val text = client.read(4)?.decodeToString() + client.writeUtf8("echo:$text") + client.flush() + client.close() + server.close() + text + } + + val socket = engine.tcpConnect("127.0.0.1", server.localAddress().port, timeoutMillis = 2_000, noDelay = true) + socket.writeUtf8("ping") + socket.flush() + val reply = socket.read(32)?.decodeToString() + socket.close() + + assertEquals("ping", accepted.await()) + assertEquals("echo:ping", reply) + } + + withTimeout(5_000) { + val receiver = engine.udpBind(host = "127.0.0.1", port = 0, reuseAddress = true) + val sender = engine.udpBind(host = "127.0.0.1", port = 0, reuseAddress = true) + + sender.send("ping".encodeToByteArray(), "127.0.0.1", receiver.localAddress().port) + val datagram = receiver.receive(32) + + sender.close() + receiver.close() + + assertEquals("ping", datagram?.data?.decodeToString()) + assertTrue((datagram?.address?.port ?: 0) > 0) + } + } +} diff --git a/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt index e26b12e..d8e1e0a 100644 --- a/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt +++ b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt @@ -1,58 +1,5 @@ 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) - } - } -} +actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(WinHttp) diff --git a/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/net/PlatformNative.kt b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/net/PlatformMingw.kt similarity index 100% rename from lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/net/PlatformNative.kt rename to lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/net/PlatformMingw.kt diff --git a/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/ws/PlatformMingw.kt b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/ws/PlatformMingw.kt index 186b733..b4fdf5f 100644 --- a/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/ws/PlatformMingw.kt +++ b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/ws/PlatformMingw.kt @@ -1,91 +1,5 @@ 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") - } -} +actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(WinHttp) diff --git a/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/net/NativeKtorNetEngine.kt b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/net/NativeKtorNetEngine.kt new file mode 100644 index 0000000..2233a71 --- /dev/null +++ b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/net/NativeKtorNetEngine.kt @@ -0,0 +1,216 @@ +package net.sergeych.lyngio.net + +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.BoundDatagramSocket +import io.ktor.network.sockets.Datagram +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.ServerSocket +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.SocketAddress +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.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.withTimeout +import kotlinx.io.Buffer +import kotlinx.io.readByteArray + +internal fun createNativeKtorNetEngine( + isSupported: Boolean, + isTcpAvailable: Boolean, + isTcpServerAvailable: Boolean, + isUdpAvailable: Boolean, +): LyngNetEngine = NativeKtorNetEngine( + isSupported = isSupported, + isTcpAvailable = isTcpAvailable, + isTcpServerAvailable = isTcpServerAvailable, + isUdpAvailable = isUdpAvailable, +) + +private class NativeKtorNetEngine( + override val isSupported: Boolean, + override val isTcpAvailable: Boolean, + override val isTcpServerAvailable: Boolean, + override val isUdpAvailable: Boolean, +) : LyngNetEngine { + private val selectorManager: SelectorManager by lazy { SelectorManager(Dispatchers.Default) } + + override suspend fun resolve(host: String, port: Int): List { + val rawAddress = InetSocketAddress(host, port).resolveAddress() + ?: throw IllegalStateException("Failed to resolve address for $host") + return listOf( + LyngSocketAddress( + host = rawAddress.toIpHostString(), + port = port, + ipVersion = rawAddress.toLyngIpVersion(), + 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 NativeLyngTcpSocket(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 NativeLyngTcpServer(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 NativeLyngUdpSocket(socket) + } +} + +private class NativeLyngTcpSocket( + 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 NativeLyngTcpServer( + 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 = NativeLyngTcpSocket(server.accept()) + + override fun close() { + server.close() + } +} + +private class NativeLyngUdpSocket( + 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(Datagram(packet, InetSocketAddress(host, port))) + } + + override fun close() { + socket.close() + } +} + +private fun SocketAddress.toLyngSocketAddress(resolved: Boolean): LyngSocketAddress { + val inetAddress = this as? InetSocketAddress + if (inetAddress != null) { + val rawAddress = inetAddress.resolveAddress() + val host = rawAddress?.toIpHostString() ?: inetAddress.hostname + return LyngSocketAddress( + host = host, + port = inetAddress.port, + ipVersion = rawAddress?.toLyngIpVersion() + ?: if (host.contains(':')) LyngIpVersion.IPV6 else LyngIpVersion.IPV4, + resolved = resolved, + ) + } + + val rendered = toString() + return LyngSocketAddress( + host = rendered, + port = 0, + ipVersion = if (rendered.contains(':')) LyngIpVersion.IPV6 else LyngIpVersion.IPV4, + resolved = resolved, + ) +} + +private fun ByteArray.toLyngIpVersion(): LyngIpVersion = if (size == 16) LyngIpVersion.IPV6 else LyngIpVersion.IPV4 + +private fun ByteArray.toIpHostString(): String = when (size) { + 4 -> joinToString(".") { (it.toInt() and 0xff).toString() } + 16 -> (0 until 8).joinToString(":") { index -> + val hi = this[index * 2].toInt() and 0xff + val lo = this[index * 2 + 1].toInt() and 0xff + ((hi shl 8) or lo).toString(16) + } + else -> error("Unsupported IP address length: $size") +} diff --git a/notes/networking_handoff_2026-04-02.md b/notes/networking_handoff_2026-04-02.md new file mode 100644 index 0000000..e2bde85 --- /dev/null +++ b/notes/networking_handoff_2026-04-02.md @@ -0,0 +1,205 @@ +# Networking Handoff + +Date: 2026-04-02 +Commit: `5346d15` (`Add KMP networking backends`) + +## Scope completed + +The `lyngio` networking work now provides a uniform Lyng-facing API with capability probes and platform-specific implementations. + +Implemented modules: + +- `lyng.io.http` +- `lyng.io.ws` +- `lyng.io.net` + +## Current support matrix + +### HTTP / HTTPS + +- JVM: supported +- Android: supported +- JS: supported +- Linux Native: supported +- Windows Native (`mingwX64`): supported +- Apple Native: compile-verified on this Linux host + +### WS / WSS + +- JVM: supported +- Android: supported +- JS: supported +- Linux Native: supported +- Windows Native (`mingwX64`): supported +- Apple Native: compile-verified on this Linux host + +### Raw networking (`lyng.io.net`) + +- JVM: supported +- Android: supported +- JS/Node: supported +- JS/browser: unsupported by capability probe +- Linux Native: supported +- Apple Native: enabled via shared native backend; compile-verified, runtime not yet host-verified +- Other Native targets: intentionally still unsupported + +## Important design decisions + +- Ktor is the backend for all currently implemented networking. +- API is uniform across targets; platform variance is exposed through capability checks such as: + - `Http.isSupported()` + - `Ws.isSupported()` + - `Net.isSupported()` + - `Net.isTcpAvailable()` + - `Net.isTcpServerAvailable()` + - `Net.isUdpAvailable()` +- Native support was restricted to what matches Ktor client-engine support: + - Darwin for Apple Native + - Curl for Linux Native + - WinHttp for Windows Native +- Native raw sockets use a shared Ktor socket implementation for Linux and Darwin source sets. +- Capability probes are enabled on Linux Native and Apple Native; Apple Native is compile-verified but not yet runtime-tested on a macOS host. + +## Documentation and tests status + +Docs updated: + +- `docs/lyng.io.http.md` +- `docs/lyng.io.ws.md` +- `docs/lyng.io.net.md` + +Verified docs/tests: + +- HTTP/HTTPS docs are covered with extracted markdown tests on JVM. +- WS/WSS docs are covered with extracted markdown tests on JVM. +- JS/Node has both engine-level and Lyng-module-level tests for raw networking. + +## Key implementation files + +Shared: + +- `lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt` +- `lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt` +- `lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/LyngWs.kt` +- `lyngio/src/commonMain/kotlin/net/sergeych/lyngio/net/LyngNet.kt` +- `lyngio/build.gradle.kts` +- `gradle/libs.versions.toml` + +Platform HTTP: + +- `lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt` +- `lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt` +- `lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt` +- `lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt` +- `lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt` +- `lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt` + +Platform WS: + +- `lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/ws/PlatformJvm.kt` +- `lyngio/src/jsMain/kotlin/net/sergeych/lyngio/ws/PlatformJs.kt` +- `lyngio/src/androidMain/kotlin/net/sergeych/lyngio/ws/PlatformAndroid.kt` +- `lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/ws/PlatformDarwin.kt` +- `lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/ws/PlatformLinux.kt` +- `lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/ws/PlatformMingw.kt` + +Platform raw net: + +- `lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/net/PlatformJvm.kt` +- `lyngio/src/jsMain/kotlin/net/sergeych/lyngio/net/PlatformJs.kt` +- `lyngio/src/androidMain/kotlin/net/sergeych/lyngio/net/PlatformAndroid.kt` +- `lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/net/NativeKtorNetEngine.kt` +- `lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/net/PlatformLinux.kt` +- `lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/net/PlatformDarwin.kt` +- `lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/net/PlatformMingw.kt` + +JS tests: + +- `lyngio/src/jsTest/kotlin/net/sergeych/lyngio/PlatformCapabilityJsTest.kt` +- `lyngio/src/jsTest/kotlin/net/sergeych/lyngio/NetJsNodeTest.kt` +- `lyngio/src/jsTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleJsNodeTest.kt` + +JVM tests: + +- `lyngio/src/jvmTest/kotlin/LyngioBookTest.kt` +- `lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/LyngHttpModuleTest.kt` +- `lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/ws/LyngWsModuleTest.kt` +- `lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleTest.kt` + +Linux Native tests: + +- `lyngio/src/linuxTest/kotlin/net/sergeych/lyngio/net/NetLinuxNativeTest.kt` + +## Verification already run + +JVM / docs: + +- `./gradlew :lyngio:jvmTest --tests LyngioBookTest` +- `./gradlew :lyngio:jvmTest --tests net.sergeych.lyng.io.http.LyngHttpModuleTest` +- `./gradlew :lyngio:jvmTest --tests net.sergeych.lyng.io.ws.LyngWsModuleTest` +- `./gradlew :lyngio:jvmTest --tests net.sergeych.lyng.io.net.LyngNetModuleTest` + +JS: + +- `./gradlew :lyngio:compileKotlinJs` +- `./gradlew :lyngio:compileTestKotlinJs` +- `./gradlew :lyngio:jsNodeTest` +- `./gradlew kotlinUpgradeYarnLock` + +Android: + +- `./gradlew :lyngio:compileDebugKotlinAndroid` +- `./gradlew :lyngio:compileReleaseKotlinAndroid` + +Native: + +- `./gradlew :lyngio:compileKotlinLinuxX64` +- `./gradlew :lyngio:compileKotlinLinuxArm64` +- `./gradlew :lyngio:compileKotlinMingwX64` +- `./gradlew :lyngio:compileKotlinIosX64` +- `./gradlew :lyngio:compileKotlinIosArm64` +- `./gradlew :lyngio:compileKotlinIosSimulatorArm64` +- `./gradlew :lyngio:compileKotlinMacosArm64` +- `./gradlew :lyngio:compileTestKotlinLinuxX64` +- `./gradlew :lyngio:compileTestKotlinLinuxArm64` +- `./gradlew :lyngio:linkDebugTestLinuxX64` +- `./gradlew :lyngio:linuxX64Test` +- `./gradlew :lyngio:linuxX64Test --tests net.sergeych.lyngio.net.NetLinuxNativeTest` +- `./gradlew :lyngio:linuxX64Test --tests net.sergeych.lyngio.net.NetLinuxNativeTest.testLinuxNativeTcpAndUdpLoopback` +- `./lyngio/build/bin/linuxX64/debugTest/test.kexe --ktest_filter='net.sergeych.lyngio.net.NetLinuxNativeTest.*'` + +## Known intentional gaps + +- Native raw sockets are enabled on Linux Native and Apple Native. +- Apple Native raw networking is enabled based on shared-backend compile verification; runtime verification on macOS is still pending. +- No Android device/instrumented runtime tests were added; only compile verification was done. + +## Worktree state after commit + +Current HEAD: + +- `5346d15` `Add KMP networking backends` + +Unrelated remaining change: + +- `examples/tetris_console.lyng` + +That file was not touched by the networking work and was intentionally left out of the commit. + +## Recommended next steps + +1. Add Native raw socket support only per target that compiles and passes a smoke test. +2. Keep capability probes `false` on non-Linux Native targets until each raw-socket backend is proven. +3. If Apple Native work continues, compile Darwin targets on a macOS host before claiming support. +4. Run a macOS-hosted runtime smoke test when available to verify the already-enabled Darwin backend. +5. Optionally add Android runtime tests later; compile-only verification exists now. + +## Suggested first task for the next chat + +Continue Native raw `lyng.io.net` incrementally from the verified Linux baseline: + +- keep Linux Native enabled +- keep Apple Native enabled unless runtime verification disproves the shared-backend assumption +- keep other Native targets capability-gated off until compiled and smoke-tested +- use only `ktor-network` support that actually compiles +- do not change the Lyng-facing API