Improve LiveView's API with inspiration from TEA

This commit is contained in:
Paul-Henri Froidmont 2025-09-03 22:38:52 +02:00
parent 4af9a78408
commit 08036ab5aa
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
10 changed files with 254 additions and 238 deletions

View file

@ -2,28 +2,23 @@ package scalive
package playground package playground
import scalive.* import scalive.*
import zio.*
final case class MyModel( import TestView.*
cls: String = "text-xs", class TestView extends LiveView[Msg, Model]:
bool: Boolean = true,
elems: List[Elem] = List.empty)
final case class Elem(name: String, age: Int)
class TestView(initialModel: MyModel) extends LiveView[TestView.Event]: def init = ZIO.succeed(Model())
import TestView.Event.*
private val modelVar = Var[MyModel](initialModel) def update(model: Model) =
case Msg.UpdateModel(f) => ZIO.succeed(f(model))
def handleEvent = def view(model: Dyn[Model]) =
case UpdateModel(f) => modelVar.update(f)
val el: HtmlElement =
div( div(
idAttr := "42", idAttr := "42",
cls := modelVar(_.cls), cls := model(_.cls),
disabled := modelVar(_.bool), disabled := model(_.bool),
ul( ul(
modelVar(_.elems).splitByIndex((_, elem) => model(_.elems).splitByIndex((_, elem) =>
li( li(
"Nom: ", "Nom: ",
elem(_.name), elem(_.name),
@ -35,5 +30,12 @@ class TestView(initialModel: MyModel) extends LiveView[TestView.Event]:
) )
object TestView: object TestView:
enum Event:
case UpdateModel(f: MyModel => MyModel) enum Msg:
case UpdateModel(f: Model => Model)
final case class Model(
cls: String = "text-xs",
bool: Boolean = true,
elems: List[Elem] = List.empty)
final case class Elem(name: String, age: Int)

View file

@ -4,33 +4,31 @@ package playground
import scalive.* import scalive.*
import zio.json.* import zio.json.*
extension (lv: LiveView[?]) extension (el: HtmlElement) def html: String = HtmlBuilder.build(el)
def renderHtml: String =
HtmlBuilder.build(lv.el)
import TestView.*
@main @main
def main = def main =
val initModel = MyModel(elems = val initModel = Model(elems =
List( List(
Elem("a", 10), Elem("a", 10),
Elem("b", 15), Elem("b", 15),
Elem("c", 30) Elem("c", 30)
) )
) )
val lv = TestView(initModel) val modelVar = Var(initModel)
val lv = TestView()
val el = lv.view(modelVar)
println("Init") println("Init")
println(lv.renderHtml) println(el.html)
println(lv.diff().toJsonPretty) println(el.diff().toJsonPretty)
println("Edit class attribue") println("Edit class attribue")
lv.handleEvent( modelVar.update(_.copy(cls = "text-lg"))
TestView.Event.UpdateModel(_.copy(cls = "text-lg")) println(el.diff().toJsonPretty)
)
println(lv.diff().toJsonPretty)
println("Edit first and last") println("Edit first and last")
lv.handleEvent( modelVar.update(
TestView.Event.UpdateModel(
_.copy(elems = _.copy(elems =
List( List(
Elem("x", 10), Elem("x", 10),
@ -39,13 +37,11 @@ def main =
) )
) )
) )
) println(el.diff().toJsonPretty)
println(lv.diff().toJsonPretty) println(el.diff().toJsonPretty)
println(lv.diff().toJsonPretty)
println("Add one") println("Add one")
lv.handleEvent( modelVar.update(
TestView.Event.UpdateModel(
_.copy(elems = _.copy(elems =
List( List(
Elem("x", 10), Elem("x", 10),
@ -55,13 +51,11 @@ def main =
) )
) )
) )
) println(el.diff().toJsonPretty)
println(lv.diff().toJsonPretty) println(el.html)
println(lv.renderHtml)
println("Remove first") println("Remove first")
lv.handleEvent( modelVar.update(
TestView.Event.UpdateModel(
_.copy(elems = _.copy(elems =
List( List(
Elem("b", 15), Elem("b", 15),
@ -70,21 +64,18 @@ def main =
) )
) )
) )
) println(el.diff().toJsonPretty)
println(lv.diff().toJsonPretty) println(el.html)
println(lv.renderHtml)
println("Remove all") println("Remove all")
lv.handleEvent( modelVar.update(
TestView.Event.UpdateModel(
_.copy( _.copy(
cls = "text-lg", cls = "text-lg",
bool = false, bool = false,
elems = List.empty elems = List.empty
) )
) )
) println(el.diff().toJsonPretty)
println(lv.diff().toJsonPretty) println(el.diff().toJsonPretty)
println(lv.diff().toJsonPretty) println(el.html)
println(lv.renderHtml)
end main end main

View file

@ -46,5 +46,5 @@ object Diff:
case Diff.Value(value) => Json.Str(value) case Diff.Value(value) => Json.Str(value)
case Diff.Dynamic(index, diff) => case Diff.Dynamic(index, diff) =>
Json.Obj(index.toString -> toJson(diff)) Json.Obj(index.toString -> toJson(diff))
case Diff.Deleted => Json.Bool(false) case Diff.Deleted => Json.Str("")
end Diff end Diff

View file

@ -46,7 +46,7 @@ extension [T](parent: Dyn[List[T]])
) )
) )
class Var[T] private (initial: T) extends Dyn[T]: private class Var[T] private (initial: T) extends Dyn[T]:
private[scalive] var currentValue: T = initial private[scalive] var currentValue: T = initial
private[scalive] var changed: Boolean = true private[scalive] var changed: Boolean = true
def set(value: T): Unit = def set(value: T): Unit =
@ -61,10 +61,10 @@ class Var[T] private (initial: T) extends Dyn[T]:
private[scalive] def setUnchanged(): Unit = changed = false private[scalive] def setUnchanged(): Unit = changed = false
private[scalive] inline def sync(): Unit = () private[scalive] inline def sync(): Unit = ()
private[scalive] def callOnEveryChild(f: T => Unit): Unit = f(currentValue) private[scalive] def callOnEveryChild(f: T => Unit): Unit = f(currentValue)
object Var: private object Var:
def apply[T](initial: T): Var[T] = new Var(initial) def apply[T](initial: T): Var[T] = new Var(initial)
class DerivedVar[I, O] private[scalive] (parent: Var[I], f: I => O) extends Dyn[O]: private class DerivedVar[I, O] private[scalive] (parent: Var[I], f: I => O) extends Dyn[O]:
private[scalive] var currentValue: O = f(parent.currentValue) private[scalive] var currentValue: O = f(parent.currentValue)
private[scalive] var changed: Boolean = true private[scalive] var changed: Boolean = true
@ -88,7 +88,7 @@ class DerivedVar[I, O] private[scalive] (parent: Var[I], f: I => O) extends Dyn[
private[scalive] def callOnEveryChild(f: O => Unit): Unit = f(currentValue) private[scalive] def callOnEveryChild(f: O => Unit): Unit = f(currentValue)
class SplitVar[I, O, Key]( private class SplitVar[I, O, Key](
parent: Dyn[List[I]], parent: Dyn[List[I]],
key: I => Key, key: I => Key,
project: (Key, Dyn[I]) => O): project: (Key, Dyn[I]) => O):

View file

@ -22,12 +22,18 @@ class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]):
mods.collect { case mod: (Mod.Attr & DynamicMod) => mod } mods.collect { case mod: (Mod.Attr & DynamicMod) => mod }
def dynamicContentMods: Seq[Mod.Content & DynamicMod] = def dynamicContentMods: Seq[Mod.Content & DynamicMod] =
mods.collect { case mod: (Mod.Content & DynamicMod) => mod } mods.collect { case mod: (Mod.Content & DynamicMod) => mod }
private[scalive] def syncAll(): Unit = mods.foreach(_.syncAll())
private[scalive] def setAllUnchanged(): Unit = dynamicMods.foreach(_.setAllUnchanged())
def prepended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.prependedAll(mod)) def prepended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.prependedAll(mod))
def apended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.appendedAll(mod)) def apended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.appendedAll(mod))
private[scalive] def syncAll(): Unit = mods.foreach(_.syncAll())
private[scalive] def setAllUnchanged(): Unit = dynamicMods.foreach(_.setAllUnchanged())
private[scalive] def diff(trackUpdates: Boolean = true): Diff =
syncAll()
val diff = DiffBuilder.build(this, trackUpdates = trackUpdates)
setAllUnchanged()
diff
class HtmlTag(val name: String, val void: Boolean = false): class HtmlTag(val name: String, val void: Boolean = false):
def apply(mods: Mod*): HtmlElement = HtmlElement(this, mods.toVector) def apply(mods: Mod*): HtmlElement = HtmlElement(this, mods.toVector)

View file

@ -1,11 +1,9 @@
package scalive package scalive
trait LiveView[Event]: import zio.*
def handleEvent: Event => Unit
val el: HtmlElement
private[scalive] def diff(trackUpdates: Boolean = true): Diff = trait LiveView[Msg, Model]:
el.syncAll() def init: Task[Model]
val diff = DiffBuilder.build(el, trackUpdates = trackUpdates) def update(model: Model): Msg => Task[Model]
el.setAllUnchanged() def view(model: Dyn[Model]): HtmlElement
diff // def subscriptions(model: Model): ZStream[Any, Nothing, Msg]

View file

@ -14,7 +14,6 @@ object LiveViewSpec extends TestSuite:
cls: String = "text-sm", cls: String = "text-sm",
items: List[NestedModel] = List.empty) items: List[NestedModel] = List.empty)
final case class NestedModel(name: String, age: Int) final case class NestedModel(name: String, age: Int)
final case class UpdateEvent(f: TestModel => TestModel)
def assertEqualsDiff(el: HtmlElement, expected: Json, trackChanges: Boolean = true) = def assertEqualsDiff(el: HtmlElement, expected: Json, trackChanges: Boolean = true) =
el.syncAll() el.syncAll()
@ -26,15 +25,12 @@ object LiveViewSpec extends TestSuite:
val tests = Tests { val tests = Tests {
test("Static only") { test("Static only") {
val lv =
new LiveView[Nothing]:
val el = div("Static string") val el = div("Static string")
def handleEvent = _ => () el.syncAll()
lv.el.syncAll()
test("init") { test("init") {
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json.Obj( Json.Obj(
"s" -> Json.Arr(Json.Str("<div>Static string</div>")) "s" -> Json.Arr(Json.Str("<div>Static string</div>"))
), ),
@ -42,27 +38,24 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff") { test("diff") {
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(el, emptyDiff)
} }
} }
test("Dynamic string") { test("Dynamic string") {
val lv =
new LiveView[UpdateEvent]:
val model = Var(TestModel()) val model = Var(TestModel())
val el = val el =
div( div(
h1(model(_.title)), h1(model(_.title)),
p(model(_.otherString)) p(model(_.otherString))
) )
def handleEvent = evt => model.update(evt.f)
lv.el.syncAll() el.syncAll()
lv.el.setAllUnchanged() el.setAllUnchanged()
test("init") { test("init") {
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json Json
.Obj( .Obj(
"s" -> Json.Arr(Json.Str("<div><h1>"), Json.Str("</h1><p>"), Json.Str("</p></div>")), "s" -> Json.Arr(Json.Str("<div><h1>"), Json.Str("</h1><p>"), Json.Str("</p></div>")),
@ -73,24 +66,24 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff no update") { test("diff no update") {
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(el, emptyDiff)
} }
test("diff with update") { test("diff with update") {
lv.handleEvent(UpdateEvent(_.copy(title = "title updated"))) model.update(_.copy(title = "title updated"))
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json.Obj("0" -> Json.Str("title updated")) Json.Obj("0" -> Json.Str("title updated"))
) )
} }
test("diff with update and no change") { test("diff with update and no change") {
lv.handleEvent(UpdateEvent(_.copy(title = "title value"))) model.update(_.copy(title = "title value"))
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(el, emptyDiff)
} }
test("diff with update in multiple commands") { test("diff with update in multiple commands") {
lv.handleEvent(UpdateEvent(_.copy(title = "title updated"))) model.update(_.copy(title = "title updated"))
lv.handleEvent(UpdateEvent(_.copy(otherString = "other string updated"))) model.update(_.copy(otherString = "other string updated"))
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json Json
.Obj( .Obj(
"0" -> Json.Str("title updated"), "0" -> Json.Str("title updated"),
@ -101,19 +94,16 @@ object LiveViewSpec extends TestSuite:
} }
test("Dynamic attribute") { test("Dynamic attribute") {
val lv =
new LiveView[UpdateEvent]:
val model = Var(TestModel()) val model = Var(TestModel())
val el = val el =
div(cls := model(_.cls)) div(cls := model(_.cls))
def handleEvent = evt => model.update(evt.f)
lv.el.syncAll() el.syncAll()
lv.el.setAllUnchanged() el.setAllUnchanged()
test("init") { test("init") {
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json Json
.Obj( .Obj(
"s" -> Json "s" -> Json
@ -124,20 +114,18 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff no update") { test("diff no update") {
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(el, emptyDiff)
} }
test("diff with update") { test("diff with update") {
lv.handleEvent(UpdateEvent(_.copy(cls = "text-md"))) model.update(_.copy(cls = "text-md"))
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json.Obj("0" -> Json.Str("text-md")) Json.Obj("0" -> Json.Str("text-md"))
) )
} }
} }
test("when mod") { test("when mod") {
val lv =
new LiveView[UpdateEvent]:
val model = Var(TestModel()) val model = Var(TestModel())
val el = val el =
div( div(
@ -145,14 +133,13 @@ object LiveViewSpec extends TestSuite:
div("static string", model(_.nestedTitle)) div("static string", model(_.nestedTitle))
) )
) )
def handleEvent = evt => model.update(evt.f)
lv.el.syncAll() el.syncAll()
lv.el.setAllUnchanged() el.setAllUnchanged()
test("init") { test("init") {
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json Json
.Obj( .Obj(
"s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")), "s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
@ -162,16 +149,16 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff no update") { test("diff no update") {
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(el, emptyDiff)
} }
test("diff with unrelated update") { test("diff with unrelated update") {
lv.handleEvent(UpdateEvent(_.copy(title = "title updated"))) model.update(_.copy(title = "title updated"))
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(el, emptyDiff)
} }
test("diff when true and nested update") { test("diff when true and nested update") {
lv.handleEvent(UpdateEvent(_.copy(bool = true))) model.update(_.copy(bool = true))
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json.Obj( Json.Obj(
"0" -> "0" ->
Json Json
@ -184,12 +171,12 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff when nested change") { test("diff when nested change") {
lv.handleEvent(UpdateEvent(_.copy(bool = true))) model.update(_.copy(bool = true))
lv.el.syncAll() el.syncAll()
lv.el.setAllUnchanged() el.setAllUnchanged()
lv.handleEvent(UpdateEvent(_.copy(bool = true, nestedTitle = "nested title updated"))) model.update(_.copy(bool = true, nestedTitle = "nested title updated"))
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json.Obj( Json.Obj(
"0" -> "0" ->
Json Json
@ -209,8 +196,6 @@ object LiveViewSpec extends TestSuite:
NestedModel("c", 20) NestedModel("c", 20)
) )
) )
val lv =
new LiveView[UpdateEvent]:
val model = Var(initModel) val model = Var(initModel)
val el = val el =
div( div(
@ -225,14 +210,13 @@ object LiveViewSpec extends TestSuite:
) )
) )
) )
def handleEvent = evt => model.update(evt.f)
lv.el.syncAll() el.syncAll()
lv.el.setAllUnchanged() el.setAllUnchanged()
test("init") { test("init") {
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json Json
.Obj( .Obj(
"s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")), "s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")),
@ -263,18 +247,16 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff no update") { test("diff no update") {
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(el, emptyDiff)
} }
test("diff with unrelated update") { test("diff with unrelated update") {
lv.handleEvent(UpdateEvent(_.copy(title = "title updated"))) model.update(_.copy(title = "title updated"))
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(el, emptyDiff)
} }
test("diff with item changed") { test("diff with item changed") {
lv.handleEvent( model.update(_.copy(items = initModel.items.updated(2, NestedModel("c", 99))))
UpdateEvent(_.copy(items = initModel.items.updated(2, NestedModel("c", 99))))
)
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json.Obj( Json.Obj(
"0" -> "0" ->
Json Json
@ -290,13 +272,9 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff with item added") { test("diff with item added") {
lv.handleEvent( model.update(_.copy(items = initModel.items.appended(NestedModel("d", 35))))
UpdateEvent(
_.copy(items = initModel.items.appended(NestedModel("d", 35)))
)
)
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json.Obj( Json.Obj(
"0" -> "0" ->
Json Json
@ -313,13 +291,9 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff with first item removed") { test("diff with first item removed") {
lv.handleEvent( model.update(_.copy(items = initModel.items.tail))
UpdateEvent(
_.copy(items = initModel.items.tail)
)
)
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json.Obj( Json.Obj(
"0" -> "0" ->
Json Json
@ -340,9 +314,9 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff all removed") { test("diff all removed") {
lv.handleEvent(UpdateEvent(_.copy(items = List.empty))) model.update(_.copy(items = List.empty))
assertEqualsDiff( assertEqualsDiff(
lv.el, el,
Json.Obj( Json.Obj(
"0" -> "0" ->
Json Json

View file

@ -1,16 +1,16 @@
import ExampleLiveView.Event
import monocle.syntax.all.* import monocle.syntax.all.*
import scalive.* import scalive.*
import zio.*
import zio.json.* import zio.json.*
final case class ExampleModel(elems: List[NestedModel], cls: String = "text-xs") import ExampleLiveView.*
final case class NestedModel(name: String, age: Int) class ExampleLiveView(someParam: String) extends LiveView[Msg, Model]:
class ExampleLiveView(someParam: String) extends LiveView[Event]: def init = ZIO.succeed(
Model(
val model = Var( isVisible = true,
ExampleModel( counter = 0,
List( elems = List(
NestedModel("a", 10), NestedModel("a", 10),
NestedModel("b", 15), NestedModel("b", 15),
NestedModel("c", 20) NestedModel("c", 20)
@ -18,11 +18,17 @@ class ExampleLiveView(someParam: String) extends LiveView[Event]:
) )
) )
def handleEvent = def update(model: Model) =
case Event.Event(value) => case Msg.IncAge(value) =>
model.update(_.focus(_.elems.index(2).age).modify(_ + value)) ZIO.succeed(model.focus(_.elems.index(2).age).modify(_ + value))
case Msg.ToggleCounter =>
ZIO.succeed(model.focus(_.isVisible).modify(!_))
case Msg.IncCounter =>
ZIO.succeed(model.focus(_.counter).modify(_ + 1))
case Msg.DecCounter =>
ZIO.succeed(model.focus(_.counter).modify(_ - 1))
val el = def view(model: Dyn[Model]) =
div( div(
h1(someParam), h1(someParam),
h2(model(_.cls)), h2(model(_.cls)),
@ -39,12 +45,39 @@ class ExampleLiveView(someParam: String) extends LiveView[Event]:
) )
), ),
button( button(
phx.click := Event.Event(1), phx.click := Msg.IncAge(1),
"Inc age" "Inc age"
),
br(),
br(),
br(),
button(
phx.click := Msg.ToggleCounter,
model(_.isVisible match
case true => "Hide counter"
case false => "Show counter")
),
model.when(_.isVisible)(
div(
button(phx.click := Msg.DecCounter, "-"),
div(model(_.counter.toString)),
button(phx.click := Msg.IncCounter, "+")
)
) )
) )
end ExampleLiveView end ExampleLiveView
object ExampleLiveView: object ExampleLiveView:
enum Event derives JsonCodec:
case Event(value: Int) enum Msg derives JsonCodec:
case IncAge(value: Int)
case ToggleCounter
case IncCounter
case DecCounter
final case class Model(
isVisible: Boolean,
counter: Int,
elems: List[NestedModel],
cls: String = "text-xs")
final case class NestedModel(name: String, age: Int)

View file

@ -15,19 +15,22 @@ import zio.stream.ZStream
import java.util.Base64 import java.util.Base64
import scala.util.Random import scala.util.Random
final case class LiveRoute[A, Event: JsonCodec]( final case class LiveRoute[A, Msg: JsonCodec, Model](
path: PathCodec[A], path: PathCodec[A],
liveviewBuilder: (A, Request) => LiveView[Event]): liveviewBuilder: (A, Request) => LiveView[Msg, Model]):
val eventCodec = JsonCodec[Event] val messageCodec = JsonCodec[Msg]
def toZioRoute(rootLayout: HtmlElement => HtmlElement): Route[Any, Nothing] = def toZioRoute(rootLayout: HtmlElement => HtmlElement): Route[Any, Throwable] =
Method.GET / path -> handler { (params: A, req: Request) => Method.GET / path -> handler { (params: A, req: Request) =>
val lv = liveviewBuilder(params, req) val lv = liveviewBuilder(params, req)
val id: String = val id: String =
s"phx-${Base64.getUrlEncoder().withoutPadding().encodeToString(Random().nextBytes(12))}" s"phx-${Base64.getUrlEncoder().withoutPadding().encodeToString(Random().nextBytes(12))}"
val token = Token.sign("secret", id, "") val token = Token.sign("secret", id, "")
lv.el.syncAll() for
Response.html( initModel <- lv.init
el = lv.view(Var(initModel))
_ = el.syncAll()
yield Response.html(
Html.raw( Html.raw(
HtmlBuilder.build( HtmlBuilder.build(
rootLayout( rootLayout(
@ -35,15 +38,16 @@ final case class LiveRoute[A, Event: JsonCodec](
idAttr := id, idAttr := id,
phx.main := true, phx.main := true,
phx.session := token, phx.session := token,
lv.el el
) )
) )
) )
) )
) )
} }
end LiveRoute
class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?]]]): class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?, ?]]]):
def diffsStream: ZStream[Any, Nothing, (LiveResponse, Meta)] = def diffsStream: ZStream[Any, Nothing, (LiveResponse, Meta)] =
sockets.changes sockets.changes
.map(m => .map(m =>
@ -51,12 +55,12 @@ class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?]]]):
.mergeAllUnbounded()(m.values.map(_.outbox).toList*) .mergeAllUnbounded()(m.values.map(_.outbox).toList*)
).flatMapParSwitch(1, 1)(identity) ).flatMapParSwitch(1, 1)(identity)
def join[Event: JsonCodec]( def join[Msg: JsonCodec, Model](
id: String, id: String,
token: String, token: String,
lv: LiveView[Event], lv: LiveView[Msg, Model],
meta: WebSocketMessage.Meta meta: WebSocketMessage.Meta
): URIO[Scope, Unit] = ): RIO[Scope, Unit] =
sockets.updateZIO { m => sockets.updateZIO { m =>
m.get(id) match m.get(id) match
case Some(socket) => case Some(socket) =>
@ -78,7 +82,7 @@ class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?]]]):
socket.inbox socket.inbox
.offer( .offer(
value value
.fromJson(using socket.clientEventCodec.decoder) .fromJson(using socket.messageCodec.decoder)
.getOrElse(throw new IllegalArgumentException()) .getOrElse(throw new IllegalArgumentException())
-> meta -> meta
).unit ).unit
@ -91,7 +95,7 @@ object LiveChannel:
def make(): UIO[LiveChannel] = def make(): UIO[LiveChannel] =
SubscriptionRef.make(Map.empty).map(new LiveChannel(_)) SubscriptionRef.make(Map.empty).map(new LiveChannel(_))
class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRoute[?, ?]]): class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRoute[?, ?, ?]]):
private val socketApp: WebSocketApp[Any] = private val socketApp: WebSocketApp[Any] =
Handler.webSocket { channel => Handler.webSocket { channel =>
@ -155,7 +159,7 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
val pathParams = route.path.decode(req.path).getOrElse(???) val pathParams = route.path.decode(req.path).getOrElse(???)
val lv = route.liveviewBuilder(pathParams, req) val lv = route.liveviewBuilder(pathParams, req)
liveChannel liveChannel
.join(message.topic, session, lv, message.meta)(using route.eventCodec) .join(message.topic, session, lv, message.meta)(using route.messageCodec)
.map(_ => None) .map(_ => None)
}.getOrElse(ZIO.succeed(None)) }.getOrElse(ZIO.succeed(None))
@ -168,12 +172,15 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
end match end match
end handleMessage end handleMessage
val routes: Routes[Any, Response] = val routes: Routes[Any, Nothing] =
Routes.fromIterable( Routes
.fromIterable(
liveRoutes liveRoutes
.map(route => route.toZioRoute(rootLayout)) .map(route => route.toZioRoute(rootLayout))
.prepended( .prepended(
Method.GET / "live" / "websocket" -> handler(socketApp.toResponse) Method.GET / "live" / "websocket" -> handler(socketApp.toResponse)
) )
).handleErrorZIO(e =>
ZIO.logErrorCause(Cause.die(e)).as(Response(status = Status.InternalServerError))
) )
end LiveRouter end LiveRouter

View file

@ -6,34 +6,38 @@ import zio.Queue
import zio.json.* import zio.json.*
import zio.stream.ZStream import zio.stream.ZStream
final case class Socket[Event: JsonCodec] private ( final case class Socket[Msg: JsonCodec, Model] private (
id: String, id: String,
token: String, token: String,
inbox: Queue[(Event, WebSocketMessage.Meta)], inbox: Queue[(Msg, WebSocketMessage.Meta)],
outbox: ZStream[Any, Nothing, (LiveResponse, WebSocketMessage.Meta)], outbox: ZStream[Any, Nothing, (LiveResponse, WebSocketMessage.Meta)],
fiber: Fiber.Runtime[Nothing, Unit], fiber: Fiber.Runtime[Throwable, Unit],
shutdown: UIO[Unit]): shutdown: UIO[Unit]):
val clientEventCodec = JsonCodec[Event] val messageCodec = JsonCodec[Msg]
object Socket: object Socket:
def start[Event: JsonCodec]( def start[Msg: JsonCodec, Model](
id: String, id: String,
token: String, token: String,
lv: LiveView[Event], lv: LiveView[Msg, Model],
meta: WebSocketMessage.Meta meta: WebSocketMessage.Meta
): URIO[Scope, Socket[Event]] = ): RIO[Scope, Socket[Msg, Model]] =
for for
inbox <- Queue.bounded[(Event, WebSocketMessage.Meta)](4) inbox <- Queue.bounded[(Msg, WebSocketMessage.Meta)](4)
outHub <- Hub.bounded[(LiveResponse, WebSocketMessage.Meta)](4) outHub <- Hub.bounded[(LiveResponse, WebSocketMessage.Meta)](4)
initDiff = lv.diff(trackUpdates = false) initModel <- lv.init
lvRef <- Ref.make(lv) modelVar = Var(initModel)
el = lv.view(modelVar)
ref <- Ref.make((modelVar, el))
initDiff = el.diff(trackUpdates = false)
fiber <- ZStream fiber <- ZStream
.fromQueue(inbox) .fromQueue(inbox)
.mapZIO { (msg, meta) => .mapZIO { (msg, meta) =>
for for
lv <- lvRef.get (modelVar, el) <- ref.get
_ = lv.handleEvent(msg) updatedModel <- lv.update(modelVar.currentValue)(msg)
diff = lv.diff() _ = modelVar.set(updatedModel)
diff = el.diff()
_ <- outHub.publish(LiveResponse.Diff(diff) -> meta) _ <- outHub.publish(LiveResponse.Diff(diff) -> meta)
yield () yield ()
} }
@ -42,4 +46,5 @@ object Socket:
stop = inbox.shutdown *> outHub.shutdown *> fiber.interrupt.unit stop = inbox.shutdown *> outHub.shutdown *> fiber.interrupt.unit
diffStream <- ZStream.fromHubScoped(outHub) diffStream <- ZStream.fromHubScoped(outHub)
outbox = ZStream.succeed(LiveResponse.InitDiff(initDiff) -> meta) ++ diffStream outbox = ZStream.succeed(LiveResponse.InitDiff(initDiff) -> meta) ++ diffStream
yield Socket[Event](id, token, inbox, outbox, fiber, stop) yield Socket[Msg, Model](id, token, inbox, outbox, fiber, stop)
end Socket