From d409a4bb8bc80fe8480a373d5e55ca369df03455 Mon Sep 17 00:00:00 2001 From: sergeych Date: Thu, 2 Apr 2026 16:19:14 +0300 Subject: [PATCH] Add verified Ktor-backed networking modules to lyngio --- docs/ai_stdlib_reference.md | 5 +- docs/lyng.io.http.md | 169 +++++ docs/lyng.io.net.md | 164 +++++ docs/lyng.io.ws.md | 115 ++++ docs/lyngio.md | 40 +- gradle/libs.versions.toml | 5 + lyngio/build.gradle.kts | 39 +- .../sergeych/lyngio/net/PlatformAndroid.kt | 3 + .../net/sergeych/lyngio/ws/PlatformAndroid.kt | 3 + .../sergeych/lyng/io/http/LyngHttpModule.kt | 388 ++++++++++++ .../net/sergeych/lyng/io/net/LyngNetModule.kt | 376 ++++++++++++ .../net/sergeych/lyng/io/ws/LyngWsModule.kt | 199 ++++++ .../net/sergeych/lyngio/http/LyngHttp.kt | 104 ++++ .../lyngio/http/security/HttpAccessPolicy.kt | 45 ++ .../kotlin/net/sergeych/lyngio/net/LyngNet.kt | 99 +++ .../lyngio/net/security/NetAccessPolicy.kt | 48 ++ .../kotlin/net/sergeych/lyngio/ws/LyngWs.kt | 48 ++ .../lyngio/ws/security/WsAccessPolicy.kt | 47 ++ .../net/sergeych/lyngio/net/PlatformJs.kt | 3 + .../net/sergeych/lyngio/ws/PlatformJs.kt | 3 + .../net/sergeych/lyngio/net/PlatformJvm.kt | 223 +++++++ .../net/sergeych/lyngio/ws/PlatformJvm.kt | 110 ++++ lyngio/src/jvmTest/kotlin/LyngioBookTest.kt | 256 ++++++++ .../lyng/io/http/LyngHttpModuleTest.kt | 140 +++++ .../sergeych/lyng/io/net/LyngNetModuleTest.kt | 168 +++++ .../lyng/io/testtls/TlsTestMaterial.kt | 123 ++++ .../sergeych/lyng/io/ws/LyngWsModuleTest.kt | 135 ++++ .../lyng/io/ws/TestWebSocketServer.kt | 180 ++++++ .../net/sergeych/lyngio/net/PlatformNative.kt | 3 + .../net/sergeych/lyngio/ws/PlatformNative.kt | 3 + lyngio/stdlib/lyng/io/http.lyng | 72 +++ lyngio/stdlib/lyng/io/net.lyng | 113 ++++ lyngio/stdlib/lyng/io/ws.lyng | 40 ++ lynglib/build.gradle.kts | 2 +- proposals/lyngio_network_module.md | 577 ++++++++++++++++++ 35 files changed, 4025 insertions(+), 23 deletions(-) create mode 100644 docs/lyng.io.http.md create mode 100644 docs/lyng.io.net.md create mode 100644 docs/lyng.io.ws.md create mode 100644 lyngio/src/androidMain/kotlin/net/sergeych/lyngio/net/PlatformAndroid.kt create mode 100644 lyngio/src/androidMain/kotlin/net/sergeych/lyngio/ws/PlatformAndroid.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/net/LyngNetModule.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/ws/LyngWsModule.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/security/HttpAccessPolicy.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/net/LyngNet.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/net/security/NetAccessPolicy.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/LyngWs.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/security/WsAccessPolicy.kt create mode 100644 lyngio/src/jsMain/kotlin/net/sergeych/lyngio/net/PlatformJs.kt create mode 100644 lyngio/src/jsMain/kotlin/net/sergeych/lyngio/ws/PlatformJs.kt create mode 100644 lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/net/PlatformJvm.kt create mode 100644 lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/ws/PlatformJvm.kt create mode 100644 lyngio/src/jvmTest/kotlin/LyngioBookTest.kt create mode 100644 lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/LyngHttpModuleTest.kt create mode 100644 lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleTest.kt create mode 100644 lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/testtls/TlsTestMaterial.kt create mode 100644 lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/ws/LyngWsModuleTest.kt create mode 100644 lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/ws/TestWebSocketServer.kt create mode 100644 lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/net/PlatformNative.kt create mode 100644 lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/ws/PlatformNative.kt create mode 100644 lyngio/stdlib/lyng/io/http.lyng create mode 100644 lyngio/stdlib/lyng/io/net.lyng create mode 100644 lyngio/stdlib/lyng/io/ws.lyng create mode 100644 proposals/lyngio_network_module.md diff --git a/docs/ai_stdlib_reference.md b/docs/ai_stdlib_reference.md index 4f150bd..d699fa3 100644 --- a/docs/ai_stdlib_reference.md +++ b/docs/ai_stdlib_reference.md @@ -82,8 +82,11 @@ Requires installing `lyngio` into the import manager from host code. - `import lyng.io.fs` (filesystem `Path` API) - `import lyng.io.process` (process execution API) - `import lyng.io.console` (console capabilities, geometry, ANSI/output, events) +- `import lyng.io.http` (HTTP/HTTPS client API) +- `import lyng.io.ws` (WebSocket client API; currently supported on JVM, capability-gated elsewhere) +- `import lyng.io.net` (TCP/UDP transport API; currently supported on JVM, capability-gated elsewhere) ## 7. AI Generation Tips - Assume `lyng.stdlib` APIs exist in regular script contexts. -- For platform-sensitive code (`fs`, `process`, `console`), gate assumptions and mention required module install. +- For platform-sensitive code (`fs`, `process`, `console`, `http`, `ws`, `net`), gate assumptions and mention required module install. - Prefer extension-method style (`items.filter { ... }`) and standard scope helpers (`let`/`also`/`apply`/`run`). diff --git a/docs/lyng.io.http.md b/docs/lyng.io.http.md new file mode 100644 index 0000000..7b67ac4 --- /dev/null +++ b/docs/lyng.io.http.md @@ -0,0 +1,169 @@ +### lyng.io.http — HTTP/HTTPS client for Lyng scripts + +This module provides a compact HTTP client API for Lyng scripts. It is implemented in `lyngio` and backed by Ktor on supported 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. + +--- + +#### Add the library to your project (Gradle) + +If you use this repository as a multi-module project, add a dependency on `:lyngio`: + +```kotlin +dependencies { + implementation("net.sergeych:lyngio:0.0.1-SNAPSHOT") +} +``` + +For external projects, ensure you also use the Lyng Maven repository described in `lyng.io.fs`. + +--- + +#### Install the module into a Lyng session + +The HTTP module is not installed automatically. Install it into the session scope and provide a policy. + +Kotlin (host) bootstrap example: + +```kotlin +import net.sergeych.lyng.EvalSession +import net.sergeych.lyng.Scope +import net.sergeych.lyng.io.http.createHttpModule +import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy + +suspend fun bootstrapHttp() { + val session = EvalSession() + val scope: Scope = session.getScope() + createHttpModule(PermitAllHttpAccessPolicy, scope) + session.eval("import lyng.io.http") +} +``` + +--- + +#### Using from Lyng scripts + +Simple GET: + + import lyng.io.http + + val r = Http.get(HTTP_TEST_URL + "/hello") + [r.status, r.text()] + >>> [200,hello from test] + +Headers and response header access: + + import lyng.io.http + + val r = Http.get(HTTP_TEST_URL + "/headers") + [r.headers["X-Reply"], r.headers.getAll("X-Reply").size, r.text()] + >>> [one,2,header demo] + +Programmatic request object: + + import lyng.io.http + + val q = HttpRequest() + q.method = "POST" + q.url = HTTP_TEST_URL + "/echo" + q.headers = Map("Content-Type" => "text/plain") + q.bodyText = "ping" + + val r = Http.request(q) + r.text() + >>> "POST:ping" + +HTTPS GET: + + import lyng.io.http + + val r = Http.get(HTTPS_TEST_URL + "/hello") + [r.status, r.text()] + >>> [200,hello from test] + +--- + +#### API reference + +##### `Http` (static methods) + +- `isSupported(): Bool` — Whether HTTP client support is available on the current runtime. +- `request(req: HttpRequest): HttpResponse` — Execute a request described by a mutable request object. +- `get(url: String, headers...): HttpResponse` — Convenience GET request. +- `post(url: String, bodyText: String = "", contentType: String? = null, headers...): HttpResponse` — Convenience text POST request. +- `postBytes(url: String, body: Buffer, contentType: String? = null, headers...): HttpResponse` — Convenience binary POST request. + +For convenience methods, `headers...` accepts: + +- `MapEntry`, e.g. `"Accept" => "text/plain"` +- 2-item lists, e.g. `["Accept", "text/plain"]` + +##### `HttpRequest` + +- `method: String` +- `url: String` +- `headers: Map` +- `bodyText: String?` +- `bodyBytes: Buffer?` +- `timeoutMillis: Int?` + +Only one of `bodyText` and `bodyBytes` should be set. + +##### `HttpResponse` + +- `status: Int` +- `statusText: String` +- `headers: HttpHeaders` +- `text(): String` +- `bytes(): Buffer` + +Response body decoding is cached inside the response object. + +##### `HttpHeaders` + +`HttpHeaders` behaves like `Map` for the first value of each header name and additionally exposes: + +- `get(name: String): String?` +- `getAll(name: String): List` +- `names(): List` + +Header lookup is case-insensitive. + +--- + +#### Security policy + +The module uses `HttpAccessPolicy` to authorize requests before they are sent. + +- `HttpAccessPolicy` — interface for custom policies +- `PermitAllHttpAccessPolicy` — allows all requests +- `HttpAccessOp.Request(method, url)` — operation checked by the policy + +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.http.security.HttpAccessOp +import net.sergeych.lyngio.http.security.HttpAccessPolicy + +val allowLocalOnly = object : HttpAccessPolicy { + override suspend fun check(op: HttpAccessOp, ctx: AccessContext): AccessDecision = + when (op) { + is HttpAccessOp.Request -> + if (op.url.startsWith("http://127.0.0.1:") || op.url.startsWith("http://localhost:")) + AccessDecision(Decision.Allow) + else + AccessDecision(Decision.Deny, "only local HTTP requests are allowed") + } +} +``` + +--- + +#### Platform support + +- **JVM:** supported +- **Other targets:** implementation may be added later; use `Http.isSupported()` before relying on it diff --git a/docs/lyng.io.net.md b/docs/lyng.io.net.md new file mode 100644 index 0000000..600d0d2 --- /dev/null +++ b/docs/lyng.io.net.md @@ -0,0 +1,164 @@ +### 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. + +> **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. + +--- + +#### Install the module into a Lyng session + +Kotlin (host) bootstrap example: + +```kotlin +import net.sergeych.lyng.EvalSession +import net.sergeych.lyng.Scope +import net.sergeych.lyng.io.net.createNetModule +import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy + +suspend fun bootstrapNet() { + val session = EvalSession() + val scope: Scope = session.getScope() + createNetModule(PermitAllNetAccessPolicy, scope) + session.eval("import lyng.io.net") +} +``` + +--- + +#### Using from Lyng scripts + +Capability checks and address resolution: + + import lyng.io.net + + val a: SocketAddress = Net.resolve("127.0.0.1", 4040)[0] + [Net.isSupported(), a.toString(), a.resolved, a.ipVersion == IpVersion.IPV4] + >>> [true,127.0.0.1:4040,true,true] + +TCP client connect, write, read, and close: + + import lyng.buffer + import lyng.io.net + + val socket = Net.tcpConnect("127.0.0.1", NET_TEST_TCP_PORT) + socket.writeUtf8("ping") + socket.flush() + val reply = (socket.read(16) as Buffer).decodeUtf8() + socket.close() + reply + >>> "reply:ping" + +Lyng TCP server socket operations with `tcpListen()` and `accept()`: + + import lyng.buffer + import lyng.io.net + + val server = Net.tcpListen(0, "127.0.0.1") + val port = server.localAddress().port + val accepted = launch { + val client = server.accept() + val line = (client.read(4) as Buffer).decodeUtf8() + client.writeUtf8("echo:" + line) + client.flush() + client.close() + server.close() + line + } + + val socket = Net.tcpConnect("127.0.0.1", port) + socket.writeUtf8("ping") + socket.flush() + val reply = (socket.read(16) as Buffer).decodeUtf8() + socket.close() + [accepted.await(), reply] + >>> [ping,echo:ping] + +UDP bind, send, receive, and inspect sender address: + + 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] + >>> [ping,true] + +--- + +#### API reference + +##### `Net` (static methods) + +- `isSupported(): Bool` — Whether any raw networking support is available. +- `isTcpAvailable(): Bool` — Whether outbound TCP sockets are available. +- `isTcpServerAvailable(): Bool` — Whether listening TCP server sockets are available. +- `isUdpAvailable(): Bool` — Whether UDP datagram sockets are available. +- `resolve(host: String, port: Int): List` — Resolve a host and port into concrete addresses. +- `tcpConnect(host: String, port: Int, timeoutMillis: Int? = null, noDelay: Bool = true): TcpSocket` — Open an outbound TCP socket. +- `tcpListen(port: Int, host: String? = null, backlog: Int = 128, reuseAddress: Bool = true): TcpServer` — Start a listening TCP server socket. +- `udpBind(port: Int = 0, host: String? = null, reuseAddress: Bool = true): UdpSocket` — Bind a UDP socket. + +##### `SocketAddress` + +- `host: String` +- `port: Int` +- `ipVersion: IpVersion` +- `resolved: Bool` +- `toString(): String` + +##### `TcpSocket` + +- `isOpen(): Bool` +- `localAddress(): SocketAddress` +- `remoteAddress(): SocketAddress` +- `read(maxBytes: Int = 65536): Buffer?` +- `readLine(): String?` +- `write(data: Buffer): void` +- `writeUtf8(text: String): void` +- `flush(): void` +- `close(): void` + +##### `TcpServer` + +- `isOpen(): Bool` +- `localAddress(): SocketAddress` +- `accept(): TcpSocket` +- `close(): void` + +##### `UdpSocket` + +- `isOpen(): Bool` +- `localAddress(): SocketAddress` +- `receive(maxBytes: Int = 65536): Datagram?` +- `send(data: Buffer, host: String, port: Int): void` +- `close(): void` + +##### `Datagram` + +- `data: Buffer` +- `address: SocketAddress` + +--- + +#### Security policy + +The module uses `NetAccessPolicy` to authorize network operations before they are executed. + +- `NetAccessPolicy` — interface for custom policies +- `PermitAllNetAccessPolicy` — allows all network operations +- `NetAccessOp.Resolve(host, port)` +- `NetAccessOp.TcpConnect(host, port)` +- `NetAccessOp.TcpListen(host, port, backlog)` +- `NetAccessOp.UdpBind(host, port)` + +--- + +#### Platform support + +- **JVM:** supported +- **Other targets:** currently report unsupported; use capability checks before relying on raw sockets diff --git a/docs/lyng.io.ws.md b/docs/lyng.io.ws.md new file mode 100644 index 0000000..9d10f45 --- /dev/null +++ b/docs/lyng.io.ws.md @@ -0,0 +1,115 @@ +### lyng.io.ws — WebSocket client for Lyng scripts + +This module provides a compact WebSocket client API for Lyng scripts. It is implemented in `lyngio` and currently backed by Ktor WebSockets on the JVM. + +> **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. + +--- + +#### Install the module into a Lyng session + +Kotlin (host) bootstrap example: + +```kotlin +import net.sergeych.lyng.EvalSession +import net.sergeych.lyng.Scope +import net.sergeych.lyng.io.ws.createWsModule +import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy + +suspend fun bootstrapWs() { + val session = EvalSession() + val scope: Scope = session.getScope() + createWsModule(PermitAllWsAccessPolicy, scope) + session.eval("import lyng.io.ws") +} +``` + +--- + +#### Using from Lyng scripts + +Simple text message exchange: + + import lyng.io.ws + + val ws = Ws.connect(WS_TEST_URL) + ws.sendText("ping") + val m: WsMessage = ws.receive() + ws.close() + [ws.url() == WS_TEST_URL, m.isText, m.text] + >>> [true,true,echo:ping] + +Binary message exchange: + + import lyng.buffer + import lyng.io.ws + + val ws = Ws.connect(WS_TEST_BINARY_URL) + ws.sendBytes(Buffer(9, 8, 7)) + val m: WsMessage = ws.receive() + ws.close() + [m.isText, (m.data as Buffer).hex] + >>> [false,010203090807] + +Secure websocket (`wss`) exchange: + + import lyng.io.ws + + val ws = Ws.connect(WSS_TEST_URL) + ws.sendText("ping") + val m: WsMessage = ws.receive() + ws.close() + [ws.url() == WSS_TEST_URL, m.text] + >>> [true,secure:ping] + +--- + +#### API reference + +##### `Ws` (static methods) + +- `isSupported(): Bool` — Whether WebSocket client support is available on the current runtime. +- `connect(url: String, headers...): WsSession` — Open a client websocket session. + +`headers...` accepts: + +- `MapEntry`, e.g. `"Authorization" => "Bearer x"` +- 2-item lists, e.g. `["Authorization", "Bearer x"]` + +##### `WsSession` + +- `isOpen(): Bool` +- `url(): String` +- `sendText(text: String): void` +- `sendBytes(data: Buffer): void` +- `receive(): WsMessage?` +- `close(code: Int = 1000, reason: String = ""): void` + +`receive()` returns `null` after a clean close. + +##### `WsMessage` + +- `isText: Bool` +- `text: String?` +- `data: Buffer?` + +Text messages populate `text`; binary messages populate `data`. + +--- + +#### Security policy + +The module uses `WsAccessPolicy` to authorize websocket operations. + +- `WsAccessPolicy` — interface for custom policies +- `PermitAllWsAccessPolicy` — allows all websocket operations +- `WsAccessOp.Connect(url)` +- `WsAccessOp.Send(url, bytes, isText)` +- `WsAccessOp.Receive(url)` + +--- + +#### Platform support + +- **JVM:** supported +- **Other targets:** currently report unsupported; use `Ws.isSupported()` before relying on websocket client access diff --git a/docs/lyngio.md b/docs/lyngio.md index d1f5917..14b491e 100644 --- a/docs/lyngio.md +++ b/docs/lyngio.md @@ -13,6 +13,9 @@ - **[lyng.io.fs](lyng.io.fs.md):** Async filesystem access. Provides the `Path` class for file/directory operations, streaming, and globbing. - **[lyng.io.process](lyng.io.process.md):** External process execution and shell commands. Provides `Process`, `RunningProcess`, and `Platform` information. - **[lyng.io.console](lyng.io.console.md):** Rich console/TTY access. Provides `Console` capability detection, geometry, output, and iterable events. +- **[lyng.io.http](lyng.io.http.md):** HTTP/HTTPS client access. Provides `Http`, `HttpRequest`, `HttpResponse`, and `HttpHeaders`. +- **[lyng.io.ws](lyng.io.ws.md):** WebSocket client access. Provides `Ws`, `WsSession`, and `WsMessage`. +- **[lyng.io.net](lyng.io.net.md):** Transport networking. Provides `Net`, `TcpSocket`, `TcpServer`, `UdpSocket`, and `SocketAddress`. --- @@ -41,9 +44,15 @@ import net.sergeych.lyng.EvalSession import net.sergeych.lyng.io.fs.createFs import net.sergeych.lyng.io.process.createProcessModule import net.sergeych.lyng.io.console.createConsoleModule +import net.sergeych.lyng.io.http.createHttpModule +import net.sergeych.lyng.io.net.createNetModule +import net.sergeych.lyng.io.ws.createWsModule import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy +import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy +import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy +import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy suspend fun runMyScript() { val session = EvalSession() @@ -53,16 +62,25 @@ suspend fun runMyScript() { createFs(PermitAllAccessPolicy, scope) createProcessModule(PermitAllProcessAccessPolicy, scope) createConsoleModule(PermitAllConsoleAccessPolicy, scope) + createHttpModule(PermitAllHttpAccessPolicy, scope) + createNetModule(PermitAllNetAccessPolicy, scope) + createWsModule(PermitAllWsAccessPolicy, scope) // Now scripts can import them session.eval(""" import lyng.io.fs import lyng.io.process import lyng.io.console + import lyng.io.http + import lyng.io.net + import lyng.io.ws println("Working dir: " + Path(".").readUtf8()) println("OS: " + Platform.details().name) println("TTY: " + Console.isTty()) + println("HTTP available: " + Http.isSupported()) + println("TCP available: " + Net.isTcpAvailable()) + println("WS available: " + Ws.isSupported()) """) } ``` @@ -76,21 +94,27 @@ suspend fun runMyScript() { - **Filesystem Security:** Implement `FsAccessPolicy` to restrict access to specific paths or operations (e.g., read-only access to a sandbox directory). - **Process Security:** Implement `ProcessAccessPolicy` to restrict which executables can be run or to disable shell execution entirely. - **Console Security:** Implement `ConsoleAccessPolicy` to control output writes, event reads, and raw mode switching. +- **HTTP Security:** Implement `HttpAccessPolicy` to restrict which requests scripts may send. +- **Transport Security:** Implement `NetAccessPolicy` to restrict DNS resolution and TCP/UDP socket operations. +- **WebSocket Security:** Implement `WsAccessPolicy` to restrict websocket connects and message flow. For more details, see the specific module documentation: - [Filesystem Security Details](lyng.io.fs.md#access-policy-security) - [Process Security Details](lyng.io.process.md#security-policy) - [Console Module Details](lyng.io.console.md) +- [HTTP Module Details](lyng.io.http.md) +- [Transport Networking Details](lyng.io.net.md) +- [WebSocket Module Details](lyng.io.ws.md) --- #### Platform Support Overview -| Platform | lyng.io.fs | lyng.io.process | lyng.io.console | -| :--- | :---: | :---: | :---: | -| **JVM** | ✅ | ✅ | ✅ (baseline) | -| **Native (Linux/macOS)** | ✅ | ✅ | 🚧 | -| **Native (Windows)** | ✅ | 🚧 (Planned) | 🚧 | -| **Android** | ✅ | ❌ | ❌ | -| **NodeJS** | ✅ | ❌ | ❌ | -| **Browser / Wasm** | ✅ (In-memory) | ❌ | ❌ | +| Platform | lyng.io.fs | lyng.io.process | lyng.io.console | lyng.io.http | lyng.io.ws | lyng.io.net | +| :--- | :---: | :---: | :---: | :---: | :---: | :---: | +| **JVM** | ✅ | ✅ | ✅ (baseline) | ✅ | ✅ | ✅ | +| **Native (Linux/macOS)** | ✅ | ✅ | 🚧 | 🚧 | 🚧 | 🚧 | +| **Native (Windows)** | ✅ | 🚧 (Planned) | 🚧 | 🚧 | 🚧 | 🚧 | +| **Android** | ✅ | ❌ | ❌ | 🚧 | 🚧 | 🚧 | +| **NodeJS** | ✅ | ❌ | ❌ | 🚧 | 🚧 | 🚧 | +| **Browser / Wasm** | ✅ (In-memory) | ❌ | ❌ | 🚧 | 🚧 | 🚧 | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b4c687..084e6cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ multik = "0.3.0" firebaseCrashlyticsBuildtools = "3.0.3" okioVersion = "3.10.2" compiler = "3.2.0-alpha11" +ktor = "3.3.1" [libraries] clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } @@ -31,6 +32,10 @@ okio = { module = "com.squareup.okio:okio", version.ref = "okioVersion" } okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okioVersion" } okio-nodefilesystem = { module = "com.squareup.okio:okio-nodefilesystem", version.ref = "okioVersion" } compiler = { group = "androidx.databinding", name = "compiler", version.ref = "compiler" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } +ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" } [plugins] androidLibrary = { id = "com.android.library", version.ref = "agp" } diff --git a/lyngio/build.gradle.kts b/lyngio/build.gradle.kts index a35b45a..38205e2 100644 --- a/lyngio/build.gradle.kts +++ b/lyngio/build.gradle.kts @@ -80,6 +80,9 @@ kotlin { api(libs.okio) api(libs.kotlinx.coroutines.core) api(libs.mordant.core) + api(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.websockets) } } val nativeMain by creating { @@ -123,6 +126,7 @@ kotlin { implementation(libs.mordant.jvm.jna) implementation("org.jline:jline-reader:3.29.0") implementation("org.jline:jline-terminal:3.29.0") + implementation(libs.ktor.network) } } // // For Wasm we use in-memory VFS for now @@ -135,10 +139,10 @@ kotlin { } } -abstract class GenerateLyngioConsoleDecls : DefaultTask() { - @get:InputFile +abstract class GenerateLyngioDecls : DefaultTask() { + @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val sourceFile: RegularFileProperty + abstract val sourceDir: DirectoryProperty @get:OutputDirectory abstract val outputDir: DirectoryProperty @@ -148,9 +152,9 @@ abstract class GenerateLyngioConsoleDecls : DefaultTask() { val targetPkg = "net.sergeych.lyngio.stdlib_included" val pkgPath = targetPkg.replace('.', '/') val targetDir = outputDir.get().asFile.resolve(pkgPath) + if (targetDir.exists()) targetDir.deleteRecursively() targetDir.mkdirs() - val text = sourceFile.get().asFile.readText() fun escapeForQuoted(s: String): String = buildString { for (ch in s) when (ch) { '\\' -> append("\\\\") @@ -165,30 +169,39 @@ abstract class GenerateLyngioConsoleDecls : DefaultTask() { val out = buildString { append("package ").append(targetPkg).append("\n\n") append("@Suppress(\"Unused\", \"MemberVisibilityCanBePrivate\")\n") - append("internal val consoleLyng = \"") - append(escapeForQuoted(text)) - append("\"\n") + sourceDir.get().asFile + .listFiles { file -> file.isFile && file.extension == "lyng" } + ?.sortedBy { it.name } + ?.forEach { file -> + val propertyName = buildString { + append(file.nameWithoutExtension) + append("Lyng") + } + append("internal val ").append(propertyName).append(" = \"") + append(escapeForQuoted(file.readText())) + append("\"\n") + } } - targetDir.resolve("console_types_lyng.generated.kt").writeText(out) + targetDir.resolve("lyngio_types_lyng.generated.kt").writeText(out) } } -val lyngioConsoleDeclsFile = layout.projectDirectory.file("stdlib/lyng/io/console.lyng") +val lyngioDeclsDir = layout.projectDirectory.dir("stdlib/lyng/io") val generatedLyngioDeclsDir = layout.buildDirectory.dir("generated/source/lyngioDecls/commonMain/kotlin") -val generateLyngioConsoleDecls by tasks.registering(GenerateLyngioConsoleDecls::class) { - sourceFile.set(lyngioConsoleDeclsFile) +val generateLyngioDecls by tasks.registering(GenerateLyngioDecls::class) { + sourceDir.set(lyngioDeclsDir) outputDir.set(generatedLyngioDeclsDir) } kotlin.sourceSets.named("commonMain") { - kotlin.srcDir(generateLyngioConsoleDecls) + kotlin.srcDir(generateLyngioDecls) } kotlin.targets.configureEach { compilations.configureEach { compileTaskProvider.configure { - dependsOn(generateLyngioConsoleDecls) + dependsOn(generateLyngioDecls) } } } diff --git a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/net/PlatformAndroid.kt b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/net/PlatformAndroid.kt new file mode 100644 index 0000000..7a10ec5 --- /dev/null +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/net/PlatformAndroid.kt @@ -0,0 +1,3 @@ +package net.sergeych.lyngio.net + +actual fun getSystemNetEngine(): LyngNetEngine = UnsupportedLyngNetEngine diff --git a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/ws/PlatformAndroid.kt b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/ws/PlatformAndroid.kt new file mode 100644 index 0000000..e9bc921 --- /dev/null +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/ws/PlatformAndroid.kt @@ -0,0 +1,3 @@ +package net.sergeych.lyngio.ws + +actual fun getSystemWsEngine(): LyngWsEngine = UnsupportedLyngWsEngine diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt new file mode 100644 index 0000000..a3a667b --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt @@ -0,0 +1,388 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.io.http + +import net.sergeych.lyng.ModuleScope +import net.sergeych.lyng.Scope +import net.sergeych.lyng.ScopeFacade +import net.sergeych.lyng.Source +import net.sergeych.lyng.obj.Obj +import net.sergeych.lyng.obj.ObjBool +import net.sergeych.lyng.obj.ObjBuffer +import net.sergeych.lyng.obj.ObjClass +import net.sergeych.lyng.obj.ObjImmutableMap +import net.sergeych.lyng.obj.ObjInt +import net.sergeych.lyng.obj.ObjList +import net.sergeych.lyng.obj.ObjMap +import net.sergeych.lyng.obj.ObjMapEntry +import net.sergeych.lyng.obj.ObjNull +import net.sergeych.lyng.obj.ObjString +import net.sergeych.lyng.obj.ObjVoid +import net.sergeych.lyng.obj.requiredArg +import net.sergeych.lyng.obj.thisAs +import net.sergeych.lyng.pacman.ImportManager +import net.sergeych.lyng.raiseIllegalOperation +import net.sergeych.lyng.requireNoArgs +import net.sergeych.lyng.requireScope +import net.sergeych.lyngio.http.LyngHttpRequest +import net.sergeych.lyngio.http.LyngHttpResponse +import net.sergeych.lyngio.http.getSystemHttpEngine +import net.sergeych.lyngio.http.security.HttpAccessDeniedException +import net.sergeych.lyngio.http.security.HttpAccessOp +import net.sergeych.lyngio.http.security.HttpAccessPolicy +import net.sergeych.lyngio.stdlib_included.httpLyng + +private const val HTTP_MODULE_NAME = "lyng.io.http" + +fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean = + createHttpModule(policy, scope.importManager) + +fun createHttp(policy: HttpAccessPolicy, scope: Scope): Boolean = createHttpModule(policy, scope) + +fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean { + if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false + manager.addPackage(HTTP_MODULE_NAME) { module -> + buildHttpModule(module, policy) + } + return true +} + +fun createHttp(policy: HttpAccessPolicy, manager: ImportManager): Boolean = createHttpModule(policy, manager) + +private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolicy) { + module.eval(Source(HTTP_MODULE_NAME, httpLyng)) + val engine = getSystemHttpEngine() + + val headersType = ObjHttpHeaders.type + val requestType = ObjHttpRequest.type + val responseType = ObjHttpResponse.type + + val httpType = object : ObjClass("Http") {} + httpType.addClassFn("isSupported") { + ObjBool(engine.isSupported) + } + httpType.addClassFn("request") { + httpGuard { + val req = requiredArg(0) + val built = req.toRequest(this) + policy.require(HttpAccessOp.Request(built.method, built.url)) + ObjHttpResponse.from(engine.request(built)) + } + } + httpType.addClassFn("get") { + httpGuard { + val url = requiredArg(0).value + val headers = parseHeaderEntries(args.list.drop(1)) + policy.require(HttpAccessOp.Request("GET", url)) + ObjHttpResponse.from(engine.request(LyngHttpRequest(method = "GET", url = url, headers = headers))) + } + } + httpType.addClassFn("post") { + httpGuard { + val url = requiredArg(0).value + val bodyText = requiredArg(1).value + val contentType = args.list.getOrNull(2)?.let { objOrNullToString(this, it) } + val headers = parseHeaderEntries(args.list.drop(3)).toMutableMap() + if (contentType != null && "Content-Type" !in headers) headers["Content-Type"] = contentType + policy.require(HttpAccessOp.Request("POST", url)) + ObjHttpResponse.from( + engine.request( + LyngHttpRequest(method = "POST", url = url, headers = headers, bodyText = bodyText) + ) + ) + } + } + httpType.addClassFn("postBytes") { + httpGuard { + val url = requiredArg(0).value + val body = requiredArg(1).byteArray.toByteArray() + val contentType = args.list.getOrNull(2)?.let { objOrNullToString(this, it) } + val headers = parseHeaderEntries(args.list.drop(3)).toMutableMap() + if (contentType != null && "Content-Type" !in headers) headers["Content-Type"] = contentType + policy.require(HttpAccessOp.Request("POST", url)) + ObjHttpResponse.from( + engine.request( + LyngHttpRequest(method = "POST", url = url, headers = headers, bodyBytes = body) + ) + ) + } + } + + module.addConst("Http", httpType) + module.addConst("HttpHeaders", headersType) + module.addConst("HttpRequest", requestType) + module.addConst("HttpResponse", responseType) +} + +private suspend inline fun ScopeFacade.httpGuard(crossinline block: suspend () -> Obj): Obj { + return try { + block() + } catch (e: HttpAccessDeniedException) { + raiseIllegalOperation(e.reasonDetail ?: "http access denied") + } catch (e: Exception) { + raiseIllegalOperation(e.message ?: "http error") + } +} + +private class ObjHttpHeaders( + singleValueHeaders: Map = emptyMap(), + private val allHeaders: Map> = emptyMap(), +) : Obj() { + private val entries: LinkedHashMap = + LinkedHashMap(singleValueHeaders.entries.associate { ObjString(it.key) to ObjString(it.value) }) + + override val objClass: ObjClass + get() = type + + override suspend fun getAt(scope: Scope, index: Obj): Obj = findEntry(index)?.value ?: ObjNull + + override suspend fun contains(scope: Scope, other: Obj): Boolean = findEntry(other) != null + + override suspend fun defaultToString(scope: Scope): ObjString { + val rendered = buildString { + append("HttpHeaders(") + var first = true + for ((k, v) in entries) { + if (!first) append(", ") + append(k.toString(scope).value) + append(" => ") + append(v.toString(scope).value) + first = false + } + append(")") + } + return ObjString(rendered) + } + + companion object { + val type = object : ObjClass("HttpHeaders", ObjMap.type) { + override suspend fun callOn(scope: Scope): Obj = ObjHttpHeaders() + }.apply { + addFn("get") { + val self = thisAs() + val name = requiredArg(0).value + self.firstValue(name)?.let(::ObjString) ?: ObjNull + } + addFn("getAll") { + val self = thisAs() + val name = requiredArg(0).value + ObjList(self.valuesOf(name).map(::ObjString).toMutableList()) + } + addFn("names") { + val self = thisAs() + ObjList(self.allHeaders.keys.map(::ObjString).toMutableList()) + } + addFn("getOrNull") { + val self = thisAs() + val name = requiredArg(0).value + self.firstValue(name)?.let(::ObjString) ?: ObjNull + } + addProperty("size", getter = { ObjInt(thisAs().entries.size.toLong()) }) + addProperty("keys", getter = { ObjList(thisAs().entries.keys.toMutableList()) }) + addProperty("values", getter = { ObjList(thisAs().entries.values.toMutableList()) }) + addFn("iterator") { + ObjList( + thisAs().entries.map { (k, v) -> ObjMapEntry(k, v) }.toMutableList() + ).invokeInstanceMethod(requireScope(), "iterator") + } + } + } + + private fun valuesOf(name: String): List = allHeaders[lookupKey(name)] ?: emptyList() + + private fun firstValue(name: String): String? = valuesOf(name).firstOrNull() + + private fun lookupKey(name: String): String = + allHeaders.keys.firstOrNull { it.equals(name, ignoreCase = true) } ?: name + + private fun findEntry(index: Obj): Map.Entry? { + if (index is ObjString) { + return entries.entries.firstOrNull { (k, _) -> + (k as? ObjString)?.value?.equals(index.value, ignoreCase = true) == true + } + } + return entries.entries.firstOrNull { it.key == index } + } +} + +private class ObjHttpRequest( + var method: String = "GET", + var url: String = "", + val headers: MutableMap = linkedMapOf(), + var bodyText: String? = null, + var bodyBytes: ByteArray? = null, + var timeoutMillis: Long? = null, +) : Obj() { + override val objClass: ObjClass + get() = type + + suspend fun toRequest(scope: ScopeFacade): LyngHttpRequest { + if (bodyText != null && bodyBytes != null) { + scope.raiseIllegalArgument("Only one of bodyText or bodyBytes may be set") + } + return LyngHttpRequest( + method = method, + url = url, + headers = LinkedHashMap(headers), + bodyText = bodyText, + bodyBytes = bodyBytes, + timeoutMillis = timeoutMillis, + ) + } + + companion object { + val type = object : ObjClass("HttpRequest") { + override suspend fun callOn(scope: Scope): Obj { + if (scope.args.list.isNotEmpty()) scope.raiseError("HttpRequest() does not accept arguments") + return ObjHttpRequest() + } + }.apply { + addProperty("method", + getter = { ObjString(thisAs().method) }, + setter = { value -> + thisAs().method = objOrNullToString(this, value) + ?: raiseIllegalArgument("method cannot be null") + } + ) + addProperty("url", + getter = { ObjString(thisAs().url) }, + setter = { value -> + thisAs().url = objOrNullToString(this, value) + ?: raiseIllegalArgument("url cannot be null") + } + ) + addProperty("headers", + getter = { thisAs().headers.toObjMap() }, + setter = { value -> + thisAs().headers.clear() + thisAs().headers.putAll(mapObjToStrings(this, value)) + } + ) + addProperty("bodyText", + getter = { thisAs().bodyText?.let(::ObjString) ?: ObjNull }, + setter = { value -> + thisAs().bodyText = objOrNullToString(this, value) + } + ) + addProperty("bodyBytes", + getter = { thisAs().bodyBytes?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull }, + setter = { value -> + thisAs().bodyBytes = when (value) { + ObjNull -> null + is ObjBuffer -> value.byteArray.toByteArray() + else -> raiseClassCastError("bodyBytes must be Buffer or null") + } + } + ) + addProperty("timeoutMillis", + getter = { thisAs().timeoutMillis?.let { ObjInt(it) } ?: ObjNull }, + setter = { value -> + thisAs().timeoutMillis = when (value) { + ObjNull -> null + is ObjInt -> value.value + else -> raiseClassCastError("timeoutMillis must be Int or null") + } + } + ) + } + } +} + +private class ObjHttpResponse( + val status: Long, + val statusText: String, + val headers: ObjHttpHeaders, + private val bodyBytes: ByteArray, +) : Obj() { + override val objClass: ObjClass + get() = type + + companion object { + val type = object : ObjClass("HttpResponse") { + override suspend fun callOn(scope: Scope): Obj { + scope.raiseError("HttpResponse cannot be created directly") + } + }.apply { + addProperty("status", getter = { ObjInt(thisAs().status) }) + addProperty("statusText", getter = { ObjString(thisAs().statusText) }) + addProperty("headers", getter = { thisAs().headers }) + addFn("text") { + ObjString(thisAs().bodyBytes.decodeToString()) + } + addFn("bytes") { + ObjBuffer(thisAs().bodyBytes.toUByteArray()) + } + } + + fun from(response: LyngHttpResponse): ObjHttpResponse { + val single = linkedMapOf() + response.headers.forEach { (name, values) -> + if (values.isNotEmpty()) single.putIfAbsent(name, values.first()) + } + return ObjHttpResponse( + status = response.status.toLong(), + statusText = response.statusText, + headers = ObjHttpHeaders(singleValueHeaders = single, allHeaders = response.headers), + bodyBytes = response.bodyBytes, + ) + } + } +} + +private suspend fun ScopeFacade.parseHeaderEntries(values: List): Map { + val out = linkedMapOf() + values.forEach { value -> + when (value) { + is ObjMapEntry -> { + out[toStringOf(value.key).value] = toStringOf(value.value).value + } + else -> { + if (!value.isInstanceOf(net.sergeych.lyng.obj.ObjArray)) { + raiseIllegalArgument("headers entries must be MapEntry or [key, value]") + } + val size = (value.invokeInstanceMethod(requireScope(), "size") as ObjInt).value.toInt() + if (size != 2) { + raiseIllegalArgument("header entry array must contain exactly 2 items") + } + out[toStringOf(value.getAt(requireScope(), ObjInt.Zero)).value] = + toStringOf(value.getAt(requireScope(), ObjInt.One)).value + } + } + } + return out +} + +private suspend fun mapObjToStrings(scope: ScopeFacade, value: Obj): MutableMap { + val entries = when (value) { + is ObjMap -> value.map + is ObjImmutableMap -> value.map + ObjNull -> return linkedMapOf() + else -> scope.raiseClassCastError("headers must be Map") + } + return entries.entries.associateTo(linkedMapOf()) { (k, v) -> + scope.toStringOf(k).value to scope.toStringOf(v).value + } +} + +private suspend fun objOrNullToString(scope: ScopeFacade, value: Obj): String? = when (value) { + ObjNull -> null + else -> scope.toStringOf(value).value +} + +private fun Map.toObjMap(): ObjMap = + ObjMap(entries.associate { ObjString(it.key) to ObjString(it.value) }.toMutableMap()) diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/net/LyngNetModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/net/LyngNetModule.kt new file mode 100644 index 0000000..7189664 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/net/LyngNetModule.kt @@ -0,0 +1,376 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.io.net + +import net.sergeych.lyng.ModuleScope +import net.sergeych.lyng.Scope +import net.sergeych.lyng.ScopeFacade +import net.sergeych.lyng.Source +import net.sergeych.lyng.obj.Obj +import net.sergeych.lyng.obj.ObjBool +import net.sergeych.lyng.obj.ObjBuffer +import net.sergeych.lyng.obj.ObjClass +import net.sergeych.lyng.obj.ObjInt +import net.sergeych.lyng.obj.ObjList +import net.sergeych.lyng.obj.ObjNull +import net.sergeych.lyng.obj.ObjString +import net.sergeych.lyng.obj.ObjVoid +import net.sergeych.lyng.obj.requiredArg +import net.sergeych.lyng.obj.thisAs +import net.sergeych.lyng.pacman.ImportManager +import net.sergeych.lyng.raiseIllegalOperation +import net.sergeych.lyng.requireNoArgs +import net.sergeych.lyngio.net.LyngDatagram +import net.sergeych.lyngio.net.LyngIpVersion +import net.sergeych.lyngio.net.LyngNetEngine +import net.sergeych.lyngio.net.LyngSocketAddress +import net.sergeych.lyngio.net.LyngTcpServer +import net.sergeych.lyngio.net.LyngTcpSocket +import net.sergeych.lyngio.net.LyngUdpSocket +import net.sergeych.lyngio.net.getSystemNetEngine +import net.sergeych.lyngio.net.security.NetAccessDeniedException +import net.sergeych.lyngio.net.security.NetAccessOp +import net.sergeych.lyngio.net.security.NetAccessPolicy +import net.sergeych.lyngio.stdlib_included.netLyng + +private const val NET_MODULE_NAME = "lyng.io.net" + +fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean = + createNetModule(policy, scope.importManager) + +fun createNet(policy: NetAccessPolicy, scope: Scope): Boolean = createNetModule(policy, scope) + +fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean { + if (manager.packageNames.contains(NET_MODULE_NAME)) return false + manager.addPackage(NET_MODULE_NAME) { module -> + buildNetModule(module, policy) + } + return true +} + +fun createNet(policy: NetAccessPolicy, manager: ImportManager): Boolean = createNetModule(policy, manager) + +private suspend fun buildNetModule(module: ModuleScope, policy: NetAccessPolicy) { + module.eval(Source(NET_MODULE_NAME, netLyng)) + val engine = getSystemNetEngine() + val enumValues = NetEnumValues.load(module) + + val netType = object : ObjClass("Net") {} + netType.addClassFn("isSupported") { ObjBool(engine.isSupported) } + netType.addClassFn("isTcpAvailable") { ObjBool(engine.isTcpAvailable) } + netType.addClassFn("isTcpServerAvailable") { ObjBool(engine.isTcpServerAvailable) } + netType.addClassFn("isUdpAvailable") { ObjBool(engine.isUdpAvailable) } + netType.addClassFn("resolve") { + netGuard { + val host = requiredArg(0).value + val port = requirePort(requiredArg(1).value) + policy.require(NetAccessOp.Resolve(host, port)) + ObjList(engine.resolve(host, port).map { ObjSocketAddress(it, enumValues) }.toMutableList()) + } + } + netType.addClassFn("tcpConnect") { + netGuard { + val host = requiredArg(0).value + val port = requirePort(requiredArg(1).value) + val timeoutMillis = args.list.getOrNull(2)?.let { objOrNullToLong(this, it, "timeoutMillis") } + val noDelay = args.list.getOrNull(3)?.let { objToBool(this, it, "noDelay") } ?: true + policy.require(NetAccessOp.TcpConnect(host, port)) + ObjTcpSocket(engine.tcpConnect(host, port, timeoutMillis, noDelay), enumValues) + } + } + netType.addClassFn("tcpListen") { + netGuard { + val port = requirePort(requiredArg(0).value) + val host = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "host") } + val backlog = args.list.getOrNull(2)?.let { objToInt(this, it, "backlog") } ?: 128 + requirePositive(backlog, "backlog") + val reuseAddress = args.list.getOrNull(3)?.let { objToBool(this, it, "reuseAddress") } ?: true + policy.require(NetAccessOp.TcpListen(host, port, backlog)) + ObjTcpServer(engine.tcpListen(host, port, backlog, reuseAddress), enumValues) + } + } + netType.addClassFn("udpBind") { + netGuard { + val port = args.list.getOrNull(0)?.let { objToInt(this, it, "port") } ?: 0 + requirePort(port) + val host = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "host") } + val reuseAddress = args.list.getOrNull(2)?.let { objToBool(this, it, "reuseAddress") } ?: true + policy.require(NetAccessOp.UdpBind(host, port)) + ObjUdpSocket(engine.udpBind(host, port, reuseAddress), enumValues) + } + } + + module.addConst("Net", netType) + module.addConst("SocketAddress", ObjSocketAddress.type(enumValues)) + module.addConst("Datagram", ObjDatagram.type(enumValues)) + module.addConst("TcpSocket", ObjTcpSocket.type(enumValues)) + module.addConst("TcpServer", ObjTcpServer.type(enumValues)) + module.addConst("UdpSocket", ObjUdpSocket.type(enumValues)) +} + +private suspend inline fun ScopeFacade.netGuard(crossinline block: suspend () -> Obj): Obj { + return try { + block() + } catch (e: NetAccessDeniedException) { + raiseIllegalOperation(e.reasonDetail ?: "network access denied") + } catch (e: Exception) { + raiseIllegalOperation(e.message ?: "network error") + } +} + +private class NetEnumValues( + val ipv4: Obj, + val ipv6: Obj, +) { + fun of(version: LyngIpVersion): Obj = when (version) { + LyngIpVersion.IPV4 -> ipv4 + LyngIpVersion.IPV6 -> ipv6 + } + + companion object { + suspend fun load(module: ModuleScope): NetEnumValues { + val ipVersionClass = module["IpVersion"]?.value as? ObjClass + ?: error("lyng.io.net.IpVersion is missing after declaration load") + return NetEnumValues( + ipv4 = ipVersionClass.readField(module, "IPV4").value, + ipv6 = ipVersionClass.readField(module, "IPV6").value, + ) + } + } +} + +private class ObjSocketAddress( + private val address: LyngSocketAddress, + private val enumValues: NetEnumValues, +) : Obj() { + override val objClass: ObjClass + get() = type(enumValues) + + override suspend fun defaultToString(scope: Scope): ObjString = ObjString(renderAddress(address)) + + companion object { + private val types = mutableMapOf() + + fun type(enumValues: NetEnumValues): ObjClass = + types.getOrPut(enumValues) { + object : ObjClass("SocketAddress") { + override suspend fun callOn(scope: Scope): Obj { + scope.raiseError("SocketAddress cannot be created directly") + } + }.apply { + addProperty("host", getter = { ObjString(thisAs().address.host) }) + addProperty("port", getter = { ObjInt(thisAs().address.port.toLong()) }) + addProperty("ipVersion", getter = { enumValues.of(thisAs().address.ipVersion) }) + addProperty("resolved", getter = { ObjBool(thisAs().address.resolved) }) + addFn("toString") { ObjString(renderAddress(thisAs().address)) } + } + } + } +} + +private class ObjDatagram( + private val datagram: LyngDatagram, + private val enumValues: NetEnumValues, +) : Obj() { + override val objClass: ObjClass + get() = type(enumValues) + + companion object { + private val types = mutableMapOf() + + fun type(enumValues: NetEnumValues): ObjClass = + types.getOrPut(enumValues) { + object : ObjClass("Datagram") { + override suspend fun callOn(scope: Scope): Obj { + scope.raiseError("Datagram cannot be created directly") + } + }.apply { + addProperty("data", getter = { + ObjBuffer(thisAs().datagram.data.toUByteArray()) + }) + addProperty("address", getter = { + ObjSocketAddress(thisAs().datagram.address, enumValues) + }) + } + } + } +} + +private class ObjTcpSocket( + private val socket: LyngTcpSocket, + private val enumValues: NetEnumValues, +) : Obj() { + override val objClass: ObjClass + get() = type(enumValues) + + companion object { + private val types = mutableMapOf() + + fun type(enumValues: NetEnumValues): ObjClass = + types.getOrPut(enumValues) { + object : ObjClass("TcpSocket") { + override suspend fun callOn(scope: Scope): Obj { + scope.raiseError("TcpSocket cannot be created directly") + } + }.apply { + addFn("isOpen") { ObjBool(thisAs().socket.isOpen()) } + addFn("localAddress") { ObjSocketAddress(thisAs().socket.localAddress(), enumValues) } + addFn("remoteAddress") { ObjSocketAddress(thisAs().socket.remoteAddress(), enumValues) } + addFn("read") { + val maxBytes = args.list.getOrNull(0)?.let { objToInt(this, it, "maxBytes") } ?: 65536 + requirePositive(maxBytes, "maxBytes") + thisAs().socket.read(maxBytes)?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull + } + addFn("readLine") { + thisAs().socket.readLine()?.let(::ObjString) ?: ObjNull + } + addFn("write") { + val data = requiredArg(0).byteArray.toByteArray() + thisAs().socket.write(data) + ObjVoid + } + addFn("writeUtf8") { + val text = requiredArg(0).value + thisAs().socket.writeUtf8(text) + ObjVoid + } + addFn("flush") { + requireNoArgs() + thisAs().socket.flush() + ObjVoid + } + addFn("close") { + requireNoArgs() + thisAs().socket.close() + ObjVoid + } + } + } + } +} + +private class ObjTcpServer( + private val server: LyngTcpServer, + private val enumValues: NetEnumValues, +) : Obj() { + override val objClass: ObjClass + get() = type(enumValues) + + companion object { + private val types = mutableMapOf() + + fun type(enumValues: NetEnumValues): ObjClass = + types.getOrPut(enumValues) { + object : ObjClass("TcpServer") { + override suspend fun callOn(scope: Scope): Obj { + scope.raiseError("TcpServer cannot be created directly") + } + }.apply { + addFn("isOpen") { ObjBool(thisAs().server.isOpen()) } + addFn("localAddress") { ObjSocketAddress(thisAs().server.localAddress(), enumValues) } + addFn("accept") { + ObjTcpSocket(thisAs().server.accept(), enumValues) + } + addFn("close") { + requireNoArgs() + thisAs().server.close() + ObjVoid + } + } + } + } +} + +private class ObjUdpSocket( + private val socket: LyngUdpSocket, + private val enumValues: NetEnumValues, +) : Obj() { + override val objClass: ObjClass + get() = type(enumValues) + + companion object { + private val types = mutableMapOf() + + fun type(enumValues: NetEnumValues): ObjClass = + types.getOrPut(enumValues) { + object : ObjClass("UdpSocket") { + override suspend fun callOn(scope: Scope): Obj { + scope.raiseError("UdpSocket cannot be created directly") + } + }.apply { + addFn("isOpen") { ObjBool(thisAs().socket.isOpen()) } + addFn("localAddress") { ObjSocketAddress(thisAs().socket.localAddress(), enumValues) } + addFn("receive") { + val maxBytes = args.list.getOrNull(0)?.let { objToInt(this, it, "maxBytes") } ?: 65536 + requirePositive(maxBytes, "maxBytes") + thisAs().socket.receive(maxBytes)?.let { ObjDatagram(it, enumValues) } ?: ObjNull + } + addFn("send") { + val data = requiredArg(0).byteArray.toByteArray() + val host = requiredArg(1).value + val port = requirePort(requiredArg(2).value) + thisAs().socket.send(data, host, port) + ObjVoid + } + addFn("close") { + requireNoArgs() + thisAs().socket.close() + ObjVoid + } + } + } + } +} + +private fun renderAddress(address: LyngSocketAddress): String = + if (address.ipVersion == LyngIpVersion.IPV6) "[${address.host}]:${address.port}" else "${address.host}:${address.port}" + +private fun ScopeFacade.requirePort(value: Long): Int { + if (value !in 0..65535) raiseIllegalArgument("port must be in 0..65535") + return value.toInt() +} + +private fun ScopeFacade.requirePort(value: Int): Int { + if (value !in 0..65535) raiseIllegalArgument("port must be in 0..65535") + return value +} + +private fun ScopeFacade.requirePositive(value: Int, name: String) { + if (value <= 0) raiseIllegalArgument("$name must be positive") +} + +private suspend fun objOrNullToString(scope: ScopeFacade, value: Obj, name: String): String? = when (value) { + ObjNull -> null + else -> scope.toStringOf(value).value +} + +private fun objToInt(scope: ScopeFacade, value: Obj, name: String): Int = when (value) { + is ObjInt -> value.value.toInt() + else -> scope.raiseClassCastError("$name must be Int") +} + +private fun objToBool(scope: ScopeFacade, value: Obj, name: String): Boolean = when (value) { + is ObjBool -> value.value + else -> scope.raiseClassCastError("$name must be Bool") +} + +private fun objOrNullToLong(scope: ScopeFacade, value: Obj, name: String): Long? = when (value) { + ObjNull -> null + is ObjInt -> value.value + else -> scope.raiseClassCastError("$name must be Int or null") +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/ws/LyngWsModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/ws/LyngWsModule.kt new file mode 100644 index 0000000..71d704b --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/ws/LyngWsModule.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.io.ws + +import net.sergeych.lyng.ModuleScope +import net.sergeych.lyng.Scope +import net.sergeych.lyng.ScopeFacade +import net.sergeych.lyng.Source +import net.sergeych.lyng.obj.Obj +import net.sergeych.lyng.obj.ObjBool +import net.sergeych.lyng.obj.ObjBuffer +import net.sergeych.lyng.obj.ObjClass +import net.sergeych.lyng.obj.ObjInt +import net.sergeych.lyng.obj.ObjMapEntry +import net.sergeych.lyng.obj.ObjNull +import net.sergeych.lyng.obj.ObjString +import net.sergeych.lyng.obj.ObjVoid +import net.sergeych.lyng.obj.requiredArg +import net.sergeych.lyng.obj.thisAs +import net.sergeych.lyng.pacman.ImportManager +import net.sergeych.lyng.raiseIllegalOperation +import net.sergeych.lyng.requireNoArgs +import net.sergeych.lyng.requireScope +import net.sergeych.lyngio.stdlib_included.wsLyng +import net.sergeych.lyngio.ws.LyngWsEngine +import net.sergeych.lyngio.ws.LyngWsMessage +import net.sergeych.lyngio.ws.LyngWsSession +import net.sergeych.lyngio.ws.getSystemWsEngine +import net.sergeych.lyngio.ws.security.WsAccessDeniedException +import net.sergeych.lyngio.ws.security.WsAccessOp +import net.sergeych.lyngio.ws.security.WsAccessPolicy + +private const val WS_MODULE_NAME = "lyng.io.ws" + +fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean = + createWsModule(policy, scope.importManager) + +fun createWs(policy: WsAccessPolicy, scope: Scope): Boolean = createWsModule(policy, scope) + +fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean { + if (manager.packageNames.contains(WS_MODULE_NAME)) return false + manager.addPackage(WS_MODULE_NAME) { module -> + buildWsModule(module, policy) + } + return true +} + +fun createWs(policy: WsAccessPolicy, manager: ImportManager): Boolean = createWsModule(policy, manager) + +private suspend fun buildWsModule(module: ModuleScope, policy: WsAccessPolicy) { + module.eval(Source(WS_MODULE_NAME, wsLyng)) + val engine = getSystemWsEngine() + + val wsType = object : ObjClass("Ws") {} + wsType.addClassFn("isSupported") { ObjBool(engine.isSupported) } + wsType.addClassFn("connect") { + wsGuard { + val url = requiredArg(0).value + val headers = parseHeaderEntries(args.list.drop(1)) + policy.require(WsAccessOp.Connect(url)) + ObjWsSession(url, engine.connect(url, headers), policy) + } + } + + module.addConst("Ws", wsType) + module.addConst("WsMessage", ObjWsMessage.type) + module.addConst("WsSession", ObjWsSession.type) +} + +private suspend inline fun ScopeFacade.wsGuard(crossinline block: suspend () -> Obj): Obj { + return try { + block() + } catch (e: WsAccessDeniedException) { + raiseIllegalOperation(e.reasonDetail ?: "websocket access denied") + } catch (e: Exception) { + raiseIllegalOperation(e.message ?: "websocket error") + } +} + +private class ObjWsMessage( + private val message: LyngWsMessage, +) : Obj() { + override val objClass: ObjClass + get() = type + + companion object { + val type = object : ObjClass("WsMessage") { + override suspend fun callOn(scope: Scope): Obj { + scope.raiseError("WsMessage cannot be created directly") + } + }.apply { + addProperty("isText", getter = { ObjBool(thisAs().message.isText) }) + addProperty("text", getter = { + thisAs().message.text?.let(::ObjString) ?: ObjNull + }) + addProperty("data", getter = { + thisAs().message.data?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull + }) + } + } +} + +private class ObjWsSession( + private val targetUrl: String, + private val session: LyngWsSession, + private val policy: WsAccessPolicy, +) : Obj() { + override val objClass: ObjClass + get() = type + + companion object { + val type = object : ObjClass("WsSession") { + override suspend fun callOn(scope: Scope): Obj { + scope.raiseError("WsSession cannot be created directly") + } + }.apply { + addFn("isOpen") { + ObjBool(thisAs().session.isOpen()) + } + addFn("url") { + ObjString(thisAs().targetUrl) + } + addFn("sendText") { + val self = thisAs() + val text = requiredArg(0).value + self.policy.require(WsAccessOp.Send(self.targetUrl, text.encodeToByteArray().size, isText = true)) + self.session.sendText(text) + ObjVoid + } + addFn("sendBytes") { + val self = thisAs() + val data = requiredArg(0).byteArray.toByteArray() + self.policy.require(WsAccessOp.Send(self.targetUrl, data.size, isText = false)) + self.session.sendBytes(data) + ObjVoid + } + addFn("receive") { + val self = thisAs() + self.policy.require(WsAccessOp.Receive(self.targetUrl)) + self.session.receive()?.let(::ObjWsMessage) ?: ObjNull + } + addFn("close") { + val self = thisAs() + val code = args.list.getOrNull(0)?.let { objToInt(this, it, "code") } ?: 1000 + val reason = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "reason") } ?: "" + self.session.close(code, reason) + ObjVoid + } + } + } +} + +private suspend fun ScopeFacade.parseHeaderEntries(values: List): Map { + val out = linkedMapOf() + values.forEach { value -> + when (value) { + is ObjMapEntry -> { + out[toStringOf(value.key).value] = toStringOf(value.value).value + } + else -> { + if (!value.isInstanceOf(net.sergeych.lyng.obj.ObjArray)) { + raiseIllegalArgument("headers entries must be MapEntry or [key, value]") + } + val size = (value.invokeInstanceMethod(requireScope(), "size") as ObjInt).value.toInt() + if (size != 2) { + raiseIllegalArgument("header entry array must contain exactly 2 items") + } + out[toStringOf(value.getAt(requireScope(), ObjInt.Zero)).value] = + toStringOf(value.getAt(requireScope(), ObjInt.One)).value + } + } + } + return out +} + +private suspend fun objOrNullToString(scope: ScopeFacade, value: Obj, name: String): String? = when (value) { + ObjNull -> null + else -> scope.toStringOf(value).value +} + +private fun objToInt(scope: ScopeFacade, value: Obj, name: String): Int = when (value) { + is ObjInt -> value.value.toInt() + else -> scope.raiseClassCastError("$name must be Int") +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt new file mode 100644 index 0000000..dc908a6 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.http + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.timeout +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.http.HttpMethod +import io.ktor.http.headers +import io.ktor.http.takeFrom + +data class LyngHttpRequest( + val method: String, + val url: String, + val headers: Map = emptyMap(), + val bodyText: String? = null, + val bodyBytes: ByteArray? = null, + val timeoutMillis: Long? = null, +) + +data class LyngHttpResponse( + val status: Int, + val statusText: String, + val headers: Map>, + val bodyBytes: ByteArray, +) + +interface LyngHttpEngine { + val isSupported: Boolean + suspend fun request(request: LyngHttpRequest): LyngHttpResponse +} + +private object UnsupportedHttpEngine : LyngHttpEngine { + override val isSupported: Boolean = false + + override suspend fun request(request: LyngHttpRequest): LyngHttpResponse { + throw UnsupportedOperationException("HTTP client is not supported on this runtime") + } +} + +private object KtorLyngHttpEngine : LyngHttpEngine { + private val clientResult by lazy { + runCatching { + HttpClient(CIO) { + expectSuccess = false + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun request(request: LyngHttpRequest): LyngHttpResponse { + val httpClient = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "HTTP client is not supported") + } + + val response = httpClient.request { + applyRequest(request) + } + return LyngHttpResponse( + status = response.status.value, + statusText = response.status.description, + headers = response.headers.entries().associate { it.key to it.value.toList() }, + bodyBytes = response.body(), + ) + } + + private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) { + method = HttpMethod.parse(request.method.uppercase()) + url.takeFrom(request.url) + headers { + request.headers.forEach { (name, value) -> append(name, value) } + } + request.timeoutMillis?.let { timeout { requestTimeoutMillis = it } } + when { + request.bodyBytes != null && request.bodyText != null -> + throw IllegalArgumentException("Only one of bodyText or bodyBytes may be set") + request.bodyBytes != null -> setBody(request.bodyBytes) + request.bodyText != null -> setBody(request.bodyText) + } + } +} + +fun getSystemHttpEngine(): LyngHttpEngine = KtorLyngHttpEngine diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/security/HttpAccessPolicy.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/security/HttpAccessPolicy.kt new file mode 100644 index 0000000..f4a11cf --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/security/HttpAccessPolicy.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.http.security + +import net.sergeych.lyngio.fs.security.AccessContext +import net.sergeych.lyngio.fs.security.AccessDecision +import net.sergeych.lyngio.fs.security.Decision + +sealed interface HttpAccessOp { + data class Request(val method: String, val url: String) : HttpAccessOp +} + +class HttpAccessDeniedException( + val op: HttpAccessOp, + val reasonDetail: String? = null, +) : IllegalStateException("HTTP access denied for $op" + (reasonDetail?.let { ": $it" } ?: "")) + +interface HttpAccessPolicy { + suspend fun check(op: HttpAccessOp, ctx: AccessContext = AccessContext()): AccessDecision + + suspend fun require(op: HttpAccessOp, ctx: AccessContext = AccessContext()) { + val res = check(op, ctx) + if (!res.isAllowed()) throw HttpAccessDeniedException(op, res.reason) + } +} + +object PermitAllHttpAccessPolicy : HttpAccessPolicy { + override suspend fun check(op: HttpAccessOp, ctx: AccessContext): AccessDecision = + AccessDecision(Decision.Allow) +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/net/LyngNet.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/net/LyngNet.kt new file mode 100644 index 0000000..66307ec --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/net/LyngNet.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.net + +enum class LyngIpVersion { + IPV4, + IPV6, +} + +data class LyngSocketAddress( + val host: String, + val port: Int, + val ipVersion: LyngIpVersion, + val resolved: Boolean, +) + +data class LyngDatagram( + val data: ByteArray, + val address: LyngSocketAddress, +) + +interface LyngTcpSocket { + fun isOpen(): Boolean + fun localAddress(): LyngSocketAddress + fun remoteAddress(): LyngSocketAddress + suspend fun read(maxBytes: Int): ByteArray? + suspend fun readLine(): String? + suspend fun write(data: ByteArray) + suspend fun writeUtf8(text: String) + suspend fun flush() + fun close() +} + +interface LyngTcpServer { + fun isOpen(): Boolean + fun localAddress(): LyngSocketAddress + suspend fun accept(): LyngTcpSocket + fun close() +} + +interface LyngUdpSocket { + fun isOpen(): Boolean + fun localAddress(): LyngSocketAddress + suspend fun receive(maxBytes: Int): LyngDatagram? + suspend fun send(data: ByteArray, host: String, port: Int) + fun close() +} + +interface LyngNetEngine { + val isSupported: Boolean + val isTcpAvailable: Boolean + val isTcpServerAvailable: Boolean + val isUdpAvailable: Boolean + + suspend fun resolve(host: String, port: Int): List + suspend fun tcpConnect(host: String, port: Int, timeoutMillis: Long?, noDelay: Boolean): LyngTcpSocket + suspend fun tcpListen(host: String?, port: Int, backlog: Int, reuseAddress: Boolean): LyngTcpServer + suspend fun udpBind(host: String?, port: Int, reuseAddress: Boolean): LyngUdpSocket +} + +internal object UnsupportedLyngNetEngine : LyngNetEngine { + override val isSupported: Boolean = false + override val isTcpAvailable: Boolean = false + override val isTcpServerAvailable: Boolean = false + override val isUdpAvailable: Boolean = false + + override suspend fun resolve(host: String, port: Int): List { + throw UnsupportedOperationException("Raw networking is not supported on this runtime") + } + + override suspend fun tcpConnect(host: String, port: Int, timeoutMillis: Long?, noDelay: Boolean): LyngTcpSocket { + throw UnsupportedOperationException("TCP client sockets are not supported on this runtime") + } + + override suspend fun tcpListen(host: String?, port: Int, backlog: Int, reuseAddress: Boolean): LyngTcpServer { + throw UnsupportedOperationException("TCP server sockets are not supported on this runtime") + } + + override suspend fun udpBind(host: String?, port: Int, reuseAddress: Boolean): LyngUdpSocket { + throw UnsupportedOperationException("UDP sockets are not supported on this runtime") + } +} + +expect fun getSystemNetEngine(): LyngNetEngine diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/net/security/NetAccessPolicy.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/net/security/NetAccessPolicy.kt new file mode 100644 index 0000000..167b864 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/net/security/NetAccessPolicy.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.net.security + +import net.sergeych.lyngio.fs.security.AccessContext +import net.sergeych.lyngio.fs.security.AccessDecision +import net.sergeych.lyngio.fs.security.Decision + +sealed interface NetAccessOp { + data class Resolve(val host: String, val port: Int) : NetAccessOp + data class TcpConnect(val host: String, val port: Int) : NetAccessOp + data class TcpListen(val host: String?, val port: Int, val backlog: Int) : NetAccessOp + data class UdpBind(val host: String?, val port: Int) : NetAccessOp +} + +class NetAccessDeniedException( + val op: NetAccessOp, + val reasonDetail: String? = null, +) : IllegalStateException("Network access denied for $op" + (reasonDetail?.let { ": $it" } ?: "")) + +interface NetAccessPolicy { + suspend fun check(op: NetAccessOp, ctx: AccessContext = AccessContext()): AccessDecision + + suspend fun require(op: NetAccessOp, ctx: AccessContext = AccessContext()) { + val res = check(op, ctx) + if (!res.isAllowed()) throw NetAccessDeniedException(op, res.reason) + } +} + +object PermitAllNetAccessPolicy : NetAccessPolicy { + override suspend fun check(op: NetAccessOp, ctx: AccessContext): AccessDecision = + AccessDecision(Decision.Allow) +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/LyngWs.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/LyngWs.kt new file mode 100644 index 0000000..706db9d --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/LyngWs.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.ws + +data class LyngWsMessage( + val isText: Boolean, + val text: String? = null, + val data: ByteArray? = null, +) + +interface LyngWsSession { + fun isOpen(): Boolean + fun url(): String + suspend fun sendText(text: String) + suspend fun sendBytes(data: ByteArray) + suspend fun receive(): LyngWsMessage? + suspend fun close(code: Int, reason: String) +} + +interface LyngWsEngine { + val isSupported: Boolean + suspend fun connect(url: String, headers: Map): LyngWsSession +} + +internal object UnsupportedLyngWsEngine : LyngWsEngine { + override val isSupported: Boolean = false + + override suspend fun connect(url: String, headers: Map): LyngWsSession { + throw UnsupportedOperationException("WebSocket client is not supported on this runtime") + } +} + +expect fun getSystemWsEngine(): LyngWsEngine diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/security/WsAccessPolicy.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/security/WsAccessPolicy.kt new file mode 100644 index 0000000..43fe54c --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/security/WsAccessPolicy.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.ws.security + +import net.sergeych.lyngio.fs.security.AccessContext +import net.sergeych.lyngio.fs.security.AccessDecision +import net.sergeych.lyngio.fs.security.Decision + +sealed interface WsAccessOp { + data class Connect(val url: String) : WsAccessOp + data class Send(val url: String, val bytes: Int, val isText: Boolean) : WsAccessOp + data class Receive(val url: String) : WsAccessOp +} + +class WsAccessDeniedException( + val op: WsAccessOp, + val reasonDetail: String? = null, +) : IllegalStateException("WebSocket access denied for $op" + (reasonDetail?.let { ": $it" } ?: "")) + +interface WsAccessPolicy { + suspend fun check(op: WsAccessOp, ctx: AccessContext = AccessContext()): AccessDecision + + suspend fun require(op: WsAccessOp, ctx: AccessContext = AccessContext()) { + val res = check(op, ctx) + if (!res.isAllowed()) throw WsAccessDeniedException(op, res.reason) + } +} + +object PermitAllWsAccessPolicy : WsAccessPolicy { + override suspend fun check(op: WsAccessOp, ctx: AccessContext): AccessDecision = + AccessDecision(Decision.Allow) +} diff --git a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/net/PlatformJs.kt b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/net/PlatformJs.kt new file mode 100644 index 0000000..7a10ec5 --- /dev/null +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/net/PlatformJs.kt @@ -0,0 +1,3 @@ +package net.sergeych.lyngio.net + +actual fun getSystemNetEngine(): LyngNetEngine = UnsupportedLyngNetEngine diff --git a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/ws/PlatformJs.kt b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/ws/PlatformJs.kt new file mode 100644 index 0000000..e9bc921 --- /dev/null +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/ws/PlatformJs.kt @@ -0,0 +1,3 @@ +package net.sergeych.lyngio.ws + +actual fun getSystemWsEngine(): LyngWsEngine = UnsupportedLyngWsEngine diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/net/PlatformJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/net/PlatformJvm.kt new file mode 100644 index 0000000..22cdae5 --- /dev/null +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/net/PlatformJvm.kt @@ -0,0 +1,223 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.net + +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 = JvmKtorNetEngine + +private object JvmKtorNetEngine : LyngNetEngine { + private val selectorManager: SelectorManager by lazy { ActorSelectorManager(Dispatchers.IO) } + + override val isSupported: Boolean = true + override val isTcpAvailable: Boolean = true + override val isTcpServerAvailable: Boolean = true + override val isUdpAvailable: Boolean = true + + override suspend fun resolve(host: String, port: Int): List = withContext(Dispatchers.IO) { + InetAddress.getAllByName(host).map { address -> + address.toLyngSocketAddress(port = port, resolved = true) + } + } + + override suspend fun tcpConnect( + host: String, + port: Int, + timeoutMillis: Long?, + noDelay: Boolean, + ): LyngTcpSocket { + val connectBlock: suspend () -> Socket = { + aSocket(selectorManager).tcp().connect(host, port) { + this.noDelay = noDelay + } + } + val socket = if (timeoutMillis != null) withTimeout(timeoutMillis) { connectBlock() } else connectBlock() + return JvmLyngTcpSocket(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 JvmLyngTcpServer(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 JvmLyngUdpSocket(socket) + } +} + +private class JvmLyngTcpSocket( + 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 JvmLyngTcpServer( + 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 = JvmLyngTcpSocket(server.accept()) + + override fun close() { + server.close() + } +} + +private class JvmLyngUdpSocket( + 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, + port = port, + ipVersion = when (this) { + is Inet6Address -> LyngIpVersion.IPV6 + else -> LyngIpVersion.IPV4 + }, + resolved = resolved, + ) diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/ws/PlatformJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/ws/PlatformJvm.kt new file mode 100644 index 0000000..0f2b056 --- /dev/null +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/ws/PlatformJvm.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.ws + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.websocket.CloseReason +import io.ktor.websocket.DefaultWebSocketSession +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.channels.ClosedReceiveChannelException + +actual fun getSystemWsEngine(): LyngWsEngine = JvmKtorWsEngine + +private object JvmKtorWsEngine : LyngWsEngine { + private val clientResult by lazy { + runCatching { + HttpClient(CIO) { + install(WebSockets) + } + } + } + + override val isSupported: Boolean + get() = clientResult.isSuccess + + override suspend fun connect(url: String, headers: Map): LyngWsSession { + val client = clientResult.getOrElse { + throw UnsupportedOperationException(it.message ?: "WebSocket client is not supported") + } + val session = client.webSocketSession { + url(url) + headers.forEach { (name, value) -> header(name, value) } + } + return JvmLyngWsSession(url, session) + } +} + +private class JvmLyngWsSession( + private val targetUrl: String, + private val session: DefaultWebSocketSession, +) : LyngWsSession { + @Volatile + private var closed = false + + override fun isOpen(): Boolean = !closed + + override fun url(): String = targetUrl + + override suspend fun sendText(text: String) { + ensureOpen() + session.send(text) + } + + override suspend fun sendBytes(data: ByteArray) { + ensureOpen() + session.send(data) + } + + override suspend fun receive(): LyngWsMessage? { + if (closed) return null + val frame = try { + session.incoming.receive() + } catch (_: ClosedReceiveChannelException) { + closed = true + return null + } + return when (frame) { + is Frame.Text -> LyngWsMessage(isText = true, text = frame.readText()) + is Frame.Binary -> LyngWsMessage(isText = false, data = frame.data.copyOf()) + is Frame.Close -> { + closed = true + null + } + else -> receive() + } + } + + override suspend fun close(code: Int, reason: String) { + if (closed) return + closed = true + val safeCode = code.toShort() + session.close(CloseReason(safeCode, reason)) + } + + private fun ensureOpen() { + if (closed) throw IllegalStateException("websocket session is closed") + } +} diff --git a/lyngio/src/jvmTest/kotlin/LyngioBookTest.kt b/lyngio/src/jvmTest/kotlin/LyngioBookTest.kt new file mode 100644 index 0000000..2cb856e --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/LyngioBookTest.kt @@ -0,0 +1,256 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import com.sun.net.httpserver.HttpsConfigurator +import com.sun.net.httpserver.HttpsServer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.runBlocking +import net.sergeych.lyng.Scope +import net.sergeych.lyng.Script +import net.sergeych.lyng.io.http.createHttpModule +import net.sergeych.lyng.io.net.createNetModule +import net.sergeych.lyng.io.testtls.TlsTestMaterial +import net.sergeych.lyng.io.ws.TestWebSocketServer +import net.sergeych.lyng.io.ws.createWsModule +import net.sergeych.lyng.leftMargin +import net.sergeych.lyng.obj.ObjInt +import net.sergeych.lyng.obj.ObjString +import net.sergeych.lyng.obj.ObjVoid +import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy +import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy +import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.nio.file.Files.readAllLines +import java.nio.file.Paths +import kotlin.concurrent.thread +import kotlin.io.path.absolutePathString +import kotlin.test.Test +import kotlin.test.assertEquals + +data class IoDocTest( + val fileName: String, + val line: Int, + val code: String, + val expectedOutput: String, + val expectedResult: String, +) { + val fileNamePart by lazy { Paths.get(fileName).fileName.toString() } + + override fun toString(): String = "DocTest: ${Paths.get(fileName).absolutePathString()}:${line + 1}" +} + +private fun parseIoDocTests(fileName: String): Flow = flow { + val book = readAllLines(Paths.get(fileName)) + var startOffset = 0 + val block = mutableListOf() + var startIndex = 0 + for ((index, l) in book.withIndex()) { + val off = leftMargin(l) + when { + off < startOffset && startOffset != 0 -> { + if (l.isBlank()) continue + if (block.size > 1) { + for ((i, s) in block.withIndex()) { + var x = s + val initial = leftMargin(x) + do { + x = x.drop(1) + } while (initial - leftMargin(x) != startOffset) + block[i] = x + } + val outStart = block.indexOfFirst { it.startsWith(">>>") } + if (outStart >= 0) { + val result = mutableListOf() + while (block.lastOrNull()?.isEmpty() == true) block.removeLast() + var valid = true + while (block.size > outStart) { + val line = block.removeAt(outStart) + if (!line.startsWith(">>> ")) { + valid = false + break + } + result.add(line.drop(4)) + } + if (valid) { + emit( + IoDocTest( + fileName = fileName, + line = startIndex, + code = block.joinToString("\n"), + expectedOutput = if (result.size > 1) result.dropLast(1).joinToString("") { "$it\n" } else "", + expectedResult = result.last(), + ) + ) + } + } + } + block.clear() + startOffset = 0 + } + + off != 0 && startOffset == 0 -> { + block.clear() + startIndex = index + block.add(l) + startOffset = off + } + + off != 0 -> block.add(l) + } + } +}.flowOn(Dispatchers.IO) + +private suspend fun IoDocTest.run(scope: Scope) { + val output = StringBuilder() + scope.addFn("println") { + for ((i, a) in args.withIndex()) { + if (i > 0) output.append(' ') + output.append(toStringOf(a).value) + } + output.append('\n') + ObjVoid + } + val result = scope.eval(code).inspect(scope).replace(Regex("@\\d+"), "@...") + assertEquals(expectedOutput, output.toString(), "script output mismatch at $this") + assertEquals(expectedResult, result, "script result mismatch at $this") +} + +private suspend fun runIoDocTests( + fileName: String, + installModules: suspend (Scope) -> Unit, + installConsts: suspend (Scope) -> Unit = {}, +) { + parseIoDocTests(fileName).collect { test -> + val scope = Script.newScope() + installModules(scope) + installConsts(scope) + test.run(scope) + } +} + +class LyngioBookTest { + + @Test + fun testHttpDocs() = runBlocking { + val server = newHttpServer(secure = false) + val secureServer = newHttpServer(secure = true) + try { + runIoDocTests( + fileName = "../docs/lyng.io.http.md", + installModules = { scope -> createHttpModule(PermitAllHttpAccessPolicy, scope) }, + installConsts = { scope -> + scope.addConst("HTTP_TEST_URL", ObjString("http://127.0.0.1:${server.address.port}")) + scope.addConst("HTTPS_TEST_URL", ObjString("https://127.0.0.1:${secureServer.address.port}")) + }, + ) + } finally { + server.stop(0) + secureServer.stop(0) + } + } + + @Test + fun testNetDocs() = runBlocking { + ServerSocket(0, 50).use { server -> + val worker = thread(start = true) { + server.accept().use { client -> + val line = client.getInputStream().readNBytes(4).decodeToString() + client.getOutputStream().write(("reply:" + line).toByteArray()) + client.getOutputStream().flush() + } + } + try { + runIoDocTests( + fileName = "../docs/lyng.io.net.md", + installModules = { scope -> createNetModule(PermitAllNetAccessPolicy, scope) }, + installConsts = { scope -> scope.addConst("NET_TEST_TCP_PORT", ObjInt(server.localPort.toLong())) }, + ) + } finally { + worker.join(2000) + } + } + } + + @Test + fun testWsDocs() = runBlocking { + TestWebSocketServer { connection -> + val text = connection.receiveText() + connection.sendText("echo:$text") + connection.close() + }.use { textServer -> + TestWebSocketServer { connection -> + val data = connection.receiveBinary() + connection.sendBinary(byteArrayOf(1, 2, 3) + data) + connection.close() + }.use { binaryServer -> + TestWebSocketServer(secure = true) { connection -> + val text = connection.receiveText() + connection.sendText("secure:$text") + connection.close() + }.use { secureServer -> + runIoDocTests( + fileName = "../docs/lyng.io.ws.md", + installModules = { scope -> createWsModule(PermitAllWsAccessPolicy, scope) }, + installConsts = { scope -> + scope.addConst("WS_TEST_URL", ObjString(textServer.url)) + scope.addConst("WS_TEST_BINARY_URL", ObjString(binaryServer.url)) + scope.addConst("WSS_TEST_URL", ObjString(secureServer.url)) + }, + ) + } + } + } + } + + private fun writeResponse(exchange: HttpExchange, status: Int, body: String) { + val bytes = body.toByteArray() + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + + private fun newHttpServer(secure: Boolean): HttpServer { + if (secure) TlsTestMaterial.installJvmClientTrust() + 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("/hello") { exchange -> + exchange.responseHeaders.add("Content-Type", "text/plain; charset=utf-8") + writeResponse(exchange, 200, "hello from test") + } + server.createContext("/headers") { exchange -> + exchange.responseHeaders.add("X-Reply", "one") + exchange.responseHeaders.add("X-Reply", "two") + writeResponse(exchange, 200, "header demo") + } + server.createContext("/echo") { exchange -> + val body = exchange.requestBody.readBytes().decodeToString() + writeResponse(exchange, 200, exchange.requestMethod + ":" + body) + } + server.start() + return server + } +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/LyngHttpModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/LyngHttpModuleTest.kt new file mode 100644 index 0000000..d899316 --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/LyngHttpModuleTest.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.io.http + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +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.http.security.HttpAccessOp +import net.sergeych.lyngio.http.security.HttpAccessPolicy +import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy +import java.net.InetSocketAddress +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class LyngHttpModuleTest { + + @Test + fun testConvenienceGetAndHeaders() = runBlocking { + val server = newServer { exchange -> + exchange.responseHeaders.add("Content-Type", "text/plain; charset=utf-8") + exchange.responseHeaders.add("X-Reply", "one") + exchange.responseHeaders.add("X-Reply", "two") + writeResponse(exchange, 200, "hello from test") + } + try { + val scope = Script.newScope() + createHttpModule(PermitAllHttpAccessPolicy, scope) + + val code = """ + import lyng.io.http + + val r = Http.get( + "http://127.0.0.1:${server.address.port}/hello", + "Accept" => "text/plain", + "X-Test" => "yes" + ) + [r.status, r.headers["Content-Type"], r.headers.getAll("X-Reply").size, r.text()] + """.trimIndent() + + val result = Compiler.compile(code).execute(scope) + val rendered = result.inspect(scope) + assertTrue(rendered.contains("200"), rendered) + assertTrue(rendered.contains("text/plain"), rendered) + assertTrue(rendered.contains("2"), rendered) + assertTrue(rendered.contains("hello from test"), rendered) + } finally { + server.stop(0) + } + } + + @Test + fun testMutableRequestPost() = runBlocking { + val server = newServer { exchange -> + val body = exchange.requestBody.readBytes().decodeToString() + exchange.responseHeaders.add("Content-Type", "text/plain") + writeResponse(exchange, 200, exchange.requestMethod + ":" + body) + } + try { + val scope = Script.newScope() + createHttpModule(PermitAllHttpAccessPolicy, scope) + + val code = """ + import lyng.io.http + + val q = HttpRequest() + q.method = "POST" + q.url = "http://127.0.0.1:${server.address.port}/echo" + q.headers = Map("Content-Type" => "text/plain") + q.bodyText = "ping" + + val r = Http.request(q) + r.text() + """.trimIndent() + + val result = Compiler.compile(code).execute(scope) + assertTrue(result.inspect(scope).contains("POST:ping")) + } finally { + server.stop(0) + } + } + + @Test + fun testPolicyDenialSurfacesAsLyngError() = runBlocking { + val scope = Script.newScope() + val denyAll = object : HttpAccessPolicy { + override suspend fun check(op: HttpAccessOp, ctx: AccessContext): AccessDecision = + AccessDecision(Decision.Deny, "blocked by test policy") + } + createHttpModule(denyAll, scope) + + val code = """ + import lyng.io.http + Http.get("http://127.0.0.1:1/") + """.trimIndent() + + val error = assertFailsWith { + Compiler.compile(code).execute(scope) + } + assertTrue(error.errorMessage.isNotBlank()) + } + + private fun newServer(handler: (HttpExchange) -> Unit): HttpServer { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + handler(exchange) + } + server.start() + return server + } + + private fun writeResponse(exchange: HttpExchange, status: Int, body: String) { + val bytes = body.toByteArray() + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { out -> + out.write(bytes) + } + } +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleTest.kt new file mode 100644 index 0000000..8a84564 --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.io.net + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +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.security.NetAccessOp +import net.sergeych.lyngio.net.security.NetAccessPolicy +import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.ServerSocket +import java.net.Socket +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class LyngNetModuleTest { + + @Test + fun testResolveAndCapabilities() = runBlocking { + val scope = Script.newScope() + createNetModule(PermitAllNetAccessPolicy, scope) + + val code = """ + import lyng.io.net + + val a: SocketAddress = Net.resolve("127.0.0.1", 4040)[0] + [Net.isSupported(), Net.isTcpAvailable(), Net.isTcpServerAvailable(), Net.isUdpAvailable(), a.toString(), a.resolved, 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() = runBlocking { + ServerSocket(0, 50).use { server -> + val worker = thread(start = true) { + server.accept().use { client -> + val line = client.getInputStream().readNBytes(4).decodeToString() + client.getOutputStream().write(("reply:" + line).toByteArray()) + client.getOutputStream().flush() + } + } + + val scope = Script.newScope() + createNetModule(PermitAllNetAccessPolicy, scope) + val code = """ + import lyng.buffer + import lyng.io.net + + val socket = Net.tcpConnect("127.0.0.1", ${server.localPort}) + 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 == ${server.localPort}] + """.trimIndent() + + val result = Compiler.compile(code).execute(scope).inspect(scope) + worker.join(2000) + assertTrue(result.contains("reply:ping"), result) + assertTrue(result.contains("true,true"), result) + } + } + + @Test + fun testTcpListenAndAcceptInLyng() = runBlocking { + val scope = Script.newScope() + createNetModule(PermitAllNetAccessPolicy, scope) + + val code = """ + import lyng.buffer + import lyng.io.net + + val server = Net.tcpListen(0, "127.0.0.1") + val port = server.localAddress().port + val accepted = launch { + val client = server.accept() + val line = (client.read(4) as Buffer).decodeUtf8() + client.writeUtf8("echo:" + line) + client.flush() + client.close() + server.close() + line + } + + val socket = Net.tcpConnect("127.0.0.1", port) + socket.writeUtf8("ping") + socket.flush() + val reply = (socket.read(16) as Buffer).decodeUtf8() + socket.close() + [accepted.await(), reply] + """.trimIndent() + + val result = Compiler.compile(code).execute(scope).inspect(scope) + assertTrue(result.contains("[ping,echo:ping]"), result) + } + + @Test + fun testUdpLoopback() = runBlocking { + 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() = runBlocking { + val scope = Script.newScope() + val denyAll = object : NetAccessPolicy { + override suspend fun check(op: NetAccessOp, ctx: AccessContext): AccessDecision = + AccessDecision(Decision.Deny, "blocked by test policy") + } + createNetModule(denyAll, scope) + + val code = """ + import lyng.io.net + Net.tcpConnect("127.0.0.1", 1) + """.trimIndent() + + val error = assertFailsWith { + Compiler.compile(code).execute(scope) + } + assertTrue(error.errorMessage.isNotBlank()) + } +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/testtls/TlsTestMaterial.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/testtls/TlsTestMaterial.kt new file mode 100644 index 0000000..ee9dde6 --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/testtls/TlsTestMaterial.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.io.testtls + +import java.nio.file.Files +import java.nio.file.Path +import java.security.KeyStore +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLServerSocketFactory +import javax.net.ssl.TrustManagerFactory +import kotlin.io.path.absolutePathString + +internal object TlsTestMaterial { + private const val STORE_PASSWORD = "changeit" + private const val KEY_ALIAS = "lyng-io-test" + + private val dir: Path by lazy { Files.createTempDirectory("lyng-io-tls-test-") } + private val serverStore: Path by lazy { dir.resolve("server.p12") } + private val trustStore: Path by lazy { dir.resolve("trust.p12") } + private val certFile: Path by lazy { dir.resolve("server.cer") } + + val serverSocketFactory: SSLServerSocketFactory by lazy { + ensureGenerated() + val keyStore = KeyStore.getInstance("PKCS12") + serverStore.toFile().inputStream().use { keyStore.load(it, STORE_PASSWORD.toCharArray()) } + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + kmf.init(keyStore, STORE_PASSWORD.toCharArray()) + SSLContext.getInstance("TLS").apply { + init(kmf.keyManagers, null, null) + }.serverSocketFactory + } + + val sslContext: SSLContext by lazy { + ensureGenerated() + val keyStore = KeyStore.getInstance("PKCS12") + serverStore.toFile().inputStream().use { keyStore.load(it, STORE_PASSWORD.toCharArray()) } + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + kmf.init(keyStore, STORE_PASSWORD.toCharArray()) + val trust = KeyStore.getInstance("PKCS12") + trustStore.toFile().inputStream().use { trust.load(it, STORE_PASSWORD.toCharArray()) } + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(trust) + SSLContext.getInstance("TLS").apply { + init(kmf.keyManagers, tmf.trustManagers, null) + } + } + + fun installJvmClientTrust() { + ensureGenerated() + System.setProperty("javax.net.ssl.trustStore", trustStore.absolutePathString()) + System.setProperty("javax.net.ssl.trustStorePassword", STORE_PASSWORD) + System.setProperty("javax.net.ssl.trustStoreType", "PKCS12") + + val trust = KeyStore.getInstance("PKCS12") + trustStore.toFile().inputStream().use { trust.load(it, STORE_PASSWORD.toCharArray()) } + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(trust) + SSLContext.setDefault( + SSLContext.getInstance("TLS").apply { + init(null, tmf.trustManagers, null) + } + ) + } + + private fun ensureGenerated() { + if (Files.exists(serverStore) && Files.exists(trustStore) && Files.exists(certFile)) return + runKeytool( + "-genkeypair", + "-alias", KEY_ALIAS, + "-keyalg", "RSA", + "-keysize", "2048", + "-validity", "2", + "-storetype", "PKCS12", + "-keystore", serverStore.absolutePathString(), + "-storepass", STORE_PASSWORD, + "-keypass", STORE_PASSWORD, + "-dname", "CN=127.0.0.1, OU=Lyng, O=Lyng, L=Test, ST=Test, C=US", + "-ext", "SAN=ip:127.0.0.1,dns:localhost", + ) + runKeytool( + "-exportcert", + "-alias", KEY_ALIAS, + "-keystore", serverStore.absolutePathString(), + "-storepass", STORE_PASSWORD, + "-rfc", + "-file", certFile.absolutePathString(), + ) + runKeytool( + "-importcert", + "-noprompt", + "-alias", KEY_ALIAS, + "-storetype", "PKCS12", + "-keystore", trustStore.absolutePathString(), + "-storepass", STORE_PASSWORD, + "-file", certFile.absolutePathString(), + ) + } + + private fun runKeytool(vararg args: String) { + val process = ProcessBuilder(listOf("keytool") + args) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText() + val code = process.waitFor() + require(code == 0) { "keytool failed: $output" } + } +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/ws/LyngWsModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/ws/LyngWsModuleTest.kt new file mode 100644 index 0000000..45c0781 --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/ws/LyngWsModuleTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.io.ws + +import kotlinx.coroutines.runBlocking +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.ws.security.PermitAllWsAccessPolicy +import net.sergeych.lyngio.ws.security.WsAccessOp +import net.sergeych.lyngio.ws.security.WsAccessPolicy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class LyngWsModuleTest { + + @Test + fun testTextSessionAndHeaders() = runBlocking { + TestWebSocketServer { connection -> + assertEquals("yes", connection.requestHeaders["x-test"]) + val text = connection.receiveText() + connection.sendText("echo:$text") + connection.close() + }.use { server -> + val scope = Script.newScope() + createWsModule(PermitAllWsAccessPolicy, scope) + + val code = """ + import lyng.io.ws + + val ws = Ws.connect("${server.url}", "X-Test" => "yes") + ws.sendText("ping") + val m: WsMessage = ws.receive() + ws.close() + [ws.url(), m.isText, m.text] + """.trimIndent() + + val result = Compiler.compile(code).execute(scope).inspect(scope) + assertTrue(result.contains(server.url), result) + assertTrue(result.contains("true,echo:ping"), result) + } + } + + @Test + fun testBinarySession() = runBlocking { + TestWebSocketServer { connection -> + val data = connection.receiveBinary() + connection.sendBinary(byteArrayOf(1, 2, 3) + data) + connection.close() + }.use { server -> + val scope = Script.newScope() + createWsModule(PermitAllWsAccessPolicy, scope) + + val code = """ + import lyng.buffer + import lyng.io.ws + + val ws = Ws.connect("${server.url}") + ws.sendBytes(Buffer(9, 8, 7)) + val m: WsMessage = ws.receive() + ws.close() + [m.isText, (m.data as Buffer).hex] + """.trimIndent() + + val result = Compiler.compile(code).execute(scope).inspect(scope) + assertTrue(result.contains("false,010203090807"), result) + } + } + + @Test + fun testSecureTextSession() = runBlocking { + TestWebSocketServer(secure = true) { connection -> + val text = connection.receiveText() + connection.sendText("secure:$text") + connection.close() + }.use { server -> + val scope = Script.newScope() + createWsModule(PermitAllWsAccessPolicy, scope) + + val code = """ + import lyng.io.ws + + val ws = Ws.connect("${server.url}") + ws.sendText("ping") + val m: WsMessage = ws.receive() + ws.close() + [ws.url(), m.text] + """.trimIndent() + + val result = Compiler.compile(code).execute(scope).inspect(scope) + assertTrue(result.contains(server.url), result) + assertTrue(result.contains("secure:ping"), result) + } + } + + @Test + fun testPolicyDenialSurfacesAsLyngError() = runBlocking { + val scope = Script.newScope() + val denyAll = object : WsAccessPolicy { + override suspend fun check(op: WsAccessOp, ctx: AccessContext): AccessDecision = + AccessDecision(Decision.Deny, "blocked by test policy") + } + createWsModule(denyAll, scope) + + val code = """ + import lyng.io.ws + Ws.connect("ws://127.0.0.1:1/ws") + """.trimIndent() + + val error = assertFailsWith { + Compiler.compile(code).execute(scope) + } + assertTrue(error.errorMessage.isNotBlank()) + } +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/ws/TestWebSocketServer.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/ws/TestWebSocketServer.kt new file mode 100644 index 0000000..b48c2dd --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/ws/TestWebSocketServer.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.io.ws + +import net.sergeych.lyng.io.testtls.TlsTestMaterial +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.security.MessageDigest +import java.util.Base64 +import kotlin.concurrent.thread + +internal class TestWebSocketServer( + secure: Boolean = false, + private val handler: (TestWebSocketConnection) -> Unit, +) : AutoCloseable { + private val server: ServerSocket = if (secure) { + TlsTestMaterial.installJvmClientTrust() + TlsTestMaterial.serverSocketFactory.createServerSocket(0, 50, InetAddress.getByName("127.0.0.1")) as ServerSocket + } else { + ServerSocket(0, 50, InetAddress.getByName("127.0.0.1")) + } + private val scheme = if (secure) "wss" else "ws" + private val worker = thread(start = true, name = "ws-test-server") { + try { + server.accept().use { socket -> + val connection = TestWebSocketConnection(socket) + connection.handshake() + handler(connection) + } + } catch (_: Exception) { + } + } + + val url: String = "$scheme://127.0.0.1:${server.localPort}/ws" + + override fun close() { + server.close() + worker.join(2000) + } +} + +internal class TestWebSocketConnection(socket: Socket) { + private val input = BufferedInputStream(socket.getInputStream()) + private val output = BufferedOutputStream(socket.getOutputStream()) + val requestHeaders = linkedMapOf() + + fun handshake() { + val requestLine = input.readAsciiLine() ?: error("missing websocket request line") + require(requestLine.startsWith("GET ")) { "unexpected request: $requestLine" } + while (true) { + val line = input.readAsciiLine() ?: error("unexpected EOF during websocket handshake") + if (line.isEmpty()) break + val colon = line.indexOf(':') + if (colon > 0) { + requestHeaders[line.substring(0, colon).trim().lowercase()] = line.substring(colon + 1).trim() + } + } + val key = requestHeaders["sec-websocket-key"] ?: error("missing Sec-WebSocket-Key") + val accept = Base64.getEncoder().encodeToString( + MessageDigest.getInstance("SHA-1") + .digest((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").toByteArray()) + ) + output.write( + ( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: $accept\r\n" + + "\r\n" + ).toByteArray() + ) + output.flush() + } + + fun receiveText(): String = receiveFrame().let { frame -> + require(frame.opcode == 0x1) { "expected text frame, got opcode=${frame.opcode}" } + frame.payload.decodeToString() + } + + fun receiveBinary(): ByteArray = receiveFrame().let { frame -> + require(frame.opcode == 0x2) { "expected binary frame, got opcode=${frame.opcode}" } + frame.payload + } + + fun sendText(text: String) { + sendFrame(0x1, text.encodeToByteArray()) + } + + fun sendBinary(data: ByteArray) { + sendFrame(0x2, data) + } + + fun close(code: Int = 1000, reason: String = "") { + val reasonBytes = reason.encodeToByteArray() + val payload = ByteArray(2 + reasonBytes.size) + payload[0] = ((code shr 8) and 0xff).toByte() + payload[1] = (code and 0xff).toByte() + reasonBytes.copyInto(payload, 2) + sendFrame(0x8, payload) + } + + private fun sendFrame(opcode: Int, payload: ByteArray) { + output.write(0x80 or opcode) + when { + payload.size < 126 -> output.write(payload.size) + payload.size <= 0xffff -> { + output.write(126) + output.write((payload.size ushr 8) and 0xff) + output.write(payload.size and 0xff) + } + else -> error("payload too large for test websocket server") + } + output.write(payload) + output.flush() + } + + private fun receiveFrame(): IncomingFrame { + val b0 = input.read() + require(b0 >= 0) { "unexpected EOF reading websocket frame" } + val b1 = input.read() + require(b1 >= 0) { "unexpected EOF reading websocket frame length" } + val opcode = b0 and 0x0f + var length = b1 and 0x7f + if (length == 126) { + length = (input.read() shl 8) or input.read() + } else if (length == 127) { + error("64-bit websocket lengths are not supported in tests") + } + val masked = (b1 and 0x80) != 0 + val mask = if (masked) ByteArray(4).also { input.readFully(it) } else null + val payload = ByteArray(length) + input.readFully(payload) + if (mask != null) { + for (i in payload.indices) payload[i] = (payload[i].toInt() xor mask[i % 4].toInt()).toByte() + } + return IncomingFrame(opcode, payload) + } +} + +private data class IncomingFrame(val opcode: Int, val payload: ByteArray) + +private fun BufferedInputStream.readAsciiLine(): String? { + val out = StringBuilder() + while (true) { + val b = read() + if (b < 0) return if (out.isEmpty()) null else out.toString() + if (b == '\n'.code) { + if (out.endsWith("\r")) out.setLength(out.length - 1) + return out.toString() + } + out.append(b.toChar()) + } +} + +private fun BufferedInputStream.readFully(target: ByteArray) { + var offset = 0 + while (offset < target.size) { + val read = read(target, offset, target.size - offset) + require(read > 0) { "unexpected EOF" } + offset += read + } +} diff --git a/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/net/PlatformNative.kt b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/net/PlatformNative.kt new file mode 100644 index 0000000..7a10ec5 --- /dev/null +++ b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/net/PlatformNative.kt @@ -0,0 +1,3 @@ +package net.sergeych.lyngio.net + +actual fun getSystemNetEngine(): LyngNetEngine = UnsupportedLyngNetEngine diff --git a/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/ws/PlatformNative.kt b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/ws/PlatformNative.kt new file mode 100644 index 0000000..e9bc921 --- /dev/null +++ b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/ws/PlatformNative.kt @@ -0,0 +1,3 @@ +package net.sergeych.lyngio.ws + +actual fun getSystemWsEngine(): LyngWsEngine = UnsupportedLyngWsEngine diff --git a/lyngio/stdlib/lyng/io/http.lyng b/lyngio/stdlib/lyng/io/http.lyng new file mode 100644 index 0000000..20f69ab --- /dev/null +++ b/lyngio/stdlib/lyng/io/http.lyng @@ -0,0 +1,72 @@ +package lyng.io.http + +/* + Response/header view that behaves like a map for the first value of each header name. + Multi-valued headers are exposed through `getAll`. +*/ +extern class HttpHeaders : Map { + /* Return the first value for the given header name, or null when absent. */ + fun get(name: String): String? + /* Return all values for the given header name, preserving wire order when available. */ + fun getAll(name: String): List + /* Return distinct header names present in this response. */ + fun names(): List +} + +/* Mutable request descriptor for programmatic HTTP calls. */ +extern class HttpRequest { + /* Request method, e.g. GET, POST, PUT, DELETE. */ + var method: String + /* Absolute URL including scheme. */ + var url: String + /* Simple request headers map. */ + var headers: Map + /* Optional UTF-8 request body. */ + var bodyText: String? + /* Optional binary request body. */ + var bodyBytes: Buffer? + /* Optional total request timeout in milliseconds. */ + var timeoutMillis: Int? +} + +/* HTTP response snapshot with cached body accessors. */ +extern class HttpResponse { + /* Numeric status code, e.g. 200, 404, 500. */ + val status: Int + /* Human-readable status text when available. */ + val statusText: String + /* Response headers. */ + val headers: HttpHeaders + /* Decode response body as text. Cached after first call. */ + fun text(): String + /* Return response body as bytes. Cached after first call. */ + fun bytes(): Buffer +} + +/* HTTP/HTTPS client singleton. */ +extern object Http { + /* True when HTTP client support is available on this runtime. */ + fun isSupported(): Bool + + /* Execute a request described by `HttpRequest`. */ + fun request(req: HttpRequest): HttpResponse + + /* + Convenience GET request. + `headers...` accepts `MapEntry` values such as `"Accept" => "text/plain"` + and 2-item lists such as `["Accept", "text/plain"]`. + */ + fun get(url: String, headers...): HttpResponse + + /* + Convenience POST request with UTF-8 body text. + `headers...` follows the same rules as `get`. + */ + fun post(url: String, bodyText: String = "", contentType: String? = null, headers...): HttpResponse + + /* + Convenience POST request with binary body. + `headers...` follows the same rules as `get`. + */ + fun postBytes(url: String, body: Buffer, contentType: String? = null, headers...): HttpResponse +} diff --git a/lyngio/stdlib/lyng/io/net.lyng b/lyngio/stdlib/lyng/io/net.lyng new file mode 100644 index 0000000..1428107 --- /dev/null +++ b/lyngio/stdlib/lyng/io/net.lyng @@ -0,0 +1,113 @@ +package lyng.io.net + +/* Address family for resolved or bound endpoints. */ +enum IpVersion { + IPV4, + IPV6 +} + +/* Concrete socket endpoint. */ +extern class SocketAddress { + /* Numeric or host-form address string. */ + val host: String + /* Transport port number. */ + val port: Int + /* Address family. */ + val ipVersion: IpVersion + /* True when obtained from DNS resolution rather than raw bind input. */ + val resolved: Bool + /* Stable printable form such as `127.0.0.1:4040` or `[::1]:4040`. */ + override fun toString(): String +} + +/* Datagram payload paired with sender/peer address. */ +extern class Datagram { + val data: Buffer + val address: SocketAddress +} + +/* Connected TCP socket. */ +extern class TcpSocket { + /* True while the socket remains open. */ + fun isOpen(): Bool + /* Local bound address after connect/bind. */ + fun localAddress(): SocketAddress + /* Connected peer address. */ + fun remoteAddress(): SocketAddress + /* Read up to `maxBytes`; returns null on clean EOF before any data. */ + fun read(maxBytes: Int = 65536): Buffer? + /* Read one UTF-8 line without trailing newline; null on clean EOF. */ + fun readLine(): String? + /* Write all bytes from the buffer. */ + fun write(data: Buffer): void + /* Write UTF-8 encoded text. */ + fun writeUtf8(text: String): void + /* Flush buffered output if needed by the backend. */ + fun flush(): void + /* Close the socket. */ + fun close(): void +} + +/* Listening TCP server socket. */ +extern class TcpServer { + /* True while the listening socket remains open. */ + fun isOpen(): Bool + /* Actual local bind address. */ + fun localAddress(): SocketAddress + /* Accept next incoming client connection. */ + fun accept(): TcpSocket + /* Close the listening socket. */ + fun close(): void +} + +/* Bound UDP socket. */ +extern class UdpSocket { + /* True while the socket remains open. */ + fun isOpen(): Bool + /* Actual local bind address. */ + fun localAddress(): SocketAddress + /* Receive one datagram up to `maxBytes`, or null on clean shutdown. */ + fun receive(maxBytes: Int = 65536): Datagram? + /* Send one datagram to the provided host and port. */ + fun send(data: Buffer, host: String, port: Int): void + /* Close the socket. */ + fun close(): void +} + +/* Raw transport networking singleton. */ +extern object Net { + /* True when at least one transport feature is implemented on this runtime. */ + fun isSupported(): Bool + /* True when outbound TCP client sockets are available. */ + fun isTcpAvailable(): Bool + /* True when listening TCP server sockets are available. */ + fun isTcpServerAvailable(): Bool + /* True when UDP datagram sockets are available. */ + fun isUdpAvailable(): Bool + + /* Resolve host name and port into concrete addresses. */ + fun resolve(host: String, port: Int): List + + /* Connect outbound TCP socket. */ + fun tcpConnect( + host: String, + port: Int, + timeoutMillis: Int? = null, + noDelay: Bool = true + ): TcpSocket + + /* Start listening TCP server socket. */ + fun tcpListen( + port: Int, + host: String? = null, + backlog: Int = 128, + reuseAddress: Bool = true + ): TcpServer + + /* Bind UDP socket, using an ephemeral port by default. */ + fun udpBind( + port: Int = 0, + host: String? = null, + reuseAddress: Bool = true + ): UdpSocket +} diff --git a/lyngio/stdlib/lyng/io/ws.lyng b/lyngio/stdlib/lyng/io/ws.lyng new file mode 100644 index 0000000..0e096c7 --- /dev/null +++ b/lyngio/stdlib/lyng/io/ws.lyng @@ -0,0 +1,40 @@ +package lyng.io.ws + +/* Received WebSocket message. */ +extern class WsMessage { + /* True when this message carries text payload. */ + val isText: Bool + /* Text payload for text messages, otherwise null. */ + val text: String? + /* Binary payload for binary messages, otherwise null. */ + val data: Buffer? +} + +/* Active WebSocket client session. */ +extern class WsSession { + /* True while the session remains open. */ + fun isOpen(): Bool + /* Connected URL string. */ + fun url(): String + /* Send a text message. */ + fun sendText(text: String): void + /* Send a binary message. */ + fun sendBytes(data: Buffer): void + /* Receive next message, or null on clean close. */ + fun receive(): WsMessage? + /* Close the session with optional status code and reason. */ + fun close(code: Int = 1000, reason: String = ""): void +} + +/* WebSocket client singleton. */ +extern object Ws { + /* True when WebSocket client support is available on this runtime. */ + fun isSupported(): Bool + + /* + Open a client WebSocket session. + `headers...` accepts `MapEntry` values such as `"Authorization" => "Bearer x"` + and 2-item lists such as `["Authorization", "Bearer x"]`. + */ + fun connect(url: String, headers...): WsSession +} diff --git a/lynglib/build.gradle.kts b/lynglib/build.gradle.kts index 1ae8939..7289dac 100644 --- a/lynglib/build.gradle.kts +++ b/lynglib/build.gradle.kts @@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget group = "net.sergeych" -version = "1.5.3" +version = "1.5.4-SNAPSHOT" // Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below diff --git a/proposals/lyngio_network_module.md b/proposals/lyngio_network_module.md new file mode 100644 index 0000000..a4dbc30 --- /dev/null +++ b/proposals/lyngio_network_module.md @@ -0,0 +1,577 @@ +# Proposal: Ktor-Backed Networking Modules For `lyngio` + +Status: Draft +Date: 2026-04-02 +Owner: `lyngio` + +## Context + +`lyngio` currently provides optional modules for filesystem, process execution, and console access. Networking is still missing. + +The first networking release should optimize for: + +- broad multiplatform reach from day one +- a stable Lyng-native API surface +- small implementation cost by building on existing, well-tested KMP libraries +- explicit capability discovery, because not every transport exists on every platform + +The proposed implementation base is: + +- `io.ktor:ktor-client-*` for HTTP/HTTPS +- Ktor WebSockets client on top of the HTTP client for `ws` and `wss` +- `io.ktor:ktor-network` for TCP client/server sockets and UDP datagrams +- no `ktor-server` + +The critical design constraint is that Lyng must not expose Ktor types directly. Ktor is an implementation dependency, not the public scripting model. + +## Goals + +- Add first-release networking support with a practical breadth of features: + - HTTP/HTTPS client + - WebSocket client (`ws` / `wss`) + - TCP client socket + - TCP server socket where supported + - UDP socket with a minimal datagram API where supported +- Keep the Lyng-facing API uniform across platforms. +- Provide capability discovery functions so scripts can branch safely. +- Keep declarations in `.lyng` files as the source of truth. +- Preserve room for later lower-level or non-universal socket APIs. + +## Non-goals (phase 1) + +- exposing Ktor classes, channels, frames, engines, or plugins directly to Lyng +- HTTP server support +- TLS configuration beyond default secure client behavior +- fully general stream/channel APIs for every transport +- advanced socket tuning beyond a minimal useful set +- promising identical runtime support on every platform + +## Proposed module split + +Use three Lyng modules instead of one monolith: + +- `lyng.io.http` +- `lyng.io.ws` +- `lyng.io.net` + +Rationale: + +- HTTP and WebSocket are much more portable than raw sockets. +- TCP/UDP belong in a transport module, not in the HTTP client API. +- Each module can have its own capability checks while still sharing common address and security types underneath. +- The user-facing import structure stays clear and unsurprising. + +## Uniform capability model + +Every module should expose explicit capability discovery. + +Recommended shape: + +- `Http.isSupported(): Bool` +- `Ws.isSupported(): Bool` +- `Net.isSupported(): Bool` +- `Net.isTcpAvailable(): Bool` +- `Net.isTcpServerAvailable(): Bool` +- `Net.isUdpAvailable(): Bool` + +Optional richer form for later: + +- `Http.details(): HttpSupport` +- `Ws.details(): WsSupport` +- `Net.details(): NetSupport` + +For v1, booleans are enough. + +Scripts should be able to write: + +```lyng +if (Net.isTcpServerAvailable()) { + val server = Net.tcpListen(4040) +} +``` + +This is preferable to hard-failing during import or construction. + +## Design principles + +### 1. Lyng-native API, Ktor-backed implementation + +Public Lyng objects should be small and script-oriented. + +Do not expose: + +- `HttpClient` +- `HttpResponse` +- `ByteReadChannel` +- `ByteWriteChannel` +- `Frame` +- Ktor engine names +- Ktor pipeline/plugin concepts + +Instead, expose a narrow stable surface implemented internally via Ktor. + +### 2. Minimal but broad v1 + +The first release should support more transport kinds, but only with the smallest coherent API for each kind. + +That means: + +- HTTP: request/response, headers, text/body bytes, status, method +- WebSocket: connect, send text/binary, receive text/binary, close +- TCP: connect, accept, read, write, close +- UDP: bind, send datagram, receive datagram, close + +Notably absent from v1: + +- interceptors +- cookies/session stores beyond defaults +- multipart builders +- HTTP/2 tuning knobs +- ping/pong tuning for WebSockets +- socket option explosion + +### 3. Uniform shapes where practical + +Across transports, prefer the same ideas: + +- `isOpen()` +- `close()` +- `localAddress()` +- `remoteAddress()` when meaningful +- `read(...)`, `write(...)` +- `receive()`, `send(...)` for datagrams/messages +- `isSupported()` / capability methods on module singletons + +This keeps discovery predictable without forcing false sameness where semantics differ. + +## Proposed Lyng modules + +### `lyng.io.http` + +Purpose: + +- high-level HTTP/HTTPS client backed by Ktor client + +Recommended declaration sketch: + +```lyng +package lyng.io.http + +extern class HttpHeaders : Map { + fun get(name: String): String? + fun getAll(name: String): List + fun names(): List +} + +extern class HttpRequest { + var method: String + var url: String + var headers: Map + var bodyText: String? + var bodyBytes: Buffer? + var timeoutMillis: Int? +} + +extern class HttpResponse { + val status: Int + val statusText: String + val headers: HttpHeaders + fun text(): String + fun bytes(): Buffer +} + +extern object Http { + fun isSupported(): Bool + fun request(req: HttpRequest): HttpResponse + fun get(url: String, headers...): HttpResponse + fun post(url: String, bodyText: String = "", contentType: String? = null, headers...): HttpResponse + fun postBytes(url: String, body: Buffer, contentType: String? = null, headers...): HttpResponse +} +``` + +Notes: + +- `HttpRequest` is mutable because it is ergonomic in scripts. +- `headers` is intentionally simple in v1: `Map` for programmatic request construction. +- `HttpHeaders` is primarily for response headers, where repeated names matter. +- `text()` and `bytes()` should cache decoded content inside the response object. +- For convenience methods, `headers...` should accept `MapEntry` values such as `"X-Token" => "abc"` and `[key, value]` pairs, then normalize them internally into request headers. + +### `lyng.io.ws` + +Purpose: + +- WebSocket client built on Ktor client WebSockets + +Recommended declaration sketch: + +```lyng +package lyng.io.ws + +extern class WsMessage { + val isText: Bool + val text: String? + val data: Buffer? +} + +extern class WsSession { + fun isOpen(): Bool + fun url(): String + fun sendText(text: String): void + fun sendBytes(data: Buffer): void + fun receive(): WsMessage? + fun close(code: Int = 1000, reason: String = ""): void +} + +extern object Ws { + fun isSupported(): Bool + fun connect(url: String, headers...): WsSession +} +``` + +Notes: + +- Keep frames hidden. Lyng sees messages. +- `receive()` returns `null` on clean close. +- v1 supports text and binary messages only. +- `headers...` should follow the same rules as `Http.get(...)`: `MapEntry` values or `[key, value]` pairs. + +### `lyng.io.net` + +Purpose: + +- transport sockets backed by `ktor-network` + +Recommended declaration sketch: + +```lyng +package lyng.io.net + +enum IpVersion { + IPV4, + IPV6 +} + +extern class SocketAddress { + val host: String + val port: Int + val ipVersion: IpVersion + val resolved: Bool + fun toString(): String +} + +extern class Datagram { + val data: Buffer + val address: SocketAddress +} + +extern class TcpSocket { + fun isOpen(): Bool + fun localAddress(): SocketAddress + fun remoteAddress(): SocketAddress + fun read(maxBytes: Int = 65536): Buffer? + fun readLine(): String? + fun write(data: Buffer): void + fun writeUtf8(text: String): void + fun flush(): void + fun close(): void +} + +extern class TcpServer { + fun isOpen(): Bool + fun localAddress(): SocketAddress + fun accept(): TcpSocket + fun close(): void +} + +extern class UdpSocket { + fun isOpen(): Bool + fun localAddress(): SocketAddress + fun receive(maxBytes: Int = 65536): Datagram? + fun send(data: Buffer, host: String, port: Int): void + fun close(): void +} + +extern object Net { + fun isSupported(): Bool + fun isTcpAvailable(): Bool + fun isTcpServerAvailable(): Bool + fun isUdpAvailable(): Bool + + fun resolve(host: String, port: Int): List + + fun tcpConnect( + host: String, + port: Int, + timeoutMillis: Int? = null, + noDelay: Bool = true + ): TcpSocket + + fun tcpListen( + port: Int, + host: String? = null, + backlog: Int = 128, + reuseAddress: Bool = true + ): TcpServer + + fun udpBind( + port: Int = 0, + host: String? = null, + reuseAddress: Bool = true + ): UdpSocket +} +``` + +Notes: + +- UDP is included in v1, but only with a minimal datagram API. +- `udpBind` should bind an ephemeral port by default. +- TCP server methods exist only where the backend supports them, but the declaration stays uniform. + +## Example usage + +HTTP: + +```lyng +import lyng.io.http + +if (!Http.isSupported()) + throw IllegalStateException("http is not supported here") + +val r = Http.get( + "https://example.com", + "Accept" => "text/plain", + "X-Trace" => "demo" +) +println(r.status) +println(r.text()) +``` + +WebSocket: + +```lyng +import lyng.io.ws + +if (Ws.isSupported()) { + val ws = Ws.connect( + "wss://echo.example/ws", + "Authorization" => "Bearer " + ) + try { + ws.sendText("hello") + val msg = ws.receive() + if (msg != null && msg.isText) + println(msg.text) + } finally { + ws.close() + } +} +``` + +TCP: + +```lyng +import lyng.io.net + +if (Net.isTcpAvailable()) { + val s = Net.tcpConnect("example.com", 80) + try { + s.writeUtf8("GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n") + s.flush() + while (true) { + val line = s.readLine() + if (line == null) break + println(line) + } + } finally { + s.close() + } +} +``` + +TCP server: + +```lyng +import lyng.io.net + +if (Net.isTcpServerAvailable()) { + val server = Net.tcpListen(4040, host="127.0.0.1") + println("listening on " + server.localAddress()) + + try { + while (server.isOpen()) { + val client = server.accept() + launch { + try { + println("accepted " + client.remoteAddress()) + + val requestLine = client.readLine() + if (requestLine != null) + println("request: " + requestLine) + + while (true) { + val line = client.readLine() + if (line == null || line == "") break + } + + client.writeUtf8("hello from lyng server\n") + client.flush() + } finally { + client.close() + } + } + } + } finally { + server.close() + } +} +``` + +UDP: + +```lyng +import lyng.io.net + +if (Net.isUdpAvailable()) { + val udp = Net.udpBind() + try { + udp.send(Buffer("ping"), "127.0.0.1", 4041) + val d = udp.receive() + if (d != null) + println("from " + d.address + ": " + d.data) + } finally { + udp.close() + } +} +``` + +## Platform support model + +The interface is uniform. Support is platform-dependent. + +Initial intent: + +| Platform | HTTP | WS client | TCP client | TCP server | UDP | +| :--- | :---: | :---: | :---: | :---: | :---: | +| JVM | yes | yes | yes | yes | yes | +| Android | yes | yes | likely yes | likely yes | likely yes | +| Native Linux/macOS | yes | yes | target | target | target | +| Native Windows | yes | yes | target | target | target | +| NodeJS | yes | engine-dependent | target | target | target | +| Browser / Wasm | yes | engine-dependent | no or target-specific | no | no | + +Interpretation rules: + +- import succeeds when the module is installed +- construction/operations may still fail if the specific feature is unavailable +- discovery methods must let scripts avoid that path cleanly +- `isSupported()` means the module has at least one useful implementation on the current runtime + +## Security model + +Use separate policies per module to avoid muddy authorization. + +Recommended policies: + +- `HttpAccessPolicy` +- `WsAccessPolicy` +- `NetAccessPolicy` + +Suggested operations: + +```kotlin +sealed interface HttpAccessOp { + data class Request(val method: String, val url: String) : HttpAccessOp +} + +sealed interface WsAccessOp { + data class Connect(val url: String) : WsAccessOp + data class Send(val url: String, val bytes: Int, val isText: Boolean) : WsAccessOp + data class Receive(val url: String) : WsAccessOp +} + +sealed interface NetAccessOp { + data class Resolve(val host: String, val port: Int) : NetAccessOp + data class TcpConnect(val host: String, val port: Int) : NetAccessOp + data class TcpListen(val host: String?, val port: Int, val backlog: Int) : NetAccessOp + data class TcpAccept(val localHost: String?, val localPort: Int, val remoteHost: String, val remotePort: Int) : NetAccessOp + data class TcpRead(val remoteHost: String, val remotePort: Int, val requestedBytes: Int) : NetAccessOp + data class TcpWrite(val remoteHost: String, val remotePort: Int, val bytes: Int) : NetAccessOp + data class UdpBind(val host: String?, val port: Int) : NetAccessOp + data class UdpSend(val host: String, val port: Int, val bytes: Int) : NetAccessOp + data class UdpReceive(val localHost: String?, val localPort: Int, val requestedBytes: Int) : NetAccessOp +} +``` + +This is intentionally strict. It lets embedders enforce: + +- allow HTTP but forbid raw sockets +- allow outbound TCP but forbid listening sockets +- allow UDP only on loopback +- allow WebSockets only to whitelisted hosts + +## Error mapping + +Like other `lyngio` modules, host exceptions should be mapped into Lyng runtime errors. + +Recommended v1 mapping: + +- policy denial -> illegal operation +- malformed URL / invalid host / invalid port -> illegal argument +- connect failure / bind failure / timeout / protocol failure -> illegal state + +Later, if needed, add typed Lyng exceptions such as: + +- `HttpException` +- `WebSocketException` +- `NetworkException` +- `TimeoutException` + +## Implementation outline + +Recommended layout: + +- `lyngio/stdlib/lyng/io/http.lyng` +- `lyngio/stdlib/lyng/io/ws.lyng` +- `lyngio/stdlib/lyng/io/net.lyng` +- `lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt` +- `lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/ws/LyngWsModule.kt` +- `lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/net/LyngNetModule.kt` +- `lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/...` +- `lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/...` +- `lyngio/src/commonMain/kotlin/net/sergeych/lyngio/net/...` + +Module installers should mirror existing `lyngio` patterns: + +```kotlin +fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean +fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean +fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean +``` + +As with `lyng.io.console`, the registrar should evaluate embedded `.lyng` declarations into module scope. + +## Why this is better than the earlier raw-sockets-first plan + +- it delivers useful networking sooner +- it covers HTTP and WebSocket immediately, not as future work +- it reuses mature KMP networking code instead of creating a transport stack from scratch +- it still leaves room for a later low-level socket API if Ktor abstractions prove insufficient + +## Open questions + +1. Should `Http` use static convenience methods only in v1, or also expose a reusable `HttpClient`-like Lyng object later? +2. Should `Ws.connect(...)` accept only URL plus headers, or also subprotocols in v1? +3. Should `TcpSocket.readExact(...)` be included from the start, or wait until there is a real use case? +4. Should `UdpSocket.send(...)` accept `SocketAddress` overloads in v1, or only `host` and `port`? +5. Should `isSupported()` return true when only a subset of the module works, or should module support be stricter? + +## Recommended implementation order + +1. `lyng.io.http` +2. `lyng.io.ws` +3. `lyng.io.net` TCP client +4. `lyng.io.net` TCP server +5. `lyng.io.net` UDP + +This order matches both practical usefulness and implementation risk.