diff --git a/build.gradle.kts b/build.gradle.kts index b5e3fd7e..5a82b18c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,7 +12,7 @@ val fxVersion by extra("11") allprojects { group = "space.kscience" - version = "0.3.0-dev-4" + version = "0.3.0-dev-5" } subprojects { diff --git a/demo/playground/src/jvmMain/kotlin/formServer.kt b/demo/playground/src/jvmMain/kotlin/formServer.kt index 4d60a423..4b63b569 100644 --- a/demo/playground/src/jvmMain/kotlin/formServer.kt +++ b/demo/playground/src/jvmMain/kotlin/formServer.kt @@ -1,5 +1,8 @@ package space.kscience.visionforge.examples +import io.ktor.server.cio.CIO +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing import kotlinx.html.* import space.kscience.dataforge.context.Global import space.kscience.dataforge.context.fetch @@ -9,51 +12,54 @@ import space.kscience.visionforge.html.formFragment import space.kscience.visionforge.onPropertyChange import space.kscience.visionforge.server.close import space.kscience.visionforge.server.openInBrowser -import space.kscience.visionforge.server.serve +import space.kscience.visionforge.server.visionPage fun main() { val visionManager = Global.fetch(VisionManager) - val server = visionManager.serve { - page(VisionPage.scriptHeader("js/visionforge-playground.js")) { - val form = formFragment("form") { - label { - htmlFor = "fname" - +"First name:" - } - br() - input { - type = InputType.text - id = "fname" - name = "fname" - value = "John" - } - br() - label { - htmlFor = "lname" - +"Last name:" - } - br() - input { - type = InputType.text - id = "lname" - name = "lname" - value = "Doe" - } - br() - br() - input { - type = InputType.submit - value = "Submit" - } - } + val server = embeddedServer(CIO, 7777, "localhost") { - vision("form") { form } - form.onPropertyChange { - println(this) + routing { + visionPage(visionManager, VisionPage.scriptHeader("js/visionforge-playground.js")) { + val form = formFragment("form") { + label { + htmlFor = "fname" + +"First name:" + } + br() + input { + type = InputType.text + id = "fname" + name = "fname" + value = "John" + } + br() + label { + htmlFor = "lname" + +"Last name:" + } + br() + input { + type = InputType.text + id = "lname" + name = "lname" + value = "Doe" + } + br() + br() + input { + type = InputType.submit + value = "Submit" + } + } + + vision("form") { form } + form.onPropertyChange { + println(this) + } } } - } + }.start(false) server.openInBrowser() diff --git a/demo/playground/src/jvmMain/kotlin/serverExtensions.kt b/demo/playground/src/jvmMain/kotlin/serverExtensions.kt index eda8ba2b..a3bde8d6 100644 --- a/demo/playground/src/jvmMain/kotlin/serverExtensions.kt +++ b/demo/playground/src/jvmMain/kotlin/serverExtensions.kt @@ -6,6 +6,7 @@ import space.kscience.visionforge.html.ResourceLocation import space.kscience.visionforge.html.VisionPage import space.kscience.visionforge.html.importScriptHeader import space.kscience.visionforge.makeFile +import space.kscience.visionforge.visionManager import java.awt.Desktop import java.nio.file.Path @@ -16,7 +17,7 @@ public fun makeVisionFile( show: Boolean = true, content: HtmlVisionFragment, ): Unit { - val actualPath = VisionPage(Global, content = content).makeFile(path) { actualPath -> + val actualPath = VisionPage(Global.visionManager, content = content).makeFile(path) { actualPath -> mapOf( "title" to VisionPage.title(title), "playground" to VisionPage.importScriptHeader("js/visionforge-playground.js", resourceLocation, actualPath), diff --git a/demo/sat-demo/src/main/kotlin/ru/mipt/npm/sat/satServer.kt b/demo/sat-demo/src/main/kotlin/ru/mipt/npm/sat/satServer.kt index 23699443..a55020a2 100644 --- a/demo/sat-demo/src/main/kotlin/ru/mipt/npm/sat/satServer.kt +++ b/demo/sat-demo/src/main/kotlin/ru/mipt/npm/sat/satServer.kt @@ -1,26 +1,28 @@ package ru.mipt.npm.sat +import io.ktor.server.cio.CIO +import io.ktor.server.engine.embeddedServer +import io.ktor.server.http.content.resources +import io.ktor.server.http.content.static +import io.ktor.server.routing.routing import kotlinx.coroutines.* import kotlinx.html.div import kotlinx.html.h1 import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.fetch import space.kscience.dataforge.meta.Null -import space.kscience.dataforge.misc.DFExperimental import space.kscience.dataforge.names.Name import space.kscience.visionforge.Colors import space.kscience.visionforge.html.VisionPage import space.kscience.visionforge.server.close import space.kscience.visionforge.server.openInBrowser -import space.kscience.visionforge.server.serve +import space.kscience.visionforge.server.visionPage import space.kscience.visionforge.solid.* import space.kscience.visionforge.three.threeJsHeader -import space.kscience.visionforge.visionManager import kotlin.random.Random -@OptIn(DFExperimental::class) fun main() { val satContext = Context("sat") { plugin(Solids) @@ -35,14 +37,21 @@ fun main() { } } - val server = satContext.visionManager.serve { - page(VisionPage.threeJsHeader, VisionPage.styleSheetHeader("css/styles.css")) { - div("flex-column") { - h1 { +"Satellite detector demo" } - vision { sat } + val server = embeddedServer(CIO,7777, "localhost") { + routing { + + static { + resources() + } + + visionPage(solids.visionManager, VisionPage.threeJsHeader, VisionPage.styleSheetHeader("css/styles.css")) { + div("flex-column") { + h1 { +"Satellite detector demo" } + vision { sat } + } } } - } + }.start(false) server.openInBrowser() diff --git a/jupyter/src/jvmMain/kotlin/VFForNotebook.kt b/jupyter/src/jvmMain/kotlin/VFForNotebook.kt index 4c7b4e29..c3b79734 100644 --- a/jupyter/src/jvmMain/kotlin/VFForNotebook.kt +++ b/jupyter/src/jvmMain/kotlin/VFForNotebook.kt @@ -1,6 +1,14 @@ package space.kscience.visionforge.jupyter +import io.ktor.http.URLProtocol +import io.ktor.server.application.install +import io.ktor.server.cio.CIO import io.ktor.server.engine.ApplicationEngine +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.Routing +import io.ktor.server.routing.route +import io.ktor.server.util.url +import io.ktor.server.websocket.WebSockets import kotlinx.coroutines.CoroutineScope import kotlinx.html.* import kotlinx.html.stream.createHTML @@ -13,11 +21,14 @@ import space.kscience.dataforge.context.logger import space.kscience.dataforge.meta.get import space.kscience.dataforge.meta.int import space.kscience.dataforge.meta.string +import space.kscience.visionforge.VisionManager import space.kscience.visionforge.html.HtmlFormFragment import space.kscience.visionforge.html.HtmlVisionFragment +import space.kscience.visionforge.html.VisionCollector import space.kscience.visionforge.html.visionFragment -import space.kscience.visionforge.server.VisionServer -import space.kscience.visionforge.server.serve +import space.kscience.visionforge.server.VisionRouteConfiguration +import space.kscience.visionforge.server.require +import space.kscience.visionforge.server.serveVisionData import space.kscience.visionforge.visionManager import kotlin.coroutines.CoroutineContext import kotlin.random.Random @@ -34,10 +45,14 @@ internal fun TagConsumer<*>.renderScriptForId(id: String) { * A handler class that includes a server and common utilities */ public class VFForNotebook(override val context: Context) : ContextAware, CoroutineScope { + + public val visionManager: VisionManager = context.visionManager + + private val configuration = VisionRouteConfiguration(visionManager) + private var counter = 0 private var engine: ApplicationEngine? = null - private var server: VisionServer? = null public var isolateFragments: Boolean = false @@ -47,16 +62,15 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout isolateFragments = true } - public fun isServerRunning(): Boolean = server != null + public fun isServerRunning(): Boolean = engine != null public fun html(block: TagConsumer<*>.() -> Unit): MimeTypedResult = HTML(createHTML().apply(block).finalize()) public fun startServer( host: String = context.properties["visionforge.host"].string ?: "localhost", - port: Int = context.properties["visionforge.port"].int ?: VisionServer.DEFAULT_PORT, - configuration: VisionServer.() -> Unit = {}, + port: Int = context.properties["visionforge.port"].int ?: VisionRouteConfiguration.DEFAULT_PORT, ): MimeTypedResult = html { - if (server != null) { + if (engine != null) { p { style = "color: red;" +"Stopping current VisionForge server" @@ -64,10 +78,9 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout } engine?.stop(1000, 2000) - engine = context.visionManager.serve(host, port) { - configuration() - server = this - }.start() + engine = context.embeddedServer(CIO, port, host) { + install(WebSockets) + }.start(false) p { style = "color: blue;" @@ -80,20 +93,46 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout logger.info { "Stopping VisionForge server" } stop(1000, 2000) engine = null - server = null } } private fun produceHtmlString( fragment: HtmlVisionFragment, ): String = createHTML().apply { - val server = server val id = "fragment[${fragment.hashCode()}/${Random.nextUInt()}]" div { this.id = id - if (server != null) { + val engine = engine + if (engine != null) { //if server exist, serve dynamically - server.serveVisionsFromFragment(consumer, "content-${counter++}", fragment) + //server.serveVisionsFromFragment(consumer, "content-${counter++}", fragment) + val cellRoute = "content-${counter++}" + + val collector: VisionCollector = mutableMapOf() + + val url = engine.environment.connectors.first().let { + url{ + protocol = URLProtocol.WS + host = it.host + port = it.port + pathSegments = listOf(cellRoute, "ws") + } + } + + visionFragment( + context, + embedData = true, + updatesUrl = url, + collector = collector, + fragment = fragment + ) + + engine.application.require(Routing) { + route(cellRoute) { + serveVisionData(TODO(), collector) + } + } + } else { //if not, use static rendering visionFragment(context, fragment = fragment) diff --git a/jupyter/src/jvmMain/kotlin/VFIntegrationBase.kt b/jupyter/src/jvmMain/kotlin/VFIntegrationBase.kt index c8f6831d..8aa9348a 100644 --- a/jupyter/src/jvmMain/kotlin/VFIntegrationBase.kt +++ b/jupyter/src/jvmMain/kotlin/VFIntegrationBase.kt @@ -48,7 +48,7 @@ public abstract class VFIntegrationBase(final override val context: Context) : J render { vision -> handler.produceHtml { - vision { vision } + vision(vision) } } @@ -83,7 +83,7 @@ public abstract class VFIntegrationBase(final override val context: Context) : J } } fragment(fragment.formBody) - vision { fragment.vision } + vision(fragment.vision) } } diff --git a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/HtmlVisionRenderer.kt b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/HtmlVisionRenderer.kt index e8c91b1e..341c38ab 100644 --- a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/HtmlVisionRenderer.kt +++ b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/HtmlVisionRenderer.kt @@ -2,10 +2,11 @@ package space.kscience.visionforge.html import kotlinx.html.* import space.kscience.dataforge.context.Context -import space.kscience.dataforge.context.Global import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.misc.DFExperimental import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.NameToken +import space.kscience.dataforge.names.asName import space.kscience.visionforge.Vision import space.kscience.visionforge.VisionManager @@ -14,31 +15,48 @@ public typealias HtmlVisionFragment = VisionTagConsumer<*>.() -> Unit @DFExperimental public fun HtmlVisionFragment(content: VisionTagConsumer<*>.() -> Unit): HtmlVisionFragment = content +public typealias VisionCollector = MutableMap> + /** * Render a fragment in the given consumer and return a map of extracted visions * @param context a context used to create a vision fragment * @param embedData embed Vision initial state in the HTML * @param fetchDataUrl fetch data after first render from given url - * @param fetchUpdatesUrl receive push updates from the server at given url + * @param updatesUrl receive push updates from the server at given url * @param idPrefix a prefix to be used before vision ids - * @param renderScript if true add rendering script after the fragment */ public fun TagConsumer<*>.visionFragment( - context: Context = Global, + context: Context, embedData: Boolean = true, fetchDataUrl: String? = null, - fetchUpdatesUrl: String? = null, + updatesUrl: String? = null, idPrefix: String? = null, + collector: VisionCollector = mutableMapOf(), fragment: HtmlVisionFragment, -): Map { - val visionMap = HashMap() +) { val consumer = object : VisionTagConsumer(this@visionFragment, context, idPrefix) { + + override fun TagConsumer.vision(name: Name?, buildOutput: VisionOutput.() -> Vision): T { + //Avoid re-creating cached visions + val actualName = name ?: NameToken( + DEFAULT_VISION_NAME, + buildOutput.hashCode().toUInt().toString() + ).asName() + + val (output, vision) = collector.getOrPut(actualName) { + val output = VisionOutput(context, actualName) + val vision = output.buildOutput() + output to vision + } + + return addVision(actualName, output.visionManager, vision, output.meta) + } + override fun DIV.renderVision(manager: VisionManager, name: Name, vision: Vision, outputMeta: Meta) { - visionMap[name] = vision // Toggle update mode - fetchUpdatesUrl?.let { + updatesUrl?.let { attributes[OUTPUT_CONNECT_ATTRIBUTE] = it } @@ -59,22 +77,22 @@ public fun TagConsumer<*>.visionFragment( } fragment(consumer) - - return visionMap } public fun FlowContent.visionFragment( - context: Context = Global, + context: Context, embedData: Boolean = true, fetchDataUrl: String? = null, - flowDataUrl: String? = null, + updatesUrl: String? = null, idPrefix: String? = null, + visionCache: VisionCollector = mutableMapOf(), fragment: HtmlVisionFragment, -): Map = consumer.visionFragment( +): Unit = consumer.visionFragment( context, embedData, fetchDataUrl, - flowDataUrl, + updatesUrl, idPrefix, - fragment + visionCache, + fragment = fragment ) \ No newline at end of file diff --git a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/VisionPage.kt b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/VisionPage.kt index 1f297abe..de18ced5 100644 --- a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/VisionPage.kt +++ b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/VisionPage.kt @@ -1,7 +1,7 @@ package space.kscience.visionforge.html import kotlinx.html.* -import space.kscience.dataforge.context.Context +import space.kscience.visionforge.VisionManager /** * A structure representing a single page with Visions to be rendered. @@ -9,7 +9,7 @@ import space.kscience.dataforge.context.Context * @param pageHeaders static headers for this page. */ public data class VisionPage( - public val context: Context, + public val visionManager: VisionManager, public val pageHeaders: Map = emptyMap(), public val content: HtmlVisionFragment, ) { diff --git a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/VisionTagConsumer.kt b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/VisionTagConsumer.kt index df0b9391..6fc915ef 100644 --- a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/VisionTagConsumer.kt +++ b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/VisionTagConsumer.kt @@ -7,7 +7,6 @@ import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.MetaSerializer import space.kscience.dataforge.meta.MutableMeta import space.kscience.dataforge.meta.isEmpty -import space.kscience.dataforge.misc.DFExperimental import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.NameToken import space.kscience.dataforge.names.asName @@ -25,9 +24,8 @@ public annotation class VisionDSL /** * A placeholder object to attach inline vision builders. */ -@DFExperimental @VisionDSL -public class VisionOutput @PublishedApi internal constructor(public val context: Context, public val name: Name?) { +public class VisionOutput @PublishedApi internal constructor(public val context: Context, public val name: Name) { public var meta: Meta = Meta.EMPTY private val requirements: MutableSet> = HashSet() @@ -36,8 +34,8 @@ public class VisionOutput @PublishedApi internal constructor(public val context: requirements.add(factory) } - internal fun buildVisionManager(): VisionManager = - if (requirements.all { req -> context.plugins.find(true) { it.tag == req.tag } != null }) { + public val visionManager: VisionManager + get() = if (requirements.all { req -> context.plugins.find(true) { it.tag == req.tag } != null }) { context.visionManager } else { val newContext = context.buildContext(NameToken(DEFAULT_VISION_NAME, name.toString()).asName()) { @@ -56,7 +54,6 @@ public class VisionOutput @PublishedApi internal constructor(public val context: * Modified [TagConsumer] that allows rendering output fragments and visions in them */ @VisionDSL -@OptIn(DFExperimental::class) public abstract class VisionTagConsumer( private val root: TagConsumer, public val context: Context, @@ -78,12 +75,14 @@ public abstract class VisionTagConsumer( * Create a placeholder for a vision output with optional [Vision] in it * TODO with multi-receivers could be replaced by [VisionTagConsumer, TagConsumer] extension */ - private fun TagConsumer.vision( + protected fun TagConsumer.addVision( name: Name, manager: VisionManager, - vision: Vision, + vision: Vision?, outputMeta: Meta = Meta.EMPTY, - ): T = div { + ): T = if (vision == null) div { + +"Empty Vision output" + } else div { id = resolveId(name) classes = setOf(OUTPUT_CLASS) if (vision.parent == null) { @@ -106,26 +105,35 @@ public abstract class VisionTagConsumer( * Insert a vision in this HTML. * TODO replace by multi-receiver */ - @OptIn(DFExperimental::class) - public fun TagConsumer.vision( + @VisionDSL + public open fun TagConsumer.vision( name: Name? = null, - @OptIn(DFExperimental::class) visionProvider: VisionOutput.() -> Vision, + buildOutput: VisionOutput.() -> Vision, ): T { - val output = VisionOutput(context, name) - val vision = output.visionProvider() - val actualName = name ?: NameToken(DEFAULT_VISION_NAME, vision.hashCode().toUInt().toString()).asName() - return vision(actualName, output.buildVisionManager(), vision, output.meta) + val actualName = name ?: NameToken(DEFAULT_VISION_NAME, buildOutput.hashCode().toUInt().toString()).asName() + val output = VisionOutput(context, actualName) + val vision = output.buildOutput() + return addVision(actualName, output.visionManager, vision, output.meta) } /** * TODO to be replaced by multi-receiver */ - @OptIn(DFExperimental::class) @VisionDSL public fun TagConsumer.vision( name: String?, - @OptIn(DFExperimental::class) visionProvider: VisionOutput.() -> Vision, - ): T = vision(name?.parseAsName(), visionProvider) + buildOutput: VisionOutput.() -> Vision, + ): T = vision(name?.parseAsName(), buildOutput) + + @VisionDSL + public open fun TagConsumer.vision( + vision: Vision, + name: Name? = null, + outputMeta: Meta = Meta.EMPTY, + ) { + val actualName = name ?: NameToken(DEFAULT_VISION_NAME, vision.hashCode().toUInt().toString()).asName() + addVision(actualName, context.visionManager, vision, outputMeta) + } /** * Process the resulting object produced by [TagConsumer] diff --git a/visionforge-core/src/jvmMain/kotlin/space/kscience/visionforge/html/HtmlVisionContext.kt b/visionforge-core/src/jvmMain/kotlin/space/kscience/visionforge/html/HtmlVisionContext.kt index 007658b1..7d82da49 100644 --- a/visionforge-core/src/jvmMain/kotlin/space/kscience/visionforge/html/HtmlVisionContext.kt +++ b/visionforge-core/src/jvmMain/kotlin/space/kscience/visionforge/html/HtmlVisionContext.kt @@ -5,13 +5,13 @@ import space.kscience.dataforge.context.ContextAware import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.MetaSerializer import space.kscience.dataforge.meta.isEmpty -import space.kscience.dataforge.misc.DFExperimental import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.NameToken import space.kscience.dataforge.names.asName import space.kscience.dataforge.names.parseAsName import space.kscience.visionforge.Vision import space.kscience.visionforge.VisionManager +import space.kscience.visionforge.html.VisionTagConsumer.Companion.DEFAULT_VISION_NAME import space.kscience.visionforge.setAsRoot import space.kscience.visionforge.visionManager @@ -34,11 +34,13 @@ public interface HtmlVisionContext : ContextAware { public typealias HtmlVisionContextFragment = context(HtmlVisionContext) TagConsumer<*>.() -> Unit -context(HtmlVisionContext) public fun HtmlVisionFragment( - content: TagConsumer<*>.() -> Unit +context(HtmlVisionContext) +public fun HtmlVisionFragment( + content: TagConsumer<*>.() -> Unit, ): HtmlVisionFragment = content -context(HtmlVisionContext) private fun TagConsumer.vision( +context(HtmlVisionContext) +private fun TagConsumer.vision( visionManager: VisionManager, name: Name, vision: Vision, @@ -61,7 +63,7 @@ context(HtmlVisionContext) private fun TagConsumer.vision( } context(HtmlVisionContext) - private fun TagConsumer.vision( +private fun TagConsumer.vision( name: Name, vision: Vision, outputMeta: Meta = Meta.EMPTY, @@ -71,26 +73,23 @@ context(HtmlVisionContext) * Insert a vision in this HTML. */ context(HtmlVisionContext) - @DFExperimental - @VisionDSL - public fun TagConsumer.vision( +@VisionDSL +public fun TagConsumer.vision( name: Name? = null, visionProvider: VisionOutput.() -> Vision, ): T { - val output = VisionOutput(context, name) + val actualName = name ?: NameToken(DEFAULT_VISION_NAME, visionProvider.hashCode().toUInt().toString()).asName() + val output = VisionOutput(context, actualName) val vision = output.visionProvider() - val actualName = - name ?: NameToken(VisionTagConsumer.DEFAULT_VISION_NAME, vision.hashCode().toUInt().toString()).asName() - return vision(output.buildVisionManager(), actualName, vision, output.meta) + return vision(output.visionManager, actualName, vision, output.meta) } /** * Insert a vision in this HTML. */ context(HtmlVisionContext) - @DFExperimental - @VisionDSL - public fun TagConsumer.vision( +@VisionDSL +public fun TagConsumer.vision( name: String?, visionProvider: VisionOutput.() -> Vision, ): T = vision(name?.parseAsName(), visionProvider) \ No newline at end of file diff --git a/visionforge-core/src/jvmMain/kotlin/space/kscience/visionforge/html/htmlExport.kt b/visionforge-core/src/jvmMain/kotlin/space/kscience/visionforge/html/htmlExport.kt index 223a9473..cba1ee31 100644 --- a/visionforge-core/src/jvmMain/kotlin/space/kscience/visionforge/html/htmlExport.kt +++ b/visionforge-core/src/jvmMain/kotlin/space/kscience/visionforge/html/htmlExport.kt @@ -4,6 +4,7 @@ import kotlinx.html.body import kotlinx.html.head import kotlinx.html.meta import kotlinx.html.stream.createHTML +import space.kscience.dataforge.context.Global import space.kscience.dataforge.misc.DFExperimental import space.kscience.visionforge.html.HtmlFragment import space.kscience.visionforge.html.VisionPage @@ -86,7 +87,7 @@ public fun VisionPage.makeFile( } } body { - visionFragment(context, fragment = content) + visionFragment(Global, fragment = content) } }.finalize() diff --git a/visionforge-server/src/main/kotlin/space/kscience/visionforge/server/VisionServer.kt b/visionforge-server/src/main/kotlin/space/kscience/visionforge/server/VisionServer.kt index d806c278..2fe84660 100644 --- a/visionforge-server/src/main/kotlin/space/kscience/visionforge/server/VisionServer.kt +++ b/visionforge-server/src/main/kotlin/space/kscience/visionforge/server/VisionServer.kt @@ -2,39 +2,34 @@ package space.kscience.visionforge.server import io.ktor.http.* import io.ktor.server.application.* -import io.ktor.server.cio.CIO -import io.ktor.server.engine.ApplicationEngine -import io.ktor.server.engine.embeddedServer -import io.ktor.server.html.respondHtml -import io.ktor.server.http.content.resources -import io.ktor.server.http.content.static -import io.ktor.server.plugins.cors.routing.CORS -import io.ktor.server.response.respond -import io.ktor.server.response.respondText +import io.ktor.server.cio.* +import io.ktor.server.engine.* +import io.ktor.server.html.* +import io.ktor.server.http.content.* +import io.ktor.server.plugins.* +import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.request.* +import io.ktor.server.response.* import io.ktor.server.routing.* -import io.ktor.server.util.getOrFail -import io.ktor.server.websocket.WebSockets -import io.ktor.server.websocket.webSocket -import io.ktor.util.pipeline.Pipeline -import io.ktor.websocket.Frame +import io.ktor.server.util.* +import io.ktor.server.websocket.* +import io.ktor.util.pipeline.* +import io.ktor.websocket.* import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.html.* -import kotlinx.html.stream.createHTML +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.ContextAware import space.kscience.dataforge.meta.* import space.kscience.dataforge.names.Name import space.kscience.visionforge.Vision import space.kscience.visionforge.VisionChange import space.kscience.visionforge.VisionManager import space.kscience.visionforge.flowChanges -import space.kscience.visionforge.html.HtmlFragment -import space.kscience.visionforge.html.HtmlVisionFragment -import space.kscience.visionforge.html.VisionPage -import space.kscience.visionforge.html.visionFragment -import space.kscience.visionforge.server.VisionServer.Companion.DEFAULT_PAGE +import space.kscience.visionforge.html.* import java.awt.Desktop import java.net.URI import kotlin.time.Duration.Companion.milliseconds @@ -57,210 +52,20 @@ public enum class DataServeMode { UPDATE } -/** - * A ktor plugin container with given [routing] - * @param serverUrl a server url including root route - */ -public class VisionServer internal constructor( - private val visionManager: VisionManager, - private val serverUrl: Url, - private val root: Route, -) : Configurable { +public class VisionRouteConfiguration( + public val visionManager: VisionManager, + override val meta: ObservableMutableMeta = MutableMeta(), +) : Configurable, ContextAware { - public val application: Application get() = root.application - - override val meta: ObservableMutableMeta = MutableMeta() + override val context: Context get() = visionManager.context /** * Update minimal interval between updates in milliseconds (if there are no updates, push will not happen */ public var updateInterval: Long by meta.long(300, key = UPDATE_INTERVAL_KEY) - /** - * Cache page fragments. If false, pages will be reconstructed on each call. Default: `true` - */ - public var cacheFragments: Boolean by meta.boolean(true) - public var dataMode: DataServeMode by meta.enum(DataServeMode.UPDATE) - private val serverHeaders: MutableMap = mutableMapOf() - - /** - * Set up a default header that is automatically added to all pages on this server - */ - public fun header(key: String, block: HtmlFragment) { - serverHeaders[key] = block - } - - private fun HTML.visionPage( - pagePath: String, - headers: Map, - visionFragment: HtmlVisionFragment, - ): Map { - var visionMap: Map? = null - - head { - meta { - charset = "utf-8" - } - (serverHeaders + headers).values.forEach { - consumer.it() - } - } - body { - //Load the fragment and remember all loaded visions - visionMap = visionFragment( - context = visionManager.context, - embedData = dataMode == DataServeMode.EMBED, - fetchDataUrl = if (dataMode != DataServeMode.EMBED) "$serverUrl$pagePath/data" else null, - flowDataUrl = if (dataMode == DataServeMode.UPDATE) "$serverUrl$pagePath/ws" else null, - fragment = visionFragment - ) - } - - return visionMap!! - } - - /** - * Server a map of visions without providing explicit html page for them - */ - private fun serveVisions(route: Route, visions: Map): Unit = route { - application.log.info("Serving visions $visions at $route") - - //Update websocket - webSocket("ws") { - val name: String = call.request.queryParameters.getOrFail("name") - application.log.debug("Opened server socket for $name") - val vision: Vision = visions[Name.parse(name)] ?: error("Plot with id='$name' not registered") - - launch { - incoming.consumeEach { - val data = it.data.decodeToString() - application.log.debug("Received update: \n$data") - val change = visionManager.jsonFormat.decodeFromString( - VisionChange.serializer(), data - ) - vision.update(change) - } - } - - try { - withContext(visionManager.context.coroutineContext) { - vision.flowChanges(updateInterval.milliseconds).onEach { update -> - val json = visionManager.jsonFormat.encodeToString( - VisionChange.serializer(), - update - ) - application.log.debug("Sending update: \n$json") - outgoing.send(Frame.Text(json)) - }.collect() - } - } catch (t: Throwable) { - application.log.info("WebSocket update channel for $name is closed with exception: $t") - } - } - //Plots in their json representation - get("data") { - val name: String = call.request.queryParameters.getOrFail("name") - - val vision: Vision? = visions[Name.parse(name)] - if (vision == null) { - call.respond(HttpStatusCode.NotFound, "Vision with name '$name' not found") - } else { - call.respondText( - visionManager.encodeToString(vision), - contentType = ContentType.Application.Json, - status = HttpStatusCode.OK - ) - } - } - } - - - /** - * Serve visions in a given [route] without providing a page template - */ - public fun serveVisions(route: String, visions: Map) { - root.route(route) { - serveVisions(this, visions) - } - } - - /** - * Compile a fragment to string and serve visions from it - */ - public fun serveVisionsFromFragment( - consumer: TagConsumer<*>, - route: String, - fragment: HtmlVisionFragment, - ): Unit { - val visions = consumer.visionFragment( - visionManager.context, - embedData = true, - fetchUpdatesUrl = "$serverUrl$route/ws", - fragment = fragment - ) - - serveVisions(route, visions) - } - - /** - * Serve a page, potentially containing any number of visions at a given [route] with given [header]. - */ - public fun page( - route: String = DEFAULT_PAGE, - headers: Map, - visionFragment: HtmlVisionFragment, - ) { - val visions = HashMap() - - val cachedHtml: String? = if (cacheFragments) { - //Create and cache page html and map of visions - createHTML(true).html { - visions.putAll(visionPage(route, headers, visionFragment)) - } - } else { - null - } - - root.route(route) { - serveVisions(this, visions) - //filled pages - get { - if (cachedHtml == null) { - //re-create html and vision list on each call - call.respondHtml { - visions.clear() - visions.putAll(visionPage(route, headers, visionFragment)) - } - } else { - //Use cached html - call.respondText(cachedHtml, ContentType.Text.Html.withCharset(Charsets.UTF_8)) - } - } - } - } - - public fun page( - vararg headers: HtmlFragment, - route: String = DEFAULT_PAGE, - title: String = "VisionForge server page '$route'", - visionFragment: HtmlVisionFragment, - ) { - page( - route, - mapOf("title" to VisionPage.title(title)) + headers.associateBy { it.hashCode().toString() }, - visionFragment - ) - } - - /** - * Render given [VisionPage] at server - */ - public fun page(route: String, page: VisionPage) { - page(route = route, headers = page.pageHeaders, visionFragment = page.content) - } - public companion object { public const val DEFAULT_PORT: Int = 7777 public const val DEFAULT_PAGE: String = "/" @@ -268,50 +73,168 @@ public class VisionServer internal constructor( } } + +/** + * Serve visions in a given [route] without providing a page template. + * [visions] could be changed during the service. + */ +public fun Route.serveVisionData( + configuration: VisionRouteConfiguration, + resolveVision: (Name) -> Vision?, +) { + application.log.info("Serving visions at ${this@serveVisionData}") + + //Update websocket + webSocket("ws") { + val name: String = call.request.queryParameters.getOrFail("name") + application.log.debug("Opened server socket for $name") + val vision: Vision = resolveVision(Name.parse(name)) ?: error("Plot with id='$name' not registered") + + launch { + incoming.consumeEach { + val data = it.data.decodeToString() + application.log.debug("Received update: \n$data") + val change = configuration.visionManager.jsonFormat.decodeFromString( + VisionChange.serializer(), data + ) + vision.update(change) + } + } + + try { + withContext(configuration.context.coroutineContext) { + vision.flowChanges(configuration.updateInterval.milliseconds).onEach { update -> + val json = configuration.visionManager.jsonFormat.encodeToString( + VisionChange.serializer(), + update + ) + application.log.debug("Sending update: \n$json") + outgoing.send(Frame.Text(json)) + }.collect() + } + } catch (t: Throwable) { + this.application.log.info("WebSocket update channel for $name is closed with exception: $t") + } + } + //Plots in their json representation + get("data") { + val name: String = call.request.queryParameters.getOrFail("name") + + val vision: Vision? = resolveVision(Name.parse(name)) + if (vision == null) { + call.respond(HttpStatusCode.NotFound, "Vision with name '$name' not found") + } else { + call.respondText( + configuration.visionManager.encodeToString(vision), + contentType = ContentType.Application.Json, + status = HttpStatusCode.OK + ) + } + } +} + +public fun Route.serveVisionData( + configuration: VisionRouteConfiguration, + cache: VisionCollector, +): Unit = serveVisionData(configuration) { cache[it]?.second } +// +///** +// * Compile a fragment to string and serve visions from it +// */ +//public fun Route.serveVisionsFromFragment( +// consumer: TagConsumer<*>, +// sererPageUrl: Url, +// visionManager: VisionManager, +// fragment: HtmlVisionFragment, +//): Unit { +// val visions = consumer.visionFragment( +// visionManager.context, +// embedData = true, +// fetchUpdatesUrl = "$serverUrl$route/ws", +// fragment = fragment +// ) +// +// serveVisionData(visionManager, visions) +//} + + +/** + * Serve a page, potentially containing any number of visions at a given [route] with given [header]. + */ +public fun Route.visionPage( + configuration: VisionRouteConfiguration, + headers: Collection, + visionFragment: HtmlVisionFragment, +) { + application.require(WebSockets) + require(CORS) { + anyHost() + } + + val visionCache: VisionCollector = mutableMapOf() + serveVisionData(configuration, visionCache) + + //filled pages + get { + //re-create html and vision list on each call + call.respondHtml { + val callbackUrl = call.url() + head { + meta { + charset = "utf-8" + } + headers.forEach { header -> + consumer.header() + } + } + body { + //Load the fragment and remember all loaded visions + visionFragment( + context = configuration.context, + embedData = configuration.dataMode == DataServeMode.EMBED, + fetchDataUrl = if (configuration.dataMode != DataServeMode.EMBED) { + URLBuilder(callbackUrl).apply { + pathSegments = pathSegments + "data" + }.buildString() + } else null, + updatesUrl = if (configuration.dataMode == DataServeMode.UPDATE) { + URLBuilder(callbackUrl).apply { + protocol = URLProtocol.WS + pathSegments = pathSegments + "ws" + }.buildString() + } else null, + visionCache = visionCache, + fragment = visionFragment + ) + } + } + + } +} + +public fun Route.visionPage( + visionManager: VisionManager, + vararg headers: HtmlFragment, + configurationBuilder: VisionRouteConfiguration.() -> Unit = {}, + visionFragment: HtmlVisionFragment, +) { + val configuration = VisionRouteConfiguration(visionManager).apply(configurationBuilder) + visionPage(configuration, listOf(*headers), visionFragment) +} + +/** + * Render given [VisionPage] at server + */ +public fun Route.visionPage(page: VisionPage, configurationBuilder: VisionRouteConfiguration.() -> Unit = {}) { + val configuration = VisionRouteConfiguration(page.visionManager).apply(configurationBuilder) + visionPage(configuration, page.pageHeaders.values, visionFragment = page.content) +} + public fun

, B : Any, F : Any> P.require( plugin: Plugin, configure: B.() -> Unit = {}, ): F = pluginOrNull(plugin) ?: install(plugin, configure) -/** - * Attach VisionForge server application to given server - */ -public fun Application.visionServer( - visionManager: VisionManager, - webServerUrl: Url, - path: String = DEFAULT_PAGE, -): VisionServer { - require(WebSockets) - require(CORS) { - anyHost() - } - -// if (pluginOrNull(CallLogging) == null) { -// install(CallLogging) -// } - - val serverRoute = require(Routing).createRouteFromPath(path) - - serverRoute { - static { - resources() - } - } - - return VisionServer(visionManager, URLBuilder(webServerUrl).apply { encodedPath = path }.build(), serverRoute) -} - -/** - * Start a stand-alone VisionForge server at given host/port - */ -public fun VisionManager.serve( - host: String = "localhost", - port: Int = VisionServer.DEFAULT_PORT, - block: VisionServer.() -> Unit, -): ApplicationEngine = context.embeddedServer(CIO, port, host) { - val url = URLBuilder(host = host, port = port).build() - visionServer(this@serve, url).apply(block) -}.start() /** * Connect to a given Ktor server using browser diff --git a/visionforge-solid/src/commonMain/kotlin/space/kscience/visionforge/solid/Solids.kt b/visionforge-solid/src/commonMain/kotlin/space/kscience/visionforge/solid/Solids.kt index 43640182..919ac4cd 100644 --- a/visionforge-solid/src/commonMain/kotlin/space/kscience/visionforge/solid/Solids.kt +++ b/visionforge-solid/src/commonMain/kotlin/space/kscience/visionforge/solid/Solids.kt @@ -33,7 +33,7 @@ public class Solids(meta: Meta) : VisionPlugin(meta), MutableVisionContainer = Solids::class public val default: Solids by lazy { - Context("@Solids"){ + Context("@Solids") { plugin(Solids) }.fetch(Solids) } diff --git a/visionforge-threejs/visionforge-threejs-server/src/jvmMain/kotlin/space/kscience/visionforge/three/serverExtensions.kt b/visionforge-threejs/visionforge-threejs-server/src/jvmMain/kotlin/space/kscience/visionforge/three/serverExtensions.kt index 1261a0de..8f52ccf1 100644 --- a/visionforge-threejs/visionforge-threejs-server/src/jvmMain/kotlin/space/kscience/visionforge/three/serverExtensions.kt +++ b/visionforge-threejs/visionforge-threejs-server/src/jvmMain/kotlin/space/kscience/visionforge/three/serverExtensions.kt @@ -4,6 +4,7 @@ import space.kscience.dataforge.context.Global import space.kscience.dataforge.misc.DFExperimental import space.kscience.visionforge.html.* import space.kscience.visionforge.makeFile +import space.kscience.visionforge.visionManager import java.awt.Desktop import java.nio.file.Path @@ -19,7 +20,7 @@ public fun makeThreeJsFile( show: Boolean = true, content: HtmlVisionFragment, ): Unit { - val actualPath = VisionPage(Global, content = content).makeFile(path) { actualPath -> + val actualPath = VisionPage(Global.visionManager, content = content).makeFile(path) { actualPath -> mapOf( "title" to VisionPage.title(title), "threeJs" to VisionPage.importScriptHeader("js/visionforge-three.js", resourceLocation, actualPath)