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.ws` (WebSocket client API; currently supported on JVM, capability-gated elsewhere)
|
||||
- `import lyng.io.net` (TCP/UDP transport API; currently supported on JVM, capability-gated elsewhere)
|
||||
- Shared network value-type packages are also available when installed by host code:
|
||||
- `import lyng.io.http.types` (`HttpHeaders`)
|
||||
- `import lyng.io.ws.types` (`WsMessage`)
|
||||
- `import lyng.io.net.types` (`IpVersion`, `SocketAddress`, `Datagram`)
|
||||
|
||||
## 7. AI Generation Tips
|
||||
- Assume `lyng.stdlib` APIs exist in regular script contexts.
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
This module provides a compact HTTP client API for Lyng scripts. It is implemented in `lyngio` and backed by Ktor on supported runtimes.
|
||||
|
||||
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
|
||||
>
|
||||
> **Shared type note:** `HttpHeaders` is also available from `lyng.io.http.types` when host code wants the reusable value type without relying on the HTTP client module itself.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -4,6 +4,8 @@ This module provides minimal raw transport networking for Lyng scripts. It is im
|
||||
|
||||
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
|
||||
>
|
||||
> **Shared type note:** `IpVersion`, `SocketAddress`, and `Datagram` are also available from `lyng.io.net.types` when host code wants reusable transport value types without depending on the `Net` capability object itself.
|
||||
>
|
||||
> **Important native platform limit:** current native TCP/UDP support is backed by a selector with a per-process file descriptor ceiling. On Linux/macOS native targets this makes high-connection-count servers and same-process load tests unsuitable once the process approaches that limit.
|
||||
>
|
||||
> **Recommendation:** for serious HTTP/TCP servers, prefer the JVM target today. On native targets, keep concurrency bounded, batch local load tests in waves, and use multiple worker processes behind a reverse proxy if you need more throughput before the backend is reworked.
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
This module provides a compact WebSocket client API for Lyng scripts. It is implemented in `lyngio` and currently backed by Ktor WebSockets on the JVM.
|
||||
|
||||
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
|
||||
>
|
||||
> **Shared type note:** `WsMessage` is also available from `lyng.io.ws.types` when host code wants the reusable message type without depending on the WebSocket client module itself.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
- **[lyng.io.http](lyng.io.http.md):** HTTP/HTTPS client access. Provides `Http`, `HttpRequest`, `HttpResponse`, and `HttpHeaders`.
|
||||
- **[lyng.io.ws](lyng.io.ws.md):** WebSocket client access. Provides `Ws`, `WsSession`, and `WsMessage`.
|
||||
- **[lyng.io.net](lyng.io.net.md):** Transport networking. Provides `Net`, `TcpSocket`, `TcpServer`, `UdpSocket`, and `SocketAddress`.
|
||||
- **Shared networking type packages:** `lyng.io.http.types`, `lyng.io.ws.types`, and `lyng.io.net.types` expose reusable value types such as `HttpHeaders`, `WsMessage`, `IpVersion`, `SocketAddress`, and `Datagram` when host code wants type-only imports without installing the corresponding capability object module.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -46,8 +46,10 @@ import net.sergeych.lyngio.http.security.HttpAccessDeniedException
|
||||
import net.sergeych.lyngio.http.security.HttpAccessOp
|
||||
import net.sergeych.lyngio.http.security.HttpAccessPolicy
|
||||
import net.sergeych.lyngio.stdlib_included.httpLyng
|
||||
import net.sergeych.lyngio.stdlib_included.http_typesLyng
|
||||
|
||||
private const val HTTP_MODULE_NAME = "lyng.io.http"
|
||||
internal const val HTTP_TYPES_MODULE_NAME = "lyng.io.http.types"
|
||||
|
||||
fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
|
||||
createHttpModule(policy, scope.importManager)
|
||||
@ -55,6 +57,7 @@ fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
|
||||
fun createHttp(policy: HttpAccessPolicy, scope: Scope): Boolean = createHttpModule(policy, scope)
|
||||
|
||||
fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean {
|
||||
createHttpTypesModule(manager)
|
||||
if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false
|
||||
manager.addPackage(HTTP_MODULE_NAME) { module ->
|
||||
buildHttpModule(module, policy)
|
||||
@ -64,6 +67,19 @@ fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean
|
||||
|
||||
fun createHttp(policy: HttpAccessPolicy, manager: ImportManager): Boolean = createHttpModule(policy, manager)
|
||||
|
||||
internal fun createHttpTypesModule(manager: ImportManager): Boolean {
|
||||
if (manager.packageNames.contains(HTTP_TYPES_MODULE_NAME)) return false
|
||||
manager.addPackage(HTTP_TYPES_MODULE_NAME) { module ->
|
||||
buildHttpTypesModule(module)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun buildHttpTypesModule(module: ModuleScope) {
|
||||
module.eval(Source(HTTP_TYPES_MODULE_NAME, http_typesLyng))
|
||||
module.addConst("HttpHeaders", ObjHttpHeaders.type)
|
||||
}
|
||||
|
||||
private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolicy) {
|
||||
module.eval(Source(HTTP_MODULE_NAME, httpLyng))
|
||||
val engine = getSystemHttpEngine()
|
||||
@ -139,7 +155,7 @@ private suspend inline fun ScopeFacade.httpGuard(crossinline block: suspend () -
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjHttpHeaders(
|
||||
internal class ObjHttpHeaders(
|
||||
singleValueHeaders: Map<String, String> = emptyMap(),
|
||||
private val allHeaders: Map<String, List<String>> = emptyMap(),
|
||||
) : Obj() {
|
||||
@ -201,6 +217,11 @@ private class ObjHttpHeaders(
|
||||
).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()
|
||||
|
||||
@ -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.NetAccessPolicy
|
||||
import net.sergeych.lyngio.stdlib_included.netLyng
|
||||
import net.sergeych.lyngio.stdlib_included.net_typesLyng
|
||||
|
||||
private const val NET_MODULE_NAME = "lyng.io.net"
|
||||
internal const val NET_TYPES_MODULE_NAME = "lyng.io.net.types"
|
||||
|
||||
fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean =
|
||||
createNetModule(policy, scope.importManager)
|
||||
@ -56,6 +58,7 @@ fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean =
|
||||
fun createNet(policy: NetAccessPolicy, scope: Scope): Boolean = createNetModule(policy, scope)
|
||||
|
||||
fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
|
||||
createNetTypesModule(manager)
|
||||
if (manager.packageNames.contains(NET_MODULE_NAME)) return false
|
||||
manager.addPackage(NET_MODULE_NAME) { module ->
|
||||
buildNetModule(module, policy)
|
||||
@ -65,6 +68,21 @@ fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
|
||||
|
||||
fun createNet(policy: NetAccessPolicy, manager: ImportManager): Boolean = createNetModule(policy, manager)
|
||||
|
||||
internal fun createNetTypesModule(manager: ImportManager): Boolean {
|
||||
if (manager.packageNames.contains(NET_TYPES_MODULE_NAME)) return false
|
||||
manager.addPackage(NET_TYPES_MODULE_NAME) { module ->
|
||||
buildNetTypesModule(module)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun buildNetTypesModule(module: ModuleScope) {
|
||||
module.eval(Source(NET_TYPES_MODULE_NAME, net_typesLyng))
|
||||
val enumValues = NetEnumValues.load(module)
|
||||
module.addConst("SocketAddress", ObjSocketAddress.type(enumValues))
|
||||
module.addConst("Datagram", ObjDatagram.type(enumValues))
|
||||
}
|
||||
|
||||
private suspend fun buildNetModule(module: ModuleScope, policy: NetAccessPolicy) {
|
||||
module.eval(Source(NET_MODULE_NAME, netLyng))
|
||||
val engine = getSystemNetEngine()
|
||||
@ -164,10 +182,12 @@ private class ObjSocketAddress(
|
||||
override suspend fun defaultToString(scope: Scope): ObjString = ObjString(renderAddress(address))
|
||||
|
||||
companion object {
|
||||
private val types = mutableMapOf<NetEnumValues, ObjClass>()
|
||||
private data class EnumKey(val ipv4: Obj, val ipv6: Obj)
|
||||
|
||||
private val types = mutableMapOf<EnumKey, ObjClass>()
|
||||
|
||||
fun type(enumValues: NetEnumValues): ObjClass =
|
||||
types.getOrPut(enumValues) {
|
||||
types.getOrPut(EnumKey(enumValues.ipv4, enumValues.ipv6)) {
|
||||
object : ObjClass("SocketAddress") {
|
||||
override suspend fun callOn(scope: Scope): Obj {
|
||||
scope.raiseError("SocketAddress cannot be created directly")
|
||||
@ -191,10 +211,12 @@ private class ObjDatagram(
|
||||
get() = type(enumValues)
|
||||
|
||||
companion object {
|
||||
private val types = mutableMapOf<NetEnumValues, ObjClass>()
|
||||
private data class EnumKey(val ipv4: Obj, val ipv6: Obj)
|
||||
|
||||
private val types = mutableMapOf<EnumKey, ObjClass>()
|
||||
|
||||
fun type(enumValues: NetEnumValues): ObjClass =
|
||||
types.getOrPut(enumValues) {
|
||||
types.getOrPut(EnumKey(enumValues.ipv4, enumValues.ipv6)) {
|
||||
object : ObjClass("Datagram") {
|
||||
override suspend fun callOn(scope: Scope): Obj {
|
||||
scope.raiseError("Datagram cannot be created directly")
|
||||
|
||||
@ -37,6 +37,7 @@ import net.sergeych.lyng.raiseIllegalOperation
|
||||
import net.sergeych.lyng.requireNoArgs
|
||||
import net.sergeych.lyng.requireScope
|
||||
import net.sergeych.lyngio.stdlib_included.wsLyng
|
||||
import net.sergeych.lyngio.stdlib_included.ws_typesLyng
|
||||
import net.sergeych.lyngio.ws.LyngWsEngine
|
||||
import net.sergeych.lyngio.ws.LyngWsMessage
|
||||
import net.sergeych.lyngio.ws.LyngWsSession
|
||||
@ -46,6 +47,7 @@ import net.sergeych.lyngio.ws.security.WsAccessOp
|
||||
import net.sergeych.lyngio.ws.security.WsAccessPolicy
|
||||
|
||||
private const val WS_MODULE_NAME = "lyng.io.ws"
|
||||
internal const val WS_TYPES_MODULE_NAME = "lyng.io.ws.types"
|
||||
|
||||
fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean =
|
||||
createWsModule(policy, scope.importManager)
|
||||
@ -53,6 +55,7 @@ fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean =
|
||||
fun createWs(policy: WsAccessPolicy, scope: Scope): Boolean = createWsModule(policy, scope)
|
||||
|
||||
fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
|
||||
createWsTypesModule(manager)
|
||||
if (manager.packageNames.contains(WS_MODULE_NAME)) return false
|
||||
manager.addPackage(WS_MODULE_NAME) { module ->
|
||||
buildWsModule(module, policy)
|
||||
@ -62,6 +65,19 @@ fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
|
||||
|
||||
fun createWs(policy: WsAccessPolicy, manager: ImportManager): Boolean = createWsModule(policy, manager)
|
||||
|
||||
internal fun createWsTypesModule(manager: ImportManager): Boolean {
|
||||
if (manager.packageNames.contains(WS_TYPES_MODULE_NAME)) return false
|
||||
manager.addPackage(WS_TYPES_MODULE_NAME) { module ->
|
||||
buildWsTypesModule(module)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun buildWsTypesModule(module: ModuleScope) {
|
||||
module.eval(Source(WS_TYPES_MODULE_NAME, ws_typesLyng))
|
||||
module.addConst("WsMessage", ObjWsMessage.type)
|
||||
}
|
||||
|
||||
private suspend fun buildWsModule(module: ModuleScope, policy: WsAccessPolicy) {
|
||||
module.eval(Source(WS_MODULE_NAME, wsLyng))
|
||||
val engine = getSystemWsEngine()
|
||||
@ -92,7 +108,7 @@ private suspend inline fun ScopeFacade.wsGuard(crossinline block: suspend () ->
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjWsMessage(
|
||||
internal class ObjWsMessage(
|
||||
private val message: LyngWsMessage,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
@ -112,6 +128,8 @@ private class ObjWsMessage(
|
||||
thisAs<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") {
|
||||
val self = thisAs<ObjWsSession>()
|
||||
self.policy.require(WsAccessOp.Receive(self.targetUrl))
|
||||
self.session.receive()?.let(::ObjWsMessage) ?: ObjNull
|
||||
self.session.receive()?.let(ObjWsMessage::from) ?: ObjNull
|
||||
}
|
||||
addFn("close") {
|
||||
val self = thisAs<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 net.sergeych.lyng.Compiler
|
||||
import net.sergeych.lyng.ExecutionError
|
||||
import net.sergeych.lyng.Pos
|
||||
import net.sergeych.lyng.Script
|
||||
import net.sergeych.lyngio.fs.security.AccessContext
|
||||
import net.sergeych.lyngio.fs.security.AccessDecision
|
||||
@ -35,11 +36,25 @@ import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LyngNetModuleTest {
|
||||
|
||||
@Test
|
||||
fun testSharedNetTypesModuleExportsCanonicalTypes() = runBlocking {
|
||||
val scope = Script.newScope()
|
||||
createNetModule(PermitAllNetAccessPolicy, scope)
|
||||
|
||||
val netModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.net")
|
||||
val typesModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.net.types")
|
||||
|
||||
assertSame(typesModule.get("IpVersion")?.value, netModule.get("IpVersion")?.value)
|
||||
assertSame(typesModule.get("SocketAddress")?.value, netModule.get("SocketAddress")?.value)
|
||||
assertSame(typesModule.get("Datagram")?.value, netModule.get("Datagram")?.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testResolveAndCapabilities() = runBlocking {
|
||||
val scope = Script.newScope()
|
||||
|
||||
@ -1,17 +1,6 @@
|
||||
package lyng.io.http
|
||||
|
||||
/*
|
||||
Response/header view that behaves like a map for the first value of each header name.
|
||||
Multi-valued headers are exposed through `getAll`.
|
||||
*/
|
||||
extern class HttpHeaders : Map<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>
|
||||
}
|
||||
import lyng.io.http.types
|
||||
|
||||
/* Mutable request descriptor for programmatic HTTP calls. */
|
||||
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
|
||||
|
||||
/* Address family for resolved or bound endpoints. */
|
||||
enum IpVersion {
|
||||
IPV4,
|
||||
IPV6
|
||||
}
|
||||
|
||||
/* Concrete socket endpoint. */
|
||||
extern class SocketAddress {
|
||||
/* Numeric or host-form address string. */
|
||||
val host: String
|
||||
/* Transport port number. */
|
||||
val port: Int
|
||||
/* Address family. */
|
||||
val ipVersion: IpVersion
|
||||
/* True when obtained from DNS resolution rather than raw bind input. */
|
||||
val resolved: Bool
|
||||
/* Stable printable form such as `127.0.0.1:4040` or `[::1]:4040`. */
|
||||
override fun toString(): String
|
||||
}
|
||||
|
||||
/* Datagram payload paired with sender/peer address. */
|
||||
extern class Datagram {
|
||||
val data: Buffer
|
||||
val address: SocketAddress
|
||||
}
|
||||
import lyng.io.net.types
|
||||
|
||||
/* Connected TCP socket. */
|
||||
extern class TcpSocket {
|
||||
|
||||
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
|
||||
|
||||
/* Received WebSocket message. */
|
||||
extern class WsMessage {
|
||||
/* True when this message carries text payload. */
|
||||
val isText: Bool
|
||||
/* Text payload for text messages, otherwise null. */
|
||||
val text: String?
|
||||
/* Binary payload for binary messages, otherwise null. */
|
||||
val data: Buffer?
|
||||
}
|
||||
import lyng.io.ws.types
|
||||
|
||||
/* Active WebSocket client session. */
|
||||
extern class WsSession {
|
||||
|
||||
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 -> {
|
||||
cc.next()
|
||||
if (shouldTreatAsClassScopeCall(left, next.value)) {
|
||||
val parsed = parseArgs(null, implicitItTypeForMemberLambda(left, next.value))
|
||||
val parsed = parseArgs(
|
||||
null,
|
||||
implicitItTypeForMemberLambda(left, next.value),
|
||||
FieldRef(left, next.value, isOptional)
|
||||
)
|
||||
val args = parsed.first
|
||||
val tailBlock = parsed.second
|
||||
isCall = true
|
||||
@ -2976,7 +2980,11 @@ class Compiler(
|
||||
val receiverType = if (next.value == "apply" || next.value == "run") {
|
||||
inferReceiverTypeFromRef(left)
|
||||
} else null
|
||||
val parsed = parseArgs(receiverType, implicitItTypeForMemberLambda(left, next.value))
|
||||
val parsed = parseArgs(
|
||||
receiverType,
|
||||
implicitItTypeForMemberLambda(left, next.value),
|
||||
FieldRef(left, next.value, isOptional)
|
||||
)
|
||||
val args = parsed.first
|
||||
val tailBlock = parsed.second
|
||||
if (left is LocalVarRef && left.name == "scope") {
|
||||
|
||||
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