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:
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
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.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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue