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) 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: object phx:
private def phxAttr(suffix: String): HtmlAttr[String] = private def phxAttr(suffix: String): HtmlAttr[String] =
new HtmlAttr(s"phx-$suffix", StringAsIsCodec) 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] = private def dataPhxAttr(suffix: String): HtmlAttr[String] =
dataAttr(s"phx-$suffix") dataAttr(s"phx-$suffix")
private[scalive] lazy val session = dataPhxAttr("session") private[scalive] lazy val session = dataPhxAttr("session")
private[scalive] lazy val main = htmlAttr("data-phx-main", BooleanAsAttrPresenceCodec) private[scalive] lazy val main = htmlAttr("data-phx-main", BooleanAsAttrPresenceCodec)
lazy val click = phxAttrJson("click") private[scalive] lazy val link = dataPhxAttr("link")
def value(key: String) = phxAttr(s"value-$key") 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 stringToMod(v: String): Mod = Mod.Content.Text(v)
implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Content.Tag(el) implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Content.Tag(el)

View file

@ -16,7 +16,15 @@ final case class WebSocketMessage(
topic: String, topic: String,
eventType: String, eventType: String,
payload: WebSocketMessage.Payload): 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: object WebSocketMessage:
final case class Meta( final case class Meta(
@ -33,6 +41,8 @@ object WebSocketMessage:
val payloadParsed = eventType match val payloadParsed = eventType match
case "heartbeat" => Right(Payload.Heartbeat) case "heartbeat" => Right(Payload.Heartbeat)
case "phx_join" => payload.as[Payload.Join] 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 "event" => payload.as[Payload.Event]
case s => Left(s"Unknown event type : $s") case s => Left(s"Unknown event type : $s")
@ -56,6 +66,8 @@ object WebSocketMessage:
m.payload match m.payload match
case Payload.Heartbeat => Json.Obj.empty case Payload.Heartbeat => Json.Obj.empty
case p: Payload.Join => p.toJsonAST.getOrElse(throw new IllegalArgumentException()) 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.Reply => p.toJsonAST.getOrElse(throw new IllegalArgumentException())
case p: Payload.Event => 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()) case p: Payload.Diff => p.toJsonAST.getOrElse(throw new IllegalArgumentException())
@ -65,11 +77,14 @@ object WebSocketMessage:
enum Payload: enum Payload:
case Heartbeat case Heartbeat
case Join( case Join(
url: String, url: Option[String],
redirect: Option[String],
// params: Map[String, String], // params: Map[String, String],
session: String, session: String,
static: Option[String], static: Option[String],
sticky: Boolean) sticky: Boolean)
case Leave
case Close
case Reply(status: String, response: LiveResponse) case Reply(status: String, response: LiveResponse)
case Diff(diff: scalive.Diff) case Diff(diff: scalive.Diff)
case Event(`type`: Payload.EventType, event: String, value: Map[String, String]) 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", cls := "space-y-2",
links.map((path, name) => links.map((path, name) =>
li( li(
a( link.navigate(
href := path, path,
cls := "block px-4 py-2 rounded-lg text-gray-700 hover:bg-gray-100 hover:text-gray-900 font-medium transition", cls := "block px-4 py-2 rounded-lg text-gray-700 hover:bg-gray-100 hover:text-gray-900 font-medium transition",
name name
) )

View file

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

View file

@ -64,7 +64,11 @@ object Socket:
yield () yield ()
}.fork }.fork
stop = 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 = outbox =
ZStream.succeed( ZStream.succeed(
Payload.okReply(LiveResponse.InitDiff(initDiff)) -> meta Payload.okReply(LiveResponse.InitDiff(initDiff)) -> meta