Add minimal HTTP server and shared network type packages

This commit is contained in:
Sergey Chernov 2026-04-26 10:09:25 +03:00
parent 79b015ee56
commit 01ceecd7df
28 changed files with 2642 additions and 55 deletions

View File

@ -92,6 +92,10 @@ Requires installing `lyngio` into the import manager from host code.
- `import lyng.io.http` (HTTP/HTTPS client API) - `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.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) - `import lyng.io.net` (TCP/UDP transport API; currently supported on JVM, capability-gated elsewhere)
- Shared network value-type packages are also available when installed by host code:
- `import lyng.io.http.types` (`HttpHeaders`)
- `import lyng.io.ws.types` (`WsMessage`)
- `import lyng.io.net.types` (`IpVersion`, `SocketAddress`, `Datagram`)
## 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.

View File

@ -3,6 +3,8 @@
This module provides a compact HTTP client API for Lyng scripts. It is implemented in `lyngio` and backed by Ktor on supported runtimes. 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. > **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:** `HttpHeaders` is also available from `lyng.io.http.types` when host code wants the reusable value type without relying on the HTTP client module itself.
--- ---

View File

@ -4,6 +4,8 @@ This module provides minimal raw transport networking for Lyng scripts. It is im
> **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. > **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:** `IpVersion`, `SocketAddress`, and `Datagram` are also available from `lyng.io.net.types` when host code wants reusable transport value types without depending on the `Net` capability object itself.
>
> **Important native platform limit:** current native TCP/UDP support is backed by a selector with a per-process file descriptor ceiling. On Linux/macOS native targets this makes high-connection-count servers and same-process load tests unsuitable once the process approaches that limit. > **Important native platform limit:** current native TCP/UDP support is backed by a selector with a per-process file descriptor ceiling. On Linux/macOS native targets this makes high-connection-count servers and same-process load tests unsuitable once the process approaches that limit.
> >
> **Recommendation:** for serious HTTP/TCP servers, prefer the JVM target today. On native targets, keep concurrency bounded, batch local load tests in waves, and use multiple worker processes behind a reverse proxy if you need more throughput before the backend is reworked. > **Recommendation:** for serious HTTP/TCP servers, prefer the JVM target today. On native targets, keep concurrency bounded, batch local load tests in waves, and use multiple worker processes behind a reverse proxy if you need more throughput before the backend is reworked.

View File

@ -3,6 +3,8 @@
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. 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. > **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.
--- ---

View File

@ -19,6 +19,7 @@
- **[lyng.io.http](lyng.io.http.md):** HTTP/HTTPS client access. Provides `Http`, `HttpRequest`, `HttpResponse`, and `HttpHeaders`. - **[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.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`. - **[lyng.io.net](lyng.io.net.md):** Transport networking. Provides `Net`, `TcpSocket`, `TcpServer`, `UdpSocket`, and `SocketAddress`.
- **Shared networking type packages:** `lyng.io.http.types`, `lyng.io.ws.types`, and `lyng.io.net.types` expose reusable value types such as `HttpHeaders`, `WsMessage`, `IpVersion`, `SocketAddress`, and `Datagram` when host code wants type-only imports without installing the corresponding capability object module.
--- ---

View File

@ -46,8 +46,10 @@ import net.sergeych.lyngio.http.security.HttpAccessDeniedException
import net.sergeych.lyngio.http.security.HttpAccessOp import net.sergeych.lyngio.http.security.HttpAccessOp
import net.sergeych.lyngio.http.security.HttpAccessPolicy import net.sergeych.lyngio.http.security.HttpAccessPolicy
import net.sergeych.lyngio.stdlib_included.httpLyng import net.sergeych.lyngio.stdlib_included.httpLyng
import net.sergeych.lyngio.stdlib_included.http_typesLyng
private const val HTTP_MODULE_NAME = "lyng.io.http" private const val HTTP_MODULE_NAME = "lyng.io.http"
internal const val HTTP_TYPES_MODULE_NAME = "lyng.io.http.types"
fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean = fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
createHttpModule(policy, scope.importManager) createHttpModule(policy, scope.importManager)
@ -55,6 +57,7 @@ fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
fun createHttp(policy: HttpAccessPolicy, scope: Scope): Boolean = createHttpModule(policy, scope) fun createHttp(policy: HttpAccessPolicy, scope: Scope): Boolean = createHttpModule(policy, scope)
fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean { fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean {
createHttpTypesModule(manager)
if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false
manager.addPackage(HTTP_MODULE_NAME) { module -> manager.addPackage(HTTP_MODULE_NAME) { module ->
buildHttpModule(module, policy) buildHttpModule(module, policy)
@ -64,6 +67,19 @@ fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean
fun createHttp(policy: HttpAccessPolicy, manager: ImportManager): Boolean = createHttpModule(policy, manager) fun createHttp(policy: HttpAccessPolicy, manager: ImportManager): Boolean = createHttpModule(policy, manager)
internal fun createHttpTypesModule(manager: ImportManager): Boolean {
if (manager.packageNames.contains(HTTP_TYPES_MODULE_NAME)) return false
manager.addPackage(HTTP_TYPES_MODULE_NAME) { module ->
buildHttpTypesModule(module)
}
return true
}
private suspend fun buildHttpTypesModule(module: ModuleScope) {
module.eval(Source(HTTP_TYPES_MODULE_NAME, http_typesLyng))
module.addConst("HttpHeaders", ObjHttpHeaders.type)
}
private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolicy) { private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolicy) {
module.eval(Source(HTTP_MODULE_NAME, httpLyng)) module.eval(Source(HTTP_MODULE_NAME, httpLyng))
val engine = getSystemHttpEngine() val engine = getSystemHttpEngine()
@ -139,7 +155,7 @@ private suspend inline fun ScopeFacade.httpGuard(crossinline block: suspend () -
} }
} }
private class ObjHttpHeaders( internal class ObjHttpHeaders(
singleValueHeaders: Map<String, String> = emptyMap(), singleValueHeaders: Map<String, String> = emptyMap(),
private val allHeaders: Map<String, List<String>> = emptyMap(), private val allHeaders: Map<String, List<String>> = emptyMap(),
) : Obj() { ) : Obj() {
@ -201,6 +217,11 @@ private class ObjHttpHeaders(
).invokeInstanceMethod(requireScope(), "iterator") ).invokeInstanceMethod(requireScope(), "iterator")
} }
} }
internal fun fromHeaders(
singleValueHeaders: Map<String, String>,
allHeaders: Map<String, List<String>>,
): ObjHttpHeaders = ObjHttpHeaders(singleValueHeaders, allHeaders)
} }
private fun valuesOf(name: String): List<String> = allHeaders[lookupKey(name)] ?: emptyList() private fun valuesOf(name: String): List<String> = allHeaders[lookupKey(name)] ?: emptyList()

View File

@ -0,0 +1,514 @@
package net.sergeych.lyng.io.http.server
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.Source
import net.sergeych.lyng.Arguments
import net.sergeych.lyng.TypeDecl
import net.sergeych.lyng.asFacade
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.ObjExternCallable
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjList
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjProperty
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.io.http.ObjHttpHeaders
import net.sergeych.lyng.io.http.createHttpTypesModule
import net.sergeych.lyng.io.ws.ObjWsMessage
import net.sergeych.lyng.io.ws.createWsTypesModule
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.server.HttpHandlerResult
import net.sergeych.lyngio.http.server.HttpHeader
import net.sergeych.lyngio.http.server.HttpRequest
import net.sergeych.lyngio.http.server.HttpResponse
import net.sergeych.lyngio.http.server.HttpServerConfig
import net.sergeych.lyngio.http.server.HttpWebSocketSession
import net.sergeych.lyngio.http.server.defaultReason
import net.sergeych.lyngio.http.server.startHttpServer
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.http_serverLyng
import net.sergeych.lyng.bytecode.BytecodeLambdaCallable
private const val HTTP_SERVER_MODULE_NAME = "lyng.io.http.server"
fun createHttpServerModule(policy: NetAccessPolicy, scope: Scope): Boolean =
createHttpServerModule(policy, scope.importManager)
fun createHttpServer(policy: NetAccessPolicy, scope: Scope): Boolean = createHttpServerModule(policy, scope)
fun createHttpServerModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
createHttpTypesModule(manager)
createWsTypesModule(manager)
if (manager.packageNames.contains(HTTP_SERVER_MODULE_NAME)) return false
manager.addPackage(HTTP_SERVER_MODULE_NAME) { module ->
buildHttpServerModule(module, policy)
}
return true
}
fun createHttpServer(policy: NetAccessPolicy, manager: ImportManager): Boolean = createHttpServerModule(policy, manager)
private suspend fun buildHttpServerModule(module: ModuleScope, policy: NetAccessPolicy) {
module.eval(Source(HTTP_SERVER_MODULE_NAME, http_serverLyng))
module.addConst("HttpHeaders", ObjHttpHeaders.type)
module.addConst("WsMessage", ObjWsMessage.type)
module.addConst("ServerRequest", ObjServerRequest.type)
module.addConst("ServerExchange", ObjServerExchange.type)
module.addConst("ServerWebSocket", ObjServerWebSocket.type)
module.addConst("HttpServerHandle", ObjHttpServerHandle.type)
module.addConst("HttpServer", ObjLyngHttpServer.type(policy))
}
private suspend inline fun ScopeFacade.httpServerGuard(crossinline block: suspend () -> Obj): Obj {
return try {
block()
} catch (e: NetAccessDeniedException) {
raiseIllegalOperation(e.reasonDetail ?: "http server access denied")
} catch (e: Exception) {
raiseIllegalOperation(e.message ?: "http server error")
}
}
private data class RegisteredCallable(
val callable: Obj,
val scope: Scope,
)
private fun captureCallable(scope: Scope, rawCallable: Obj): RegisteredCallable {
val captured = if (scope is ModuleScope) scope else scope.snapshotForClosure()
val callable = (rawCallable as? BytecodeLambdaCallable)?.freezeForLaunch(captured) ?: rawCallable
return RegisteredCallable(callable, captured)
}
private suspend fun RegisteredCallable.call(vararg args: Obj): Obj =
scope.asFacade().call(callable, Arguments(args.toList()))
private val stringType = TypeDecl.Simple("String", false)
private val nullableStringType = TypeDecl.Simple("String", true)
private val boolType = TypeDecl.Simple("Bool", false)
private val intType = TypeDecl.Simple("Int", false)
private val bufferType = TypeDecl.Simple("Buffer", false)
private val nullableBufferType = TypeDecl.Simple("Buffer", true)
private val voidType = TypeDecl.Simple("Void", false)
private val httpHeadersType = TypeDecl.Simple("HttpHeaders", false)
private val serverRequestType = TypeDecl.Simple("ServerRequest", false)
private val serverExchangeType = TypeDecl.Simple("ServerExchange", false)
private val serverWebSocketType = TypeDecl.Simple("ServerWebSocket", false)
private val nullableServerWsMessageType = TypeDecl.Simple("WsMessage", true)
private val httpServerHandleType = TypeDecl.Simple("HttpServerHandle", false)
private val httpServerType = TypeDecl.Simple("HttpServer", false)
private val nullableAnyType = TypeDecl.TypeNullableAny
private fun listType(item: TypeDecl) = TypeDecl.Generic("List", listOf(item), false)
private fun fnType(returnType: TypeDecl, vararg params: TypeDecl) =
TypeDecl.Function(receiver = null, params = params.toList(), returnType = returnType)
private fun bridgeFn(
owner: ObjClass,
name: String,
typeDecl: TypeDecl.Function,
code: suspend ScopeFacade.() -> Obj,
) {
owner.createField(
name = name,
initialValue = ObjExternCallable.fromBridge { code() },
type = net.sergeych.lyng.obj.ObjRecord.Type.Fun,
typeDecl = typeDecl,
)
}
private fun bridgeProperty(
owner: ObjClass,
name: String,
typeDecl: TypeDecl,
getter: suspend ScopeFacade.() -> Obj,
) {
owner.createField(
name = name,
initialValue = ObjProperty(name, ObjExternCallable.fromBridge { getter() }, null),
type = net.sergeych.lyng.obj.ObjRecord.Type.Property,
typeDecl = typeDecl,
)
}
private class ObjLyngHttpServer(
private val netPolicy: NetAccessPolicy,
) : Obj() {
private val methodRoutes = linkedMapOf<String, LinkedHashMap<String, RegisteredCallable>>()
private val anyRoutes = linkedMapOf<String, RegisteredCallable>()
private val wsRoutes = linkedMapOf<String, RegisteredCallable>()
private var fallback: RegisteredCallable? = null
private var handle: net.sergeych.lyngio.http.server.HttpServer? = null
override val objClass: ObjClass
get() = type(netPolicy)
companion object {
private val types = mutableMapOf<NetAccessPolicy, ObjClass>()
fun type(netPolicy: NetAccessPolicy): ObjClass =
types.getOrPut(netPolicy) {
object : ObjClass("HttpServer") {
override suspend fun callOn(scope: Scope): Obj {
if (scope.args.list.isNotEmpty()) scope.raiseError("HttpServer() does not accept arguments")
return ObjLyngHttpServer(netPolicy)
}
}.apply {
val exchangeHandlerType = fnType(nullableAnyType, serverExchangeType)
val webSocketHandlerType = fnType(nullableAnyType, serverWebSocketType, serverExchangeType)
bridgeFn(this, "get", fnType(httpServerType, stringType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerRoute("GET", this)
}
bridgeFn(this, "post", fnType(httpServerType, stringType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerRoute("POST", this)
}
bridgeFn(this, "put", fnType(httpServerType, stringType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerRoute("PUT", this)
}
bridgeFn(this, "delete", fnType(httpServerType, stringType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerRoute("DELETE", this)
}
bridgeFn(this, "any", fnType(httpServerType, stringType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerAny(this)
}
bridgeFn(this, "ws", fnType(httpServerType, stringType, webSocketHandlerType)) {
thisAs<ObjLyngHttpServer>().registerWs(this)
}
bridgeFn(this, "fallback", fnType(httpServerType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerFallback(this)
}
bridgeFn(this, "listen", fnType(httpServerHandleType, intType, nullableStringType, intType)) {
thisAs<ObjLyngHttpServer>().listen(this)
}
}
}
}
private fun ensureMutable(scope: ScopeFacade) {
if (handle != null) scope.raiseIllegalState("HttpServer routes cannot be modified after listen()")
}
private fun requirePath(scope: ScopeFacade, index: Int): String {
val path = scope.requiredArg<ObjString>(index).value
if (!path.startsWith('/')) scope.raiseIllegalArgument("path must start with '/'")
return path
}
private suspend fun registerRoute(method: String, scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
val path = requirePath(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
val routes = methodRoutes.getOrPut(method) { linkedMapOf() }
if (routes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for $method $path")
routes[path] = handler
scope.thisObj
}
private suspend fun registerAny(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
val path = requirePath(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
if (anyRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for ANY $path")
anyRoutes[path] = handler
scope.thisObj
}
private suspend fun registerWs(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
val path = requirePath(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
if (wsRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate websocket route for $path")
wsRoutes[path] = handler
scope.thisObj
}
private suspend fun registerFallback(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
fallback = captureCallable(scope.requireScope(), scope.args.list[0])
scope.thisObj
}
private suspend fun listen(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
val port = scope.requiredArg<ObjInt>(0).value.toInt()
val host = scope.args.list.getOrNull(1)?.let { objOrNullToString(scope, it, "host") }
val backlog = scope.args.list.getOrNull(2)?.let { objToInt(scope, it, "backlog") } ?: 128
if (port !in 0..65535) scope.raiseIllegalArgument("port must be in 0..65535")
if (backlog <= 0) scope.raiseIllegalArgument("backlog must be positive")
netPolicy.require(NetAccessOp.TcpListen(host, port, backlog))
val started = startHttpServer(
config = HttpServerConfig(host = host ?: "127.0.0.1", port = port, backlog = backlog),
) { request ->
dispatchRequest(request)
}
handle = started
ObjHttpServerHandle(started)
}
private suspend fun dispatchRequest(request: HttpRequest): HttpHandlerResult {
val path = request.head.path
if (request.head.wantsWebSocketUpgrade) {
wsRoutes[path]?.let { route ->
return HttpHandlerResult.WebSocket { session ->
val exchange = ObjServerExchange(request)
route.call(ObjServerWebSocket(session), exchange)
}
}
}
val route = methodRoutes[request.head.method.uppercase()]?.get(path)
?: anyRoutes[path]
?: fallback
if (route == null) {
return HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray()))
}
val exchange = ObjServerExchange(request)
route.call(exchange)
return when (val result = exchange.result) {
is ExchangeResult.Http -> result.value
is ExchangeResult.WebSocket -> result.value
ExchangeResult.Unhandled -> {
if (route === fallback) {
HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray()))
} else {
HttpHandlerResult.Response(HttpResponse(status = 500, body = "route handler did not handle exchange".encodeToByteArray(), close = true))
}
}
}
}
}
private class ObjHttpServerHandle(
private val handle: net.sergeych.lyngio.http.server.HttpServer,
) : Obj() {
override val objClass: ObjClass
get() = type
companion object {
val type = object : ObjClass("HttpServerHandle") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("HttpServerHandle cannot be created directly")
}
}.apply {
addFn("localPort") {
ObjInt(thisAs<ObjHttpServerHandle>().handle.localAddress().port.toLong())
}
addFn("close") {
requireNoArgs()
thisAs<ObjHttpServerHandle>().handle.close()
ObjVoid
}
}
}
}
private class ObjServerRequest(
private val request: HttpRequest,
) : Obj() {
override val objClass: ObjClass
get() = type
companion object {
val type = object : ObjClass("ServerRequest") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("ServerRequest cannot be created directly")
}
}.apply {
bridgeProperty(this, "method", stringType) {
ObjString(thisAs<ObjServerRequest>().request.head.method)
}
bridgeProperty(this, "target", stringType) {
ObjString(thisAs<ObjServerRequest>().request.head.target)
}
bridgeProperty(this, "path", stringType) {
ObjString(thisAs<ObjServerRequest>().request.head.path)
}
bridgeProperty(this, "query", nullableStringType) {
thisAs<ObjServerRequest>().request.head.query?.let(::ObjString) ?: ObjNull
}
bridgeProperty(this, "headers", httpHeadersType) {
requestHeadersObj(thisAs<ObjServerRequest>().request.head.headers)
}
bridgeProperty(this, "body", bufferType) {
ObjBuffer(thisAs<ObjServerRequest>().request.body.toUByteArray())
}
bridgeFn(this, "text", fnType(stringType)) {
ObjString(thisAs<ObjServerRequest>().request.body.decodeToString())
}
bridgeFn(this, "isWebSocketUpgrade", fnType(boolType)) {
ObjBool(thisAs<ObjServerRequest>().request.head.wantsWebSocketUpgrade)
}
}
}
}
private sealed interface ExchangeResult {
data object Unhandled : ExchangeResult
data class Http(val value: HttpHandlerResult.Response) : ExchangeResult
data class WebSocket(val value: HttpHandlerResult.WebSocket) : ExchangeResult
}
private class ObjServerExchange(
private val request: HttpRequest,
) : Obj() {
private val responseHeaders = linkedMapOf<String, MutableList<String>>()
var result: ExchangeResult = ExchangeResult.Unhandled
private set
override val objClass: ObjClass
get() = type
companion object {
val type = object : ObjClass("ServerExchange") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("ServerExchange cannot be created directly")
}
}.apply {
bridgeProperty(this, "request", serverRequestType) {
ObjServerRequest(thisAs<ObjServerExchange>().request)
}
bridgeFn(this, "respond", fnType(voidType, intType, nullableBufferType)) {
val self = thisAs<ObjServerExchange>()
val status = args.list.getOrNull(0)?.let { objToInt(this, it, "status") } ?: 200
val body = args.list.getOrNull(1)?.let { objBufferOrNull(this, it, "body") }
self.setHttpResponse(status, body?.byteArray?.toByteArray() ?: ByteArray(0))
ObjVoid
}
bridgeFn(this, "respondText", fnType(voidType, intType, stringType)) {
val self = thisAs<ObjServerExchange>()
val status = args.list.getOrNull(0)?.let { objToInt(this, it, "status") } ?: 200
val bodyText = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "bodyText") } ?: ""
self.setHttpResponse(status, bodyText.encodeToByteArray())
ObjVoid
}
bridgeFn(this, "setHeader", fnType(voidType, stringType, stringType)) {
val self = thisAs<ObjServerExchange>()
val name = requiredArg<ObjString>(0).value
val value = requiredArg<ObjString>(1).value
self.ensureMutable(this)
self.responseHeaders[name] = mutableListOf(value)
ObjVoid
}
bridgeFn(this, "addHeader", fnType(voidType, stringType, stringType)) {
val self = thisAs<ObjServerExchange>()
val name = requiredArg<ObjString>(0).value
val value = requiredArg<ObjString>(1).value
self.ensureMutable(this)
self.responseHeaders.getOrPut(name) { mutableListOf() }.add(value)
ObjVoid
}
bridgeFn(
this,
"acceptWebSocket",
fnType(voidType, fnType(nullableAnyType, serverWebSocketType, serverExchangeType))
) {
val self = thisAs<ObjServerExchange>()
val registered = captureCallable(requireScope(), args.list[0])
self.ensureMutable(this)
self.result = ExchangeResult.WebSocket(
HttpHandlerResult.WebSocket { session ->
registered.call(ObjServerWebSocket(session), self)
}
)
ObjVoid
}
bridgeFn(this, "isHandled", fnType(boolType)) {
ObjBool(thisAs<ObjServerExchange>().result !== ExchangeResult.Unhandled)
}
}
}
private fun ensureMutable(scope: ScopeFacade) {
if (result !== ExchangeResult.Unhandled) {
scope.raiseIllegalState("exchange has already been handled")
}
}
private fun setHttpResponse(status: Int, body: ByteArray) {
result = ExchangeResult.Http(
HttpHandlerResult.Response(
HttpResponse(
status = status,
reason = defaultReason(status),
headers = responseHeaders.entries.flatMap { (name, values) -> values.map { HttpHeader(name, it) } },
body = body,
)
)
)
}
}
private class ObjServerWebSocket(
private val session: HttpWebSocketSession,
) : Obj() {
override val objClass: ObjClass
get() = type
companion object {
val type = object : ObjClass("ServerWebSocket") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("ServerWebSocket cannot be created directly")
}
}.apply {
bridgeFn(this, "isOpen", fnType(boolType)) {
ObjBool(thisAs<ObjServerWebSocket>().session.isOpen())
}
bridgeFn(this, "sendText", fnType(voidType, stringType)) {
thisAs<ObjServerWebSocket>().session.sendText(requiredArg<ObjString>(0).value)
ObjVoid
}
bridgeFn(this, "sendBytes", fnType(voidType, bufferType)) {
thisAs<ObjServerWebSocket>().session.sendBytes(requiredArg<ObjBuffer>(0).byteArray.toByteArray())
ObjVoid
}
bridgeFn(this, "receive", fnType(nullableServerWsMessageType)) {
thisAs<ObjServerWebSocket>().session.receive()?.let(ObjWsMessage::from) ?: ObjNull
}
bridgeFn(this, "close", fnType(voidType, intType, stringType)) {
val code = args.list.getOrNull(0)?.let { objToInt(this, it, "code") } ?: 1000
val reason = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "reason") } ?: ""
thisAs<ObjServerWebSocket>().session.close(code, reason)
ObjVoid
}
}
}
}
private fun requestHeadersObj(headers: net.sergeych.lyngio.http.server.HttpHeaders): ObjHttpHeaders {
val all = headers.entries().groupBy(HttpHeader::name, HttpHeader::value)
val single = all.mapValues { (_, values) -> values.first() }
return ObjHttpHeaders.fromHeaders(single, all)
}
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 objBufferOrNull(scope: ScopeFacade, value: Obj, name: String): ObjBuffer? = when (value) {
ObjNull -> null
is ObjBuffer -> value
else -> scope.raiseClassCastError("$name must be Buffer or null")
}

View File

@ -47,8 +47,10 @@ import net.sergeych.lyngio.net.security.NetAccessDeniedException
import net.sergeych.lyngio.net.security.NetAccessOp import net.sergeych.lyngio.net.security.NetAccessOp
import net.sergeych.lyngio.net.security.NetAccessPolicy import net.sergeych.lyngio.net.security.NetAccessPolicy
import net.sergeych.lyngio.stdlib_included.netLyng import net.sergeych.lyngio.stdlib_included.netLyng
import net.sergeych.lyngio.stdlib_included.net_typesLyng
private const val NET_MODULE_NAME = "lyng.io.net" private const val NET_MODULE_NAME = "lyng.io.net"
internal const val NET_TYPES_MODULE_NAME = "lyng.io.net.types"
fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean = fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean =
createNetModule(policy, scope.importManager) createNetModule(policy, scope.importManager)
@ -56,6 +58,7 @@ fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean =
fun createNet(policy: NetAccessPolicy, scope: Scope): Boolean = createNetModule(policy, scope) fun createNet(policy: NetAccessPolicy, scope: Scope): Boolean = createNetModule(policy, scope)
fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean { fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
createNetTypesModule(manager)
if (manager.packageNames.contains(NET_MODULE_NAME)) return false if (manager.packageNames.contains(NET_MODULE_NAME)) return false
manager.addPackage(NET_MODULE_NAME) { module -> manager.addPackage(NET_MODULE_NAME) { module ->
buildNetModule(module, policy) buildNetModule(module, policy)
@ -65,6 +68,21 @@ fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
fun createNet(policy: NetAccessPolicy, manager: ImportManager): Boolean = createNetModule(policy, manager) fun createNet(policy: NetAccessPolicy, manager: ImportManager): Boolean = createNetModule(policy, manager)
internal fun createNetTypesModule(manager: ImportManager): Boolean {
if (manager.packageNames.contains(NET_TYPES_MODULE_NAME)) return false
manager.addPackage(NET_TYPES_MODULE_NAME) { module ->
buildNetTypesModule(module)
}
return true
}
private suspend fun buildNetTypesModule(module: ModuleScope) {
module.eval(Source(NET_TYPES_MODULE_NAME, net_typesLyng))
val enumValues = NetEnumValues.load(module)
module.addConst("SocketAddress", ObjSocketAddress.type(enumValues))
module.addConst("Datagram", ObjDatagram.type(enumValues))
}
private suspend fun buildNetModule(module: ModuleScope, policy: NetAccessPolicy) { private suspend fun buildNetModule(module: ModuleScope, policy: NetAccessPolicy) {
module.eval(Source(NET_MODULE_NAME, netLyng)) module.eval(Source(NET_MODULE_NAME, netLyng))
val engine = getSystemNetEngine() val engine = getSystemNetEngine()
@ -164,10 +182,12 @@ private class ObjSocketAddress(
override suspend fun defaultToString(scope: Scope): ObjString = ObjString(renderAddress(address)) override suspend fun defaultToString(scope: Scope): ObjString = ObjString(renderAddress(address))
companion object { companion object {
private val types = mutableMapOf<NetEnumValues, ObjClass>() private data class EnumKey(val ipv4: Obj, val ipv6: Obj)
private val types = mutableMapOf<EnumKey, ObjClass>()
fun type(enumValues: NetEnumValues): ObjClass = fun type(enumValues: NetEnumValues): ObjClass =
types.getOrPut(enumValues) { types.getOrPut(EnumKey(enumValues.ipv4, enumValues.ipv6)) {
object : ObjClass("SocketAddress") { object : ObjClass("SocketAddress") {
override suspend fun callOn(scope: Scope): Obj { override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("SocketAddress cannot be created directly") scope.raiseError("SocketAddress cannot be created directly")
@ -191,10 +211,12 @@ private class ObjDatagram(
get() = type(enumValues) get() = type(enumValues)
companion object { companion object {
private val types = mutableMapOf<NetEnumValues, ObjClass>() private data class EnumKey(val ipv4: Obj, val ipv6: Obj)
private val types = mutableMapOf<EnumKey, ObjClass>()
fun type(enumValues: NetEnumValues): ObjClass = fun type(enumValues: NetEnumValues): ObjClass =
types.getOrPut(enumValues) { types.getOrPut(EnumKey(enumValues.ipv4, enumValues.ipv6)) {
object : ObjClass("Datagram") { object : ObjClass("Datagram") {
override suspend fun callOn(scope: Scope): Obj { override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("Datagram cannot be created directly") scope.raiseError("Datagram cannot be created directly")

View File

@ -37,6 +37,7 @@ import net.sergeych.lyng.raiseIllegalOperation
import net.sergeych.lyng.requireNoArgs import net.sergeych.lyng.requireNoArgs
import net.sergeych.lyng.requireScope import net.sergeych.lyng.requireScope
import net.sergeych.lyngio.stdlib_included.wsLyng import net.sergeych.lyngio.stdlib_included.wsLyng
import net.sergeych.lyngio.stdlib_included.ws_typesLyng
import net.sergeych.lyngio.ws.LyngWsEngine import net.sergeych.lyngio.ws.LyngWsEngine
import net.sergeych.lyngio.ws.LyngWsMessage import net.sergeych.lyngio.ws.LyngWsMessage
import net.sergeych.lyngio.ws.LyngWsSession import net.sergeych.lyngio.ws.LyngWsSession
@ -46,6 +47,7 @@ import net.sergeych.lyngio.ws.security.WsAccessOp
import net.sergeych.lyngio.ws.security.WsAccessPolicy import net.sergeych.lyngio.ws.security.WsAccessPolicy
private const val WS_MODULE_NAME = "lyng.io.ws" private const val WS_MODULE_NAME = "lyng.io.ws"
internal const val WS_TYPES_MODULE_NAME = "lyng.io.ws.types"
fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean = fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean =
createWsModule(policy, scope.importManager) createWsModule(policy, scope.importManager)
@ -53,6 +55,7 @@ fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean =
fun createWs(policy: WsAccessPolicy, scope: Scope): Boolean = createWsModule(policy, scope) fun createWs(policy: WsAccessPolicy, scope: Scope): Boolean = createWsModule(policy, scope)
fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean { fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
createWsTypesModule(manager)
if (manager.packageNames.contains(WS_MODULE_NAME)) return false if (manager.packageNames.contains(WS_MODULE_NAME)) return false
manager.addPackage(WS_MODULE_NAME) { module -> manager.addPackage(WS_MODULE_NAME) { module ->
buildWsModule(module, policy) buildWsModule(module, policy)
@ -62,6 +65,19 @@ fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
fun createWs(policy: WsAccessPolicy, manager: ImportManager): Boolean = createWsModule(policy, manager) fun createWs(policy: WsAccessPolicy, manager: ImportManager): Boolean = createWsModule(policy, manager)
internal fun createWsTypesModule(manager: ImportManager): Boolean {
if (manager.packageNames.contains(WS_TYPES_MODULE_NAME)) return false
manager.addPackage(WS_TYPES_MODULE_NAME) { module ->
buildWsTypesModule(module)
}
return true
}
private suspend fun buildWsTypesModule(module: ModuleScope) {
module.eval(Source(WS_TYPES_MODULE_NAME, ws_typesLyng))
module.addConst("WsMessage", ObjWsMessage.type)
}
private suspend fun buildWsModule(module: ModuleScope, policy: WsAccessPolicy) { private suspend fun buildWsModule(module: ModuleScope, policy: WsAccessPolicy) {
module.eval(Source(WS_MODULE_NAME, wsLyng)) module.eval(Source(WS_MODULE_NAME, wsLyng))
val engine = getSystemWsEngine() val engine = getSystemWsEngine()
@ -92,7 +108,7 @@ private suspend inline fun ScopeFacade.wsGuard(crossinline block: suspend () ->
} }
} }
private class ObjWsMessage( internal class ObjWsMessage(
private val message: LyngWsMessage, private val message: LyngWsMessage,
) : Obj() { ) : Obj() {
override val objClass: ObjClass override val objClass: ObjClass
@ -112,6 +128,8 @@ private class ObjWsMessage(
thisAs<ObjWsMessage>().message.data?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull thisAs<ObjWsMessage>().message.data?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull
}) })
} }
internal fun from(message: LyngWsMessage): ObjWsMessage = ObjWsMessage(message)
} }
} }
@ -152,7 +170,7 @@ private class ObjWsSession(
addFn("receive") { addFn("receive") {
val self = thisAs<ObjWsSession>() val self = thisAs<ObjWsSession>()
self.policy.require(WsAccessOp.Receive(self.targetUrl)) self.policy.require(WsAccessOp.Receive(self.targetUrl))
self.session.receive()?.let(::ObjWsMessage) ?: ObjNull self.session.receive()?.let(ObjWsMessage::from) ?: ObjNull
} }
addFn("close") { addFn("close") {
val self = thisAs<ObjWsSession>() val self = thisAs<ObjWsSession>()

View File

@ -0,0 +1,50 @@
package net.sergeych.lyngio.http.server
import net.sergeych.lyngio.net.LyngTcpSocket
internal class BufferedSocketReader(
private val socket: LyngTcpSocket,
) {
private var pending = ByteArray(0)
suspend fun readLine(
maxBytes: Int,
overflowStatus: Int,
overflowMessage: String,
): String? {
require(maxBytes > 0) { "maxBytes must be positive" }
val out = ByteArray(maxBytes)
var count = 0
while (true) {
val next = readByte() ?: return if (count == 0) null else out.copyOf(count).decodeToString()
if (next == '\n'.code.toByte()) {
return if (count > 0 && out[count - 1] == '\r'.code.toByte()) {
out.copyOf(count - 1).decodeToString()
} else {
out.copyOf(count).decodeToString()
}
}
if (count >= maxBytes) throw HttpProtocolException(overflowStatus, overflowMessage)
out[count++] = next
}
}
suspend fun readExact(byteCount: Int): ByteArray? {
require(byteCount >= 0) { "byteCount must be non-negative" }
if (byteCount == 0) return ByteArray(0)
while (pending.size < byteCount) {
val chunk = socket.read(maxOf(4096, byteCount - pending.size)) ?: break
if (chunk.isEmpty()) break
pending += chunk
}
if (pending.size < byteCount) return null
val result = pending.copyOfRange(0, byteCount)
pending = pending.copyOfRange(byteCount, pending.size)
return result
}
private suspend fun readByte(): Byte? {
val bytes = readExact(1) ?: return null
return bytes[0]
}
}

View File

@ -0,0 +1,181 @@
package net.sergeych.lyngio.http.server
internal class HttpProtocolException(
val status: Int,
message: String,
) : IllegalStateException(message)
internal suspend fun parseHttpRequest(
reader: BufferedSocketReader,
config: HttpServerConfig,
): HttpRequest? {
val requestLine = reader.readLine(
maxBytes = config.maxRequestLineBytes,
overflowStatus = 414,
overflowMessage = "request line is too long",
) ?: return null
val requestHead = parseRequestLine(requestLine, config)
val headerEntries = parseHeaders(reader, config)
val headers = HttpHeaders(headerEntries)
validateHost(headers)
val contentLength = parseContentLength(headers, config)
validateUnsupportedRequestFeatures(headers)
val wantsWebSocketUpgrade = isWebSocketUpgrade(requestHead.method, headers)
validateWebSocketUpgradeRequest(headers, requestHead.method, contentLength, wantsWebSocketUpgrade)
val body = if (contentLength != null) {
reader.readExact(contentLength)
?: throw HttpProtocolException(400, "unexpected EOF while reading request body")
} else {
ByteArray(0)
}
return HttpRequest(
head = HttpRequestHead(
method = requestHead.method,
target = requestHead.target,
path = requestHead.path,
query = requestHead.query,
version = requestHead.version,
headers = headers,
contentLength = contentLength,
wantsClose = headers.containsToken("Connection", "close"),
wantsWebSocketUpgrade = wantsWebSocketUpgrade,
),
body = body,
)
}
private data class ParsedRequestLine(
val method: String,
val target: String,
val path: String,
val query: String?,
val version: String,
)
private fun parseRequestLine(line: String, config: HttpServerConfig): ParsedRequestLine {
val firstSpace = line.indexOf(' ')
val lastSpace = line.lastIndexOf(' ')
if (firstSpace <= 0 || lastSpace <= firstSpace || lastSpace == line.lastIndex) {
throw HttpProtocolException(400, "malformed request line")
}
val method = line.substring(0, firstSpace)
val target = line.substring(firstSpace + 1, lastSpace)
val version = line.substring(lastSpace + 1)
if (!method.all(::isHttpTokenChar)) {
throw HttpProtocolException(400, "invalid HTTP method")
}
if (version != "HTTP/1.1") {
throw HttpProtocolException(505, "unsupported HTTP version: $version")
}
if (target.length > config.maxRequestLineBytes) {
throw HttpProtocolException(414, "request target is too long")
}
if (!target.startsWith('/')) {
throw HttpProtocolException(400, "only origin-form request targets are supported")
}
val queryAt = target.indexOf('?')
val path = if (queryAt >= 0) target.substring(0, queryAt) else target
val query = if (queryAt >= 0) target.substring(queryAt + 1) else null
return ParsedRequestLine(method = method, target = target, path = path, query = query, version = version)
}
private suspend fun parseHeaders(
reader: BufferedSocketReader,
config: HttpServerConfig,
): List<HttpHeader> {
val headers = ArrayList<HttpHeader>()
var totalBytes = 0
while (true) {
val line = reader.readLine(
maxBytes = config.maxHeaderBytes,
overflowStatus = 431,
overflowMessage = "request headers are too large",
)
?: throw HttpProtocolException(400, "unexpected EOF while reading headers")
totalBytes += line.length + 2
if (totalBytes > config.maxHeaderBytes) {
throw HttpProtocolException(431, "request headers are too large")
}
if (line.isEmpty()) return headers
if (line.firstOrNull() == ' ' || line.firstOrNull() == '\t') {
throw HttpProtocolException(400, "obsolete folded headers are not supported")
}
val colonAt = line.indexOf(':')
if (colonAt <= 0) throw HttpProtocolException(400, "invalid header syntax")
val name = line.substring(0, colonAt)
if (!name.all(::isHttpTokenChar)) {
throw HttpProtocolException(400, "invalid header name: $name")
}
val value = line.substring(colonAt + 1).trim(' ', '\t')
if (value.any { it == '\r' || it == '\n' || it.code < 0x20 && it != '\t' }) {
throw HttpProtocolException(400, "invalid header value")
}
headers += HttpHeader(name, value)
if (headers.size > config.maxHeaderCount) {
throw HttpProtocolException(431, "too many headers")
}
}
}
private fun validateHost(headers: HttpHeaders) {
val values = headers.all("Host").map(String::trim)
if (values.isEmpty()) throw HttpProtocolException(400, "Host header is required")
if (values.distinct().size > 1) throw HttpProtocolException(400, "conflicting Host header values")
}
private fun parseContentLength(headers: HttpHeaders, config: HttpServerConfig): Int? {
val values = headers.all("Content-Length")
if (values.isEmpty()) return null
val normalized = values.flatMap { raw -> raw.split(',').map(String::trim) }
if (normalized.any { it.isEmpty() }) throw HttpProtocolException(400, "invalid Content-Length")
val distinct = normalized.distinct()
if (distinct.size > 1) throw HttpProtocolException(400, "conflicting Content-Length values")
val parsed = distinct.single().toLongOrNull() ?: throw HttpProtocolException(400, "invalid Content-Length")
if (parsed < 0L || parsed > Int.MAX_VALUE.toLong()) throw HttpProtocolException(400, "invalid Content-Length")
if (parsed > config.maxBodyBytes.toLong()) throw HttpProtocolException(413, "request body is too large")
return parsed.toInt()
}
private fun validateUnsupportedRequestFeatures(headers: HttpHeaders) {
if (headers.all("Transfer-Encoding").isNotEmpty()) {
throw HttpProtocolException(501, "Transfer-Encoding is not supported")
}
if (headers.first("Expect")?.equals("100-continue", ignoreCase = true) == true) {
throw HttpProtocolException(501, "Expect: 100-continue is not supported")
}
val upgrade = headers.first("Upgrade")
if (upgrade != null && !upgrade.equals("websocket", ignoreCase = true)) {
throw HttpProtocolException(501, "unsupported Upgrade value")
}
}
private fun isWebSocketUpgrade(method: String, headers: HttpHeaders): Boolean =
method.equals("GET", ignoreCase = true) &&
headers.first("Upgrade")?.equals("websocket", ignoreCase = true) == true &&
headers.containsToken("Connection", "upgrade")
private fun validateWebSocketUpgradeRequest(
headers: HttpHeaders,
method: String,
contentLength: Int?,
wantsWebSocketUpgrade: Boolean,
) {
if (!wantsWebSocketUpgrade) return
if (!method.equals("GET", ignoreCase = true)) {
throw HttpProtocolException(400, "websocket upgrade requires GET")
}
if (contentLength != null && contentLength != 0) {
throw HttpProtocolException(400, "websocket upgrade request must not include a body")
}
if (headers.first("Sec-WebSocket-Key").isNullOrBlank()) {
throw HttpProtocolException(400, "missing Sec-WebSocket-Key")
}
if (headers.first("Sec-WebSocket-Version") != "13") {
throw HttpProtocolException(400, "unsupported Sec-WebSocket-Version")
}
}
private fun isHttpTokenChar(ch: Char): Boolean =
ch in '0'..'9' || ch in 'A'..'Z' || ch in 'a'..'z' || ch in setOf(
'!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~'
)

View File

@ -0,0 +1,101 @@
package net.sergeych.lyngio.http.server
import net.sergeych.lyngio.net.LyngSocketAddress
import net.sergeych.lyngio.ws.LyngWsMessage
internal data class HttpServerConfig(
val host: String? = "127.0.0.1",
val port: Int = 0,
val backlog: Int = 128,
val reuseAddress: Boolean = true,
val maxRequestLineBytes: Int = 8 * 1024,
val maxHeaderBytes: Int = 32 * 1024,
val maxHeaderCount: Int = 100,
val maxBodyBytes: Int = 1024 * 1024,
val keepAliveTimeoutMillis: Long = 15_000,
)
internal data class HttpHeader(
val name: String,
val value: String,
)
internal class HttpHeaders(
private val headerEntries: List<HttpHeader>,
) {
fun first(name: String): String? =
headerEntries.firstOrNull { it.name.equals(name, ignoreCase = true) }?.value
fun all(name: String): List<String> =
headerEntries.filter { it.name.equals(name, ignoreCase = true) }.map(HttpHeader::value)
fun containsToken(name: String, token: String): Boolean =
all(name).flatMap { value -> value.split(',') }
.any { it.trim().equals(token, ignoreCase = true) }
fun entries(): List<HttpHeader> = headerEntries
}
internal data class HttpRequestHead(
val method: String,
val target: String,
val path: String,
val query: String?,
val version: String,
val headers: HttpHeaders,
val contentLength: Int?,
val wantsClose: Boolean,
val wantsWebSocketUpgrade: Boolean,
)
internal data class HttpRequest(
val head: HttpRequestHead,
val body: ByteArray,
)
internal data class HttpResponse(
val status: Int,
val reason: String = defaultReason(status),
val headers: List<HttpHeader> = emptyList(),
val body: ByteArray = ByteArray(0),
val close: Boolean = false,
)
internal interface HttpWebSocketSession {
fun isOpen(): Boolean
suspend fun sendText(text: String)
suspend fun sendBytes(data: ByteArray)
suspend fun receive(): LyngWsMessage?
suspend fun close(code: Int = 1000, reason: String = "")
}
internal sealed interface HttpHandlerResult {
data class Response(val response: HttpResponse) : HttpHandlerResult
data class WebSocket(val handler: suspend (HttpWebSocketSession) -> Unit) : HttpHandlerResult
}
internal fun interface HttpHandler {
suspend fun handle(request: HttpRequest): HttpHandlerResult
}
internal interface HttpServer {
fun isOpen(): Boolean
fun localAddress(): LyngSocketAddress
fun close()
}
internal fun defaultReason(status: Int): String = when (status) {
101 -> "Switching Protocols"
200 -> "OK"
204 -> "No Content"
400 -> "Bad Request"
404 -> "Not Found"
413 -> "Payload Too Large"
414 -> "URI Too Long"
426 -> "Upgrade Required"
431 -> "Request Header Fields Too Large"
500 -> "Internal Server Error"
501 -> "Not Implemented"
505 -> "HTTP Version Not Supported"
else -> "HTTP $status"
}

View File

@ -0,0 +1,149 @@
package net.sergeych.lyngio.http.server
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
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.getSystemNetEngine
internal fun startHttpServer(
config: HttpServerConfig = HttpServerConfig(),
netEngine: LyngNetEngine = getSystemNetEngine(),
handler: HttpHandler,
): HttpServer {
if (!netEngine.isSupported || !netEngine.isTcpServerAvailable) {
throw UnsupportedOperationException("HTTP server is not supported on this runtime")
}
return StartedHttpServer(config, netEngine, handler)
}
private class StartedHttpServer(
private val config: HttpServerConfig,
private val netEngine: LyngNetEngine,
private val handler: HttpHandler,
) : HttpServer {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var serverRef: LyngTcpServer? = null
private var open = true
init {
scope.launch {
val server = netEngine.tcpListen(
host = config.host,
port = config.port,
backlog = config.backlog,
reuseAddress = config.reuseAddress,
)
serverRef = server
acceptLoop(server)
}
}
override fun isOpen(): Boolean = open && (serverRef?.isOpen() ?: true)
override fun localAddress(): LyngSocketAddress =
serverRef?.localAddress() ?: throw IllegalStateException("server is not bound yet")
override fun close() {
if (!open) return
open = false
serverRef?.close()
scope.cancel()
}
private suspend fun acceptLoop(server: LyngTcpServer) {
try {
while (open && server.isOpen()) {
val socket = try {
server.accept()
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
if (!open || !server.isOpen()) break
continue
}
scope.launch {
handleConnection(socket)
}
}
} finally {
open = false
server.close()
}
}
private suspend fun handleConnection(socket: LyngTcpSocket) {
val reader = BufferedSocketReader(socket)
try {
while (socket.isOpen()) {
val request = try {
withTimeout(config.keepAliveTimeoutMillis) {
parseHttpRequest(reader, config)
}
} catch (_: CancellationException) {
throw CancellationException()
} catch (e: HttpProtocolException) {
safeWriteError(socket, e.status, e.message ?: defaultReason(e.status))
break
} catch (_: Throwable) {
safeWriteError(socket, 400, defaultReason(400))
break
} ?: break
val result = try {
handler.handle(request)
} catch (_: CancellationException) {
throw CancellationException()
} catch (_: Throwable) {
HttpHandlerResult.Response(HttpResponse(status = 500, close = true))
}
when (result) {
is HttpHandlerResult.Response -> {
val close = request.head.wantsClose || result.response.close
writeHttpResponse(socket, result.response, closeConnection = close)
if (close) break
}
is HttpHandlerResult.WebSocket -> {
if (!request.head.wantsWebSocketUpgrade) {
writeHttpResponse(
socket,
HttpResponse(status = 400, close = true, body = "WebSocket upgrade required".encodeToByteArray()),
closeConnection = true,
)
break
}
val session = upgradeToWebSocket(socket, request)
try {
result.handler(session)
} finally {
session.close()
}
break
}
}
}
} catch (_: CancellationException) {
} finally {
socket.close()
}
}
private suspend fun safeWriteError(socket: LyngTcpSocket, status: Int, message: String) {
try {
writeHttpResponse(
socket,
HttpResponse(status = status, body = message.encodeToByteArray(), close = true),
closeConnection = true,
)
} catch (_: Throwable) {
}
}
}

View File

@ -0,0 +1,44 @@
package net.sergeych.lyngio.http.server
import net.sergeych.lyngio.net.LyngTcpSocket
internal suspend fun writeHttpResponse(
socket: LyngTcpSocket,
response: HttpResponse,
closeConnection: Boolean,
) {
val body = response.body
val headerLines = LinkedHashMap<String, MutableList<String>>()
response.headers.forEach { header ->
headerLines.getOrPut(header.name) { mutableListOf() }.add(header.value)
}
if (headerLines.keys.none { it.equals("Content-Length", ignoreCase = true) }) {
headerLines["Content-Length"] = mutableListOf(body.size.toString())
}
if (closeConnection) {
val connectionKey = headerLines.keys.firstOrNull { it.equals("Connection", ignoreCase = true) }
if (connectionKey != null) {
headerLines.remove(connectionKey)
}
headerLines["Connection"] = mutableListOf("close")
}
val head = buildString {
append("HTTP/1.1 ")
append(response.status)
append(' ')
append(response.reason)
append("\r\n")
headerLines.forEach { (name, values) ->
values.forEach { value ->
append(name)
append(": ")
append(value)
append("\r\n")
}
}
append("\r\n")
}
socket.writeUtf8(head)
if (body.isNotEmpty()) socket.write(body)
socket.flush()
}

View File

@ -0,0 +1,295 @@
package net.sergeych.lyngio.http.server
import net.sergeych.lyngio.net.LyngTcpSocket
import net.sergeych.lyngio.ws.LyngWsMessage
import net.sergeych.mp_tools.encodeToBase64
internal suspend fun upgradeToWebSocket(
socket: LyngTcpSocket,
request: HttpRequest,
): HttpWebSocketSession {
val key = request.head.headers.first("Sec-WebSocket-Key")
?: throw HttpProtocolException(400, "missing Sec-WebSocket-Key")
val response = buildString {
append("HTTP/1.1 101 Switching Protocols\r\n")
append("Upgrade: websocket\r\n")
append("Connection: Upgrade\r\n")
append("Sec-WebSocket-Accept: ")
append(websocketAcceptKey(key))
append("\r\n\r\n")
}
socket.writeUtf8(response)
socket.flush()
return SocketHttpWebSocketSession(socket)
}
private class SocketHttpWebSocketSession(
private val socket: LyngTcpSocket,
) : HttpWebSocketSession {
private val reader = BufferedSocketReader(socket)
private var closed = false
private var fragmentedOpcode: Int? = null
private var fragmentedPayload = ByteArray(0)
private var closeSent = false
override fun isOpen(): Boolean = !closed && socket.isOpen()
override suspend fun sendText(text: String) {
ensureOpen()
sendFrame(OPCODE_TEXT, text.encodeToByteArray())
}
override suspend fun sendBytes(data: ByteArray) {
ensureOpen()
sendFrame(OPCODE_BINARY, data)
}
override suspend fun receive(): LyngWsMessage? {
while (!closed) {
val frame = readFrame() ?: run {
release()
return null
}
when (frame.opcode) {
OPCODE_CONTINUATION -> {
val opcode = fragmentedOpcode ?: throw IllegalStateException("unexpected websocket continuation frame")
fragmentedPayload += frame.payload
if (frame.fin) {
val payload = fragmentedPayload
fragmentedOpcode = null
fragmentedPayload = ByteArray(0)
return payload.toMessage(opcode)
}
}
OPCODE_TEXT, OPCODE_BINARY -> {
if (frame.fin) return frame.payload.toMessage(frame.opcode)
fragmentedOpcode = frame.opcode
fragmentedPayload = frame.payload
}
OPCODE_CLOSE -> {
if (!closeSent) {
sendFrame(OPCODE_CLOSE, frame.payload)
closeSent = true
}
release()
return null
}
OPCODE_PING -> sendFrame(OPCODE_PONG, frame.payload)
OPCODE_PONG -> Unit
else -> Unit
}
}
return null
}
override suspend fun close(code: Int, reason: String) {
if (closed) return
val reasonBytes = reason.encodeToByteArray()
val payload = ByteArray(reasonBytes.size + 2)
payload[0] = (code shr 8).toByte()
payload[1] = code.toByte()
reasonBytes.copyInto(payload, destinationOffset = 2)
try {
if (!closeSent) {
sendFrame(OPCODE_CLOSE, payload)
closeSent = true
}
} finally {
release()
}
}
private suspend fun sendFrame(opcode: Int, payload: ByteArray) {
socket.write(buildFrameHeader(opcode, payload.size, masked = false) + payload)
socket.flush()
}
private suspend fun readFrame(): WsFrame? {
val head = reader.readExact(2) ?: return null
val fin = (head[0].toInt() and 0x80) != 0
val opcode = head[0].toInt() and 0x0f
val masked = (head[1].toInt() and 0x80) != 0
val payloadLength = when (val lengthCode = head[1].toInt() and 0x7f) {
126 -> {
val extended = reader.readExact(2) ?: return null
((extended[0].toInt() and 0xff) shl 8) or (extended[1].toInt() and 0xff)
}
127 -> {
val extended = reader.readExact(8) ?: return null
var acc = 0L
extended.forEach { byte ->
acc = (acc shl 8) or (byte.toInt() and 0xff).toLong()
}
require(acc <= Int.MAX_VALUE.toLong()) { "websocket frame is too large" }
acc.toInt()
}
else -> lengthCode
}
if (!masked) throw IllegalStateException("client websocket frames must be masked")
val mask = reader.readExact(4) ?: return null
val payload = if (payloadLength > 0) reader.readExact(payloadLength) ?: return null else ByteArray(0)
payload.indices.forEach { index ->
payload[index] = (payload[index].toInt() xor mask[index % mask.size].toInt()).toByte()
}
return WsFrame(fin = fin, opcode = opcode, payload = payload)
}
private fun ensureOpen() {
if (!isOpen()) throw IllegalStateException("websocket session is closed")
}
private fun release() {
if (closed) return
closed = true
socket.close()
}
}
private data class WsFrame(
val fin: Boolean,
val opcode: Int,
val payload: ByteArray,
)
private fun ByteArray.toMessage(opcode: Int): LyngWsMessage = when (opcode) {
OPCODE_TEXT -> LyngWsMessage(isText = true, text = decodeToString())
OPCODE_BINARY -> LyngWsMessage(isText = false, data = copyOf())
else -> throw IllegalStateException("unsupported websocket opcode: $opcode")
}
private fun websocketAcceptKey(key: String): String =
sha1((key + WS_GUID).encodeToByteArray()).encodeToBase64()
private fun buildFrameHeader(opcode: Int, payloadSize: Int, masked: Boolean): ByteArray {
require(payloadSize >= 0) { "payload size must be non-negative" }
val firstByte = (0x80 or (opcode and 0x0f)).toByte()
val maskBit = if (masked) 0x80 else 0
return when {
payloadSize <= 125 -> byteArrayOf(firstByte, (maskBit or payloadSize).toByte())
payloadSize <= 0xffff -> byteArrayOf(
firstByte,
(maskBit or 126).toByte(),
((payloadSize ushr 8) and 0xff).toByte(),
(payloadSize and 0xff).toByte(),
)
else -> byteArrayOf(
firstByte,
(maskBit or 127).toByte(),
0,
0,
0,
0,
((payloadSize ushr 24) and 0xff).toByte(),
((payloadSize ushr 16) and 0xff).toByte(),
((payloadSize ushr 8) and 0xff).toByte(),
(payloadSize and 0xff).toByte(),
)
}
}
private fun sha1(input: ByteArray): ByteArray {
var h0 = 0x67452301
var h1 = 0xEFCDAB89.toInt()
var h2 = 0x98BADCFE.toInt()
var h3 = 0x10325476
var h4 = 0xC3D2E1F0.toInt()
val msgLen = input.size
val bitLen = msgLen.toLong() * 8L
val totalLen = ((msgLen + 1 + 8 + 63) / 64) * 64
val padded = ByteArray(totalLen).also { buf ->
input.copyInto(buf)
buf[msgLen] = 0x80.toByte()
for (i in 0..7) {
buf[totalLen - 8 + i] = ((bitLen ushr (56 - i * 8)) and 0xff).toByte()
}
}
val words = IntArray(80)
var blockStart = 0
while (blockStart < padded.size) {
for (i in 0..15) {
val off = blockStart + i * 4
words[i] = ((padded[off].toInt() and 0xff) shl 24) or
((padded[off + 1].toInt() and 0xff) shl 16) or
((padded[off + 2].toInt() and 0xff) shl 8) or
(padded[off + 3].toInt() and 0xff)
}
for (i in 16..79) {
val mixed = words[i - 3] xor words[i - 8] xor words[i - 14] xor words[i - 16]
words[i] = (mixed shl 1) or (mixed ushr 31)
}
var a = h0
var b = h1
var c = h2
var d = h3
var e = h4
for (i in 0..19) {
val f = (b and c) or (b.inv() and d)
val temp = ((a shl 5) or (a ushr 27)) + f + e + 0x5A827999 + words[i]
e = d
d = c
c = (b shl 30) or (b ushr 2)
b = a
a = temp
}
for (i in 20..39) {
val f = b xor c xor d
val temp = ((a shl 5) or (a ushr 27)) + f + e + 0x6ED9EBA1 + words[i]
e = d
d = c
c = (b shl 30) or (b ushr 2)
b = a
a = temp
}
for (i in 40..59) {
val f = (b and c) or (b and d) or (c and d)
val temp = ((a shl 5) or (a ushr 27)) + f + e + 0x8F1BBCDC.toInt() + words[i]
e = d
d = c
c = (b shl 30) or (b ushr 2)
b = a
a = temp
}
for (i in 60..79) {
val f = b xor c xor d
val temp = ((a shl 5) or (a ushr 27)) + f + e + 0xCA62C1D6.toInt() + words[i]
e = d
d = c
c = (b shl 30) or (b ushr 2)
b = a
a = temp
}
h0 += a
h1 += b
h2 += c
h3 += d
h4 += e
blockStart += 64
}
return ByteArray(20).also { out ->
fun putInt(offset: Int, value: Int) {
out[offset] = (value ushr 24).toByte()
out[offset + 1] = (value ushr 16).toByte()
out[offset + 2] = (value ushr 8).toByte()
out[offset + 3] = value.toByte()
}
putInt(0, h0)
putInt(4, h1)
putInt(8, h2)
putInt(12, h3)
putInt(16, h4)
}
}
private const val WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
private const val OPCODE_CONTINUATION = 0x0
private const val OPCODE_TEXT = 0x1
private const val OPCODE_BINARY = 0x2
private const val OPCODE_CLOSE = 0x8
private const val OPCODE_PING = 0x9
private const val OPCODE_PONG = 0xA

View File

@ -0,0 +1,122 @@
package net.sergeych.lyngio.http.server
import net.sergeych.lyngio.net.LyngIpVersion
import net.sergeych.lyngio.net.LyngSocketAddress
import net.sergeych.lyngio.net.LyngTcpSocket
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class HttpParserTest {
@Test
fun tooLargeHeadersMapTo431() = kotlinx.coroutines.test.runTest {
val request = buildString {
append("GET / HTTP/1.1\r\n")
append("Host: localhost\r\n")
append("X-Big: ")
append("a".repeat(64))
append("\r\n\r\n")
}
val error = assertFailsWith<HttpProtocolException> {
parse(request, HttpServerConfig(maxHeaderBytes = 32))
}
assertEquals(431, error.status)
}
@Test
fun conflictingDuplicateHostIsRejected() = kotlinx.coroutines.test.runTest {
val error = assertFailsWith<HttpProtocolException> {
parse(
"GET / HTTP/1.1\r\n" +
"Host: one.example\r\n" +
"Host: two.example\r\n\r\n"
)
}
assertEquals(400, error.status)
}
@Test
fun conflictingDuplicateContentLengthIsRejected() = kotlinx.coroutines.test.runTest {
val error = assertFailsWith<HttpProtocolException> {
parse(
"POST /echo HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Content-Length: 4\r\n" +
"Content-Length: 5\r\n\r\nping!"
)
}
assertEquals(400, error.status)
}
@Test
fun malformedRequestLineIsRejected() = kotlinx.coroutines.test.runTest {
val error = assertFailsWith<HttpProtocolException> {
parse("GET /only-two-parts\r\nHost: localhost\r\n\r\n")
}
assertEquals(400, error.status)
}
@Test
fun identicalDuplicateContentLengthIsAccepted() = kotlinx.coroutines.test.runTest {
val request = parse(
"POST /echo HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Content-Length: 4\r\n" +
"Content-Length: 4\r\n\r\nping"
)
assertEquals("POST", request.head.method)
assertEquals("/echo", request.head.path)
assertEquals(4, request.head.contentLength)
assertEquals("ping", request.body.decodeToString())
}
private suspend fun parse(
rawRequest: String,
config: HttpServerConfig = HttpServerConfig(),
): HttpRequest {
val socket = FakeTcpSocket(rawRequest.encodeToByteArray())
val reader = BufferedSocketReader(socket)
return parseHttpRequest(reader, config) ?: error("expected parsed request")
}
}
private class FakeTcpSocket(
source: ByteArray,
) : LyngTcpSocket {
private var input = source
private var output = ByteArray(0)
private var open = true
override fun isOpen(): Boolean = open
override fun localAddress(): LyngSocketAddress =
LyngSocketAddress("127.0.0.1", 8080, LyngIpVersion.IPV4, resolved = true)
override fun remoteAddress(): LyngSocketAddress =
LyngSocketAddress("127.0.0.1", 12345, LyngIpVersion.IPV4, resolved = true)
override suspend fun read(maxBytes: Int): ByteArray? {
if (!open || input.isEmpty()) return null
val count = minOf(maxBytes, input.size)
val chunk = input.copyOfRange(0, count)
input = input.copyOfRange(count, input.size)
return chunk
}
override suspend fun readLine(): String? = error("BufferedSocketReader should not call LyngTcpSocket.readLine()")
override suspend fun write(data: ByteArray) {
output += data
}
override suspend fun writeUtf8(text: String) {
output += text.encodeToByteArray()
}
override suspend fun flush() = Unit
override fun close() {
open = false
}
}

View File

@ -0,0 +1,242 @@
package net.sergeych.lyngio.http.server
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import net.sergeych.lyngio.net.LyngTcpSocket
import net.sergeych.lyngio.net.getSystemNetEngine
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class HttpServerLoopbackTest {
@Test
fun simpleGetReturnsResponse() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
withTimeout(10_000) {
val server = startHttpServer { request ->
HttpHandlerResult.Response(
HttpResponse(
status = 200,
headers = listOf(HttpHeader("Content-Type", "text/plain")),
body = "hello:${request.head.path}".encodeToByteArray(),
)
)
}
try {
val port = waitForPort(server)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8("GET /demo HTTP/1.1\r\nHost: localhost\r\n\r\n")
client.flush()
val text = readHttpResponse(client)
assertTrue(text.startsWith("HTTP/1.1 200 OK\r\n"), text)
assertTrue(text.contains("Content-Type: text/plain\r\n"), text)
assertTrue(text.endsWith("hello:/demo"), text)
} finally {
client.close()
}
} finally {
server.close()
}
}
}
@Test
fun keepAliveServesTwoRequestsOnOneSocket() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
withTimeout(10_000) {
val server = startHttpServer { request ->
HttpHandlerResult.Response(
HttpResponse(status = 200, body = request.head.path.encodeToByteArray())
)
}
try {
val port = waitForPort(server)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8(
"GET /one HTTP/1.1\r\nHost: localhost\r\n\r\n" +
"GET /two HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
)
client.flush()
val first = readHttpResponse(client)
val second = readHttpResponse(client)
assertTrue(first.endsWith("/one"), first)
assertTrue(second.contains("Connection: close\r\n"), second)
assertTrue(second.endsWith("/two"), second)
} finally {
client.close()
}
} finally {
server.close()
}
}
}
@Test
fun postWithContentLengthReadsBody() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
withTimeout(10_000) {
val server = startHttpServer { request ->
HttpHandlerResult.Response(
HttpResponse(status = 200, body = (request.head.method + ":" + request.body.decodeToString()).encodeToByteArray())
)
}
try {
val port = waitForPort(server)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8(
"POST /echo HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\nConnection: close\r\n\r\nping"
)
client.flush()
val text = readHttpResponse(client)
assertTrue(text.endsWith("POST:ping"), text)
} finally {
client.close()
}
} finally {
server.close()
}
}
}
@Test
fun transferEncodingIsRejected() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
withTimeout(10_000) {
val server = startHttpServer { _ ->
HttpHandlerResult.Response(HttpResponse(status = 200, body = "ok".encodeToByteArray()))
}
try {
val port = waitForPort(server)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8(
"POST /x HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n"
)
client.flush()
val text = readHttpResponse(client)
assertTrue(text.startsWith("HTTP/1.1 501 Not Implemented\r\n"), text)
} finally {
client.close()
}
} finally {
server.close()
}
}
}
@Test
fun websocketUpgradeEchoesText() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
withTimeout(10_000) {
val server = startHttpServer { request ->
if (request.head.path != "/ws") {
HttpHandlerResult.Response(HttpResponse(status = 404, close = true))
} else {
HttpHandlerResult.WebSocket { session ->
val message = session.receive() ?: return@WebSocket
session.sendText("echo:${message.text}")
}
}
}
try {
val port = waitForPort(server)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
val key = "dGhlIHNhbXBsZSBub25jZQ=="
client.writeUtf8(
"GET /ws HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Key: $key\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n"
)
client.flush()
val headers = ArrayList<String>()
while (true) {
val line = client.readLine() ?: break
if (line.isEmpty()) break
headers += line
}
assertEquals("HTTP/1.1 101 Switching Protocols", headers.first())
sendMaskedTextFrame(client, "ping")
val reply = readServerTextFrame(client)
assertEquals("echo:ping", reply)
} finally {
client.close()
}
} finally {
server.close()
}
}
}
private suspend fun waitForPort(server: HttpServer): Int {
repeat(100) {
runCatching { return server.localAddress().port }
kotlinx.coroutines.delay(10)
}
error("server did not bind in time")
}
private suspend fun readHttpResponse(client: LyngTcpSocket): String {
val statusLine = client.readLine() ?: error("missing status line")
val headers = linkedMapOf<String, String>()
while (true) {
val line = client.readLine() ?: error("unexpected EOF in response headers")
if (line.isEmpty()) break
val colonAt = line.indexOf(':')
if (colonAt > 0) headers[line.substring(0, colonAt)] = line.substring(colonAt + 1).trim()
}
val bodyLength = headers["Content-Length"]?.toIntOrNull() ?: 0
val body = if (bodyLength > 0) readExact(client, bodyLength).decodeToString() else ""
return buildString {
append(statusLine).append("\r\n")
headers.forEach { (name, value) -> append(name).append(": ").append(value).append("\r\n") }
append("\r\n")
append(body)
}
}
private suspend fun sendMaskedTextFrame(client: LyngTcpSocket, text: String) {
val payload = text.encodeToByteArray()
val mask = byteArrayOf(1, 2, 3, 4)
val masked = payload.copyOf()
masked.indices.forEach { index ->
masked[index] = (masked[index].toInt() xor mask[index % mask.size].toInt()).toByte()
}
val frame = byteArrayOf(0x81.toByte(), (0x80 or payload.size).toByte()) + mask + masked
client.write(frame)
client.flush()
}
private suspend fun readServerTextFrame(client: LyngTcpSocket): String {
val head = readExact(client, 2)
val len = head[1].toInt() and 0x7f
val payload = if (len > 0) readExact(client, len) else ByteArray(0)
return payload.decodeToString()
}
private suspend fun readExact(client: LyngTcpSocket, size: Int): ByteArray {
var pending = ByteArray(0)
while (pending.size < size) {
val chunk = client.read(size - pending.size) ?: error("unexpected EOF")
pending += chunk
}
return pending
}
}

View File

@ -0,0 +1,131 @@
package net.sergeych.lyng.io.http.server
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Script
import net.sergeych.lyng.io.http.server.createHttpServerModule
import net.sergeych.lyng.io.ws.createWsModule
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.io.http.createHttpModule
import net.sergeych.lyngio.net.getSystemNetEngine
import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy
import kotlin.test.Test
import kotlin.test.assertSame
import kotlin.test.assertTrue
class LyngHttpServerModuleTest {
@Test
fun serverModuleReusesSharedHttpHeadersRuntimeType() = runBlocking {
val scope = Script.newScope()
createHttpModule(PermitAllHttpAccessPolicy, scope)
createWsModule(PermitAllWsAccessPolicy, scope)
createHttpServerModule(PermitAllNetAccessPolicy, scope)
val httpModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.http")
val wsModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.ws")
val serverModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.http.server")
val sharedTypesModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.http.types")
val sharedWsTypesModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.ws.types")
assertSame(sharedTypesModule.get("HttpHeaders")?.value, httpModule.get("HttpHeaders")?.value)
assertSame(sharedTypesModule.get("HttpHeaders")?.value, serverModule.get("HttpHeaders")?.value)
assertSame(sharedWsTypesModule.get("WsMessage")?.value, wsModule.get("WsMessage")?.value)
assertSame(sharedWsTypesModule.get("WsMessage")?.value, serverModule.get("WsMessage")?.value)
}
@Test
fun exactRouteAndFallbackWork() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable) return@runBlocking
val scope = Script.newScope()
createHttpServerModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.io.http.server
val server = HttpServer()
server.get("/hello") { ex ->
ex.setHeader("Content-Type", "text/plain")
ex.respondText(200, "hello from lyng")
}
server.fallback { ex ->
ex.respondText(404, "miss:" + ex.request.path)
}
server.listen(0, "127.0.0.1")
""".trimIndent()
val handle = Compiler.compile(code).execute(scope)
val port = waitForPort(handle, scope)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8("GET /hello HTTP/1.1\r\nHost: localhost\r\n\r\n")
client.flush()
val hello = readHttpResponse(client)
assertTrue(hello.contains("200 OK"), hello)
assertTrue(hello.endsWith("hello from lyng"), hello)
} finally {
client.close()
}
val client2 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client2.writeUtf8("GET /other HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client2.flush()
val miss = readHttpResponse(client2)
assertTrue(miss.contains("404"), miss)
assertTrue(miss.endsWith("miss:/other"), miss)
} finally {
client2.close()
}
handle.invokeInstanceMethod(scope, "close")
}
private suspend fun waitForPort(handle: Obj, scope: net.sergeych.lyng.Scope): Int {
repeat(100) {
val port = runCatching {
val value = handle.invokeInstanceMethod(scope, "localPort")
(value as ObjInt).value.toInt()
}.getOrNull()
if (port != null && port > 0) return port
delay(10)
}
error("server did not bind in time")
}
private suspend fun readHttpResponse(client: net.sergeych.lyngio.net.LyngTcpSocket): String {
val statusLine = client.readLine() ?: error("missing status line")
val headers = linkedMapOf<String, String>()
while (true) {
val line = client.readLine() ?: error("unexpected EOF in response headers")
if (line.isEmpty()) break
val colonAt = line.indexOf(':')
if (colonAt > 0) headers[line.substring(0, colonAt)] = line.substring(colonAt + 1).trim()
}
val bodyLength = headers["Content-Length"]?.toIntOrNull() ?: 0
val body = if (bodyLength > 0) readExact(client, bodyLength).decodeToString() else ""
return buildString {
append(statusLine).append("\r\n")
headers.forEach { (name, value) -> append(name).append(": ").append(value).append("\r\n") }
append("\r\n")
append(body)
}
}
private suspend fun readExact(client: net.sergeych.lyngio.net.LyngTcpSocket, size: Int): ByteArray {
var pending = ByteArray(0)
while (pending.size < size) {
val chunk = client.read(size - pending.size) ?: error("unexpected EOF")
pending += chunk
}
return pending
}
}

View File

@ -22,6 +22,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.sergeych.lyng.Compiler import net.sergeych.lyng.Compiler
import net.sergeych.lyng.ExecutionError import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Script import net.sergeych.lyng.Script
import net.sergeych.lyngio.fs.security.AccessContext import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision import net.sergeych.lyngio.fs.security.AccessDecision
@ -35,11 +36,25 @@ import java.net.ServerSocket
import java.net.Socket import java.net.Socket
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertSame
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
import kotlin.test.assertTrue import kotlin.test.assertTrue
class LyngNetModuleTest { class LyngNetModuleTest {
@Test
fun testSharedNetTypesModuleExportsCanonicalTypes() = runBlocking {
val scope = Script.newScope()
createNetModule(PermitAllNetAccessPolicy, scope)
val netModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.net")
val typesModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.net.types")
assertSame(typesModule.get("IpVersion")?.value, netModule.get("IpVersion")?.value)
assertSame(typesModule.get("SocketAddress")?.value, netModule.get("SocketAddress")?.value)
assertSame(typesModule.get("Datagram")?.value, netModule.get("Datagram")?.value)
}
@Test @Test
fun testResolveAndCapabilities() = runBlocking { fun testResolveAndCapabilities() = runBlocking {
val scope = Script.newScope() val scope = Script.newScope()

View File

@ -1,17 +1,6 @@
package lyng.io.http package lyng.io.http
/* import lyng.io.http.types
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. */ /* Mutable request descriptor for programmatic HTTP calls. */
extern class HttpRequest { extern class HttpRequest {

View File

@ -0,0 +1,54 @@
package lyng.io.http.server
import lyng.io.http.types
import lyng.io.ws.types
/* Immutable parsed incoming server request. */
extern class ServerRequest {
val method: String
val target: String
val path: String
val query: String?
val headers: HttpHeaders
val body: Buffer
fun text(): String
fun isWebSocketUpgrade(): Bool
}
/* Active server-side WebSocket session. */
extern class ServerWebSocket {
fun isOpen(): Bool
fun sendText(text: String): void
fun sendBytes(data: Buffer): void
fun receive(): WsMessage?
fun close(code: Int = 1000, reason: String = ""): void
}
/* Mutable exchange object for one incoming request. */
extern class ServerExchange {
val request: ServerRequest
fun respond(status: Int = 200, body: Buffer? = null): void
fun respondText(status: Int = 200, bodyText: String = ""): void
fun setHeader(name: String, value: String): void
fun addHeader(name: String, value: String): void
fun acceptWebSocket(handler: (ServerWebSocket, ServerExchange) -> Object?): void
fun isHandled(): Bool
}
/* Running listener handle. */
extern class HttpServerHandle {
fun localPort(): Int
fun close(): void
}
/* Exact-path HTTP/WebSocket server with built-in router. */
extern class HttpServer {
fun get(path: String, handler: (ServerExchange) -> Object?): HttpServer
fun post(path: String, handler: (ServerExchange) -> Object?): HttpServer
fun put(path: String, handler: (ServerExchange) -> Object?): HttpServer
fun delete(path: String, handler: (ServerExchange) -> Object?): HttpServer
fun any(path: String, handler: (ServerExchange) -> Object?): HttpServer
fun ws(path: String, handler: (ServerWebSocket, ServerExchange) -> Object?): HttpServer
fun fallback(handler: (ServerExchange) -> Object?): HttpServer
fun listen(port: Int, host: String? = null, backlog: Int = 128): HttpServerHandle
}

View File

@ -0,0 +1,14 @@
package lyng.io.http.types
/*
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>
}

View File

@ -1,30 +1,6 @@
package lyng.io.net package lyng.io.net
/* Address family for resolved or bound endpoints. */ import lyng.io.net.types
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. */ /* Connected TCP socket. */
extern class TcpSocket { extern class TcpSocket {

View File

@ -0,0 +1,27 @@
package lyng.io.net.types
/* 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
}

View File

@ -1,14 +1,6 @@
package lyng.io.ws package lyng.io.ws
/* Received WebSocket message. */ import lyng.io.ws.types
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. */ /* Active WebSocket client session. */
extern class WsSession { extern class WsSession {

View File

@ -0,0 +1,11 @@
package lyng.io.ws.types
/* 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?
}

View File

@ -2959,7 +2959,11 @@ class Compiler(
Token.Type.LPAREN -> { Token.Type.LPAREN -> {
cc.next() cc.next()
if (shouldTreatAsClassScopeCall(left, next.value)) { if (shouldTreatAsClassScopeCall(left, next.value)) {
val parsed = parseArgs(null, implicitItTypeForMemberLambda(left, next.value)) val parsed = parseArgs(
null,
implicitItTypeForMemberLambda(left, next.value),
FieldRef(left, next.value, isOptional)
)
val args = parsed.first val args = parsed.first
val tailBlock = parsed.second val tailBlock = parsed.second
isCall = true isCall = true
@ -2976,7 +2980,11 @@ class Compiler(
val receiverType = if (next.value == "apply" || next.value == "run") { val receiverType = if (next.value == "apply" || next.value == "run") {
inferReceiverTypeFromRef(left) inferReceiverTypeFromRef(left)
} else null } else null
val parsed = parseArgs(receiverType, implicitItTypeForMemberLambda(left, next.value)) val parsed = parseArgs(
receiverType,
implicitItTypeForMemberLambda(left, next.value),
FieldRef(left, next.value, isOptional)
)
val args = parsed.first val args = parsed.first
val tailBlock = parsed.second val tailBlock = parsed.second
if (left is LocalVarRef && left.name == "scope") { if (left is LocalVarRef && left.name == "scope") {

View File

@ -0,0 +1,600 @@
# Proposal: Minimal HTTP/1.1 + WebSocket Server For `lyngio`
Status: Draft
Date: 2026-04-26
Owner: `lyngio`
## Context
`lyngio` already provides:
- HTTP client support via `lyng.io.http`
- WebSocket client support via `lyng.io.ws`
- raw TCP/UDP transport via `lyng.io.net`
The current transport layer is already multiplatform and exposes a small common Kotlin interface:
- `LyngTcpSocket`
- `LyngTcpServer`
- `LyngNetEngine`
This makes it practical to add a minimal server implementation in pure Kotlin without introducing a second public networking model.
The intended deployment model for this server is:
- behind a frontend proxy such as nginx
- no TLS termination in `lyngio`
- no HTTP/2 in `lyngio` v1
- minimal, strict HTTP/1.1 subset
- classic HTTP/1.1 WebSocket upgrade support
This proposal deliberately does **not** attempt to implement HTTP/2. That work is substantially larger because it requires binary framing, stream multiplexing, HPACK, and flow control. For the intended deployment model, a frontend proxy can provide TLS and public HTTP/2 while `lyngio` speaks HTTP/1.1 on the backend.
## Goals
- Add a minimal HTTP server implementation in pure Kotlin.
- Keep the implementation compatible with Kotlin Multiplatform common code constraints.
- Reuse the existing `lyngio.net` TCP transport layer.
- Support a strict, useful HTTP/1.1 subset.
- Support classic WebSocket upgrade from HTTP/1.1.
- Keep the API and implementation small enough to be auditable and testable.
- Preserve room for later richer server APIs or JVM-specific backends.
## Non-goals
- HTTP/2
- TLS
- ALPN
- proxy protocol support
- request pipelining
- chunked request bodies
- HTTP trailers
- content compression
- multipart/form-data parsing
- range requests
- streaming request bodies in v1
- streaming response bodies in v1
- WebSocket extensions
- WebSocket subprotocol negotiation in v1
- exposing Ktor server APIs or types
## Design principles
### 1. Common-code first
The implementation should live primarily in `commonMain` and depend only on existing common abstractions built on top of `LyngTcpSocket` and `LyngTcpServer`.
### 2. Strict subset over broad tolerance
The server should reject unsupported or ambiguous protocol constructs instead of trying to be maximally permissive.
This reduces complexity, avoids parser edge cases, and makes connection reuse easier to reason about.
### 3. Small surface area
The first version should only implement what is needed for:
- ordinary backend HTTP request/response handling behind a proxy
- WebSocket upgrade and session handling
- persistent HTTP/1.1 connections when message framing is unambiguous
### 4. Frontend proxy assumption
The server is expected to run behind nginx or a similar reverse proxy that can provide:
- TLS termination
- public HTTP/2 if needed
- request filtering and size limiting
- buffering and slow-client protection
- optional compression and edge-specific behavior
## Proposed package
Add a new internal package:
- `net.sergeych.lyngio.http.server`
This proposal defines an internal Kotlin API first. Lyng-facing scripting bindings are explicitly out of scope for the first phase.
## Supported HTTP request subset
### Request line
Accepted format:
- `METHOD SP request-target SP HTTP/1.1`
Rules:
- request line must split into exactly 3 parts
- `METHOD` must be a non-empty HTTP token
- version must be exactly `HTTP/1.1`
- request target must be origin-form only
Accepted request-target examples:
- `/`
- `/hello`
- `/hello/world?x=1&y=2`
Rejected request-target forms:
- absolute-form: `http://example.com/x`
- authority-form
- asterisk-form: `*`
### Methods
The parser should accept any syntactically valid token as a method and expose it as a string.
The handler layer may then decide what to do with it.
This keeps the parser generic and avoids hardcoding a small method list.
### Headers
Rules:
- header section ends at the first empty line
- each header line must have `name:value` form
- header names are case-insensitive for lookup
- original header values are preserved
- repeated headers are preserved as repeated values
- obsolete line folding is rejected
- embedded CR or LF in header values is rejected
### Host header
Rules:
- `Host` is required on every request
- there must be exactly one effective host value after normalization
- duplicate `Host` values are allowed only if they are identical after trimming
- conflicting `Host` values are rejected
### Request bodies
v1 accepted request body framing:
- no body
- body with a valid `Content-Length`
v1 rejected request body framing:
- any `Transfer-Encoding`
- chunked request bodies
- ambiguous or conflicting body framing
### Keep-alive
HTTP/1.1 persistent connections are supported.
Rules:
- keep-alive is the default
- the server closes the connection if the client sends `Connection: close`
- the server may close the connection after any response if it chooses
- the server closes the connection on parse errors or framing errors
- after a successful WebSocket upgrade, the HTTP request loop ends for that socket
### WebSocket upgrade
v1 supports classic HTTP/1.1 upgrade to WebSocket.
Required request properties:
- method is `GET`
- `Upgrade: websocket`
- `Connection` contains token `upgrade`
- `Sec-WebSocket-Key` is present
- `Sec-WebSocket-Version: 13`
v1 behavior:
- no subprotocol negotiation
- no extension negotiation
- no HTTP/2 WebSocket support
- no fallback upgrade modes beyond the standard HTTP/1.1 handshake
## Rejection and error rules
### `400 Bad Request`
Return `400` for:
- malformed request line
- invalid HTTP token in method or header name
- unsupported request-target form
- missing `Host`
- conflicting `Host` values
- invalid header syntax
- obsolete folded headers
- invalid `Content-Length`
- conflicting duplicate `Content-Length`
- invalid WebSocket upgrade request
### `413 Payload Too Large`
Return `413` when request body exceeds configured maximum size.
### `414 URI Too Long`
Return `414` when the request-target exceeds configured limits.
### `431 Request Header Fields Too Large`
Return `431` when:
- total header bytes exceed the configured limit
- header count exceeds the configured limit
- an individual header line exceeds the configured limit if such a per-line limit is introduced
### `501 Not Implemented`
Return `501` for:
- `Transfer-Encoding` in requests
- chunked request bodies
- `Expect: 100-continue`
- unsupported `Upgrade` values
- request features intentionally excluded from v1
### `505 HTTP Version Not Supported`
Return `505` for any HTTP version other than `HTTP/1.1`.
### `500 Internal Server Error`
Return `500` when the request was parsed successfully but the application handler throws or otherwise fails unexpectedly.
## Response model
v1 responses should be fully materialized before writing.
Rules:
- always send a status line
- always send response headers
- prefer sending `Content-Length` on all normal responses
- do not emit chunked responses in v1
- if response framing is ambiguous, close the connection instead of attempting reuse
Connection closing rules:
- include `Connection: close` when the server intends to close after the response
- close after the response if the request asked for `Connection: close`
- close after protocol errors
- after `101 Switching Protocols`, the HTTP server loop yields ownership of the socket to the WebSocket session
## Suggested defaults and limits
Default operational limits:
- maximum request line bytes: `8 KiB`
- maximum total header bytes: `32 KiB`
- maximum header count: `100`
- maximum request body bytes: `1 MiB`
- keep-alive idle timeout: `15_000 ms`
These should be configurable per server instance.
## Internal Kotlin API
The following shape is recommended as the initial internal API.
```kotlin
data class HttpServerConfig(
val host: String? = "127.0.0.1",
val port: Int = 0,
val backlog: Int = 128,
val reuseAddress: Boolean = true,
val maxRequestLineBytes: Int = 8 * 1024,
val maxHeaderBytes: Int = 32 * 1024,
val maxHeaderCount: Int = 100,
val maxBodyBytes: Int = 1 * 1024 * 1024,
val keepAliveTimeoutMillis: Long = 15_000,
)
data class HttpHeader(
val name: String,
val value: String,
)
class HttpHeaders(
private val entries: List<HttpHeader>,
) {
fun first(name: String): String?
fun all(name: String): List<String>
fun containsToken(name: String, token: String): Boolean
fun entries(): List<HttpHeader>
}
data class HttpRequestHead(
val method: String,
val target: String,
val path: String,
val query: String?,
val version: String,
val headers: HttpHeaders,
val contentLength: Int?,
val wantsClose: Boolean,
val wantsWebSocketUpgrade: Boolean,
)
data class HttpRequest(
val head: HttpRequestHead,
val body: ByteArray,
)
data class HttpResponse(
val status: Int,
val reason: String = defaultReason(status),
val headers: List<HttpHeader> = emptyList(),
val body: ByteArray = ByteArray(0),
val close: Boolean = false,
)
interface HttpWebSocketSession {
fun isOpen(): Boolean
suspend fun sendText(text: String)
suspend fun sendBytes(data: ByteArray)
suspend fun receive(): net.sergeych.lyngio.ws.LyngWsMessage?
suspend fun close(code: Int = 1000, reason: String = "")
}
sealed interface HttpHandlerResult {
data class Response(val response: HttpResponse) : HttpHandlerResult
data class WebSocket(val handler: suspend (HttpWebSocketSession) -> Unit) : HttpHandlerResult
}
fun interface HttpHandler {
suspend fun handle(request: HttpRequest): HttpHandlerResult
}
interface HttpServer {
fun isOpen(): Boolean
fun localAddress(): net.sergeych.lyngio.net.LyngSocketAddress
fun close()
}
```
## Implementation architecture
The implementation should be split into a small number of focused components.
### 1. `HttpServer.kt`
Contains:
- public internal interfaces and data classes
- config and response models
- default reason phrase mapping
### 2. `BufferedSocketReader.kt`
A small internal reader built on top of `LyngTcpSocket`.
Responsibilities:
- buffered reads
- line reads with explicit limits
- exact byte reads for request bodies and WebSocket frames
- avoiding fragile mixing of raw `read()` and `readLine()` semantics
This reader should be internal and should not require changes to `LyngTcpSocket` in v1.
### 3. `HttpParser.kt`
Responsibilities:
- request line parsing
- target parsing into `path` and optional query
- header parsing and normalization
- validation of `Host`, `Content-Length`, and connection semantics
- mapping parse failures into typed HTTP errors
### 4. `HttpWriter.kt`
Responsibilities:
- writing status line and headers
- adding `Content-Length` where needed
- setting `Connection: close` when the server intends to close
- writing the response body
- flushing output
### 5. `HttpServerLoop.kt`
Responsibilities:
- accept loop over `LyngTcpServer`
- per-connection request loop
- keep-alive timeout handling
- error-to-response mapping
- handing off upgraded sockets to WebSocket session implementation
### 6. `ServerWebSocket.kt`
Responsibilities:
- validating upgrade request
- computing `Sec-WebSocket-Accept`
- writing `101 Switching Protocols`
- reading and writing WebSocket frames
- close handling
This should reuse the client-side frame and handshake logic already present in spirit, but server-side behavior should stay separate and explicit.
## Connection processing model
Per accepted TCP connection:
1. read request line
2. read headers
3. validate request
4. read request body if `Content-Length` is present
5. call the application handler
6. if handler returns HTTP response, write it and decide whether to continue
7. if handler returns WebSocket upgrade, send `101`, create a WebSocket session, and transfer ownership of the socket
8. continue until close, error, timeout, or upgrade
The server should process one request at a time per connection.
Pipelining is out of scope.
## Detailed parser rules
### Method parsing
- method must be a valid HTTP token
- parser does not enforce a fixed method allowlist
### Target parsing
- target must begin with `/`
- split on the first `?`
- `path` is the portion before `?`
- `query` is the portion after `?`, or `null`
- no URL decoding is required in v1; raw target text may be exposed
### Header parsing
- split each header line on the first `:`
- trim outer spaces and tabs from the value
- reject control characters other than horizontal tab if any are allowed at all
- do case-insensitive matching by normalized header name
- preserve the original values as supplied
### Content-Length rules
- absent means no request body
- one valid decimal value is accepted
- multiple values are accepted only if all normalized values are identical
- negative values are rejected
- values above configured maximum body size are rejected with `413`
### Connection token parsing
- `Connection` is tokenized case-insensitively on commas
- surrounding spaces are ignored
- helper methods should support `containsToken("Connection", "close")`
- helper methods should support `containsToken("Connection", "upgrade")`
## WebSocket v1 rules
### Upgrade acceptance
Accept only if all of the following are true:
- request method is `GET`
- request version is `HTTP/1.1`
- request body is empty
- `Upgrade` contains `websocket`
- `Connection` contains `upgrade`
- `Sec-WebSocket-Key` is present and syntactically valid
- `Sec-WebSocket-Version` equals `13`
Otherwise return a regular HTTP error response.
### WebSocket features in v1
Supported:
- text messages
- binary messages
- ping/pong handling
- close handshake
Not supported in v1:
- permessage-deflate
- subprotocol negotiation
- fragmented-message streaming to the application
- very large frame optimizations beyond a reasonable implementation limit
## Testing plan
A server like this should be tested at three levels.
### 1. Parser unit tests
Cases:
- valid request line parsing
- invalid request line parsing
- target parsing with and without query
- header case-insensitive lookup
- duplicate `Host` handling
- duplicate `Content-Length` handling
- oversized request line rejection
- oversized headers rejection
- `Transfer-Encoding` rejection
### 2. Engine-level loopback tests
Using existing TCP backends:
- simple `GET` request and response
- `POST` with `Content-Length`
- keep-alive with two sequential requests on one socket
- `Connection: close`
- malformed request closes connection
- handler exception becomes `500`
- body too large becomes `413`
### 3. WebSocket upgrade tests
Cases:
- successful upgrade handshake
- text echo
- binary echo
- ping/pong behavior
- clean close handshake
- invalid upgrade headers rejected as HTTP errors
## Implementation phases
### Phase 1: internal HTTP server core
Implement:
- config
- buffered reader
- parser
- writer
- request loop
- fixed-body responses
- keep-alive
### Phase 2: server-side WebSocket upgrade
Implement:
- upgrade validation
- `101 Switching Protocols`
- WebSocket frame IO
- session object
- close and ping/pong handling
### Phase 3: host integration and optional Lyng exposure
Possible future work:
- host-facing convenience factory APIs
- Lyng module exposure if there is a clear scripting use case
- route helpers or lightweight dispatching
- JVM-specific richer backends if requirements grow
## Open questions
1. Should the first version expose only a Kotlin host API, or should it also be surfaced to Lyng scripts immediately?
2. Should response headers be represented as repeated `HttpHeader` entries only, or should a convenience builder API be added from the start?
3. Should the first version include a small path router helper, or should routing stay entirely in host code?
4. Should very small chunked response support be added later if keep-alive plus unknown response length becomes a real need, or should v1 require fully materialized responses only?
## Recommendation
Proceed with this strict HTTP/1.1 + WebSocket subset.
It is small enough to finish in common Kotlin, fits the current `lyngio` transport architecture, and avoids turning the project into a full protocol-stack implementation effort.