mirror of
https://github.com/phfroidmont/scalive.git
synced 2025-12-25 05:26:59 +01:00
Ability to read websocket join messages
This commit is contained in:
parent
aae3db841b
commit
fadef26425
8 changed files with 158 additions and 52 deletions
21
build.mill
21
build.mill
|
|
@ -20,7 +20,7 @@ trait ScalaCommon extends ScalaModule:
|
|||
)
|
||||
|
||||
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 generatedSources = Task {
|
||||
|
|
@ -28,26 +28,9 @@ object core extends ScalaCommon:
|
|||
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:
|
||||
def utestVersion = "0.9.0"
|
||||
|
||||
object js extends TypeScriptModule:
|
||||
def npmDeps = Seq("morphdom@2.7.7")
|
||||
def mainFileName = "index.ts"
|
||||
|
||||
object zio extends ScalaCommon:
|
||||
def mvnDeps = Seq(mvn"dev.zio::zio-http:3.4.0")
|
||||
def moduleDeps = Seq(core)
|
||||
|
|
@ -70,4 +53,4 @@ object example extends ScalaCommon:
|
|||
|
||||
object js extends TypeScriptModule:
|
||||
def mainFileName = "app.ts"
|
||||
def moduleDeps = Seq(core.js)
|
||||
def npmDeps = Seq("phoenix@1.7.21", "phoenix_live_view@1.1.8")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -24,6 +24,9 @@ class HtmlElement(val tag: HtmlTag, val mods: Vector[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 apended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.appendedAll(mod))
|
||||
|
||||
class HtmlTag(val name: String, val void: Boolean = false):
|
||||
def apply(mods: Mod*): HtmlElement = HtmlElement(this, mods.toVector)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,22 +2,27 @@ package scalive
|
|||
|
||||
import zio.json.*
|
||||
|
||||
import java.util.Base64
|
||||
import scala.util.Random
|
||||
|
||||
final case class Socket[Cmd](lv: LiveView[Cmd]):
|
||||
|
||||
lv.el.syncAll()
|
||||
|
||||
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 =
|
||||
lv.handleCommand(cmd)
|
||||
|
||||
def renderHtml(rootLayout: HtmlElement => HtmlElement = identity): String =
|
||||
lv.el.syncAll()
|
||||
HtmlBuilder.build(rootLayout(lv.el))
|
||||
element.syncAll()
|
||||
HtmlBuilder.build(rootLayout(element))
|
||||
|
||||
def syncClient: Unit =
|
||||
lv.el.syncAll()
|
||||
println(DiffBuilder.build(lv.el, trackUpdates = clientInitialized).toJsonPretty)
|
||||
element.syncAll()
|
||||
println(DiffBuilder.build(element, trackUpdates = clientInitialized).toJsonPretty)
|
||||
clientInitialized = true
|
||||
lv.el.setAllUnchanged()
|
||||
element.setAllUnchanged()
|
||||
|
|
|
|||
59
core/src/scalive/Token.scala
Normal file
59
core/src/scalive/Token.scala
Normal 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
|
||||
|
|
@ -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
1
example/js/src/dummy.ts
Normal 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.
|
||||
|
|
@ -5,7 +5,8 @@ import zio.http.*
|
|||
import zio.http.ChannelEvent.Read
|
||||
import zio.http.codec.PathCodec
|
||||
import zio.http.template.Html
|
||||
import zio.json.JsonCodec
|
||||
import zio.json.*
|
||||
import zio.json.ast.Json
|
||||
|
||||
final case class LiveRoute[A, Cmd](
|
||||
path: PathCodec[A],
|
||||
|
|
@ -17,19 +18,17 @@ final case class LiveRoute[A, Cmd](
|
|||
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")))
|
||||
for
|
||||
_ <-
|
||||
content.fromJson[SocketMessage].fold(m => ZIO.logError(m), m => ZIO.log(m.toString))
|
||||
_ <- channel.send(Read(WebSocketFrame.text("bar")))
|
||||
yield ()
|
||||
case _ => ZIO.unit
|
||||
}
|
||||
}
|
||||
|
|
@ -37,8 +36,9 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
|
|||
val routes: Routes[Any, Response] =
|
||||
Routes.fromIterable(
|
||||
liveRoutes
|
||||
.map(route => route.toZioRoute(rootLayout)).prepended(
|
||||
Method.GET / "live" / "ws" -> handler(socketApp.toResponse)
|
||||
.map(route => route.toZioRoute(rootLayout))
|
||||
.prepended(
|
||||
Method.GET / "live" / "websocket" -> handler(socketApp.toResponse)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -49,5 +49,47 @@ final case class SocketMessage(
|
|||
messageRef: Int,
|
||||
// LiveView instance id
|
||||
topic: String,
|
||||
payload: String)
|
||||
derives JsonCodec
|
||||
eventType: String,
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue