Compare commits

..

9 Commits

Author SHA1 Message Date
9bee0aed5b Merge branch 'inference-bug'
# Conflicts:
#	lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt
2026-04-02 20:06:11 +03:00
cd7e001f41 Refactor KMP networking backends 2026-04-02 20:04:25 +03:00
62520f6203 Add downloads page and navigation link to site 2026-04-02 19:27:19 +03:00
d0aaa2c256 fix tetris code: declaration now in place 2026-04-02 19:25:17 +03:00
5346d15a9f Add KMP networking backends 2026-04-02 19:23:46 +03:00
7578128689 some order to outdated and special docs 2026-04-02 19:10:48 +03:00
71243b6bde Stabilize browser unicode script test 2026-04-02 17:10:58 +03:00
7b65ff9d0e Fix inference regression and green tests 2026-04-02 16:48:14 +03:00
d409a4bb8b Add verified Ktor-backed networking modules to lyngio 2026-04-02 16:19:14 +03:00
67 changed files with 5668 additions and 41 deletions

View File

@ -1,5 +1,7 @@
# Lyng Language Reference for AI Agents (Current Compiler State) # Lyng Language Reference for AI Agents (Current Compiler State)
[//]: # (excludeFromIndex)
Purpose: dense, implementation-first reference for generating valid Lyng code. Purpose: dense, implementation-first reference for generating valid Lyng code.
Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,Token,Compiler,Script,TypeDecl}.kt`, `lynglib/stdlib/lyng/root.lyng`, tests in `lynglib/src/commonTest` and `lynglib/src/jvmTest`. Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,Token,Compiler,Script,TypeDecl}.kt`, `lynglib/stdlib/lyng/root.lyng`, tests in `lynglib/src/commonTest` and `lynglib/src/jvmTest`.

View File

@ -1,5 +1,7 @@
# AI notes: avoid Kotlin/Wasm invalid IR with suspend lambdas # AI notes: avoid Kotlin/Wasm invalid IR with suspend lambdas
[//]: # (excludeFromIndex)
## Do ## Do
- Prefer explicit `object : Statement()` with `override suspend fun execute(...)` when building compiler statements. - Prefer explicit `object : Statement()` with `override suspend fun execute(...)` when building compiler statements.
- Keep `Statement` objects non-lambda, especially in compiler hot paths like parsing/var declarations. - Keep `Statement` objects non-lambda, especially in compiler hot paths like parsing/var declarations.

View File

@ -1,5 +1,7 @@
# Lyng Stdlib Reference for AI Agents (Compact) # Lyng Stdlib Reference for AI Agents (Compact)
[//]: # (excludeFromIndex)
Purpose: fast overview of what is available by default and what must be imported. Purpose: fast overview of what is available by default and what must be imported.
Sources: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt`, `lynglib/stdlib/lyng/root.lyng`, `lynglib/src/commonMain/kotlin/net/sergeych/lyng/stdlib_included/observable_lyng.kt`. Sources: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt`, `lynglib/stdlib/lyng/root.lyng`, `lynglib/src/commonMain/kotlin/net/sergeych/lyng/stdlib_included/observable_lyng.kt`.
@ -82,8 +84,11 @@ Requires installing `lyngio` into the import manager from host code.
- `import lyng.io.fs` (filesystem `Path` API) - `import lyng.io.fs` (filesystem `Path` API)
- `import lyng.io.process` (process execution API) - `import lyng.io.process` (process execution API)
- `import lyng.io.console` (console capabilities, geometry, ANSI/output, events) - `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 ## 7. AI Generation Tips
- Assume `lyng.stdlib` APIs exist in regular script contexts. - 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`). - Prefer extension-method style (`items.filter { ... }`) and standard scope helpers (`let`/`also`/`apply`/`run`).

11
docs/downloads.md Normal file
View File

@ -0,0 +1,11 @@
# Some resources to download
## Lync CLI tool
- [lyng-linuxX64.zip](/distributables/lyng-linuxX64.zip) CLI tool for linuxX64: nodependencies, small monolith executable binary.
## IDE plugins
- [lyng-textmate.zip](../../lyng/distributables/lyng-textmate.zip) Texmate-compatible bundle with syntax coloring (could be outdated)
- [lyng-idea-0.0.5-SNAPSHOT.zip](/distributables/lyng-idea-0.0.5-SNAPSHOT.zip) - plugin for IntelliJ-compatible IDE

View File

@ -1,5 +1,7 @@
# Embedding Lyng in your Kotlin project # Embedding Lyng in your Kotlin project
[//]: # (topMenu)
Lyng is a tiny, embeddable, Kotlin‑first scripting language. This page shows, step by step, how to: Lyng is a tiny, embeddable, Kotlin‑first scripting language. This page shows, step by step, how to:
- add Lyng to your build - add Lyng to your build

179
docs/lyng.io.http.md Normal file
View File

@ -0,0 +1,179 @@
### 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<String, String>`
- `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<String, String>` for the first value of each header name and additionally exposes:
- `get(name: String): String?`
- `getAll(name: String): List<String>`
- `names(): List<String>`
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("https://127.0.0.1:") ||
op.url.startsWith("http://localhost:") ||
op.url.startsWith("https://localhost:")
)
AccessDecision(Decision.Allow)
else
AccessDecision(Decision.Deny, "only local HTTP/HTTPS requests are allowed")
}
}
```
---
#### Platform support
- **JVM:** supported
- **Android:** supported via the Ktor CIO client backend
- **JS:** supported via the Ktor JS client backend
- **Linux native:** supported via the Ktor Curl client backend
- **Windows native:** supported via the Ktor WinHttp client backend
- **Apple native:** supported via the Ktor Darwin client backend
- **Other targets:** may report unsupported; use `Http.isSupported()` before relying on it

169
docs/lyng.io.net.md Normal file
View File

@ -0,0 +1,169 @@
### lyng.io.net — TCP and UDP sockets for Lyng scripts
This module provides minimal raw transport networking for Lyng scripts. It is implemented in `lyngio` and backed by Ktor sockets on the JVM and Linux Native, and by Node networking APIs on JS/Node runtimes.
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
---
#### 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<SocketAddress>` — 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
- **Android:** supported via the Ktor CIO and Ktor sockets backends
- **JS/Node:** supported for `resolve`, TCP client/server, and UDP
- **JS/browser:** unsupported; capability checks report unavailable
- **Linux Native:** supported via Ktor sockets
- **Apple Native:** enabled via the shared native Ktor sockets backend; compile-verified, runtime not yet host-verified
- **Other native targets:** currently report unsupported; use capability checks before relying on raw sockets

148
docs/lyng.io.ws.md Normal file
View File

@ -0,0 +1,148 @@
### 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)`
Example restricted policy in Kotlin:
```kotlin
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
import net.sergeych.lyngio.ws.security.WsAccessOp
import net.sergeych.lyngio.ws.security.WsAccessPolicy
val allowLocalOnly = object : WsAccessPolicy {
override suspend fun check(op: WsAccessOp, ctx: AccessContext): AccessDecision =
when (op) {
is WsAccessOp.Connect ->
if (
op.url.startsWith("ws://127.0.0.1:") ||
op.url.startsWith("wss://127.0.0.1:") ||
op.url.startsWith("ws://localhost:") ||
op.url.startsWith("wss://localhost:")
)
AccessDecision(Decision.Allow)
else
AccessDecision(Decision.Deny, "only local ws/wss connections are allowed")
else -> AccessDecision(Decision.Allow)
}
}
```
---
#### Platform support
- **JVM:** supported
- **Android:** supported via the Ktor CIO websocket client backend
- **JS:** supported via the Ktor JS websocket client backend
- **Linux native:** supported via the Ktor Curl websocket client backend
- **Windows native:** supported via the Ktor WinHttp websocket client backend
- **Apple native:** supported via the Ktor Darwin websocket client backend
- **Other targets:** may report unsupported; use `Ws.isSupported()` before relying on websocket client access

View File

@ -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.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.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.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.fs.createFs
import net.sergeych.lyng.io.process.createProcessModule import net.sergeych.lyng.io.process.createProcessModule
import net.sergeych.lyng.io.console.createConsoleModule 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.fs.security.PermitAllAccessPolicy
import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy 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() { suspend fun runMyScript() {
val session = EvalSession() val session = EvalSession()
@ -53,16 +62,25 @@ suspend fun runMyScript() {
createFs(PermitAllAccessPolicy, scope) createFs(PermitAllAccessPolicy, scope)
createProcessModule(PermitAllProcessAccessPolicy, scope) createProcessModule(PermitAllProcessAccessPolicy, scope)
createConsoleModule(PermitAllConsoleAccessPolicy, scope) createConsoleModule(PermitAllConsoleAccessPolicy, scope)
createHttpModule(PermitAllHttpAccessPolicy, scope)
createNetModule(PermitAllNetAccessPolicy, scope)
createWsModule(PermitAllWsAccessPolicy, scope)
// Now scripts can import them // Now scripts can import them
session.eval(""" session.eval("""
import lyng.io.fs import lyng.io.fs
import lyng.io.process import lyng.io.process
import lyng.io.console import lyng.io.console
import lyng.io.http
import lyng.io.net
import lyng.io.ws
println("Working dir: " + Path(".").readUtf8()) println("Working dir: " + Path(".").readUtf8())
println("OS: " + Platform.details().name) println("OS: " + Platform.details().name)
println("TTY: " + Console.isTty()) 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). - **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. - **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. - **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: For more details, see the specific module documentation:
- [Filesystem Security Details](lyng.io.fs.md#access-policy-security) - [Filesystem Security Details](lyng.io.fs.md#access-policy-security)
- [Process Security Details](lyng.io.process.md#security-policy) - [Process Security Details](lyng.io.process.md#security-policy)
- [Console Module Details](lyng.io.console.md) - [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 Support Overview
| Platform | lyng.io.fs | lyng.io.process | lyng.io.console | | Platform | lyng.io.fs | lyng.io.process | lyng.io.console | lyng.io.http | lyng.io.ws | lyng.io.net |
| :--- | :---: | :---: | :---: | | :--- | :---: | :---: | :---: | :---: | :---: | :---: |
| **JVM** | ✅ | ✅ | ✅ (baseline) | | **JVM** | ✅ | ✅ | ✅ (baseline) | ✅ | ✅ | ✅ |
| **Native (Linux/macOS)** | ✅ | ✅ | 🚧 | | **Native (Linux/macOS)** | ✅ | ✅ | 🚧 | 🚧 | 🚧 | 🚧 |
| **Native (Windows)** | ✅ | 🚧 (Planned) | 🚧 | | **Native (Windows)** | ✅ | 🚧 (Planned) | 🚧 | 🚧 | 🚧 | 🚧 |
| **Android** | ✅ | ❌ | ❌ | | **Android** | ✅ | ❌ | ❌ | 🚧 | 🚧 | 🚧 |
| **NodeJS** | ✅ | ❌ | ❌ | | **NodeJS** | ✅ | ❌ | ❌ | 🚧 | 🚧 | 🚧 |
| **Browser / Wasm** | ✅ (In-memory) | ❌ | ❌ | | **Browser / Wasm** | ✅ (In-memory) | ❌ | ❌ | 🚧 | 🚧 | 🚧 |

View File

@ -49,6 +49,7 @@ val UNICODE_BOTTOM_RIGHT = "┘"
val UNICODE_HORIZONTAL = "──" val UNICODE_HORIZONTAL = "──"
val UNICODE_VERTICAL = "│" val UNICODE_VERTICAL = "│"
val UNICODE_DOT = "· " val UNICODE_DOT = "· "
val PIECES: List<Piece> = []
type Cell = List<Int> type Cell = List<Int>
type Rotation = List<Cell> type Rotation = List<Cell>
@ -468,8 +469,6 @@ fun rot(a: Cell, b: Cell, c: Cell, d: Cell): Rotation {
r r
} }
val PIECES: List<Piece> = []
val iRots: Rotations = [] val iRots: Rotations = []
iRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(3,1))) iRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(3,1)))
iRots.add(rot(cell(2,0), cell(2,1), cell(2,2), cell(2,3))) iRots.add(rot(cell(2,0), cell(2,1), cell(2,2), cell(2,3)))

View File

@ -13,6 +13,7 @@ multik = "0.3.0"
firebaseCrashlyticsBuildtools = "3.0.3" firebaseCrashlyticsBuildtools = "3.0.3"
okioVersion = "3.10.2" okioVersion = "3.10.2"
compiler = "3.2.0-alpha11" compiler = "3.2.0-alpha11"
ktor = "3.3.1"
[libraries] [libraries]
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
@ -31,6 +32,14 @@ okio = { module = "com.squareup.okio:okio", version.ref = "okioVersion" }
okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", 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" } okio-nodefilesystem = { module = "com.squareup.okio:okio-nodefilesystem", version.ref = "okioVersion" }
compiler = { group = "androidx.databinding", name = "compiler", version.ref = "compiler" } compiler = { group = "androidx.databinding", name = "compiler", version.ref = "compiler" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-client-winhttp = { module = "io.ktor:ktor-client-winhttp", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" }
[plugins] [plugins]
androidLibrary = { id = "com.android.library", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" }

View File

@ -31,11 +31,12 @@ class FsIntegrationJvmTest {
val dir = createTempDirectory("lyng_cli_fs_test_") val dir = createTempDirectory("lyng_cli_fs_test_")
try { try {
val file = dir.resolve("hello.txt") val file = dir.resolve("hello.txt")
val filePath = file.toString().replace("\\", "\\\\")
// Drive the operation via Lyng code to validate bindings end-to-end // Drive the operation via Lyng code to validate bindings end-to-end
scope.eval( scope.eval(
""" """
import lyng.io.fs import lyng.io.fs
val p = Path("${'$'}{file}") val p = Path("${filePath}")
p.writeUtf8("hello from cli test") p.writeUtf8("hello from cli test")
assertEquals(true, p.exists()) assertEquals(true, p.exists())
assertEquals("hello from cli test", p.readUtf8()) assertEquals("hello from cli test", p.readUtf8())

View File

@ -80,28 +80,49 @@ kotlin {
api(libs.okio) api(libs.okio)
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
api(libs.mordant.core) api(libs.mordant.core)
api(libs.ktor.client.core)
implementation(libs.ktor.client.websockets)
} }
} }
val nativeMain by creating { val nativeMain by creating {
dependsOn(commonMain) dependsOn(commonMain)
dependencies {
implementation(libs.ktor.network)
}
}
val darwinMain by creating {
dependsOn(nativeMain)
dependencies {
implementation(libs.ktor.client.darwin)
}
} }
val iosMain by creating { val iosMain by creating {
dependsOn(nativeMain) dependsOn(darwinMain)
} }
val linuxMain by creating { val linuxMain by creating {
dependsOn(nativeMain) dependsOn(nativeMain)
dependencies {
implementation(libs.ktor.client.curl)
}
} }
val macosMain by creating { val macosMain by creating {
dependsOn(nativeMain) dependsOn(darwinMain)
} }
val mingwMain by creating { val mingwMain by creating {
dependsOn(nativeMain) dependsOn(nativeMain)
dependencies {
implementation(libs.ktor.client.winhttp)
}
} }
val commonTest by getting { val commonTest by getting {
dependencies { dependencies {
implementation(libs.kotlin.test) implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
} }
} }
val linuxTest by creating {
dependsOn(commonTest)
}
val iosX64Main by getting { dependsOn(iosMain) } val iosX64Main by getting { dependsOn(iosMain) }
val iosArm64Main by getting { dependsOn(iosMain) } val iosArm64Main by getting { dependsOn(iosMain) }
val iosSimulatorArm64Main by getting { dependsOn(iosMain) } val iosSimulatorArm64Main by getting { dependsOn(iosMain) }
@ -109,6 +130,8 @@ kotlin {
val mingwX64Main by getting { dependsOn(mingwMain) } val mingwX64Main by getting { dependsOn(mingwMain) }
val linuxX64Main by getting { dependsOn(linuxMain) } val linuxX64Main by getting { dependsOn(linuxMain) }
val linuxArm64Main by getting { dependsOn(linuxMain) } val linuxArm64Main by getting { dependsOn(linuxMain) }
val linuxX64Test by getting { dependsOn(linuxTest) }
val linuxArm64Test by getting { dependsOn(linuxTest) }
// JS: use runtime detection in jsMain to select Node vs Browser implementation // JS: use runtime detection in jsMain to select Node vs Browser implementation
val jsMain by getting { val jsMain by getting {
@ -116,6 +139,13 @@ kotlin {
api(libs.okio) api(libs.okio)
implementation(libs.okio.fakefilesystem) implementation(libs.okio.fakefilesystem)
implementation("com.squareup.okio:okio-nodefilesystem:${libs.versions.okioVersion.get()}") implementation("com.squareup.okio:okio-nodefilesystem:${libs.versions.okioVersion.get()}")
implementation(libs.ktor.client.js)
}
}
val androidMain by getting {
dependencies {
implementation(libs.ktor.client.cio)
implementation(libs.ktor.network)
} }
} }
val jvmMain by getting { val jvmMain by getting {
@ -123,6 +153,8 @@ kotlin {
implementation(libs.mordant.jvm.jna) implementation(libs.mordant.jvm.jna)
implementation("org.jline:jline-reader:3.29.0") implementation("org.jline:jline-reader:3.29.0")
implementation("org.jline:jline-terminal:3.29.0") implementation("org.jline:jline-terminal:3.29.0")
implementation(libs.ktor.client.cio)
implementation(libs.ktor.network)
} }
} }
// // For Wasm we use in-memory VFS for now // // For Wasm we use in-memory VFS for now
@ -135,10 +167,10 @@ kotlin {
} }
} }
abstract class GenerateLyngioConsoleDecls : DefaultTask() { abstract class GenerateLyngioDecls : DefaultTask() {
@get:InputFile @get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE) @get:PathSensitive(PathSensitivity.RELATIVE)
abstract val sourceFile: RegularFileProperty abstract val sourceDir: DirectoryProperty
@get:OutputDirectory @get:OutputDirectory
abstract val outputDir: DirectoryProperty abstract val outputDir: DirectoryProperty
@ -148,9 +180,9 @@ abstract class GenerateLyngioConsoleDecls : DefaultTask() {
val targetPkg = "net.sergeych.lyngio.stdlib_included" val targetPkg = "net.sergeych.lyngio.stdlib_included"
val pkgPath = targetPkg.replace('.', '/') val pkgPath = targetPkg.replace('.', '/')
val targetDir = outputDir.get().asFile.resolve(pkgPath) val targetDir = outputDir.get().asFile.resolve(pkgPath)
if (targetDir.exists()) targetDir.deleteRecursively()
targetDir.mkdirs() targetDir.mkdirs()
val text = sourceFile.get().asFile.readText()
fun escapeForQuoted(s: String): String = buildString { fun escapeForQuoted(s: String): String = buildString {
for (ch in s) when (ch) { for (ch in s) when (ch) {
'\\' -> append("\\\\") '\\' -> append("\\\\")
@ -165,30 +197,39 @@ abstract class GenerateLyngioConsoleDecls : DefaultTask() {
val out = buildString { val out = buildString {
append("package ").append(targetPkg).append("\n\n") append("package ").append(targetPkg).append("\n\n")
append("@Suppress(\"Unused\", \"MemberVisibilityCanBePrivate\")\n") append("@Suppress(\"Unused\", \"MemberVisibilityCanBePrivate\")\n")
append("internal val consoleLyng = \"") sourceDir.get().asFile
append(escapeForQuoted(text)) .listFiles { file -> file.isFile && file.extension == "lyng" }
append("\"\n") ?.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 generatedLyngioDeclsDir = layout.buildDirectory.dir("generated/source/lyngioDecls/commonMain/kotlin")
val generateLyngioConsoleDecls by tasks.registering(GenerateLyngioConsoleDecls::class) { val generateLyngioDecls by tasks.registering(GenerateLyngioDecls::class) {
sourceFile.set(lyngioConsoleDeclsFile) sourceDir.set(lyngioDeclsDir)
outputDir.set(generatedLyngioDeclsDir) outputDir.set(generatedLyngioDeclsDir)
} }
kotlin.sourceSets.named("commonMain") { kotlin.sourceSets.named("commonMain") {
kotlin.srcDir(generateLyngioConsoleDecls) kotlin.srcDir(generateLyngioDecls)
} }
kotlin.targets.configureEach { kotlin.targets.configureEach {
compilations.configureEach { compilations.configureEach {
compileTaskProvider.configure { compileTaskProvider.configure {
dependsOn(generateLyngioConsoleDecls) dependsOn(generateLyngioDecls)
} }
} }
} }

View File

@ -0,0 +1,5 @@
package net.sergeych.lyngio.http
import io.ktor.client.engine.cio.CIO
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(CIO)

View File

@ -0,0 +1,206 @@
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 = AndroidKtorNetEngine
private object AndroidKtorNetEngine : LyngNetEngine {
private val selectorManager: SelectorManager by lazy { ActorSelectorManager(Dispatchers.IO) }
override val isSupported: Boolean = true
override val isTcpAvailable: Boolean = true
override val isTcpServerAvailable: Boolean = true
override val isUdpAvailable: Boolean = true
override suspend fun resolve(host: String, port: Int): List<LyngSocketAddress> = withContext(Dispatchers.IO) {
InetAddress.getAllByName(host).map { address ->
address.toLyngSocketAddress(port = port, resolved = true)
}
}
override suspend fun tcpConnect(
host: String,
port: Int,
timeoutMillis: Long?,
noDelay: Boolean,
): LyngTcpSocket {
val connectBlock: suspend () -> Socket = {
aSocket(selectorManager).tcp().connect(host, port) {
this.noDelay = noDelay
}
}
val socket = if (timeoutMillis != null) withTimeout(timeoutMillis) { connectBlock() } else connectBlock()
return AndroidLyngTcpSocket(socket)
}
override suspend fun tcpListen(
host: String?,
port: Int,
backlog: Int,
reuseAddress: Boolean,
): LyngTcpServer {
val bindHost = host ?: "0.0.0.0"
val server = aSocket(selectorManager).tcp().bind(bindHost, port) {
backlogSize = backlog
this.reuseAddress = reuseAddress
}
return AndroidLyngTcpServer(server)
}
override suspend fun udpBind(host: String?, port: Int, reuseAddress: Boolean): LyngUdpSocket {
val bindHost = host ?: "0.0.0.0"
val socket = aSocket(selectorManager).udp().bind(bindHost, port) {
this.reuseAddress = reuseAddress
}
return AndroidLyngUdpSocket(socket)
}
}
private class AndroidLyngTcpSocket(
private val socket: Socket,
) : LyngTcpSocket {
private val input: ByteReadChannel by lazy { socket.openReadChannel() }
private val output: ByteWriteChannel by lazy { socket.openWriteChannel(autoFlush = true) }
override fun isOpen(): Boolean = !socket.isClosed
override fun localAddress(): LyngSocketAddress = socket.localAddress.toLyngSocketAddress(resolved = true)
override fun remoteAddress(): LyngSocketAddress = socket.remoteAddress.toLyngSocketAddress(resolved = true)
override suspend fun read(maxBytes: Int): ByteArray? {
if (!input.awaitContent(1)) return null
val buffer = ByteArray(maxBytes)
val count = input.readAvailable(buffer, 0, maxBytes)
return when {
count <= 0 -> null
count == maxBytes -> buffer
else -> buffer.copyOf(count)
}
}
override suspend fun readLine(): String? = input.readUTF8Line()
override suspend fun write(data: ByteArray) {
output.writeFully(data, 0, data.size)
}
override suspend fun writeUtf8(text: String) {
output.writeStringUtf8(text)
}
override suspend fun flush() {
output.flush()
}
override fun close() {
socket.close()
}
}
private class AndroidLyngTcpServer(
private val server: ServerSocket,
) : LyngTcpServer {
override fun isOpen(): Boolean = !server.isClosed
override fun localAddress(): LyngSocketAddress = server.localAddress.toLyngSocketAddress(resolved = true)
override suspend fun accept(): LyngTcpSocket = AndroidLyngTcpSocket(server.accept())
override fun close() {
server.close()
}
}
private class AndroidLyngUdpSocket(
private val socket: BoundDatagramSocket,
) : LyngUdpSocket {
override fun isOpen(): Boolean = !socket.isClosed
override fun localAddress(): LyngSocketAddress = socket.localAddress.toLyngSocketAddress(resolved = true)
override suspend fun receive(maxBytes: Int): LyngDatagram? {
val datagram = try {
socket.receive()
} catch (e: Throwable) {
if (!isOpen()) return null
throw e
}
val bytes = datagram.packet.readByteArray().let {
if (it.size <= maxBytes) it else it.copyOf(maxBytes)
}
return LyngDatagram(bytes, datagram.address.toLyngSocketAddress(resolved = true))
}
override suspend fun send(data: ByteArray, host: String, port: Int) {
val packet = Buffer()
packet.write(data)
socket.send(io.ktor.network.sockets.Datagram(packet, InetSocketAddress(host, port)))
}
override fun close() {
socket.close()
}
}
private fun io.ktor.network.sockets.SocketAddress.toLyngSocketAddress(
port: Int? = null,
resolved: Boolean,
): LyngSocketAddress {
val javaAddress = this.toJavaAddress()
val inetSocket = javaAddress as? java.net.InetSocketAddress
if (inetSocket != null) {
val inetAddress = inetSocket.address
val host = inetAddress?.hostAddress ?: inetSocket.hostString
val actualPort = port ?: inetSocket.port
val version = when (inetAddress) {
is Inet6Address -> LyngIpVersion.IPV6
is Inet4Address -> LyngIpVersion.IPV4
else -> if (host.contains(':')) LyngIpVersion.IPV6 else LyngIpVersion.IPV4
}
return LyngSocketAddress(host = host, port = actualPort, ipVersion = version, resolved = resolved)
}
val rendered = toString()
return LyngSocketAddress(
host = rendered,
port = port ?: 0,
ipVersion = if (rendered.contains(':')) LyngIpVersion.IPV6 else LyngIpVersion.IPV4,
resolved = resolved,
)
}
private fun InetAddress.toLyngSocketAddress(port: Int, resolved: Boolean): LyngSocketAddress =
LyngSocketAddress(
host = hostAddress ?: hostName ?: "0.0.0.0",
port = port,
ipVersion = when (this) {
is Inet6Address -> LyngIpVersion.IPV6
else -> LyngIpVersion.IPV4
},
resolved = resolved,
)

View File

@ -0,0 +1,5 @@
package net.sergeych.lyngio.ws
import io.ktor.client.engine.cio.CIO
actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(CIO)

View File

@ -0,0 +1,390 @@
/*
* 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<ObjHttpRequest>(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<ObjString>(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<ObjString>(0).value
val bodyText = requiredArg<ObjString>(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<ObjString>(0).value
val body = requiredArg<ObjBuffer>(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<String, String> = emptyMap(),
private val allHeaders: Map<String, List<String>> = emptyMap(),
) : Obj() {
private val entries: LinkedHashMap<Obj, Obj> =
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<ObjHttpHeaders>()
val name = requiredArg<ObjString>(0).value
self.firstValue(name)?.let(::ObjString) ?: ObjNull
}
addFn("getAll") {
val self = thisAs<ObjHttpHeaders>()
val name = requiredArg<ObjString>(0).value
ObjList(self.valuesOf(name).map(::ObjString).toMutableList())
}
addFn("names") {
val self = thisAs<ObjHttpHeaders>()
ObjList(self.allHeaders.keys.map(::ObjString).toMutableList())
}
addFn("getOrNull") {
val self = thisAs<ObjHttpHeaders>()
val name = requiredArg<ObjString>(0).value
self.firstValue(name)?.let(::ObjString) ?: ObjNull
}
addProperty("size", getter = { ObjInt(thisAs<ObjHttpHeaders>().entries.size.toLong()) })
addProperty("keys", getter = { ObjList(thisAs<ObjHttpHeaders>().entries.keys.toMutableList()) })
addProperty("values", getter = { ObjList(thisAs<ObjHttpHeaders>().entries.values.toMutableList()) })
addFn("iterator") {
ObjList(
thisAs<ObjHttpHeaders>().entries.map { (k, v) -> ObjMapEntry(k, v) }.toMutableList()
).invokeInstanceMethod(requireScope(), "iterator")
}
}
}
private fun valuesOf(name: String): List<String> = 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<Obj, Obj>? {
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<String, String> = 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<ObjHttpRequest>().method) },
setter = { value ->
thisAs<ObjHttpRequest>().method = objOrNullToString(this, value)
?: raiseIllegalArgument("method cannot be null")
}
)
addProperty("url",
getter = { ObjString(thisAs<ObjHttpRequest>().url) },
setter = { value ->
thisAs<ObjHttpRequest>().url = objOrNullToString(this, value)
?: raiseIllegalArgument("url cannot be null")
}
)
addProperty("headers",
getter = { thisAs<ObjHttpRequest>().headers.toObjMap() },
setter = { value ->
thisAs<ObjHttpRequest>().headers.clear()
thisAs<ObjHttpRequest>().headers.putAll(mapObjToStrings(this, value))
}
)
addProperty("bodyText",
getter = { thisAs<ObjHttpRequest>().bodyText?.let(::ObjString) ?: ObjNull },
setter = { value ->
thisAs<ObjHttpRequest>().bodyText = objOrNullToString(this, value)
}
)
addProperty("bodyBytes",
getter = { thisAs<ObjHttpRequest>().bodyBytes?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull },
setter = { value ->
thisAs<ObjHttpRequest>().bodyBytes = when (value) {
ObjNull -> null
is ObjBuffer -> value.byteArray.toByteArray()
else -> raiseClassCastError("bodyBytes must be Buffer or null")
}
}
)
addProperty("timeoutMillis",
getter = { thisAs<ObjHttpRequest>().timeoutMillis?.let { ObjInt(it) } ?: ObjNull },
setter = { value ->
thisAs<ObjHttpRequest>().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<ObjHttpResponse>().status) })
addProperty("statusText", getter = { ObjString(thisAs<ObjHttpResponse>().statusText) })
addProperty("headers", getter = { thisAs<ObjHttpResponse>().headers })
addFn("text") {
ObjString(thisAs<ObjHttpResponse>().bodyBytes.decodeToString())
}
addFn("bytes") {
ObjBuffer(thisAs<ObjHttpResponse>().bodyBytes.toUByteArray())
}
}
fun from(response: LyngHttpResponse): ObjHttpResponse {
val single = linkedMapOf<String, String>()
response.headers.forEach { (name, values) ->
if (values.isNotEmpty() && !single.containsKey(name)) {
single[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<Obj>): Map<String, String> {
val out = linkedMapOf<String, String>()
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<String, String> {
val entries = when (value) {
is ObjMap -> value.map
is ObjImmutableMap -> value.map
ObjNull -> return linkedMapOf()
else -> scope.raiseClassCastError("headers must be Map<String, String>")
}
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<String, String>.toObjMap(): ObjMap =
ObjMap(entries.associate { ObjString(it.key) to ObjString(it.value) }.toMutableMap())

View File

@ -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<ObjString>(0).value
val port = requirePort(requiredArg<ObjInt>(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<ObjString>(0).value
val port = requirePort(requiredArg<ObjInt>(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<ObjInt>(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<NetEnumValues, ObjClass>()
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<ObjSocketAddress>().address.host) })
addProperty("port", getter = { ObjInt(thisAs<ObjSocketAddress>().address.port.toLong()) })
addProperty("ipVersion", getter = { enumValues.of(thisAs<ObjSocketAddress>().address.ipVersion) })
addProperty("resolved", getter = { ObjBool(thisAs<ObjSocketAddress>().address.resolved) })
addFn("toString") { ObjString(renderAddress(thisAs<ObjSocketAddress>().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<NetEnumValues, ObjClass>()
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<ObjDatagram>().datagram.data.toUByteArray())
})
addProperty("address", getter = {
ObjSocketAddress(thisAs<ObjDatagram>().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<NetEnumValues, ObjClass>()
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<ObjTcpSocket>().socket.isOpen()) }
addFn("localAddress") { ObjSocketAddress(thisAs<ObjTcpSocket>().socket.localAddress(), enumValues) }
addFn("remoteAddress") { ObjSocketAddress(thisAs<ObjTcpSocket>().socket.remoteAddress(), enumValues) }
addFn("read") {
val maxBytes = args.list.getOrNull(0)?.let { objToInt(this, it, "maxBytes") } ?: 65536
requirePositive(maxBytes, "maxBytes")
thisAs<ObjTcpSocket>().socket.read(maxBytes)?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull
}
addFn("readLine") {
thisAs<ObjTcpSocket>().socket.readLine()?.let(::ObjString) ?: ObjNull
}
addFn("write") {
val data = requiredArg<ObjBuffer>(0).byteArray.toByteArray()
thisAs<ObjTcpSocket>().socket.write(data)
ObjVoid
}
addFn("writeUtf8") {
val text = requiredArg<ObjString>(0).value
thisAs<ObjTcpSocket>().socket.writeUtf8(text)
ObjVoid
}
addFn("flush") {
requireNoArgs()
thisAs<ObjTcpSocket>().socket.flush()
ObjVoid
}
addFn("close") {
requireNoArgs()
thisAs<ObjTcpSocket>().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<NetEnumValues, ObjClass>()
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<ObjTcpServer>().server.isOpen()) }
addFn("localAddress") { ObjSocketAddress(thisAs<ObjTcpServer>().server.localAddress(), enumValues) }
addFn("accept") {
ObjTcpSocket(thisAs<ObjTcpServer>().server.accept(), enumValues)
}
addFn("close") {
requireNoArgs()
thisAs<ObjTcpServer>().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<NetEnumValues, ObjClass>()
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<ObjUdpSocket>().socket.isOpen()) }
addFn("localAddress") { ObjSocketAddress(thisAs<ObjUdpSocket>().socket.localAddress(), enumValues) }
addFn("receive") {
val maxBytes = args.list.getOrNull(0)?.let { objToInt(this, it, "maxBytes") } ?: 65536
requirePositive(maxBytes, "maxBytes")
thisAs<ObjUdpSocket>().socket.receive(maxBytes)?.let { ObjDatagram(it, enumValues) } ?: ObjNull
}
addFn("send") {
val data = requiredArg<ObjBuffer>(0).byteArray.toByteArray()
val host = requiredArg<ObjString>(1).value
val port = requirePort(requiredArg<ObjInt>(2).value)
thisAs<ObjUdpSocket>().socket.send(data, host, port)
ObjVoid
}
addFn("close") {
requireNoArgs()
thisAs<ObjUdpSocket>().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")
}

View File

@ -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<ObjString>(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<ObjWsMessage>().message.isText) })
addProperty("text", getter = {
thisAs<ObjWsMessage>().message.text?.let(::ObjString) ?: ObjNull
})
addProperty("data", getter = {
thisAs<ObjWsMessage>().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<ObjWsSession>().session.isOpen())
}
addFn("url") {
ObjString(thisAs<ObjWsSession>().targetUrl)
}
addFn("sendText") {
val self = thisAs<ObjWsSession>()
val text = requiredArg<ObjString>(0).value
self.policy.require(WsAccessOp.Send(self.targetUrl, text.encodeToByteArray().size, isText = true))
self.session.sendText(text)
ObjVoid
}
addFn("sendBytes") {
val self = thisAs<ObjWsSession>()
val data = requiredArg<ObjBuffer>(0).byteArray.toByteArray()
self.policy.require(WsAccessOp.Send(self.targetUrl, data.size, isText = false))
self.session.sendBytes(data)
ObjVoid
}
addFn("receive") {
val self = thisAs<ObjWsSession>()
self.policy.require(WsAccessOp.Receive(self.targetUrl))
self.session.receive()?.let(::ObjWsMessage) ?: ObjNull
}
addFn("close") {
val self = thisAs<ObjWsSession>()
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<Obj>): Map<String, String> {
val out = linkedMapOf<String, String>()
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")
}

View File

@ -0,0 +1,61 @@
package net.sergeych.lyngio.http
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.plugins.timeout
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.http.HttpMethod
import io.ktor.http.headers
import io.ktor.http.takeFrom
internal fun createKtorHttpEngine(
engineFactory: HttpClientEngineFactory<HttpClientEngineConfig>,
): LyngHttpEngine = KtorLyngHttpEngine(engineFactory)
private class KtorLyngHttpEngine(
engineFactory: HttpClientEngineFactory<HttpClientEngineConfig>,
) : LyngHttpEngine {
private val clientResult = runCatching {
HttpClient(engineFactory) {
expectSuccess = false
}
}
override val isSupported: Boolean
get() = clientResult.isSuccess
override suspend fun request(request: LyngHttpRequest): LyngHttpResponse {
val httpClient = clientResult.getOrElse {
throw UnsupportedOperationException(it.message ?: "HTTP client is not supported")
}
val response = httpClient.request {
applyRequest(request)
}
return LyngHttpResponse(
status = response.status.value,
statusText = response.status.description,
headers = response.headers.entries().associate { it.key to it.value.toList() },
bodyBytes = response.body<ByteArray>(),
)
}
private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) {
method = HttpMethod.parse(request.method.uppercase())
url.takeFrom(request.url)
headers {
request.headers.forEach { (name, value) -> append(name, value) }
}
request.timeoutMillis?.let { timeout { requestTimeoutMillis = it } }
when {
request.bodyBytes != null && request.bodyText != null ->
throw IllegalArgumentException("Only one of bodyText or bodyBytes may be set")
request.bodyBytes != null -> setBody(request.bodyBytes)
request.bodyText != null -> setBody(request.bodyText)
}
}
}

View File

@ -0,0 +1,49 @@
/*
* 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
data class LyngHttpRequest(
val method: String,
val url: String,
val headers: Map<String, String> = 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<String, List<String>>,
val bodyBytes: ByteArray,
)
interface LyngHttpEngine {
val isSupported: Boolean
suspend fun request(request: LyngHttpRequest): LyngHttpResponse
}
internal 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")
}
}
expect fun getSystemHttpEngine(): LyngHttpEngine

View File

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

View File

@ -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<LyngSocketAddress>
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<LyngSocketAddress> {
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

View File

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

View File

@ -0,0 +1,94 @@
package net.sergeych.lyngio.ws
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.header
import io.ktor.client.request.url
import io.ktor.websocket.CloseReason
import io.ktor.websocket.DefaultWebSocketSession
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.channels.ClosedReceiveChannelException
internal fun createKtorWsEngine(
engineFactory: HttpClientEngineFactory<HttpClientEngineConfig>,
): LyngWsEngine = KtorLyngWsEngine(engineFactory)
private class KtorLyngWsEngine(
engineFactory: HttpClientEngineFactory<HttpClientEngineConfig>,
) : LyngWsEngine {
private val clientResult = runCatching {
HttpClient(engineFactory) {
install(WebSockets)
}
}
override val isSupported: Boolean
get() = clientResult.isSuccess
override suspend fun connect(url: String, headers: Map<String, String>): LyngWsSession {
val client = clientResult.getOrElse {
throw UnsupportedOperationException(it.message ?: "WebSocket client is not supported")
}
val session = client.webSocketSession {
url(url)
headers.forEach { (name, value) -> header(name, value) }
}
return KtorLyngWsSession(url, session)
}
}
private class KtorLyngWsSession(
private val targetUrl: String,
private val session: DefaultWebSocketSession,
) : LyngWsSession {
private var closed = false
override fun isOpen(): Boolean = !closed
override fun url(): String = targetUrl
override suspend fun sendText(text: String) {
ensureOpen()
session.send(text)
}
override suspend fun sendBytes(data: ByteArray) {
ensureOpen()
session.send(data)
}
override suspend fun receive(): LyngWsMessage? {
if (closed) return null
val frame = try {
session.incoming.receive()
} catch (_: ClosedReceiveChannelException) {
closed = true
return null
}
return when (frame) {
is Frame.Text -> LyngWsMessage(isText = true, text = frame.readText())
is Frame.Binary -> LyngWsMessage(isText = false, data = frame.data.copyOf())
is Frame.Close -> {
closed = true
null
}
else -> receive()
}
}
override suspend fun close(code: Int, reason: String) {
if (closed) return
closed = true
session.close(CloseReason(code.toShort(), reason))
}
private fun ensureOpen() {
if (closed) throw IllegalStateException("websocket session is closed")
}
}

View File

@ -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<String, String>): LyngWsSession
}
internal object UnsupportedLyngWsEngine : LyngWsEngine {
override val isSupported: Boolean = false
override suspend fun connect(url: String, headers: Map<String, String>): LyngWsSession {
throw UnsupportedOperationException("WebSocket client is not supported on this runtime")
}
}
expect fun getSystemWsEngine(): LyngWsEngine

View File

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

View File

@ -0,0 +1,5 @@
package net.sergeych.lyngio.http
import io.ktor.client.engine.darwin.Darwin
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(Darwin)

View File

@ -0,0 +1,8 @@
package net.sergeych.lyngio.net
actual fun getSystemNetEngine(): LyngNetEngine = createNativeKtorNetEngine(
isSupported = true,
isTcpAvailable = true,
isTcpServerAvailable = true,
isUdpAvailable = true,
)

View File

@ -0,0 +1,5 @@
package net.sergeych.lyngio.ws
import io.ktor.client.engine.darwin.Darwin
actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(Darwin)

View File

@ -0,0 +1,5 @@
package net.sergeych.lyngio.http
import io.ktor.client.engine.js.Js
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(Js)

View File

@ -0,0 +1,422 @@
@file:Suppress("UnsafeCastFromDynamic", "SpellCheckingInspection")
package net.sergeych.lyngio.net
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.js.json
import org.khronos.webgl.Uint8Array
actual fun getSystemNetEngine(): LyngNetEngine = jsNodeNetEngineOrNull ?: UnsupportedLyngNetEngine
private val jsNodeNetEngineOrNull: LyngNetEngine? by lazy {
if (!isNodeRuntime()) return@lazy null
val net = requireNodeModule("net") ?: return@lazy null
val dgram = requireNodeModule("dgram") ?: return@lazy null
val dns = requireNodeModule("dns") ?: return@lazy null
JsNodeNetEngine(net, dgram, dns)
}
private class JsNodeNetEngine(
private val netModule: dynamic,
private val dgramModule: dynamic,
private val dnsModule: dynamic,
) : LyngNetEngine {
override val isSupported: Boolean = true
override val isTcpAvailable: Boolean = true
override val isTcpServerAvailable: Boolean = true
override val isUdpAvailable: Boolean = true
override suspend fun resolve(host: String, port: Int): List<LyngSocketAddress> {
val family = netModule.isIP(host) as Int
if (family == 4 || family == 6) {
return listOf(
LyngSocketAddress(
host = host,
port = port,
ipVersion = if (family == 6) LyngIpVersion.IPV6 else LyngIpVersion.IPV4,
resolved = true,
)
)
}
return suspendCancellableCoroutine { cont ->
dnsModule.lookup(host, json("all" to true), { error: dynamic, result: dynamic ->
if (!cont.isActive) return@lookup
if (error != null) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "DNS lookup failed"))
return@lookup
}
val addresses = mutableListOf<LyngSocketAddress>()
val items = result.unsafeCast<Array<dynamic>>()
for (item in items) {
val address = item.address?.unsafeCast<String>() ?: continue
val itemFamily = item.family?.unsafeCast<Int>() ?: if (address.contains(':')) 6 else 4
addresses += LyngSocketAddress(
host = address,
port = port,
ipVersion = if (itemFamily == 6) LyngIpVersion.IPV6 else LyngIpVersion.IPV4,
resolved = true,
)
}
cont.resume(addresses)
})
}
}
override suspend fun tcpConnect(
host: String,
port: Int,
timeoutMillis: Long?,
noDelay: Boolean,
): LyngTcpSocket {
var socket: dynamic = null
return try {
val connected = suspend {
suspendCancellableCoroutine<dynamic> { cont ->
socket = netModule.createConnection(json("host" to host, "port" to port)) {
if (cont.isActive) cont.resume(socket)
}
socket.once("error", { error: dynamic ->
if (cont.isActive) {
cont.resumeWithException(
IllegalStateException(error.message?.unsafeCast<String>() ?: "TCP connect failed")
)
}
})
}
}
val connectedSocket = if (timeoutMillis != null) withTimeout(timeoutMillis) { connected() } else connected()
connectedSocket.setNoDelay(noDelay)
JsNodeTcpSocket(connectedSocket)
} catch (e: Throwable) {
if (socket != null) socket.destroy()
throw e
}
}
override suspend fun tcpListen(
host: String?,
port: Int,
backlog: Int,
reuseAddress: Boolean,
): LyngTcpServer {
val accepted = Channel<LyngTcpSocket>(Channel.UNLIMITED)
val server = netModule.createServer({ socket: dynamic ->
accepted.trySend(JsNodeTcpSocket(socket))
})
server.on("error", { _: dynamic -> })
val listenHost = host ?: "0.0.0.0"
val options = json(
"host" to listenHost,
"port" to port,
"backlog" to backlog,
"exclusive" to !reuseAddress,
)
suspendCancellableCoroutine<Unit> { cont ->
server.once("error", { error: dynamic ->
if (cont.isActive) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "TCP listen failed"))
}
})
server.listen(options) {
if (cont.isActive) cont.resume(Unit)
}
}
return JsNodeTcpServer(server, accepted)
}
override suspend fun udpBind(host: String?, port: Int, reuseAddress: Boolean): LyngUdpSocket {
val socketType = if ((host ?: "").contains(':')) "udp6" else "udp4"
val socket = dgramModule.createSocket(json("type" to socketType, "reuseAddr" to reuseAddress))
val incoming = Channel<LyngDatagram>(Channel.UNLIMITED)
socket.on("message", { msg: dynamic, rinfo: dynamic ->
incoming.trySend(
LyngDatagram(
data = dynamicToByteArray(msg),
address = rinfoToAddress(rinfo),
)
)
})
socket.on("error", { _: dynamic -> })
suspendCancellableCoroutine<Unit> { cont ->
socket.once("error", { error: dynamic ->
if (cont.isActive) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "UDP bind failed"))
}
})
socket.bind(port, host ?: "0.0.0.0") {
if (cont.isActive) cont.resume(Unit)
}
}
return JsNodeUdpSocket(socket, incoming)
}
}
private class JsNodeTcpSocket(
private val socket: dynamic,
) : LyngTcpSocket {
private val incoming = Channel<ByteArray?>(Channel.UNLIMITED)
private val buffered = ArrayDeque<Byte>()
private var closed = false
private var failure: Throwable? = null
init {
socket.on("data", { chunk: dynamic ->
incoming.trySend(dynamicToByteArray(chunk))
})
socket.on("end", {
closed = true
incoming.trySend(null)
})
socket.on("close", {
closed = true
incoming.trySend(null)
})
socket.on("error", { error: dynamic ->
failure = IllegalStateException(error.message?.unsafeCast<String>() ?: "TCP socket failed")
closed = true
incoming.trySend(null)
})
}
override fun isOpen(): Boolean = !closed && socket.destroyed != true
override fun localAddress(): LyngSocketAddress = socketAddress(
host = socket.localAddress?.unsafeCast<String>() ?: "0.0.0.0",
port = socket.localPort?.unsafeCast<Int>() ?: 0,
family = socket.localFamily,
resolved = true,
)
override fun remoteAddress(): LyngSocketAddress = socketAddress(
host = socket.remoteAddress?.unsafeCast<String>() ?: "0.0.0.0",
port = socket.remotePort?.unsafeCast<Int>() ?: 0,
family = socket.remoteFamily,
resolved = true,
)
override suspend fun read(maxBytes: Int): ByteArray? {
if (!ensureBuffered()) return null
val count = minOf(maxBytes, buffered.size)
return ByteArray(count) { buffered.removeFirst() }
}
override suspend fun readLine(): String? {
while (true) {
val newlineIndex = buffered.indexOfFirst { it == '\n'.code.toByte() }
if (newlineIndex >= 0) {
val raw = takeBuffered(newlineIndex + 1)
val trimmed = if (raw.lastOrNull() == '\n'.code.toByte()) raw.dropLast(1) else raw
val withoutCr = if (trimmed.lastOrNull() == '\r'.code.toByte()) trimmed.dropLast(1) else trimmed
return withoutCr.toByteArray().decodeToString()
}
if (!fillBuffer()) break
}
if (buffered.isEmpty()) {
failure?.let { throw it }
return null
}
return takeBuffered(buffered.size).toByteArray().decodeToString()
}
override suspend fun write(data: ByteArray) {
ensureOpen()
suspendCancellableCoroutine<Unit> { cont ->
socket.write(byteArrayToUint8Array(data), { error: dynamic ->
if (!cont.isActive) return@write
if (error != null) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "TCP write failed"))
} else {
cont.resume(Unit)
}
})
}
}
override suspend fun writeUtf8(text: String) {
ensureOpen()
suspendCancellableCoroutine<Unit> { cont ->
socket.write(text, "utf8", { error: dynamic ->
if (!cont.isActive) return@write
if (error != null) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "TCP write failed"))
} else {
cont.resume(Unit)
}
})
}
}
override suspend fun flush() {
ensureOpen()
if (socket.writableNeedDrain == true) {
withTimeoutOrNull(5_000) {
awaitNodeEvent(socket, "drain")
}
}
}
override fun close() {
if (closed) return
closed = true
if (socket.destroyed == true) {
incoming.trySend(null)
return
}
if (socket.writable == true) socket.end() else socket.destroy()
}
private suspend fun ensureBuffered(): Boolean {
if (buffered.isNotEmpty()) return true
return fillBuffer()
}
private suspend fun fillBuffer(): Boolean {
while (buffered.isEmpty()) {
val chunk = incoming.receive()
if (chunk == null) {
failure?.let { if (buffered.isEmpty()) throw it }
return buffered.isNotEmpty()
}
chunk.forEach { buffered.addLast(it) }
}
return true
}
private fun takeBuffered(count: Int): List<Byte> = List(count) { buffered.removeFirst() }
private fun ensureOpen() {
if (!isOpen()) throw IllegalStateException("tcp socket is closed")
}
}
private class JsNodeTcpServer(
private val server: dynamic,
private val accepted: Channel<LyngTcpSocket>,
) : LyngTcpServer {
private var closed = false
override fun isOpen(): Boolean = !closed && server.listening == true
override fun localAddress(): LyngSocketAddress {
val info = server.address()
return socketAddress(
host = info.address?.unsafeCast<String>() ?: "0.0.0.0",
port = info.port?.unsafeCast<Int>() ?: 0,
family = info.family,
resolved = true,
)
}
override suspend fun accept(): LyngTcpSocket = accepted.receive()
override fun close() {
if (closed) return
closed = true
server.close()
accepted.close()
}
}
private class JsNodeUdpSocket(
private val socket: dynamic,
private val incoming: Channel<LyngDatagram>,
) : LyngUdpSocket {
private var closed = false
override fun isOpen(): Boolean = !closed
override fun localAddress(): LyngSocketAddress = rinfoToAddress(socket.address())
override suspend fun receive(maxBytes: Int): LyngDatagram? {
val datagram = incoming.receiveCatching().getOrNull() ?: return null
return if (datagram.data.size <= maxBytes) datagram else datagram.copy(data = datagram.data.copyOf(maxBytes))
}
override suspend fun send(data: ByteArray, host: String, port: Int) {
if (closed) throw IllegalStateException("udp socket is closed")
suspendCancellableCoroutine<Unit> { cont ->
socket.send(byteArrayToUint8Array(data), port, host, { error: dynamic ->
if (!cont.isActive) return@send
if (error != null) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "UDP send failed"))
} else {
cont.resume(Unit)
}
})
}
}
override fun close() {
if (closed) return
closed = true
socket.close()
incoming.close()
}
}
private suspend fun awaitNodeEvent(target: dynamic, name: String) {
suspendCancellableCoroutine<Unit> { cont ->
target.once("error", { error: dynamic ->
if (cont.isActive) {
cont.resumeWithException(IllegalStateException(error.message?.unsafeCast<String>() ?: "Node operation failed"))
}
})
target.once(name) {
if (cont.isActive) cont.resume(Unit)
}
}
}
private fun socketAddress(host: String, port: Int, family: dynamic, resolved: Boolean): LyngSocketAddress =
LyngSocketAddress(
host = host,
port = port,
ipVersion = when (family?.toString()) {
"IPv6", "6" -> LyngIpVersion.IPV6
else -> if (host.contains(':')) LyngIpVersion.IPV6 else LyngIpVersion.IPV4
},
resolved = resolved,
)
private fun rinfoToAddress(rinfo: dynamic): LyngSocketAddress = socketAddress(
host = rinfo.address?.unsafeCast<String>() ?: "0.0.0.0",
port = rinfo.port?.unsafeCast<Int>() ?: 0,
family = rinfo.family,
resolved = true,
)
private fun isNodeRuntime(): Boolean = js(
"""
typeof process !== "undefined" &&
process != null &&
process.versions != null &&
process.versions.node != null
"""
).unsafeCast<Boolean>()
private fun requireNodeModule(name: String): dynamic {
val requireFn = js("typeof require !== 'undefined' ? require : undefined")
if (requireFn == js("undefined")) return null
return try {
requireFn(name)
} catch (_: Throwable) {
null
}
}
private fun dynamicToByteArray(value: dynamic): ByteArray {
val source = js("new Uint8Array(value)").unsafeCast<Uint8Array>()
val size = source.length
return ByteArray(size) { index -> source.asDynamic()[index].unsafeCast<Byte>() }
}
private fun byteArrayToUint8Array(value: ByteArray): Uint8Array {
val out = Uint8Array(value.size)
value.forEachIndexed { index, byte -> out.asDynamic()[index] = byte.toInt() and 0xff }
return out
}

View File

@ -0,0 +1,5 @@
package net.sergeych.lyngio.ws
import io.ktor.client.engine.js.Js
actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(Js)

View File

@ -0,0 +1,117 @@
package net.sergeych.lyng.io.net
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.promise
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Script
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
import net.sergeych.lyngio.net.getSystemNetEngine
import net.sergeych.lyngio.net.security.NetAccessOp
import net.sergeych.lyngio.net.security.NetAccessPolicy
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
@OptIn(DelicateCoroutinesApi::class)
class LyngNetModuleJsNodeTest {
@Test
fun testResolveAndCapabilities() = GlobalScope.promise {
val scope = Script.newScope()
createNetModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.io.net
val a: SocketAddress = Net.resolve("127.0.0.1", 4040)[0]
[Net.isSupported(), Net.isTcpAvailable(), Net.isTcpServerAvailable(), Net.isUdpAvailable(), a.toString(), a.resolved, a.ipVersion == IpVersion.IPV4]
""".trimIndent()
val result = Compiler.compile(code).execute(scope).inspect(scope)
assertTrue(result.contains("true,true,true,true"), result)
assertTrue(result.contains("127.0.0.1:4040"), result)
}
@Test
fun testTcpConnectConvenience() = GlobalScope.promise {
val engine = getSystemNetEngine()
val server = engine.tcpListen(host = "127.0.0.1", port = 0, backlog = 8, reuseAddress = true)
val serverPort = server.localAddress().port
val worker = async {
val client = server.accept()
val line = client.read(4)?.decodeToString()
client.writeUtf8("reply:$line")
client.flush()
client.close()
}
val scope = Script.newScope()
createNetModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.buffer
import lyng.io.net
val socket = Net.tcpConnect("127.0.0.1", $serverPort)
socket.writeUtf8("ping")
socket.flush()
val reply = (socket.read(16) as Buffer).decodeUtf8()
val localPort = socket.localAddress().port
val remotePort = socket.remoteAddress().port
socket.close()
[reply, localPort > 0, remotePort == $serverPort]
""".trimIndent()
val result = Compiler.compile(code).execute(scope).inspect(scope)
worker.await()
server.close()
assertTrue(result.contains("reply:ping"), result)
assertTrue(result.contains("true,true"), result)
}
@Test
fun testUdpLoopback() = GlobalScope.promise {
val scope = Script.newScope()
createNetModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.buffer
import lyng.io.net
val server = Net.udpBind(0, "127.0.0.1")
val client = Net.udpBind(0, "127.0.0.1")
client.send(Buffer("ping"), "127.0.0.1", server.localAddress().port)
val d = server.receive()
client.close()
server.close()
[d.data.decodeUtf8(), d.address.port > 0]
""".trimIndent()
val result = Compiler.compile(code).execute(scope).inspect(scope)
assertTrue(result.contains("[ping,true]"), result)
}
@Test
fun testPolicyDenialSurfacesAsLyngError() = GlobalScope.promise {
val scope = Script.newScope()
val denyAll = object : NetAccessPolicy {
override suspend fun check(op: NetAccessOp, ctx: AccessContext): AccessDecision =
AccessDecision(Decision.Deny, "blocked by test policy")
}
createNetModule(denyAll, scope)
val code = """
import lyng.io.net
Net.tcpConnect("127.0.0.1", 1)
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(code).execute(scope)
}
assertTrue(error.errorMessage.isNotBlank())
}
}

View File

@ -0,0 +1,70 @@
package net.sergeych.lyngio
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.promise
import net.sergeych.lyngio.net.LyngIpVersion
import net.sergeych.lyngio.net.getSystemNetEngine
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@OptIn(DelicateCoroutinesApi::class)
class NetJsNodeTest {
@Test
fun testNodeNetCapabilitiesAndResolve() = GlobalScope.promise {
val engine = getSystemNetEngine()
assertTrue(engine.isSupported)
assertTrue(engine.isTcpAvailable)
assertTrue(engine.isTcpServerAvailable)
assertTrue(engine.isUdpAvailable)
val resolved = engine.resolve("127.0.0.1", 4040)
assertTrue(resolved.isNotEmpty())
assertEquals(4040, resolved.first().port)
assertEquals(LyngIpVersion.IPV4, resolved.first().ipVersion)
}
@Test
fun testNodeTcpLoopback() = GlobalScope.promise {
val engine = getSystemNetEngine()
val server = engine.tcpListen(host = "127.0.0.1", port = 0, backlog = 8, reuseAddress = true)
val accepted = async {
val socket = server.accept()
val line = socket.readLine()
socket.writeUtf8("echo:$line\n")
socket.flush()
socket.close()
line
}
val client = engine.tcpConnect("127.0.0.1", server.localAddress().port, timeoutMillis = null, noDelay = true)
client.writeUtf8("ping\n")
client.flush()
val reply = client.readLine()
client.close()
server.close()
assertEquals("ping", accepted.await())
assertEquals("echo:ping", reply)
}
@Test
fun testNodeUdpLoopback() = GlobalScope.promise {
val engine = getSystemNetEngine()
val server = engine.udpBind(host = "127.0.0.1", port = 0, reuseAddress = true)
val client = engine.udpBind(host = "127.0.0.1", port = 0, reuseAddress = true)
client.send("ping".encodeToByteArray(), "127.0.0.1", server.localAddress().port)
val received = server.receive(1024)
client.close()
server.close()
assertNotNull(received)
assertEquals("ping", received.data.decodeToString())
assertTrue(received.address.port > 0)
}
}

View File

@ -0,0 +1,14 @@
package net.sergeych.lyngio
import net.sergeych.lyngio.http.getSystemHttpEngine
import net.sergeych.lyngio.ws.getSystemWsEngine
import kotlin.test.Test
import kotlin.test.assertTrue
class PlatformCapabilityJsTest {
@Test
fun testJsHttpAndWsCapabilitiesReportSupported() {
assertTrue(getSystemHttpEngine().isSupported, "JS HTTP engine should be available")
assertTrue(getSystemWsEngine().isSupported, "JS websocket engine should be available")
}
}

View File

@ -0,0 +1,5 @@
package net.sergeych.lyngio.http
import io.ktor.client.engine.cio.CIO
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(CIO)

View File

@ -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<LyngSocketAddress> = withContext(Dispatchers.IO) {
InetAddress.getAllByName(host).map { address ->
address.toLyngSocketAddress(port = port, resolved = true)
}
}
override suspend fun tcpConnect(
host: String,
port: Int,
timeoutMillis: Long?,
noDelay: Boolean,
): LyngTcpSocket {
val connectBlock: suspend () -> Socket = {
aSocket(selectorManager).tcp().connect(host, port) {
this.noDelay = noDelay
}
}
val socket = if (timeoutMillis != null) withTimeout(timeoutMillis) { connectBlock() } else connectBlock()
return 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,
)

View File

@ -0,0 +1,22 @@
/*
* 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.engine.cio.CIO
actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(CIO)

View File

@ -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<IoDocTest> = flow {
val book = readAllLines(Paths.get(fileName))
var startOffset = 0
val block = mutableListOf<String>()
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<String>()
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
}
}

View File

@ -0,0 +1,176 @@
/*
* 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 com.sun.net.httpserver.HttpsConfigurator
import com.sun.net.httpserver.HttpsServer
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Script
import net.sergeych.lyng.io.testtls.TlsTestMaterial
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
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 testHttpsGet() = runBlocking {
TlsTestMaterial.installJvmClientTrust()
val server = newServer(secure = true) { exchange ->
exchange.responseHeaders.add("Content-Type", "text/plain; charset=utf-8")
writeResponse(exchange, 200, "hello over tls")
}
try {
val scope = Script.newScope()
createHttpModule(PermitAllHttpAccessPolicy, scope)
val code = """
import lyng.io.http
val r = Http.get("https://127.0.0.1:${server.address.port}/hello")
[r.status, r.text()]
""".trimIndent()
val result = Compiler.compile(code).execute(scope)
val rendered = result.inspect(scope)
assertTrue(rendered.contains("200"), rendered)
assertTrue(rendered.contains("hello over tls"), rendered)
} finally {
server.stop(0)
}
}
@Test
fun testPolicyDenialSurfacesAsLyngError() = runBlocking {
val scope = Script.newScope()
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<ExecutionError> {
Compiler.compile(code).execute(scope)
}
assertTrue(error.errorMessage.isNotBlank())
}
private fun newServer(secure: Boolean = false, handler: (HttpExchange) -> Unit): HttpServer {
val server = if (secure) {
HttpsServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
httpsConfigurator = HttpsConfigurator(TlsTestMaterial.sslContext)
}
} else {
HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
}
server.createContext("/") { exchange ->
handler(exchange)
}
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)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
package net.sergeych.lyngio.http
import io.ktor.client.engine.curl.Curl
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(Curl)

View File

@ -0,0 +1,8 @@
package net.sergeych.lyngio.net
actual fun getSystemNetEngine(): LyngNetEngine = createNativeKtorNetEngine(
isSupported = true,
isTcpAvailable = true,
isTcpServerAvailable = true,
isUdpAvailable = true,
)

View File

@ -0,0 +1,5 @@
package net.sergeych.lyngio.ws
import io.ktor.client.engine.curl.Curl
actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(Curl)

View File

@ -0,0 +1,90 @@
package net.sergeych.lyngio.net
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Script
import net.sergeych.lyng.io.net.createNetModule
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class NetLinuxNativeTest {
@Test
fun testLinuxNativeCapabilitiesAndResolve() = runBlocking {
val engine = getSystemNetEngine()
assertTrue(engine.isSupported)
assertTrue(engine.isTcpAvailable)
assertTrue(engine.isTcpServerAvailable)
assertTrue(engine.isUdpAvailable)
val resolved = engine.resolve("127.0.0.1", 4040)
assertEquals(1, resolved.size)
assertEquals("127.0.0.1", resolved.single().host)
assertEquals(4040, resolved.single().port)
assertEquals(LyngIpVersion.IPV4, resolved.single().ipVersion)
assertTrue(resolved.single().resolved)
}
@Test
fun testLinuxNativeLyngModuleCapabilities() = runBlocking {
val scope = Script.newScope()
createNetModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.io.net
val a: SocketAddress = Net.resolve("127.0.0.1", 4040)[0]
[Net.isSupported(), Net.isTcpAvailable(), Net.isTcpServerAvailable(), Net.isUdpAvailable(), a.toString(), a.resolved]
""".trimIndent()
val result = Compiler.compile(code).execute(scope).inspect(scope)
assertTrue(result.contains("true,true,true,true"), result)
assertTrue(result.contains("127.0.0.1:4040"), result)
}
@Test
fun testLinuxNativeTcpAndUdpLoopback() = runBlocking {
val engine = getSystemNetEngine()
withTimeout(5_000) {
val server = engine.tcpListen(host = "127.0.0.1", port = 0, backlog = 16, reuseAddress = true)
val accepted = async {
val client = server.accept()
val text = client.read(4)?.decodeToString()
client.writeUtf8("echo:$text")
client.flush()
client.close()
server.close()
text
}
val socket = engine.tcpConnect("127.0.0.1", server.localAddress().port, timeoutMillis = 2_000, noDelay = true)
socket.writeUtf8("ping")
socket.flush()
val reply = socket.read(32)?.decodeToString()
socket.close()
assertEquals("ping", accepted.await())
assertEquals("echo:ping", reply)
}
withTimeout(5_000) {
val receiver = engine.udpBind(host = "127.0.0.1", port = 0, reuseAddress = true)
val sender = engine.udpBind(host = "127.0.0.1", port = 0, reuseAddress = true)
sender.send("ping".encodeToByteArray(), "127.0.0.1", receiver.localAddress().port)
val datagram = receiver.receive(32)
sender.close()
receiver.close()
assertEquals("ping", datagram?.data?.decodeToString())
assertTrue((datagram?.address?.port ?: 0) > 0)
}
}
}

View File

@ -0,0 +1,5 @@
package net.sergeych.lyngio.http
import io.ktor.client.engine.winhttp.WinHttp
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(WinHttp)

View File

@ -0,0 +1,3 @@
package net.sergeych.lyngio.net
actual fun getSystemNetEngine(): LyngNetEngine = UnsupportedLyngNetEngine

View File

@ -0,0 +1,5 @@
package net.sergeych.lyngio.ws
import io.ktor.client.engine.winhttp.WinHttp
actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(WinHttp)

View File

@ -0,0 +1,216 @@
package net.sergeych.lyngio.net
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.BoundDatagramSocket
import io.ktor.network.sockets.Datagram
import io.ktor.network.sockets.InetSocketAddress
import io.ktor.network.sockets.ServerSocket
import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.SocketAddress
import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.isClosed
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.ByteWriteChannel
import io.ktor.utils.io.readAvailable
import io.ktor.utils.io.readUTF8Line
import io.ktor.utils.io.writeFully
import io.ktor.utils.io.writeStringUtf8
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withTimeout
import kotlinx.io.Buffer
import kotlinx.io.readByteArray
internal fun createNativeKtorNetEngine(
isSupported: Boolean,
isTcpAvailable: Boolean,
isTcpServerAvailable: Boolean,
isUdpAvailable: Boolean,
): LyngNetEngine = NativeKtorNetEngine(
isSupported = isSupported,
isTcpAvailable = isTcpAvailable,
isTcpServerAvailable = isTcpServerAvailable,
isUdpAvailable = isUdpAvailable,
)
private class NativeKtorNetEngine(
override val isSupported: Boolean,
override val isTcpAvailable: Boolean,
override val isTcpServerAvailable: Boolean,
override val isUdpAvailable: Boolean,
) : LyngNetEngine {
private val selectorManager: SelectorManager by lazy { SelectorManager(Dispatchers.Default) }
override suspend fun resolve(host: String, port: Int): List<LyngSocketAddress> {
val rawAddress = InetSocketAddress(host, port).resolveAddress()
?: throw IllegalStateException("Failed to resolve address for $host")
return listOf(
LyngSocketAddress(
host = rawAddress.toIpHostString(),
port = port,
ipVersion = rawAddress.toLyngIpVersion(),
resolved = true,
)
)
}
override suspend fun tcpConnect(
host: String,
port: Int,
timeoutMillis: Long?,
noDelay: Boolean,
): LyngTcpSocket {
val connectBlock: suspend () -> Socket = {
aSocket(selectorManager).tcp().connect(host, port) {
this.noDelay = noDelay
}
}
val socket = if (timeoutMillis != null) withTimeout(timeoutMillis) { connectBlock() } else connectBlock()
return NativeLyngTcpSocket(socket)
}
override suspend fun tcpListen(
host: String?,
port: Int,
backlog: Int,
reuseAddress: Boolean,
): LyngTcpServer {
val bindHost = host ?: "0.0.0.0"
val server = aSocket(selectorManager).tcp().bind(bindHost, port) {
backlogSize = backlog
this.reuseAddress = reuseAddress
}
return NativeLyngTcpServer(server)
}
override suspend fun udpBind(host: String?, port: Int, reuseAddress: Boolean): LyngUdpSocket {
val bindHost = host ?: "0.0.0.0"
val socket = aSocket(selectorManager).udp().bind(bindHost, port) {
this.reuseAddress = reuseAddress
}
return NativeLyngUdpSocket(socket)
}
}
private class NativeLyngTcpSocket(
private val socket: Socket,
) : LyngTcpSocket {
private val input: ByteReadChannel by lazy { socket.openReadChannel() }
private val output: ByteWriteChannel by lazy { socket.openWriteChannel(autoFlush = true) }
override fun isOpen(): Boolean = !socket.isClosed
override fun localAddress(): LyngSocketAddress = socket.localAddress.toLyngSocketAddress(resolved = true)
override fun remoteAddress(): LyngSocketAddress = socket.remoteAddress.toLyngSocketAddress(resolved = true)
override suspend fun read(maxBytes: Int): ByteArray? {
if (!input.awaitContent(1)) return null
val buffer = ByteArray(maxBytes)
val count = input.readAvailable(buffer, 0, maxBytes)
return when {
count <= 0 -> null
count == maxBytes -> buffer
else -> buffer.copyOf(count)
}
}
override suspend fun readLine(): String? = input.readUTF8Line()
override suspend fun write(data: ByteArray) {
output.writeFully(data, 0, data.size)
}
override suspend fun writeUtf8(text: String) {
output.writeStringUtf8(text)
}
override suspend fun flush() {
output.flush()
}
override fun close() {
socket.close()
}
}
private class NativeLyngTcpServer(
private val server: ServerSocket,
) : LyngTcpServer {
override fun isOpen(): Boolean = !server.isClosed
override fun localAddress(): LyngSocketAddress = server.localAddress.toLyngSocketAddress(resolved = true)
override suspend fun accept(): LyngTcpSocket = NativeLyngTcpSocket(server.accept())
override fun close() {
server.close()
}
}
private class NativeLyngUdpSocket(
private val socket: BoundDatagramSocket,
) : LyngUdpSocket {
override fun isOpen(): Boolean = !socket.isClosed
override fun localAddress(): LyngSocketAddress = socket.localAddress.toLyngSocketAddress(resolved = true)
override suspend fun receive(maxBytes: Int): LyngDatagram? {
val datagram = try {
socket.receive()
} catch (e: Throwable) {
if (!isOpen()) return null
throw e
}
val bytes = datagram.packet.readByteArray().let {
if (it.size <= maxBytes) it else it.copyOf(maxBytes)
}
return LyngDatagram(bytes, datagram.address.toLyngSocketAddress(resolved = true))
}
override suspend fun send(data: ByteArray, host: String, port: Int) {
val packet = Buffer()
packet.write(data)
socket.send(Datagram(packet, InetSocketAddress(host, port)))
}
override fun close() {
socket.close()
}
}
private fun SocketAddress.toLyngSocketAddress(resolved: Boolean): LyngSocketAddress {
val inetAddress = this as? InetSocketAddress
if (inetAddress != null) {
val rawAddress = inetAddress.resolveAddress()
val host = rawAddress?.toIpHostString() ?: inetAddress.hostname
return LyngSocketAddress(
host = host,
port = inetAddress.port,
ipVersion = rawAddress?.toLyngIpVersion()
?: if (host.contains(':')) LyngIpVersion.IPV6 else LyngIpVersion.IPV4,
resolved = resolved,
)
}
val rendered = toString()
return LyngSocketAddress(
host = rendered,
port = 0,
ipVersion = if (rendered.contains(':')) LyngIpVersion.IPV6 else LyngIpVersion.IPV4,
resolved = resolved,
)
}
private fun ByteArray.toLyngIpVersion(): LyngIpVersion = if (size == 16) LyngIpVersion.IPV6 else LyngIpVersion.IPV4
private fun ByteArray.toIpHostString(): String = when (size) {
4 -> joinToString(".") { (it.toInt() and 0xff).toString() }
16 -> (0 until 8).joinToString(":") { index ->
val hi = this[index * 2].toInt() and 0xff
val lo = this[index * 2 + 1].toInt() and 0xff
((hi shl 8) or lo).toString(16)
}
else -> error("Unsupported IP address length: $size")
}

View File

@ -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<String, String> {
/* 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<String>
/* Return distinct header names present in this response. */
fun names(): List<String>
}
/* 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<String, String>
/* 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
}

View File

@ -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<SocketAddress>
/* 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
}

View File

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

View File

@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych" 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 // Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below

View File

@ -4532,6 +4532,15 @@ class Compiler(
} }
} }
private fun inferForLoopElementType(source: Statement, constRange: ConstIntRange?): TypeDecl? {
if (constRange != null) return TypeDecl.Simple("Int", false)
val sourceType = inferTypeDeclFromInitializer(source) ?: return null
return when {
isRangeType(sourceType) -> TypeDecl.Simple("Int", false)
else -> inferCollectionElementType(expandTypeAliases(sourceType, source.pos))
}
}
private fun typeDeclSubtypeOf(arg: TypeDecl, param: TypeDecl): Boolean { private fun typeDeclSubtypeOf(arg: TypeDecl, param: TypeDecl): Boolean {
if (param == TypeDecl.TypeAny || param == TypeDecl.TypeNullableAny) return true if (param == TypeDecl.TypeAny || param == TypeDecl.TypeNullableAny) return true
val (argBase, argNullable) = stripNullable(arg) val (argBase, argNullable) = stripNullable(arg)
@ -4934,12 +4943,33 @@ class Compiler(
private fun inferCallReturnClass(ref: CallRef): ObjClass? { private fun inferCallReturnClass(ref: CallRef): ObjClass? {
return when (val target = ref.target) { return when (val target = ref.target) {
is LocalSlotRef -> callableReturnTypeByScopeId[target.scopeId]?.get(target.slot) is LocalSlotRef -> when (target.name) {
?: resolveClassByName(target.name) "lazy" -> resolveClassByName("lazy")
is LocalVarRef -> callableReturnTypeByName[target.name] "iterator" -> ObjIterator
?: resolveClassByName(target.name) "flow" -> ObjFlow.type
is FastLocalVarRef -> callableReturnTypeByName[target.name] "launch" -> ObjDeferred.type
?: resolveClassByName(target.name) "dynamic" -> ObjDynamic.type
else -> callableReturnTypeByScopeId[target.scopeId]?.get(target.slot)
?: resolveClassByName(target.name)
}
is LocalVarRef -> when (target.name) {
"lazy" -> resolveClassByName("lazy")
"iterator" -> ObjIterator
"flow" -> ObjFlow.type
"launch" -> ObjDeferred.type
"dynamic" -> ObjDynamic.type
else -> callableReturnTypeByName[target.name]
?: resolveClassByName(target.name)
}
is FastLocalVarRef -> when (target.name) {
"lazy" -> resolveClassByName("lazy")
"iterator" -> ObjIterator
"flow" -> ObjFlow.type
"launch" -> ObjDeferred.type
"dynamic" -> ObjDynamic.type
else -> callableReturnTypeByName[target.name]
?: resolveClassByName(target.name)
}
is ConstRef -> when (val value = target.constValue) { is ConstRef -> when (val value = target.constValue) {
is ObjClass -> value is ObjClass -> value
is ObjString -> ObjString.type is ObjString -> ObjString.type
@ -7490,6 +7520,20 @@ class Compiler(
val loopSlotPlan = SlotPlan(mutableMapOf(), 0, nextScopeId++) val loopSlotPlan = SlotPlan(mutableMapOf(), 0, nextScopeId++)
slotPlanStack.add(loopSlotPlan) slotPlanStack.add(loopSlotPlan)
declareSlotName(tVar.value, isMutable = true, isDelegated = false) declareSlotName(tVar.value, isMutable = true, isDelegated = false)
val loopSlotIndex = loopSlotPlan.slots[tVar.value]?.index
val loopVarTypeDecl = inferForLoopElementType(source, constRange)
val hadLoopNameType = nameTypeDecl.containsKey(tVar.value)
val prevLoopNameType = nameTypeDecl[tVar.value]
val hadLoopNameClass = nameObjClass.containsKey(tVar.value)
val prevLoopNameClass = nameObjClass[tVar.value]
if (loopSlotIndex != null && loopVarTypeDecl != null) {
slotTypeDeclByScopeId.getOrPut(loopSlotPlan.id) { mutableMapOf() }[loopSlotIndex] = loopVarTypeDecl
nameTypeDecl[tVar.value] = loopVarTypeDecl
resolveTypeDeclObjClass(loopVarTypeDecl)?.let { loopVarClass ->
slotTypeByScopeId.getOrPut(loopSlotPlan.id) { mutableMapOf() }[loopSlotIndex] = loopVarClass
nameObjClass[tVar.value] = loopVarClass
}
}
val (canBreak, body, elseStatement) = try { val (canBreak, body, elseStatement) = try {
resolutionSink?.enterScope(ScopeKind.BLOCK, tVar.pos, null) resolutionSink?.enterScope(ScopeKind.BLOCK, tVar.pos, null)
resolutionSink?.declareSymbol(tVar.value, SymbolKind.LOCAL, isMutable = true, pos = tVar.pos) resolutionSink?.declareSymbol(tVar.value, SymbolKind.LOCAL, isMutable = true, pos = tVar.pos)
@ -7509,6 +7553,16 @@ class Compiler(
Triple(loopParsed.first, loopParsed.second, elseStmt) Triple(loopParsed.first, loopParsed.second, elseStmt)
} }
} finally { } finally {
if (hadLoopNameType) {
nameTypeDecl[tVar.value] = prevLoopNameType!!
} else {
nameTypeDecl.remove(tVar.value)
}
if (hadLoopNameClass) {
nameObjClass[tVar.value] = prevLoopNameClass!!
} else {
nameObjClass.remove(tVar.value)
}
resolutionSink?.exitScope(cc.currentPos()) resolutionSink?.exitScope(cc.currentPos())
slotPlanStack.removeLast() slotPlanStack.removeLast()
} }
@ -9164,7 +9218,6 @@ class Compiler(
varTypeDecl = inferred varTypeDecl = inferred
} }
} }
if (isDelegate && initialExpression != null) { if (isDelegate && initialExpression != null) {
ensureDelegateType(initialExpression) ensureDelegateType(initialExpression)
val lazyClass = resolveClassByName("lazy") val lazyClass = resolveClassByName("lazy")

View File

@ -209,4 +209,26 @@ class TestCoroutines {
// }.toList()) // }.toList())
""".trimIndent()) """.trimIndent())
} }
@Test
fun testInferenceList() = runTest {
eval("""
import lyng.time
val d1 = launch {
delay(1000.milliseconds)
"Task A finished"
}
val d2 = launch {
delay(500.milliseconds)
"Task B finished"
}
val foo = [d1, d2]
for (d in foo) {
d.await()
println(d)
}
""".trimIndent())
}
} }

View File

@ -2050,9 +2050,9 @@ class ScriptTest {
fun nationalCharsTest() = runTest { fun nationalCharsTest() = runTest {
eval( eval(
""" """
fun сумма_ряда(x, погрешность=0.0001, f) { fun сумма_ряда(x, погрешность=0.001, f) {
var сумма = 0 var сумма = 0
for( n in 1..100000) { for( n in 1..5000) {
val следующая_сумма = сумма + f(x, n) val следующая_сумма = сумма + f(x, n)
if( n > 1 && abs(следующая_сумма - сумма) < погрешность ) if( n > 1 && abs(следующая_сумма - сумма) < погрешность )
break следующая_сумма break следующая_сумма

View File

@ -97,15 +97,15 @@ class ComplexModuleTest {
assert( 5 + 1.d.i is Complex ) assert( 5 + 1.d.i is Complex )
assert( 5.d + 1.i is Complex ) assert( 5.d + 1.i is Complex )
assert( 5.d + 2.d.i is Complex ) assert( 5.d + 2.d.i is Complex )
assertEquals("0.0+1.0i", 1.d.i.toString()) assert(1.d.i.toString() in ["0.0+1.0i", "0+1i"])
assertEquals("1.0+0.0i", 1.d.re.toString()) assert(1.d.re.toString() in ["1.0+0.0i", "1+0i"])
var c = 1 + 2.i var c = 1 + 2.i
assert(c is Complex) assert(c is Complex)
assertEquals("1.0+2.0i", c.toString()) assert(c.toString() in ["1.0+2.0i", "1+2i"])
c = 1.d + 2.i c = 1.d + 2.i
assertEquals("1.0+2.0i", c.toString()) assert(c.toString() in ["1.0+2.0i", "1+2i"])
""".trimIndent() """.trimIndent()
) )
} }

View File

@ -0,0 +1,205 @@
# Networking Handoff
Date: 2026-04-02
Commit: `5346d15` (`Add KMP networking backends`)
## Scope completed
The `lyngio` networking work now provides a uniform Lyng-facing API with capability probes and platform-specific implementations.
Implemented modules:
- `lyng.io.http`
- `lyng.io.ws`
- `lyng.io.net`
## Current support matrix
### HTTP / HTTPS
- JVM: supported
- Android: supported
- JS: supported
- Linux Native: supported
- Windows Native (`mingwX64`): supported
- Apple Native: compile-verified on this Linux host
### WS / WSS
- JVM: supported
- Android: supported
- JS: supported
- Linux Native: supported
- Windows Native (`mingwX64`): supported
- Apple Native: compile-verified on this Linux host
### Raw networking (`lyng.io.net`)
- JVM: supported
- Android: supported
- JS/Node: supported
- JS/browser: unsupported by capability probe
- Linux Native: supported
- Apple Native: enabled via shared native backend; compile-verified, runtime not yet host-verified
- Other Native targets: intentionally still unsupported
## Important design decisions
- Ktor is the backend for all currently implemented networking.
- API is uniform across targets; platform variance is exposed through capability checks such as:
- `Http.isSupported()`
- `Ws.isSupported()`
- `Net.isSupported()`
- `Net.isTcpAvailable()`
- `Net.isTcpServerAvailable()`
- `Net.isUdpAvailable()`
- Native support was restricted to what matches Ktor client-engine support:
- Darwin for Apple Native
- Curl for Linux Native
- WinHttp for Windows Native
- Native raw sockets use a shared Ktor socket implementation for Linux and Darwin source sets.
- Capability probes are enabled on Linux Native and Apple Native; Apple Native is compile-verified but not yet runtime-tested on a macOS host.
## Documentation and tests status
Docs updated:
- `docs/lyng.io.http.md`
- `docs/lyng.io.ws.md`
- `docs/lyng.io.net.md`
Verified docs/tests:
- HTTP/HTTPS docs are covered with extracted markdown tests on JVM.
- WS/WSS docs are covered with extracted markdown tests on JVM.
- JS/Node has both engine-level and Lyng-module-level tests for raw networking.
## Key implementation files
Shared:
- `lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt`
- `lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt`
- `lyngio/src/commonMain/kotlin/net/sergeych/lyngio/ws/LyngWs.kt`
- `lyngio/src/commonMain/kotlin/net/sergeych/lyngio/net/LyngNet.kt`
- `lyngio/build.gradle.kts`
- `gradle/libs.versions.toml`
Platform HTTP:
- `lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt`
- `lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt`
- `lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt`
- `lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt`
- `lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt`
- `lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt`
Platform WS:
- `lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/ws/PlatformJvm.kt`
- `lyngio/src/jsMain/kotlin/net/sergeych/lyngio/ws/PlatformJs.kt`
- `lyngio/src/androidMain/kotlin/net/sergeych/lyngio/ws/PlatformAndroid.kt`
- `lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/ws/PlatformDarwin.kt`
- `lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/ws/PlatformLinux.kt`
- `lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/ws/PlatformMingw.kt`
Platform raw net:
- `lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/net/PlatformJvm.kt`
- `lyngio/src/jsMain/kotlin/net/sergeych/lyngio/net/PlatformJs.kt`
- `lyngio/src/androidMain/kotlin/net/sergeych/lyngio/net/PlatformAndroid.kt`
- `lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/net/NativeKtorNetEngine.kt`
- `lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/net/PlatformLinux.kt`
- `lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/net/PlatformDarwin.kt`
- `lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/net/PlatformMingw.kt`
JS tests:
- `lyngio/src/jsTest/kotlin/net/sergeych/lyngio/PlatformCapabilityJsTest.kt`
- `lyngio/src/jsTest/kotlin/net/sergeych/lyngio/NetJsNodeTest.kt`
- `lyngio/src/jsTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleJsNodeTest.kt`
JVM tests:
- `lyngio/src/jvmTest/kotlin/LyngioBookTest.kt`
- `lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/LyngHttpModuleTest.kt`
- `lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/ws/LyngWsModuleTest.kt`
- `lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleTest.kt`
Linux Native tests:
- `lyngio/src/linuxTest/kotlin/net/sergeych/lyngio/net/NetLinuxNativeTest.kt`
## Verification already run
JVM / docs:
- `./gradlew :lyngio:jvmTest --tests LyngioBookTest`
- `./gradlew :lyngio:jvmTest --tests net.sergeych.lyng.io.http.LyngHttpModuleTest`
- `./gradlew :lyngio:jvmTest --tests net.sergeych.lyng.io.ws.LyngWsModuleTest`
- `./gradlew :lyngio:jvmTest --tests net.sergeych.lyng.io.net.LyngNetModuleTest`
JS:
- `./gradlew :lyngio:compileKotlinJs`
- `./gradlew :lyngio:compileTestKotlinJs`
- `./gradlew :lyngio:jsNodeTest`
- `./gradlew kotlinUpgradeYarnLock`
Android:
- `./gradlew :lyngio:compileDebugKotlinAndroid`
- `./gradlew :lyngio:compileReleaseKotlinAndroid`
Native:
- `./gradlew :lyngio:compileKotlinLinuxX64`
- `./gradlew :lyngio:compileKotlinLinuxArm64`
- `./gradlew :lyngio:compileKotlinMingwX64`
- `./gradlew :lyngio:compileKotlinIosX64`
- `./gradlew :lyngio:compileKotlinIosArm64`
- `./gradlew :lyngio:compileKotlinIosSimulatorArm64`
- `./gradlew :lyngio:compileKotlinMacosArm64`
- `./gradlew :lyngio:compileTestKotlinLinuxX64`
- `./gradlew :lyngio:compileTestKotlinLinuxArm64`
- `./gradlew :lyngio:linkDebugTestLinuxX64`
- `./gradlew :lyngio:linuxX64Test`
- `./gradlew :lyngio:linuxX64Test --tests net.sergeych.lyngio.net.NetLinuxNativeTest`
- `./gradlew :lyngio:linuxX64Test --tests net.sergeych.lyngio.net.NetLinuxNativeTest.testLinuxNativeTcpAndUdpLoopback`
- `./lyngio/build/bin/linuxX64/debugTest/test.kexe --ktest_filter='net.sergeych.lyngio.net.NetLinuxNativeTest.*'`
## Known intentional gaps
- Native raw sockets are enabled on Linux Native and Apple Native.
- Apple Native raw networking is enabled based on shared-backend compile verification; runtime verification on macOS is still pending.
- No Android device/instrumented runtime tests were added; only compile verification was done.
## Worktree state after commit
Current HEAD:
- `5346d15` `Add KMP networking backends`
Unrelated remaining change:
- `examples/tetris_console.lyng`
That file was not touched by the networking work and was intentionally left out of the commit.
## Recommended next steps
1. Add Native raw socket support only per target that compiles and passes a smoke test.
2. Keep capability probes `false` on non-Linux Native targets until each raw-socket backend is proven.
3. If Apple Native work continues, compile Darwin targets on a macOS host before claiming support.
4. Run a macOS-hosted runtime smoke test when available to verify the already-enabled Darwin backend.
5. Optionally add Android runtime tests later; compile-only verification exists now.
## Suggested first task for the next chat
Continue Native raw `lyng.io.net` incrementally from the verified Linux baseline:
- keep Linux Native enabled
- keep Apple Native enabled unless runtime verification disproves the shared-backend assumption
- keep other Native targets capability-gated off until compiled and smoke-tested
- use only `ktor-network` support that actually compiles
- do not change the Lyng-facing API

View File

@ -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<String, String> {
fun get(name: String): String?
fun getAll(name: String): List<String>
fun names(): List<String>
}
extern class HttpRequest {
var method: String
var url: String
var headers: Map<String, String>
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<String, String>` 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<SocketAddress>
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 <token>"
)
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.

View File

@ -413,6 +413,9 @@
</li> </li>
</ul> </ul>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="#/docs/downloads.md" data-route="downloads">Downloads</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="#/tryling" data-route="tryling">Try in browser</a> <a class="nav-link" href="#/tryling" data-route="tryling">Try in browser</a>
</li> </li>
@ -492,6 +495,8 @@
var activeLink = null; var activeLink = null;
if (!hash || hash === '#' || hash === '#/') { if (!hash || hash === '#' || hash === '#/') {
activeLink = document.querySelector('#topbarNav .nav-link[data-route="home"]'); activeLink = document.querySelector('#topbarNav .nav-link[data-route="home"]');
} else if (hash.startsWith('#/docs/downloads.md')) {
activeLink = document.querySelector('#topbarNav .nav-link[data-route="downloads"]');
} else if (hash.startsWith('#/docs/') || hash.startsWith('#/authors')) { } else if (hash.startsWith('#/docs/') || hash.startsWith('#/authors')) {
// Mark Docs menu root as active // Mark Docs menu root as active
activeLink = document.querySelector('#topbarNav .nav-link.dropdown-toggle'); activeLink = document.querySelector('#topbarNav .nav-link.dropdown-toggle');