Skip to content
This repository has been archived by the owner on Mar 6, 2023. It is now read-only.

Commit

Permalink
Allow live views to self-update
Browse files Browse the repository at this point in the history
The `CoroutineScope.actor()` API from `kotlinx.coroutines.cannels` might
be a better fit even though it is marked as `@ObsoleteCoroutinesApi`.

Kotlin might get a new actors API at some point. It is unclear though,
when that might happen. Maybe it is worth rolling with the current one
and porting onto the new API when and if it comes out.

Kotlin/kotlinx.coroutines#87
  • Loading branch information
pmeinhardt committed Feb 11, 2023
1 parent b1a959a commit c210533
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 121 deletions.
2 changes: 1 addition & 1 deletion assets/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { setup } from "./live";

setup("/live");
setup("/live", { name: ["Firefox"] });
15 changes: 10 additions & 5 deletions assets/live/View.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import morphdom from "morphdom";

import type Socket from "./Socket";
import type { Params } from "./types";

export default class View {
private readonly socket: Socket;
private readonly root: HTMLElement;
private readonly params: Params;

constructor(socket: Socket, root: HTMLElement) {
constructor(socket: Socket, root: HTMLElement, params: Params) {
this.socket = socket;
this.root = root;
this.params = params;
}

join() {
Expand All @@ -21,13 +24,15 @@ export default class View {

protected onconnect = () => {
const path = window.location.pathname;
const parameters = { name: "Firefox" };
this.socket.send(JSON.stringify({ path, parameters }));
this.socket.send(JSON.stringify({ path, parameters: this.params }));
};

protected ondisconnect = () => {};
protected ondisconnect = () => {
// ?
};

protected onmessage = (event) => {
morphdom(this.root, event.data);
const message = JSON.parse(event.data);
morphdom(this.root, message.html);
};
}
14 changes: 7 additions & 7 deletions assets/live/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import resolve from "./resolve";
import Socket from "./Socket";
import type { Params } from "./types";
import View from "./View";

export function setup(endpoint: string) {
export function setup(endpoint: string, params: Params) {
if (["complete", "interactive", "loaded"].includes(document.readyState)) {
connect(endpoint);
connect(endpoint, params);
} else {
document.addEventListener("DOMContentLoaded", () => connect(endpoint));
const deferred = () => connect(endpoint, params);
document.addEventListener("DOMContentLoaded", deferred);
}
}

export function connect(endpoint: string) {
export function connect(endpoint: string, params: Params) {
const socket = new Socket(resolve(endpoint));
const view = new View(socket, document.documentElement);
const view = new View(socket, document.documentElement, params);

view.join();

socket.connect();

return socket;
}
2 changes: 2 additions & 0 deletions assets/live/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type Params = Record<string, string[]>;

export type TypedArray =
| Int8Array
| Uint8Array
Expand Down
3 changes: 2 additions & 1 deletion assets/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"compilerOptions": {
"lib": ["dom", "es2020"],
"target": "es2015",
"moduleResolution": "nodenext"
"moduleResolution": "nodenext",
"allowSyntheticDefaultImports": true
}
}
20 changes: 17 additions & 3 deletions src/main/kotlin/io/mnhrdt/plugins/Routing.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,28 @@ import io.ktor.server.routing.*
import io.mnhrdt.plugins.live.*
import java.io.File
import java.util.Date
import kotlinx.coroutines.*
import kotlinx.html.*

class Index(private val connected: Boolean, private val name: String) : LiveView() {
class Index(private val name: String) : LiveView() {
override fun mount() {
if (connected) {
launch {
while (true) {
delay(1000L)
assign("time", now())
}
}
}

assign("time", now())
}

override fun render(): String =
html {
head { title { +"Hello $name" } }
body {
p { +"It is ${now()} ($connected)" }
p { +"It is ${assigns["time"]} ($connected)" }
script(src = "/assets/index.js") {}
}
}
Expand All @@ -33,7 +47,7 @@ fun Application.configureRouting() {
live(scope) {
view("/") {
val name = parameters["name"] ?: "Ktor"
Index(connected, name)
Index(name)
}
}

Expand Down
104 changes: 0 additions & 104 deletions src/main/kotlin/io/mnhrdt/plugins/live/Live.kt

This file was deleted.

141 changes: 141 additions & 0 deletions src/main/kotlin/io/mnhrdt/plugins/live/live.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package io.mnhrdt.plugins.live

import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import kotlinx.html.*
import kotlinx.html.stream.*
import kotlinx.serialization.*

class LiveViewScope private constructor(options: Options) {
internal val handlers = mutableMapOf<String, LiveViewContext.() -> LiveView>()
internal var installed = false

val endpoint: String

init {
endpoint = options.endpoint
}

constructor(configure: Options.() -> Unit) : this(Options().apply(configure))

class Options {
var endpoint: String = "/live"
}
}

class LiveViewContext(val application: Application, val parameters: Parameters)

interface LiveEvent // TODO: Implementing classes need to be serializable (fun serializer(): KSerializer<…>)

@Serializable
data class LiveUpdate(val html: String) : LiveEvent

class LiveViewSession(private val session: DefaultWebSocketServerSession) : CoroutineScope by session {
suspend fun send(event: LiveEvent) = session.sendSerialized(event)

suspend fun flush() = session.flush()

suspend fun receive() = session.receiveDeserialized<LiveEvent>()
}

@Serializable
data class LiveConnect(val path: String, val parameters: Map<String, List<String>>)

class LiveRouting(private val routing: Routing, private val scope: LiveViewScope) {
init {
routing.application.plugin(WebSockets) // require plugin to be installed

if (!scope.installed) {
routing.webSocket(scope.endpoint) {
val connect = receiveDeserialized<LiveConnect>()
val init = scope.handlers[connect.path]

checkNotNull(init) { "No view registered for path ${connect.path}" }

val context = LiveViewContext(application, parametersOf(connect.parameters))
val session = LiveViewSession(this)

val view = init(context)
view.join(session)
}

scope.installed = true
}
}

fun view(path: String, params: Parameters.() -> Parameters = { this }, init: LiveViewContext.() -> LiveView) {
routing.get(path) {
val context = LiveViewContext(call.application, params(call.parameters))
val view = init(context)
view.mount()

val content = view.render()

val type = ContentType.Text.Html.withCharset(Charsets.UTF_8)
val ok = HttpStatusCode.OK

call.respond(TextContent(content, type, ok))
}

scope.handlers[path] = init
}
}

fun Routing.live(scope: LiveViewScope, block: LiveRouting.() -> Unit) = LiveRouting(this, scope).apply(block)

abstract class LiveView : CoroutineScope {
override val coroutineContext get() = checkNotNull(session) { "View is not connected" }.coroutineContext

private var session: LiveViewSession? = null
val connected: Boolean get() = session != null

private val state = mutableMapOf<String, Any>()
val assigns = state as Map<String, Any>

private var pending: Deferred<*>? = null

abstract fun mount()

abstract fun render(): String

fun assign(key: String, value: Any) {
pending?.cancel()
state[key] = value
pending = session?.async { send(LiveUpdate(render())) }
}

private suspend fun handle(event: LiveEvent) {}

private suspend fun send(event: LiveEvent) {
val session = checkNotNull(session) { "View is not connected" }
session.send(event)
}

internal suspend fun join(session: LiveViewSession) {
check(this.session == null) { "View is joined to another session already" }
this.session = session

try {
mount()

while (session.isActive) {
val event = session.receive()
println("Received LiveEvent: $event")
// TODO: handle events
}
} catch (e: ClosedReceiveChannelException) {
//
} finally {
this.session = null
}
}
}

fun LiveView.html(block: HTML.() -> Unit): String =
buildString { append("<!DOCTYPE html>\n").appendHTML().html(block = block) }

0 comments on commit c210533

Please sign in to comment.