Compare commits
No commits in common. "9bee0aed5b7a12bbea11011491926ada16f32fa6" and "f168e9f6edc7e52650fe3cecdfb0abf9185488f4" have entirely different histories.
9bee0aed5b
...
f168e9f6ed
@ -1,7 +1,5 @@
|
|||||||
# 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`.
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
# 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.
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
# 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`.
|
||||||
@ -84,11 +82,8 @@ 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`, `http`, `ws`, `net`), gate assumptions and mention required module install.
|
- For platform-sensitive code (`fs`, `process`, `console`), 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`).
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,7 +1,5 @@
|
|||||||
# 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
|
||||||
|
|||||||
@ -1,179 +0,0 @@
|
|||||||
### 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
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
### 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
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
### 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
|
|
||||||
@ -13,9 +13,6 @@
|
|||||||
- **[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`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -44,15 +41,9 @@ 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()
|
||||||
@ -62,25 +53,16 @@ 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())
|
|
||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -94,27 +76,21 @@ 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 | lyng.io.http | lyng.io.ws | lyng.io.net |
|
| Platform | lyng.io.fs | lyng.io.process | lyng.io.console |
|
||||||
| :--- | :---: | :---: | :---: | :---: | :---: | :---: |
|
| :--- | :---: | :---: | :---: |
|
||||||
| **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) | ❌ | ❌ |
|
||||||
|
|||||||
@ -49,7 +49,6 @@ 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>
|
||||||
@ -469,6 +468,8 @@ 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)))
|
||||||
|
|||||||
@ -13,7 +13,6 @@ 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" }
|
||||||
@ -32,14 +31,6 @@ 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" }
|
||||||
|
|||||||
@ -31,12 +31,11 @@ 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("${filePath}")
|
val p = Path("${'$'}{file}")
|
||||||
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())
|
||||||
|
|||||||
@ -80,49 +80,28 @@ 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(darwinMain)
|
dependsOn(nativeMain)
|
||||||
}
|
}
|
||||||
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(darwinMain)
|
dependsOn(nativeMain)
|
||||||
}
|
}
|
||||||
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) }
|
||||||
@ -130,8 +109,6 @@ 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 {
|
||||||
@ -139,13 +116,6 @@ 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 {
|
||||||
@ -153,8 +123,6 @@ 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
|
||||||
@ -167,10 +135,10 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class GenerateLyngioDecls : DefaultTask() {
|
abstract class GenerateLyngioConsoleDecls : DefaultTask() {
|
||||||
@get:InputDirectory
|
@get:InputFile
|
||||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||||
abstract val sourceDir: DirectoryProperty
|
abstract val sourceFile: RegularFileProperty
|
||||||
|
|
||||||
@get:OutputDirectory
|
@get:OutputDirectory
|
||||||
abstract val outputDir: DirectoryProperty
|
abstract val outputDir: DirectoryProperty
|
||||||
@ -180,9 +148,9 @@ abstract class GenerateLyngioDecls : 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("\\\\")
|
||||||
@ -197,39 +165,30 @@ abstract class GenerateLyngioDecls : 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")
|
||||||
sourceDir.get().asFile
|
append("internal val consoleLyng = \"")
|
||||||
.listFiles { file -> file.isFile && file.extension == "lyng" }
|
append(escapeForQuoted(text))
|
||||||
?.sortedBy { it.name }
|
append("\"\n")
|
||||||
?.forEach { file ->
|
|
||||||
val propertyName = buildString {
|
|
||||||
append(file.nameWithoutExtension)
|
|
||||||
append("Lyng")
|
|
||||||
}
|
|
||||||
append("internal val ").append(propertyName).append(" = \"")
|
|
||||||
append(escapeForQuoted(file.readText()))
|
|
||||||
append("\"\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
targetDir.resolve("lyngio_types_lyng.generated.kt").writeText(out)
|
targetDir.resolve("console_types_lyng.generated.kt").writeText(out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val lyngioDeclsDir = layout.projectDirectory.dir("stdlib/lyng/io")
|
val lyngioConsoleDeclsFile = layout.projectDirectory.file("stdlib/lyng/io/console.lyng")
|
||||||
val generatedLyngioDeclsDir = layout.buildDirectory.dir("generated/source/lyngioDecls/commonMain/kotlin")
|
val generatedLyngioDeclsDir = layout.buildDirectory.dir("generated/source/lyngioDecls/commonMain/kotlin")
|
||||||
|
|
||||||
val generateLyngioDecls by tasks.registering(GenerateLyngioDecls::class) {
|
val generateLyngioConsoleDecls by tasks.registering(GenerateLyngioConsoleDecls::class) {
|
||||||
sourceDir.set(lyngioDeclsDir)
|
sourceFile.set(lyngioConsoleDeclsFile)
|
||||||
outputDir.set(generatedLyngioDeclsDir)
|
outputDir.set(generatedLyngioDeclsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin.sourceSets.named("commonMain") {
|
kotlin.sourceSets.named("commonMain") {
|
||||||
kotlin.srcDir(generateLyngioDecls)
|
kotlin.srcDir(generateLyngioConsoleDecls)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin.targets.configureEach {
|
kotlin.targets.configureEach {
|
||||||
compilations.configureEach {
|
compilations.configureEach {
|
||||||
compileTaskProvider.configure {
|
compileTaskProvider.configure {
|
||||||
dependsOn(generateLyngioDecls)
|
dependsOn(generateLyngioConsoleDecls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
package net.sergeych.lyngio.http
|
|
||||||
|
|
||||||
import io.ktor.client.engine.cio.CIO
|
|
||||||
|
|
||||||
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(CIO)
|
|
||||||
@ -1,206 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package net.sergeych.lyngio.ws
|
|
||||||
|
|
||||||
import io.ktor.client.engine.cio.CIO
|
|
||||||
|
|
||||||
actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(CIO)
|
|
||||||
@ -1,390 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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())
|
|
||||||
@ -1,376 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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")
|
|
||||||
}
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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")
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package net.sergeych.lyngio.http
|
|
||||||
|
|
||||||
import io.ktor.client.engine.darwin.Darwin
|
|
||||||
|
|
||||||
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(Darwin)
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
package net.sergeych.lyngio.net
|
|
||||||
|
|
||||||
actual fun getSystemNetEngine(): LyngNetEngine = createNativeKtorNetEngine(
|
|
||||||
isSupported = true,
|
|
||||||
isTcpAvailable = true,
|
|
||||||
isTcpServerAvailable = true,
|
|
||||||
isUdpAvailable = true,
|
|
||||||
)
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package net.sergeych.lyngio.ws
|
|
||||||
|
|
||||||
import io.ktor.client.engine.darwin.Darwin
|
|
||||||
|
|
||||||
actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(Darwin)
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package net.sergeych.lyngio.http
|
|
||||||
|
|
||||||
import io.ktor.client.engine.js.Js
|
|
||||||
|
|
||||||
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(Js)
|
|
||||||
@ -1,422 +0,0 @@
|
|||||||
@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
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package net.sergeych.lyngio.ws
|
|
||||||
|
|
||||||
import io.ktor.client.engine.js.Js
|
|
||||||
|
|
||||||
actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(Js)
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package net.sergeych.lyngio.http
|
|
||||||
|
|
||||||
import io.ktor.client.engine.cio.CIO
|
|
||||||
|
|
||||||
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(CIO)
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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,
|
|
||||||
)
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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)
|
|
||||||
@ -1,256 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,168 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package net.sergeych.lyngio.http
|
|
||||||
|
|
||||||
import io.ktor.client.engine.curl.Curl
|
|
||||||
|
|
||||||
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(Curl)
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
package net.sergeych.lyngio.net
|
|
||||||
|
|
||||||
actual fun getSystemNetEngine(): LyngNetEngine = createNativeKtorNetEngine(
|
|
||||||
isSupported = true,
|
|
||||||
isTcpAvailable = true,
|
|
||||||
isTcpServerAvailable = true,
|
|
||||||
isUdpAvailable = true,
|
|
||||||
)
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package net.sergeych.lyngio.ws
|
|
||||||
|
|
||||||
import io.ktor.client.engine.curl.Curl
|
|
||||||
|
|
||||||
actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(Curl)
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package net.sergeych.lyngio.http
|
|
||||||
|
|
||||||
import io.ktor.client.engine.winhttp.WinHttp
|
|
||||||
|
|
||||||
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(WinHttp)
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
package net.sergeych.lyngio.net
|
|
||||||
|
|
||||||
actual fun getSystemNetEngine(): LyngNetEngine = UnsupportedLyngNetEngine
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package net.sergeych.lyngio.ws
|
|
||||||
|
|
||||||
import io.ktor.client.engine.winhttp.WinHttp
|
|
||||||
|
|
||||||
actual fun getSystemWsEngine(): LyngWsEngine = createKtorWsEngine(WinHttp)
|
|
||||||
@ -1,216 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -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.4-SNAPSHOT"
|
version = "1.5.3"
|
||||||
|
|
||||||
// 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
|
||||||
|
|
||||||
|
|||||||
@ -4532,15 +4532,6 @@ 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)
|
||||||
@ -4943,33 +4934,12 @@ 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 -> when (target.name) {
|
is LocalSlotRef -> callableReturnTypeByScopeId[target.scopeId]?.get(target.slot)
|
||||||
"lazy" -> resolveClassByName("lazy")
|
?: resolveClassByName(target.name)
|
||||||
"iterator" -> ObjIterator
|
is LocalVarRef -> callableReturnTypeByName[target.name]
|
||||||
"flow" -> ObjFlow.type
|
?: resolveClassByName(target.name)
|
||||||
"launch" -> ObjDeferred.type
|
is FastLocalVarRef -> callableReturnTypeByName[target.name]
|
||||||
"dynamic" -> ObjDynamic.type
|
?: resolveClassByName(target.name)
|
||||||
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
|
||||||
@ -7520,20 +7490,6 @@ 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)
|
||||||
@ -7553,16 +7509,6 @@ 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()
|
||||||
}
|
}
|
||||||
@ -9218,6 +9164,7 @@ 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")
|
||||||
|
|||||||
@ -209,26 +209,4 @@ 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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2050,9 +2050,9 @@ class ScriptTest {
|
|||||||
fun nationalCharsTest() = runTest {
|
fun nationalCharsTest() = runTest {
|
||||||
eval(
|
eval(
|
||||||
"""
|
"""
|
||||||
fun сумма_ряда(x, погрешность=0.001, f) {
|
fun сумма_ряда(x, погрешность=0.0001, f) {
|
||||||
var сумма = 0
|
var сумма = 0
|
||||||
for( n in 1..5000) {
|
for( n in 1..100000) {
|
||||||
val следующая_сумма = сумма + f(x, n)
|
val следующая_сумма = сумма + f(x, n)
|
||||||
if( n > 1 && abs(следующая_сумма - сумма) < погрешность )
|
if( n > 1 && abs(следующая_сумма - сумма) < погрешность )
|
||||||
break следующая_сумма
|
break следующая_сумма
|
||||||
|
|||||||
@ -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 )
|
||||||
assert(1.d.i.toString() in ["0.0+1.0i", "0+1i"])
|
assertEquals("0.0+1.0i", 1.d.i.toString())
|
||||||
assert(1.d.re.toString() in ["1.0+0.0i", "1+0i"])
|
assertEquals("1.0+0.0i", 1.d.re.toString())
|
||||||
|
|
||||||
var c = 1 + 2.i
|
var c = 1 + 2.i
|
||||||
assert(c is Complex)
|
assert(c is Complex)
|
||||||
assert(c.toString() in ["1.0+2.0i", "1+2i"])
|
assertEquals("1.0+2.0i", c.toString())
|
||||||
|
|
||||||
c = 1.d + 2.i
|
c = 1.d + 2.i
|
||||||
assert(c.toString() in ["1.0+2.0i", "1+2i"])
|
assertEquals("1.0+2.0i", c.toString())
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,205 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,577 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -413,9 +413,6 @@
|
|||||||
</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>
|
||||||
@ -495,8 +492,6 @@
|
|||||||
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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user