lyng/docs/lyng.io.http.server.md
2026-04-29 22:39:43 +03:00

12 KiB

lyng.io.http.server - Minimal HTTP/1.1 And WebSocket Server

This module provides a small server-side HTTP API for Lyng scripts. It is implemented in lyngio on top of the existing TCP layer and is intended for embedded tools, local services, test fixtures, and lightweight app backends.

It supports:

  • HTTP/1.1 request parsing
  • keep-alive
  • exact-path routing
  • regex routing
  • path-template routing with named parameters
  • websocket upgrade and server-side sessions

It does not aim to replace a full reverse proxy. Typical deployment is behind nginx, Caddy, or another frontend that handles TLS and public-facing edge concerns.

Security note: this module uses the same NetAccessPolicy capability model as raw TCP sockets. If scripts are allowed to listen on TCP, they can host an HTTP server.

Install The Module Into A Lyng Session

Kotlin bootstrap example:

import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.Scope
import net.sergeych.lyng.io.http.server.createHttpServerModule
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy

suspend fun bootstrapHttpServer() {
    val session = EvalSession()
    val scope: Scope = session.getScope()
    createHttpServerModule(PermitAllNetAccessPolicy, scope)
    session.eval("import lyng.io.http.server")
}

RequestContext Sugar

Route handlers use RequestContext as the receiver, so inside handlers you normally write direct calls such as:

  • jsonBody<T>()
  • respondJson(...)
  • respondHtml { ... }
  • respondText(...)
  • setHeader(...)
  • request.path
  • routeParams["id"]

This keeps ordinary HTTP endpoints compact and avoids passing an explicit request or exchange parameter through every route lambda.

HTML Response Sugar

Use respondHtml { ... } to render an HTML document with the lyng.io.html DSL and send it as text/html; charset=utf-8.

import lyng.io.http.server
import lyng.io.html

val server = HttpServer()

server.get("/") {
    respondHtml {
        head {
            title { +"Lyng status" }
        }
        body {
            h3 { +"Service is running" }
            p { +("Path: ${request.path}" }
        }
    }
}

server.listen(8080, "127.0.0.1")

Pass code: when the route should return a non-200 status:

server.get("/accepted") {
    respondHtml(code: 202) {
        body { h3 { +"Accepted" } }
    }
}

JSON API Sugar

For ordinary JSON APIs, RequestContext includes two primary helpers:

  • jsonBody<T>() decodes the request body with typed Json.decodeAs(...)
  • respondJson(body, status = 200) sets JSON content type and responds with plain toJsonString()

These helpers intentionally use ordinary JSON projection for HTTP interop, not canonical Json.encode(...).

Typed JSON POST With Route Params

import lyng.io.http.server

closed class CreateResultRequest(title: String, score: Int)
closed class CreateResultResponse(id: String, userId: String, title: String, score: Int)

val server = HttpServer()

server.postPath("/api/users/{userId}/results") {
    val req = jsonBody<CreateResultRequest>()

    if (req.title.isBlank()) {
        respondJson({ error: "title must not be empty" }, 400)
        return
    }

    respondJson(
        CreateResultResponse("r-101", routeParams["userId"], req.title, req.score),
        201
    )
}

server.listen(8080, "127.0.0.1")

JSON Response With Route Params

import lyng.io.http.server

val server = HttpServer()

server.getPath("/api/users/{id}") {
    respondJson({
        id: routeParams["id"],
        path: request.path,
        ok: true
    })
}

server.listen(8080, "127.0.0.1")

Request And Route Data

ServerRequest exposes parsed HTTP request data:

  • method: String
  • target: String
  • path: String
  • pathParts: List<String>
  • queryString: String?
  • query: Map<String, String>
  • headers: HttpHeaders
  • body: Buffer

RequestContext exposes routing context and response controls:

  • request: ServerRequest
  • routeMatch: RegexMatch?
  • routeParams: Map<String, String>
  • jsonBody<T>()
  • respond(...)
  • respondText(...)
  • respondJson(body, status = 200)
  • respondHtml(code: 200) { ... }
  • setHeader(...)
  • addHeader(...)
  • acceptWebSocket(...)

For exact routes, routeMatch is null and routeParams is empty. For regex routes, routeMatch is set and routeParams is empty. For path-template routes, both routeMatch and routeParams are set.

Reusable Routers

Router collects the same route kinds as HttpServer, but does not listen on sockets by itself. Mount it into HttpServer or another Router.

import lyng.io.http.server

val api = Router()
api.get("/health") {
    respondText(200, "ok")
}

val users = Router()
users.getPath("/users/{id}") {
    respondJson({ id: routeParams["id"] })
}

api.mount(users)

val server = HttpServer()
server.mount(api)
server.listen(8080, "127.0.0.1")

Mounted routers reuse the built-in server router. They are configuration-time composition, not an extra per-request Lyng dispatch layer.

WebSocket Routes

You can route websocket upgrades by exact path, regex, or path template.

server.ws("/chat") { ws ->
    ws.sendText("hello")
    ws.close()
}

server.wsPath("/ws/{room}") { ws ->
    ws.sendText("room=" + routeParams["room"])
    ws.close()
}

A websocket handler runs only for requests that actually ask for websocket upgrade. Ordinary HTTP requests to the same path are not treated as websocket sessions.

Choosing Between ws(...) And acceptWebSocket(...)

Use server.ws(...) or server.wsPath(...) when the route is always a websocket endpoint.

Use acceptWebSocket(...) inside a normal HTTP handler when the same route may inspect the request first and then decide whether to upgrade.

server.get("/maybe-upgrade") {
    if (!request.isWebSocketUpgrade()) {
        respondText(400, "websocket upgrade required")
        return
    }

    acceptWebSocket { ws ->
        ws.sendText("connected")
        ws.close()
    }
}

Reading Incoming Messages

Inside a websocket handler, call ws.receive() to wait for the next application message.

What receive() returns:

  • WsMessage for the next text or binary message.
  • null after the client sends a close frame.
  • null after the socket is already closed and no more frames can arrive.

What reaches Lyng code:

  • Text frames become WsMessage(isText = true, text = ...).
  • Binary frames become WsMessage(isText = false, data = ...).
  • Fragmented websocket messages are reassembled before they are returned.
  • Ping and pong control frames are handled internally and do not appear in Lyng.
  • A client close frame is answered by the server close handshake, then receive() returns null.

Typical server receive loop:

import lyng.buffer

server.ws("/echo") { ws ->
    while (true) {
        val msg = ws.receive() ?: break
        if (msg.isText) {
            ws.sendText("echo:" + msg.text)
        } else {
            ws.sendBytes(msg.data as Buffer)
        }
    }
}

Sending Outgoing Messages

Use:

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

Example:

import lyng.buffer

server.ws("/push") { ws ->
    ws.sendText("ready")
    ws.sendBytes(Buffer(1, 2, 3))
    ws.close()
}

Send behavior:

  • Each call sends one websocket message.
  • The server API does not expose frame-by-frame streaming.
  • Once the session is closed, send calls fail with a websocket error.

What Happens When The Connection Closes

There are three practical cases:

  1. The client closes first. The runtime replies with a close frame, releases the socket, and receive() returns null.

  2. Your handler closes first with ws.close(...). The runtime sends a close frame and releases the socket locally.

  3. The transport disappears unexpectedly. The session is released and no more messages can be received; subsequent sends fail.

What Lyng code should do:

  • Treat receive() == null as end-of-session.
  • Exit the handler or break the receive loop at that point.
  • Do not keep sending after close has been observed.

The current server-side API does not expose the peer close code or close reason to Lyng.

Closing The Connection Yourself

Call ws.close() when you want to terminate the websocket session.

server.ws("/chat") { ws ->
    ws.sendText("server shutting down")
    ws.close(1000, "done")
}

Close semantics:

  • close() sends a websocket close frame with the given code and reason.
  • Defaults are code = 1000 and reason = "".
  • close() is idempotent; calling it again after close does nothing.
  • After local close, the session should be treated as unusable.
  • After close, isOpen() becomes false and further sends fail.

WebSocket Handler Pattern

import lyng.io.http.server

val server = HttpServer()

server.wsPath("/rooms/{room}") { ws ->
    val room = routeParams["room"] ?: "<unknown>"
    ws.sendText("joined:" + room)

    while (true) {
        val msg = ws.receive() ?: break
        if (msg.isText) {
            ws.sendText(room + ":" + msg.text)
        }
    }

    ws.close()
}

server.listen(8080, "127.0.0.1")

Path-Template Routes

Path templates are sugar on top of regex routes. Template parameters are exposed as decoded routeParams.

server.getPath("/users/{userId}/posts/{postId}") {
    respondText(
        200,
        routeParams["userId"] + ":" + routeParams["postId"]
    )
}

Template rules:

  • template must start with /
  • a segment is either literal text or {name}
  • parameter names must be valid identifiers
  • parameter values match one path segment only
  • parameter values use path decoding rules:
    • valid percent-encoding is decoded
    • + stays +
    • malformed % stays literal

Regex Routes

Regex routes match the whole request path, not a substring.

server.get("^/users/([0-9]+)/posts/([0-9]+)$".re) {
    val m = routeMatch!!
    respondText(200, "user=" + m[1] + ", post=" + m[2])
}

Basic Exact Route

import lyng.io.http.server

val server = HttpServer()
server.get("/hello") {
    setHeader("Content-Type", "text/plain")
    respondText(200, "hello")
}
server.listen(8080, "127.0.0.1")

Route Precedence

Dispatch order is:

  1. exact method route
  2. exact any route
  3. regex method route, registration order
  4. regex any route, registration order
  5. fallback

This means exact routes stay fast and always win over template or regex routes for the same path.

API Surface

Router Route Registration Methods

  • get(path: String|Regex, handler)
  • getPath(pathTemplate: String, handler)
  • post(path: String|Regex, handler)
  • postPath(pathTemplate: String, handler)
  • put(path: String|Regex, handler)
  • putPath(pathTemplate: String, handler)
  • delete(path: String|Regex, handler)
  • deletePath(pathTemplate: String, handler)
  • any(path: String|Regex, handler)
  • anyPath(pathTemplate: String, handler)
  • ws(path: String|Regex, handler)
  • wsPath(pathTemplate: String, handler)
  • fallback(handler)
  • mount(router)

HttpServer Route Registration Methods

  • get(path: String|Regex, handler)
  • getPath(pathTemplate: String, handler)
  • post(path: String|Regex, handler)
  • postPath(pathTemplate: String, handler)
  • put(path: String|Regex, handler)
  • putPath(pathTemplate: String, handler)
  • delete(path: String|Regex, handler)
  • deletePath(pathTemplate: String, handler)
  • any(path: String|Regex, handler)
  • anyPath(pathTemplate: String, handler)
  • ws(path: String|Regex, handler)
  • wsPath(pathTemplate: String, handler)
  • fallback(handler)
  • mount(router)
  • listen(port, host = null, backlog = 128)