lyng/docs/lyng.io.ws.md

7.5 KiB

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.

Shared type note: WsMessage is also available from lyng.io.ws.types when host code wants the reusable message type without depending on the WebSocket client module itself.

Install The Module Into A Lyng Session

Kotlin host bootstrap example:

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

Text 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 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 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]

Message Flow And Session Lifecycle

Reading Incoming Messages

Call ws.receive() to wait for the next application message.

What receive() returns:

  • WsMessage for the next text or binary message.
  • null after the peer closes the connection cleanly.
  • null after the transport has already been closed and no more messages can arrive.

What reaches Lyng code:

  • Text frames are exposed as WsMessage(isText = true, text = ...).
  • Binary frames are exposed as WsMessage(isText = false, data = ...).
  • Fragmented websocket messages are reassembled before they are returned.
  • Ping and pong control frames are handled internally and are not returned by receive().
  • Incoming close frames are handled internally; after that receive() returns null.

Typical receive loop:

import lyng.buffer
import lyng.io.ws

val ws = Ws.connect(WS_URL)

while (true) {
    val msg = ws.receive() ?: break

    if (msg.isText) {
        println("text=" + msg.text)
    } else {
        println("bytes=" + ((msg.data as Buffer).size))
    }
}

println("peer closed the websocket")

Sending Outgoing Messages

Use:

  • ws.sendText(text) for UTF-8 text messages.
  • ws.sendBytes(data) for binary messages.

Example:

import lyng.buffer
import lyng.io.ws

val ws = Ws.connect(WS_URL)
ws.sendText("hello")
ws.sendBytes(Buffer(1, 2, 3, 4))

Send behavior:

  • Each call sends one websocket message.
  • The API does not expose partial-frame streaming; send the whole message in one call.
  • If the session is already closed, sendText(...) and sendBytes(...) fail with a websocket error.
  • If the transport breaks during send, the session is released and the send call fails.

Detecting Closed Connections

Use both signals together:

  • ws.isOpen() tells you whether the session is still considered open right now.
  • ws.receive() == null tells you the receive side has reached the end of the websocket session.

Practical rule:

  • If receive() returns null, stop reading and treat the session as closed.
  • After close has been observed, do not attempt further sends.

The API does not currently expose the peer close code or close reason to Lyng code.

Closing The Connection Yourself

Call ws.close() when you are done.

import lyng.io.ws

val ws = Ws.connect(WS_URL)
ws.sendText("bye")
ws.close(1000, "done")

Close semantics:

  • close() sends a websocket close frame with the given code and reason.
  • Defaults are code = 1000 and reason = "".
  • After close(), the session is released locally and should be treated as closed immediately.
  • Calling close() on an already closed session is a no-op.
  • After local close, receive() returns null and further sends fail.

For request-response style exchanges:

import lyng.io.ws

val ws = Ws.connect(WS_URL)
try {
    ws.sendText("ping")
    val reply = ws.receive() ?: error("socket closed before reply")
    println(reply.text)
} finally {
    ws.close()
}

For long-lived consumers:

import lyng.io.ws

val ws = Ws.connect(WS_URL)

try {
    while (true) {
        val msg = ws.receive() ?: break
        if (msg.isText) {
            println(msg.text)
        }
    }
} finally {
    ws.close()
}

API Reference

Ws

  • 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, for example "Authorization" => "Bearer x"
  • 2-item lists, for example ["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

Behavior summary:

  • receive() returns null after close.
  • close() is safe to call more than once.
  • send operations require an open session.

WsMessage

  • isText: Bool
  • text: String?
  • data: Buffer?

Payload rules:

  • Text messages populate text and leave data == null.
  • Binary messages populate data and leave text == null.

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:

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.