Add minimal HTTP server and shared network type packages

This commit is contained in:
sergeych committed 2026-04-26 10:09:25 +03:00
1 parent 79b015ee56
commit 01ceecd7df
28 files changed
+2642 -55

No files matched your search

@@ -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()