From 01ceecd7df3ed1657ccc48ded75dac423b1161c9 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 26 Apr 2026 10:09:25 +0300 Subject: [PATCH] Add minimal HTTP server and shared network type packages --- docs/ai_stdlib_reference.md | 4 + docs/lyng.io.http.md | 2 + docs/lyng.io.net.md | 2 + docs/lyng.io.ws.md | 2 + docs/lyngio.md | 1 + .../sergeych/lyng/io/http/LyngHttpModule.kt | 23 +- .../io/http/server/LyngHttpServerModule.kt | 514 +++++++++++++++ .../net/sergeych/lyng/io/net/LyngNetModule.kt | 30 +- .../net/sergeych/lyng/io/ws/LyngWsModule.kt | 22 +- .../http/server/BufferedSocketReader.kt | 50 ++ .../sergeych/lyngio/http/server/HttpParser.kt | 181 ++++++ .../sergeych/lyngio/http/server/HttpServer.kt | 101 +++ .../lyngio/http/server/HttpServerLoop.kt | 149 +++++ .../sergeych/lyngio/http/server/HttpWriter.kt | 44 ++ .../lyngio/http/server/ServerWebSocket.kt | 295 +++++++++ .../lyngio/http/server/HttpParserTest.kt | 122 ++++ .../http/server/HttpServerLoopbackTest.kt | 242 +++++++ .../http/server/LyngHttpServerModuleTest.kt | 131 ++++ .../sergeych/lyng/io/net/LyngNetModuleTest.kt | 15 + lyngio/stdlib/lyng/io/http.lyng | 13 +- lyngio/stdlib/lyng/io/http_server.lyng | 54 ++ lyngio/stdlib/lyng/io/http_types.lyng | 14 + lyngio/stdlib/lyng/io/net.lyng | 26 +- lyngio/stdlib/lyng/io/net_types.lyng | 27 + lyngio/stdlib/lyng/io/ws.lyng | 10 +- lyngio/stdlib/lyng/io/ws_types.lyng | 11 + .../kotlin/net/sergeych/lyng/Compiler.kt | 12 +- proposals/lyngio_minimal_http_server.md | 600 ++++++++++++++++++ 28 files changed, 2642 insertions(+), 55 deletions(-) create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModule.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/BufferedSocketReader.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpParser.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpServer.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpServerLoop.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpWriter.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/ServerWebSocket.kt create mode 100644 lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/server/HttpParserTest.kt create mode 100644 lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/server/HttpServerLoopbackTest.kt create mode 100644 lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModuleTest.kt create mode 100644 lyngio/stdlib/lyng/io/http_server.lyng create mode 100644 lyngio/stdlib/lyng/io/http_types.lyng create mode 100644 lyngio/stdlib/lyng/io/net_types.lyng create mode 100644 lyngio/stdlib/lyng/io/ws_types.lyng create mode 100644 proposals/lyngio_minimal_http_server.md diff --git a/docs/ai_stdlib_reference.md b/docs/ai_stdlib_reference.md index 2f3fe11..5c51dae 100644 --- a/docs/ai_stdlib_reference.md +++ b/docs/ai_stdlib_reference.md @@ -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.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) +- 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 - Assume `lyng.stdlib` APIs exist in regular script contexts. diff --git a/docs/lyng.io.http.md b/docs/lyng.io.http.md index 795ba58..d68de48 100644 --- a/docs/lyng.io.http.md +++ b/docs/lyng.io.http.md @@ -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. > **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. --- diff --git a/docs/lyng.io.net.md b/docs/lyng.io.net.md index ea8659b..8ab9dbc 100644 --- a/docs/lyng.io.net.md +++ b/docs/lyng.io.net.md @@ -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. > +> **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. > > **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. diff --git a/docs/lyng.io.ws.md b/docs/lyng.io.ws.md index 9dc5bff..7f58d7b 100644 --- a/docs/lyng.io.ws.md +++ b/docs/lyng.io.ws.md @@ -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. > **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. --- diff --git a/docs/lyngio.md b/docs/lyngio.md index fd3171e..3447450 100644 --- a/docs/lyngio.md +++ b/docs/lyngio.md @@ -19,6 +19,7 @@ - **[lyng.io.http](lyng.io.http.md):** HTTP/HTTPS client access. Provides `Http`, `HttpRequest`, `HttpResponse`, and `HttpHeaders`. - **[lyng.io.ws](lyng.io.ws.md):** WebSocket client access. Provides `Ws`, `WsSession`, and `WsMessage`. - **[lyng.io.net](lyng.io.net.md):** Transport networking. Provides `Net`, `TcpSocket`, `TcpServer`, `UdpSocket`, and `SocketAddress`. +- **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. --- diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt index af09106..be48e15 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt @@ -46,8 +46,10 @@ import net.sergeych.lyngio.http.security.HttpAccessDeniedException import net.sergeych.lyngio.http.security.HttpAccessOp import net.sergeych.lyngio.http.security.HttpAccessPolicy import net.sergeych.lyngio.stdlib_included.httpLyng +import net.sergeych.lyngio.stdlib_included.http_typesLyng 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 = 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 createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean { + createHttpTypesModule(manager) if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false manager.addPackage(HTTP_MODULE_NAME) { module -> buildHttpModule(module, policy) @@ -64,6 +67,19 @@ fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean 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) { module.eval(Source(HTTP_MODULE_NAME, httpLyng)) val engine = getSystemHttpEngine() @@ -139,7 +155,7 @@ private suspend inline fun ScopeFacade.httpGuard(crossinline block: suspend () - } } -private class ObjHttpHeaders( +internal class ObjHttpHeaders( singleValueHeaders: Map = emptyMap(), private val allHeaders: Map> = emptyMap(), ) : Obj() { @@ -201,6 +217,11 @@ private class ObjHttpHeaders( ).invokeInstanceMethod(requireScope(), "iterator") } } + + internal fun fromHeaders( + singleValueHeaders: Map, + allHeaders: Map>, + ): ObjHttpHeaders = ObjHttpHeaders(singleValueHeaders, allHeaders) } private fun valuesOf(name: String): List = allHeaders[lookupKey(name)] ?: emptyList() diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModule.kt new file mode 100644 index 0000000..ca49c42 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModule.kt @@ -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>() + private val anyRoutes = linkedMapOf() + private val wsRoutes = linkedMapOf() + 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() + + 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().registerRoute("GET", this) + } + bridgeFn(this, "post", fnType(httpServerType, stringType, exchangeHandlerType)) { + thisAs().registerRoute("POST", this) + } + bridgeFn(this, "put", fnType(httpServerType, stringType, exchangeHandlerType)) { + thisAs().registerRoute("PUT", this) + } + bridgeFn(this, "delete", fnType(httpServerType, stringType, exchangeHandlerType)) { + thisAs().registerRoute("DELETE", this) + } + bridgeFn(this, "any", fnType(httpServerType, stringType, exchangeHandlerType)) { + thisAs().registerAny(this) + } + bridgeFn(this, "ws", fnType(httpServerType, stringType, webSocketHandlerType)) { + thisAs().registerWs(this) + } + bridgeFn(this, "fallback", fnType(httpServerType, exchangeHandlerType)) { + thisAs().registerFallback(this) + } + bridgeFn(this, "listen", fnType(httpServerHandleType, intType, nullableStringType, intType)) { + thisAs().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(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(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().handle.localAddress().port.toLong()) + } + addFn("close") { + requireNoArgs() + thisAs().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().request.head.method) + } + bridgeProperty(this, "target", stringType) { + ObjString(thisAs().request.head.target) + } + bridgeProperty(this, "path", stringType) { + ObjString(thisAs().request.head.path) + } + bridgeProperty(this, "query", nullableStringType) { + thisAs().request.head.query?.let(::ObjString) ?: ObjNull + } + bridgeProperty(this, "headers", httpHeadersType) { + requestHeadersObj(thisAs().request.head.headers) + } + bridgeProperty(this, "body", bufferType) { + ObjBuffer(thisAs().request.body.toUByteArray()) + } + bridgeFn(this, "text", fnType(stringType)) { + ObjString(thisAs().request.body.decodeToString()) + } + bridgeFn(this, "isWebSocketUpgrade", fnType(boolType)) { + ObjBool(thisAs().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>() + 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().request) + } + bridgeFn(this, "respond", fnType(voidType, intType, nullableBufferType)) { + val self = thisAs() + 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() + 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() + val name = requiredArg(0).value + val value = requiredArg(1).value + self.ensureMutable(this) + self.responseHeaders[name] = mutableListOf(value) + ObjVoid + } + bridgeFn(this, "addHeader", fnType(voidType, stringType, stringType)) { + val self = thisAs() + val name = requiredArg(0).value + val value = requiredArg(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() + 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().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().session.isOpen()) + } + bridgeFn(this, "sendText", fnType(voidType, stringType)) { + thisAs().session.sendText(requiredArg(0).value) + ObjVoid + } + bridgeFn(this, "sendBytes", fnType(voidType, bufferType)) { + thisAs().session.sendBytes(requiredArg(0).byteArray.toByteArray()) + ObjVoid + } + bridgeFn(this, "receive", fnType(nullableServerWsMessageType)) { + thisAs().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().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") +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/net/LyngNetModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/net/LyngNetModule.kt index 7189664..726f29b 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/net/LyngNetModule.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/net/LyngNetModule.kt @@ -47,8 +47,10 @@ import net.sergeych.lyngio.net.security.NetAccessDeniedException import net.sergeych.lyngio.net.security.NetAccessOp import net.sergeych.lyngio.net.security.NetAccessPolicy import net.sergeych.lyngio.stdlib_included.netLyng +import net.sergeych.lyngio.stdlib_included.net_typesLyng 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 = 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 createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean { + createNetTypesModule(manager) if (manager.packageNames.contains(NET_MODULE_NAME)) return false manager.addPackage(NET_MODULE_NAME) { module -> buildNetModule(module, policy) @@ -65,6 +68,21 @@ fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean { 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) { module.eval(Source(NET_MODULE_NAME, netLyng)) val engine = getSystemNetEngine() @@ -164,10 +182,12 @@ private class ObjSocketAddress( override suspend fun defaultToString(scope: Scope): ObjString = ObjString(renderAddress(address)) companion object { - private val types = mutableMapOf() + private data class EnumKey(val ipv4: Obj, val ipv6: Obj) + + private val types = mutableMapOf() fun type(enumValues: NetEnumValues): ObjClass = - types.getOrPut(enumValues) { + types.getOrPut(EnumKey(enumValues.ipv4, enumValues.ipv6)) { object : ObjClass("SocketAddress") { override suspend fun callOn(scope: Scope): Obj { scope.raiseError("SocketAddress cannot be created directly") @@ -191,10 +211,12 @@ private class ObjDatagram( get() = type(enumValues) companion object { - private val types = mutableMapOf() + private data class EnumKey(val ipv4: Obj, val ipv6: Obj) + + private val types = mutableMapOf() fun type(enumValues: NetEnumValues): ObjClass = - types.getOrPut(enumValues) { + types.getOrPut(EnumKey(enumValues.ipv4, enumValues.ipv6)) { object : ObjClass("Datagram") { override suspend fun callOn(scope: Scope): Obj { scope.raiseError("Datagram cannot be created directly") diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/ws/LyngWsModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/ws/LyngWsModule.kt index 71d704b..35e750c 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/ws/LyngWsModule.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/ws/LyngWsModule.kt @@ -37,6 +37,7 @@ import net.sergeych.lyng.raiseIllegalOperation import net.sergeych.lyng.requireNoArgs import net.sergeych.lyng.requireScope import net.sergeych.lyngio.stdlib_included.wsLyng +import net.sergeych.lyngio.stdlib_included.ws_typesLyng import net.sergeych.lyngio.ws.LyngWsEngine import net.sergeych.lyngio.ws.LyngWsMessage import net.sergeych.lyngio.ws.LyngWsSession @@ -46,6 +47,7 @@ import net.sergeych.lyngio.ws.security.WsAccessOp import net.sergeych.lyngio.ws.security.WsAccessPolicy 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 = 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 createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean { + createWsTypesModule(manager) if (manager.packageNames.contains(WS_MODULE_NAME)) return false manager.addPackage(WS_MODULE_NAME) { module -> buildWsModule(module, policy) @@ -62,6 +65,19 @@ fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean { 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) { module.eval(Source(WS_MODULE_NAME, wsLyng)) 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, ) : Obj() { override val objClass: ObjClass @@ -112,6 +128,8 @@ private class ObjWsMessage( thisAs().message.data?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull }) } + + internal fun from(message: LyngWsMessage): ObjWsMessage = ObjWsMessage(message) } } @@ -152,7 +170,7 @@ private class ObjWsSession( addFn("receive") { val self = thisAs() self.policy.require(WsAccessOp.Receive(self.targetUrl)) - self.session.receive()?.let(::ObjWsMessage) ?: ObjNull + self.session.receive()?.let(ObjWsMessage::from) ?: ObjNull } addFn("close") { val self = thisAs() diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/BufferedSocketReader.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/BufferedSocketReader.kt new file mode 100644 index 0000000..424a27b --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/BufferedSocketReader.kt @@ -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] + } +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpParser.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpParser.kt new file mode 100644 index 0000000..2aa4f23 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpParser.kt @@ -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 { + val headers = ArrayList() + 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( + '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~' + ) diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpServer.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpServer.kt new file mode 100644 index 0000000..4672855 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpServer.kt @@ -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, +) { + fun first(name: String): String? = + headerEntries.firstOrNull { it.name.equals(name, ignoreCase = true) }?.value + + fun all(name: String): List = + 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 = 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 = 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" +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpServerLoop.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpServerLoop.kt new file mode 100644 index 0000000..a98c61d --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpServerLoop.kt @@ -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) { + } + } +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpWriter.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpWriter.kt new file mode 100644 index 0000000..cf18767 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpWriter.kt @@ -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>() + 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() +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/ServerWebSocket.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/ServerWebSocket.kt new file mode 100644 index 0000000..c93876f --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/ServerWebSocket.kt @@ -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 diff --git a/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/server/HttpParserTest.kt b/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/server/HttpParserTest.kt new file mode 100644 index 0000000..be6e212 --- /dev/null +++ b/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/server/HttpParserTest.kt @@ -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 { + parse(request, HttpServerConfig(maxHeaderBytes = 32)) + } + assertEquals(431, error.status) + } + + @Test + fun conflictingDuplicateHostIsRejected() = kotlinx.coroutines.test.runTest { + val error = assertFailsWith { + 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 { + 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 { + 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 + } +} diff --git a/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/server/HttpServerLoopbackTest.kt b/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/server/HttpServerLoopbackTest.kt new file mode 100644 index 0000000..a4aa9b1 --- /dev/null +++ b/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/server/HttpServerLoopbackTest.kt @@ -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() + 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() + 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 + } +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModuleTest.kt new file mode 100644 index 0000000..34322ea --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModuleTest.kt @@ -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() + 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 + } +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleTest.kt index 8a84564..1c08e46 100644 --- a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleTest.kt +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/net/LyngNetModuleTest.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import net.sergeych.lyng.Compiler import net.sergeych.lyng.ExecutionError +import net.sergeych.lyng.Pos import net.sergeych.lyng.Script import net.sergeych.lyngio.fs.security.AccessContext import net.sergeych.lyngio.fs.security.AccessDecision @@ -35,11 +36,25 @@ import java.net.ServerSocket import java.net.Socket import kotlin.concurrent.thread import kotlin.test.Test +import kotlin.test.assertSame import kotlin.test.assertFailsWith import kotlin.test.assertTrue 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 fun testResolveAndCapabilities() = runBlocking { val scope = Script.newScope() diff --git a/lyngio/stdlib/lyng/io/http.lyng b/lyngio/stdlib/lyng/io/http.lyng index 20f69ab..83726e8 100644 --- a/lyngio/stdlib/lyng/io/http.lyng +++ b/lyngio/stdlib/lyng/io/http.lyng @@ -1,17 +1,6 @@ package lyng.io.http -/* - Response/header view that behaves like a map for the first value of each header name. - Multi-valued headers are exposed through `getAll`. -*/ -extern class HttpHeaders : Map { - /* 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 - /* Return distinct header names present in this response. */ - fun names(): List -} +import lyng.io.http.types /* Mutable request descriptor for programmatic HTTP calls. */ extern class HttpRequest { diff --git a/lyngio/stdlib/lyng/io/http_server.lyng b/lyngio/stdlib/lyng/io/http_server.lyng new file mode 100644 index 0000000..28f18dc --- /dev/null +++ b/lyngio/stdlib/lyng/io/http_server.lyng @@ -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 +} diff --git a/lyngio/stdlib/lyng/io/http_types.lyng b/lyngio/stdlib/lyng/io/http_types.lyng new file mode 100644 index 0000000..5795610 --- /dev/null +++ b/lyngio/stdlib/lyng/io/http_types.lyng @@ -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 { + /* 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 + /* Return distinct header names present in this response. */ + fun names(): List +} diff --git a/lyngio/stdlib/lyng/io/net.lyng b/lyngio/stdlib/lyng/io/net.lyng index 1428107..23e0c65 100644 --- a/lyngio/stdlib/lyng/io/net.lyng +++ b/lyngio/stdlib/lyng/io/net.lyng @@ -1,30 +1,6 @@ package lyng.io.net -/* Address family for resolved or bound endpoints. */ -enum IpVersion { - IPV4, - IPV6 -} - -/* Concrete socket endpoint. */ -extern class SocketAddress { - /* Numeric or host-form address string. */ - val host: String - /* Transport port number. */ - val port: Int - /* Address family. */ - val ipVersion: IpVersion - /* True when obtained from DNS resolution rather than raw bind input. */ - val resolved: Bool - /* Stable printable form such as `127.0.0.1:4040` or `[::1]:4040`. */ - override fun toString(): String -} - -/* Datagram payload paired with sender/peer address. */ -extern class Datagram { - val data: Buffer - val address: SocketAddress -} +import lyng.io.net.types /* Connected TCP socket. */ extern class TcpSocket { diff --git a/lyngio/stdlib/lyng/io/net_types.lyng b/lyngio/stdlib/lyng/io/net_types.lyng new file mode 100644 index 0000000..6a40d4c --- /dev/null +++ b/lyngio/stdlib/lyng/io/net_types.lyng @@ -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 +} diff --git a/lyngio/stdlib/lyng/io/ws.lyng b/lyngio/stdlib/lyng/io/ws.lyng index 0e096c7..231c5b4 100644 --- a/lyngio/stdlib/lyng/io/ws.lyng +++ b/lyngio/stdlib/lyng/io/ws.lyng @@ -1,14 +1,6 @@ package lyng.io.ws -/* Received WebSocket message. */ -extern class WsMessage { - /* True when this message carries text payload. */ - val isText: Bool - /* Text payload for text messages, otherwise null. */ - val text: String? - /* Binary payload for binary messages, otherwise null. */ - val data: Buffer? -} +import lyng.io.ws.types /* Active WebSocket client session. */ extern class WsSession { diff --git a/lyngio/stdlib/lyng/io/ws_types.lyng b/lyngio/stdlib/lyng/io/ws_types.lyng new file mode 100644 index 0000000..c90c1d0 --- /dev/null +++ b/lyngio/stdlib/lyng/io/ws_types.lyng @@ -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? +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 7bb9a3c..f1cefe8 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -2959,7 +2959,11 @@ class Compiler( Token.Type.LPAREN -> { cc.next() 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 tailBlock = parsed.second isCall = true @@ -2976,7 +2980,11 @@ class Compiler( val receiverType = if (next.value == "apply" || next.value == "run") { inferReceiverTypeFromRef(left) } 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 tailBlock = parsed.second if (left is LocalVarRef && left.name == "scope") { diff --git a/proposals/lyngio_minimal_http_server.md b/proposals/lyngio_minimal_http_server.md new file mode 100644 index 0000000..1b6324a --- /dev/null +++ b/proposals/lyngio_minimal_http_server.md @@ -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, +) { + fun first(name: String): String? + fun all(name: String): List + fun containsToken(name: String, token: String): Boolean + fun entries(): List +} + +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 = 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.