Ability to read websocket join messages

This commit is contained in:
Paul-Henri Froidmont 2025-08-23 01:42:56 +02:00
parent aae3db841b
commit fadef26425
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
8 changed files with 158 additions and 52 deletions

View file

@ -20,7 +20,7 @@ trait ScalaCommon extends ScalaModule:
) )
object core extends ScalaCommon: object core extends ScalaCommon:
// Replace with ujson, core shouldn't depend on ZIO // TODO Replace with ujson, core shouldn't depend on ZIO
def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44") def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44")
def generatedSources = Task { def generatedSources = Task {
@ -28,26 +28,9 @@ object core extends ScalaCommon:
super.generatedSources() ++ Seq(PathRef(Task.dest)) super.generatedSources() ++ Seq(PathRef(Task.dest))
} }
def jsBundle = Task {
os.copy(
from = js.bundle().path,
to = Task.dest / "static" / "scalive.js",
createFolders = true
)
PathRef(Task.dest)
}
def resources = Task {
super.resources() ++ Seq(jsBundle())
}
object test extends ScalaTests with scalalib.TestModule.Utest: object test extends ScalaTests with scalalib.TestModule.Utest:
def utestVersion = "0.9.0" def utestVersion = "0.9.0"
object js extends TypeScriptModule:
def npmDeps = Seq("morphdom@2.7.7")
def mainFileName = "index.ts"
object zio extends ScalaCommon: object zio extends ScalaCommon:
def mvnDeps = Seq(mvn"dev.zio::zio-http:3.4.0") def mvnDeps = Seq(mvn"dev.zio::zio-http:3.4.0")
def moduleDeps = Seq(core) def moduleDeps = Seq(core)
@ -70,4 +53,4 @@ object example extends ScalaCommon:
object js extends TypeScriptModule: object js extends TypeScriptModule:
def mainFileName = "app.ts" def mainFileName = "app.ts"
def moduleDeps = Seq(core.js) def npmDeps = Seq("phoenix@1.7.21", "phoenix_live_view@1.1.8")

View file

@ -1,11 +0,0 @@
import morphdom from 'morphdom';
var el1 = document.createElement('div');
el1.className = 'foo';
var el2 = document.createElement('div');
el2.className = 'bar';
morphdom(el1, el2);
export const scalive = "scalive"

View file

@ -24,6 +24,9 @@ class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]):
private[scalive] def syncAll(): Unit = mods.foreach(_.syncAll()) private[scalive] def syncAll(): Unit = mods.foreach(_.syncAll())
private[scalive] def setAllUnchanged(): Unit = dynamicMods.foreach(_.setAllUnchanged()) private[scalive] def setAllUnchanged(): Unit = dynamicMods.foreach(_.setAllUnchanged())
def prepended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.prependedAll(mod))
def apended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.appendedAll(mod))
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

@ -2,22 +2,27 @@ package scalive
import zio.json.* import zio.json.*
import java.util.Base64
import scala.util.Random
final case class Socket[Cmd](lv: LiveView[Cmd]): final case class Socket[Cmd](lv: LiveView[Cmd]):
lv.el.syncAll()
private var clientInitialized = false private var clientInitialized = false
val id: String = "scl-123" val id: String = s"phx-${Base64.getEncoder().encodeToString(Random().nextBytes(8))}"
private val token = Token.sign("secret", id, "")
private val element = lv.el.prepended(idAttr := id, dataAttr("phx-session") := token)
element.syncAll()
def receiveCommand(cmd: Cmd): Unit = def receiveCommand(cmd: Cmd): Unit =
lv.handleCommand(cmd) lv.handleCommand(cmd)
def renderHtml(rootLayout: HtmlElement => HtmlElement = identity): String = def renderHtml(rootLayout: HtmlElement => HtmlElement = identity): String =
lv.el.syncAll() element.syncAll()
HtmlBuilder.build(rootLayout(lv.el)) HtmlBuilder.build(rootLayout(element))
def syncClient: Unit = def syncClient: Unit =
lv.el.syncAll() element.syncAll()
println(DiffBuilder.build(lv.el, trackUpdates = clientInitialized).toJsonPretty) println(DiffBuilder.build(element, trackUpdates = clientInitialized).toJsonPretty)
clientInitialized = true clientInitialized = true
lv.el.setAllUnchanged() element.setAllUnchanged()

View file

@ -0,0 +1,59 @@
package scalive
import zio.json.*
import java.time.Instant
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import scala.concurrent.duration.Duration
import scala.util.Random
final case class Token[T] private (
version: Int,
liveViewId: String,
payload: T,
issuedAt: Long,
salt: String)
derives JsonCodec
object Token:
private val version = 1
def sign[T: JsonCodec](secret: String, liveViewId: String, payload: T)
: String = // TODO use messagepack and add salt
val salt = Random.nextString(16)
val token =
Token(version, liveViewId, payload, Instant.now().toEpochMilli(), salt).toJson.getBytes()
val tokenHash = hash(secret, token)
s"${base64Encode(token)}.${base64Encode(tokenHash)}"
private def hash(secret: String, value: Array[Byte]): Array[Byte] =
val mac = Mac.getInstance("HmacSHA256")
mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"))
mac.doFinal(value)
private def base64Encode(value: Array[Byte]): String =
Base64.getEncoder().encodeToString(value)
private def base64Decode(value: String): Array[Byte] =
Base64.getDecoder().decode(value)
def verify[T: JsonCodec](secret: String, token: String, maxAge: Duration)
: Either[String, (liveViewId: String, payload: T)] =
val tokenBase64 = token.takeWhile(_ != '.')
val hashBase64 = token.drop(tokenBase64.length)
val tokenBytes = base64Decode(tokenBase64)
val tokenValue = String.valueOf(tokenBytes).fromJson[Token[T]]
val currentHash = hash(secret, tokenBytes)
if base64Decode(hashBase64) == currentHash then
tokenValue.flatMap(t =>
if (t.issuedAt + maxAge.toMillis) > Instant.now().toEpochMilli() then Left("Token expired")
else Right(t.liveViewId, t.payload)
)
else Left("Invalid signature")
end Token

View file

@ -1,3 +1,27 @@
import { scalive } from "core/js/index" import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
// import topbar from "../vendor/topbar"
// import Calendar from "./hooks/calendar"
console.log(scalive) // let csrfToken =
// document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
// params: { _csrf_token: csrfToken },
// hooks: Hooks
});
// Show progress bar on live navigation and form submits
// topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" })
// window.addEventListener("phx:page-loading-start", info => topbar.delayedShow(200))
// window.addEventListener("phx:page-loading-stop", info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket

1
example/js/src/dummy.ts Normal file
View file

@ -0,0 +1 @@
// At least one ts files is needed so that mill doesn't set files in tsconfig.json to an empty array.

View file

@ -5,7 +5,8 @@ import zio.http.*
import zio.http.ChannelEvent.Read import zio.http.ChannelEvent.Read
import zio.http.codec.PathCodec import zio.http.codec.PathCodec
import zio.http.template.Html import zio.http.template.Html
import zio.json.JsonCodec import zio.json.*
import zio.json.ast.Json
final case class LiveRoute[A, Cmd]( final case class LiveRoute[A, Cmd](
path: PathCodec[A], path: PathCodec[A],
@ -17,19 +18,17 @@ final case class LiveRoute[A, Cmd](
Response.html(Html.raw(s.renderHtml(rootLayout))) 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[?, ?]]): class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRoute[?, ?]]):
private val socketApp: WebSocketApp[Any] = private val socketApp: WebSocketApp[Any] =
Handler.webSocket { channel => Handler.webSocket { channel =>
channel.receiveAll { channel.receiveAll {
case Read(WebSocketFrame.Text(content)) => case Read(WebSocketFrame.Text(content)) =>
// content.fromJson[SocketMessage] for
channel.send(Read(WebSocketFrame.text("bar"))) _ <-
content.fromJson[SocketMessage].fold(m => ZIO.logError(m), m => ZIO.log(m.toString))
_ <- channel.send(Read(WebSocketFrame.text("bar")))
yield ()
case _ => ZIO.unit case _ => ZIO.unit
} }
} }
@ -37,8 +36,9 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
val routes: Routes[Any, Response] = val routes: Routes[Any, Response] =
Routes.fromIterable( Routes.fromIterable(
liveRoutes liveRoutes
.map(route => route.toZioRoute(rootLayout)).prepended( .map(route => route.toZioRoute(rootLayout))
Method.GET / "live" / "ws" -> handler(socketApp.toResponse) .prepended(
Method.GET / "live" / "websocket" -> handler(socketApp.toResponse)
) )
) )
@ -49,5 +49,47 @@ final case class SocketMessage(
messageRef: Int, messageRef: Int,
// LiveView instance id // LiveView instance id
topic: String, topic: String,
payload: String) eventType: String,
derives JsonCodec payload: SocketMessage.Payload)
object SocketMessage:
given JsonCodec[SocketMessage] = JsonCodec[Json].transformOrFail(
{
case Json.Arr(
Chunk(joinRef, Json.Str(messageRef), Json.Str(topic), Json.Str(eventType), payload)
) =>
val payloadParsed = eventType match
case "phx_join" => payload.as[Payload.Join]
case s => Left(s"Unknown event type : $s")
payloadParsed.map(
SocketMessage(
joinRef.asString.map(_.toInt),
messageRef.toInt,
topic,
eventType,
_
)
)
case v => Left(s"Could not parse socket message ${v.toJson}")
},
m =>
Json.Arr(
m.joinRef.map(Json.Num(_)).getOrElse(Json.Null),
Json.Num(m.messageRef),
Json.Str(m.topic),
Json.Str(m.eventType),
m.payload.match
case p: Payload.Join => p.toJsonAST.getOrElse(throw new IllegalArgumentException())
)
)
enum Payload:
case Join(
url: String,
// params: Map[String, String],
session: String,
static: Option[String],
sticky: Boolean)
object Payload:
given JsonCodec[Payload.Join] = JsonCodec.derived
end SocketMessage