Support live navigation

This commit is contained in:
Paul-Henri Froidmont 2025-09-13 02:44:09 +02:00
parent 0b067aa7e1
commit d42472061b
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
5 changed files with 63 additions and 30 deletions

View file

@ -8,6 +8,10 @@ package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys:
lazy val defer = htmlAttr("defer", codecs.BooleanAsAttrPresenceCodec)
object link:
def navigate(path: String, mods: Mod*): HtmlElement =
a(href := path, phx.link := "redirect", phx.linkState := "push", mods)
object phx:
private def phxAttr(suffix: String): HtmlAttr[String] =
new HtmlAttr(s"phx-$suffix", StringAsIsCodec)
@ -16,10 +20,12 @@ package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys:
private def dataPhxAttr(suffix: String): HtmlAttr[String] =
dataAttr(s"phx-$suffix")
private[scalive] lazy val session = dataPhxAttr("session")
private[scalive] lazy val main = htmlAttr("data-phx-main", BooleanAsAttrPresenceCodec)
lazy val click = phxAttrJson("click")
def value(key: String) = phxAttr(s"value-$key")
private[scalive] lazy val session = dataPhxAttr("session")
private[scalive] lazy val main = htmlAttr("data-phx-main", BooleanAsAttrPresenceCodec)
private[scalive] lazy val link = dataPhxAttr("link")
private[scalive] lazy val linkState = dataPhxAttr("link-state")
lazy val click = phxAttrJson("click")
def value(key: String) = phxAttr(s"value-$key")
implicit def stringToMod(v: String): Mod = Mod.Content.Text(v)
implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Content.Tag(el)

View file

@ -16,7 +16,15 @@ final case class WebSocketMessage(
topic: String,
eventType: String,
payload: WebSocketMessage.Payload):
val meta = WebSocketMessage.Meta(joinRef, messageRef, topic, eventType)
val meta = WebSocketMessage.Meta(joinRef, messageRef, topic, eventType)
def okReply =
WebSocketMessage(
joinRef,
messageRef,
topic,
"phx_reply",
Payload.Reply("ok", LiveResponse.Empty)
)
object WebSocketMessage:
final case class Meta(
@ -33,6 +41,8 @@ object WebSocketMessage:
val payloadParsed = eventType match
case "heartbeat" => Right(Payload.Heartbeat)
case "phx_join" => payload.as[Payload.Join]
case "phx_leave" => Right(Payload.Leave)
case "phx_close" => Right(Payload.Close)
case "event" => payload.as[Payload.Event]
case s => Left(s"Unknown event type : $s")
@ -56,6 +66,8 @@ object WebSocketMessage:
m.payload match
case Payload.Heartbeat => Json.Obj.empty
case p: Payload.Join => p.toJsonAST.getOrElse(throw new IllegalArgumentException())
case Payload.Leave => Json.Obj.empty
case Payload.Close => Json.Obj.empty
case p: Payload.Reply => p.toJsonAST.getOrElse(throw new IllegalArgumentException())
case p: Payload.Event => p.toJsonAST.getOrElse(throw new IllegalArgumentException())
case p: Payload.Diff => p.toJsonAST.getOrElse(throw new IllegalArgumentException())
@ -65,11 +77,14 @@ object WebSocketMessage:
enum Payload:
case Heartbeat
case Join(
url: String,
url: Option[String],
redirect: Option[String],
// params: Map[String, String],
session: String,
static: Option[String],
sticky: Boolean)
case Leave
case Close
case Reply(status: String, response: LiveResponse)
case Diff(diff: scalive.Diff)
case Event(`type`: Payload.EventType, event: String, value: Map[String, String])

View file

@ -17,8 +17,8 @@ class HomeLiveView() extends LiveView[String, Unit]:
cls := "space-y-2",
links.map((path, name) =>
li(
a(
href := path,
link.navigate(
path,
cls := "block px-4 py-2 rounded-lg text-gray-700 hover:bg-gray-100 hover:text-gray-900 font-medium transition",
name
)

View file

@ -1,6 +1,5 @@
package scalive
import scalive.WebSocketMessage.LiveResponse
import scalive.WebSocketMessage.Meta
import scalive.WebSocketMessage.Payload
import zio.*
@ -75,6 +74,18 @@ class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?, ?]]
.map(m.updated(id, _))
}.flatMap(_ => ZIO.logDebug(s"LiveView joined $id"))
def leave(id: String): UIO[Unit] =
sockets.updateZIO { m =>
m.get(id) match
case Some(socket) =>
for
_ <- socket.shutdown
_ <- ZIO.logDebug(s"Left LiveView $id")
yield m.removed(id)
case None =>
ZIO.logWarning(s"Tried to leave LiveView $id which doesn't exist").as(m)
}
def event(id: String, value: String, meta: WebSocketMessage.Meta): UIO[Unit] =
sockets.get.flatMap { m =>
m.get(id) match
@ -109,13 +120,16 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
Read(
WebSocketFrame.text(
WebSocketMessage(
meta.joinRef,
meta.messageRef,
meta.topic,
payload match
joinRef = meta.joinRef,
messageRef = payload match
case Payload.Close => meta.joinRef
case _ => meta.messageRef,
topic = meta.topic,
eventType = payload match
case Payload.Diff(_) => "diff"
case Payload.Close => "phx_close"
case _ => "phx_reply",
payload
payload = payload
).toJson
)
)
@ -146,21 +160,10 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
private def handleMessage(message: WebSocketMessage, liveChannel: LiveChannel)
: RIO[Scope, Option[WebSocketMessage]] =
message.payload match
case Payload.Heartbeat =>
ZIO.succeed(
Some(
WebSocketMessage(
message.joinRef,
message.messageRef,
message.topic,
"phx_reply",
Payload.Reply("ok", LiveResponse.Empty)
)
)
)
case Payload.Join(url, session, static, sticky) =>
case Payload.Heartbeat => ZIO.succeed(Some(message.okReply))
case Payload.Join(url, redirect, session, static, sticky) =>
ZIO
.fromEither(URL.decode(url)).flatMap(url =>
.fromEither(URL.decode(url.orElse(redirect).getOrElse(???))).flatMap(url =>
val req = Request(url = url)
liveRoutes.iterator
.map(route =>
@ -169,7 +172,7 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
.toOption
.map(route.liveviewBuilder(_, req))
.map(
ZIO.logDebug(s"Joining live view ${route.path.toString} ${message.topic}") *>
ZIO.logDebug(s"Joining LiveView ${route.path.toString} ${message.topic}") *>
liveChannel.join(message.topic, session, _, message.meta)(
using route.messageCodec
)
@ -178,12 +181,17 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
.collectFirst { case Some(join) => join.map(_ => None) }
.getOrElse(ZIO.succeed(None))
)
case Payload.Leave =>
liveChannel
.leave(message.topic)
.as(Some(message.okReply))
case Payload.Event(_, event, _) =>
liveChannel
.event(message.topic, event, message.meta)
.map(_ => None)
case Payload.Reply(_, _) => ZIO.die(new IllegalArgumentException())
case Payload.Diff(_) => ZIO.die(new IllegalArgumentException())
case Payload.Close => ZIO.die(new IllegalArgumentException())
end match
end handleMessage

View file

@ -64,7 +64,11 @@ object Socket:
yield ()
}.fork
stop =
inbox.shutdown *> outHub.shutdown *> clientFiber.interrupt.unit *> serverFiber.interrupt.unit
outHub.publish(Payload.Close -> meta) *>
inbox.shutdown *>
outHub.shutdown *>
clientFiber.interrupt.unit *>
serverFiber.interrupt.unit
outbox =
ZStream.succeed(
Payload.okReply(LiveResponse.InitDiff(initDiff)) -> meta