├── .gitignore
├── LICENSE
├── README.md
├── backend
└── src
│ └── main
│ ├── resources
│ ├── application.conf
│ └── web
│ │ └── index.html
│ └── scala
│ └── example
│ └── pekkowschat
│ ├── Chat.scala
│ ├── ChatBackendMain.scala
│ └── Webservice.scala
├── build.sbt
├── chat
├── cli
└── src
│ └── main
│ └── scala
│ └── example
│ └── pekkowschat
│ └── cli
│ ├── ChatCLI.scala
│ ├── ChatClient.scala
│ ├── ConsoleDSL.scala
│ ├── ConsoleInput.scala
│ ├── PromptFlow.scala
│ └── TTY.scala
├── docs
├── cli-completion.gif
└── cli-screencast.gif
├── frontend
└── src
│ └── main
│ └── scala
│ └── example
│ └── pekkowschat
│ └── Frontend.scala
├── project
├── ScalariformSupport.scala
├── build.properties
└── plugins.sbt
└── shared
└── src
└── main
└── scala
└── Protocol.scala
/.gitignore:
--------------------------------------------------------------------------------
1 | /.idea/
2 | /project/boot/
3 | /project/plugins/project
4 | target/
5 | lib_managed/
6 | src_managed/
7 | test-output/
8 | *.iml
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Johannes Rudolph
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Pekko HTTP / Scala.js / Websocket Chat App
2 |
3 | A simple chat app that uses a pekko-http backend and a scala.js frontend to implement a simple
4 | websocket based chat application.
5 |
6 | To run:
7 |
8 | ```
9 | sbt
10 |
11 | > project backend
12 | > reStart
13 | ```
14 |
15 | Navigate to [http://localhost:8080/](http://localhost:8080/).
16 |
17 | You can build a fully self-contained jar using `assembly` in the backend project.
18 |
19 | ## Configuration
20 |
21 | You can set `app.interface` and `app.port` in `application.conf` to configure where the server
22 | should listen to.
23 |
24 | This also works on the command line using JVM properties, e.g. using `re-start`:
25 |
26 | ```
27 | > re-start --- -Dapp.interface=0.0.0.0 -Dapp.port=8080
28 | ```
29 |
30 | will start the server listening on all interfaces.
31 |
32 | ## CLI
33 |
34 | The `cli` project contains a command line client for the chat to demonstrate the Websocket client and
35 | how to deal with console input in a streaming way.
36 |
37 | 
38 |
39 | It runs best directly from a terminal.
40 |
41 | Start the server as explained above. Then, to build a fat jar use
42 |
43 | ```
44 | sbt
45 |
46 | > project cli
47 | > assembly
48 | ```
49 |
50 | Run
51 |
52 | ```
53 | java -jar cli/target/cli.jar
54 | ```
55 |
56 | or
57 |
58 | ```
59 | ./chat
60 | ```
61 |
62 | Here's another screencast that shows live tab completion in action:
63 |
64 | 
65 |
66 | ## Known issues
67 |
68 | ### The "frontend"
69 |
70 | There isn't more than absolutely necessary there right now.
71 |
--------------------------------------------------------------------------------
/backend/src/main/resources/application.conf:
--------------------------------------------------------------------------------
1 | app {
2 | interface = "localhost"
3 | port = 8080
4 | }
--------------------------------------------------------------------------------
/backend/src/main/resources/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Hello World!
4 |
5 |
9 |
10 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/backend/src/main/scala/example/pekkowschat/Chat.scala:
--------------------------------------------------------------------------------
1 | package example.pekkowschat
2 |
3 | import org.apache.pekko.actor._
4 | import org.apache.pekko.stream.OverflowStrategy
5 | import org.apache.pekko.stream.scaladsl._
6 | import shared.Protocol
7 |
8 | import scala.util.control.NonFatal
9 |
10 | trait Chat {
11 | def chatFlow(sender: String): Flow[String, Protocol.Message, Any]
12 |
13 | def injectMessage(message: Protocol.ChatMessage): Unit
14 | }
15 |
16 | object Chat {
17 | def create()(implicit system: ActorSystem): Chat = {
18 | // The chat room implementation
19 | val ((in, injectionQueue), out) =
20 | // a dynamic merge hub that collects messages from all current participants
21 | MergeHub.source[Protocol.Message]
22 | // small stateful component that keeps track of current members as a convenience to subscribers
23 | // so that the full set of members can be reported in Joined/Left messages
24 | .statefulMapConcat[Protocol.Message] { () =>
25 | var members = Set.empty[String]
26 |
27 | {
28 | case Protocol.Joined(newMember, _) =>
29 | members += newMember
30 | Protocol.Joined(newMember, members.toSeq) :: Nil
31 | case Protocol.Left(oldMember, _) =>
32 | members -= oldMember
33 | Protocol.Left(oldMember, members.toSeq) :: Nil
34 | case x => x :: Nil
35 | }
36 | }
37 | // add in a source queue to support an imperative API for injecting messages into the chat
38 | .mergeMat(Source.queue[Protocol.ChatMessage](100, OverflowStrategy.dropNew))(Keep.both)
39 | // a dynamic broadcast hub that distributes messages to all participants
40 | .toMat(BroadcastHub.sink[Protocol.Message])(Keep.both)
41 | .run()
42 |
43 | // The channel in a nice wrapped package for consumption of a participant
44 | val chatChannel: Flow[Protocol.Message, Protocol.Message, Any] = Flow.fromSinkAndSource(in, out)
45 |
46 | new Chat {
47 | override def chatFlow(sender: String): Flow[String, Protocol.Message, Any] =
48 | // incoming data from participant is just plain String messages
49 | Flow[String]
50 | // now wrap them in ChatMessages
51 | .map(Protocol.ChatMessage(sender, _))
52 | // and enclose them in the stream with Joined and Left messages
53 | .prepend(Source.single(Protocol.Joined(sender, Nil)))
54 | .concat(Source.single(Protocol.Left(sender, Nil)))
55 | .recoverWithRetries(0, {
56 | case NonFatal(ex) => Source(
57 | Protocol.ChatMessage(sender, s"Oops, I crashed with $ex") ::
58 | Protocol.Left(sender, Nil) :: Nil)
59 | })
60 | // now send them to the chat
61 | .via(chatChannel)
62 |
63 | override def injectMessage(message: Protocol.ChatMessage): Unit =
64 | injectionQueue.offer(message)
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/backend/src/main/scala/example/pekkowschat/ChatBackendMain.scala:
--------------------------------------------------------------------------------
1 | package example.pekkowschat
2 |
3 | import org.apache.pekko.actor.ActorSystem
4 | import org.apache.pekko.http.scaladsl.Http
5 |
6 | import scala.util.{ Failure, Success }
7 |
8 | object ChatBackendMain extends App {
9 | implicit val system: ActorSystem = ActorSystem()
10 | import system.dispatcher
11 |
12 | val config = system.settings.config
13 | val interface = config.getString("app.interface")
14 | val port = config.getInt("app.port")
15 |
16 | val service = new Webservice
17 |
18 | val binding = Http().bindAndHandle(service.route, interface, port)
19 | binding.onComplete {
20 | case Success(binding) =>
21 | val localAddress = binding.localAddress
22 | println(s"Server is listening on ${localAddress.getHostName}:${localAddress.getPort}")
23 | case Failure(e) =>
24 | println(s"Binding failed with ${e.getMessage}")
25 | system.terminate()
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/backend/src/main/scala/example/pekkowschat/Webservice.scala:
--------------------------------------------------------------------------------
1 | package example.pekkowschat
2 |
3 | import java.util.Date
4 |
5 | import org.apache.pekko.actor.ActorSystem
6 | import org.apache.pekko.http.scaladsl.model.ws.{ Message, TextMessage }
7 |
8 | import scala.concurrent.duration._
9 | import org.apache.pekko.http.scaladsl.server.{ Directives, Route }
10 | import org.apache.pekko.stream.scaladsl.Flow
11 | import upickle.default._
12 | import shared.Protocol
13 | import shared.Protocol._
14 |
15 | import scala.util.Failure
16 |
17 | class Webservice(implicit system: ActorSystem) extends Directives {
18 | val theChat = Chat.create()
19 | import system.dispatcher
20 | system.scheduler.scheduleAtFixedRate(15.second, 15.second) { () =>
21 | theChat.injectMessage(ChatMessage(sender = "clock", s"Bling! The time is ${new Date().toString}."))
22 | }
23 |
24 | val route: Route =
25 | get {
26 | pathSingleSlash {
27 | getFromResource("web/index.html")
28 | } ~
29 | // Scala-JS puts them in the root of the resource directory per default,
30 | // so that's where we pick them up
31 | path("frontend-launcher.js")(getFromResource("frontend-launcher.js")) ~
32 | path("frontend-fastopt.js")(getFromResource("frontend-fastopt.js")) ~
33 | path("chat") {
34 | parameter("name") { name =>
35 | handleWebSocketMessages(websocketChatFlow(sender = name))
36 | }
37 | }
38 | } ~
39 | getFromResourceDirectory("web")
40 |
41 | def websocketChatFlow(sender: String): Flow[Message, Message, Any] =
42 | Flow[Message]
43 | .collect {
44 | case TextMessage.Strict(msg) => msg // unpack incoming WS text messages...
45 | // This will lose (ignore) messages not received in one chunk (which is
46 | // unlikely because chat messages are small) but absolutely possible
47 | // FIXME: We need to handle TextMessage.Streamed as well.
48 | }
49 | .via(theChat.chatFlow(sender)) // ... and route them through the chatFlow ...
50 | .map {
51 | case msg: Protocol.Message =>
52 | TextMessage.Strict(write(msg)) // ... pack outgoing messages into WS JSON messages ...
53 | }
54 | .via(reportErrorsFlow) // ... then log any processing errors on stdin
55 |
56 | def reportErrorsFlow[T]: Flow[T, T, Any] =
57 | Flow[T]
58 | .watchTermination()((_, f) => f.onComplete {
59 | case Failure(cause) =>
60 | println(s"WS stream failed with $cause")
61 | case _ => // ignore regular completion
62 | })
63 | }
64 |
--------------------------------------------------------------------------------
/build.sbt:
--------------------------------------------------------------------------------
1 | val scalaV = "3.3.4"
2 | val pekkoV = "1.1.1"
3 | val pekkoHttpV = "1.1.0"
4 |
5 | val upickleV = "4.0.2"
6 | val utestV = "0.8.4"
7 | val scalaJsDomV = "2.8.0"
8 | val specs2V = "5.5.3"
9 |
10 | lazy val root =
11 | project.in(file("."))
12 | .aggregate(frontend, backend, cli)
13 |
14 | // Scala-Js frontend
15 | lazy val frontend =
16 | project.in(file("frontend"))
17 | .enablePlugins(ScalaJSPlugin)
18 | .settings(commonSettings: _*)
19 | .settings(
20 | scalaJSUseMainModuleInitializer := true,
21 | testFrameworks += new TestFramework("utest.runner.Framework"),
22 | libraryDependencies ++= Seq(
23 | "org.scala-js" %%% "scalajs-dom" % scalaJsDomV,
24 | "com.lihaoyi" %%% "utest" % utestV % "test"
25 | )
26 | )
27 | .dependsOn(sharedJs)
28 |
29 | // Akka Http based backend
30 | lazy val backend =
31 | project.in(file("backend"))
32 | .settings(commonSettings: _*)
33 | .settings(
34 | libraryDependencies ++= Seq(
35 | "org.apache.pekko" %% "pekko-stream" % pekkoV,
36 | "org.apache.pekko" %% "pekko-http" % pekkoHttpV,
37 | "org.specs2" %% "specs2-core" % specs2V % "test",
38 | "com.lihaoyi" %% "upickle" % upickleV
39 | ),
40 | Compile / resourceGenerators += Def.task {
41 | val f1 = (frontend / Compile / fastOptJS).value.data
42 | Seq(f1, new File(f1.getPath+".map"))
43 | }.taskValue,
44 | watchSources ++= (frontend / watchSources).value
45 | )
46 | .dependsOn(sharedJvm)
47 |
48 | lazy val cli =
49 | project.in(file("cli"))
50 | .settings(commonSettings: _*)
51 | .settings(
52 | libraryDependencies ++= Seq(
53 | "org.apache.pekko" %% "pekko-stream" % pekkoV,
54 | "org.apache.pekko" %% "pekko-http-core" % pekkoHttpV,
55 | "org.specs2" %% "specs2-core" % specs2V % "test",
56 | "com.lihaoyi" %% "upickle" % upickleV
57 | ),
58 | run / fork := true,
59 | run / connectInput := true,
60 | assemblyJarName := "../cli.jar"
61 | )
62 | .dependsOn(sharedJvm)
63 |
64 | lazy val shared =
65 | (crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Pure) in file ("shared"))
66 | .settings(
67 | scalaVersion := scalaV,
68 | libraryDependencies += "com.lihaoyi" %%% "upickle" % upickleV,
69 | )
70 |
71 |
72 | lazy val sharedJvm= shared.jvm
73 | lazy val sharedJs= shared.js
74 |
75 | def commonSettings = Seq(
76 | scalaVersion := scalaV,
77 | scalacOptions ++= Seq("-deprecation", "-feature", "-encoding", "utf8", "-unchecked", "-Xlint"),
78 | resolvers += "Apache Nexus Snapshots".at("https://repository.apache.org/content/repositories/snapshots/"),
79 | resolvers += "Apache Nexus Staging".at("https://repository.apache.org/content/repositories/staging/"),
80 | )
81 |
--------------------------------------------------------------------------------
/chat:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | java -jar cli/target/cli.jar
--------------------------------------------------------------------------------
/cli/src/main/scala/example/pekkowschat/cli/ChatCLI.scala:
--------------------------------------------------------------------------------
1 | package example.pekkowschat.cli
2 |
3 | import org.apache.pekko.actor.ActorSystem
4 |
5 | import org.apache.pekko.stream.scaladsl.{ Flow, Source }
6 | import org.apache.pekko.http.scaladsl.model.Uri
7 | import shared.Protocol
8 |
9 | import scala.util.{ Failure, Success }
10 |
11 | object ChatCLI extends App {
12 | def promptForName(): String = {
13 | Console.out.print("What's your name? ")
14 | Console.out.flush()
15 | Console.in.readLine()
16 | }
17 |
18 | val endpointBase = "ws://localhost:8080/chat"
19 | val name = promptForName()
20 |
21 | val endpoint = Uri(endpointBase).withQuery(Uri.Query("name" -> name))
22 |
23 | implicit val system: ActorSystem = ActorSystem()
24 | import system.dispatcher
25 |
26 | import Console._
27 | def formatCurrentMembers(members: Seq[String]): String =
28 | s"(${members.size} people chatting: ${members.map(m => s"$YELLOW$m$RESET").mkString(", ")})"
29 |
30 | object ChatApp extends ConsoleDSL[String] {
31 | type State = Seq[String] // current chat members
32 | def initialState: Seq[String] = Nil
33 |
34 | def run(): Unit = {
35 | lazy val initialCommands =
36 | Command.PrintLine("Welcome to the Chat!") ~ readLineAndEmitLoop
37 |
38 | val inputFlow =
39 | Flow[Protocol.Message]
40 | .map {
41 | case Protocol.ChatMessage(sender, message) => Command.PrintLine(s"$YELLOW$sender$RESET: $message")
42 | case Protocol.Joined(member, all) => Command.PrintLine(s"$YELLOW$member$RESET ${GREEN}joined!$RESET ${formatCurrentMembers(all)}") ~ Command.SetState(all)
43 | case Protocol.Left(member, all) => Command.PrintLine(s"$YELLOW$member$RESET ${RED}left!$RESET ${formatCurrentMembers(all)}") ~ Command.SetState(all)
44 | }
45 | // inject initial commands before the commands generated by the server
46 | .prepend(Source.single(initialCommands))
47 |
48 | val appFlow =
49 | inputFlow
50 | .via(consoleHandler)
51 | .filterNot(_.trim.isEmpty)
52 | .watchTermination()((_, f) => f onComplete {
53 | case Success(_) =>
54 | println("\nFinishing...")
55 | system.terminate()
56 | case Failure(e) =>
57 | println(s"Connection to $endpoint failed because of '${e.getMessage}'")
58 | system.terminate()
59 | })
60 |
61 | println("Connecting... (Use Ctrl-D to exit.)")
62 | ChatClient.connect(endpoint, appFlow)
63 | }
64 |
65 | val basePrompt = s"($name) >"
66 |
67 | lazy val readLineAndEmitLoop: Command =
68 | readWithParticipantNameCompletion { line =>
69 | Command.Emit(line) ~ readLineAndEmitLoop
70 | }
71 |
72 | def readWithParticipantNameCompletion(andThen: String => Command): Command = {
73 | import Command._
74 |
75 | /** Base mode: just collect characters */
76 | def simpleMode(line: String): Command =
77 | SetPrompt(s"$basePrompt $line") ~
78 | SetInputHandler {
79 | case '\r' => andThen(line)
80 | case '@' => mentionMode(line, "")
81 | case x if x >= 0x20 && x < 0x7e => simpleMode(line + x)
82 | case 127 /* backspace */ =>
83 | // TODO: if backspacing to the end of a mention, enable mention mode
84 | simpleMode(line.dropRight(1))
85 | }
86 |
87 | /** Mention mode: collect characters and try to make progress towards one of the current candidates */
88 | def mentionMode(prefix: String, namePrefix: String): Command = {
89 | def candidates(state: State) = state.filter(_.toLowerCase startsWith namePrefix.toLowerCase).filterNot(_ == name)
90 | def fullLine = s"$prefix@$namePrefix"
91 |
92 | StatefulPrompt { state =>
93 | val cs = candidates(state)
94 |
95 | val completion =
96 | cs.size match {
97 | case 1 => cs.head.drop(namePrefix.size)
98 | case 0 => " (no one there with this name)"
99 | case n if n < 5 => s" (${cs.mkString(", ")})"
100 | case n => s" (${cs.size} chatters)"
101 | }
102 |
103 | val left = if (completion.nonEmpty) TTY.cursorLeft(completion.length) else ""
104 |
105 | s"$basePrompt $prefix${TTY.GRAY}@$namePrefix$RESET${YELLOW}$completion$left$RESET"
106 | } ~
107 | SetStatefulInputHandler { state =>
108 | {
109 | //case '\r' ⇒ andThen(fullLine)
110 | case ' ' => simpleMode(fullLine + " ")
111 | case '\t' =>
112 | val cs = candidates(state)
113 | if (cs.size != 1) mentionMode(prefix, namePrefix) // ignore
114 | else simpleMode(prefix + "@" + cs.head + " ")
115 | case x if x >= 0x20 && x < 0x7e => mentionMode(prefix, namePrefix + x)
116 | case 127 /* backspace */ =>
117 | if (namePrefix.isEmpty) simpleMode(prefix)
118 | else mentionMode(prefix, namePrefix.dropRight(1))
119 | }
120 | }
121 | }
122 |
123 | simpleMode("")
124 | }
125 |
126 | }
127 | ChatApp.run()
128 | }
129 |
--------------------------------------------------------------------------------
/cli/src/main/scala/example/pekkowschat/cli/ChatClient.scala:
--------------------------------------------------------------------------------
1 | package example.pekkowschat.cli
2 |
3 | import org.apache.pekko.actor.ActorSystem
4 | import org.apache.pekko.http.scaladsl.Http
5 | import org.apache.pekko.http.scaladsl.model.Uri
6 | import org.apache.pekko.http.scaladsl.model.ws._
7 | import org.apache.pekko.stream.scaladsl.{ Flow, Keep, Sink, Source }
8 | import shared.Protocol
9 | import upickle.default._
10 |
11 | import scala.concurrent.Future
12 |
13 | object ChatClient {
14 | def connect[T](endpoint: Uri, handler: Flow[Protocol.Message, String, T])(implicit system: ActorSystem): Future[T] = {
15 | val wsFlow: Flow[Message, Message, T] =
16 | Flow[Message]
17 | .collect {
18 | case TextMessage.Strict(msg) => read[Protocol.Message](msg)
19 | }
20 | .viaMat(handler)(Keep.right)
21 | .map(TextMessage(_))
22 |
23 | val (fut, t) = Http().singleWebSocketRequest(WebSocketRequest(endpoint), wsFlow)
24 | fut.map {
25 | case v: ValidUpgrade => t
26 | case InvalidUpgradeResponse(response, cause) => throw new RuntimeException(s"Connection to chat at $endpoint failed with $cause")
27 | }(system.dispatcher)
28 | }
29 |
30 | def connect[T](endpoint: Uri, in: Sink[Protocol.Message, Any], out: Source[String, Any])(implicit system: ActorSystem): Future[Unit] =
31 | connect(endpoint, Flow.fromSinkAndSource(in, out)).map(_ => ())(system.dispatcher)
32 |
33 | def connect[T](endpoint: Uri, onMessage: Protocol.Message => Unit, out: Source[String, Any])(implicit system: ActorSystem): Future[Unit] =
34 | connect(endpoint, Sink.foreach(onMessage), out)
35 | }
--------------------------------------------------------------------------------
/cli/src/main/scala/example/pekkowschat/cli/ConsoleDSL.scala:
--------------------------------------------------------------------------------
1 | package example.pekkowschat.cli
2 |
3 | import org.apache.pekko.stream.stage.{ InHandler, GraphStageLogic, GraphStage }
4 | import org.apache.pekko.stream._
5 | import org.apache.pekko.stream.scaladsl.{ GraphDSL, Source, Flow }
6 |
7 | import scala.concurrent.ExecutionContext
8 |
9 | /** Infrastructure for a small DSL that allows to write stateful concurrent console apps of a certain kind */
10 | trait ConsoleDSL[T] {
11 | type State <: AnyRef
12 | def initialState: State
13 |
14 | /** Returns a Flow that implements the console logic. */
15 | def consoleHandler(implicit ec: ExecutionContext): Flow[Command, T, Any] = {
16 | val characters = Source.fromGraph(new ConsoleInput)
17 |
18 | val graph =
19 | GraphDSL.create() { implicit b =>
20 | import GraphDSL.Implicits._
21 |
22 | val prompt = b.add(ConsoleStage)
23 | characters ~> prompt.characterInput
24 |
25 | FlowShape(prompt.commandIn, prompt.output)
26 | }
27 |
28 | Flow.fromGraph(graph)
29 | }
30 |
31 | case class ConsoleStageShape(characterInput: Inlet[Char], commandIn: Inlet[Command], output: Outlet[T]) extends Shape {
32 | def inlets = Vector(characterInput, commandIn)
33 | def outlets = Vector(output)
34 | def deepCopy(): Shape = ConsoleStageShape(characterInput.carbonCopy(), commandIn.carbonCopy(), output.carbonCopy())
35 | }
36 | object ConsoleStage extends GraphStage[ConsoleStageShape] {
37 | import TTY._
38 |
39 | val shape: ConsoleStageShape = ConsoleStageShape(Inlet[Char]("characterInput"), Inlet[Command]("commandIn"), Outlet[T]("output"))
40 | import shape.{ characterInput, commandIn, output }
41 |
42 | def createLogic(inheritedAttributes: Attributes): GraphStageLogic =
43 | new GraphStageLogic(shape) {
44 | var inputHandler: State => PartialFunction[Char, Command] = (_ => PartialFunction.empty)
45 | var promptLine: State => String = (_ => "")
46 | var state: State = initialState
47 |
48 | setHandler(characterInput, new InHandler {
49 | def onPush(): Unit = {
50 | val input = grab(characterInput)
51 | if (input == 4) {
52 | outputLine("Goodbye!")
53 | completeStage()
54 | } else {
55 | val cmd = inputHandler(state).applyOrElse[Char, Command](input, _ => Command.Empty)
56 | runCommand(cmd)
57 |
58 | pull(characterInput)
59 | }
60 | }
61 | })
62 | setHandler(commandIn, new InHandler {
63 | def onPush(): Unit = {
64 | runCommand(grab(commandIn))
65 | pull(commandIn)
66 | }
67 | })
68 | setHandler(output, eagerTerminateOutput)
69 |
70 | import Command._
71 | def runCommand(command: Command): Unit = command match {
72 | case Empty =>
73 | case Multiple(cmds) => cmds foreach runCommand
74 | case PrintLine(line) =>
75 | outputLine(line)
76 | updatePrompt()
77 | case StatefulPrompt(newPrompt) =>
78 | promptLine = newPrompt
79 | updatePrompt()
80 | case SetStatefulInputHandler(newHandler) => inputHandler = newHandler
81 | case UpdateState(modify) =>
82 | state = modify(state)
83 | updatePrompt()
84 |
85 | case Emit(element) => push(output, element)
86 | case Complete => completeStage()
87 | }
88 |
89 | def outputLine(line: String): Unit = print(s"$RESTORE$ERASE_LINE$line\n$SAVE")
90 | def updatePrompt(): Unit = print(s"$RESTORE$ERASE_LINE$SAVE${promptLine(state)}")
91 |
92 | override def preStart(): Unit = {
93 | pull(commandIn)
94 | pull(characterInput)
95 | print(SAVE) // to prevent jumping before the current output
96 | }
97 | }
98 | }
99 |
100 | sealed trait Command {
101 | def ~(other: Command): Command = Command.Multiple(Seq(this, other))
102 | }
103 | object Command {
104 | val Empty = Multiple(Nil)
105 |
106 | case class PrintLine(line: String) extends Command
107 | def SetPrompt(prompt: String): Command = StatefulPrompt(_ => prompt)
108 | case class StatefulPrompt(prompt: State => String) extends Command
109 | def SetState(state: State): Command = UpdateState(_ => state)
110 | case class UpdateState(modify: State => State) extends Command
111 | def SetInputHandler(handler: PartialFunction[Char, Command]): Command = SetStatefulInputHandler(_ => handler)
112 | case class SetStatefulInputHandler(handler: State => PartialFunction[Char, Command]) extends Command
113 | case class Emit(element: T) extends Command
114 | case object Complete extends Command
115 | case class Multiple(commands: Seq[Command]) extends Command {
116 | override def ~(other: Command): Command = Multiple(commands :+ other) // don't nest
117 | }
118 | }
119 |
120 | import Command._
121 | def readLineStatefulPrompt(prompt: State => String, currentInput: String = "")(andThen: String => Command): Command =
122 | StatefulPrompt(state => s"${prompt(state)}$currentInput") ~
123 | SetInputHandler {
124 | case '\r' => andThen(currentInput)
125 | case x if x >= 0x20 && x < 0x7e => readLineStatefulPrompt(prompt, currentInput + x)(andThen)
126 | case 127 /* backspace */ => readLineStatefulPrompt(prompt, currentInput.dropRight(1))(andThen)
127 | }
128 | def readLine(prompt: String = "> ")(andThen: String => Command): Command =
129 | readLineStatefulPrompt(_ => prompt)(andThen)
130 | }
131 |
--------------------------------------------------------------------------------
/cli/src/main/scala/example/pekkowschat/cli/ConsoleInput.scala:
--------------------------------------------------------------------------------
1 | package example.pekkowschat.cli
2 |
3 | import org.apache.pekko.stream.stage.{ OutHandler, GraphStageLogic, GraphStage }
4 | import org.apache.pekko.stream._
5 |
6 | import scala.annotation.tailrec
7 | import scala.concurrent.{ Future, ExecutionContext }
8 | import scala.util.control.NoStackTrace
9 |
10 | class ConsoleInput(implicit ec: ExecutionContext) extends GraphStage[SourceShape[Char]] {
11 | val out = Outlet[Char]("consoleOut")
12 | val shape: SourceShape[Char] = SourceShape(out)
13 |
14 | def createLogic(inheritedAttributes: Attributes): GraphStageLogic =
15 | new GraphStageLogic(shape) {
16 | TTY.noEchoStty()
17 |
18 | @volatile var cancelled = false
19 | def getOne(): Unit = {
20 | val callback = getAsyncCallback[Char](push(out, _))
21 |
22 | Future {
23 | @tailrec def read(): Unit =
24 | if (cancelled) throw new Exception with NoStackTrace
25 | else if (System.in.available() > 0)
26 | callback.invoke(System.in.read().toChar)
27 | else {
28 | Thread.sleep(10)
29 | read()
30 | }
31 |
32 | read()
33 | }
34 | }
35 |
36 | setHandler(out, new OutHandler {
37 | def onPull(): Unit = getOne()
38 |
39 | override def onDownstreamFinish(cause: Throwable): Unit = {
40 | cancelled = true
41 | super.onDownstreamFinish(cause)
42 | }
43 | })
44 |
45 | override def postStop(): Unit =
46 | TTY.saneStty()
47 | }
48 | }
--------------------------------------------------------------------------------
/cli/src/main/scala/example/pekkowschat/cli/PromptFlow.scala:
--------------------------------------------------------------------------------
1 | package example.pekkowschat.cli
2 |
3 | import org.apache.pekko.stream._
4 | import org.apache.pekko.stream.scaladsl.{ Flow, Source, GraphDSL }
5 | import org.apache.pekko.stream.stage.{ InHandler, GraphStageLogic, GraphStage }
6 |
7 | import scala.concurrent.ExecutionContext
8 |
9 | object Prompt {
10 | /**
11 | * A flow that prompts for lines from the tty and allows to output lines at the same time, without
12 | * disrupting user input
13 | */
14 | def prompt(implicit ec: ExecutionContext): Flow[String, String, Any] = {
15 | val characters = Source.fromGraph(new ConsoleInput)
16 |
17 | val graph =
18 | GraphDSL.create() { implicit b =>
19 | import GraphDSL.Implicits._
20 |
21 | val prompt = b.add(PromptFlow)
22 | characters ~> prompt.characterInput
23 |
24 | FlowShape(prompt.outputLines, prompt.readLines)
25 | }
26 |
27 | Flow.fromGraph(graph)
28 | }
29 | }
30 |
31 | case class PromptFlowShape(characterInput: Inlet[Char], outputLines: Inlet[String], readLines: Outlet[String]) extends Shape {
32 | def inlets = Vector(characterInput, outputLines)
33 | def outlets = Vector(readLines)
34 | def deepCopy(): Shape = PromptFlowShape(characterInput.carbonCopy(), outputLines.carbonCopy(), readLines.carbonCopy())
35 | }
36 |
37 | object PromptFlow extends GraphStage[PromptFlowShape] {
38 | import TTY._
39 |
40 | val characterInput = Inlet[Char]("characterInput")
41 | val outputLinesIn = Inlet[String]("outputLinesIn")
42 | val readLinesOut = Outlet[String]("readLinesOut")
43 |
44 | val shape = PromptFlowShape(characterInput, outputLinesIn, readLinesOut)
45 |
46 | def createLogic(inheritedAttributes: Attributes): GraphStageLogic =
47 | new GraphStageLogic(shape) {
48 | var collectedString: String = ""
49 |
50 | setHandler(characterInput, new InHandler {
51 | def onPush(): Unit = {
52 | val res = grab(characterInput)
53 | res match {
54 | case 4 /* Ctrl-D */ =>
55 | println()
56 | complete(readLinesOut)
57 | completeStage()
58 | case '\r' =>
59 | push(readLinesOut, collectedString)
60 | collectedString = ""
61 | prompt()
62 | case 127 /* backspace */ =>
63 | collectedString = collectedString.dropRight(1)
64 | prompt()
65 | case x =>
66 | //println(s"Got ${x.toInt}")
67 | collectedString += x
68 | print(x)
69 | pull(characterInput)
70 | }
71 | }
72 | })
73 | setHandler(outputLinesIn, new InHandler {
74 | def onPush(): Unit = {
75 | print(s"$RESTORE$ERASE_LINE${grab(outputLinesIn)}\n$SAVE$promptLine")
76 | pull(outputLinesIn)
77 | }
78 | })
79 | setHandler(readLinesOut, eagerTerminateOutput)
80 |
81 | override def preStart(): Unit = {
82 | pull(outputLinesIn)
83 | print(SAVE) // to make sure we don't jump back to former SAVE position in the terminal
84 | prompt()
85 | }
86 |
87 | def promptLine = s"$RESTORE$ERASE_LINE$SAVE> $collectedString"
88 |
89 | def prompt(): Unit = {
90 | print(promptLine)
91 | pull(characterInput)
92 | }
93 | }
94 | }
95 |
96 |
--------------------------------------------------------------------------------
/cli/src/main/scala/example/pekkowschat/cli/TTY.scala:
--------------------------------------------------------------------------------
1 | package example.pekkowschat.cli
2 |
3 | object TTY {
4 | val ANSI_ESCAPE = "\u001b["
5 | val SAVE = ANSI_ESCAPE + "s"
6 | val RESTORE = ANSI_ESCAPE + "u"
7 | val ERASE_LINE = ANSI_ESCAPE + "K"
8 | val GRAY = ANSI_ESCAPE + "0;1m"
9 |
10 | def cursorLeft(chars: Int): String =
11 | s"$ANSI_ESCAPE${chars}D"
12 |
13 | import sys.process._
14 |
15 | // touch this to ensure that the TTY isn't left in a broken state
16 | lazy val ensureShutdownHook: Unit =
17 | Runtime.getRuntime.addShutdownHook(new Thread() {
18 | override def run(): Unit = {
19 | saneStty()
20 | }
21 | })
22 |
23 | // stty arguments shamelessly stolen from ammonite (https://github.com/lihaoyi/Ammonite/blob/master/terminal/src/main/scala/ammonite/terminal/Utils.scala#L71)
24 | def noEchoStty() = {
25 | ensureShutdownHook
26 | "stty -F /dev/tty -echo -icanon min 1 -icrnl -inlcr".!!
27 | }
28 | def saneStty() = "stty -F /dev/tty sane".!!
29 | }
--------------------------------------------------------------------------------
/docs/cli-completion.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrudolph/pekko-http-scala-js-websocket-chat/a08bf6fdc05f1fd26e13e0685ba22670c7c73920/docs/cli-completion.gif
--------------------------------------------------------------------------------
/docs/cli-screencast.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrudolph/pekko-http-scala-js-websocket-chat/a08bf6fdc05f1fd26e13e0685ba22670c7c73920/docs/cli-screencast.gif
--------------------------------------------------------------------------------
/frontend/src/main/scala/example/pekkowschat/Frontend.scala:
--------------------------------------------------------------------------------
1 | package example.pekkowschat
2 |
3 | import org.scalajs.dom.raw._
4 |
5 | import org.scalajs.dom
6 |
7 | import upickle.default._
8 | import shared.Protocol
9 |
10 | object Frontend {
11 | val joinButton = dom.document.getElementById("join").asInstanceOf[HTMLButtonElement]
12 | val sendButton = dom.document.getElementById("send").asInstanceOf[HTMLButtonElement]
13 |
14 | def main(args: Array[String]): Unit = {
15 | val nameField = dom.document.getElementById("name").asInstanceOf[HTMLInputElement]
16 | joinButton.onclick = { (event: MouseEvent) =>
17 | joinChat(nameField.value)
18 | event.preventDefault()
19 | }
20 | nameField.focus()
21 | nameField.onkeypress = { (event: KeyboardEvent) =>
22 | if (event.keyCode == 13) {
23 | joinButton.click()
24 | event.preventDefault()
25 | }
26 | }
27 | }
28 |
29 | def joinChat(name: String): Unit = {
30 | joinButton.disabled = true
31 | val playground = dom.document.getElementById("playground")
32 | playground.innerHTML = s"Trying to join chat as '$name'..."
33 | val chat = new WebSocket(getWebsocketUri(dom.document, name))
34 | chat.onopen = { (event: Event) =>
35 | playground.insertBefore(p("Chat connection was successful!"), playground.firstChild)
36 | sendButton.disabled = false
37 |
38 | val messageField = dom.document.getElementById("message").asInstanceOf[HTMLInputElement]
39 | messageField.focus()
40 | messageField.onkeypress = { (event: KeyboardEvent) =>
41 | if (event.keyCode == 13) {
42 | sendButton.click()
43 | event.preventDefault()
44 | }
45 | }
46 | sendButton.onclick = { (event: Event) =>
47 | chat.send(messageField.value)
48 | messageField.value = ""
49 | messageField.focus()
50 | event.preventDefault()
51 | }
52 |
53 | event
54 | }
55 | chat.onerror = { (event: Event) =>
56 | playground.insertBefore(p(s"Failed: code: ${event.asInstanceOf[ErrorEvent].colno}"), playground.firstChild)
57 | joinButton.disabled = false
58 | sendButton.disabled = true
59 | }
60 | chat.onmessage = { (event: MessageEvent) =>
61 | val wsMsg = read[Protocol.Message](event.data.toString)
62 |
63 | wsMsg match {
64 | case Protocol.ChatMessage(sender, message) => writeToArea(s"$sender said: $message")
65 | case Protocol.Joined(member, _) => writeToArea(s"$member joined!")
66 | case Protocol.Left(member, _) => writeToArea(s"$member left!")
67 | }
68 | }
69 | chat.onclose = { (event: Event) =>
70 | playground.insertBefore(p("Connection to chat lost. You can try to rejoin manually."), playground.firstChild)
71 | joinButton.disabled = false
72 | sendButton.disabled = true
73 | }
74 |
75 | def writeToArea(text: String): Unit =
76 | playground.insertBefore(p(text), playground.firstChild)
77 | }
78 |
79 | def getWebsocketUri(document: Document, nameOfChatParticipant: String): String = {
80 | val wsProtocol = if (dom.document.location.protocol == "https:") "wss" else "ws"
81 |
82 | s"$wsProtocol://${dom.document.location.host}/chat?name=$nameOfChatParticipant"
83 | }
84 |
85 | def p(msg: String) = {
86 | val paragraph = dom.document.createElement("p")
87 | paragraph.innerHTML = msg
88 | paragraph
89 | }
90 | }
--------------------------------------------------------------------------------
/project/ScalariformSupport.scala:
--------------------------------------------------------------------------------
1 | import sbt._
2 | import com.typesafe.sbt.SbtScalariform
3 | import com.typesafe.sbt.SbtScalariform.ScalariformKeys
4 |
5 | object Formatting extends AutoPlugin {
6 | override def trigger: PluginTrigger = AllRequirements
7 | override def requires: Plugins = SbtScalariform
8 |
9 | override def projectSettings: Seq[_root_.sbt.Def.Setting[_]] = formatSettings
10 |
11 | lazy val formatSettings = Seq(
12 | Compile / ScalariformKeys.preferences := formattingPreferences,
13 | Test / ScalariformKeys.preferences := formattingPreferences
14 | )
15 |
16 | import scalariform.formatter.preferences._
17 |
18 | def formattingPreferences: FormattingPreferences =
19 | FormattingPreferences()
20 | .setPreference(RewriteArrowSymbols, true)
21 | .setPreference(UseUnicodeArrows, false)
22 | .setPreference(AlignParameters, true)
23 | .setPreference(AlignSingleLineCaseStatements, true)
24 | .setPreference(DanglingCloseParenthesis, Preserve)
25 | .setPreference(DoubleIndentConstructorArguments, true)
26 | .setPreference(SpacesAroundMultiImports, true)
27 | }
28 |
--------------------------------------------------------------------------------
/project/build.properties:
--------------------------------------------------------------------------------
1 | sbt.version=1.10.2
2 |
--------------------------------------------------------------------------------
/project/plugins.sbt:
--------------------------------------------------------------------------------
1 | addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.8.3")
2 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1")
3 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.15.0")
4 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.17.0")
5 | addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0")
6 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.4")
--------------------------------------------------------------------------------
/shared/src/main/scala/Protocol.scala:
--------------------------------------------------------------------------------
1 | package shared
2 |
3 | object Protocol {
4 | sealed trait Message
5 | case class ChatMessage(sender: String, message: String) extends Message
6 | case class Joined(member: String, allMembers: Seq[String]) extends Message
7 | case class Left(member: String, allMembers: Seq[String]) extends Message
8 |
9 | import upickle.default._
10 | implicit val messageRW: ReadWriter[Message] = {
11 | implicit val cmRW: ReadWriter[ChatMessage] = macroRW[ChatMessage]
12 | implicit val jRW: ReadWriter[Joined] = macroRW[Joined]
13 | implicit val lRW: ReadWriter[Left] = macroRW[Left]
14 |
15 | macroRW[Message]
16 | }
17 | }
18 |
--------------------------------------------------------------------------------