From 385436f8fe61d91f9341c29c83e77531d2d27047 Mon Sep 17 00:00:00 2001 From: Paul-Henri Froidmont Date: Wed, 20 Aug 2025 01:31:10 +0200 Subject: [PATCH] Basic zio-http based live router --- core/src/main.scala | 10 ++--- core/src/scalive/Socket.scala | 4 +- zio/src/scalive/Example.scala | 71 +++++++------------------------- zio/src/scalive/LiveRouter.scala | 53 ++++++++++++++++++++++++ zio/src/scalive/RootLayout.scala | 8 ++-- zio/src/scalive/ScaliveZio.scala | 30 -------------- 6 files changed, 80 insertions(+), 96 deletions(-) create mode 100644 zio/src/scalive/LiveRouter.scala delete mode 100644 zio/src/scalive/ScaliveZio.scala diff --git a/core/src/main.scala b/core/src/main.scala index e652ea1..984d86e 100644 --- a/core/src/main.scala +++ b/core/src/main.scala @@ -11,7 +11,7 @@ def main = ) val s = Socket(TestView(initModel)) println("Init") - println(s.renderHtml) + println(s.renderHtml()) s.syncClient s.syncClient @@ -34,7 +34,7 @@ def main = ) ) s.syncClient - println(s.renderHtml) + println(s.renderHtml()) println("Add one") s.receiveCommand( @@ -50,7 +50,7 @@ def main = ) ) s.syncClient - println(s.renderHtml) + println(s.renderHtml()) println("Remove first") s.receiveCommand( @@ -65,7 +65,7 @@ def main = ) ) s.syncClient - println(s.renderHtml) + println(s.renderHtml()) println("Remove all") s.receiveCommand( @@ -79,5 +79,5 @@ def main = ) s.syncClient s.syncClient - println(s.renderHtml) + println(s.renderHtml()) end main diff --git a/core/src/scalive/Socket.scala b/core/src/scalive/Socket.scala index 155a4fb..3bd27a5 100644 --- a/core/src/scalive/Socket.scala +++ b/core/src/scalive/Socket.scala @@ -12,9 +12,9 @@ final case class Socket[Cmd](lv: LiveView[Cmd]): def receiveCommand(cmd: Cmd): Unit = lv.handleCommand(cmd) - def renderHtml: String = + def renderHtml(rootLayout: HtmlElement => HtmlElement = identity): String = lv.el.syncAll() - HtmlBuilder.build(lv.el, isRoot = true) + HtmlBuilder.build(rootLayout(lv.el)) def syncClient: Unit = lv.el.syncAll() diff --git a/zio/src/scalive/Example.scala b/zio/src/scalive/Example.scala index bb037ea..033c7bf 100644 --- a/zio/src/scalive/Example.scala +++ b/zio/src/scalive/Example.scala @@ -1,72 +1,29 @@ package scalive import zio.* - -import zio.http.ChannelEvent.{ExceptionCaught, Read, UserEvent, UserEventTriggered} import zio.http.* -import zio.http.template.Html object Example extends ZIOAppDefault: - val s = Socket(new TestView()) - - val socketApp: WebSocketApp[Any] = - Handler.webSocket { channel => - channel.receiveAll { - case Read(WebSocketFrame.Text("end")) => - channel.shutdown - - // Send a "bar" if the client sends a "foo" - case Read(WebSocketFrame.Text("foo")) => - channel.send(Read(WebSocketFrame.text("bar"))) - - // Send a "foo" if the client sends a "bar" - case Read(WebSocketFrame.Text("bar")) => - channel.send(Read(WebSocketFrame.text("foo"))) - - // Echo the same message 10 times if it's not "foo" or "bar" - case Read(WebSocketFrame.Text(text)) => - channel - .send(Read(WebSocketFrame.text(s"echo $text"))) - .repeatN(10) - .catchSomeCause { case cause => - ZIO.logErrorCause(s"failed sending", cause) - } - - // Send a "greeting" message to the client once the connection is established - case UserEventTriggered(UserEvent.HandshakeComplete) => - channel.send(Read(WebSocketFrame.text("Greetings!"))) - - // Log when the channel is getting closed - case Read(WebSocketFrame.Close(status, reason)) => - Console.printLine( - "Closing channel with status: " + status + " and reason: " + reason - ) - - // Print the exception if it's not a normal close - case ExceptionCaught(cause) => - Console.printLine(s"Channel error!: ${cause.getMessage}") - - case _ => - ZIO.unit - } - } - - val routes: Routes[Any, Response] = - Routes( - Method.GET / "" -> handler { (_: Request) => - Response.html(Html.raw(s.renderHtml)) - }, - Method.GET / "live" / "ws" -> handler(socketApp.toResponse) + val liveRouter = + LiveRouter( + RootLayout(_), + List( + LiveRoute( + Root / "test", + (_, req) => + val q = req.queryParam("q").map("Param : " ++ _).getOrElse("No param") + TestView(q) + ) + ) ) - override val run = Server.serve(routes).provide(Server.default) -end Example + override val run = Server.serve(liveRouter.routes).provide(Server.default) final case class MyModel(elems: List[NestedModel], cls: String = "text-xs") final case class NestedModel(name: String, age: Int) -class TestView extends LiveView[Nothing]: +class TestView(someParam: String) extends LiveView[Nothing]: val model = Var( MyModel( @@ -82,6 +39,7 @@ class TestView extends LiveView[Nothing]: val el = div( + h1(someParam), idAttr := "42", cls := model(_.cls), ul( @@ -95,3 +53,4 @@ class TestView extends LiveView[Nothing]: ) ) ) +end TestView diff --git a/zio/src/scalive/LiveRouter.scala b/zio/src/scalive/LiveRouter.scala new file mode 100644 index 0000000..9ff0fc8 --- /dev/null +++ b/zio/src/scalive/LiveRouter.scala @@ -0,0 +1,53 @@ +package scalive + +import zio.* +import zio.http.* +import zio.http.ChannelEvent.Read +import zio.http.codec.PathCodec +import zio.http.template.Html +import zio.json.JsonCodec + +final case class LiveRoute[A, Cmd]( + path: PathCodec[A], + liveviewBuilder: (A, Request) => LiveView[Cmd]): + + def toZioRoute(rootLayout: HtmlElement => HtmlElement): Route[Any, Nothing] = + Method.GET / path -> handler { (params: A, req: Request) => + val s = Socket(liveviewBuilder(params, req)) + Response.html(Html.raw(s.renderHtml(rootLayout))) + } + +// 1 Request to live route +// 2 Create live view with stateless token containing user id if connected, http params, live view id +// 3 Response with HTML and token +// 4 Websocket connection with token +// 5 Recreate exact same liveview as before using token data +class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRoute[?, ?]]): + + private val socketApp: WebSocketApp[Any] = + Handler.webSocket { channel => + channel.receiveAll { + case Read(WebSocketFrame.Text(content)) => + // content.fromJson[SocketMessage] + channel.send(Read(WebSocketFrame.text("bar"))) + case _ => ZIO.unit + } + } + + val routes: Routes[Any, Response] = + Routes.fromIterable( + liveRoutes + .map(route => route.toZioRoute(rootLayout)).prepended( + Method.GET / "live" / "ws" -> handler(socketApp.toResponse) + ) + ) + +final case class SocketMessage( + // Live session ID, auto increment defined by the client on join + joinRef: Option[Int], + // Message ID, global auto increment defined by the client on every message + messageRef: Int, + // LiveView instance id + topic: String, + payload: String) + derives JsonCodec diff --git a/zio/src/scalive/RootLayout.scala b/zio/src/scalive/RootLayout.scala index 3675f7a..64e1bd0 100644 --- a/zio/src/scalive/RootLayout.scala +++ b/zio/src/scalive/RootLayout.scala @@ -3,11 +3,13 @@ package scalive import scalive.HtmlElement object RootLayout: - def apply[RootModel](content: HtmlElement): HtmlElement = + def apply(content: HtmlElement): HtmlElement = htmlRootTag( lang := "en", - metaTag(charset := "utf-8"), + headTag( + metaTag(charset := "utf-8") + ), bodyTag( - // content + content ) ) diff --git a/zio/src/scalive/ScaliveZio.scala b/zio/src/scalive/ScaliveZio.scala deleted file mode 100644 index 41190dd..0000000 --- a/zio/src/scalive/ScaliveZio.scala +++ /dev/null @@ -1,30 +0,0 @@ -package scalive - -import zio.http.Response -import zio.http.template.Html - -// trait LiveRouter: -// type RootModel -// private lazy val viewsMap: Map[String, View] = views.map(r => (r.name, r.view)).toMap -// def rootLayout: HtmlElement[RootModel] -// def views: Seq[LiveRoute] -// -// final case class LiveRoute(name: String, view: View) - -object ZioLiveApp: -// 1 Request to live route -// 2 Create live view with stateless token containing user id if connected, http params, live view id -// 3 Response with HTML and token -// 4 Websocket connection with token -// 5 Recreate exact same liveview as before using token data - - // val testRoute = LiveRoute("test", TestView) - // val router = new LiveRouter: - // val rootLayout = htmlRootTag() - // val views = Seq(testRoute) - - // def htmlRender(v: View, model: v.Model) = - // val lv = LiveView(v, model) - // Response.html(Html.raw(HtmlBuilder.build(lv, isRoot = true))) - - private val socketApp = ???