From 8ecae787ff7124b008229d958c579c73649dd9e4 Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Sun, 6 May 2018 13:56:16 -0700 Subject: Initial commit --- .gitignore | 2 + README.md | 26 +++ build.sbt | 112 +++++++++++ client/src/main/scala/Main.scala | 62 ++++++ common/js/src/main/scala/JsTemplates.scala | 3 + common/js/src/main/scala/http/XhrBackend.scala | 51 +++++ common/js/src/main/scala/http/package.scala | 3 + common/native/src/main/scala/http/ArrayUtils.scala | 28 +++ .../native/src/main/scala/http/CurlBackend.scala | 215 +++++++++++++++++++++ common/native/src/main/scala/http/curl.scala | 82 ++++++++ common/native/src/main/scala/http/package.scala | 3 + common/shared/src/main/scala/ApiProtocol.scala | 12 ++ common/shared/src/main/scala/Message.scala | 39 ++++ common/shared/src/main/scala/Templates.scala | 16 ++ common/shared/src/main/scala/TextTemplates.scala | 46 +++++ common/shared/src/main/scala/http/Backend.scala | 8 + common/shared/src/main/scala/http/Request.scala | 9 + common/shared/src/main/scala/http/Response.scala | 6 + project/Js.scala | 20 ++ project/build.properties | 1 + project/plugins.sbt | 7 + server/src/main/scala/LiveMessages.scala | 18 ++ server/src/main/scala/Main.scala | 28 +++ server/src/main/scala/Repository.scala | 49 +++++ server/src/main/scala/Routes.scala | 80 ++++++++ ui/src/main/scala/Main.scala | 30 +++ 26 files changed, 956 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.sbt create mode 100644 client/src/main/scala/Main.scala create mode 100644 common/js/src/main/scala/JsTemplates.scala create mode 100644 common/js/src/main/scala/http/XhrBackend.scala create mode 100644 common/js/src/main/scala/http/package.scala create mode 100644 common/native/src/main/scala/http/ArrayUtils.scala create mode 100644 common/native/src/main/scala/http/CurlBackend.scala create mode 100644 common/native/src/main/scala/http/curl.scala create mode 100644 common/native/src/main/scala/http/package.scala create mode 100644 common/shared/src/main/scala/ApiProtocol.scala create mode 100644 common/shared/src/main/scala/Message.scala create mode 100644 common/shared/src/main/scala/Templates.scala create mode 100644 common/shared/src/main/scala/TextTemplates.scala create mode 100644 common/shared/src/main/scala/http/Backend.scala create mode 100644 common/shared/src/main/scala/http/Request.scala create mode 100644 common/shared/src/main/scala/http/Response.scala create mode 100644 project/Js.scala create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 server/src/main/scala/LiveMessages.scala create mode 100644 server/src/main/scala/Main.scala create mode 100644 server/src/main/scala/Repository.scala create mode 100644 server/src/main/scala/Routes.scala create mode 100644 ui/src/main/scala/Main.scala diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f388d96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +database.sqlite \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea10134 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Scala Triad +A demo webapp that uses Scala's coolest libraries and features. The +project is called "triad" as it includes parts written in all 3 of +Scala's target plaforms: JVM, JS, and Native. + +The demo is a chat application and demontstrates how a full-fledged +web application can be built to be 100% typesafe, sharing a model +across backend, frontend and commandline utility. + +It also showcases how an application can gracefuly degrade if a user +does not have javascript enabled. + +## Features + +- Server written in Scala + - Akka HTTP for routing + - Akka streams for safe concurrency abstractions + - Slick for database access + +- Scala Native as a commandline interface + +- ScalaJS as an interactive frontend + +- Shared model classes, utilities, and formats across all platforms + - spray-json (derivation) + - scalatags diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..c152f4b --- /dev/null +++ b/build.sbt @@ -0,0 +1,112 @@ +// shadow sbt-scalajs' crossProject and CrossType until Scala.js 1.0.0 is released + +import sbtcrossproject.{crossProject, CrossType} +import scalajscrossproject.ScalaJSCrossPlugin.autoImport.{ + toScalaJSGroupID => _, + _ +} + +scalaVersion in ThisBuild := "2.12.6" +version in ThisBuild := { + import sys.process._ + ("git describe --always --dirty=-SNAPSHOT --match v[0-9].*" !!).tail.trim +} +scalacOptions in ThisBuild ++= Seq( + "-feature", + "-language:_", + "-unchecked", + "-deprecation", + "-Xlint", + "-encoding", + "utf8" +) + +lazy val common = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Full) + .settings( + testFrameworks += new TestFramework("utest.runner.Framework"), + libraryDependencies ++= Seq( + "xyz.driver" %%% "spray-json-derivation" % "0.4.3", + "com.lihaoyi" %%% "scalatags" % "0.6.7", + "com.lihaoyi" %%% "utest" % "0.6.3" % "test" + ), + sourceGenerators in Compile += Def.task { + val file = (sourceManaged in Compile).value / "scala" / "BuildInfo.scala" + val content = + s"""package triad + |object BuildInfo { + | final val Version: String = "${version.value}" + |} + |""".stripMargin + IO.write(file, content) + Seq(file) + } + ) + .jsSettings( + libraryDependencies ++= Seq( + "org.scala-js" %%% "scalajs-dom" % "0.9.2", + "org.scala-js" %%% "scalajs-java-time" % "0.2.4" + ) + ) + .nativeSettings( + scalaVersion := "2.11.12", + nativeLinkStubs := true, + libraryDependencies ++= Seq( + "io.crashbox" %%% "commando" % "0.1.1" + ), + sourceGenerators in Compile += Def.task { + import sys.process._ + val file = (sourceManaged in Compile).value / "scala" / "NativeBuildInfo.scala" + val content = + s"""package triad + |object NativeBuildInfo { + | final val Platform: String = + | "${("uname -s" !!).trim}/${("uname -m" !!).trim}" + | final val NativeVersion: String = "${nativeVersion}" + |} + |""".stripMargin + IO.write(file, content) + Seq(file) + } + ) + +lazy val commonJS = common.js +lazy val commonJVM = common.jvm +lazy val commonNative = common.native + +lazy val server = project + .settings( + libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-stream" % "2.5.11", + "com.typesafe.akka" %% "akka-http" % "10.1.0", + "com.typesafe.akka" %% "akka-http-spray-json" % "10.1.0", + "com.typesafe.slick" %% "slick" % "3.2.3", + "org.slf4j" % "slf4j-nop" % "1.6.4", + "org.xerial" % "sqlite-jdbc" % "3.21.0.1" + ) + ) + .dependsOn(commonJVM) + .settings(Js.dependsOnJs(ui)) + +lazy val ui = project + .enablePlugins(ScalaJSPlugin) + .disablePlugins(RevolverPlugin) + .dependsOn(commonJS) + +lazy val client = project + .enablePlugins(ScalaNativePlugin) + .settings( + scalaVersion := "2.11.12", + nativeMode := "debug", + ) + .dependsOn(commonNative) + +lazy val root = (project in file(".")) + .aggregate(commonJS, commonJVM, commonNative, client, ui, server) + .settings( + publish := {}, + publishLocal := {} + ) + +addCommandAlias("start", "reStart") +addCommandAlias("stop", "reStop") diff --git a/client/src/main/scala/Main.scala b/client/src/main/scala/Main.scala new file mode 100644 index 0000000..4b11fe0 --- /dev/null +++ b/client/src/main/scala/Main.scala @@ -0,0 +1,62 @@ +package triad + +import ApiProtocol._ +import http.Request +import commando._ +import spray.json._ + +import scala.concurrent._ +import scala.concurrent.duration._ +import scala.util.control.NonFatal + +object Main { + + val command: Command = cmd("triad")( + opt("server", 's', "url" -> true), + opt("verbose", 'v') + ).sub( + cmd("message")( + opt("author", param = "name" -> true), + pos("content") + ).run { args => + val server = + args.get("server").map(_.head).getOrElse("http://localhost:9090") + val author = args.get("author").map(_.head).getOrElse(sys.env("USER")) + val content = args("content").head + val verbose = args.get("verbose").map(_ => true).getOrElse(false) + + val message = Message(content, author).toJson.compactPrint + + val req = Request("POST", + s"$server/messages", + Map("Content-type" -> "application/json"), + message.getBytes("utf-8")) + + if (verbose) { + System.err.println(req.url) + System.err.println(message) + } + + try { + Await.result(http.send(req), 10.seconds) match { + case resp if 200 <= resp.statusCode && resp.statusCode <= 300 => + sys.exit(0) + case resp => + System.err.println( + s"Bad response code while posting message ${resp.statusCode}.") + sys.exit(1) + } + } catch { + case NonFatal(e) => + System.err.println(e.getMessage) + sys.exit(1) + } + }, + cmd("completion")().run { _ => + System.out.println(command.completion) + } + ) + + def main(args: Array[String]): Unit = commando.parse(args, command) + +} diff --git a/common/js/src/main/scala/JsTemplates.scala b/common/js/src/main/scala/JsTemplates.scala new file mode 100644 index 0000000..bbb29c5 --- /dev/null +++ b/common/js/src/main/scala/JsTemplates.scala @@ -0,0 +1,3 @@ +package triad + +object JsTemplates extends Templates(scalatags.JsDom) diff --git a/common/js/src/main/scala/http/XhrBackend.scala b/common/js/src/main/scala/http/XhrBackend.scala new file mode 100644 index 0000000..3a791c1 --- /dev/null +++ b/common/js/src/main/scala/http/XhrBackend.scala @@ -0,0 +1,51 @@ +package triad +package http + +import org.scalajs.dom.{ErrorEvent, Event, XMLHttpRequest} + +import scala.concurrent.{Future, Promise, TimeoutException} +import scala.scalajs.js +import scala.scalajs.js.typedarray.{ArrayBuffer, Int8Array} + +trait XhrBackend extends Backend { + + def send(request: Request): Future[Response] = { + val promise = Promise[Response] + val xhr = new XMLHttpRequest() + + xhr.open(request.method, request.url) + xhr.responseType = "arraybuffer" + for ((name, value) <- request.headers) { + xhr.setRequestHeader(name, value) + } + + xhr.send(js.Array(request.body: _*)) + + xhr.onload = (e: Event) => { + val body: Array[Byte] = if (!js.isUndefined(xhr.response)) { + val buffer = new Int8Array(xhr.response.asInstanceOf[ArrayBuffer]) + buffer.toArray + } else { + Array.empty[Byte] + } + + val response = Response( + xhr.status, + Map.empty, + body + ) + promise.success(response) + } + + xhr.onerror = (e: ErrorEvent) => { + promise.failure(new RuntimeException(s"XHR error: ${e.message}")) + } + xhr.ontimeout = (e: Event) => { + promise.failure( + new TimeoutException(s"Request timed out: ${xhr.statusText}")) + } + + promise.future + } + +} diff --git a/common/js/src/main/scala/http/package.scala b/common/js/src/main/scala/http/package.scala new file mode 100644 index 0000000..7b680eb --- /dev/null +++ b/common/js/src/main/scala/http/package.scala @@ -0,0 +1,3 @@ +package triad + +package object http extends XhrBackend diff --git a/common/native/src/main/scala/http/ArrayUtils.scala b/common/native/src/main/scala/http/ArrayUtils.scala new file mode 100644 index 0000000..00c1067 --- /dev/null +++ b/common/native/src/main/scala/http/ArrayUtils.scala @@ -0,0 +1,28 @@ +package triad +package http + +import scala.scalanative.native._ + +object ArrayUtils { + + def toBuffer(array: Array[Byte])(implicit z: Zone): Ptr[Byte] = { + val buffer = z.alloc(array.size) + var i = 0 + while (i < array.size) { + buffer(i) = array(i) + i += 1 + } + buffer + } + + def toArray(buffer: Ptr[Byte], size: CSize): Array[Byte] = { + val array = new Array[Byte](size.toInt) + var i = 0 + while (i < array.size) { + array(i) = buffer(i) + i += 1 + } + array + } + +} diff --git a/common/native/src/main/scala/http/CurlBackend.scala b/common/native/src/main/scala/http/CurlBackend.scala new file mode 100644 index 0000000..4dc8577 --- /dev/null +++ b/common/native/src/main/scala/http/CurlBackend.scala @@ -0,0 +1,215 @@ +package triad +package http + +import curl._ +import curlh._ + +import scala.collection.{Map, mutable} +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future +import scala.scalanative.native._ +import scala.util.{Failure, Success, Try} + +object CurlBackend { + + type Chunk = CStruct4[Ptr[CStruct0], Ptr[CStruct0], CSize, Ptr[Byte]] + implicit class ChunksOps(val self: Ptr[Chunk]) extends AnyVal { + @inline def prev: Ptr[Chunk] = (!self._1).cast[Ptr[Chunk]] + @inline def prev_=(other: Ptr[Chunk]): Unit = + !(self._1) = other.cast[Ptr[CStruct0]] + @inline def next: Ptr[Chunk] = (!self._2).cast[Ptr[Chunk]] + @inline def next_=(other: Ptr[Chunk]): Unit = + !(self._2) = other.cast[Ptr[CStruct0]] + @inline def size: CSize = !(self._3) + @inline def size_=(value: CSize): Unit = !(self._3) = value + @inline def buffer: Ptr[Byte] = !(self._4) + @inline def buffer_=(value: Ptr[Byte]): Unit = !(self._4) = value + } + + object Chunk { + + @inline def NullPtr[T] = 0.cast[Ptr[T]] + + def allocHead() = allocAppend(0, NullPtr[Chunk]) + + def allocAppend(size: CSize, + head: Ptr[Chunk] = NullPtr[Chunk]): Ptr[Chunk] = { + val chunk: Ptr[Chunk] = stdlib.malloc(sizeof[Chunk]).cast[Ptr[Chunk]] + if (chunk == NullPtr[Chunk]) return NullPtr[Chunk] + + chunk.buffer = stdlib.malloc(size) + + if (chunk.buffer == NullPtr[Chunk] && size != 0) { + stdlib.free(chunk.cast[Ptr[Byte]]) + return NullPtr[Chunk] + } + chunk.size = size + + if (head == NullPtr[Chunk]) { // this will be the head + chunk.next = chunk + chunk.prev = chunk + } else { + val last = head.prev + last.next = chunk + chunk.prev = last + head.prev = chunk + chunk.next = head + } + chunk + } + + def freeAll(head: Ptr[Chunk]): Unit = { + var chunk: Ptr[Chunk] = head + do { + val next = chunk.next + stdlib.free(chunk.buffer) + stdlib.free(chunk.cast[Ptr[Byte]]) + chunk = next + } while (chunk != head) + } + + def toArray(head: Ptr[Chunk]): Array[Byte] = { + val buffer = new ArrayBuffer[Byte]() + var chunk = head + do { + val next = chunk.next + var i = 0l + while (i < next.size) { + buffer += next.buffer(i) + i += 1 + } + chunk = next + } while (chunk != head) + buffer.toArray + } + + def traverse(head: Ptr[Chunk])(fct: Array[Byte] => Unit) = { + var chunk = head + do { + val next = chunk.next + val buffer = new ArrayBuffer[Byte]() + var i = 0l + while (i < next.size) { + buffer += next.buffer(i) + i += 1 + } + chunk = next + fct(buffer.toArray) + } while (chunk != head) + } + + } + + private def receive(data: Ptr[Byte], + size: CSize, + nmemb: CSize, + userdata: Ptr[Byte]): CSize = { + val head = userdata.cast[Ptr[Chunk]] + val length = size * nmemb + val chunk = Chunk.allocAppend(length, head) + string.memcpy(chunk.buffer, data, chunk.size) + chunk.size + } + private val receivePtr: WriteFunction = CFunctionPtr.fromFunction4(receive) + + private def chain[A](success: A)(calls: (() => A)*) = { + var result: A = success + for (c <- calls if result == success) { + result = c() + } + result + } + + private def request(request: Request)(implicit z: Zone): Try[Response] = { + val curl: CURL = curl_easy_init() + if (curl != null) { + val errorBuffer = stackalloc[Byte](CURL_ERROR_SIZE) + !errorBuffer = 0 + val requestHeaders = stackalloc[curl_slist](1) + !requestHeaders = 0.cast[curl_slist] + + val responseChunks = Chunk.allocHead() + val responseHeaderChunks = Chunk.allocHead() + + val curlResult = chain(CURLcode.CURL_OK)( + () => + curl_easy_setopt(curl, CURLoption.CURLOPT_ERRORBUFFER, errorBuffer), + () => + curl_easy_setopt(curl, + CURLoption.CURLOPT_CUSTOMREQUEST, + toCString(request.method)), + () => + curl_easy_setopt(curl, + CURLoption.CURLOPT_URL, + toCString(request.url)), + () => { + val buffer = ArrayUtils.toBuffer(request.body) + curl_easy_setopt(curl, CURLoption.CURLOPT_POSTFIELDS, buffer) + curl_easy_setopt(curl, + CURLoption.CURLOPT_POSTFIELDSIZE, + request.body.size) + }, + () => { + for ((k, v) <- request.headers) { + !requestHeaders = + curl_slist_append(!requestHeaders, toCString(s"$k:$v")) + } + curl_easy_setopt(curl, CURLoption.CURLOPT_HTTPHEADER, !requestHeaders) + }, + () => + curl_easy_setopt(curl, CURLoption.CURLOPT_WRITEFUNCTION, receivePtr), + () => + curl_easy_setopt(curl, CURLoption.CURLOPT_WRITEDATA, responseChunks), + () => + curl_easy_setopt(curl, + CURLoption.CURLOPT_HEADERDATA, + responseHeaderChunks), + () => curl_easy_perform(curl) + ) + + val result = curlResult match { + case CURLcode.CURL_OK => + val responseCode: Ptr[Long] = stackalloc[Long](1) + curl_easy_getinfo(curl, CURLINFO.CURLINFO_RESPONSE_CODE, responseCode) + + val responseHeaders = mutable.HashMap.empty[String, String] + Chunk.traverse(responseHeaderChunks) { headerChunk => + val line = new String(headerChunk, "utf-8").trim + if (line.contains(":")) { + val parts = line.split(":", 2) + responseHeaders += parts(0) -> parts(1) + } + } + + Success( + Response( + statusCode = (!responseCode).toInt, + headers = responseHeaders.toMap, + body = Chunk.toArray(responseChunks) + )) + + case code => + val message = curl_easy_strerror(curl, code) + Failure( + new RuntimeException( + s"${fromCString(errorBuffer)} (curl exit status $code)")) + } + Chunk.freeAll(responseChunks) + Chunk.freeAll(responseHeaderChunks) + curl_slist_free_all(!requestHeaders) + curl_easy_cleanup(curl) + result + } else { + Failure(new RuntimeException(s"curl failed to initialize")) + } + } + + def curlVersion = fromCString(curl_version()) + +} + +trait CurlBackend extends Backend { + def send(req: Request): Future[Response] = Zone { implicit z => + Future.fromTry(CurlBackend.request(req)) + } +} diff --git a/common/native/src/main/scala/http/curl.scala b/common/native/src/main/scala/http/curl.scala new file mode 100644 index 0000000..1eda584 --- /dev/null +++ b/common/native/src/main/scala/http/curl.scala @@ -0,0 +1,82 @@ +package triad +package http + +import scala.scalanative.native._ + +object curlh { + final val CURL_ERROR_SIZE = 256 + + type CURL = Ptr[CStruct0] + + /* + * #define CURLOPTTYPE_LONG 0 + * #define CURLOPTTYPE_OBJECTPOINT 10000 + * #define CURLOPTTYPE_STRINGPOINT 10000 + * #define CURLOPTTYPE_FUNCTIONPOINT 20000 + * #define CURLOPTTYPE_OFF_T 30000 + */ + + type CURLcode = CInt + object CURLcode { + final val CURL_OK: CInt = 0 + } + + type CURLoption = CInt + object CURLoption { + final val CURLOPT_VERBOSE: CInt = 41 + final val CURLOPT_POSTFIELDSIZE: CInt = 60 + final val CURLOPT_WRITEDATA: CInt = 10001 + final val CURLOPT_URL: CInt = 10002 + final val CURLOPT_ERRORBUFFER: CInt = 10010 + final val CURLOPT_POSTFIELDS: CInt = 10015 + final val CURLOPT_HTTPHEADER: CInt = 10023 + final val CURLOPT_HEADERDATA: CInt = 10029 + final val CURLOPT_CUSTOMREQUEST: CInt = 10036 + final val CURLOPT_WRITEFUNCTION: CInt = 20011 + } + + type CURLINFO = CInt + object CURLINFO { + final val CURLINFO_RESPONSE_CODE = 0x200002 + } + + type WriteFunction = CFunctionPtr4[ + Ptr[Byte], // data + CSize, // size + CSize, // nmemb + Ptr[Byte], // userdata + CSize // return + ] + + type curl_slist = Ptr[CStruct0] + +} + +@link("curl") +@extern +object curl { + import curlh._ + + def curl_easy_init(): CURL = extern + + def curl_easy_setopt(curl: CURL, + option: CURLoption, + parameter: CVararg*): CURLcode = extern + + def curl_easy_perform(curl: CURL): CURLcode = extern + + def curl_easy_getinfo(curl: CURL, + option: CURLINFO, + parameter: CVararg*): CURLcode = extern + + def curl_easy_cleanup(curl: CURL): Unit = extern + + def curl_easy_strerror(curl: CURL, code: CURLcode): CString = extern + + def curl_slist_append(head: curl_slist, string: CString): curl_slist = extern + + def curl_slist_free_all(head: curl_slist): Unit = extern + + def curl_version(): CString = extern + +} diff --git a/common/native/src/main/scala/http/package.scala b/common/native/src/main/scala/http/package.scala new file mode 100644 index 0000000..63b5405 --- /dev/null +++ b/common/native/src/main/scala/http/package.scala @@ -0,0 +1,3 @@ +package triad + +package object http extends CurlBackend diff --git a/common/shared/src/main/scala/ApiProtocol.scala b/common/shared/src/main/scala/ApiProtocol.scala new file mode 100644 index 0000000..5d0e00a --- /dev/null +++ b/common/shared/src/main/scala/ApiProtocol.scala @@ -0,0 +1,12 @@ +package triad + +import java.time.Instant +import spray.json.{DerivedJsonProtocol, JsNumber, JsValue, JsonFormat} + +object ApiProtocol extends DerivedJsonProtocol { + implicit val timestampFormat: JsonFormat[Instant] = new JsonFormat[Instant] { + def read(js: JsValue) = Instant.ofEpochMilli(js.convertTo[Long]) + def write(i: Instant) = JsNumber(i.toEpochMilli) + } + implicit val messageFormat = jsonFormat[Message] +} diff --git a/common/shared/src/main/scala/Message.scala b/common/shared/src/main/scala/Message.scala new file mode 100644 index 0000000..7ee36cb --- /dev/null +++ b/common/shared/src/main/scala/Message.scala @@ -0,0 +1,39 @@ +package triad + +import java.security.MessageDigest +import java.time.Instant + +case class Message(content: String, + author: String, + timestamp: Instant = Instant.now()) { + + lazy val id: String = { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(content.getBytes) + digest.update(author.getBytes) + digest.update((timestamp.getEpochSecond & 0xff).toByte) + digest.update(((timestamp.getEpochSecond >> 8) & 0xff).toByte) + digest.update(((timestamp.getEpochSecond >> 16) & 0xff).toByte) + digest.update(((timestamp.getEpochSecond >> 24) & 0xff).toByte) + digest.update(((timestamp.getEpochSecond >> 32) & 0xff).toByte) + digest.update(((timestamp.getEpochSecond >> 40) & 0xff).toByte) + digest.update(((timestamp.getEpochSecond >> 48) & 0xff).toByte) + digest.update(((timestamp.getEpochSecond >> 56) & 0xff).toByte) + Message.bytesToHex(digest.digest()) + } + +} + +object Message { + private def bytesToHex(hash: Array[Byte]): String = { + val hexString = new StringBuffer(hash.length * 2) + var i = 0 + while (i < hash.length) { + val hex = Integer.toHexString(0xff & hash(i)) + if (hex.length == 1) hexString.append('0') + hexString.append(hex) + i += 1 + } + hexString.toString + } +} diff --git a/common/shared/src/main/scala/Templates.scala b/common/shared/src/main/scala/Templates.scala new file mode 100644 index 0000000..82e1dda --- /dev/null +++ b/common/shared/src/main/scala/Templates.scala @@ -0,0 +1,16 @@ +package triad + +class Templates[Builder, Output <: FragT, FragT]( + val bundle: scalatags.generic.Bundle[Builder, Output, FragT]) { + import bundle.all._ + + def message(msg: Message) = li( + div(`class` := "from")(msg.author), + div(`class` := "content")(msg.content) + ) + + def conversation(messages: Seq[Message]): Tag = ul(id := "conversation")( + for (msg <- messages.sortBy(_.timestamp)) yield message(msg) + ) + +} diff --git a/common/shared/src/main/scala/TextTemplates.scala b/common/shared/src/main/scala/TextTemplates.scala new file mode 100644 index 0000000..24a3b0e --- /dev/null +++ b/common/shared/src/main/scala/TextTemplates.scala @@ -0,0 +1,46 @@ +package triad + +object TextTemplates extends Templates(scalatags.Text) { + import bundle.all._ + + def scripts(js: Boolean = true) = + if (js) + Seq( + script(`type` := "text/javascript", + src := "/assets/ui/js/ui-fastopt.js"), + script(`type` := "text/javascript")( + raw( + """|document.addEventListener("DOMContentLoaded", function(event) { + | try { + | // root element that will contain the ScalaJS application + | var root = document.getElementById("conversation"); + | + | // clear any existing content + | while (root.firstChild) { + | root.removeChild(root.firstChild); + | } + | + | // run ScalaJS application + | console.info("Starting ScalaJS application...") + | triad.Main().main(root) + | } catch(ex) { + | // display warning message in case of exception + | //document.getElementById("scalajs-error").style.display = "block"; + | throw ex; + | } + |}); + |""".stripMargin + ) + ) + ) + else Seq.empty + + def page(messages: Seq[Message], js: Boolean = true) = html( + head(), + body( + conversation(messages), + scripts(js) + ) + ) + +} diff --git a/common/shared/src/main/scala/http/Backend.scala b/common/shared/src/main/scala/http/Backend.scala new file mode 100644 index 0000000..f3ce5f8 --- /dev/null +++ b/common/shared/src/main/scala/http/Backend.scala @@ -0,0 +1,8 @@ +package triad +package http + +import scala.concurrent.Future + +trait Backend { + def send(request: Request): Future[Response] +} diff --git a/common/shared/src/main/scala/http/Request.scala b/common/shared/src/main/scala/http/Request.scala new file mode 100644 index 0000000..ec7d28d --- /dev/null +++ b/common/shared/src/main/scala/http/Request.scala @@ -0,0 +1,9 @@ +package triad +package http + +case class Request( + method: String, + url: String, + headers: Map[String, String] = Map.empty, + body: Array[Byte] = Array.empty +) diff --git a/common/shared/src/main/scala/http/Response.scala b/common/shared/src/main/scala/http/Response.scala new file mode 100644 index 0000000..4ba2342 --- /dev/null +++ b/common/shared/src/main/scala/http/Response.scala @@ -0,0 +1,6 @@ +package triad +package http + +case class Response(statusCode: Int, + headers: Map[String, String], + body: Array[Byte]) diff --git a/project/Js.scala b/project/Js.scala new file mode 100644 index 0000000..9afb064 --- /dev/null +++ b/project/Js.scala @@ -0,0 +1,20 @@ +import sbt._ +import sbt.Keys._ +import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._ + +object Js { + + def dependsOnJs(proj: Project): Seq[Setting[_]] = Seq( + resourceGenerators in Compile += Def.task { + val js: File = (fastOptJS in (proj, Compile)).value.data + val map = js.getParentFile / (js.name + ".map") + val out = (resourceManaged in Compile).value / "assets" / "ui" / "js" + val toCopy = Seq( + js -> out / js.name, + map -> out / map.name + ) + IO.copy(toCopy).toSeq + }.taskValue + ) + +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..2305049 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.1.4 \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..a884d99 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,7 @@ +addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") +addSbtPlugin("com.geirsson" % "sbt-scalafmt" % "1.5.1") + +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "0.4.0") +addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "0.4.0") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.22") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.3.7") diff --git a/server/src/main/scala/LiveMessages.scala b/server/src/main/scala/LiveMessages.scala new file mode 100644 index 0000000..710c1dd --- /dev/null +++ b/server/src/main/scala/LiveMessages.scala @@ -0,0 +1,18 @@ +package triad + +import akka.NotUsed +import akka.stream.scaladsl.{BroadcastHub, Keep, Source, SourceQueueWithComplete} +import akka.stream.{Materializer, OverflowStrategy} + +class LiveMessages(implicit materializer: Materializer) { + + private val (in: SourceQueueWithComplete[Message], + out: Source[Message, NotUsed]) = Source + .queue[Message](10, OverflowStrategy.dropTail) + .toMat(BroadcastHub.sink[Message])(Keep.both) + .run() + + def push(message: Message) = in.offer(message) + def feed = out + +} diff --git a/server/src/main/scala/Main.scala b/server/src/main/scala/Main.scala new file mode 100644 index 0000000..8db8873 --- /dev/null +++ b/server/src/main/scala/Main.scala @@ -0,0 +1,28 @@ +package triad + +import java.nio.file.{Files, Paths} + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.stream.ActorMaterializer + +import scala.concurrent._ +import scala.concurrent.duration._ + +object Main extends App { + + implicit val system = ActorSystem("triad") + implicit val materializer = ActorMaterializer() + + val repository = { + Files.deleteIfExists(Paths.get("database.sqlite")) + Repository.sqlite("database.sqlite") + } + val liveMessages = new LiveMessages + val routes = new Routes(repository, liveMessages) + + Await.result(repository.database.run(repository.initAction), 10.seconds) + + Await.result(Http().bindAndHandle(routes.all, "localhost", 9090), 10.seconds) + +} diff --git a/server/src/main/scala/Repository.scala b/server/src/main/scala/Repository.scala new file mode 100644 index 0000000..003ac92 --- /dev/null +++ b/server/src/main/scala/Repository.scala @@ -0,0 +1,49 @@ +package triad + +import java.time.Instant + +import slick.jdbc.{JdbcProfile, SQLiteProfile} + +class Repository(val profile: JdbcProfile, url: String, driver: String) { + val database: profile.backend.DatabaseDef = + profile.api.Database.forURL(url, driver) + + import profile.api._ + + implicit val instantColumnType = MappedColumnType.base[Instant, Long]( + { i => + i.toEpochMilli() + }, { l => + Instant.ofEpochMilli(l) + } + ) + + class Messages(tag: Tag) extends Table[Message](tag, "messages") { + def id = column[String]("id") + def content = column[String]("content") + def author = column[String]("author") + def timestamp = column[Instant]("timestamp") + def * = + (id, content, author, timestamp) <> ({ cols => + Message(cols._2, cols._3, cols._4) + }, { message: Message => + Some((message.id, message.content, message.author, message.timestamp)) + }) + def pk = primaryKey("pk", id) + } + + val Messages = TableQuery[Messages] + + def initAction = DBIO.seq( + Messages.schema.create, + Messages += Message("first!", "John Smith") + ) + +} + +object Repository { + + def sqlite(name: String) = + new Repository(SQLiteProfile, s"jdbc:sqlite:$name", "org.sqlite.JDBC") + +} diff --git a/server/src/main/scala/Routes.scala b/server/src/main/scala/Routes.scala new file mode 100644 index 0000000..d39fa18 --- /dev/null +++ b/server/src/main/scala/Routes.scala @@ -0,0 +1,80 @@ +package triad + +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ +import akka.http.scaladsl.marshalling.sse.EventStreamMarshalling._ +import akka.http.scaladsl.marshalling.{Marshaller, ToEntityMarshaller} +import akka.http.scaladsl.model.MediaTypes +import akka.http.scaladsl.model.sse.ServerSentEvent +import akka.http.scaladsl.server.Directives._ +import akka.stream.scaladsl.Source +import spray.json._ +import triad.ApiProtocol._ + +import scala.concurrent.duration._ + +class Routes(repository: Repository, liveMessages: LiveMessages) { + import repository.profile.api._ + + // allows using scalatags templates as HTTP responses + implicit val tagMarshaller: ToEntityMarshaller[scalatags.Text.Tag] = { + Marshaller.stringMarshaller(MediaTypes.`text/html`).compose { + (tag: scalatags.Text.Tag) => + tag.render + } + } + + private val lastMessages = repository.Messages.take(100).result + + private val messageStream: Source[Message, _] = { + val publisher = repository.database.stream(lastMessages) + Source + .fromPublisher(publisher) + .concat(liveMessages.feed) + } + + val messages = path("messages") { + get { + onSuccess(repository.database.run(lastMessages)) { messages => + complete(messages) + } + } ~ post { + entity(as[Message]) { message => + extractExecutionContext { implicit ec => + val query = repository.Messages.insertOrUpdate(message) + val action = repository.database.run(query).flatMap { _ => + liveMessages.push(message) + } + onSuccess(action) { _ => + complete(message) + } + } + } + } + } + + val ui = pathEndOrSingleSlash { + get { + parameter("js".as[Boolean] ? true) { js => + onSuccess(repository.database.run(lastMessages)) { messages => + complete(TextTemplates.page(messages, js)) + } + } + } + } + + val live = path("live") { + get { + val src = messageStream + .map(msg => ServerSentEvent(msg.toJson.compactPrint)) + .keepAlive(10.seconds, () => ServerSentEvent.heartbeat) + complete(src) + } + } + + val assets = pathPrefix("assets") { + getFromResourceDirectory("assets") + } + + def all = messages ~ ui ~ live ~ assets + +} diff --git a/ui/src/main/scala/Main.scala b/ui/src/main/scala/Main.scala new file mode 100644 index 0000000..3621b4a --- /dev/null +++ b/ui/src/main/scala/Main.scala @@ -0,0 +1,30 @@ +package triad + +import spray.json._ +import ApiProtocol._ +import scalajs.js +import org.scalajs.dom +import org.scalajs.dom.html + +@js.annotation.JSExport +object Main { + + @js.annotation.JSExport + def main(root: html.Element): Unit = { + val source = new dom.EventSource("live") + + source.onmessage = (e: dom.MessageEvent) => { + val str = e.data.asInstanceOf[String] + if (str.nonEmpty) { // ignore empty strings on heartbeats + println(str) + val message = str.parseJson.convertTo[Message] + val template = JsTemplates.message(message) + root.appendChild(template.render) + dom.window + .scrollTo(0, dom.document.body.scrollHeight) // scroll to bottom + } + } + + } + +} -- cgit v1.2.3