├── .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 | ![CLI Screencast](https://github.com/jrudolph/pekko-http-scala-js-websocket-chat/raw/master/docs/cli-screencast.gif) 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 | ![CLI completion screencast](https://github.com/jrudolph/pekko-http-scala-js-websocket-chat/raw/master/docs/cli-completion.gif) 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 |
6 | 7 | 8 |
9 | 10 |
11 | 12 | 13 |
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 | --------------------------------------------------------------------------------