Add minimal HTTP server and shared network type packages
This commit is contained in:
parent
79b015ee56
commit
01ceecd7df
@ -92,6 +92,10 @@ Requires installing `lyngio` into the import manager from host code.
|
|||||||
- `import lyng.io.http` (HTTP/HTTPS client API)
|
- `import lyng.io.http` (HTTP/HTTPS client API)
|
||||||
- `import lyng.io.ws` (WebSocket client API; currently supported on JVM, capability-gated elsewhere)
|
- `import lyng.io.ws` (WebSocket client API; currently supported on JVM, capability-gated elsewhere)
|
||||||
- `import lyng.io.net` (TCP/UDP transport API; currently supported on JVM, capability-gated elsewhere)
|
- `import lyng.io.net` (TCP/UDP transport API; currently supported on JVM, capability-gated elsewhere)
|
||||||
|
- Shared network value-type packages are also available when installed by host code:
|
||||||
|
- `import lyng.io.http.types` (`HttpHeaders`)
|
||||||
|
- `import lyng.io.ws.types` (`WsMessage`)
|
||||||
|
- `import lyng.io.net.types` (`IpVersion`, `SocketAddress`, `Datagram`)
|
||||||
|
|
||||||
## 7. AI Generation Tips
|
## 7. AI Generation Tips
|
||||||
- Assume `lyng.stdlib` APIs exist in regular script contexts.
|
- Assume `lyng.stdlib` APIs exist in regular script contexts.
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
This module provides a compact HTTP client API for Lyng scripts. It is implemented in `lyngio` and backed by Ktor on supported runtimes.
|
This module provides a compact HTTP client API for Lyng scripts. It is implemented in `lyngio` and backed by Ktor on supported runtimes.
|
||||||
|
|
||||||
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
|
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
|
||||||
|
>
|
||||||
|
> **Shared type note:** `HttpHeaders` is also available from `lyng.io.http.types` when host code wants the reusable value type without relying on the HTTP client module itself.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,8 @@ This module provides minimal raw transport networking for Lyng scripts. It is im
|
|||||||
|
|
||||||
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
|
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
|
||||||
>
|
>
|
||||||
|
> **Shared type note:** `IpVersion`, `SocketAddress`, and `Datagram` are also available from `lyng.io.net.types` when host code wants reusable transport value types without depending on the `Net` capability object itself.
|
||||||
|
>
|
||||||
> **Important native platform limit:** current native TCP/UDP support is backed by a selector with a per-process file descriptor ceiling. On Linux/macOS native targets this makes high-connection-count servers and same-process load tests unsuitable once the process approaches that limit.
|
> **Important native platform limit:** current native TCP/UDP support is backed by a selector with a per-process file descriptor ceiling. On Linux/macOS native targets this makes high-connection-count servers and same-process load tests unsuitable once the process approaches that limit.
|
||||||
>
|
>
|
||||||
> **Recommendation:** for serious HTTP/TCP servers, prefer the JVM target today. On native targets, keep concurrency bounded, batch local load tests in waves, and use multiple worker processes behind a reverse proxy if you need more throughput before the backend is reworked.
|
> **Recommendation:** for serious HTTP/TCP servers, prefer the JVM target today. On native targets, keep concurrency bounded, batch local load tests in waves, and use multiple worker processes behind a reverse proxy if you need more throughput before the backend is reworked.
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
This module provides a compact WebSocket client API for Lyng scripts. It is implemented in `lyngio` and currently backed by Ktor WebSockets on the JVM.
|
This module provides a compact WebSocket client API for Lyng scripts. It is implemented in `lyngio` and currently backed by Ktor WebSockets on the JVM.
|
||||||
|
|
||||||
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
|
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
|
||||||
|
>
|
||||||
|
> **Shared type note:** `WsMessage` is also available from `lyng.io.ws.types` when host code wants the reusable message type without depending on the WebSocket client module itself.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
- **[lyng.io.http](lyng.io.http.md):** HTTP/HTTPS client access. Provides `Http`, `HttpRequest`, `HttpResponse`, and `HttpHeaders`.
|
- **[lyng.io.http](lyng.io.http.md):** HTTP/HTTPS client access. Provides `Http`, `HttpRequest`, `HttpResponse`, and `HttpHeaders`.
|
||||||
- **[lyng.io.ws](lyng.io.ws.md):** WebSocket client access. Provides `Ws`, `WsSession`, and `WsMessage`.
|
- **[lyng.io.ws](lyng.io.ws.md):** WebSocket client access. Provides `Ws`, `WsSession`, and `WsMessage`.
|
||||||
- **[lyng.io.net](lyng.io.net.md):** Transport networking. Provides `Net`, `TcpSocket`, `TcpServer`, `UdpSocket`, and `SocketAddress`.
|
- **[lyng.io.net](lyng.io.net.md):** Transport networking. Provides `Net`, `TcpSocket`, `TcpServer`, `UdpSocket`, and `SocketAddress`.
|
||||||
|
- **Shared networking type packages:** `lyng.io.http.types`, `lyng.io.ws.types`, and `lyng.io.net.types` expose reusable value types such as `HttpHeaders`, `WsMessage`, `IpVersion`, `SocketAddress`, and `Datagram` when host code wants type-only imports without installing the corresponding capability object module.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -46,8 +46,10 @@ import net.sergeych.lyngio.http.security.HttpAccessDeniedException
|
|||||||
import net.sergeych.lyngio.http.security.HttpAccessOp
|
import net.sergeych.lyngio.http.security.HttpAccessOp
|
||||||
import net.sergeych.lyngio.http.security.HttpAccessPolicy
|
import net.sergeych.lyngio.http.security.HttpAccessPolicy
|
||||||
import net.sergeych.lyngio.stdlib_included.httpLyng
|
import net.sergeych.lyngio.stdlib_included.httpLyng
|
||||||
|
import net.sergeych.lyngio.stdlib_included.http_typesLyng
|
||||||
|
|
||||||
private const val HTTP_MODULE_NAME = "lyng.io.http"
|
private const val HTTP_MODULE_NAME = "lyng.io.http"
|
||||||
|
internal const val HTTP_TYPES_MODULE_NAME = "lyng.io.http.types"
|
||||||
|
|
||||||
fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
|
fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
|
||||||
createHttpModule(policy, scope.importManager)
|
createHttpModule(policy, scope.importManager)
|
||||||
@ -55,6 +57,7 @@ fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
|
|||||||
fun createHttp(policy: HttpAccessPolicy, scope: Scope): Boolean = createHttpModule(policy, scope)
|
fun createHttp(policy: HttpAccessPolicy, scope: Scope): Boolean = createHttpModule(policy, scope)
|
||||||
|
|
||||||
fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean {
|
fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean {
|
||||||
|
createHttpTypesModule(manager)
|
||||||
if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false
|
if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false
|
||||||
manager.addPackage(HTTP_MODULE_NAME) { module ->
|
manager.addPackage(HTTP_MODULE_NAME) { module ->
|
||||||
buildHttpModule(module, policy)
|
buildHttpModule(module, policy)
|
||||||
@ -64,6 +67,19 @@ fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean
|
|||||||
|
|
||||||
fun createHttp(policy: HttpAccessPolicy, manager: ImportManager): Boolean = createHttpModule(policy, manager)
|
fun createHttp(policy: HttpAccessPolicy, manager: ImportManager): Boolean = createHttpModule(policy, manager)
|
||||||
|
|
||||||
|
internal fun createHttpTypesModule(manager: ImportManager): Boolean {
|
||||||
|
if (manager.packageNames.contains(HTTP_TYPES_MODULE_NAME)) return false
|
||||||
|
manager.addPackage(HTTP_TYPES_MODULE_NAME) { module ->
|
||||||
|
buildHttpTypesModule(module)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildHttpTypesModule(module: ModuleScope) {
|
||||||
|
module.eval(Source(HTTP_TYPES_MODULE_NAME, http_typesLyng))
|
||||||
|
module.addConst("HttpHeaders", ObjHttpHeaders.type)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolicy) {
|
private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolicy) {
|
||||||
module.eval(Source(HTTP_MODULE_NAME, httpLyng))
|
module.eval(Source(HTTP_MODULE_NAME, httpLyng))
|
||||||
val engine = getSystemHttpEngine()
|
val engine = getSystemHttpEngine()
|
||||||
@ -139,7 +155,7 @@ private suspend inline fun ScopeFacade.httpGuard(crossinline block: suspend () -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ObjHttpHeaders(
|
internal class ObjHttpHeaders(
|
||||||
singleValueHeaders: Map<String, String> = emptyMap(),
|
singleValueHeaders: Map<String, String> = emptyMap(),
|
||||||
private val allHeaders: Map<String, List<String>> = emptyMap(),
|
private val allHeaders: Map<String, List<String>> = emptyMap(),
|
||||||
) : Obj() {
|
) : Obj() {
|
||||||
@ -201,6 +217,11 @@ private class ObjHttpHeaders(
|
|||||||
).invokeInstanceMethod(requireScope(), "iterator")
|
).invokeInstanceMethod(requireScope(), "iterator")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun fromHeaders(
|
||||||
|
singleValueHeaders: Map<String, String>,
|
||||||
|
allHeaders: Map<String, List<String>>,
|
||||||
|
): ObjHttpHeaders = ObjHttpHeaders(singleValueHeaders, allHeaders)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun valuesOf(name: String): List<String> = allHeaders[lookupKey(name)] ?: emptyList()
|
private fun valuesOf(name: String): List<String> = allHeaders[lookupKey(name)] ?: emptyList()
|
||||||
|
|||||||
@ -0,0 +1,514 @@
|
|||||||
|
package net.sergeych.lyng.io.http.server
|
||||||
|
|
||||||
|
import net.sergeych.lyng.ModuleScope
|
||||||
|
import net.sergeych.lyng.Scope
|
||||||
|
import net.sergeych.lyng.ScopeFacade
|
||||||
|
import net.sergeych.lyng.Source
|
||||||
|
import net.sergeych.lyng.Arguments
|
||||||
|
import net.sergeych.lyng.TypeDecl
|
||||||
|
import net.sergeych.lyng.asFacade
|
||||||
|
import net.sergeych.lyng.obj.Obj
|
||||||
|
import net.sergeych.lyng.obj.ObjBool
|
||||||
|
import net.sergeych.lyng.obj.ObjBuffer
|
||||||
|
import net.sergeych.lyng.obj.ObjClass
|
||||||
|
import net.sergeych.lyng.obj.ObjExternCallable
|
||||||
|
import net.sergeych.lyng.obj.ObjInt
|
||||||
|
import net.sergeych.lyng.obj.ObjList
|
||||||
|
import net.sergeych.lyng.obj.ObjNull
|
||||||
|
import net.sergeych.lyng.obj.ObjProperty
|
||||||
|
import net.sergeych.lyng.obj.ObjString
|
||||||
|
import net.sergeych.lyng.obj.ObjVoid
|
||||||
|
import net.sergeych.lyng.obj.requiredArg
|
||||||
|
import net.sergeych.lyng.obj.thisAs
|
||||||
|
import net.sergeych.lyng.io.http.ObjHttpHeaders
|
||||||
|
import net.sergeych.lyng.io.http.createHttpTypesModule
|
||||||
|
import net.sergeych.lyng.io.ws.ObjWsMessage
|
||||||
|
import net.sergeych.lyng.io.ws.createWsTypesModule
|
||||||
|
import net.sergeych.lyng.pacman.ImportManager
|
||||||
|
import net.sergeych.lyng.raiseIllegalOperation
|
||||||
|
import net.sergeych.lyng.requireNoArgs
|
||||||
|
import net.sergeych.lyng.requireScope
|
||||||
|
import net.sergeych.lyngio.http.server.HttpHandlerResult
|
||||||
|
import net.sergeych.lyngio.http.server.HttpHeader
|
||||||
|
import net.sergeych.lyngio.http.server.HttpRequest
|
||||||
|
import net.sergeych.lyngio.http.server.HttpResponse
|
||||||
|
import net.sergeych.lyngio.http.server.HttpServerConfig
|
||||||
|
import net.sergeych.lyngio.http.server.HttpWebSocketSession
|
||||||
|
import net.sergeych.lyngio.http.server.defaultReason
|
||||||
|
import net.sergeych.lyngio.http.server.startHttpServer
|
||||||
|
import net.sergeych.lyngio.net.security.NetAccessDeniedException
|
||||||
|
import net.sergeych.lyngio.net.security.NetAccessOp
|
||||||
|
import net.sergeych.lyngio.net.security.NetAccessPolicy
|
||||||
|
import net.sergeych.lyngio.stdlib_included.http_serverLyng
|
||||||
|
import net.sergeych.lyng.bytecode.BytecodeLambdaCallable
|
||||||
|
|
||||||
|
private const val HTTP_SERVER_MODULE_NAME = "lyng.io.http.server"
|
||||||
|
|
||||||
|
fun createHttpServerModule(policy: NetAccessPolicy, scope: Scope): Boolean =
|
||||||
|
createHttpServerModule(policy, scope.importManager)
|
||||||
|
|
||||||
|
fun createHttpServer(policy: NetAccessPolicy, scope: Scope): Boolean = createHttpServerModule(policy, scope)
|
||||||
|
|
||||||
|
fun createHttpServerModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
|
||||||
|
createHttpTypesModule(manager)
|
||||||
|
createWsTypesModule(manager)
|
||||||
|
if (manager.packageNames.contains(HTTP_SERVER_MODULE_NAME)) return false
|
||||||
|
manager.addPackage(HTTP_SERVER_MODULE_NAME) { module ->
|
||||||
|
buildHttpServerModule(module, policy)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createHttpServer(policy: NetAccessPolicy, manager: ImportManager): Boolean = createHttpServerModule(policy, manager)
|
||||||
|
|
||||||
|
private suspend fun buildHttpServerModule(module: ModuleScope, policy: NetAccessPolicy) {
|
||||||
|
module.eval(Source(HTTP_SERVER_MODULE_NAME, http_serverLyng))
|
||||||
|
module.addConst("HttpHeaders", ObjHttpHeaders.type)
|
||||||
|
module.addConst("WsMessage", ObjWsMessage.type)
|
||||||
|
module.addConst("ServerRequest", ObjServerRequest.type)
|
||||||
|
module.addConst("ServerExchange", ObjServerExchange.type)
|
||||||
|
module.addConst("ServerWebSocket", ObjServerWebSocket.type)
|
||||||
|
module.addConst("HttpServerHandle", ObjHttpServerHandle.type)
|
||||||
|
module.addConst("HttpServer", ObjLyngHttpServer.type(policy))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun ScopeFacade.httpServerGuard(crossinline block: suspend () -> Obj): Obj {
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} catch (e: NetAccessDeniedException) {
|
||||||
|
raiseIllegalOperation(e.reasonDetail ?: "http server access denied")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
raiseIllegalOperation(e.message ?: "http server error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RegisteredCallable(
|
||||||
|
val callable: Obj,
|
||||||
|
val scope: Scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun captureCallable(scope: Scope, rawCallable: Obj): RegisteredCallable {
|
||||||
|
val captured = if (scope is ModuleScope) scope else scope.snapshotForClosure()
|
||||||
|
val callable = (rawCallable as? BytecodeLambdaCallable)?.freezeForLaunch(captured) ?: rawCallable
|
||||||
|
return RegisteredCallable(callable, captured)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun RegisteredCallable.call(vararg args: Obj): Obj =
|
||||||
|
scope.asFacade().call(callable, Arguments(args.toList()))
|
||||||
|
|
||||||
|
private val stringType = TypeDecl.Simple("String", false)
|
||||||
|
private val nullableStringType = TypeDecl.Simple("String", true)
|
||||||
|
private val boolType = TypeDecl.Simple("Bool", false)
|
||||||
|
private val intType = TypeDecl.Simple("Int", false)
|
||||||
|
private val bufferType = TypeDecl.Simple("Buffer", false)
|
||||||
|
private val nullableBufferType = TypeDecl.Simple("Buffer", true)
|
||||||
|
private val voidType = TypeDecl.Simple("Void", false)
|
||||||
|
private val httpHeadersType = TypeDecl.Simple("HttpHeaders", false)
|
||||||
|
private val serverRequestType = TypeDecl.Simple("ServerRequest", false)
|
||||||
|
private val serverExchangeType = TypeDecl.Simple("ServerExchange", false)
|
||||||
|
private val serverWebSocketType = TypeDecl.Simple("ServerWebSocket", false)
|
||||||
|
private val nullableServerWsMessageType = TypeDecl.Simple("WsMessage", true)
|
||||||
|
private val httpServerHandleType = TypeDecl.Simple("HttpServerHandle", false)
|
||||||
|
private val httpServerType = TypeDecl.Simple("HttpServer", false)
|
||||||
|
private val nullableAnyType = TypeDecl.TypeNullableAny
|
||||||
|
|
||||||
|
private fun listType(item: TypeDecl) = TypeDecl.Generic("List", listOf(item), false)
|
||||||
|
|
||||||
|
private fun fnType(returnType: TypeDecl, vararg params: TypeDecl) =
|
||||||
|
TypeDecl.Function(receiver = null, params = params.toList(), returnType = returnType)
|
||||||
|
|
||||||
|
private fun bridgeFn(
|
||||||
|
owner: ObjClass,
|
||||||
|
name: String,
|
||||||
|
typeDecl: TypeDecl.Function,
|
||||||
|
code: suspend ScopeFacade.() -> Obj,
|
||||||
|
) {
|
||||||
|
owner.createField(
|
||||||
|
name = name,
|
||||||
|
initialValue = ObjExternCallable.fromBridge { code() },
|
||||||
|
type = net.sergeych.lyng.obj.ObjRecord.Type.Fun,
|
||||||
|
typeDecl = typeDecl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bridgeProperty(
|
||||||
|
owner: ObjClass,
|
||||||
|
name: String,
|
||||||
|
typeDecl: TypeDecl,
|
||||||
|
getter: suspend ScopeFacade.() -> Obj,
|
||||||
|
) {
|
||||||
|
owner.createField(
|
||||||
|
name = name,
|
||||||
|
initialValue = ObjProperty(name, ObjExternCallable.fromBridge { getter() }, null),
|
||||||
|
type = net.sergeych.lyng.obj.ObjRecord.Type.Property,
|
||||||
|
typeDecl = typeDecl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ObjLyngHttpServer(
|
||||||
|
private val netPolicy: NetAccessPolicy,
|
||||||
|
) : Obj() {
|
||||||
|
private val methodRoutes = linkedMapOf<String, LinkedHashMap<String, RegisteredCallable>>()
|
||||||
|
private val anyRoutes = linkedMapOf<String, RegisteredCallable>()
|
||||||
|
private val wsRoutes = linkedMapOf<String, RegisteredCallable>()
|
||||||
|
private var fallback: RegisteredCallable? = null
|
||||||
|
private var handle: net.sergeych.lyngio.http.server.HttpServer? = null
|
||||||
|
|
||||||
|
override val objClass: ObjClass
|
||||||
|
get() = type(netPolicy)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val types = mutableMapOf<NetAccessPolicy, ObjClass>()
|
||||||
|
|
||||||
|
fun type(netPolicy: NetAccessPolicy): ObjClass =
|
||||||
|
types.getOrPut(netPolicy) {
|
||||||
|
object : ObjClass("HttpServer") {
|
||||||
|
override suspend fun callOn(scope: Scope): Obj {
|
||||||
|
if (scope.args.list.isNotEmpty()) scope.raiseError("HttpServer() does not accept arguments")
|
||||||
|
return ObjLyngHttpServer(netPolicy)
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
val exchangeHandlerType = fnType(nullableAnyType, serverExchangeType)
|
||||||
|
val webSocketHandlerType = fnType(nullableAnyType, serverWebSocketType, serverExchangeType)
|
||||||
|
|
||||||
|
bridgeFn(this, "get", fnType(httpServerType, stringType, exchangeHandlerType)) {
|
||||||
|
thisAs<ObjLyngHttpServer>().registerRoute("GET", this)
|
||||||
|
}
|
||||||
|
bridgeFn(this, "post", fnType(httpServerType, stringType, exchangeHandlerType)) {
|
||||||
|
thisAs<ObjLyngHttpServer>().registerRoute("POST", this)
|
||||||
|
}
|
||||||
|
bridgeFn(this, "put", fnType(httpServerType, stringType, exchangeHandlerType)) {
|
||||||
|
thisAs<ObjLyngHttpServer>().registerRoute("PUT", this)
|
||||||
|
}
|
||||||
|
bridgeFn(this, "delete", fnType(httpServerType, stringType, exchangeHandlerType)) {
|
||||||
|
thisAs<ObjLyngHttpServer>().registerRoute("DELETE", this)
|
||||||
|
}
|
||||||
|
bridgeFn(this, "any", fnType(httpServerType, stringType, exchangeHandlerType)) {
|
||||||
|
thisAs<ObjLyngHttpServer>().registerAny(this)
|
||||||
|
}
|
||||||
|
bridgeFn(this, "ws", fnType(httpServerType, stringType, webSocketHandlerType)) {
|
||||||
|
thisAs<ObjLyngHttpServer>().registerWs(this)
|
||||||
|
}
|
||||||
|
bridgeFn(this, "fallback", fnType(httpServerType, exchangeHandlerType)) {
|
||||||
|
thisAs<ObjLyngHttpServer>().registerFallback(this)
|
||||||
|
}
|
||||||
|
bridgeFn(this, "listen", fnType(httpServerHandleType, intType, nullableStringType, intType)) {
|
||||||
|
thisAs<ObjLyngHttpServer>().listen(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureMutable(scope: ScopeFacade) {
|
||||||
|
if (handle != null) scope.raiseIllegalState("HttpServer routes cannot be modified after listen()")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requirePath(scope: ScopeFacade, index: Int): String {
|
||||||
|
val path = scope.requiredArg<ObjString>(index).value
|
||||||
|
if (!path.startsWith('/')) scope.raiseIllegalArgument("path must start with '/'")
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun registerRoute(method: String, scope: ScopeFacade): Obj = scope.httpServerGuard {
|
||||||
|
ensureMutable(scope)
|
||||||
|
val path = requirePath(scope, 0)
|
||||||
|
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
|
||||||
|
val routes = methodRoutes.getOrPut(method) { linkedMapOf() }
|
||||||
|
if (routes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for $method $path")
|
||||||
|
routes[path] = handler
|
||||||
|
scope.thisObj
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun registerAny(scope: ScopeFacade): Obj = scope.httpServerGuard {
|
||||||
|
ensureMutable(scope)
|
||||||
|
val path = requirePath(scope, 0)
|
||||||
|
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
|
||||||
|
if (anyRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for ANY $path")
|
||||||
|
anyRoutes[path] = handler
|
||||||
|
scope.thisObj
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun registerWs(scope: ScopeFacade): Obj = scope.httpServerGuard {
|
||||||
|
ensureMutable(scope)
|
||||||
|
val path = requirePath(scope, 0)
|
||||||
|
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
|
||||||
|
if (wsRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate websocket route for $path")
|
||||||
|
wsRoutes[path] = handler
|
||||||
|
scope.thisObj
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun registerFallback(scope: ScopeFacade): Obj = scope.httpServerGuard {
|
||||||
|
ensureMutable(scope)
|
||||||
|
fallback = captureCallable(scope.requireScope(), scope.args.list[0])
|
||||||
|
scope.thisObj
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun listen(scope: ScopeFacade): Obj = scope.httpServerGuard {
|
||||||
|
ensureMutable(scope)
|
||||||
|
val port = scope.requiredArg<ObjInt>(0).value.toInt()
|
||||||
|
val host = scope.args.list.getOrNull(1)?.let { objOrNullToString(scope, it, "host") }
|
||||||
|
val backlog = scope.args.list.getOrNull(2)?.let { objToInt(scope, it, "backlog") } ?: 128
|
||||||
|
if (port !in 0..65535) scope.raiseIllegalArgument("port must be in 0..65535")
|
||||||
|
if (backlog <= 0) scope.raiseIllegalArgument("backlog must be positive")
|
||||||
|
netPolicy.require(NetAccessOp.TcpListen(host, port, backlog))
|
||||||
|
val started = startHttpServer(
|
||||||
|
config = HttpServerConfig(host = host ?: "127.0.0.1", port = port, backlog = backlog),
|
||||||
|
) { request ->
|
||||||
|
dispatchRequest(request)
|
||||||
|
}
|
||||||
|
handle = started
|
||||||
|
ObjHttpServerHandle(started)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun dispatchRequest(request: HttpRequest): HttpHandlerResult {
|
||||||
|
val path = request.head.path
|
||||||
|
if (request.head.wantsWebSocketUpgrade) {
|
||||||
|
wsRoutes[path]?.let { route ->
|
||||||
|
return HttpHandlerResult.WebSocket { session ->
|
||||||
|
val exchange = ObjServerExchange(request)
|
||||||
|
route.call(ObjServerWebSocket(session), exchange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val route = methodRoutes[request.head.method.uppercase()]?.get(path)
|
||||||
|
?: anyRoutes[path]
|
||||||
|
?: fallback
|
||||||
|
|
||||||
|
if (route == null) {
|
||||||
|
return HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val exchange = ObjServerExchange(request)
|
||||||
|
route.call(exchange)
|
||||||
|
return when (val result = exchange.result) {
|
||||||
|
is ExchangeResult.Http -> result.value
|
||||||
|
is ExchangeResult.WebSocket -> result.value
|
||||||
|
ExchangeResult.Unhandled -> {
|
||||||
|
if (route === fallback) {
|
||||||
|
HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray()))
|
||||||
|
} else {
|
||||||
|
HttpHandlerResult.Response(HttpResponse(status = 500, body = "route handler did not handle exchange".encodeToByteArray(), close = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ObjHttpServerHandle(
|
||||||
|
private val handle: net.sergeych.lyngio.http.server.HttpServer,
|
||||||
|
) : Obj() {
|
||||||
|
override val objClass: ObjClass
|
||||||
|
get() = type
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val type = object : ObjClass("HttpServerHandle") {
|
||||||
|
override suspend fun callOn(scope: Scope): Obj {
|
||||||
|
scope.raiseError("HttpServerHandle cannot be created directly")
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
addFn("localPort") {
|
||||||
|
ObjInt(thisAs<ObjHttpServerHandle>().handle.localAddress().port.toLong())
|
||||||
|
}
|
||||||
|
addFn("close") {
|
||||||
|
requireNoArgs()
|
||||||
|
thisAs<ObjHttpServerHandle>().handle.close()
|
||||||
|
ObjVoid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ObjServerRequest(
|
||||||
|
private val request: HttpRequest,
|
||||||
|
) : Obj() {
|
||||||
|
override val objClass: ObjClass
|
||||||
|
get() = type
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val type = object : ObjClass("ServerRequest") {
|
||||||
|
override suspend fun callOn(scope: Scope): Obj {
|
||||||
|
scope.raiseError("ServerRequest cannot be created directly")
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
bridgeProperty(this, "method", stringType) {
|
||||||
|
ObjString(thisAs<ObjServerRequest>().request.head.method)
|
||||||
|
}
|
||||||
|
bridgeProperty(this, "target", stringType) {
|
||||||
|
ObjString(thisAs<ObjServerRequest>().request.head.target)
|
||||||
|
}
|
||||||
|
bridgeProperty(this, "path", stringType) {
|
||||||
|
ObjString(thisAs<ObjServerRequest>().request.head.path)
|
||||||
|
}
|
||||||
|
bridgeProperty(this, "query", nullableStringType) {
|
||||||
|
thisAs<ObjServerRequest>().request.head.query?.let(::ObjString) ?: ObjNull
|
||||||
|
}
|
||||||
|
bridgeProperty(this, "headers", httpHeadersType) {
|
||||||
|
requestHeadersObj(thisAs<ObjServerRequest>().request.head.headers)
|
||||||
|
}
|
||||||
|
bridgeProperty(this, "body", bufferType) {
|
||||||
|
ObjBuffer(thisAs<ObjServerRequest>().request.body.toUByteArray())
|
||||||
|
}
|
||||||
|
bridgeFn(this, "text", fnType(stringType)) {
|
||||||
|
ObjString(thisAs<ObjServerRequest>().request.body.decodeToString())
|
||||||
|
}
|
||||||
|
bridgeFn(this, "isWebSocketUpgrade", fnType(boolType)) {
|
||||||
|
ObjBool(thisAs<ObjServerRequest>().request.head.wantsWebSocketUpgrade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface ExchangeResult {
|
||||||
|
data object Unhandled : ExchangeResult
|
||||||
|
data class Http(val value: HttpHandlerResult.Response) : ExchangeResult
|
||||||
|
data class WebSocket(val value: HttpHandlerResult.WebSocket) : ExchangeResult
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ObjServerExchange(
|
||||||
|
private val request: HttpRequest,
|
||||||
|
) : Obj() {
|
||||||
|
private val responseHeaders = linkedMapOf<String, MutableList<String>>()
|
||||||
|
var result: ExchangeResult = ExchangeResult.Unhandled
|
||||||
|
private set
|
||||||
|
|
||||||
|
override val objClass: ObjClass
|
||||||
|
get() = type
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val type = object : ObjClass("ServerExchange") {
|
||||||
|
override suspend fun callOn(scope: Scope): Obj {
|
||||||
|
scope.raiseError("ServerExchange cannot be created directly")
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
bridgeProperty(this, "request", serverRequestType) {
|
||||||
|
ObjServerRequest(thisAs<ObjServerExchange>().request)
|
||||||
|
}
|
||||||
|
bridgeFn(this, "respond", fnType(voidType, intType, nullableBufferType)) {
|
||||||
|
val self = thisAs<ObjServerExchange>()
|
||||||
|
val status = args.list.getOrNull(0)?.let { objToInt(this, it, "status") } ?: 200
|
||||||
|
val body = args.list.getOrNull(1)?.let { objBufferOrNull(this, it, "body") }
|
||||||
|
self.setHttpResponse(status, body?.byteArray?.toByteArray() ?: ByteArray(0))
|
||||||
|
ObjVoid
|
||||||
|
}
|
||||||
|
bridgeFn(this, "respondText", fnType(voidType, intType, stringType)) {
|
||||||
|
val self = thisAs<ObjServerExchange>()
|
||||||
|
val status = args.list.getOrNull(0)?.let { objToInt(this, it, "status") } ?: 200
|
||||||
|
val bodyText = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "bodyText") } ?: ""
|
||||||
|
self.setHttpResponse(status, bodyText.encodeToByteArray())
|
||||||
|
ObjVoid
|
||||||
|
}
|
||||||
|
bridgeFn(this, "setHeader", fnType(voidType, stringType, stringType)) {
|
||||||
|
val self = thisAs<ObjServerExchange>()
|
||||||
|
val name = requiredArg<ObjString>(0).value
|
||||||
|
val value = requiredArg<ObjString>(1).value
|
||||||
|
self.ensureMutable(this)
|
||||||
|
self.responseHeaders[name] = mutableListOf(value)
|
||||||
|
ObjVoid
|
||||||
|
}
|
||||||
|
bridgeFn(this, "addHeader", fnType(voidType, stringType, stringType)) {
|
||||||
|
val self = thisAs<ObjServerExchange>()
|
||||||
|
val name = requiredArg<ObjString>(0).value
|
||||||
|
val value = requiredArg<ObjString>(1).value
|
||||||
|
self.ensureMutable(this)
|
||||||
|
self.responseHeaders.getOrPut(name) { mutableListOf() }.add(value)
|
||||||
|
ObjVoid
|
||||||
|
}
|
||||||
|
bridgeFn(
|
||||||
|
this,
|
||||||
|
"acceptWebSocket",
|
||||||
|
fnType(voidType, fnType(nullableAnyType, serverWebSocketType, serverExchangeType))
|
||||||
|
) {
|
||||||
|
val self = thisAs<ObjServerExchange>()
|
||||||
|
val registered = captureCallable(requireScope(), args.list[0])
|
||||||
|
self.ensureMutable(this)
|
||||||
|
self.result = ExchangeResult.WebSocket(
|
||||||
|
HttpHandlerResult.WebSocket { session ->
|
||||||
|
registered.call(ObjServerWebSocket(session), self)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ObjVoid
|
||||||
|
}
|
||||||
|
bridgeFn(this, "isHandled", fnType(boolType)) {
|
||||||
|
ObjBool(thisAs<ObjServerExchange>().result !== ExchangeResult.Unhandled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureMutable(scope: ScopeFacade) {
|
||||||
|
if (result !== ExchangeResult.Unhandled) {
|
||||||
|
scope.raiseIllegalState("exchange has already been handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setHttpResponse(status: Int, body: ByteArray) {
|
||||||
|
result = ExchangeResult.Http(
|
||||||
|
HttpHandlerResult.Response(
|
||||||
|
HttpResponse(
|
||||||
|
status = status,
|
||||||
|
reason = defaultReason(status),
|
||||||
|
headers = responseHeaders.entries.flatMap { (name, values) -> values.map { HttpHeader(name, it) } },
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ObjServerWebSocket(
|
||||||
|
private val session: HttpWebSocketSession,
|
||||||
|
) : Obj() {
|
||||||
|
override val objClass: ObjClass
|
||||||
|
get() = type
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val type = object : ObjClass("ServerWebSocket") {
|
||||||
|
override suspend fun callOn(scope: Scope): Obj {
|
||||||
|
scope.raiseError("ServerWebSocket cannot be created directly")
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
bridgeFn(this, "isOpen", fnType(boolType)) {
|
||||||
|
ObjBool(thisAs<ObjServerWebSocket>().session.isOpen())
|
||||||
|
}
|
||||||
|
bridgeFn(this, "sendText", fnType(voidType, stringType)) {
|
||||||
|
thisAs<ObjServerWebSocket>().session.sendText(requiredArg<ObjString>(0).value)
|
||||||
|
ObjVoid
|
||||||
|
}
|
||||||
|
bridgeFn(this, "sendBytes", fnType(voidType, bufferType)) {
|
||||||
|
thisAs<ObjServerWebSocket>().session.sendBytes(requiredArg<ObjBuffer>(0).byteArray.toByteArray())
|
||||||
|
ObjVoid
|
||||||
|
}
|
||||||
|
bridgeFn(this, "receive", fnType(nullableServerWsMessageType)) {
|
||||||
|
thisAs<ObjServerWebSocket>().session.receive()?.let(ObjWsMessage::from) ?: ObjNull
|
||||||
|
}
|
||||||
|
bridgeFn(this, "close", fnType(voidType, intType, stringType)) {
|
||||||
|
val code = args.list.getOrNull(0)?.let { objToInt(this, it, "code") } ?: 1000
|
||||||
|
val reason = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "reason") } ?: ""
|
||||||
|
thisAs<ObjServerWebSocket>().session.close(code, reason)
|
||||||
|
ObjVoid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestHeadersObj(headers: net.sergeych.lyngio.http.server.HttpHeaders): ObjHttpHeaders {
|
||||||
|
val all = headers.entries().groupBy(HttpHeader::name, HttpHeader::value)
|
||||||
|
val single = all.mapValues { (_, values) -> values.first() }
|
||||||
|
return ObjHttpHeaders.fromHeaders(single, all)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun objOrNullToString(scope: ScopeFacade, value: Obj, name: String): String? = when (value) {
|
||||||
|
ObjNull -> null
|
||||||
|
else -> scope.toStringOf(value).value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun objToInt(scope: ScopeFacade, value: Obj, name: String): Int = when (value) {
|
||||||
|
is ObjInt -> value.value.toInt()
|
||||||
|
else -> scope.raiseClassCastError("$name must be Int")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun objBufferOrNull(scope: ScopeFacade, value: Obj, name: String): ObjBuffer? = when (value) {
|
||||||
|
ObjNull -> null
|
||||||
|
is ObjBuffer -> value
|
||||||
|
else -> scope.raiseClassCastError("$name must be Buffer or null")
|
||||||
|
}
|
||||||
@ -47,8 +47,10 @@ import net.sergeych.lyngio.net.security.NetAccessDeniedException
|
|||||||
import net.sergeych.lyngio.net.security.NetAccessOp
|
import net.sergeych.lyngio.net.security.NetAccessOp
|
||||||
import net.sergeych.lyngio.net.security.NetAccessPolicy
|
import net.sergeych.lyngio.net.security.NetAccessPolicy
|
||||||
import net.sergeych.lyngio.stdlib_included.netLyng
|
import net.sergeych.lyngio.stdlib_included.netLyng
|
||||||
|
import net.sergeych.lyngio.stdlib_included.net_typesLyng
|
||||||
|
|
||||||
private const val NET_MODULE_NAME = "lyng.io.net"
|
private const val NET_MODULE_NAME = "lyng.io.net"
|
||||||
|
internal const val NET_TYPES_MODULE_NAME = "lyng.io.net.types"
|
||||||
|
|
||||||
fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean =
|
fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean =
|
||||||
createNetModule(policy, scope.importManager)
|
createNetModule(policy, scope.importManager)
|
||||||
@ -56,6 +58,7 @@ fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean =
|
|||||||
fun createNet(policy: NetAccessPolicy, scope: Scope): Boolean = createNetModule(policy, scope)
|
fun createNet(policy: NetAccessPolicy, scope: Scope): Boolean = createNetModule(policy, scope)
|
||||||
|
|
||||||
fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
|
fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
|
||||||
|
createNetTypesModule(manager)
|
||||||
if (manager.packageNames.contains(NET_MODULE_NAME)) return false
|
if (manager.packageNames.contains(NET_MODULE_NAME)) return false
|
||||||
manager.addPackage(NET_MODULE_NAME) { module ->
|
manager.addPackage(NET_MODULE_NAME) { module ->
|
||||||
buildNetModule(module, policy)
|
buildNetModule(module, policy)
|
||||||
@ -65,6 +68,21 @@ fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
|
|||||||
|
|
||||||
fun createNet(policy: NetAccessPolicy, manager: ImportManager): Boolean = createNetModule(policy, manager)
|
fun createNet(policy: NetAccessPolicy, manager: ImportManager): Boolean = createNetModule(policy, manager)
|
||||||
|
|
||||||
|
internal fun createNetTypesModule(manager: ImportManager): Boolean {
|
||||||
|
if (manager.packageNames.contains(NET_TYPES_MODULE_NAME)) return false
|
||||||
|
manager.addPackage(NET_TYPES_MODULE_NAME) { module ->
|
||||||
|
buildNetTypesModule(module)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildNetTypesModule(module: ModuleScope) {
|
||||||
|
module.eval(Source(NET_TYPES_MODULE_NAME, net_typesLyng))
|
||||||
|
val enumValues = NetEnumValues.load(module)
|
||||||
|
module.addConst("SocketAddress", ObjSocketAddress.type(enumValues))
|
||||||
|
module.addConst("Datagram", ObjDatagram.type(enumValues))
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun buildNetModule(module: ModuleScope, policy: NetAccessPolicy) {
|
private suspend fun buildNetModule(module: ModuleScope, policy: NetAccessPolicy) {
|
||||||
module.eval(Source(NET_MODULE_NAME, netLyng))
|
module.eval(Source(NET_MODULE_NAME, netLyng))
|
||||||
val engine = getSystemNetEngine()
|
val engine = getSystemNetEngine()
|
||||||
@ -164,10 +182,12 @@ private class ObjSocketAddress(
|
|||||||
override suspend fun defaultToString(scope: Scope): ObjString = ObjString(renderAddress(address))
|
override suspend fun defaultToString(scope: Scope): ObjString = ObjString(renderAddress(address))
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val types = mutableMapOf<NetEnumValues, ObjClass>()
|
private data class EnumKey(val ipv4: Obj, val ipv6: Obj)
|
||||||
|
|
||||||
|
private val types = mutableMapOf<EnumKey, ObjClass>()
|
||||||
|
|
||||||
fun type(enumValues: NetEnumValues): ObjClass =
|
fun type(enumValues: NetEnumValues): ObjClass =
|
||||||
types.getOrPut(enumValues) {
|
types.getOrPut(EnumKey(enumValues.ipv4, enumValues.ipv6)) {
|
||||||
object : ObjClass("SocketAddress") {
|
object : ObjClass("SocketAddress") {
|
||||||
override suspend fun callOn(scope: Scope): Obj {
|
override suspend fun callOn(scope: Scope): Obj {
|
||||||
scope.raiseError("SocketAddress cannot be created directly")
|
scope.raiseError("SocketAddress cannot be created directly")
|
||||||
@ -191,10 +211,12 @@ private class ObjDatagram(
|
|||||||
get() = type(enumValues)
|
get() = type(enumValues)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val types = mutableMapOf<NetEnumValues, ObjClass>()
|
private data class EnumKey(val ipv4: Obj, val ipv6: Obj)
|
||||||
|
|
||||||
|
private val types = mutableMapOf<EnumKey, ObjClass>()
|
||||||
|
|
||||||
fun type(enumValues: NetEnumValues): ObjClass =
|
fun type(enumValues: NetEnumValues): ObjClass =
|
||||||
types.getOrPut(enumValues) {
|
types.getOrPut(EnumKey(enumValues.ipv4, enumValues.ipv6)) {
|
||||||
object : ObjClass("Datagram") {
|
object : ObjClass("Datagram") {
|
||||||
override suspend fun callOn(scope: Scope): Obj {
|
override suspend fun callOn(scope: Scope): Obj {
|
||||||
scope.raiseError("Datagram cannot be created directly")
|
scope.raiseError("Datagram cannot be created directly")
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import net.sergeych.lyng.raiseIllegalOperation
|
|||||||
import net.sergeych.lyng.requireNoArgs
|
import net.sergeych.lyng.requireNoArgs
|
||||||
import net.sergeych.lyng.requireScope
|
import net.sergeych.lyng.requireScope
|
||||||
import net.sergeych.lyngio.stdlib_included.wsLyng
|
import net.sergeych.lyngio.stdlib_included.wsLyng
|
||||||
|
import net.sergeych.lyngio.stdlib_included.ws_typesLyng
|
||||||
import net.sergeych.lyngio.ws.LyngWsEngine
|
import net.sergeych.lyngio.ws.LyngWsEngine
|
||||||
import net.sergeych.lyngio.ws.LyngWsMessage
|
import net.sergeych.lyngio.ws.LyngWsMessage
|
||||||
import net.sergeych.lyngio.ws.LyngWsSession
|
import net.sergeych.lyngio.ws.LyngWsSession
|
||||||
@ -46,6 +47,7 @@ import net.sergeych.lyngio.ws.security.WsAccessOp
|
|||||||
import net.sergeych.lyngio.ws.security.WsAccessPolicy
|
import net.sergeych.lyngio.ws.security.WsAccessPolicy
|
||||||
|
|
||||||
private const val WS_MODULE_NAME = "lyng.io.ws"
|
private const val WS_MODULE_NAME = "lyng.io.ws"
|
||||||
|
internal const val WS_TYPES_MODULE_NAME = "lyng.io.ws.types"
|
||||||
|
|
||||||
fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean =
|
fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean =
|
||||||
createWsModule(policy, scope.importManager)
|
createWsModule(policy, scope.importManager)
|
||||||
@ -53,6 +55,7 @@ fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean =
|
|||||||
fun createWs(policy: WsAccessPolicy, scope: Scope): Boolean = createWsModule(policy, scope)
|
fun createWs(policy: WsAccessPolicy, scope: Scope): Boolean = createWsModule(policy, scope)
|
||||||
|
|
||||||
fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
|
fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
|
||||||
|
createWsTypesModule(manager)
|
||||||
if (manager.packageNames.contains(WS_MODULE_NAME)) return false
|
if (manager.packageNames.contains(WS_MODULE_NAME)) return false
|
||||||
manager.addPackage(WS_MODULE_NAME) { module ->
|
manager.addPackage(WS_MODULE_NAME) { module ->
|
||||||
buildWsModule(module, policy)
|
buildWsModule(module, policy)
|
||||||
@ -62,6 +65,19 @@ fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
|
|||||||
|
|
||||||
fun createWs(policy: WsAccessPolicy, manager: ImportManager): Boolean = createWsModule(policy, manager)
|
fun createWs(policy: WsAccessPolicy, manager: ImportManager): Boolean = createWsModule(policy, manager)
|
||||||
|
|
||||||
|
internal fun createWsTypesModule(manager: ImportManager): Boolean {
|
||||||
|
if (manager.packageNames.contains(WS_TYPES_MODULE_NAME)) return false
|
||||||
|
manager.addPackage(WS_TYPES_MODULE_NAME) { module ->
|
||||||
|
buildWsTypesModule(module)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildWsTypesModule(module: ModuleScope) {
|
||||||
|
module.eval(Source(WS_TYPES_MODULE_NAME, ws_typesLyng))
|
||||||
|
module.addConst("WsMessage", ObjWsMessage.type)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun buildWsModule(module: ModuleScope, policy: WsAccessPolicy) {
|
private suspend fun buildWsModule(module: ModuleScope, policy: WsAccessPolicy) {
|
||||||
module.eval(Source(WS_MODULE_NAME, wsLyng))
|
module.eval(Source(WS_MODULE_NAME, wsLyng))
|
||||||
val engine = getSystemWsEngine()
|
val engine = getSystemWsEngine()
|
||||||
@ -92,7 +108,7 @@ private suspend inline fun ScopeFacade.wsGuard(crossinline block: suspend () ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ObjWsMessage(
|
internal class ObjWsMessage(
|
||||||
private val message: LyngWsMessage,
|
private val message: LyngWsMessage,
|
||||||
) : Obj() {
|
) : Obj() {
|
||||||
override val objClass: ObjClass
|
override val objClass: ObjClass
|
||||||
@ -112,6 +128,8 @@ private class ObjWsMessage(
|
|||||||
thisAs<ObjWsMessage>().message.data?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull
|
thisAs<ObjWsMessage>().message.data?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun from(message: LyngWsMessage): ObjWsMessage = ObjWsMessage(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,7 +170,7 @@ private class ObjWsSession(
|
|||||||
addFn("receive") {
|
addFn("receive") {
|
||||||
val self = thisAs<ObjWsSession>()
|
val self = thisAs<ObjWsSession>()
|
||||||
self.policy.require(WsAccessOp.Receive(self.targetUrl))
|
self.policy.require(WsAccessOp.Receive(self.targetUrl))
|
||||||
self.session.receive()?.let(::ObjWsMessage) ?: ObjNull
|
self.session.receive()?.let(ObjWsMessage::from) ?: ObjNull
|
||||||
}
|
}
|
||||||
addFn("close") {
|
addFn("close") {
|
||||||
val self = thisAs<ObjWsSession>()
|
val self = thisAs<ObjWsSession>()
|
||||||
|
|||||||
@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,181 @@
|
|||||||
|
package net.sergeych.lyngio.http.server
|
||||||
|
|
||||||
|
internal class HttpProtocolException(
|
||||||
|
val status: Int,
|
||||||
|
message: String,
|
||||||
|
) : IllegalStateException(message)
|
||||||
|
|
||||||
|
internal suspend fun parseHttpRequest(
|
||||||
|
reader: BufferedSocketReader,
|
||||||
|
config: HttpServerConfig,
|
||||||
|
): HttpRequest? {
|
||||||
|
val requestLine = reader.readLine(
|
||||||
|
maxBytes = config.maxRequestLineBytes,
|
||||||
|
overflowStatus = 414,
|
||||||
|
overflowMessage = "request line is too long",
|
||||||
|
) ?: return null
|
||||||
|
val requestHead = parseRequestLine(requestLine, config)
|
||||||
|
val headerEntries = parseHeaders(reader, config)
|
||||||
|
val headers = HttpHeaders(headerEntries)
|
||||||
|
validateHost(headers)
|
||||||
|
val contentLength = parseContentLength(headers, config)
|
||||||
|
validateUnsupportedRequestFeatures(headers)
|
||||||
|
val wantsWebSocketUpgrade = isWebSocketUpgrade(requestHead.method, headers)
|
||||||
|
validateWebSocketUpgradeRequest(headers, requestHead.method, contentLength, wantsWebSocketUpgrade)
|
||||||
|
val body = if (contentLength != null) {
|
||||||
|
reader.readExact(contentLength)
|
||||||
|
?: throw HttpProtocolException(400, "unexpected EOF while reading request body")
|
||||||
|
} else {
|
||||||
|
ByteArray(0)
|
||||||
|
}
|
||||||
|
return HttpRequest(
|
||||||
|
head = HttpRequestHead(
|
||||||
|
method = requestHead.method,
|
||||||
|
target = requestHead.target,
|
||||||
|
path = requestHead.path,
|
||||||
|
query = requestHead.query,
|
||||||
|
version = requestHead.version,
|
||||||
|
headers = headers,
|
||||||
|
contentLength = contentLength,
|
||||||
|
wantsClose = headers.containsToken("Connection", "close"),
|
||||||
|
wantsWebSocketUpgrade = wantsWebSocketUpgrade,
|
||||||
|
),
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ParsedRequestLine(
|
||||||
|
val method: String,
|
||||||
|
val target: String,
|
||||||
|
val path: String,
|
||||||
|
val query: String?,
|
||||||
|
val version: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseRequestLine(line: String, config: HttpServerConfig): ParsedRequestLine {
|
||||||
|
val firstSpace = line.indexOf(' ')
|
||||||
|
val lastSpace = line.lastIndexOf(' ')
|
||||||
|
if (firstSpace <= 0 || lastSpace <= firstSpace || lastSpace == line.lastIndex) {
|
||||||
|
throw HttpProtocolException(400, "malformed request line")
|
||||||
|
}
|
||||||
|
val method = line.substring(0, firstSpace)
|
||||||
|
val target = line.substring(firstSpace + 1, lastSpace)
|
||||||
|
val version = line.substring(lastSpace + 1)
|
||||||
|
if (!method.all(::isHttpTokenChar)) {
|
||||||
|
throw HttpProtocolException(400, "invalid HTTP method")
|
||||||
|
}
|
||||||
|
if (version != "HTTP/1.1") {
|
||||||
|
throw HttpProtocolException(505, "unsupported HTTP version: $version")
|
||||||
|
}
|
||||||
|
if (target.length > config.maxRequestLineBytes) {
|
||||||
|
throw HttpProtocolException(414, "request target is too long")
|
||||||
|
}
|
||||||
|
if (!target.startsWith('/')) {
|
||||||
|
throw HttpProtocolException(400, "only origin-form request targets are supported")
|
||||||
|
}
|
||||||
|
val queryAt = target.indexOf('?')
|
||||||
|
val path = if (queryAt >= 0) target.substring(0, queryAt) else target
|
||||||
|
val query = if (queryAt >= 0) target.substring(queryAt + 1) else null
|
||||||
|
return ParsedRequestLine(method = method, target = target, path = path, query = query, version = version)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun parseHeaders(
|
||||||
|
reader: BufferedSocketReader,
|
||||||
|
config: HttpServerConfig,
|
||||||
|
): List<HttpHeader> {
|
||||||
|
val headers = ArrayList<HttpHeader>()
|
||||||
|
var totalBytes = 0
|
||||||
|
while (true) {
|
||||||
|
val line = reader.readLine(
|
||||||
|
maxBytes = config.maxHeaderBytes,
|
||||||
|
overflowStatus = 431,
|
||||||
|
overflowMessage = "request headers are too large",
|
||||||
|
)
|
||||||
|
?: throw HttpProtocolException(400, "unexpected EOF while reading headers")
|
||||||
|
totalBytes += line.length + 2
|
||||||
|
if (totalBytes > config.maxHeaderBytes) {
|
||||||
|
throw HttpProtocolException(431, "request headers are too large")
|
||||||
|
}
|
||||||
|
if (line.isEmpty()) return headers
|
||||||
|
if (line.firstOrNull() == ' ' || line.firstOrNull() == '\t') {
|
||||||
|
throw HttpProtocolException(400, "obsolete folded headers are not supported")
|
||||||
|
}
|
||||||
|
val colonAt = line.indexOf(':')
|
||||||
|
if (colonAt <= 0) throw HttpProtocolException(400, "invalid header syntax")
|
||||||
|
val name = line.substring(0, colonAt)
|
||||||
|
if (!name.all(::isHttpTokenChar)) {
|
||||||
|
throw HttpProtocolException(400, "invalid header name: $name")
|
||||||
|
}
|
||||||
|
val value = line.substring(colonAt + 1).trim(' ', '\t')
|
||||||
|
if (value.any { it == '\r' || it == '\n' || it.code < 0x20 && it != '\t' }) {
|
||||||
|
throw HttpProtocolException(400, "invalid header value")
|
||||||
|
}
|
||||||
|
headers += HttpHeader(name, value)
|
||||||
|
if (headers.size > config.maxHeaderCount) {
|
||||||
|
throw HttpProtocolException(431, "too many headers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateHost(headers: HttpHeaders) {
|
||||||
|
val values = headers.all("Host").map(String::trim)
|
||||||
|
if (values.isEmpty()) throw HttpProtocolException(400, "Host header is required")
|
||||||
|
if (values.distinct().size > 1) throw HttpProtocolException(400, "conflicting Host header values")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseContentLength(headers: HttpHeaders, config: HttpServerConfig): Int? {
|
||||||
|
val values = headers.all("Content-Length")
|
||||||
|
if (values.isEmpty()) return null
|
||||||
|
val normalized = values.flatMap { raw -> raw.split(',').map(String::trim) }
|
||||||
|
if (normalized.any { it.isEmpty() }) throw HttpProtocolException(400, "invalid Content-Length")
|
||||||
|
val distinct = normalized.distinct()
|
||||||
|
if (distinct.size > 1) throw HttpProtocolException(400, "conflicting Content-Length values")
|
||||||
|
val parsed = distinct.single().toLongOrNull() ?: throw HttpProtocolException(400, "invalid Content-Length")
|
||||||
|
if (parsed < 0L || parsed > Int.MAX_VALUE.toLong()) throw HttpProtocolException(400, "invalid Content-Length")
|
||||||
|
if (parsed > config.maxBodyBytes.toLong()) throw HttpProtocolException(413, "request body is too large")
|
||||||
|
return parsed.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateUnsupportedRequestFeatures(headers: HttpHeaders) {
|
||||||
|
if (headers.all("Transfer-Encoding").isNotEmpty()) {
|
||||||
|
throw HttpProtocolException(501, "Transfer-Encoding is not supported")
|
||||||
|
}
|
||||||
|
if (headers.first("Expect")?.equals("100-continue", ignoreCase = true) == true) {
|
||||||
|
throw HttpProtocolException(501, "Expect: 100-continue is not supported")
|
||||||
|
}
|
||||||
|
val upgrade = headers.first("Upgrade")
|
||||||
|
if (upgrade != null && !upgrade.equals("websocket", ignoreCase = true)) {
|
||||||
|
throw HttpProtocolException(501, "unsupported Upgrade value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isWebSocketUpgrade(method: String, headers: HttpHeaders): Boolean =
|
||||||
|
method.equals("GET", ignoreCase = true) &&
|
||||||
|
headers.first("Upgrade")?.equals("websocket", ignoreCase = true) == true &&
|
||||||
|
headers.containsToken("Connection", "upgrade")
|
||||||
|
|
||||||
|
private fun validateWebSocketUpgradeRequest(
|
||||||
|
headers: HttpHeaders,
|
||||||
|
method: String,
|
||||||
|
contentLength: Int?,
|
||||||
|
wantsWebSocketUpgrade: Boolean,
|
||||||
|
) {
|
||||||
|
if (!wantsWebSocketUpgrade) return
|
||||||
|
if (!method.equals("GET", ignoreCase = true)) {
|
||||||
|
throw HttpProtocolException(400, "websocket upgrade requires GET")
|
||||||
|
}
|
||||||
|
if (contentLength != null && contentLength != 0) {
|
||||||
|
throw HttpProtocolException(400, "websocket upgrade request must not include a body")
|
||||||
|
}
|
||||||
|
if (headers.first("Sec-WebSocket-Key").isNullOrBlank()) {
|
||||||
|
throw HttpProtocolException(400, "missing Sec-WebSocket-Key")
|
||||||
|
}
|
||||||
|
if (headers.first("Sec-WebSocket-Version") != "13") {
|
||||||
|
throw HttpProtocolException(400, "unsupported Sec-WebSocket-Version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isHttpTokenChar(ch: Char): Boolean =
|
||||||
|
ch in '0'..'9' || ch in 'A'..'Z' || ch in 'a'..'z' || ch in setOf(
|
||||||
|
'!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~'
|
||||||
|
)
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
package net.sergeych.lyngio.http.server
|
||||||
|
|
||||||
|
import net.sergeych.lyngio.net.LyngSocketAddress
|
||||||
|
import net.sergeych.lyngio.ws.LyngWsMessage
|
||||||
|
|
||||||
|
internal data class HttpServerConfig(
|
||||||
|
val host: String? = "127.0.0.1",
|
||||||
|
val port: Int = 0,
|
||||||
|
val backlog: Int = 128,
|
||||||
|
val reuseAddress: Boolean = true,
|
||||||
|
val maxRequestLineBytes: Int = 8 * 1024,
|
||||||
|
val maxHeaderBytes: Int = 32 * 1024,
|
||||||
|
val maxHeaderCount: Int = 100,
|
||||||
|
val maxBodyBytes: Int = 1024 * 1024,
|
||||||
|
val keepAliveTimeoutMillis: Long = 15_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class HttpHeader(
|
||||||
|
val name: String,
|
||||||
|
val value: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal class HttpHeaders(
|
||||||
|
private val headerEntries: List<HttpHeader>,
|
||||||
|
) {
|
||||||
|
fun first(name: String): String? =
|
||||||
|
headerEntries.firstOrNull { it.name.equals(name, ignoreCase = true) }?.value
|
||||||
|
|
||||||
|
fun all(name: String): List<String> =
|
||||||
|
headerEntries.filter { it.name.equals(name, ignoreCase = true) }.map(HttpHeader::value)
|
||||||
|
|
||||||
|
fun containsToken(name: String, token: String): Boolean =
|
||||||
|
all(name).flatMap { value -> value.split(',') }
|
||||||
|
.any { it.trim().equals(token, ignoreCase = true) }
|
||||||
|
|
||||||
|
fun entries(): List<HttpHeader> = headerEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class HttpRequestHead(
|
||||||
|
val method: String,
|
||||||
|
val target: String,
|
||||||
|
val path: String,
|
||||||
|
val query: String?,
|
||||||
|
val version: String,
|
||||||
|
val headers: HttpHeaders,
|
||||||
|
val contentLength: Int?,
|
||||||
|
val wantsClose: Boolean,
|
||||||
|
val wantsWebSocketUpgrade: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class HttpRequest(
|
||||||
|
val head: HttpRequestHead,
|
||||||
|
val body: ByteArray,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class HttpResponse(
|
||||||
|
val status: Int,
|
||||||
|
val reason: String = defaultReason(status),
|
||||||
|
val headers: List<HttpHeader> = emptyList(),
|
||||||
|
val body: ByteArray = ByteArray(0),
|
||||||
|
val close: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal interface HttpWebSocketSession {
|
||||||
|
fun isOpen(): Boolean
|
||||||
|
suspend fun sendText(text: String)
|
||||||
|
suspend fun sendBytes(data: ByteArray)
|
||||||
|
suspend fun receive(): LyngWsMessage?
|
||||||
|
suspend fun close(code: Int = 1000, reason: String = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed interface HttpHandlerResult {
|
||||||
|
data class Response(val response: HttpResponse) : HttpHandlerResult
|
||||||
|
data class WebSocket(val handler: suspend (HttpWebSocketSession) -> Unit) : HttpHandlerResult
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun interface HttpHandler {
|
||||||
|
suspend fun handle(request: HttpRequest): HttpHandlerResult
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface HttpServer {
|
||||||
|
fun isOpen(): Boolean
|
||||||
|
fun localAddress(): LyngSocketAddress
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun defaultReason(status: Int): String = when (status) {
|
||||||
|
101 -> "Switching Protocols"
|
||||||
|
200 -> "OK"
|
||||||
|
204 -> "No Content"
|
||||||
|
400 -> "Bad Request"
|
||||||
|
404 -> "Not Found"
|
||||||
|
413 -> "Payload Too Large"
|
||||||
|
414 -> "URI Too Long"
|
||||||
|
426 -> "Upgrade Required"
|
||||||
|
431 -> "Request Header Fields Too Large"
|
||||||
|
500 -> "Internal Server Error"
|
||||||
|
501 -> "Not Implemented"
|
||||||
|
505 -> "HTTP Version Not Supported"
|
||||||
|
else -> "HTTP $status"
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package net.sergeych.lyngio.http.server
|
||||||
|
|
||||||
|
import net.sergeych.lyngio.net.LyngTcpSocket
|
||||||
|
|
||||||
|
internal suspend fun writeHttpResponse(
|
||||||
|
socket: LyngTcpSocket,
|
||||||
|
response: HttpResponse,
|
||||||
|
closeConnection: Boolean,
|
||||||
|
) {
|
||||||
|
val body = response.body
|
||||||
|
val headerLines = LinkedHashMap<String, MutableList<String>>()
|
||||||
|
response.headers.forEach { header ->
|
||||||
|
headerLines.getOrPut(header.name) { mutableListOf() }.add(header.value)
|
||||||
|
}
|
||||||
|
if (headerLines.keys.none { it.equals("Content-Length", ignoreCase = true) }) {
|
||||||
|
headerLines["Content-Length"] = mutableListOf(body.size.toString())
|
||||||
|
}
|
||||||
|
if (closeConnection) {
|
||||||
|
val connectionKey = headerLines.keys.firstOrNull { it.equals("Connection", ignoreCase = true) }
|
||||||
|
if (connectionKey != null) {
|
||||||
|
headerLines.remove(connectionKey)
|
||||||
|
}
|
||||||
|
headerLines["Connection"] = mutableListOf("close")
|
||||||
|
}
|
||||||
|
val head = buildString {
|
||||||
|
append("HTTP/1.1 ")
|
||||||
|
append(response.status)
|
||||||
|
append(' ')
|
||||||
|
append(response.reason)
|
||||||
|
append("\r\n")
|
||||||
|
headerLines.forEach { (name, values) ->
|
||||||
|
values.forEach { value ->
|
||||||
|
append(name)
|
||||||
|
append(": ")
|
||||||
|
append(value)
|
||||||
|
append("\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
append("\r\n")
|
||||||
|
}
|
||||||
|
socket.writeUtf8(head)
|
||||||
|
if (body.isNotEmpty()) socket.write(body)
|
||||||
|
socket.flush()
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
package net.sergeych.lyngio.http.server
|
||||||
|
|
||||||
|
import net.sergeych.lyngio.net.LyngIpVersion
|
||||||
|
import net.sergeych.lyngio.net.LyngSocketAddress
|
||||||
|
import net.sergeych.lyngio.net.LyngTcpSocket
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
|
||||||
|
class HttpParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tooLargeHeadersMapTo431() = kotlinx.coroutines.test.runTest {
|
||||||
|
val request = buildString {
|
||||||
|
append("GET / HTTP/1.1\r\n")
|
||||||
|
append("Host: localhost\r\n")
|
||||||
|
append("X-Big: ")
|
||||||
|
append("a".repeat(64))
|
||||||
|
append("\r\n\r\n")
|
||||||
|
}
|
||||||
|
val error = assertFailsWith<HttpProtocolException> {
|
||||||
|
parse(request, HttpServerConfig(maxHeaderBytes = 32))
|
||||||
|
}
|
||||||
|
assertEquals(431, error.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun conflictingDuplicateHostIsRejected() = kotlinx.coroutines.test.runTest {
|
||||||
|
val error = assertFailsWith<HttpProtocolException> {
|
||||||
|
parse(
|
||||||
|
"GET / HTTP/1.1\r\n" +
|
||||||
|
"Host: one.example\r\n" +
|
||||||
|
"Host: two.example\r\n\r\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(400, error.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun conflictingDuplicateContentLengthIsRejected() = kotlinx.coroutines.test.runTest {
|
||||||
|
val error = assertFailsWith<HttpProtocolException> {
|
||||||
|
parse(
|
||||||
|
"POST /echo HTTP/1.1\r\n" +
|
||||||
|
"Host: localhost\r\n" +
|
||||||
|
"Content-Length: 4\r\n" +
|
||||||
|
"Content-Length: 5\r\n\r\nping!"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(400, error.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun malformedRequestLineIsRejected() = kotlinx.coroutines.test.runTest {
|
||||||
|
val error = assertFailsWith<HttpProtocolException> {
|
||||||
|
parse("GET /only-two-parts\r\nHost: localhost\r\n\r\n")
|
||||||
|
}
|
||||||
|
assertEquals(400, error.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun identicalDuplicateContentLengthIsAccepted() = kotlinx.coroutines.test.runTest {
|
||||||
|
val request = parse(
|
||||||
|
"POST /echo HTTP/1.1\r\n" +
|
||||||
|
"Host: localhost\r\n" +
|
||||||
|
"Content-Length: 4\r\n" +
|
||||||
|
"Content-Length: 4\r\n\r\nping"
|
||||||
|
)
|
||||||
|
assertEquals("POST", request.head.method)
|
||||||
|
assertEquals("/echo", request.head.path)
|
||||||
|
assertEquals(4, request.head.contentLength)
|
||||||
|
assertEquals("ping", request.body.decodeToString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun parse(
|
||||||
|
rawRequest: String,
|
||||||
|
config: HttpServerConfig = HttpServerConfig(),
|
||||||
|
): HttpRequest {
|
||||||
|
val socket = FakeTcpSocket(rawRequest.encodeToByteArray())
|
||||||
|
val reader = BufferedSocketReader(socket)
|
||||||
|
return parseHttpRequest(reader, config) ?: error("expected parsed request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeTcpSocket(
|
||||||
|
source: ByteArray,
|
||||||
|
) : LyngTcpSocket {
|
||||||
|
private var input = source
|
||||||
|
private var output = ByteArray(0)
|
||||||
|
private var open = true
|
||||||
|
|
||||||
|
override fun isOpen(): Boolean = open
|
||||||
|
|
||||||
|
override fun localAddress(): LyngSocketAddress =
|
||||||
|
LyngSocketAddress("127.0.0.1", 8080, LyngIpVersion.IPV4, resolved = true)
|
||||||
|
|
||||||
|
override fun remoteAddress(): LyngSocketAddress =
|
||||||
|
LyngSocketAddress("127.0.0.1", 12345, LyngIpVersion.IPV4, resolved = true)
|
||||||
|
|
||||||
|
override suspend fun read(maxBytes: Int): ByteArray? {
|
||||||
|
if (!open || input.isEmpty()) return null
|
||||||
|
val count = minOf(maxBytes, input.size)
|
||||||
|
val chunk = input.copyOfRange(0, count)
|
||||||
|
input = input.copyOfRange(count, input.size)
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readLine(): String? = error("BufferedSocketReader should not call LyngTcpSocket.readLine()")
|
||||||
|
|
||||||
|
override suspend fun write(data: ByteArray) {
|
||||||
|
output += data
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun writeUtf8(text: String) {
|
||||||
|
output += text.encodeToByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush() = Unit
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
open = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,242 @@
|
|||||||
|
package net.sergeych.lyngio.http.server
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import net.sergeych.lyngio.net.LyngTcpSocket
|
||||||
|
import net.sergeych.lyngio.net.getSystemNetEngine
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class HttpServerLoopbackTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun simpleGetReturnsResponse() = runBlocking {
|
||||||
|
val engine = getSystemNetEngine()
|
||||||
|
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
|
||||||
|
|
||||||
|
withTimeout(10_000) {
|
||||||
|
val server = startHttpServer { request ->
|
||||||
|
HttpHandlerResult.Response(
|
||||||
|
HttpResponse(
|
||||||
|
status = 200,
|
||||||
|
headers = listOf(HttpHeader("Content-Type", "text/plain")),
|
||||||
|
body = "hello:${request.head.path}".encodeToByteArray(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val port = waitForPort(server)
|
||||||
|
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
|
||||||
|
try {
|
||||||
|
client.writeUtf8("GET /demo HTTP/1.1\r\nHost: localhost\r\n\r\n")
|
||||||
|
client.flush()
|
||||||
|
val text = readHttpResponse(client)
|
||||||
|
assertTrue(text.startsWith("HTTP/1.1 200 OK\r\n"), text)
|
||||||
|
assertTrue(text.contains("Content-Type: text/plain\r\n"), text)
|
||||||
|
assertTrue(text.endsWith("hello:/demo"), text)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keepAliveServesTwoRequestsOnOneSocket() = runBlocking {
|
||||||
|
val engine = getSystemNetEngine()
|
||||||
|
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
|
||||||
|
|
||||||
|
withTimeout(10_000) {
|
||||||
|
val server = startHttpServer { request ->
|
||||||
|
HttpHandlerResult.Response(
|
||||||
|
HttpResponse(status = 200, body = request.head.path.encodeToByteArray())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val port = waitForPort(server)
|
||||||
|
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
|
||||||
|
try {
|
||||||
|
client.writeUtf8(
|
||||||
|
"GET /one HTTP/1.1\r\nHost: localhost\r\n\r\n" +
|
||||||
|
"GET /two HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
client.flush()
|
||||||
|
val first = readHttpResponse(client)
|
||||||
|
val second = readHttpResponse(client)
|
||||||
|
assertTrue(first.endsWith("/one"), first)
|
||||||
|
assertTrue(second.contains("Connection: close\r\n"), second)
|
||||||
|
assertTrue(second.endsWith("/two"), second)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun postWithContentLengthReadsBody() = runBlocking {
|
||||||
|
val engine = getSystemNetEngine()
|
||||||
|
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
|
||||||
|
|
||||||
|
withTimeout(10_000) {
|
||||||
|
val server = startHttpServer { request ->
|
||||||
|
HttpHandlerResult.Response(
|
||||||
|
HttpResponse(status = 200, body = (request.head.method + ":" + request.body.decodeToString()).encodeToByteArray())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val port = waitForPort(server)
|
||||||
|
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
|
||||||
|
try {
|
||||||
|
client.writeUtf8(
|
||||||
|
"POST /echo HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\nConnection: close\r\n\r\nping"
|
||||||
|
)
|
||||||
|
client.flush()
|
||||||
|
val text = readHttpResponse(client)
|
||||||
|
assertTrue(text.endsWith("POST:ping"), text)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun transferEncodingIsRejected() = runBlocking {
|
||||||
|
val engine = getSystemNetEngine()
|
||||||
|
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
|
||||||
|
|
||||||
|
withTimeout(10_000) {
|
||||||
|
val server = startHttpServer { _ ->
|
||||||
|
HttpHandlerResult.Response(HttpResponse(status = 200, body = "ok".encodeToByteArray()))
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val port = waitForPort(server)
|
||||||
|
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
|
||||||
|
try {
|
||||||
|
client.writeUtf8(
|
||||||
|
"POST /x HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||||
|
)
|
||||||
|
client.flush()
|
||||||
|
val text = readHttpResponse(client)
|
||||||
|
assertTrue(text.startsWith("HTTP/1.1 501 Not Implemented\r\n"), text)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun websocketUpgradeEchoesText() = runBlocking {
|
||||||
|
val engine = getSystemNetEngine()
|
||||||
|
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
|
||||||
|
|
||||||
|
withTimeout(10_000) {
|
||||||
|
val server = startHttpServer { request ->
|
||||||
|
if (request.head.path != "/ws") {
|
||||||
|
HttpHandlerResult.Response(HttpResponse(status = 404, close = true))
|
||||||
|
} else {
|
||||||
|
HttpHandlerResult.WebSocket { session ->
|
||||||
|
val message = session.receive() ?: return@WebSocket
|
||||||
|
session.sendText("echo:${message.text}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val port = waitForPort(server)
|
||||||
|
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
|
||||||
|
try {
|
||||||
|
val key = "dGhlIHNhbXBsZSBub25jZQ=="
|
||||||
|
client.writeUtf8(
|
||||||
|
"GET /ws HTTP/1.1\r\n" +
|
||||||
|
"Host: localhost\r\n" +
|
||||||
|
"Upgrade: websocket\r\n" +
|
||||||
|
"Connection: Upgrade\r\n" +
|
||||||
|
"Sec-WebSocket-Key: $key\r\n" +
|
||||||
|
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||||
|
)
|
||||||
|
client.flush()
|
||||||
|
val headers = ArrayList<String>()
|
||||||
|
while (true) {
|
||||||
|
val line = client.readLine() ?: break
|
||||||
|
if (line.isEmpty()) break
|
||||||
|
headers += line
|
||||||
|
}
|
||||||
|
assertEquals("HTTP/1.1 101 Switching Protocols", headers.first())
|
||||||
|
sendMaskedTextFrame(client, "ping")
|
||||||
|
val reply = readServerTextFrame(client)
|
||||||
|
assertEquals("echo:ping", reply)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun waitForPort(server: HttpServer): Int {
|
||||||
|
repeat(100) {
|
||||||
|
runCatching { return server.localAddress().port }
|
||||||
|
kotlinx.coroutines.delay(10)
|
||||||
|
}
|
||||||
|
error("server did not bind in time")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readHttpResponse(client: LyngTcpSocket): String {
|
||||||
|
val statusLine = client.readLine() ?: error("missing status line")
|
||||||
|
val headers = linkedMapOf<String, String>()
|
||||||
|
while (true) {
|
||||||
|
val line = client.readLine() ?: error("unexpected EOF in response headers")
|
||||||
|
if (line.isEmpty()) break
|
||||||
|
val colonAt = line.indexOf(':')
|
||||||
|
if (colonAt > 0) headers[line.substring(0, colonAt)] = line.substring(colonAt + 1).trim()
|
||||||
|
}
|
||||||
|
val bodyLength = headers["Content-Length"]?.toIntOrNull() ?: 0
|
||||||
|
val body = if (bodyLength > 0) readExact(client, bodyLength).decodeToString() else ""
|
||||||
|
return buildString {
|
||||||
|
append(statusLine).append("\r\n")
|
||||||
|
headers.forEach { (name, value) -> append(name).append(": ").append(value).append("\r\n") }
|
||||||
|
append("\r\n")
|
||||||
|
append(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendMaskedTextFrame(client: LyngTcpSocket, text: String) {
|
||||||
|
val payload = text.encodeToByteArray()
|
||||||
|
val mask = byteArrayOf(1, 2, 3, 4)
|
||||||
|
val masked = payload.copyOf()
|
||||||
|
masked.indices.forEach { index ->
|
||||||
|
masked[index] = (masked[index].toInt() xor mask[index % mask.size].toInt()).toByte()
|
||||||
|
}
|
||||||
|
val frame = byteArrayOf(0x81.toByte(), (0x80 or payload.size).toByte()) + mask + masked
|
||||||
|
client.write(frame)
|
||||||
|
client.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readServerTextFrame(client: LyngTcpSocket): String {
|
||||||
|
val head = readExact(client, 2)
|
||||||
|
val len = head[1].toInt() and 0x7f
|
||||||
|
val payload = if (len > 0) readExact(client, len) else ByteArray(0)
|
||||||
|
return payload.decodeToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readExact(client: LyngTcpSocket, size: Int): ByteArray {
|
||||||
|
var pending = ByteArray(0)
|
||||||
|
while (pending.size < size) {
|
||||||
|
val chunk = client.read(size - pending.size) ?: error("unexpected EOF")
|
||||||
|
pending += chunk
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
package net.sergeych.lyng.io.http.server
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import net.sergeych.lyng.Compiler
|
||||||
|
import net.sergeych.lyng.Pos
|
||||||
|
import net.sergeych.lyng.Script
|
||||||
|
import net.sergeych.lyng.io.http.server.createHttpServerModule
|
||||||
|
import net.sergeych.lyng.io.ws.createWsModule
|
||||||
|
import net.sergeych.lyng.obj.Obj
|
||||||
|
import net.sergeych.lyng.obj.ObjInt
|
||||||
|
import net.sergeych.lyng.io.http.createHttpModule
|
||||||
|
import net.sergeych.lyngio.net.getSystemNetEngine
|
||||||
|
import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy
|
||||||
|
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
|
||||||
|
import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class LyngHttpServerModuleTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serverModuleReusesSharedHttpHeadersRuntimeType() = runBlocking {
|
||||||
|
val scope = Script.newScope()
|
||||||
|
createHttpModule(PermitAllHttpAccessPolicy, scope)
|
||||||
|
createWsModule(PermitAllWsAccessPolicy, scope)
|
||||||
|
createHttpServerModule(PermitAllNetAccessPolicy, scope)
|
||||||
|
|
||||||
|
val httpModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.http")
|
||||||
|
val wsModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.ws")
|
||||||
|
val serverModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.http.server")
|
||||||
|
val sharedTypesModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.http.types")
|
||||||
|
val sharedWsTypesModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.ws.types")
|
||||||
|
|
||||||
|
assertSame(sharedTypesModule.get("HttpHeaders")?.value, httpModule.get("HttpHeaders")?.value)
|
||||||
|
assertSame(sharedTypesModule.get("HttpHeaders")?.value, serverModule.get("HttpHeaders")?.value)
|
||||||
|
assertSame(sharedWsTypesModule.get("WsMessage")?.value, wsModule.get("WsMessage")?.value)
|
||||||
|
assertSame(sharedWsTypesModule.get("WsMessage")?.value, serverModule.get("WsMessage")?.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun exactRouteAndFallbackWork() = runBlocking {
|
||||||
|
val engine = getSystemNetEngine()
|
||||||
|
if (!engine.isSupported || !engine.isTcpAvailable) return@runBlocking
|
||||||
|
|
||||||
|
val scope = Script.newScope()
|
||||||
|
createHttpServerModule(PermitAllNetAccessPolicy, scope)
|
||||||
|
|
||||||
|
val code = """
|
||||||
|
import lyng.io.http.server
|
||||||
|
|
||||||
|
val server = HttpServer()
|
||||||
|
server.get("/hello") { ex ->
|
||||||
|
ex.setHeader("Content-Type", "text/plain")
|
||||||
|
ex.respondText(200, "hello from lyng")
|
||||||
|
}
|
||||||
|
server.fallback { ex ->
|
||||||
|
ex.respondText(404, "miss:" + ex.request.path)
|
||||||
|
}
|
||||||
|
server.listen(0, "127.0.0.1")
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val handle = Compiler.compile(code).execute(scope)
|
||||||
|
val port = waitForPort(handle, scope)
|
||||||
|
|
||||||
|
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
|
||||||
|
try {
|
||||||
|
client.writeUtf8("GET /hello HTTP/1.1\r\nHost: localhost\r\n\r\n")
|
||||||
|
client.flush()
|
||||||
|
val hello = readHttpResponse(client)
|
||||||
|
assertTrue(hello.contains("200 OK"), hello)
|
||||||
|
assertTrue(hello.endsWith("hello from lyng"), hello)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val client2 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
|
||||||
|
try {
|
||||||
|
client2.writeUtf8("GET /other HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
|
||||||
|
client2.flush()
|
||||||
|
val miss = readHttpResponse(client2)
|
||||||
|
assertTrue(miss.contains("404"), miss)
|
||||||
|
assertTrue(miss.endsWith("miss:/other"), miss)
|
||||||
|
} finally {
|
||||||
|
client2.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.invokeInstanceMethod(scope, "close")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun waitForPort(handle: Obj, scope: net.sergeych.lyng.Scope): Int {
|
||||||
|
repeat(100) {
|
||||||
|
val port = runCatching {
|
||||||
|
val value = handle.invokeInstanceMethod(scope, "localPort")
|
||||||
|
(value as ObjInt).value.toInt()
|
||||||
|
}.getOrNull()
|
||||||
|
if (port != null && port > 0) return port
|
||||||
|
delay(10)
|
||||||
|
}
|
||||||
|
error("server did not bind in time")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readHttpResponse(client: net.sergeych.lyngio.net.LyngTcpSocket): String {
|
||||||
|
val statusLine = client.readLine() ?: error("missing status line")
|
||||||
|
val headers = linkedMapOf<String, String>()
|
||||||
|
while (true) {
|
||||||
|
val line = client.readLine() ?: error("unexpected EOF in response headers")
|
||||||
|
if (line.isEmpty()) break
|
||||||
|
val colonAt = line.indexOf(':')
|
||||||
|
if (colonAt > 0) headers[line.substring(0, colonAt)] = line.substring(colonAt + 1).trim()
|
||||||
|
}
|
||||||
|
val bodyLength = headers["Content-Length"]?.toIntOrNull() ?: 0
|
||||||
|
val body = if (bodyLength > 0) readExact(client, bodyLength).decodeToString() else ""
|
||||||
|
return buildString {
|
||||||
|
append(statusLine).append("\r\n")
|
||||||
|
headers.forEach { (name, value) -> append(name).append(": ").append(value).append("\r\n") }
|
||||||
|
append("\r\n")
|
||||||
|
append(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readExact(client: net.sergeych.lyngio.net.LyngTcpSocket, size: Int): ByteArray {
|
||||||
|
var pending = ByteArray(0)
|
||||||
|
while (pending.size < size) {
|
||||||
|
val chunk = client.read(size - pending.size) ?: error("unexpected EOF")
|
||||||
|
pending += chunk
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ import kotlinx.coroutines.runBlocking
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import net.sergeych.lyng.Compiler
|
import net.sergeych.lyng.Compiler
|
||||||
import net.sergeych.lyng.ExecutionError
|
import net.sergeych.lyng.ExecutionError
|
||||||
|
import net.sergeych.lyng.Pos
|
||||||
import net.sergeych.lyng.Script
|
import net.sergeych.lyng.Script
|
||||||
import net.sergeych.lyngio.fs.security.AccessContext
|
import net.sergeych.lyngio.fs.security.AccessContext
|
||||||
import net.sergeych.lyngio.fs.security.AccessDecision
|
import net.sergeych.lyngio.fs.security.AccessDecision
|
||||||
@ -35,11 +36,25 @@ import java.net.ServerSocket
|
|||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertSame
|
||||||
import kotlin.test.assertFailsWith
|
import kotlin.test.assertFailsWith
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class LyngNetModuleTest {
|
class LyngNetModuleTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSharedNetTypesModuleExportsCanonicalTypes() = runBlocking {
|
||||||
|
val scope = Script.newScope()
|
||||||
|
createNetModule(PermitAllNetAccessPolicy, scope)
|
||||||
|
|
||||||
|
val netModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.net")
|
||||||
|
val typesModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.net.types")
|
||||||
|
|
||||||
|
assertSame(typesModule.get("IpVersion")?.value, netModule.get("IpVersion")?.value)
|
||||||
|
assertSame(typesModule.get("SocketAddress")?.value, netModule.get("SocketAddress")?.value)
|
||||||
|
assertSame(typesModule.get("Datagram")?.value, netModule.get("Datagram")?.value)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testResolveAndCapabilities() = runBlocking {
|
fun testResolveAndCapabilities() = runBlocking {
|
||||||
val scope = Script.newScope()
|
val scope = Script.newScope()
|
||||||
|
|||||||
@ -1,17 +1,6 @@
|
|||||||
package lyng.io.http
|
package lyng.io.http
|
||||||
|
|
||||||
/*
|
import lyng.io.http.types
|
||||||
Response/header view that behaves like a map for the first value of each header name.
|
|
||||||
Multi-valued headers are exposed through `getAll`.
|
|
||||||
*/
|
|
||||||
extern class HttpHeaders : Map<String, String> {
|
|
||||||
/* Return the first value for the given header name, or null when absent. */
|
|
||||||
fun get(name: String): String?
|
|
||||||
/* Return all values for the given header name, preserving wire order when available. */
|
|
||||||
fun getAll(name: String): List<String>
|
|
||||||
/* Return distinct header names present in this response. */
|
|
||||||
fun names(): List<String>
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mutable request descriptor for programmatic HTTP calls. */
|
/* Mutable request descriptor for programmatic HTTP calls. */
|
||||||
extern class HttpRequest {
|
extern class HttpRequest {
|
||||||
|
|||||||
54
lyngio/stdlib/lyng/io/http_server.lyng
Normal file
54
lyngio/stdlib/lyng/io/http_server.lyng
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package lyng.io.http.server
|
||||||
|
|
||||||
|
import lyng.io.http.types
|
||||||
|
import lyng.io.ws.types
|
||||||
|
|
||||||
|
/* Immutable parsed incoming server request. */
|
||||||
|
extern class ServerRequest {
|
||||||
|
val method: String
|
||||||
|
val target: String
|
||||||
|
val path: String
|
||||||
|
val query: String?
|
||||||
|
val headers: HttpHeaders
|
||||||
|
val body: Buffer
|
||||||
|
fun text(): String
|
||||||
|
fun isWebSocketUpgrade(): Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active server-side WebSocket session. */
|
||||||
|
extern class ServerWebSocket {
|
||||||
|
fun isOpen(): Bool
|
||||||
|
fun sendText(text: String): void
|
||||||
|
fun sendBytes(data: Buffer): void
|
||||||
|
fun receive(): WsMessage?
|
||||||
|
fun close(code: Int = 1000, reason: String = ""): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mutable exchange object for one incoming request. */
|
||||||
|
extern class ServerExchange {
|
||||||
|
val request: ServerRequest
|
||||||
|
fun respond(status: Int = 200, body: Buffer? = null): void
|
||||||
|
fun respondText(status: Int = 200, bodyText: String = ""): void
|
||||||
|
fun setHeader(name: String, value: String): void
|
||||||
|
fun addHeader(name: String, value: String): void
|
||||||
|
fun acceptWebSocket(handler: (ServerWebSocket, ServerExchange) -> Object?): void
|
||||||
|
fun isHandled(): Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Running listener handle. */
|
||||||
|
extern class HttpServerHandle {
|
||||||
|
fun localPort(): Int
|
||||||
|
fun close(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Exact-path HTTP/WebSocket server with built-in router. */
|
||||||
|
extern class HttpServer {
|
||||||
|
fun get(path: String, handler: (ServerExchange) -> Object?): HttpServer
|
||||||
|
fun post(path: String, handler: (ServerExchange) -> Object?): HttpServer
|
||||||
|
fun put(path: String, handler: (ServerExchange) -> Object?): HttpServer
|
||||||
|
fun delete(path: String, handler: (ServerExchange) -> Object?): HttpServer
|
||||||
|
fun any(path: String, handler: (ServerExchange) -> Object?): HttpServer
|
||||||
|
fun ws(path: String, handler: (ServerWebSocket, ServerExchange) -> Object?): HttpServer
|
||||||
|
fun fallback(handler: (ServerExchange) -> Object?): HttpServer
|
||||||
|
fun listen(port: Int, host: String? = null, backlog: Int = 128): HttpServerHandle
|
||||||
|
}
|
||||||
14
lyngio/stdlib/lyng/io/http_types.lyng
Normal file
14
lyngio/stdlib/lyng/io/http_types.lyng
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package lyng.io.http.types
|
||||||
|
|
||||||
|
/*
|
||||||
|
Response/header view that behaves like a map for the first value of each header name.
|
||||||
|
Multi-valued headers are exposed through `getAll`.
|
||||||
|
*/
|
||||||
|
extern class HttpHeaders : Map<String, String> {
|
||||||
|
/* Return the first value for the given header name, or null when absent. */
|
||||||
|
fun get(name: String): String?
|
||||||
|
/* Return all values for the given header name, preserving wire order when available. */
|
||||||
|
fun getAll(name: String): List<String>
|
||||||
|
/* Return distinct header names present in this response. */
|
||||||
|
fun names(): List<String>
|
||||||
|
}
|
||||||
@ -1,30 +1,6 @@
|
|||||||
package lyng.io.net
|
package lyng.io.net
|
||||||
|
|
||||||
/* Address family for resolved or bound endpoints. */
|
import lyng.io.net.types
|
||||||
enum IpVersion {
|
|
||||||
IPV4,
|
|
||||||
IPV6
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Concrete socket endpoint. */
|
|
||||||
extern class SocketAddress {
|
|
||||||
/* Numeric or host-form address string. */
|
|
||||||
val host: String
|
|
||||||
/* Transport port number. */
|
|
||||||
val port: Int
|
|
||||||
/* Address family. */
|
|
||||||
val ipVersion: IpVersion
|
|
||||||
/* True when obtained from DNS resolution rather than raw bind input. */
|
|
||||||
val resolved: Bool
|
|
||||||
/* Stable printable form such as `127.0.0.1:4040` or `[::1]:4040`. */
|
|
||||||
override fun toString(): String
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Datagram payload paired with sender/peer address. */
|
|
||||||
extern class Datagram {
|
|
||||||
val data: Buffer
|
|
||||||
val address: SocketAddress
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Connected TCP socket. */
|
/* Connected TCP socket. */
|
||||||
extern class TcpSocket {
|
extern class TcpSocket {
|
||||||
|
|||||||
27
lyngio/stdlib/lyng/io/net_types.lyng
Normal file
27
lyngio/stdlib/lyng/io/net_types.lyng
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package lyng.io.net.types
|
||||||
|
|
||||||
|
/* Address family for resolved or bound endpoints. */
|
||||||
|
enum IpVersion {
|
||||||
|
IPV4,
|
||||||
|
IPV6
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Concrete socket endpoint. */
|
||||||
|
extern class SocketAddress {
|
||||||
|
/* Numeric or host-form address string. */
|
||||||
|
val host: String
|
||||||
|
/* Transport port number. */
|
||||||
|
val port: Int
|
||||||
|
/* Address family. */
|
||||||
|
val ipVersion: IpVersion
|
||||||
|
/* True when obtained from DNS resolution rather than raw bind input. */
|
||||||
|
val resolved: Bool
|
||||||
|
/* Stable printable form such as `127.0.0.1:4040` or `[::1]:4040`. */
|
||||||
|
override fun toString(): String
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Datagram payload paired with sender/peer address. */
|
||||||
|
extern class Datagram {
|
||||||
|
val data: Buffer
|
||||||
|
val address: SocketAddress
|
||||||
|
}
|
||||||
@ -1,14 +1,6 @@
|
|||||||
package lyng.io.ws
|
package lyng.io.ws
|
||||||
|
|
||||||
/* Received WebSocket message. */
|
import lyng.io.ws.types
|
||||||
extern class WsMessage {
|
|
||||||
/* True when this message carries text payload. */
|
|
||||||
val isText: Bool
|
|
||||||
/* Text payload for text messages, otherwise null. */
|
|
||||||
val text: String?
|
|
||||||
/* Binary payload for binary messages, otherwise null. */
|
|
||||||
val data: Buffer?
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active WebSocket client session. */
|
/* Active WebSocket client session. */
|
||||||
extern class WsSession {
|
extern class WsSession {
|
||||||
|
|||||||
11
lyngio/stdlib/lyng/io/ws_types.lyng
Normal file
11
lyngio/stdlib/lyng/io/ws_types.lyng
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package lyng.io.ws.types
|
||||||
|
|
||||||
|
/* Received WebSocket message. */
|
||||||
|
extern class WsMessage {
|
||||||
|
/* True when this message carries text payload. */
|
||||||
|
val isText: Bool
|
||||||
|
/* Text payload for text messages, otherwise null. */
|
||||||
|
val text: String?
|
||||||
|
/* Binary payload for binary messages, otherwise null. */
|
||||||
|
val data: Buffer?
|
||||||
|
}
|
||||||
@ -2959,7 +2959,11 @@ class Compiler(
|
|||||||
Token.Type.LPAREN -> {
|
Token.Type.LPAREN -> {
|
||||||
cc.next()
|
cc.next()
|
||||||
if (shouldTreatAsClassScopeCall(left, next.value)) {
|
if (shouldTreatAsClassScopeCall(left, next.value)) {
|
||||||
val parsed = parseArgs(null, implicitItTypeForMemberLambda(left, next.value))
|
val parsed = parseArgs(
|
||||||
|
null,
|
||||||
|
implicitItTypeForMemberLambda(left, next.value),
|
||||||
|
FieldRef(left, next.value, isOptional)
|
||||||
|
)
|
||||||
val args = parsed.first
|
val args = parsed.first
|
||||||
val tailBlock = parsed.second
|
val tailBlock = parsed.second
|
||||||
isCall = true
|
isCall = true
|
||||||
@ -2976,7 +2980,11 @@ class Compiler(
|
|||||||
val receiverType = if (next.value == "apply" || next.value == "run") {
|
val receiverType = if (next.value == "apply" || next.value == "run") {
|
||||||
inferReceiverTypeFromRef(left)
|
inferReceiverTypeFromRef(left)
|
||||||
} else null
|
} else null
|
||||||
val parsed = parseArgs(receiverType, implicitItTypeForMemberLambda(left, next.value))
|
val parsed = parseArgs(
|
||||||
|
receiverType,
|
||||||
|
implicitItTypeForMemberLambda(left, next.value),
|
||||||
|
FieldRef(left, next.value, isOptional)
|
||||||
|
)
|
||||||
val args = parsed.first
|
val args = parsed.first
|
||||||
val tailBlock = parsed.second
|
val tailBlock = parsed.second
|
||||||
if (left is LocalVarRef && left.name == "scope") {
|
if (left is LocalVarRef && left.name == "scope") {
|
||||||
|
|||||||
600
proposals/lyngio_minimal_http_server.md
Normal file
600
proposals/lyngio_minimal_http_server.md
Normal file
@ -0,0 +1,600 @@
|
|||||||
|
# Proposal: Minimal HTTP/1.1 + WebSocket Server For `lyngio`
|
||||||
|
|
||||||
|
Status: Draft
|
||||||
|
Date: 2026-04-26
|
||||||
|
Owner: `lyngio`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`lyngio` already provides:
|
||||||
|
|
||||||
|
- HTTP client support via `lyng.io.http`
|
||||||
|
- WebSocket client support via `lyng.io.ws`
|
||||||
|
- raw TCP/UDP transport via `lyng.io.net`
|
||||||
|
|
||||||
|
The current transport layer is already multiplatform and exposes a small common Kotlin interface:
|
||||||
|
|
||||||
|
- `LyngTcpSocket`
|
||||||
|
- `LyngTcpServer`
|
||||||
|
- `LyngNetEngine`
|
||||||
|
|
||||||
|
This makes it practical to add a minimal server implementation in pure Kotlin without introducing a second public networking model.
|
||||||
|
|
||||||
|
The intended deployment model for this server is:
|
||||||
|
|
||||||
|
- behind a frontend proxy such as nginx
|
||||||
|
- no TLS termination in `lyngio`
|
||||||
|
- no HTTP/2 in `lyngio` v1
|
||||||
|
- minimal, strict HTTP/1.1 subset
|
||||||
|
- classic HTTP/1.1 WebSocket upgrade support
|
||||||
|
|
||||||
|
This proposal deliberately does **not** attempt to implement HTTP/2. That work is substantially larger because it requires binary framing, stream multiplexing, HPACK, and flow control. For the intended deployment model, a frontend proxy can provide TLS and public HTTP/2 while `lyngio` speaks HTTP/1.1 on the backend.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Add a minimal HTTP server implementation in pure Kotlin.
|
||||||
|
- Keep the implementation compatible with Kotlin Multiplatform common code constraints.
|
||||||
|
- Reuse the existing `lyngio.net` TCP transport layer.
|
||||||
|
- Support a strict, useful HTTP/1.1 subset.
|
||||||
|
- Support classic WebSocket upgrade from HTTP/1.1.
|
||||||
|
- Keep the API and implementation small enough to be auditable and testable.
|
||||||
|
- Preserve room for later richer server APIs or JVM-specific backends.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- HTTP/2
|
||||||
|
- TLS
|
||||||
|
- ALPN
|
||||||
|
- proxy protocol support
|
||||||
|
- request pipelining
|
||||||
|
- chunked request bodies
|
||||||
|
- HTTP trailers
|
||||||
|
- content compression
|
||||||
|
- multipart/form-data parsing
|
||||||
|
- range requests
|
||||||
|
- streaming request bodies in v1
|
||||||
|
- streaming response bodies in v1
|
||||||
|
- WebSocket extensions
|
||||||
|
- WebSocket subprotocol negotiation in v1
|
||||||
|
- exposing Ktor server APIs or types
|
||||||
|
|
||||||
|
## Design principles
|
||||||
|
|
||||||
|
### 1. Common-code first
|
||||||
|
|
||||||
|
The implementation should live primarily in `commonMain` and depend only on existing common abstractions built on top of `LyngTcpSocket` and `LyngTcpServer`.
|
||||||
|
|
||||||
|
### 2. Strict subset over broad tolerance
|
||||||
|
|
||||||
|
The server should reject unsupported or ambiguous protocol constructs instead of trying to be maximally permissive.
|
||||||
|
|
||||||
|
This reduces complexity, avoids parser edge cases, and makes connection reuse easier to reason about.
|
||||||
|
|
||||||
|
### 3. Small surface area
|
||||||
|
|
||||||
|
The first version should only implement what is needed for:
|
||||||
|
|
||||||
|
- ordinary backend HTTP request/response handling behind a proxy
|
||||||
|
- WebSocket upgrade and session handling
|
||||||
|
- persistent HTTP/1.1 connections when message framing is unambiguous
|
||||||
|
|
||||||
|
### 4. Frontend proxy assumption
|
||||||
|
|
||||||
|
The server is expected to run behind nginx or a similar reverse proxy that can provide:
|
||||||
|
|
||||||
|
- TLS termination
|
||||||
|
- public HTTP/2 if needed
|
||||||
|
- request filtering and size limiting
|
||||||
|
- buffering and slow-client protection
|
||||||
|
- optional compression and edge-specific behavior
|
||||||
|
|
||||||
|
## Proposed package
|
||||||
|
|
||||||
|
Add a new internal package:
|
||||||
|
|
||||||
|
- `net.sergeych.lyngio.http.server`
|
||||||
|
|
||||||
|
This proposal defines an internal Kotlin API first. Lyng-facing scripting bindings are explicitly out of scope for the first phase.
|
||||||
|
|
||||||
|
## Supported HTTP request subset
|
||||||
|
|
||||||
|
### Request line
|
||||||
|
|
||||||
|
Accepted format:
|
||||||
|
|
||||||
|
- `METHOD SP request-target SP HTTP/1.1`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- request line must split into exactly 3 parts
|
||||||
|
- `METHOD` must be a non-empty HTTP token
|
||||||
|
- version must be exactly `HTTP/1.1`
|
||||||
|
- request target must be origin-form only
|
||||||
|
|
||||||
|
Accepted request-target examples:
|
||||||
|
|
||||||
|
- `/`
|
||||||
|
- `/hello`
|
||||||
|
- `/hello/world?x=1&y=2`
|
||||||
|
|
||||||
|
Rejected request-target forms:
|
||||||
|
|
||||||
|
- absolute-form: `http://example.com/x`
|
||||||
|
- authority-form
|
||||||
|
- asterisk-form: `*`
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
The parser should accept any syntactically valid token as a method and expose it as a string.
|
||||||
|
|
||||||
|
The handler layer may then decide what to do with it.
|
||||||
|
|
||||||
|
This keeps the parser generic and avoids hardcoding a small method list.
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- header section ends at the first empty line
|
||||||
|
- each header line must have `name:value` form
|
||||||
|
- header names are case-insensitive for lookup
|
||||||
|
- original header values are preserved
|
||||||
|
- repeated headers are preserved as repeated values
|
||||||
|
- obsolete line folding is rejected
|
||||||
|
- embedded CR or LF in header values is rejected
|
||||||
|
|
||||||
|
### Host header
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `Host` is required on every request
|
||||||
|
- there must be exactly one effective host value after normalization
|
||||||
|
- duplicate `Host` values are allowed only if they are identical after trimming
|
||||||
|
- conflicting `Host` values are rejected
|
||||||
|
|
||||||
|
### Request bodies
|
||||||
|
|
||||||
|
v1 accepted request body framing:
|
||||||
|
|
||||||
|
- no body
|
||||||
|
- body with a valid `Content-Length`
|
||||||
|
|
||||||
|
v1 rejected request body framing:
|
||||||
|
|
||||||
|
- any `Transfer-Encoding`
|
||||||
|
- chunked request bodies
|
||||||
|
- ambiguous or conflicting body framing
|
||||||
|
|
||||||
|
### Keep-alive
|
||||||
|
|
||||||
|
HTTP/1.1 persistent connections are supported.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- keep-alive is the default
|
||||||
|
- the server closes the connection if the client sends `Connection: close`
|
||||||
|
- the server may close the connection after any response if it chooses
|
||||||
|
- the server closes the connection on parse errors or framing errors
|
||||||
|
- after a successful WebSocket upgrade, the HTTP request loop ends for that socket
|
||||||
|
|
||||||
|
### WebSocket upgrade
|
||||||
|
|
||||||
|
v1 supports classic HTTP/1.1 upgrade to WebSocket.
|
||||||
|
|
||||||
|
Required request properties:
|
||||||
|
|
||||||
|
- method is `GET`
|
||||||
|
- `Upgrade: websocket`
|
||||||
|
- `Connection` contains token `upgrade`
|
||||||
|
- `Sec-WebSocket-Key` is present
|
||||||
|
- `Sec-WebSocket-Version: 13`
|
||||||
|
|
||||||
|
v1 behavior:
|
||||||
|
|
||||||
|
- no subprotocol negotiation
|
||||||
|
- no extension negotiation
|
||||||
|
- no HTTP/2 WebSocket support
|
||||||
|
- no fallback upgrade modes beyond the standard HTTP/1.1 handshake
|
||||||
|
|
||||||
|
## Rejection and error rules
|
||||||
|
|
||||||
|
### `400 Bad Request`
|
||||||
|
|
||||||
|
Return `400` for:
|
||||||
|
|
||||||
|
- malformed request line
|
||||||
|
- invalid HTTP token in method or header name
|
||||||
|
- unsupported request-target form
|
||||||
|
- missing `Host`
|
||||||
|
- conflicting `Host` values
|
||||||
|
- invalid header syntax
|
||||||
|
- obsolete folded headers
|
||||||
|
- invalid `Content-Length`
|
||||||
|
- conflicting duplicate `Content-Length`
|
||||||
|
- invalid WebSocket upgrade request
|
||||||
|
|
||||||
|
### `413 Payload Too Large`
|
||||||
|
|
||||||
|
Return `413` when request body exceeds configured maximum size.
|
||||||
|
|
||||||
|
### `414 URI Too Long`
|
||||||
|
|
||||||
|
Return `414` when the request-target exceeds configured limits.
|
||||||
|
|
||||||
|
### `431 Request Header Fields Too Large`
|
||||||
|
|
||||||
|
Return `431` when:
|
||||||
|
|
||||||
|
- total header bytes exceed the configured limit
|
||||||
|
- header count exceeds the configured limit
|
||||||
|
- an individual header line exceeds the configured limit if such a per-line limit is introduced
|
||||||
|
|
||||||
|
### `501 Not Implemented`
|
||||||
|
|
||||||
|
Return `501` for:
|
||||||
|
|
||||||
|
- `Transfer-Encoding` in requests
|
||||||
|
- chunked request bodies
|
||||||
|
- `Expect: 100-continue`
|
||||||
|
- unsupported `Upgrade` values
|
||||||
|
- request features intentionally excluded from v1
|
||||||
|
|
||||||
|
### `505 HTTP Version Not Supported`
|
||||||
|
|
||||||
|
Return `505` for any HTTP version other than `HTTP/1.1`.
|
||||||
|
|
||||||
|
### `500 Internal Server Error`
|
||||||
|
|
||||||
|
Return `500` when the request was parsed successfully but the application handler throws or otherwise fails unexpectedly.
|
||||||
|
|
||||||
|
## Response model
|
||||||
|
|
||||||
|
v1 responses should be fully materialized before writing.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- always send a status line
|
||||||
|
- always send response headers
|
||||||
|
- prefer sending `Content-Length` on all normal responses
|
||||||
|
- do not emit chunked responses in v1
|
||||||
|
- if response framing is ambiguous, close the connection instead of attempting reuse
|
||||||
|
|
||||||
|
Connection closing rules:
|
||||||
|
|
||||||
|
- include `Connection: close` when the server intends to close after the response
|
||||||
|
- close after the response if the request asked for `Connection: close`
|
||||||
|
- close after protocol errors
|
||||||
|
- after `101 Switching Protocols`, the HTTP server loop yields ownership of the socket to the WebSocket session
|
||||||
|
|
||||||
|
## Suggested defaults and limits
|
||||||
|
|
||||||
|
Default operational limits:
|
||||||
|
|
||||||
|
- maximum request line bytes: `8 KiB`
|
||||||
|
- maximum total header bytes: `32 KiB`
|
||||||
|
- maximum header count: `100`
|
||||||
|
- maximum request body bytes: `1 MiB`
|
||||||
|
- keep-alive idle timeout: `15_000 ms`
|
||||||
|
|
||||||
|
These should be configurable per server instance.
|
||||||
|
|
||||||
|
## Internal Kotlin API
|
||||||
|
|
||||||
|
The following shape is recommended as the initial internal API.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class HttpServerConfig(
|
||||||
|
val host: String? = "127.0.0.1",
|
||||||
|
val port: Int = 0,
|
||||||
|
val backlog: Int = 128,
|
||||||
|
val reuseAddress: Boolean = true,
|
||||||
|
val maxRequestLineBytes: Int = 8 * 1024,
|
||||||
|
val maxHeaderBytes: Int = 32 * 1024,
|
||||||
|
val maxHeaderCount: Int = 100,
|
||||||
|
val maxBodyBytes: Int = 1 * 1024 * 1024,
|
||||||
|
val keepAliveTimeoutMillis: Long = 15_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HttpHeader(
|
||||||
|
val name: String,
|
||||||
|
val value: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
class HttpHeaders(
|
||||||
|
private val entries: List<HttpHeader>,
|
||||||
|
) {
|
||||||
|
fun first(name: String): String?
|
||||||
|
fun all(name: String): List<String>
|
||||||
|
fun containsToken(name: String, token: String): Boolean
|
||||||
|
fun entries(): List<HttpHeader>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HttpRequestHead(
|
||||||
|
val method: String,
|
||||||
|
val target: String,
|
||||||
|
val path: String,
|
||||||
|
val query: String?,
|
||||||
|
val version: String,
|
||||||
|
val headers: HttpHeaders,
|
||||||
|
val contentLength: Int?,
|
||||||
|
val wantsClose: Boolean,
|
||||||
|
val wantsWebSocketUpgrade: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HttpRequest(
|
||||||
|
val head: HttpRequestHead,
|
||||||
|
val body: ByteArray,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HttpResponse(
|
||||||
|
val status: Int,
|
||||||
|
val reason: String = defaultReason(status),
|
||||||
|
val headers: List<HttpHeader> = emptyList(),
|
||||||
|
val body: ByteArray = ByteArray(0),
|
||||||
|
val close: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface HttpWebSocketSession {
|
||||||
|
fun isOpen(): Boolean
|
||||||
|
suspend fun sendText(text: String)
|
||||||
|
suspend fun sendBytes(data: ByteArray)
|
||||||
|
suspend fun receive(): net.sergeych.lyngio.ws.LyngWsMessage?
|
||||||
|
suspend fun close(code: Int = 1000, reason: String = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface HttpHandlerResult {
|
||||||
|
data class Response(val response: HttpResponse) : HttpHandlerResult
|
||||||
|
data class WebSocket(val handler: suspend (HttpWebSocketSession) -> Unit) : HttpHandlerResult
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface HttpHandler {
|
||||||
|
suspend fun handle(request: HttpRequest): HttpHandlerResult
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HttpServer {
|
||||||
|
fun isOpen(): Boolean
|
||||||
|
fun localAddress(): net.sergeych.lyngio.net.LyngSocketAddress
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation architecture
|
||||||
|
|
||||||
|
The implementation should be split into a small number of focused components.
|
||||||
|
|
||||||
|
### 1. `HttpServer.kt`
|
||||||
|
|
||||||
|
Contains:
|
||||||
|
|
||||||
|
- public internal interfaces and data classes
|
||||||
|
- config and response models
|
||||||
|
- default reason phrase mapping
|
||||||
|
|
||||||
|
### 2. `BufferedSocketReader.kt`
|
||||||
|
|
||||||
|
A small internal reader built on top of `LyngTcpSocket`.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- buffered reads
|
||||||
|
- line reads with explicit limits
|
||||||
|
- exact byte reads for request bodies and WebSocket frames
|
||||||
|
- avoiding fragile mixing of raw `read()` and `readLine()` semantics
|
||||||
|
|
||||||
|
This reader should be internal and should not require changes to `LyngTcpSocket` in v1.
|
||||||
|
|
||||||
|
### 3. `HttpParser.kt`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- request line parsing
|
||||||
|
- target parsing into `path` and optional query
|
||||||
|
- header parsing and normalization
|
||||||
|
- validation of `Host`, `Content-Length`, and connection semantics
|
||||||
|
- mapping parse failures into typed HTTP errors
|
||||||
|
|
||||||
|
### 4. `HttpWriter.kt`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- writing status line and headers
|
||||||
|
- adding `Content-Length` where needed
|
||||||
|
- setting `Connection: close` when the server intends to close
|
||||||
|
- writing the response body
|
||||||
|
- flushing output
|
||||||
|
|
||||||
|
### 5. `HttpServerLoop.kt`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- accept loop over `LyngTcpServer`
|
||||||
|
- per-connection request loop
|
||||||
|
- keep-alive timeout handling
|
||||||
|
- error-to-response mapping
|
||||||
|
- handing off upgraded sockets to WebSocket session implementation
|
||||||
|
|
||||||
|
### 6. `ServerWebSocket.kt`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- validating upgrade request
|
||||||
|
- computing `Sec-WebSocket-Accept`
|
||||||
|
- writing `101 Switching Protocols`
|
||||||
|
- reading and writing WebSocket frames
|
||||||
|
- close handling
|
||||||
|
|
||||||
|
This should reuse the client-side frame and handshake logic already present in spirit, but server-side behavior should stay separate and explicit.
|
||||||
|
|
||||||
|
## Connection processing model
|
||||||
|
|
||||||
|
Per accepted TCP connection:
|
||||||
|
|
||||||
|
1. read request line
|
||||||
|
2. read headers
|
||||||
|
3. validate request
|
||||||
|
4. read request body if `Content-Length` is present
|
||||||
|
5. call the application handler
|
||||||
|
6. if handler returns HTTP response, write it and decide whether to continue
|
||||||
|
7. if handler returns WebSocket upgrade, send `101`, create a WebSocket session, and transfer ownership of the socket
|
||||||
|
8. continue until close, error, timeout, or upgrade
|
||||||
|
|
||||||
|
The server should process one request at a time per connection.
|
||||||
|
|
||||||
|
Pipelining is out of scope.
|
||||||
|
|
||||||
|
## Detailed parser rules
|
||||||
|
|
||||||
|
### Method parsing
|
||||||
|
|
||||||
|
- method must be a valid HTTP token
|
||||||
|
- parser does not enforce a fixed method allowlist
|
||||||
|
|
||||||
|
### Target parsing
|
||||||
|
|
||||||
|
- target must begin with `/`
|
||||||
|
- split on the first `?`
|
||||||
|
- `path` is the portion before `?`
|
||||||
|
- `query` is the portion after `?`, or `null`
|
||||||
|
- no URL decoding is required in v1; raw target text may be exposed
|
||||||
|
|
||||||
|
### Header parsing
|
||||||
|
|
||||||
|
- split each header line on the first `:`
|
||||||
|
- trim outer spaces and tabs from the value
|
||||||
|
- reject control characters other than horizontal tab if any are allowed at all
|
||||||
|
- do case-insensitive matching by normalized header name
|
||||||
|
- preserve the original values as supplied
|
||||||
|
|
||||||
|
### Content-Length rules
|
||||||
|
|
||||||
|
- absent means no request body
|
||||||
|
- one valid decimal value is accepted
|
||||||
|
- multiple values are accepted only if all normalized values are identical
|
||||||
|
- negative values are rejected
|
||||||
|
- values above configured maximum body size are rejected with `413`
|
||||||
|
|
||||||
|
### Connection token parsing
|
||||||
|
|
||||||
|
- `Connection` is tokenized case-insensitively on commas
|
||||||
|
- surrounding spaces are ignored
|
||||||
|
- helper methods should support `containsToken("Connection", "close")`
|
||||||
|
- helper methods should support `containsToken("Connection", "upgrade")`
|
||||||
|
|
||||||
|
## WebSocket v1 rules
|
||||||
|
|
||||||
|
### Upgrade acceptance
|
||||||
|
|
||||||
|
Accept only if all of the following are true:
|
||||||
|
|
||||||
|
- request method is `GET`
|
||||||
|
- request version is `HTTP/1.1`
|
||||||
|
- request body is empty
|
||||||
|
- `Upgrade` contains `websocket`
|
||||||
|
- `Connection` contains `upgrade`
|
||||||
|
- `Sec-WebSocket-Key` is present and syntactically valid
|
||||||
|
- `Sec-WebSocket-Version` equals `13`
|
||||||
|
|
||||||
|
Otherwise return a regular HTTP error response.
|
||||||
|
|
||||||
|
### WebSocket features in v1
|
||||||
|
|
||||||
|
Supported:
|
||||||
|
|
||||||
|
- text messages
|
||||||
|
- binary messages
|
||||||
|
- ping/pong handling
|
||||||
|
- close handshake
|
||||||
|
|
||||||
|
Not supported in v1:
|
||||||
|
|
||||||
|
- permessage-deflate
|
||||||
|
- subprotocol negotiation
|
||||||
|
- fragmented-message streaming to the application
|
||||||
|
- very large frame optimizations beyond a reasonable implementation limit
|
||||||
|
|
||||||
|
## Testing plan
|
||||||
|
|
||||||
|
A server like this should be tested at three levels.
|
||||||
|
|
||||||
|
### 1. Parser unit tests
|
||||||
|
|
||||||
|
Cases:
|
||||||
|
|
||||||
|
- valid request line parsing
|
||||||
|
- invalid request line parsing
|
||||||
|
- target parsing with and without query
|
||||||
|
- header case-insensitive lookup
|
||||||
|
- duplicate `Host` handling
|
||||||
|
- duplicate `Content-Length` handling
|
||||||
|
- oversized request line rejection
|
||||||
|
- oversized headers rejection
|
||||||
|
- `Transfer-Encoding` rejection
|
||||||
|
|
||||||
|
### 2. Engine-level loopback tests
|
||||||
|
|
||||||
|
Using existing TCP backends:
|
||||||
|
|
||||||
|
- simple `GET` request and response
|
||||||
|
- `POST` with `Content-Length`
|
||||||
|
- keep-alive with two sequential requests on one socket
|
||||||
|
- `Connection: close`
|
||||||
|
- malformed request closes connection
|
||||||
|
- handler exception becomes `500`
|
||||||
|
- body too large becomes `413`
|
||||||
|
|
||||||
|
### 3. WebSocket upgrade tests
|
||||||
|
|
||||||
|
Cases:
|
||||||
|
|
||||||
|
- successful upgrade handshake
|
||||||
|
- text echo
|
||||||
|
- binary echo
|
||||||
|
- ping/pong behavior
|
||||||
|
- clean close handshake
|
||||||
|
- invalid upgrade headers rejected as HTTP errors
|
||||||
|
|
||||||
|
## Implementation phases
|
||||||
|
|
||||||
|
### Phase 1: internal HTTP server core
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- config
|
||||||
|
- buffered reader
|
||||||
|
- parser
|
||||||
|
- writer
|
||||||
|
- request loop
|
||||||
|
- fixed-body responses
|
||||||
|
- keep-alive
|
||||||
|
|
||||||
|
### Phase 2: server-side WebSocket upgrade
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- upgrade validation
|
||||||
|
- `101 Switching Protocols`
|
||||||
|
- WebSocket frame IO
|
||||||
|
- session object
|
||||||
|
- close and ping/pong handling
|
||||||
|
|
||||||
|
### Phase 3: host integration and optional Lyng exposure
|
||||||
|
|
||||||
|
Possible future work:
|
||||||
|
|
||||||
|
- host-facing convenience factory APIs
|
||||||
|
- Lyng module exposure if there is a clear scripting use case
|
||||||
|
- route helpers or lightweight dispatching
|
||||||
|
- JVM-specific richer backends if requirements grow
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. Should the first version expose only a Kotlin host API, or should it also be surfaced to Lyng scripts immediately?
|
||||||
|
2. Should response headers be represented as repeated `HttpHeader` entries only, or should a convenience builder API be added from the start?
|
||||||
|
3. Should the first version include a small path router helper, or should routing stay entirely in host code?
|
||||||
|
4. Should very small chunked response support be added later if keep-alive plus unknown response length becomes a real need, or should v1 require fully materialized responses only?
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Proceed with this strict HTTP/1.1 + WebSocket subset.
|
||||||
|
|
||||||
|
It is small enough to finish in common Kotlin, fits the current `lyngio` transport architecture, and avoids turning the project into a full protocol-stack implementation effort.
|
||||||
Loading…
x
Reference in New Issue
Block a user