Add KMP networking backends

This commit is contained in:
Sergey Chernov 2026-04-02 19:23:46 +03:00
parent d409a4bb8b
commit 5346d15a9f
25 changed files with 1744 additions and 75 deletions

View File

@ -153,10 +153,15 @@ val allowLocalOnly = object : HttpAccessPolicy {
override suspend fun check(op: HttpAccessOp, ctx: AccessContext): AccessDecision = override suspend fun check(op: HttpAccessOp, ctx: AccessContext): AccessDecision =
when (op) { when (op) {
is HttpAccessOp.Request -> 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) AccessDecision(Decision.Allow)
else 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 #### Platform support
- **JVM:** supported - **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

View File

@ -1,6 +1,6 @@
### lyng.io.net — TCP and UDP sockets for Lyng scripts ### 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. > **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 #### Platform support
- **JVM:** supported - **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 - **Other targets:** currently report unsupported; use capability checks before relying on raw sockets

View File

@ -107,9 +107,42 @@ The module uses `WsAccessPolicy` to authorize websocket operations.
- `WsAccessOp.Send(url, bytes, isText)` - `WsAccessOp.Send(url, bytes, isText)`
- `WsAccessOp.Receive(url)` - `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 #### Platform support
- **JVM:** supported - **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

View File

@ -34,6 +34,10 @@ okio-nodefilesystem = { module = "com.squareup.okio:okio-nodefilesystem", versio
compiler = { group = "androidx.databinding", name = "compiler", version.ref = "compiler" } compiler = { group = "androidx.databinding", name = "compiler", version.ref = "compiler" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 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-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-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" } ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" }

View File

@ -81,28 +81,40 @@ kotlin {
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
api(libs.mordant.core) api(libs.mordant.core)
api(libs.ktor.client.core) api(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.websockets) implementation(libs.ktor.client.websockets)
} }
} }
val nativeMain by creating { val nativeMain by creating {
dependsOn(commonMain) dependsOn(commonMain)
} }
val iosMain by creating { val darwinMain by creating {
dependsOn(nativeMain) dependsOn(nativeMain)
dependencies {
implementation(libs.ktor.client.darwin)
}
}
val iosMain by creating {
dependsOn(darwinMain)
} }
val linuxMain by creating { val linuxMain by creating {
dependsOn(nativeMain) dependsOn(nativeMain)
dependencies {
implementation(libs.ktor.client.curl)
}
} }
val macosMain by creating { val macosMain by creating {
dependsOn(nativeMain) dependsOn(darwinMain)
} }
val mingwMain by creating { val mingwMain by creating {
dependsOn(nativeMain) dependsOn(nativeMain)
dependencies {
implementation(libs.ktor.client.winhttp)
}
} }
val commonTest by getting { val commonTest by getting {
dependencies { dependencies {
implementation(libs.kotlin.test) implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
} }
} }
val iosX64Main by getting { dependsOn(iosMain) } val iosX64Main by getting { dependsOn(iosMain) }
@ -119,6 +131,13 @@ kotlin {
api(libs.okio) api(libs.okio)
implementation(libs.okio.fakefilesystem) implementation(libs.okio.fakefilesystem)
implementation("com.squareup.okio:okio-nodefilesystem:${libs.versions.okioVersion.get()}") 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 { val jvmMain by getting {
@ -126,6 +145,7 @@ kotlin {
implementation(libs.mordant.jvm.jna) implementation(libs.mordant.jvm.jna)
implementation("org.jline:jline-reader:3.29.0") implementation("org.jline:jline-reader:3.29.0")
implementation("org.jline:jline-terminal:3.29.0") implementation("org.jline:jline-terminal:3.29.0")
implementation(libs.ktor.client.cio)
implementation(libs.ktor.network) implementation(libs.ktor.network)
} }
} }

View File

@ -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<ByteArray>(),
)
}
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)
}
}
}

View File

@ -1,3 +1,206 @@
package net.sergeych.lyngio.net 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<LyngSocketAddress> = 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,
)

View File

@ -1,3 +1,92 @@
package net.sergeych.lyngio.ws 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<String, String>): 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")
}
}

View File

@ -332,7 +332,7 @@ private class ObjHttpResponse(
fun from(response: LyngHttpResponse): ObjHttpResponse { fun from(response: LyngHttpResponse): ObjHttpResponse {
val single = linkedMapOf<String, String>() val single = linkedMapOf<String, String>()
response.headers.forEach { (name, values) -> 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( return ObjHttpResponse(
status = response.status.toLong(), status = response.status.toLong(),

View File

@ -17,17 +17,6 @@
package net.sergeych.lyngio.http 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( data class LyngHttpRequest(
val method: String, val method: String,
val url: String, val url: String,
@ -49,7 +38,7 @@ interface LyngHttpEngine {
suspend fun request(request: LyngHttpRequest): LyngHttpResponse suspend fun request(request: LyngHttpRequest): LyngHttpResponse
} }
private object UnsupportedHttpEngine : LyngHttpEngine { internal object UnsupportedHttpEngine : LyngHttpEngine {
override val isSupported: Boolean = false override val isSupported: Boolean = false
override suspend fun request(request: LyngHttpRequest): LyngHttpResponse { override suspend fun request(request: LyngHttpRequest): LyngHttpResponse {
@ -57,48 +46,4 @@ private object UnsupportedHttpEngine : LyngHttpEngine {
} }
} }
private object KtorLyngHttpEngine : LyngHttpEngine { expect fun getSystemHttpEngine(): 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<ByteArray>(),
)
}
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

View File

@ -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<ByteArray>(),
)
}
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)
}
}
}

View File

@ -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<String, String>): 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")
}
}

View File

@ -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<ByteArray>(),
)
}
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)
}
}
}

View File

@ -1,3 +1,422 @@
@file:Suppress("UnsafeCastFromDynamic", "SpellCheckingInspection")
package net.sergeych.lyngio.net 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<LyngSocketAddress> {
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<String>() ?: "DNS lookup failed"))
return@lookup
}
val addresses = mutableListOf<LyngSocketAddress>()
val items = result.unsafeCast<Array<dynamic>>()
for (item in items) {
val address = item.address?.unsafeCast<String>() ?: continue
val itemFamily = item.family?.unsafeCast<Int>() ?: 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<dynamic> { 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<String>() ?: "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<LyngTcpSocket>(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<Unit> { cont ->
server.once("error", { error: dynamic ->
if (cont.isActive) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "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<LyngDatagram>(Channel.UNLIMITED)
socket.on("message", { msg: dynamic, rinfo: dynamic ->
incoming.trySend(
LyngDatagram(
data = dynamicToByteArray(msg),
address = rinfoToAddress(rinfo),
)
)
})
socket.on("error", { _: dynamic -> })
suspendCancellableCoroutine<Unit> { cont ->
socket.once("error", { error: dynamic ->
if (cont.isActive) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "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<ByteArray?>(Channel.UNLIMITED)
private val buffered = ArrayDeque<Byte>()
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<String>() ?: "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<String>() ?: "0.0.0.0",
port = socket.localPort?.unsafeCast<Int>() ?: 0,
family = socket.localFamily,
resolved = true,
)
override fun remoteAddress(): LyngSocketAddress = socketAddress(
host = socket.remoteAddress?.unsafeCast<String>() ?: "0.0.0.0",
port = socket.remotePort?.unsafeCast<Int>() ?: 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<Unit> { cont ->
socket.write(byteArrayToUint8Array(data), { error: dynamic ->
if (!cont.isActive) return@write
if (error != null) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "TCP write failed"))
} else {
cont.resume(Unit)
}
})
}
}
override suspend fun writeUtf8(text: String) {
ensureOpen()
suspendCancellableCoroutine<Unit> { cont ->
socket.write(text, "utf8", { error: dynamic ->
if (!cont.isActive) return@write
if (error != null) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "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<Byte> = 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<LyngTcpSocket>,
) : 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<String>() ?: "0.0.0.0",
port = info.port?.unsafeCast<Int>() ?: 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<LyngDatagram>,
) : 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<Unit> { cont ->
socket.send(byteArrayToUint8Array(data), port, host, { error: dynamic ->
if (!cont.isActive) return@send
if (error != null) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "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<Unit> { cont ->
target.once("error", { error: dynamic ->
if (cont.isActive) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "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<String>() ?: "0.0.0.0",
port = rinfo.port?.unsafeCast<Int>() ?: 0,
family = rinfo.family,
resolved = true,
)
private fun isNodeRuntime(): Boolean = js(
"""
typeof process !== "undefined" &&
process != null &&
process.versions != null &&
process.versions.node != null
"""
).unsafeCast<Boolean>()
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<Uint8Array>()
val size = source.length
return ByteArray(size) { index -> source.asDynamic()[index].unsafeCast<Byte>() }
}
private fun byteArrayToUint8Array(value: ByteArray): Uint8Array {
val out = Uint8Array(value.size)
value.forEachIndexed { index, byte -> out.asDynamic()[index] = byte.toInt() and 0xff }
return out
}

View File

@ -1,3 +1,91 @@
package net.sergeych.lyngio.ws 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<String, String>): 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")
}
}

View File

@ -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<ExecutionError> {
Compiler.compile(code).execute(scope)
}
assertTrue(error.errorMessage.isNotBlank())
}
}

View File

@ -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)
}
}

View File

@ -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")
}
}

View File

@ -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<ByteArray>(),
)
}
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)
}
}
}

View File

@ -19,10 +19,13 @@ package net.sergeych.lyng.io.http
import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer import com.sun.net.httpserver.HttpServer
import com.sun.net.httpserver.HttpsConfigurator
import com.sun.net.httpserver.HttpsServer
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler import net.sergeych.lyng.Compiler
import net.sergeych.lyng.ExecutionError import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Script 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.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision 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 @Test
fun testPolicyDenialSurfacesAsLyngError() = runBlocking { fun testPolicyDenialSurfacesAsLyngError() = runBlocking {
val scope = Script.newScope() val scope = Script.newScope()
@ -121,8 +151,14 @@ class LyngHttpModuleTest {
assertTrue(error.errorMessage.isNotBlank()) assertTrue(error.errorMessage.isNotBlank())
} }
private fun newServer(handler: (HttpExchange) -> Unit): HttpServer { private fun newServer(secure: Boolean = false, handler: (HttpExchange) -> Unit): HttpServer {
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) 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 -> server.createContext("/") { exchange ->
handler(exchange) handler(exchange)
} }

View File

@ -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<ByteArray>(),
)
}
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)
}
}
}

View File

@ -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<String, String>): 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")
}
}

View File

@ -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<ByteArray>(),
)
}
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)
}
}
}

View File

@ -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<String, String>): 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")
}
}

View File

@ -1,3 +0,0 @@
package net.sergeych.lyngio.ws
actual fun getSystemWsEngine(): LyngWsEngine = UnsupportedLyngWsEngine