Skip to content

Commit

Permalink
Add shared JVM/JS Kotlin code example (#3728)
Browse files Browse the repository at this point in the history
This PR addresses task 5 from #3611 by adding and example of code
sharing between JVM and JS Kotlin targets.

I also simply copied `PlatformScalaModule` -> `PlatformKotlinModule`,
because type aliasing won't work: doc is different (and need to extend
from `KotlinModule` still).

---------

Co-authored-by: 0xnm <[email protected]>
Co-authored-by: Li Haoyi <[email protected]>
  • Loading branch information
3 people authored Oct 14, 2024
1 parent 3542fdd commit c9695a1
Show file tree
Hide file tree
Showing 8 changed files with 878 additions and 0 deletions.
118 changes: 118 additions & 0 deletions example/kotlinlib/web/5-webapp-kotlinjs-shared/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package build
import mill._, kotlinlib._, kotlinlib.js._

trait AppKotlinModule extends KotlinModule {
def kotlinVersion = "1.9.25"
}

trait AppKotlinJSModule extends AppKotlinModule with KotlinJSModule

object `package` extends RootModule with AppKotlinModule {

def ktorVersion = "2.3.12"
def kotlinHtmlVersion = "0.11.0"
def kotlinxSerializationVersion = "1.6.3"

def mainClass = Some("webapp.WebApp")

def moduleDeps = Seq(shared.jvm)

def ivyDeps = Agg(
ivy"io.ktor:ktor-server-core-jvm:$ktorVersion",
ivy"io.ktor:ktor-server-netty-jvm:$ktorVersion",
ivy"io.ktor:ktor-server-html-builder-jvm:$ktorVersion",
ivy"io.ktor:ktor-server-content-negotiation-jvm:$ktorVersion",
ivy"io.ktor:ktor-serialization-kotlinx-json-jvm:$ktorVersion",
ivy"ch.qos.logback:logback-classic:1.5.8",
)

def resources = Task {
os.makeDir(Task.dest / "webapp")
val jsPath = client.linkBinary().classes.path
os.copy(jsPath / "client.js", Task.dest / "webapp/client.js")
os.copy(jsPath / "client.js.map", Task.dest / "webapp/client.js.map")
super.resources() ++ Seq(PathRef(Task.dest))
}

object test extends KotlinTests with TestModule.Junit5 {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1",
ivy"io.ktor:ktor-server-test-host-jvm:$ktorVersion"
)
}

object shared extends Module {

trait SharedModule extends AppKotlinModule with PlatformKotlinModule {
def processors = Task {
defaultResolver().resolveDeps(
Agg(
ivy"org.jetbrains.kotlin:kotlin-serialization-compiler-plugin:${kotlinVersion()}"
)
)
}

def kotlincOptions = super.kotlincOptions() ++ Seq(
s"-Xplugin=${processors().head.path}"
)
}

object jvm extends SharedModule {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"org.jetbrains.kotlinx:kotlinx-html-jvm:$kotlinHtmlVersion",
ivy"org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlinxSerializationVersion",
)
}
object js extends SharedModule with AppKotlinJSModule {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"org.jetbrains.kotlinx:kotlinx-html-js:$kotlinHtmlVersion",
ivy"org.jetbrains.kotlinx:kotlinx-serialization-json-js:$kotlinxSerializationVersion",
)
}
}

object client extends AppKotlinJSModule {
def splitPerModule = false
def moduleDeps = Seq(shared.js)
def ivyDeps = Agg(
ivy"org.jetbrains.kotlinx:kotlinx-html-js:$kotlinHtmlVersion",
ivy"org.jetbrains.kotlinx:kotlinx-serialization-json-js:$kotlinxSerializationVersion",
)
}
}

// A Kotlin/JVM backend server wired up with a Kotlin/JS front-end, with a
// `shared` module containing code that is used in both client and server.
// Rather than the server sending HTML for the initial page load and HTML for
// page updates, it sends HTML for the initial load and JSON for page updates
// which is then rendered into HTML on the client.
//
// The JSON serialization logic and HTML generation logic in the `shared` module
// is shared between client and server, and uses libraries like `kotlinx-serialization` and
// `kotlinx-html` which work on both Kotlin/JVM and Kotlin/JS. This allows us to freely
// move code between the client and server, without worrying about what
// platform or language the code was originally implemented in.
//
// This is a minimal example of shared code compiled to Kotlin/JVM and Kotlin/JS,
// running on both client and server, meant for illustrating the build
// configuration. A full exploration of client-server code sharing techniques
// is beyond the scope of this example.

/** Usage

> ./mill test
...webapp.WebAppTestssimpleRequest ...

> ./mill runBackground

> curl http://localhost:8093
...What needs to be done...
...

> curl http://localhost:8093/static/client.js
...kotlin.js...
...

> ./mill clean runBackground

*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package client

import kotlinx.browser.document
import kotlinx.browser.window
import kotlinx.html.div
import kotlinx.html.stream.createHTML
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import org.w3c.dom.Element
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.asList
import org.w3c.dom.events.KeyboardEvent
import org.w3c.dom.get
import org.w3c.fetch.RequestInit
import shared.*

object ClientApp {

private var state = "all"

private val todoApp: Element
get() = checkNotNull(document.getElementsByClassName("todoapp")[0])

private fun postFetchUpdate(url: String) {
window
.fetch(url, RequestInit(method = "POST"))
.then { it.text() }
.then { text ->
todoApp.innerHTML = createHTML().div {
renderBody(Json.decodeFromString<List<Todo>>(text), state)
}
initListeners()
}
}

private fun bindEvent(cls: String, url: String, endState: String? = null) {
document.getElementsByClassName(cls)[0]
?.addEventListener("click", {
postFetchUpdate(url)
if (endState != null) state = endState
}
)
}

private fun bindIndexedEvent(cls: String, block: (String) -> String) {
for (elem in document.getElementsByClassName(cls).asList()) {
elem.addEventListener(
"click",
{ postFetchUpdate(block(elem.getAttribute("data-todo-index")!!)) }
)
}
}

fun initListeners() {
bindIndexedEvent("destroy") {
"/delete/$state/$it"
}
bindIndexedEvent("toggle") {
"/toggle/$state/$it"
}
bindEvent("toggle-all", "/toggle-all/$state")
bindEvent("todo-all", "/list/all", "all")
bindEvent("todo-active", "/list/active", "active")
bindEvent("todo-completed", "/list/completed", "completed")
bindEvent("clear-completed", "/clear-completed/$state")

val newTodoInput = document.getElementsByClassName("new-todo")[0] as HTMLInputElement
newTodoInput.addEventListener(
"keydown",
{
check(it is KeyboardEvent)
if (it.keyCode == 13) {
window
.fetch("/add/$state", RequestInit(method = "POST", body = newTodoInput.value))
.then { it.text() }
.then { text ->
newTodoInput.value = ""
todoApp.innerHTML = createHTML().div {
renderBody(Json.decodeFromString<List<Todo>>(text), state)
}
initListeners()
}
}
}
)
}
}

fun main(args: Array<String>) = ClientApp.initListeners()
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="error">
<appender-ref ref="STDOUT"/>
</root>
<logger name="io.netty" level="ERROR"/>
</configuration>
Loading

0 comments on commit c9695a1

Please sign in to comment.