diff --git a/build.mill b/build.mill index 25afce8..e5d9f4a 100644 --- a/build.mill +++ b/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") diff --git a/core/js/src/index.ts b/core/js/src/index.ts deleted file mode 100644 index 588fb52..0000000 --- a/core/js/src/index.ts +++ /dev/null @@ -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" diff --git a/core/src/scalive/HtmlElement.scala b/core/src/scalive/HtmlElement.scala index e89c3a3..c09de6c 100644 --- a/core/src/scalive/HtmlElement.scala +++ b/core/src/scalive/HtmlElement.scala @@ -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) diff --git a/core/src/scalive/Socket.scala b/core/src/scalive/Socket.scala index 3bd27a5..9d7c21b 100644 --- a/core/src/scalive/Socket.scala +++ b/core/src/scalive/Socket.scala @@ -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() diff --git a/core/src/scalive/Token.scala b/core/src/scalive/Token.scala new file mode 100644 index 0000000..fe2db2b --- /dev/null +++ b/core/src/scalive/Token.scala @@ -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 diff --git a/example/js/src/app.js b/example/js/src/app.js index 46a2147..8a108b2 100644 --- a/example/js/src/app.js +++ b/example/js/src/app.js @@ -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 diff --git a/example/js/src/dummy.ts b/example/js/src/dummy.ts new file mode 100644 index 0000000..24364e1 --- /dev/null +++ b/example/js/src/dummy.ts @@ -0,0 +1 @@ +// At least one ts files is needed so that mill doesn't set files in tsconfig.json to an empty array. diff --git a/zio/src/scalive/LiveRouter.scala b/zio/src/scalive/LiveRouter.scala index 9ff0fc8..2eecb52 100644 --- a/zio/src/scalive/LiveRouter.scala +++ b/zio/src/scalive/LiveRouter.scala @@ -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