aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJakob Odersky <jakob@odersky.com>2018-05-06 13:56:16 -0700
committerJakob Odersky <jakob@odersky.com>2018-05-08 23:02:39 -0700
commit8ecae787ff7124b008229d958c579c73649dd9e4 (patch)
treedad7bea34d9b7ea0f716a783f3b57de491ec959e
downloadscala-triad-8ecae787ff7124b008229d958c579c73649dd9e4.tar.gz
scala-triad-8ecae787ff7124b008229d958c579c73649dd9e4.tar.bz2
scala-triad-8ecae787ff7124b008229d958c579c73649dd9e4.zip
Initial commit
-rw-r--r--.gitignore2
-rw-r--r--README.md26
-rw-r--r--build.sbt112
-rw-r--r--client/src/main/scala/Main.scala62
-rw-r--r--common/js/src/main/scala/JsTemplates.scala3
-rw-r--r--common/js/src/main/scala/http/XhrBackend.scala51
-rw-r--r--common/js/src/main/scala/http/package.scala3
-rw-r--r--common/native/src/main/scala/http/ArrayUtils.scala28
-rw-r--r--common/native/src/main/scala/http/CurlBackend.scala215
-rw-r--r--common/native/src/main/scala/http/curl.scala82
-rw-r--r--common/native/src/main/scala/http/package.scala3
-rw-r--r--common/shared/src/main/scala/ApiProtocol.scala12
-rw-r--r--common/shared/src/main/scala/Message.scala39
-rw-r--r--common/shared/src/main/scala/Templates.scala16
-rw-r--r--common/shared/src/main/scala/TextTemplates.scala46
-rw-r--r--common/shared/src/main/scala/http/Backend.scala8
-rw-r--r--common/shared/src/main/scala/http/Request.scala9
-rw-r--r--common/shared/src/main/scala/http/Response.scala6
-rw-r--r--project/Js.scala20
-rw-r--r--project/build.properties1
-rw-r--r--project/plugins.sbt7
-rw-r--r--server/src/main/scala/LiveMessages.scala18
-rw-r--r--server/src/main/scala/Main.scala28
-rw-r--r--server/src/main/scala/Repository.scala49
-rw-r--r--server/src/main/scala/Routes.scala80
-rw-r--r--ui/src/main/scala/Main.scala30
26 files changed, 956 insertions, 0 deletions
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
+ }
+ }
+
+ }
+
+}