Add KMP networking backends
This commit is contained in:
parent
d409a4bb8b
commit
5346d15a9f
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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" }
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +0,0 @@
|
|||||||
package net.sergeych.lyngio.ws
|
|
||||||
|
|
||||||
actual fun getSystemWsEngine(): LyngWsEngine = UnsupportedLyngWsEngine
|
|
||||||
Loading…
x
Reference in New Issue
Block a user