├── .github ├── mergify.yml ├── dependabot.yml ├── scala-steward.conf └── workflows │ ├── publish.yml │ ├── release-drafter.yml │ ├── dependency-graph.yml │ └── build-test.yml ├── project ├── build.properties ├── Dependencies.scala └── plugins.sbt ├── samples ├── java │ ├── chat │ │ ├── conf │ │ │ ├── messages │ │ │ ├── application.conf │ │ │ ├── routes │ │ │ └── logback.xml │ │ ├── public │ │ │ ├── images │ │ │ │ └── favicon.png │ │ │ └── index.html │ │ ├── README.md │ │ └── app │ │ │ └── chat │ │ │ ├── ChatModule.java │ │ │ └── ChatEngine.java │ ├── clustered-chat │ │ ├── conf │ │ │ ├── messages │ │ │ ├── routes │ │ │ ├── application.conf │ │ │ └── logback.xml │ │ ├── public │ │ │ ├── images │ │ │ │ └── favicon.png │ │ │ └── index.html │ │ ├── app │ │ │ └── chat │ │ │ │ ├── User.java │ │ │ │ ├── ChatModule.java │ │ │ │ ├── ChatEvent.java │ │ │ │ ├── ChatEventSerializer.java │ │ │ │ └── ChatEngine.java │ │ ├── nginx.conf │ │ ├── cluster.sh │ │ └── README.md │ └── multi-room-chat │ │ ├── conf │ │ ├── messages │ │ ├── application.conf │ │ ├── routes │ │ └── logback.xml │ │ ├── public │ │ ├── images │ │ │ └── favicon.png │ │ └── index.html │ │ ├── app │ │ └── chat │ │ │ ├── User.java │ │ │ ├── ChatModule.java │ │ │ ├── ChatEvent.java │ │ │ └── ChatEngine.java │ │ └── README.md └── scala │ ├── chat │ ├── conf │ │ ├── messages │ │ ├── application.conf │ │ ├── routes │ │ └── logback.xml │ ├── public │ │ ├── images │ │ │ └── favicon.png │ │ └── index.html │ ├── README.md │ └── app │ │ ├── modules │ │ └── MyApplicationLoader.scala │ │ └── chat │ │ └── ChatEngine.scala │ ├── clustered-chat │ ├── conf │ │ ├── messages │ │ ├── routes │ │ ├── application.conf │ │ └── logback.xml │ ├── public │ │ ├── images │ │ │ └── favicon.png │ │ └── index.html │ ├── nginx.conf │ ├── app │ │ ├── modules │ │ │ └── MyApplicationLoader.scala │ │ └── chat │ │ │ ├── ChatEventSerializer.scala │ │ │ └── ChatEngine.scala │ ├── cluster.sh │ └── README.md │ └── multi-room-chat │ ├── conf │ ├── messages │ ├── application.conf │ ├── routes │ └── logback.xml │ ├── public │ ├── images │ │ └── favicon.png │ └── index.html │ ├── README.md │ └── app │ ├── modules │ └── MyApplicationLoader.scala │ └── chat │ └── ChatEngine.scala ├── .gitignore ├── src ├── test │ ├── resources │ │ ├── application.conf │ │ └── logback.xml │ ├── scala │ │ └── play │ │ │ └── socketio │ │ │ ├── TestSocketIOApplication.scala │ │ │ ├── TestSocketIOServer.scala │ │ │ ├── scaladsl │ │ │ ├── TestMultiNodeSocketIOApplication.scala │ │ │ ├── TestSocketIOScalaApplication.scala │ │ │ └── SocketIOEventCodecSpec.scala │ │ │ └── RunSocketIOTests.scala │ ├── javascript │ │ ├── index.html │ │ ├── lib │ │ │ ├── utils.js │ │ │ └── mocha-3.4.2.min.css │ │ └── socketio.js │ └── java │ │ └── play │ │ └── socketio │ │ └── javadsl │ │ └── TestSocketIOJavaApplication.java └── main │ ├── scala │ └── play │ │ ├── socketio │ │ ├── SocketIOModule.scala │ │ ├── SocketIOEventAck.scala │ │ ├── SocketIOConfig.scala │ │ ├── javadsl │ │ │ └── SocketIOSessionFlowHelper.scala │ │ ├── SocketIOEvent.scala │ │ ├── scaladsl │ │ │ └── SocketIO.scala │ │ └── protocol │ │ │ └── SocketIOProtocol.scala │ │ ├── api │ │ └── mvc │ │ │ └── ByteStringBodyParser.scala │ │ └── engineio │ │ ├── DeserializedRequestHeader.scala │ │ ├── EngineIOSessionHandler.scala │ │ ├── EngineIOManagerActor.scala │ │ ├── EngineIOAkkaSerializer.scala │ │ └── EngineIO.scala │ ├── protobuf │ └── engineio.proto │ ├── resources │ └── reference.conf │ └── java │ └── play │ └── socketio │ └── javadsl │ └── SocketIO.java ├── .scalafmt.conf └── README.md /.github/mergify.yml: -------------------------------------------------------------------------------- 1 | extends: .github 2 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.7.2 2 | -------------------------------------------------------------------------------- /samples/java/chat/conf/messages: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/ScalaI18N 2 | -------------------------------------------------------------------------------- /samples/scala/chat/conf/messages: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/ScalaI18N 2 | -------------------------------------------------------------------------------- /samples/java/clustered-chat/conf/messages: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/ScalaI18N 2 | -------------------------------------------------------------------------------- /samples/java/multi-room-chat/conf/messages: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/ScalaI18N 2 | -------------------------------------------------------------------------------- /samples/scala/clustered-chat/conf/messages: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/ScalaI18N 2 | -------------------------------------------------------------------------------- /samples/scala/multi-room-chat/conf/messages: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/ScalaI18N 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | target 3 | .bsp/ 4 | /.idea 5 | /.idea_modules 6 | /.classpath 7 | /.project 8 | /.settings 9 | /RUNNING_PID 10 | -------------------------------------------------------------------------------- /samples/java/chat/public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playframework/play-socket.io/main/samples/java/chat/public/images/favicon.png -------------------------------------------------------------------------------- /samples/scala/chat/public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playframework/play-socket.io/main/samples/scala/chat/public/images/favicon.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /samples/scala/chat/conf/application.conf: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/Configuration 2 | play.application.loader = modules.MyApplicationLoader -------------------------------------------------------------------------------- /samples/java/clustered-chat/public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playframework/play-socket.io/main/samples/java/clustered-chat/public/images/favicon.png -------------------------------------------------------------------------------- /samples/java/multi-room-chat/public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playframework/play-socket.io/main/samples/java/multi-room-chat/public/images/favicon.png -------------------------------------------------------------------------------- /samples/scala/clustered-chat/public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playframework/play-socket.io/main/samples/scala/clustered-chat/public/images/favicon.png -------------------------------------------------------------------------------- /samples/scala/multi-room-chat/conf/application.conf: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/Configuration 2 | play.application.loader = modules.MyApplicationLoader -------------------------------------------------------------------------------- /samples/scala/multi-room-chat/public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playframework/play-socket.io/main/samples/scala/multi-room-chat/public/images/favicon.png -------------------------------------------------------------------------------- /.github/scala-steward.conf: -------------------------------------------------------------------------------- 1 | commits.message = "${artifactName} ${nextVersion} (was ${currentVersion})" 2 | 3 | pullRequests.grouping = [ 4 | { name = "patches", "title" = "Patch updates", "filter" = [{"version" = "patch"}] } 5 | ] 6 | -------------------------------------------------------------------------------- /samples/java/chat/conf/application.conf: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/Configuration 2 | play.modules.enabled += chat.ChatModule 3 | 4 | # Disable the default filters, they block half the content we want. 5 | play.filters.enabled = [] -------------------------------------------------------------------------------- /samples/java/multi-room-chat/conf/application.conf: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/Configuration 2 | play.modules.enabled += chat.ChatModule 3 | 4 | # Disable the default filters, they block half the content we want. 5 | play.filters.enabled = [] -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | object Dependencies { 2 | // Sync with GA (.github/workflows/build-test.yml) 3 | val Scala212 = "2.12.18" // sync! see comment above 4 | val Scala213 = "2.13.12" // sync! see comment above 5 | 6 | val AkkaVersion = "2.5.32" 7 | } 8 | -------------------------------------------------------------------------------- /samples/java/chat/README.md: -------------------------------------------------------------------------------- 1 | # Java chat example 2 | 3 | This is a very simple chat server example written with Play socket.io. It mirrors the official socket.io chat example, but uses a Play backend. 4 | 5 | To run this example, from the root directory of `play-socket.io`, run `sbt javaChat/run`. -------------------------------------------------------------------------------- /samples/scala/chat/README.md: -------------------------------------------------------------------------------- 1 | # Scala chat example 2 | 3 | This is a very simple chat server example written with Play socket.io. It mirrors the official socket.io chat example, but uses a Play backend. 4 | 5 | To run this example, from the root directory of `play-socket.io`, run `sbt scalaChat/run`. -------------------------------------------------------------------------------- /samples/java/clustered-chat/app/chat/User.java: -------------------------------------------------------------------------------- 1 | package chat; 2 | 3 | import com.fasterxml.jackson.annotation.JsonValue; 4 | import lombok.Value; 5 | 6 | @Value 7 | public class User { 8 | String name; 9 | 10 | @JsonValue 11 | public String getName() { 12 | return name; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /samples/java/multi-room-chat/app/chat/User.java: -------------------------------------------------------------------------------- 1 | package chat; 2 | 3 | import com.fasterxml.jackson.annotation.JsonValue; 4 | import lombok.Value; 5 | 6 | @Value 7 | public class User { 8 | String name; 9 | 10 | @JsonValue 11 | public String getName() { 12 | return name; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | log-dead-letters = 10 3 | 4 | # Uncomment to debug remote messages. Make sure you adjust logback.xml too. 5 | #remote { 6 | # log-sent-messages = true 7 | # log-received-messages = true 8 | # log-remote-lifecycle-events = info 9 | #} 10 | } -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: # Snapshots 6 | - main 7 | tags: ["**"] # Releases 8 | 9 | jobs: 10 | publish-artifacts: 11 | name: Publish / Artifacts 12 | uses: playframework/.github/.github/workflows/publish.yml@v3 13 | secrets: inherit 14 | -------------------------------------------------------------------------------- /samples/java/multi-room-chat/README.md: -------------------------------------------------------------------------------- 1 | # Java multi-room chat example 2 | 3 | This is a more complex chat server example written with Play socket.io. It shows how to use dynamic Akka streams, allowing users to dynamically join and leave chat room flows. 4 | 5 | To run this example, from the root directory of `play-socket.io`, run `sbt javaMultiRoomChat/run`. -------------------------------------------------------------------------------- /samples/scala/multi-room-chat/README.md: -------------------------------------------------------------------------------- 1 | # Scala multi-room chat example 2 | 3 | This is a more complex chat server example written with Play socket.io. It shows how to use dynamic Akka streams, allowing users to dynamically join and leave chat room flows. 4 | 5 | To run this example, from the root directory of `play-socket.io`, run `sbt scalaMultiRoomChat/run`. -------------------------------------------------------------------------------- /samples/java/chat/conf/routes: -------------------------------------------------------------------------------- 1 | 2 | GET / controllers.Assets.at(path = "/public", file = "index.html") 3 | GET /assets/*file controllers.Assets.at(path = "/public", file) 4 | 5 | GET /socket.io/ play.engineio.EngineIOController.endpoint(transport) 6 | POST /socket.io/ play.engineio.EngineIOController.endpoint(transport) -------------------------------------------------------------------------------- /samples/scala/chat/conf/routes: -------------------------------------------------------------------------------- 1 | 2 | GET / controllers.Assets.at(path = "/public", file = "index.html") 3 | GET /assets/*file controllers.Assets.at(path = "/public", file) 4 | 5 | GET /socket.io/ play.engineio.EngineIOController.endpoint(transport) 6 | POST /socket.io/ play.engineio.EngineIOController.endpoint(transport) -------------------------------------------------------------------------------- /samples/java/chat/app/chat/ChatModule.java: -------------------------------------------------------------------------------- 1 | package chat; 2 | 3 | import com.google.inject.AbstractModule; 4 | import play.engineio.EngineIOController; 5 | 6 | /** 7 | * The chat module. 8 | */ 9 | public class ChatModule extends AbstractModule { 10 | protected void configure() { 11 | bind(EngineIOController.class).toProvider(ChatEngine.class); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /samples/java/clustered-chat/conf/routes: -------------------------------------------------------------------------------- 1 | 2 | GET / controllers.Assets.at(path = "/public", file = "index.html") 3 | GET /assets/*file controllers.Assets.at(path = "/public", file) 4 | 5 | GET /socket.io/ play.engineio.EngineIOController.endpoint(transport) 6 | POST /socket.io/ play.engineio.EngineIOController.endpoint(transport) -------------------------------------------------------------------------------- /samples/java/multi-room-chat/conf/routes: -------------------------------------------------------------------------------- 1 | 2 | GET / controllers.Assets.at(path = "/public", file = "index.html") 3 | GET /assets/*file controllers.Assets.at(path = "/public", file) 4 | 5 | GET /socket.io/ play.engineio.EngineIOController.endpoint(transport) 6 | POST /socket.io/ play.engineio.EngineIOController.endpoint(transport) -------------------------------------------------------------------------------- /samples/scala/clustered-chat/conf/routes: -------------------------------------------------------------------------------- 1 | 2 | GET / controllers.Assets.at(path = "/public", file = "index.html") 3 | GET /assets/*file controllers.Assets.at(path = "/public", file) 4 | 5 | GET /socket.io/ play.engineio.EngineIOController.endpoint(transport) 6 | POST /socket.io/ play.engineio.EngineIOController.endpoint(transport) -------------------------------------------------------------------------------- /samples/scala/multi-room-chat/conf/routes: -------------------------------------------------------------------------------- 1 | 2 | GET / controllers.Assets.at(path = "/public", file = "index.html") 3 | GET /assets/*file controllers.Assets.at(path = "/public", file) 4 | 5 | GET /socket.io/ play.engineio.EngineIOController.endpoint(transport) 6 | POST /socket.io/ play.engineio.EngineIOController.endpoint(transport) -------------------------------------------------------------------------------- /samples/java/clustered-chat/app/chat/ChatModule.java: -------------------------------------------------------------------------------- 1 | package chat; 2 | 3 | import com.google.inject.AbstractModule; 4 | import play.engineio.EngineIOController; 5 | 6 | /** 7 | * The chat module. 8 | */ 9 | public class ChatModule extends AbstractModule { 10 | protected void configure() { 11 | bind(EngineIOController.class).toProvider(ChatEngine.class); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /samples/java/multi-room-chat/app/chat/ChatModule.java: -------------------------------------------------------------------------------- 1 | package chat; 2 | 3 | import com.google.inject.AbstractModule; 4 | import play.engineio.EngineIOController; 5 | 6 | /** 7 | * The chat module. 8 | */ 9 | public class ChatModule extends AbstractModule { 10 | protected void configure() { 11 | bind(EngineIOController.class).toProvider(ChatEngine.class); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.3") 2 | addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.10.0") 3 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") 4 | 5 | addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.7.3") 6 | 7 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.6.0") 8 | 9 | // Protobuf 10 | addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.6") 11 | libraryDependencies += "com.thesamet.scalapb" %% "compilerplugin" % "0.11.12" 12 | -------------------------------------------------------------------------------- /samples/java/clustered-chat/app/chat/ChatEvent.java: -------------------------------------------------------------------------------- 1 | package chat; 2 | 3 | import lombok.Value; 4 | 5 | public interface ChatEvent { 6 | User getUser(); 7 | String getRoom(); 8 | 9 | @Value 10 | class ChatMessage implements ChatEvent { 11 | User user; 12 | String room; 13 | String message; 14 | } 15 | 16 | @Value 17 | class JoinRoom implements ChatEvent { 18 | User user; 19 | String room; 20 | } 21 | 22 | @Value 23 | class LeaveRoom implements ChatEvent { 24 | User user; 25 | String room; 26 | } 27 | } -------------------------------------------------------------------------------- /samples/java/multi-room-chat/app/chat/ChatEvent.java: -------------------------------------------------------------------------------- 1 | package chat; 2 | 3 | import lombok.Value; 4 | 5 | public interface ChatEvent { 6 | User getUser(); 7 | String getRoom(); 8 | 9 | @Value 10 | class ChatMessage implements ChatEvent { 11 | User user; 12 | String room; 13 | String message; 14 | } 15 | 16 | @Value 17 | class JoinRoom implements ChatEvent { 18 | User user; 19 | String room; 20 | } 21 | 22 | @Value 23 | class LeaveRoom implements ChatEvent { 24 | User user; 25 | String room; 26 | } 27 | } -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: release-drafter/release-drafter@v6 13 | with: 14 | name: "Play socket.io support $RESOLVED_VERSION" 15 | config-name: release-drafts/increasing-major-version.yml # located in .github/ in the default branch within this or the .github repo 16 | commitish: ${{ github.ref_name }} 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /src/test/scala/play/socketio/TestSocketIOApplication.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio 5 | 6 | import controllers.ExternalAssets 7 | import play.api.Application 8 | import play.api.routing.Router 9 | import play.engineio.EngineIOController 10 | 11 | import scala.concurrent.ExecutionContext 12 | 13 | trait TestSocketIOApplication { 14 | def createApplication(routerBuilder: (ExternalAssets, EngineIOController, ExecutionContext) => Router): Application 15 | } 16 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | align = true 2 | assumeStandardLibraryStripMargin = true 3 | danglingParentheses = true 4 | docstrings = JavaDoc 5 | maxColumn = 120 6 | project.excludeFilters += core/play/src/main/scala/play/core/hidden/ObjectMappings.scala 7 | project.git = true 8 | rewrite.rules = [ AvoidInfix, ExpandImportSelectors, RedundantParens, SortModifiers, PreferCurlyFors ] 9 | rewrite.sortModifiers.order = [ "private", "protected", "final", "sealed", "abstract", "implicit", "override", "lazy" ] 10 | spaces.inImportCurlyBraces = true # more idiomatic to include whitepsace in import x.{ yyy } 11 | trailingCommas = preserve 12 | version = 2.7.5 13 | -------------------------------------------------------------------------------- /.github/workflows/dependency-graph.yml: -------------------------------------------------------------------------------- 1 | name: Dependency Graph 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | concurrency: 8 | # Only run once for latest commit per ref and cancel other (previous) runs. 9 | group: dependency-graph-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | permissions: 13 | contents: write # this permission is needed to submit the dependency graph 14 | 15 | jobs: 16 | dependency-graph: 17 | name: Submit dependencies to GitHub 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v6 21 | with: 22 | fetch-depth: 0 23 | ref: ${{ inputs.ref }} 24 | - uses: sbt/setup-sbt@v1 25 | - uses: scalacenter/sbt-dependency-submission@v3 26 | -------------------------------------------------------------------------------- /samples/java/clustered-chat/nginx.conf: -------------------------------------------------------------------------------- 1 | error_log logs/nginx-error.log info; 2 | pid target/nginx.pid; 3 | 4 | events { 5 | 6 | } 7 | 8 | http { 9 | error_log logs/nginx-error.log error; 10 | access_log logs/nginx-access.log; 11 | 12 | upstream chatapp { 13 | server localhost:9001; 14 | server localhost:9002; 15 | server localhost:9003; 16 | } 17 | 18 | map $http_upgrade $connection_upgrade { 19 | default upgrade; 20 | '' close; 21 | } 22 | 23 | server { 24 | listen 9000; 25 | 26 | location / { 27 | proxy_pass http://chatapp; 28 | proxy_http_version 1.1; 29 | proxy_set_header Upgrade $http_upgrade; 30 | proxy_set_header Connection $connection_upgrade; 31 | } 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /samples/scala/clustered-chat/nginx.conf: -------------------------------------------------------------------------------- 1 | error_log logs/nginx-error.log info; 2 | pid target/nginx.pid; 3 | 4 | events { 5 | 6 | } 7 | 8 | http { 9 | error_log logs/nginx-error.log error; 10 | access_log logs/nginx-access.log; 11 | 12 | upstream chatapp { 13 | server localhost:9001; 14 | server localhost:9002; 15 | server localhost:9003; 16 | } 17 | 18 | map $http_upgrade $connection_upgrade { 19 | default upgrade; 20 | '' close; 21 | } 22 | 23 | server { 24 | listen 9000; 25 | 26 | location / { 27 | proxy_pass http://chatapp; 28 | proxy_http_version 1.1; 29 | proxy_set_header Upgrade $http_upgrade; 30 | proxy_set_header Connection $connection_upgrade; 31 | } 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /src/main/scala/play/socketio/SocketIOModule.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio 5 | 6 | import play.api.Configuration 7 | import play.api.Environment 8 | import play.api.inject.Module 9 | 10 | /** 11 | * Module for providing both scaladsl and javadsl socket.io components to Play's runtime dependency injection system. 12 | */ 13 | class SocketIOModule extends Module { 14 | override def bindings(environment: Environment, configuration: Configuration) = Seq( 15 | bind[SocketIOConfig].toProvider[SocketIOConfigProvider], 16 | bind[scaladsl.SocketIO].toSelf, 17 | bind[javadsl.SocketIO].toSelf 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/main/scala/play/socketio/SocketIOEventAck.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio 5 | 6 | import akka.util.ByteString 7 | import play.api.libs.json.JsValue 8 | import scala.util.Either 9 | 10 | /** 11 | * A socket.io ack function. 12 | */ 13 | trait SocketIOEventAck { 14 | 15 | def apply(args: Seq[Either[JsValue, ByteString]]): Unit 16 | 17 | def compose[T](f: T => Seq[Either[JsValue, ByteString]]): T => Unit = (args: T) => { 18 | SocketIOEventAck.this.apply(f(args)) 19 | } 20 | } 21 | 22 | object SocketIOEventAck { 23 | def apply(f: Seq[Either[JsValue, ByteString]] => Unit): SocketIOEventAck = args => f(args) 24 | 25 | @deprecated("Use apply method", since = "1.1.0") 26 | def fromScala(f: Seq[Either[JsValue, ByteString]] => Unit): SocketIOEventAck = apply(f) 27 | } 28 | -------------------------------------------------------------------------------- /src/main/scala/play/socketio/SocketIOConfig.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio 5 | 6 | import play.api.Configuration 7 | 8 | import scala.concurrent.duration._ 9 | import javax.inject.Inject 10 | import javax.inject.Provider 11 | import javax.inject.Singleton 12 | 13 | /** 14 | * Configuration for socket.io. 15 | * 16 | * See `reference.conf` for in depth documentation. 17 | */ 18 | case class SocketIOConfig( 19 | ackDeadline: FiniteDuration = 60.seconds, 20 | ackCleanupEvery: Int = 10 21 | ) 22 | 23 | object SocketIOConfig { 24 | def fromConfiguration(configuration: Configuration) = { 25 | val config = configuration.get[Configuration]("play.socket-io") 26 | SocketIOConfig( 27 | ackDeadline = config.get[FiniteDuration]("ack-deadline"), 28 | ackCleanupEvery = config.get[Int]("ack-cleanup-every") 29 | ) 30 | } 31 | } 32 | 33 | @Singleton 34 | class SocketIOConfigProvider @Inject() (configuration: Configuration) extends Provider[SocketIOConfig] { 35 | override lazy val get: SocketIOConfig = SocketIOConfig.fromConfiguration(configuration) 36 | } 37 | -------------------------------------------------------------------------------- /samples/scala/chat/app/modules/MyApplicationLoader.scala: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import chat.ChatEngine 4 | import play.api.ApplicationLoader 5 | import play.api.BuiltInComponents 6 | import play.api.BuiltInComponentsFromContext 7 | import play.api.LoggerConfigurator 8 | import com.softwaremill.macwire._ 9 | import controllers.AssetsComponents 10 | import play.api.inject.DefaultApplicationLifecycle 11 | import play.engineio.EngineIOController 12 | import play.socketio.scaladsl.SocketIOComponents 13 | 14 | class MyApplicationLoader extends ApplicationLoader { 15 | override def load(context: ApplicationLoader.Context) = 16 | new BuiltInComponentsFromContext(context) with MyApplication { 17 | LoggerConfigurator 18 | .apply(context.environment.classLoader) 19 | .foreach(_.configure(context.environment)) 20 | }.application 21 | } 22 | 23 | trait MyApplication extends BuiltInComponents with AssetsComponents with SocketIOComponents { 24 | 25 | lazy val chatEngine = wire[ChatEngine] 26 | lazy val engineIOController: EngineIOController = chatEngine.controller 27 | 28 | override lazy val router = { 29 | val prefix = "/" 30 | wire[_root_.router.Routes] 31 | } 32 | override lazy val httpFilters = Nil 33 | } 34 | -------------------------------------------------------------------------------- /samples/scala/clustered-chat/app/modules/MyApplicationLoader.scala: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import chat.ChatEngine 4 | import play.api.ApplicationLoader 5 | import play.api.BuiltInComponents 6 | import play.api.BuiltInComponentsFromContext 7 | import play.api.LoggerConfigurator 8 | import com.softwaremill.macwire._ 9 | import controllers.AssetsComponents 10 | import play.api.inject.DefaultApplicationLifecycle 11 | import play.engineio.EngineIOController 12 | import play.socketio.scaladsl.SocketIOComponents 13 | 14 | class MyApplicationLoader extends ApplicationLoader { 15 | override def load(context: ApplicationLoader.Context) = 16 | new BuiltInComponentsFromContext(context) with MyApplication { 17 | LoggerConfigurator 18 | .apply(context.environment.classLoader) 19 | .foreach(_.configure(context.environment)) 20 | }.application 21 | } 22 | 23 | trait MyApplication extends BuiltInComponents with AssetsComponents with SocketIOComponents { 24 | 25 | lazy val chatEngine = wire[ChatEngine] 26 | lazy val engineIOController: EngineIOController = chatEngine.controller 27 | 28 | override lazy val router = { 29 | val prefix = "/" 30 | wire[_root_.router.Routes] 31 | } 32 | override lazy val httpFilters = Nil 33 | } 34 | -------------------------------------------------------------------------------- /samples/scala/multi-room-chat/app/modules/MyApplicationLoader.scala: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import chat.ChatEngine 4 | import play.api.ApplicationLoader 5 | import play.api.BuiltInComponents 6 | import play.api.BuiltInComponentsFromContext 7 | import play.api.LoggerConfigurator 8 | import com.softwaremill.macwire._ 9 | import controllers.AssetsComponents 10 | import play.api.inject.DefaultApplicationLifecycle 11 | import play.engineio.EngineIOComponents 12 | import play.engineio.EngineIOController 13 | import play.socketio.scaladsl.SocketIOComponents 14 | 15 | class MyApplicationLoader extends ApplicationLoader { 16 | override def load(context: ApplicationLoader.Context) = 17 | new BuiltInComponentsFromContext(context) with MyApplication { 18 | LoggerConfigurator 19 | .apply(context.environment.classLoader) 20 | .foreach(_.configure(context.environment)) 21 | }.application 22 | } 23 | 24 | trait MyApplication extends BuiltInComponents with AssetsComponents with SocketIOComponents { 25 | 26 | lazy val chatEngine = wire[ChatEngine] 27 | lazy val engineIOController: EngineIOController = chatEngine.controller 28 | 29 | override lazy val router = { 30 | val prefix = "/" 31 | wire[_root_.router.Routes] 32 | } 33 | override lazy val httpFilters = Nil 34 | } 35 | -------------------------------------------------------------------------------- /samples/java/clustered-chat/cluster.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | wd=$(pwd) 5 | 6 | startNode() { 7 | node=$1 8 | target/universal/stage/bin/play-socket-io-java-clustered-chat-example \ 9 | -Dhttp.port=900$node -Dakka.remote.netty.tcp.port=255$node \ 10 | -Dpidfile.path=$wd/target/node$node.pid \ 11 | -Dnode.id=$1 \ 12 | -Dakka.cluster.seed-nodes.0=akka.tcp://application@127.0.0.1:2551 \ 13 | -Dakka.cluster.seed-nodes.1=akka.tcp://application@127.0.0.1:2552 \ 14 | -Dakka.cluster.seed-nodes.2=akka.tcp://application@127.0.0.1:2553 \ 15 | & 16 | } 17 | 18 | stopNode() { 19 | pidfile=$wd/target/node$1.pid 20 | if [ -e $pidfile ] 21 | then 22 | if kill -0 $(cat $pidfile) 23 | then 24 | kill $(cat $pidfile) 25 | fi 26 | fi 27 | } 28 | 29 | case $1 in 30 | 31 | start) 32 | 33 | # Ensure the project is built 34 | ( 35 | cd ../../.. 36 | sbt javaClusteredChat/stage 37 | ) 38 | 39 | startNode 1 40 | startNode 2 41 | startNode 3 42 | 43 | nginx -p $wd -c nginx.conf & 44 | ;; 45 | 46 | stop) 47 | 48 | stopNode 1 49 | stopNode 2 50 | stopNode 3 51 | 52 | if [ -e target/nginx.pid ] 53 | then 54 | kill $(cat target/nginx.pid) 55 | fi 56 | 57 | ;; 58 | esac 59 | -------------------------------------------------------------------------------- /samples/scala/clustered-chat/cluster.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | wd=$(pwd) 5 | 6 | startNode() { 7 | node=$1 8 | target/universal/stage/bin/play-socket-io-scala-clustered-chat-example \ 9 | -Dhttp.port=900$node -Dakka.remote.netty.tcp.port=255$node \ 10 | -Dpidfile.path=$wd/target/node$node.pid \ 11 | -Dnode.id=$1 \ 12 | -Dakka.cluster.seed-nodes.0=akka.tcp://application@127.0.0.1:2551 \ 13 | -Dakka.cluster.seed-nodes.1=akka.tcp://application@127.0.0.1:2552 \ 14 | -Dakka.cluster.seed-nodes.2=akka.tcp://application@127.0.0.1:2553 \ 15 | & 16 | } 17 | 18 | stopNode() { 19 | pidfile=$wd/target/node$1.pid 20 | if [ -e $pidfile ] 21 | then 22 | if kill -0 $(cat $pidfile) 23 | then 24 | kill $(cat $pidfile) 25 | fi 26 | rm $pidfile 27 | fi 28 | } 29 | 30 | case $1 in 31 | 32 | start) 33 | 34 | # Ensure the project is built 35 | ( 36 | cd ../../.. 37 | sbt scalaClusteredChat/stage 38 | ) 39 | 40 | startNode 1 41 | startNode 2 42 | startNode 3 43 | 44 | nginx -p $wd -c nginx.conf & 45 | ;; 46 | 47 | stop) 48 | 49 | stopNode 1 50 | stopNode 2 51 | stopNode 3 52 | 53 | if [ -e target/nginx.pid ] 54 | then 55 | kill $(cat target/nginx.pid) 56 | fi 57 | 58 | ;; 59 | esac 60 | -------------------------------------------------------------------------------- /src/main/scala/play/api/mvc/ByteStringBodyParser.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.api.mvc 5 | 6 | import akka.util.ByteString 7 | 8 | /** 9 | * Created to use in the meantime before https://github.com/playframework/playframework/pull/7551 is merged and 10 | * released. 11 | * 12 | * This is in the play.api.mvc in order to take advantage of the built in Play utilities for buffering bodies. 13 | */ 14 | class ByteStringBodyParser(parsers: PlayBodyParsers) { 15 | 16 | private object myParsers extends PlayBodyParsers { 17 | private[play] implicit override def materializer = parsers.materializer 18 | override def config = parsers.config 19 | private[play] override def errorHandler = parsers.errorHandler 20 | private[play] override def temporaryFileCreator = parsers.temporaryFileCreator 21 | 22 | // Overridden to make public 23 | override def tolerantBodyParser[A](name: String, maxLength: Long, errorMessage: String)( 24 | parser: (RequestHeader, ByteString) => A 25 | ) = 26 | super.tolerantBodyParser(name, maxLength, errorMessage)(parser) 27 | } 28 | 29 | def byteString: BodyParser[ByteString] = 30 | myParsers.tolerantBodyParser("byteString", myParsers.config.maxMemoryBuffer, "Error decoding byte string body")( 31 | (_, bytes) => bytes 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /samples/scala/clustered-chat/app/chat/ChatEventSerializer.scala: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import akka.actor.ExtendedActorSystem 4 | import akka.serialization.BaseSerializer 5 | import akka.serialization.SerializerWithStringManifest 6 | import play.api.libs.json.Json 7 | 8 | /** 9 | * Since messages sent through distributed pubsub go over Akka remoting, they need to be 10 | * serialized. This serializer serializes them as JSON. 11 | */ 12 | class ChatEventSerializer(val system: ExtendedActorSystem) extends SerializerWithStringManifest with BaseSerializer { 13 | override def manifest(o: AnyRef) = o match { 14 | case _: ChatMessage => "M" 15 | case _: JoinRoom => "J" 16 | case _: LeaveRoom => "L" 17 | case other => sys.error("Don't know how to serialize " + other) 18 | } 19 | 20 | override def toBinary(o: AnyRef) = { 21 | val json = o match { 22 | case cm: ChatMessage => Json.toJson(cm) 23 | case jr: JoinRoom => Json.toJson(jr) 24 | case lr: LeaveRoom => Json.toJson(lr) 25 | case other => sys.error("Don't know how to serialize " + other) 26 | } 27 | Json.toBytes(json) 28 | } 29 | 30 | override def fromBinary(bytes: Array[Byte], manifest: String) = { 31 | val json = Json.parse(bytes) 32 | manifest match { 33 | case "M" => json.as[ChatMessage] 34 | case "J" => json.as[JoinRoom] 35 | case "L" => json.as[LeaveRoom] 36 | case other => sys.error("Unknown manifest " + other) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | pull_request: # Check Pull Requests 5 | 6 | push: 7 | branches: 8 | - main # Check branch after merge 9 | 10 | concurrency: 11 | # Only run once for latest commit per ref and cancel other (previous) runs. 12 | group: ci-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | check-code-style: 17 | name: Code Style 18 | uses: playframework/.github/.github/workflows/cmd.yml@v3 19 | with: 20 | cmd: sbt validateCode 21 | 22 | check-binary-compatibility: 23 | name: Binary Compatibility 24 | uses: playframework/.github/.github/workflows/binary-check.yml@v3 25 | 26 | tests: 27 | name: Tests 28 | needs: 29 | - "check-code-style" 30 | - "check-binary-compatibility" 31 | uses: playframework/.github/.github/workflows/cmd.yml@v3 32 | with: 33 | scala: 2.12.x, 2.13.x 34 | cmd: sbt ++$MATRIX_SCALA test 35 | 36 | samples: 37 | name: Samples 38 | needs: 39 | - "tests" 40 | uses: playframework/.github/.github/workflows/cmd.yml@v3 41 | with: 42 | add-dimensions: >- 43 | { 44 | "sample": [ "scalaChat", "scalaMultiRoomChat", "scalaClusteredChat", "javaChat", "javaMultiRoomChat", "javaClusteredChat" ] 45 | } 46 | cmd: sbt $MATRIX_SAMPLE/test 47 | 48 | finish: 49 | name: Finish 50 | if: github.event_name == 'pull_request' 51 | needs: # Should be last 52 | - "samples" 53 | uses: playframework/.github/.github/workflows/rtm.yml@v3 54 | -------------------------------------------------------------------------------- /samples/java/chat/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Socket.IO chat 5 | 16 | 17 | 18 |
    19 |
    20 | 21 |
    22 | 23 | 24 | 38 | 39 | -------------------------------------------------------------------------------- /samples/scala/chat/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Socket.IO chat 5 | 16 | 17 | 18 |
      19 |
      20 | 21 |
      22 | 23 | 24 | 38 | 39 | -------------------------------------------------------------------------------- /src/test/javascript/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Socket.IO Integration Tests 5 | 6 | 7 | 8 |

      Play socket.io integration tests

      9 | 10 |

      This runs the Play socket.io integration tests.

      11 | 12 |

      You need to ensure that the Play TestSocketIOServer is running, this can be run by running sbt test:run. 13 | That server will serve this as the index file.

      14 | 15 | Enable JSONP tests 16 | 17 |

      JSONP tests are disabled by default because they are slow and they make working with the tests harder because they 18 | turn the refresh button into a stop button, so you have to let all the tests run before you can hit refresh and try again.

      19 | 20 |
      21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 42 | 43 | -------------------------------------------------------------------------------- /src/main/protobuf/engineio.proto: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | syntax = "proto3"; 5 | 6 | package play.engineio.protobuf; 7 | 8 | option java_package = "play.engineio.protobuf"; 9 | option optimize_for = SPEED; 10 | 11 | enum Transport { 12 | POLLING = 0; 13 | WEBSOCKET = 1; 14 | } 15 | 16 | message Connect { 17 | string sid = 1; 18 | Transport transport = 2; 19 | string requestId = 3; 20 | 21 | /* HTTP request attributes */ 22 | string method = 4; 23 | string uri = 5; 24 | string version = 6; 25 | repeated HttpHeader headers = 7; 26 | } 27 | 28 | message HttpHeader { 29 | string name = 1; 30 | string value = 2; 31 | } 32 | 33 | message Packets { 34 | string sid = 1; 35 | Transport transport = 2; 36 | repeated Packet packets = 3; 37 | string requestId = 4; 38 | } 39 | 40 | enum PacketType { 41 | OPEN = 0; 42 | CLOSE = 1; 43 | PING = 2; 44 | PONG = 3; 45 | MESSAGE = 4; 46 | UPRADE = 5; 47 | NOOP = 6; 48 | } 49 | 50 | message Packet { 51 | PacketType packetType = 1; 52 | oneof payload { 53 | string text = 2; 54 | bytes binary = 3; 55 | } 56 | } 57 | 58 | message Retrieve { 59 | string sid = 1; 60 | Transport transport = 2; 61 | string requestId = 3; 62 | } 63 | 64 | message Close { 65 | string sid = 1; 66 | Transport transport = 2; 67 | string requestId = 3; 68 | } 69 | 70 | // engine.io exceptions 71 | 72 | message EngineIOEncodingException { 73 | string message = 1; 74 | } 75 | 76 | message UnknownSessionId { 77 | string sid = 1; 78 | } 79 | 80 | message SessionClosed {} -------------------------------------------------------------------------------- /samples/scala/clustered-chat/conf/application.conf: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/Configuration 2 | play.http.secret.key=foo 3 | 4 | play.application.loader = modules.MyApplicationLoader 5 | 6 | play.engine-io { 7 | 8 | # The router name. This tells play-engine.io to use a router with this name, 9 | # which is configured below. 10 | router-name = "engine.io-router" 11 | } 12 | 13 | akka { 14 | actor { 15 | 16 | # Enable clustering 17 | provider = "cluster" 18 | 19 | deployment { 20 | 21 | # This actor path matches the configured play.engine-io.router-name above. 22 | "/engine.io-router" { 23 | 24 | # We use a consistent hashing group. 25 | router = consistent-hashing-group 26 | 27 | # This is the default path for the engine.io manager actor. 28 | # If you've changed that (via the play.engine-io.actor-name setting), 29 | # then this must be updated to match. 30 | routees.paths = ["/user/engine.io"] 31 | 32 | cluster { 33 | enabled = on 34 | allow-local-routees = on 35 | } 36 | } 37 | } 38 | 39 | # Chat event serializer config 40 | serializers.chat-event = "chat.ChatEventSerializer" 41 | serialization-bindings { 42 | "chat.ChatMessage" = chat-event 43 | "chat.JoinRoom" = chat-event 44 | "chat.LeaveRoom" = chat-event 45 | } 46 | serialization-identifiers { 47 | # "chat".hashCode 48 | "chat.ChatEventSerializer" = 3052376 49 | } 50 | } 51 | 52 | # Remove configuration. The port number wil be provided by a system property. 53 | remote { 54 | log-remote-lifecycle-events = off 55 | netty.tcp { 56 | hostname = "127.0.0.1" 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /src/main/scala/play/engineio/DeserializedRequestHeader.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.engineio 5 | 6 | import java.net.URI 7 | 8 | import play.api.libs.typedmap.TypedMap 9 | import play.api.mvc.Headers 10 | import play.api.mvc.RequestHeader 11 | import play.api.mvc.request.RemoteConnection 12 | import play.api.mvc.request.RequestTarget 13 | import play.core.parsers.FormUrlEncodedParser 14 | 15 | /** 16 | * Implementation of Play's RequestHeader that is built from a set of deserialized values. 17 | * 18 | * This is provided so that the RequestHeader can be deserialized from a form sent using Akka remoting. 19 | * 20 | * Does not provide the full request functionality, but does provide all the raw information from the wire. 21 | */ 22 | private[engineio] class DeserializedRequestHeader( 23 | val method: String, 24 | rawUri: String, 25 | val version: String, 26 | headerSeq: Seq[(String, String)] 27 | ) extends RequestHeader { 28 | override lazy val connection = RemoteConnection("0.0.0.0", false, None) 29 | override def attrs = TypedMap.empty 30 | override lazy val headers = Headers(headerSeq: _*) 31 | override lazy val target = { 32 | new RequestTarget { 33 | override lazy val uri = URI.create(rawUri) 34 | override def uriString = rawUri 35 | override def path = if (uri.getPath == null) "/" else uri.getPath 36 | override lazy val queryMap: Map[String, Seq[String]] = if (uri.getRawQuery == null) { 37 | Map.empty 38 | } else { 39 | FormUrlEncodedParser.parse(uri.getRawQuery) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /samples/java/clustered-chat/conf/application.conf: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/Configuration 2 | play.http.secret.key=foo 3 | 4 | play.modules.enabled += chat.ChatModule 5 | 6 | # Disable the default filters, they block half the content we want. 7 | play.filters.enabled = [] 8 | 9 | play.engine-io { 10 | 11 | # The router name. This tells play-engine.io to use a router with this name, 12 | # which is configured below. 13 | router-name = "engine.io-router" 14 | } 15 | 16 | akka { 17 | actor { 18 | 19 | # Enable clustering 20 | provider = "cluster" 21 | 22 | deployment { 23 | 24 | # This actor path matches the configured play.engine-io.router-name above. 25 | "/engine.io-router" { 26 | 27 | # We use a consistent hashing group. 28 | router = consistent-hashing-group 29 | 30 | # This is the default path for the engine.io manager actor. 31 | # If you've changed that (via the play.engine-io.actor-name setting), 32 | # then this must be updated to match. 33 | routees.paths = ["/user/engine.io"] 34 | 35 | cluster { 36 | enabled = on 37 | allow-local-routees = on 38 | } 39 | } 40 | } 41 | 42 | # Chat event serializer config 43 | serializers.chat-event = "chat.ChatEventSerializer" 44 | serialization-bindings { 45 | "chat.ChatEvent" = chat-event 46 | } 47 | serialization-identifiers { 48 | # "chat".hashCode 49 | "chat.ChatEventSerializer" = 3052376 50 | } 51 | } 52 | 53 | # Remove configuration. The port number wil be provided by a system property. 54 | remote { 55 | log-remote-lifecycle-events = off 56 | netty.tcp { 57 | hostname = "127.0.0.1" 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/test/scala/play/socketio/TestSocketIOServer.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio 5 | 6 | import play.core.server.AkkaHttpServer 7 | import play.core.server.ServerConfig 8 | import play.api.mvc.EssentialAction 9 | import play.api.routing.sird._ 10 | import play.api.routing.Router 11 | 12 | /** 13 | * Test server that can be 14 | */ 15 | object TestSocketIOServer { 16 | 17 | def start(testApplication: TestSocketIOApplication, config: ServerConfig = ServerConfig()): AkkaHttpServer = { 18 | AkkaHttpServer.fromApplication( 19 | testApplication.createApplication { (assets, controller, executionContext) => 20 | def extAssets: String => EssentialAction = assets.at("src/test/javascript", _) 21 | implicit val ec = executionContext 22 | Router.from { 23 | case GET(p"/socket.io/") ? q"transport=$transport" => controller.endpoint(transport) 24 | case POST(p"/socket.io/") ? q"transport=$transport" => controller.endpoint(transport) 25 | case GET(p"$path*") => 26 | EssentialAction { rh => 27 | (if (path.endsWith("/")) { 28 | extAssets(path + "index.html") 29 | } else { 30 | extAssets(path) 31 | }).apply(rh).map(_.withHeaders("Cache-Control" -> "no-cache")) 32 | } 33 | } 34 | }, 35 | config 36 | ) 37 | } 38 | 39 | def main(testApplication: TestSocketIOApplication) = { 40 | val server = start(testApplication) 41 | println("Press enter to terminate application.") 42 | System.in.read() 43 | server.stop() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /samples/java/chat/conf/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ${application.home:-.}/logs/application.log 15 | 16 | %date [%level] from %logger in %thread - %message%n%xException 17 | 18 | 19 | 20 | 21 | 22 | %highlight(%-5level) %date %logger{15} - %message%n%xException{10} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /samples/scala/chat/conf/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ${application.home:-.}/logs/application.log 15 | 16 | %date [%level] from %logger in %thread - %message%n%xException 17 | 18 | 19 | 20 | 21 | 22 | %highlight(%-5level) %date %logger{15} - %message%n%xException{10} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /samples/java/multi-room-chat/conf/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ${application.home:-.}/logs/application.log 15 | 16 | %date [%level] from %logger in %thread - %message%n%xException 17 | 18 | 19 | 20 | 21 | 22 | %highlight(%-5level) %date %logger{15} - %message%n%xException{10} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /samples/scala/multi-room-chat/conf/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ${application.home:-.}/logs/application.log 15 | 16 | %date [%level] from %logger in %thread - %message%n%xException 17 | 18 | 19 | 20 | 21 | 22 | %highlight(%-5level) %date %logger{15} - %message%n%xException{10} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /samples/scala/chat/app/chat/ChatEngine.scala: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import akka.stream.Materializer 4 | import akka.stream.scaladsl.BroadcastHub 5 | import akka.stream.scaladsl.Flow 6 | import akka.stream.scaladsl.Keep 7 | import akka.stream.scaladsl.MergeHub 8 | import play.api.libs.json.Format 9 | import play.engineio.EngineIOController 10 | import play.api.libs.functional.syntax._ 11 | import play.socketio.scaladsl.SocketIO 12 | 13 | /** 14 | * A simple chat engine. 15 | */ 16 | class ChatEngine(socketIO: SocketIO)(implicit mat: Materializer) { 17 | 18 | import play.socketio.scaladsl.SocketIOEventCodec._ 19 | 20 | // This will decode String "chat message" events coming in 21 | val decoder = decodeByName { case "chat message" => 22 | decodeJson[String] 23 | } 24 | 25 | // This will encode String "chat message" events going out 26 | val encoder = encodeByType[String] { case _: String => 27 | "chat message" -> encodeJson[String] 28 | } 29 | 30 | private val chatFlow = { 31 | // We use a MergeHub to merge all the incoming chat messages from all the 32 | // connected users into one flow, and we feed that straight into a 33 | // BroadcastHub to broadcast them out again to all the connected users. 34 | // See http://doc.akka.io/docs/akka/2.6/scala/stream/stream-dynamic.html 35 | // for details on these features. 36 | val (sink, source) = MergeHub 37 | .source[String] 38 | .toMat(BroadcastHub.sink)(Keep.both) 39 | .run() 40 | 41 | // We couple the sink and source together so that one completes, the other 42 | // will to, and we use this to handle our chat 43 | Flow.fromSinkAndSourceCoupled(sink, source) 44 | } 45 | 46 | // Here we create an EngineIOController to handle requests for our chat 47 | // system, and we add the chat flow under the "/chat" namespace. 48 | val controller: EngineIOController = socketIO.builder 49 | .addNamespace("/chat", decoder, encoder, chatFlow) 50 | .createController() 51 | } 52 | -------------------------------------------------------------------------------- /src/main/scala/play/engineio/EngineIOSessionHandler.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.engineio 5 | 6 | import akka.NotUsed 7 | import akka.stream.scaladsl.Flow 8 | import akka.util.ByteString 9 | import play.api.mvc.RequestHeader 10 | 11 | import scala.concurrent.Future 12 | 13 | /** 14 | * A handler for engine.io sessions. 15 | */ 16 | trait EngineIOSessionHandler { 17 | 18 | /** 19 | * Create a new flow to handle the flow of messages in the engine.io session. 20 | * 21 | * It may seem odd that the flow produces a `Seq[EngineIOMessage]`. The reason it does this is because socket.io 22 | * binary events get encoded into multiple `EngineIOMessage`'s. By producing messages in `Seq`'s, we allow them to 23 | * be sent as one payload back to the client, otherwise they would usually be split. 24 | * 25 | * @param request The first request for this session. 26 | * @param sid The session id. 27 | */ 28 | def onConnect(request: RequestHeader, sid: String): Future[Flow[EngineIOMessage, Seq[EngineIOMessage], NotUsed]] 29 | 30 | } 31 | 32 | /** 33 | * Exception thrown when a session id is unknown. 34 | * 35 | * @param sid The unknown session id. 36 | */ 37 | case class UnknownSessionId(sid: String) extends RuntimeException("Unknown session id: " + sid, null, true, false) 38 | 39 | /** 40 | * Exception thrown when a message is received for a session that is already closed. 41 | */ 42 | case object SessionClosed extends RuntimeException("Session closed", null, true, false) 43 | 44 | /** 45 | * An engine.io message, either binary or text. 46 | */ 47 | sealed trait EngineIOMessage 48 | 49 | /** 50 | * A binary engine.io message. 51 | */ 52 | case class BinaryEngineIOMessage(bytes: ByteString) extends EngineIOMessage 53 | 54 | /** 55 | * A text engine.io message. 56 | */ 57 | case class TextEngineIOMessage(text: String) extends EngineIOMessage 58 | -------------------------------------------------------------------------------- /src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ${application.home:-.}/logs/application.log 15 | 16 | %date [%level] from %logger in %thread - %message%n%xException 17 | 18 | 19 | 20 | 21 | 22 | %highlight(%-5level) %date %logger{15} - %message%n%xException{10} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /samples/java/clustered-chat/conf/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ${application.home:-.}/logs/application${node.id}.log 15 | 16 | %date [%level] from %logger in %thread - %message%n%xException 17 | 18 | 19 | 20 | 21 | 22 | %highlight(%-5level) [node${node.id}] %date %logger{15} - %message%n%xException{10} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /samples/scala/clustered-chat/conf/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ${application.home:-.}/logs/application${node.id}.log 15 | 16 | %date [%level] from %logger in %thread - %message%n%xException 17 | 18 | 19 | 20 | 21 | 22 | %highlight(%-5level) [node${node.id}] %date %logger{15} - %message%n%xException{10} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /samples/java/clustered-chat/app/chat/ChatEventSerializer.java: -------------------------------------------------------------------------------- 1 | package chat; 2 | 3 | import akka.actor.ExtendedActorSystem; 4 | import akka.serialization.SerializerWithStringManifest; 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | import lombok.val; 7 | 8 | import java.io.ByteArrayOutputStream; 9 | import java.io.IOException; 10 | 11 | public class ChatEventSerializer extends SerializerWithStringManifest { 12 | private final int identifier; 13 | private final ObjectMapper mapper = new ObjectMapper(); 14 | 15 | public ChatEventSerializer(ExtendedActorSystem system) { 16 | identifier = system.settings().config().getInt("akka.actor.serialization-identifiers.\"" + getClass().getName() + "\""); 17 | } 18 | 19 | @Override 20 | public int identifier() { 21 | return identifier; 22 | } 23 | 24 | @Override 25 | public String manifest(Object o) { 26 | if (o instanceof ChatEvent.ChatMessage) { 27 | return "M"; 28 | } else if (o instanceof ChatEvent.JoinRoom) { 29 | return "J"; 30 | } else if (o instanceof ChatEvent.LeaveRoom) { 31 | return "L"; 32 | } else { 33 | throw new RuntimeException("Don't know how to serialize " + o); 34 | } 35 | } 36 | 37 | @Override 38 | public byte[] toBinary(Object o) { 39 | val baos = new ByteArrayOutputStream(); 40 | try { 41 | mapper.writeValue(baos, o); 42 | } catch (IOException e) { 43 | throw new RuntimeException(e); 44 | } 45 | return baos.toByteArray(); 46 | } 47 | 48 | @Override 49 | public Object fromBinary(byte[] bytes, String manifest) { 50 | try { 51 | switch(manifest) { 52 | case "M": 53 | return mapper.readValue(bytes, ChatEvent.ChatMessage.class); 54 | case "J": 55 | return mapper.readValue(bytes, ChatEvent.JoinRoom.class); 56 | case "L": 57 | return mapper.readValue(bytes, ChatEvent.LeaveRoom.class); 58 | default: 59 | throw new RuntimeException("Unknown manifest: " + manifest); 60 | } 61 | } catch (IOException e) { 62 | throw new RuntimeException(e); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /samples/java/chat/app/chat/ChatEngine.java: -------------------------------------------------------------------------------- 1 | package chat; 2 | 3 | import akka.NotUsed; 4 | import akka.japi.Pair; 5 | import akka.stream.Materializer; 6 | import akka.stream.javadsl.*; 7 | import play.engineio.EngineIOController; 8 | import play.socketio.javadsl.SocketIO; 9 | import play.socketio.javadsl.SocketIOEventCodec; 10 | 11 | import javax.inject.Inject; 12 | import javax.inject.Provider; 13 | import javax.inject.Singleton; 14 | 15 | @Singleton 16 | public class ChatEngine implements Provider { 17 | 18 | private final EngineIOController controller; 19 | 20 | @Inject 21 | public ChatEngine(SocketIO socketIO, Materializer materializer) { 22 | 23 | // Here we define our codec. We handle chat messages in both directions as JSON strings 24 | SocketIOEventCodec codec = new SocketIOEventCodec() { 25 | { 26 | addDecoder("chat message", decodeJson(String.class)); 27 | addEncoder("chat message", String.class, encodeJson()); 28 | } 29 | }; 30 | 31 | // We use a MergeHub to merge all the incoming chat messages from all the 32 | // connected users into one flow, and we feed that straight into a 33 | // BroadcastHub to broadcast them out again to all the connected users. 34 | // See http://doc.akka.io/docs/akka/2.6/scala/stream/stream-dynamic.html 35 | // for details on these features. 36 | Pair, Source> pair = MergeHub.of(String.class) 37 | .toMat(BroadcastHub.of(String.class), Keep.both()).run(materializer); 38 | 39 | // We couple the sink and source together so that one completes, the other 40 | // will to, and we use this to handle our chat 41 | Flow chatFlow = Flow.fromSinkAndSourceCoupled(pair.first(), pair.second()); 42 | 43 | // Here we create an EngineIOController to handle requests for our chat 44 | // system, and we add the chat flow under the "/chat" namespace. 45 | controller = socketIO.createBuilder() 46 | .addNamespace("/chat", codec, chatFlow) 47 | .createController(); 48 | } 49 | 50 | public EngineIOController get() { 51 | return controller; 52 | } 53 | } -------------------------------------------------------------------------------- /src/main/scala/play/socketio/javadsl/SocketIOSessionFlowHelper.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio.javadsl 5 | 6 | import java.util.Optional 7 | import java.util.concurrent.CompletionStage 8 | import java.util.function.BiFunction 9 | import java.util.function.Function 10 | 11 | import akka.NotUsed 12 | import akka.stream.Materializer 13 | import akka.stream.javadsl.Flow 14 | import com.fasterxml.jackson.databind.JsonNode 15 | import play.api.libs.json.Json 16 | import play.mvc.Http.RequestHeader 17 | import play.socketio.SocketIOConfig 18 | import play.socketio.SocketIOEvent 19 | import play.socketio.SocketIOSession 20 | import play.socketio.SocketIOSessionFlow 21 | 22 | import scala.concurrent.ExecutionContext 23 | import scala.Function.unlift 24 | import scala.compat.java8.FutureConverters._ 25 | import scala.compat.java8.OptionConverters._ 26 | 27 | /** 28 | * Helps with mapping Java types to Scala types, which is much easier to do in Scala than Java. 29 | */ 30 | private[javadsl] object SocketIOSessionFlowHelper { 31 | 32 | def createEngineIOSessionHandler[SessionData]( 33 | config: SocketIOConfig, 34 | connectCallback: BiFunction[RequestHeader, String, CompletionStage[SessionData]], 35 | errorHandler: Function[Throwable, Optional[JsonNode]], 36 | defaultNamespaceCallback: Function[SocketIOSession[SessionData], Flow[SocketIOEvent, SocketIOEvent, NotUsed]], 37 | connectToNamespaceCallback: BiFunction[SocketIOSession[SessionData], String, Optional[ 38 | Flow[SocketIOEvent, SocketIOEvent, NotUsed] 39 | ]] 40 | )(implicit ec: ExecutionContext, mat: Materializer) = { 41 | SocketIOSessionFlow.createEngineIOSessionHandler[SessionData]( 42 | config, 43 | (request, sid) => connectCallback(request.asJava, sid).toScala, 44 | unlift(t => errorHandler(t).asScala.map(Json.toJson(_))), 45 | session => defaultNamespaceCallback(session).asScala, 46 | unlift { case (session, sid) => 47 | connectToNamespaceCallback(session, sid).asScala.map(_.asScala) 48 | } 49 | ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/test/javascript/lib/utils.js: -------------------------------------------------------------------------------- 1 | function getQueryParameter(name) { 2 | name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); 3 | var regex = new RegExp('[\\?&]' + name + '=([^&#]*)'); 4 | var results = regex.exec(location.search); 5 | return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); 6 | } 7 | 8 | // Phantomjs doesn't support Object.assign, so we add it here 9 | if (typeof Object.assign !== 'function') { 10 | (function () { 11 | Object.assign = function (target) { 12 | 'use strict'; 13 | if (target === undefined || target === null) { 14 | throw new TypeError('Cannot convert undefined or null to object'); 15 | } 16 | 17 | var output = Object(target); 18 | for (var index = 1; index < arguments.length; index++) { 19 | var source = arguments[index]; 20 | if (source !== undefined && source !== null) { 21 | for (var nextKey in source) { 22 | if (source.hasOwnProperty(nextKey)) { 23 | output[nextKey] = source[nextKey]; 24 | } 25 | } 26 | } 27 | } 28 | return output; 29 | }; 30 | })(); 31 | } 32 | 33 | // This is how we read stuff out of phantomjs 34 | 35 | var mochaEvents = []; 36 | var consumeMochaEvents = function() { 37 | var toReturn = mochaEvents; 38 | mochaEvents = []; 39 | return toReturn; 40 | }; 41 | 42 | var recordMochaEvent = function(event) { 43 | mochaEvents.push(event); 44 | }; 45 | 46 | var runMocha = function() { 47 | var mochaRunner = mocha.run(); 48 | 49 | ["suite", "pass"].forEach(function (eventName) { 50 | mochaRunner.on(eventName, function (event) { 51 | var props = []; 52 | for (var prop in event) { 53 | props.push(prop); 54 | } 55 | 56 | recordMochaEvent({ 57 | name: eventName, 58 | title: event.title, 59 | duration: event.duration 60 | }); 61 | }); 62 | }); 63 | 64 | mochaRunner.on("fail", function (event, err) { 65 | recordMochaEvent({ 66 | name: "fail", 67 | title: event.title, 68 | duration: event.duration, 69 | error: err.message 70 | }) 71 | }); 72 | 73 | mochaRunner.on("end", function (event) { 74 | recordMochaEvent({ 75 | name: "end" 76 | }) 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /src/main/scala/play/socketio/SocketIOEvent.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio 5 | 6 | import akka.util.ByteString 7 | import play.api.libs.json.JsValue 8 | 9 | /** 10 | * A socket.io session. 11 | * 12 | * The socket.io session object holds any information relevant to the session. A user can define data to be stored 13 | * there, such as authentication data, and then use that when connecting to namespaces. 14 | * 15 | * @param sid The session ID. 16 | * @param data The session data. 17 | */ 18 | case class SocketIOSession[+T](sid: String, data: T) 19 | 20 | /** 21 | * A socket.io event. 22 | * 23 | * A socket.io event consists of a name (which is used on the client side to register handlers for events), and a set 24 | * of arguments associated with that event. 25 | * 26 | * The arguments mirror the structure in JavaScript, when you publish an event, you pass the event name, and then 27 | * several arguments. When you subscribe to an event, you declare a function that takes zero or more arguments. 28 | * Consequently, the arguments get modelled as a sequence. 29 | * 30 | * Furthermore, socket.io allows you to pass either a structure that can be serialized to JSON (in the Play world, 31 | * this is a [[JsValue]]), or a binary argument (in the Play world, this is a [[ByteString]]). Hence, each argument 32 | * is `Either[JsValue, ByteString]`. 33 | * 34 | * Finally, as the last argument, socket.io allows the emitter to pass an ack function. This then gets passed to the 35 | * consumer as the last argument to their callback function, and they can invoke it, and the arguments passed to it 36 | * will be serialized to the wire, and passed to the function registered on the other side. 37 | * 38 | * @param name The name of the event. 39 | * @param arguments The list of arguments. 40 | * @param ack An optional ack function. 41 | */ 42 | case class SocketIOEvent(name: String, arguments: Seq[Either[JsValue, ByteString]], ack: Option[SocketIOEventAck]) 43 | 44 | object SocketIOEvent { 45 | 46 | /** 47 | * Create an unnamed event. 48 | * 49 | * This is a convenient function for creating events with a name of `""`. 50 | */ 51 | def unnamed(arguments: Seq[Either[JsValue, ByteString]], ack: Option[SocketIOEventAck]) = 52 | SocketIOEvent("", arguments, ack) 53 | } 54 | -------------------------------------------------------------------------------- /src/test/scala/play/socketio/scaladsl/TestMultiNodeSocketIOApplication.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio.scaladsl 5 | 6 | import controllers.ExternalAssets 7 | import play.api.inject.ApplicationLifecycle 8 | import play.api.routing.Router 9 | import play.engineio.EngineIOController 10 | import play.socketio.TestSocketIOApplication 11 | import play.socketio.TestSocketIOServer 12 | 13 | import scala.concurrent.ExecutionContext 14 | import scala.collection.JavaConverters._ 15 | 16 | object TestMultiNodeSocketIOApplication extends TestSocketIOApplication { 17 | override def createApplication(routerBuilder: (ExternalAssets, EngineIOController, ExecutionContext) => Router) = { 18 | 19 | val backendAkkaPort = 24101 20 | val frontendAkkaPort = 24102 21 | 22 | // First, create a backend application node. This node won't receive any HTTP requests, but it's where 23 | // all the socket.io sessions will live. 24 | val backendApplication = new TestSocketIOScalaApplication( 25 | Map( 26 | "akka.actor.provider" -> "remote", 27 | "akka.remote.enabled-transports" -> Seq("akka.remote.netty.tcp").asJava, 28 | "akka.remote.netty.tcp.hostname" -> "127.0.0.1", 29 | "akka.remote.netty.tcp.port" -> backendAkkaPort.asInstanceOf[java.lang.Integer] 30 | ) 31 | ).createComponents(routerBuilder).application 32 | 33 | println("Started backend application.") 34 | 35 | // Now create a frontend application node. This will be configured to route all session messages to the 36 | // backend node 37 | val frontendComponents = new TestSocketIOScalaApplication( 38 | Map( 39 | "akka.actor.provider" -> "remote", 40 | "akka.remote.enabled-transports" -> Seq("akka.remote.netty.tcp").asJava, 41 | "akka.remote.netty.tcp.hostname" -> "127.0.0.1", 42 | "akka.remote.netty.tcp.port" -> frontendAkkaPort.asInstanceOf[java.lang.Integer], 43 | "play.engine-io.router-name" -> "backend-router", 44 | "akka.actor.deployment./backend-router.router" -> "round-robin-group", 45 | "akka.actor.deployment./backend-router.routees.paths" -> 46 | Seq(s"akka.tcp://application@127.0.0.1:$backendAkkaPort/user/engine.io").asJava 47 | ) 48 | ).createComponents(routerBuilder) 49 | 50 | frontendComponents.application 51 | println("Started frontend application.") 52 | 53 | // shutdown the backend application when the frontend application shuts down 54 | frontendComponents.applicationLifecycle 55 | .addStopHook(() => backendApplication.stop()) 56 | 57 | // And return the frontend application 58 | frontendComponents.application 59 | } 60 | 61 | @annotation.varargs 62 | def main(args: String*) = { 63 | TestSocketIOServer.main(this) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /samples/java/clustered-chat/README.md: -------------------------------------------------------------------------------- 1 | # Java clustered chat example 2 | 3 | This is a clustered version of the multi room chat server example written with Play socket.io. It demonstrates how to run Play socket.io in a multi node environment. It uses a consistent hashing cluster group router to route sessions, and uses Akka distributed pubsub to publish/subscribe to rooms. 4 | 5 | The [`cluster.sh`](./cluster.sh) script has been provided to facilitate with building and then running the app in a cluster, it starts up three nodes, and starts up an nginx load balancer in front of the three nodes. Configuration for the load balancer can be found [here](./nginx.conf). It requires that `nginx` is available on your path. 6 | 7 | To start the cluster, run `./cluster.sh start`, and then visit [`http://localhost:9000`](http://localhost:9000). To shut down the cluster, run `./cluster.sh stop`. 8 | 9 | Some debug logging has been turned on which demonstrates the cluster in action. Each node adds its name (node1, node2 or node3) to its output logs, so you can see which node received the request, and also which node the socket.io session is running on. You can also see that the socket.io session may be running on multiple nodes, yet users connected to those different nodes can chat with each other. 10 | 11 | Here's an example cluster session (some info removed from the logs, such as timestamps, to reduce noise): 12 | 13 | ``` 14 | [node2] p.e.EngineIOController - Received new connection for 81111be9-fb14-4080-a224-a8bbed31b5b2 15 | [node1] application - Starting 81111be9-fb14-4080-a224-a8bbed31b5b2 session 16 | [node3] p.e.EngineIOController - Received poll request for 81111be9-fb14-4080-a224-a8bbed31b5b2 17 | [node2] p.e.EngineIOController - Received WebSocket request for 81111be9-fb14-4080-a224-a8bbed31b5b2 18 | [node1] p.e.EngineIOController - Received push request for 81111be9-fb14-4080-a224-a8bbed31b5b2 19 | [node1] p.e.EngineIOController - Received new connection for bb910f67-aac2-42ff-8539-77ff8eda8292 20 | [node2] application - Starting bb910f67-aac2-42ff-8539-77ff8eda8292 session 21 | [node1] p.e.EngineIOController - Received poll request for bb910f67-aac2-42ff-8539-77ff8eda8292 22 | [node2] p.e.EngineIOController - Received poll request for bb910f67-aac2-42ff-8539-77ff8eda8292 23 | [node3] p.e.EngineIOController - Received WebSocket request for bb910f67-aac2-42ff-8539-77ff8eda8292 24 | [node3] p.e.EngineIOController - Received poll request for bb910f67-aac2-42ff-8539-77ff8eda8292 25 | [node2] p.e.EngineIOController - Received push request for bb910f67-aac2-42ff-8539-77ff8eda8292 26 | ``` 27 | 28 | This shows two socket.io sessions, one identified by `81111be9-fb14-4080-a224-a8bbed31b5b2`, and one identified by `bb910f67-aac2-42ff-8539-77ff8eda8292`. The messages logged by the `application` logger shows which node the session is actually created on, so you can see the first one was started on `node1`, the second on `node2` (this correlation was purely chance, by the hash value of the two session ids). Meanwhile, the actual requests for the sessions, logged by the `EngineIOController` are arriving on a combination of all nodes. -------------------------------------------------------------------------------- /samples/scala/clustered-chat/README.md: -------------------------------------------------------------------------------- 1 | # Scala clustered chat example 2 | 3 | This is a clustered version of the multi room chat server example written with Play socket.io. It demonstrates how to run Play socket.io in a multi node environment. It uses a consistent hashing cluster group router to route sessions, and uses Akka distributed pubsub to publish/subscribe to rooms. 4 | 5 | The [`cluster.sh`](./cluster.sh) script has been provided to facilitate with building and then running the app in a cluster, it starts up three nodes, and starts up an nginx load balancer in front of the three nodes. Configuration for the load balancer can be found [here](./nginx.conf). It requires that `nginx` is available on your path. 6 | 7 | To start the cluster, run `./cluster.sh start`, and then visit [`http://localhost:9000`](http://localhost:9000). To shut down the cluster, run `./cluster.sh stop`. 8 | 9 | Some debug logging has been turned on which demonstrates the cluster in action. Each node adds its name (node1, node2 or node3) to its output logs, so you can see which node received the request, and also which node the socket.io session is running on. You can also see that the socket.io session may be running on multiple nodes, yet users connected to those different nodes can chat with each other. 10 | 11 | Here's an example cluster session (some info removed from the logs, such as timestamps, to reduce noise): 12 | 13 | ``` 14 | [node2] p.e.EngineIOController - Received new connection for 81111be9-fb14-4080-a224-a8bbed31b5b2 15 | [node1] application - Starting 81111be9-fb14-4080-a224-a8bbed31b5b2 session 16 | [node3] p.e.EngineIOController - Received poll request for 81111be9-fb14-4080-a224-a8bbed31b5b2 17 | [node2] p.e.EngineIOController - Received WebSocket request for 81111be9-fb14-4080-a224-a8bbed31b5b2 18 | [node1] p.e.EngineIOController - Received push request for 81111be9-fb14-4080-a224-a8bbed31b5b2 19 | [node1] p.e.EngineIOController - Received new connection for bb910f67-aac2-42ff-8539-77ff8eda8292 20 | [node2] application - Starting bb910f67-aac2-42ff-8539-77ff8eda8292 session 21 | [node1] p.e.EngineIOController - Received poll request for bb910f67-aac2-42ff-8539-77ff8eda8292 22 | [node2] p.e.EngineIOController - Received poll request for bb910f67-aac2-42ff-8539-77ff8eda8292 23 | [node3] p.e.EngineIOController - Received WebSocket request for bb910f67-aac2-42ff-8539-77ff8eda8292 24 | [node3] p.e.EngineIOController - Received poll request for bb910f67-aac2-42ff-8539-77ff8eda8292 25 | [node2] p.e.EngineIOController - Received push request for bb910f67-aac2-42ff-8539-77ff8eda8292 26 | ``` 27 | 28 | This shows two socket.io sessions, one identified by `81111be9-fb14-4080-a224-a8bbed31b5b2`, and one identified by `bb910f67-aac2-42ff-8539-77ff8eda8292`. The messages logged by the `application` logger shows which node the session is actually created on, so you can see the first one was started on `node1`, the second on `node2` (this correlation was purely chance, by the hash value of the two session ids). Meanwhile, the actual requests for the sessions, logged by the `EngineIOController` are arriving on a combination of all nodes. -------------------------------------------------------------------------------- /src/main/resources/reference.conf: -------------------------------------------------------------------------------- 1 | # Play EngineIO config 2 | play.engine-io { 3 | 4 | # The ping interval to use to send to clients. This is used both by clients 5 | # to determine how often they should ping, as well as by the socket-io 6 | # session server to determine how often it should check to see if a session 7 | # has timed out and to do other clean up tasks 8 | ping-interval = 25 seconds 9 | 10 | # The ping timeout. If a socket.io client can't get a response in this time, 11 | # it will consider the connection dead. Likewise, if the server doesn't 12 | # receive a ping in this time, it will consider the connection dead. 13 | ping-timeout = 60 seconds 14 | 15 | # The list of transports the server should advertise that it supports. The 16 | # two valid values are websocket and polling. Note that changing this list 17 | # won't actually disable the servers support for the transports, it will 18 | # just change whether the server will advertise these as available upgrades 19 | # to the client. 20 | transports = ["websocket", "polling"] 21 | 22 | # The name of the actor to create for the engine.io manager. 23 | actor-name = "engine.io" 24 | 25 | # The router name for the engine.io router. This path should correspond to 26 | # a configured router group, such as a cluster consistent hashing router. 27 | # The routees of that actor should be the path to the configured actor-name. 28 | # If null, no router group will be used, messages will be sent directly to 29 | # the engine.io manager actor. 30 | router-name = null 31 | 32 | # The role to start the engine.io actors on. Useful when using a consistent 33 | # hashing cluster router, to have engine.io sessions only run on some nodes. 34 | # This must match the cluster.use-role setting in the configured router. If 35 | # null, will start the actors on every node. This setting will have no 36 | # effect if router-name is null. 37 | use-role = null 38 | 39 | } 40 | 41 | # socket.io specific config 42 | play.socket-io { 43 | 44 | # How long the client has to respond to an ack before the server will 45 | # forget about the ack. Since the server has to track all the ack 46 | # functions it sends, if the client doesn't ack them, then this will 47 | # result in the ack map growing indefinitely for a session. Consequently, 48 | # the server periodically cleans up all expired acks to avoid this. 49 | ack-deadline = 60 seconds 50 | 51 | # How often expired acks should be cleaned up. Expired acks will be checked 52 | # every this many acks that we send. 53 | ack-cleanup-every = 10 54 | } 55 | 56 | play.modules.enabled += "play.engineio.EngineIOModule" 57 | play.modules.enabled += "play.socketio.SocketIOModule" 58 | 59 | akka.actor { 60 | serializers { 61 | play-engine-io = "play.engineio.EngineIOAkkaSerializer" 62 | } 63 | serialization-bindings { 64 | "play.engineio.EngineIOManagerActor$Connect" = play-engine-io 65 | "play.engineio.EngineIOManagerActor$Packets" = play-engine-io 66 | "play.engineio.EngineIOManagerActor$Retrieve" = play-engine-io 67 | "play.engineio.EngineIOManagerActor$Close" = play-engine-io 68 | "play.engineio.protocol.EngineIOEncodingException" = play-engine-io 69 | "play.engineio.UnknownSessionId" = play-engine-io 70 | "play.engineio.SessionClosed" = play-engine-io 71 | } 72 | serialization-identifiers { 73 | # "engine.io".hashCode 74 | "play.engineio.EngineIOAkkaSerializer" = 600604754 75 | } 76 | } -------------------------------------------------------------------------------- /src/main/scala/play/engineio/EngineIOManagerActor.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.engineio 5 | 6 | import akka.Done 7 | import akka.actor.Actor 8 | import akka.actor.Props 9 | import akka.routing.ConsistentHashingRouter.ConsistentHashable 10 | import play.api.mvc.RequestHeader 11 | import play.engineio.protocol.EngineIOPacket 12 | import play.engineio.protocol.EngineIOTransport 13 | 14 | /** 15 | * The actor responsible for managing all engine.io sessions for this node. 16 | * 17 | * The messages sent to/from this actor potentially go through Akka remoting, and so must have serializers configured 18 | * accordingly. 19 | */ 20 | object EngineIOManagerActor { 21 | 22 | /** 23 | * Parent type for all messages sent to the actor, implements consistent hashable so that it can be used with a 24 | * consistent hashing router, particularly useful in a cluster scenario. 25 | */ 26 | sealed trait SessionMessage extends ConsistentHashable { 27 | 28 | /** 29 | * The session id. 30 | */ 31 | val sid: String 32 | 33 | /** 34 | * The transport this message came from. 35 | */ 36 | val transport: EngineIOTransport 37 | 38 | /** 39 | * The id of the request that this message came from. 40 | */ 41 | val requestId: String 42 | 43 | override def consistentHashKey = sid 44 | } 45 | 46 | /** 47 | * Connect a session. Sent to initiate a new session. 48 | */ 49 | case class Connect(sid: String, transport: EngineIOTransport, request: RequestHeader, requestId: String) 50 | extends SessionMessage 51 | 52 | /** 53 | * Push packets into the session. 54 | */ 55 | case class Packets(sid: String, transport: EngineIOTransport, packets: Seq[EngineIOPacket], requestId: String) 56 | extends SessionMessage 57 | 58 | /** 59 | * Retrieve packets from the session. 60 | */ 61 | case class Retrieve(sid: String, transport: EngineIOTransport, requestId: String) extends SessionMessage 62 | 63 | /** 64 | * Close the session/connection. 65 | */ 66 | case class Close(sid: String, transport: EngineIOTransport, requestId: String) extends SessionMessage 67 | 68 | def props(config: EngineIOConfig, sessionProps: Props) = Props { 69 | new EngineIOManagerActor(config, sessionProps) 70 | } 71 | } 72 | 73 | /** 74 | * The actor responsible for managing all engine.io sessions for this node. 75 | */ 76 | class EngineIOManagerActor(config: EngineIOConfig, sessionProps: Props) extends Actor { 77 | 78 | import EngineIOManagerActor._ 79 | 80 | override def receive = { 81 | case connect: Connect => 82 | context.child(connect.sid) match { 83 | case Some(sessionActor) => 84 | // Let the actor deal with the duplicate connect 85 | sessionActor.tell(connect, sender()) 86 | case None => 87 | val session = context.actorOf(sessionProps, connect.sid) 88 | session.tell(connect, sender()) 89 | } 90 | 91 | case close @ Close(sid, _, _) => 92 | // Don't restart the session just to close it 93 | context.child(sid) match { 94 | case Some(sessionActor) => 95 | sessionActor.tell(close, sender()) 96 | case None => 97 | sender ! Done 98 | } 99 | 100 | case message: SessionMessage => 101 | context.child(message.sid) match { 102 | case Some(sessionActor) => 103 | sessionActor.tell(message, sender()) 104 | 105 | case None => 106 | // We let the session handle what to do if we get a connection for a non existing sid 107 | val sessionActor = context.actorOf(sessionProps, message.sid) 108 | sessionActor.tell(message, sender()) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/test/javascript/lib/mocha-3.4.2.min.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8";body{margin:0}#mocha{font:20px/1.5 "Helvetica Neue",Helvetica,Arial,sans-serif;margin:60px 50px}#mocha li,#mocha ul{margin:0;padding:0}#mocha ul{list-style:none}#mocha h1,#mocha h2{margin:0}#mocha h1{margin-top:15px;font-size:1em;font-weight:200}#mocha h1 a{text-decoration:none;color:inherit}#mocha h1 a:hover{text-decoration:underline}#mocha .suite .suite h1{margin-top:0;font-size:.8em}#mocha .hidden{display:none}#mocha h2{font-size:12px;font-weight:400;cursor:pointer}#mocha .suite{margin-left:15px}#mocha .test{margin-left:15px;overflow:hidden}#mocha .test.pending:hover h2::after{content:'(pending)';font-family:arial,sans-serif}#mocha .test.pass.medium .duration{background:#c09853}#mocha .test.pass.slow .duration{background:#b94a48}#mocha .test.pass::before{content:'✓';font-size:12px;display:block;float:left;margin-right:5px;color:#00d6b2}#mocha .test.pass .duration{font-size:9px;margin-left:5px;padding:2px 5px;color:#fff;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.2);-moz-box-shadow:inset 0 1px 1px rgba(0,0,0,.2);box-shadow:inset 0 1px 1px rgba(0,0,0,.2);-webkit-border-radius:5px;-moz-border-radius:5px;-ms-border-radius:5px;-o-border-radius:5px;border-radius:5px}#mocha .test.pass.fast .duration{display:none}#mocha .test.pending{color:#0b97c4}#mocha .test.pending::before{content:'◦';color:#0b97c4}#mocha .test.fail{color:#c00}#mocha .test.fail pre{color:#000}#mocha .test.fail::before{content:'✖';font-size:12px;display:block;float:left;margin-right:5px;color:#c00}#mocha .test pre.error{color:#c00;max-height:300px;overflow:auto}#mocha .test .html-error{overflow:auto;color:#000;line-height:1.5;display:block;float:left;clear:left;font:12px/1.5 monaco,monospace;margin:5px;padding:15px;border:1px solid #eee;max-width:85%;max-width:-webkit-calc(100% - 42px);max-width:-moz-calc(100% - 42px);max-width:calc(100% - 42px);max-height:300px;word-wrap:break-word;border-bottom-color:#ddd;-webkit-box-shadow:0 1px 3px #eee;-moz-box-shadow:0 1px 3px #eee;box-shadow:0 1px 3px #eee;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px}#mocha .test .html-error pre.error{border:none;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;-webkit-box-shadow:0;-moz-box-shadow:0;box-shadow:0;padding:0;margin:0;margin-top:18px;max-height:none}#mocha .test pre{display:block;float:left;clear:left;font:12px/1.5 monaco,monospace;margin:5px;padding:15px;border:1px solid #eee;max-width:85%;max-width:-webkit-calc(100% - 42px);max-width:-moz-calc(100% - 42px);max-width:calc(100% - 42px);word-wrap:break-word;border-bottom-color:#ddd;-webkit-box-shadow:0 1px 3px #eee;-moz-box-shadow:0 1px 3px #eee;box-shadow:0 1px 3px #eee;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px}#mocha .test h2{position:relative}#mocha .test a.replay{position:absolute;top:3px;right:0;text-decoration:none;vertical-align:middle;display:block;width:15px;height:15px;line-height:15px;text-align:center;background:#eee;font-size:15px;-webkit-border-radius:15px;-moz-border-radius:15px;border-radius:15px;-webkit-transition:opacity .2s;-moz-transition:opacity .2s;-o-transition:opacity .2s;transition:opacity .2s;opacity:.3;color:#888}#mocha .test:hover a.replay{opacity:1}#mocha-report.pass .test.fail{display:none}#mocha-report.fail .test.pass{display:none}#mocha-report.pending .test.fail,#mocha-report.pending .test.pass{display:none}#mocha-report.pending .test.pass.pending{display:block}#mocha-error{color:#c00;font-size:1.5em;font-weight:100;letter-spacing:1px}#mocha-stats{position:fixed;top:15px;right:10px;font-size:12px;margin:0;color:#888;z-index:1}#mocha-stats .progress{float:right;padding-top:0;height:auto;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background-color:initial}#mocha-stats em{color:#000}#mocha-stats a{text-decoration:none;color:inherit}#mocha-stats a:hover{border-bottom:1px solid #eee}#mocha-stats li{display:inline-block;margin:0 5px;list-style:none;padding-top:11px}#mocha-stats canvas{width:40px;height:40px}#mocha code .comment{color:#ddd}#mocha code .init{color:#2f6fad}#mocha code .string{color:#5890ad}#mocha code .keyword{color:#8a6343}#mocha code .number{color:#2f6fad}@media screen and (max-device-width:480px){#mocha{margin:60px 0}#mocha #stats{position:absolute}}/*# sourceMappingURL=mocha.min.css.map */ -------------------------------------------------------------------------------- /src/test/scala/play/socketio/scaladsl/TestSocketIOScalaApplication.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio.scaladsl 5 | 6 | import akka.stream.Materializer 7 | import akka.stream.OverflowStrategy 8 | import akka.stream.scaladsl.BroadcastHub 9 | import akka.stream.scaladsl.Flow 10 | import akka.stream.scaladsl.Keep 11 | import akka.stream.scaladsl.Sink 12 | import akka.stream.scaladsl.Source 13 | import controllers.AssetsComponents 14 | import controllers.ExternalAssets 15 | import play.api._ 16 | import play.api.libs.json.JsString 17 | import play.api.routing.Router 18 | import play.engineio.EngineIOController 19 | import play.socketio.scaladsl.SocketIOEventCodec.SocketIOEventsDecoder 20 | import play.socketio.scaladsl.SocketIOEventCodec.SocketIOEventsEncoder 21 | import play.socketio.SocketIOEvent 22 | import play.socketio.TestSocketIOApplication 23 | import play.socketio.TestSocketIOServer 24 | 25 | import scala.concurrent.ExecutionContext 26 | 27 | object TestSocketIOScalaApplication extends TestSocketIOScalaApplication(Map.empty) { 28 | @annotation.varargs 29 | def main(args: String*) = { 30 | TestSocketIOServer.main(this) 31 | } 32 | } 33 | 34 | class TestSocketIOScalaApplication(initialSettings: Map[String, AnyRef]) extends TestSocketIOApplication { 35 | 36 | def createApplication( 37 | routerBuilder: (ExternalAssets, EngineIOController, ExecutionContext) => Router 38 | ): Application = { 39 | 40 | val components = createComponents(routerBuilder) 41 | 42 | // eager init the application before logging that we've started 43 | components.application 44 | 45 | println("Started Scala application.") 46 | 47 | components.application 48 | } 49 | 50 | def createComponents( 51 | routerBuilder: (ExternalAssets, EngineIOController, ExecutionContext) => Router 52 | ): BuiltInComponents = { 53 | 54 | val components = new BuiltInComponentsFromContext( 55 | ApplicationLoader.Context.create( 56 | Environment.simple(), 57 | initialSettings = initialSettings 58 | ) 59 | ) with SocketIOComponents with AssetsComponents { 60 | 61 | LoggerConfigurator(environment.classLoader).foreach(_.configure(environment)) 62 | lazy val extAssets = new ExternalAssets(environment)(executionContext, fileMimeTypes) 63 | 64 | override lazy val router = routerBuilder(extAssets, createController(socketIO), executionContext) 65 | override def httpFilters = Nil 66 | } 67 | components 68 | } 69 | 70 | def createController(socketIO: SocketIO)(implicit mat: Materializer, ec: ExecutionContext) = { 71 | val decoder: SocketIOEventsDecoder[SocketIOEvent] = { case e => 72 | e 73 | } 74 | val encoder: SocketIOEventsEncoder[SocketIOEvent] = { case e => 75 | e 76 | } 77 | 78 | val (testDisconnectQueue, testDisconnectFlow) = { 79 | val (sourceQueue, source) = 80 | Source.queue[SocketIOEvent](10, OverflowStrategy.backpressure).toMat(BroadcastHub.sink)(Keep.both).run 81 | (sourceQueue, Flow.fromSinkAndSource(Sink.ignore, source)) 82 | } 83 | 84 | socketIO.builder 85 | .onConnect { (request, sid) => 86 | if (request.getQueryString("fail").contains("true")) { 87 | sys.error("failed") 88 | } else { 89 | () 90 | } 91 | } 92 | .defaultNamespace(decoder, encoder, Flow[SocketIOEvent]) 93 | .addNamespace(decoder, encoder) { case (session, "/test") => 94 | Flow[SocketIOEvent].takeWhile(_.name != "disconnect me").watchTermination() { (_, terminated) => 95 | terminated.onComplete { _ => 96 | testDisconnectQueue.offer(SocketIOEvent("test disconnect", Seq(Left(JsString(session.sid))), None)) 97 | } 98 | } 99 | } 100 | .addNamespace(decoder, encoder) { case (_, "/failable") => 101 | Flow[SocketIOEvent].map { event => 102 | if (event.name == "fail me") { 103 | throw new RuntimeException("you failed") 104 | } 105 | event 106 | } 107 | } 108 | .addNamespace("/test-disconnect-listener", decoder, encoder, testDisconnectFlow) 109 | .createController() 110 | 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Play socket.io support 2 | 3 | [![Twitter Follow](https://img.shields.io/twitter/follow/playframework?label=follow&style=flat&logo=twitter&color=brightgreen)](https://twitter.com/playframework) 4 | [![Discord](https://img.shields.io/discord/931647755942776882?logo=discord&logoColor=white)](https://discord.gg/g5s2vtZ4Fa) 5 | [![GitHub Discussions](https://img.shields.io/github/discussions/playframework/playframework?&logo=github&color=brightgreen)](https://github.com/playframework/playframework/discussions) 6 | [![StackOverflow](https://img.shields.io/static/v1?label=stackoverflow&logo=stackoverflow&logoColor=fe7a16&color=brightgreen&message=playframework)](https://stackoverflow.com/tags/playframework) 7 | [![YouTube](https://img.shields.io/youtube/channel/views/UCRp6QDm5SDjbIuisUpxV9cg?label=watch&logo=youtube&style=flat&color=brightgreen&logoColor=ff0000)](https://www.youtube.com/channel/UCRp6QDm5SDjbIuisUpxV9cg) 8 | [![Twitch Status](https://img.shields.io/twitch/status/playframework?logo=twitch&logoColor=white&color=brightgreen&label=live%20stream)](https://www.twitch.tv/playframework) 9 | [![OpenCollective](https://img.shields.io/opencollective/all/playframework?label=financial%20contributors&logo=open-collective)](https://opencollective.com/playframework) 10 | 11 | [![Build Status](https://github.com/playframework/play-socket.io/actions/workflows/build-test.yml/badge.svg)](https://github.com/playframework/play-socket.io/actions/workflows/build-test.yml) 12 | [![Maven](https://img.shields.io/maven-central/v/com.typesafe.play/play-socket-io_2.13.svg?logo=apache-maven)](https://mvnrepository.com/artifact/com.typesafe.play/play-socket-io_2.13) 13 | [![Repository size](https://img.shields.io/github/repo-size/playframework/play-socket.io.svg?logo=git)](https://github.com/playframework/play-socket.io) 14 | [![Scala Steward badge](https://img.shields.io/badge/Scala_Steward-helping-blue.svg?style=flat&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAQCAMAAAARSr4IAAAAVFBMVEUAAACHjojlOy5NWlrKzcYRKjGFjIbp293YycuLa3pYY2LSqql4f3pCUFTgSjNodYRmcXUsPD/NTTbjRS+2jomhgnzNc223cGvZS0HaSD0XLjbaSjElhIr+AAAAAXRSTlMAQObYZgAAAHlJREFUCNdNyosOwyAIhWHAQS1Vt7a77/3fcxxdmv0xwmckutAR1nkm4ggbyEcg/wWmlGLDAA3oL50xi6fk5ffZ3E2E3QfZDCcCN2YtbEWZt+Drc6u6rlqv7Uk0LdKqqr5rk2UCRXOk0vmQKGfc94nOJyQjouF9H/wCc9gECEYfONoAAAAASUVORK5CYII=)](https://scala-steward.org) 15 | [![Mergify Status](https://img.shields.io/endpoint.svg?url=https://api.mergify.com/v1/badges/playframework/play-socket.io&style=flat)](https://mergify.com) 16 | 17 | This is the Play backend socket.io support. 18 | 19 | ## Features 20 | 21 | * Supports all socket.io features, including polling and WebSocket transports, multiple namespaces, acks, JSON and binary messages. 22 | * Uses Akka streams to handle namespaces. 23 | * Supports end to end back pressure, pushing back on the TCP connection for clients that send messages faster than the server can handle them. 24 | * In-built multi node support via Akka clustering, no need to use sticky load balancing. 25 | * Straight forward codec DSL for translating socket.io callback messages to high level streamed message types. 26 | 27 | ## Documentation 28 | 29 | See the [Java](./docs/JavaSocketIO.md) and [Scala](./docs/ScalaSocketIO.md) documentation. 30 | 31 | ## Sample apps 32 | 33 | Sample apps can be found [here](./samples). 34 | 35 | ## License and support 36 | 37 | This software is licensed under the [Apache 2 license](LICENSE). 38 | 39 | As this software is still in beta status, no guarantees are made with regards to binary or source compatibility between versions. 40 | 41 | ## Developing 42 | 43 | ### Testing 44 | 45 | Integration tests are written in JavaScript using mocha and chai, so that the JavaScript socket.io client can be used, to ensure compatibility with the reference implementation. They can be run by running `test` in sbt. 46 | 47 | There are multiple different backends that the tests are run against, including one implemented in Java, one in Scala, and one in a multi node setup. To debug them, you can start these tests by running `runJavaServer`, `runScalaServer` and `runMultiNodeServer` from `sbt`, then visit `http://localhost:9000`. This will bring you to an index page will includes and will run the mocha test suite against the backend you started. From there, you can set breakpoints in the JavaScript, and inspect the communication using your browsers developer tools. 48 | 49 | The test runner runs the tests against each of these backends, using phantomjs as the browser, and it extracts the test results out and prints them nicely to the console as the tests are running. 50 | 51 | ## Releasing a new version 52 | 53 | See https://github.com/playframework/.github/blob/main/RELEASING.md 54 | -------------------------------------------------------------------------------- /src/test/scala/play/socketio/RunSocketIOTests.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio 5 | 6 | import io.github.bonigarcia.wdm.WebDriverManager 7 | import play.core.server.ServerConfig 8 | 9 | import java.util 10 | import org.openqa.selenium.chrome.ChromeDriver 11 | import org.openqa.selenium.chrome.ChromeDriverService 12 | import org.openqa.selenium.chrome.ChromeOptions 13 | import play.api.Environment 14 | import play.api.LoggerConfigurator 15 | import play.socketio.javadsl.TestSocketIOJavaApplication 16 | import play.socketio.scaladsl.TestMultiNodeSocketIOApplication 17 | import play.socketio.scaladsl.TestSocketIOScalaApplication 18 | import play.utils.Colors 19 | 20 | import scala.collection.JavaConverters._ 21 | 22 | object RunSocketIOTests extends App { 23 | 24 | val port = 9123 25 | 26 | val timeout = 60000 27 | val pollInterval = 200 28 | 29 | // Initialise logging before we start to do anything 30 | val environment = Environment.simple() 31 | LoggerConfigurator(environment.classLoader).foreach(_.configure(environment)) 32 | 33 | WebDriverManager.chromedriver().setup() 34 | 35 | val chromeOptions: ChromeOptions = new ChromeOptions() 36 | .addArguments("--headless", "--no-sandbox", "--disable-dev-shm-usage") 37 | val chromeDriverService: ChromeDriverService = ChromeDriverService.createDefaultService() 38 | val driver = new ChromeDriver(chromeDriverService, chromeOptions) 39 | 40 | Runtime.getRuntime.addShutdownHook(new Thread(new Runnable { 41 | def run(): Unit = driver.quit() 42 | })) 43 | 44 | val passed = 45 | try { 46 | 47 | runTests("Scala support", TestSocketIOScalaApplication) && 48 | runTests("Java support", new TestSocketIOJavaApplication) && 49 | runTests("Multi-node support", TestMultiNodeSocketIOApplication) 50 | 51 | } finally { 52 | driver.quit() 53 | } 54 | 55 | if (!passed) { 56 | System.exit(1) 57 | } else { 58 | System.exit(0) 59 | } 60 | 61 | def runTests(name: String, application: TestSocketIOApplication): Boolean = { 62 | 63 | println() 64 | println(name) 65 | println(Seq.fill(name.length)('=').mkString) 66 | println() 67 | 68 | var passCount = 0 69 | var failCount = 0 70 | 71 | withCloseable( 72 | TestSocketIOServer.start( 73 | application, 74 | ServerConfig( 75 | port = Some(port) 76 | ) 77 | ) 78 | )(_.stop()) { _ => 79 | driver.navigate().to(s"http://localhost:$port/index.html?dontrun=true&jsonp=true") 80 | driver.executeScript("runMocha();") 81 | consume(driver, System.currentTimeMillis()) 82 | } 83 | 84 | @annotation.tailrec 85 | def consume(driver: ChromeDriver, start: Long): Unit = { 86 | var end = false 87 | driver.executeScript("return consumeMochaEvents();") match { 88 | case list: util.List[_] => 89 | list.asScala.foreach { 90 | case map: util.Map[String, _] @unchecked => 91 | val obj = map.asScala 92 | obj.get("name") match { 93 | case Some("suite") => 94 | println(obj.getOrElse("title", "")) 95 | case Some("pass") => 96 | println(s" ${Colors.green("+")} ${obj.getOrElse("title", "")} (${obj.getOrElse("duration", 0)}ms)") 97 | passCount += 1 98 | case Some("fail") => 99 | println(Colors.red(" - ") + obj.getOrElse("title", "")) 100 | println(s"[${Colors.red("error")} ${obj.getOrElse("error", "")}") 101 | failCount += 1 102 | case Some("end") => 103 | val status = if (failCount > 0) { 104 | Colors.red("error") 105 | } else { 106 | Colors.green("success") 107 | } 108 | println( 109 | s"[$status] Test run finished in ${System.currentTimeMillis() - start}ms with $passCount passed and $failCount failed" 110 | ) 111 | end = true 112 | case other => sys.error("Unexpected event: " + other) 113 | } 114 | case unexpected => sys.error("Unexpected object in list: " + unexpected) 115 | } 116 | case unexpected => sys.error("Unexpected return value: " + unexpected) 117 | } 118 | if (start + timeout < System.currentTimeMillis()) { 119 | throw new RuntimeException("Tests have taken too long!") 120 | } 121 | if (!end) { 122 | Thread.sleep(pollInterval) 123 | consume(driver, start) 124 | } 125 | } 126 | 127 | failCount == 0 128 | } 129 | 130 | def withCloseable[T](closeable: T)(close: T => Unit)(block: T => Unit) = 131 | try { 132 | block(closeable) 133 | } finally { 134 | close(closeable) 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /src/test/java/play/socketio/javadsl/TestSocketIOJavaApplication.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio.javadsl; 5 | 6 | 7 | import akka.NotUsed; 8 | import akka.japi.Pair; 9 | import akka.stream.Materializer; 10 | import akka.stream.OverflowStrategy; 11 | import akka.stream.javadsl.*; 12 | import akka.util.ByteString; 13 | import com.google.inject.AbstractModule; 14 | import controllers.ExternalAssets; 15 | import play.api.Application; 16 | import play.api.libs.json.JsString; 17 | import play.api.libs.json.JsValue; 18 | import play.api.routing.Router; 19 | import play.engineio.EngineIOController; 20 | import play.inject.guice.GuiceApplicationBuilder; 21 | import play.socketio.SocketIOEvent; 22 | import play.socketio.TestSocketIOApplication; 23 | import play.socketio.TestSocketIOServer; 24 | import scala.Function3; 25 | import scala.Option; 26 | import scala.concurrent.ExecutionContext; 27 | import scala.util.Either; 28 | import scala.util.Left; 29 | import scala.collection.JavaConverters; 30 | 31 | import javax.inject.Inject; 32 | import javax.inject.Provider; 33 | import java.util.Collections; 34 | import java.util.Optional; 35 | 36 | public class TestSocketIOJavaApplication implements TestSocketIOApplication { 37 | 38 | public static void main(String... args) { 39 | TestSocketIOServer.main(new TestSocketIOJavaApplication()); 40 | } 41 | 42 | @Override 43 | public play.api.Application createApplication(Function3 routerBuilder) { 44 | Application application = new GuiceApplicationBuilder() 45 | .overrides(new AbstractModule() { 46 | @Override 47 | protected void configure() { 48 | bind(Router.class).toProvider(new RouterProvider(routerBuilder)); 49 | } 50 | }) 51 | .build().getWrappedApplication(); 52 | 53 | System.out.println("Started Java application."); 54 | return application; 55 | } 56 | 57 | public static class RouterProvider implements Provider { 58 | private final Function3 routerBuilder; 59 | @Inject private ExternalAssets extAssets; 60 | @Inject private SocketIO socketIO; 61 | @Inject private Materializer mat; 62 | @Inject private ExecutionContext ec; 63 | 64 | private RouterProvider(Function3 routerBuilder) { 65 | this.routerBuilder = routerBuilder; 66 | } 67 | 68 | @Override 69 | public Router get() { 70 | return routerBuilder.apply(extAssets, createController(socketIO, mat), ec); 71 | } 72 | } 73 | 74 | public static EngineIOController createController(SocketIO socketIO, Materializer mat) { 75 | Pair, Source> 76 | disconnectPair = Source.queue(10, OverflowStrategy.backpressure()) 77 | .toMat(BroadcastHub.of(SocketIOEvent.class), Keep.both()).run(mat); 78 | Flow testDisconnectFlow = Flow.fromSinkAndSource(Sink.ignore(), 79 | disconnectPair.second()); 80 | SourceQueueWithComplete testDisconnectQueue = disconnectPair.first(); 81 | 82 | Codec codec = new Codec(); 83 | 84 | return socketIO.createBuilder() 85 | .onConnect((req, sid) -> { 86 | if ("true".equals(req.getQueryString("fail"))) { 87 | throw new RuntimeException("failed"); 88 | } else { 89 | return NotUsed.getInstance(); 90 | } 91 | }).defaultNamespace(codec, Flow.create()) 92 | .addNamespace(codec, (session, namespace) -> { 93 | if (namespace.equals("/test")) { 94 | return Optional.of( 95 | Flow.create() 96 | .takeWhile(e -> !e.name().equals("disconnect me")) 97 | .watchTermination((notUsed, future) -> 98 | future.whenComplete((d, t) -> testDisconnectQueue.offer( 99 | new SocketIOEvent("test disconnect", 100 | JavaConverters.asScalaBufferConverter( 101 | Collections.>singletonList(Left.apply(JsString.apply(session.sid()))) 102 | ).asScala().toSeq(), Option.empty()) 103 | )) 104 | ) 105 | ); 106 | } else { 107 | return Optional.empty(); 108 | } 109 | }).addNamespace("/failable", codec, 110 | Flow.create().map(event -> { 111 | if (event.name().equals("fail me")) { 112 | throw new RuntimeException("you failed"); 113 | } else { 114 | return event; 115 | } 116 | }) 117 | ).addNamespace("/test-disconnect-listener", codec, testDisconnectFlow) 118 | .createController(); 119 | } 120 | 121 | public static class Codec extends SocketIOEventCodec { 122 | { 123 | addDecoder(event -> true, event -> event); 124 | addEncoder(event -> true, event -> event); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /samples/java/clustered-chat/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Socket.IO chat 5 | 32 | 33 | 34 |
      35 |
        36 |
        37 |
         
        38 | 39 | 40 |
        41 |
        42 | 50 | 51 | 52 | 152 | 153 | -------------------------------------------------------------------------------- /samples/java/multi-room-chat/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Socket.IO chat 5 | 32 | 33 | 34 |
        35 |
          36 |
          37 |
           
          38 | 39 | 40 |
          41 |
          42 | 50 | 51 | 52 | 152 | 153 | -------------------------------------------------------------------------------- /samples/scala/clustered-chat/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Socket.IO chat 5 | 32 | 33 | 34 |
          35 |
            36 |
            37 |
             
            38 | 39 | 40 |
            41 |
            42 | 50 | 51 | 52 | 152 | 153 | -------------------------------------------------------------------------------- /samples/scala/multi-room-chat/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Socket.IO chat 5 | 32 | 33 | 34 |
            35 |
              36 |
              37 |
               
              38 | 39 | 40 |
              41 |
              42 | 50 | 51 | 52 | 152 | 153 | -------------------------------------------------------------------------------- /samples/java/multi-room-chat/app/chat/ChatEngine.java: -------------------------------------------------------------------------------- 1 | package chat; 2 | 3 | import akka.NotUsed; 4 | import akka.japi.Pair; 5 | import akka.stream.Materializer; 6 | import akka.stream.javadsl.*; 7 | import lombok.val; 8 | import play.engineio.EngineIOController; 9 | import play.socketio.javadsl.SocketIO; 10 | import play.socketio.javadsl.SocketIOEventCodec; 11 | 12 | import javax.inject.Inject; 13 | import javax.inject.Provider; 14 | import javax.inject.Singleton; 15 | 16 | import chat.ChatEvent.*; 17 | 18 | import java.util.Optional; 19 | import java.util.concurrent.ConcurrentHashMap; 20 | 21 | @Singleton 22 | public class ChatEngine implements Provider { 23 | 24 | private final EngineIOController controller; 25 | private final Materializer materializer; 26 | // All the chat rooms 27 | private final ConcurrentHashMap, Source>> chatRooms = 28 | new ConcurrentHashMap<>(); 29 | 30 | @Inject 31 | @SuppressWarnings("unchecked") 32 | public ChatEngine(SocketIO socketIO, Materializer materializer) { 33 | this.materializer = materializer; 34 | 35 | // Here we define our codec. We're serializing our events to/from json. 36 | val codec = new SocketIOEventCodec() { 37 | { 38 | addDecoder("chat message", decodeJson(ChatMessage.class)); 39 | addDecoder("join room", decodeJson(JoinRoom.class)); 40 | addDecoder("leave room", decodeJson(LeaveRoom.class)); 41 | 42 | addEncoder("chat message", ChatMessage.class, encodeJson()); 43 | addEncoder("join room", JoinRoom.class, encodeJson()); 44 | addEncoder("leave room", LeaveRoom.class, encodeJson()); 45 | } 46 | }; 47 | 48 | controller = socketIO.createBuilder() 49 | .onConnect((request, sid) -> { 50 | // Extract the username from the header 51 | val username = request.getQueryString("user"); 52 | if (username == null) { 53 | throw new RuntimeException("No user parameter"); 54 | } 55 | // And return the user, this will be the data for the session that we can read when we add a namespace 56 | return new User(username); 57 | 58 | }).addNamespace(codec, (session, chat) -> { 59 | if (chat.split("\\?")[0].equals("/chat")) { 60 | return Optional.of(createFlow(session.data())); 61 | } else { 62 | return Optional.empty(); 63 | } 64 | }) 65 | .createController(); 66 | } 67 | 68 | // This gets an existing chat room, or creates it if it doesn't exist 69 | private Flow getChatRoom(String room, User user) { 70 | 71 | val pair = chatRooms.computeIfAbsent(room, (r) -> { 72 | // Each chat room is a merge hub merging messages into a broadcast hub. 73 | return MergeHub.of(ChatEvent.class).toMat(BroadcastHub.of(ChatEvent.class), Keep.both()).run(materializer); 74 | }); 75 | 76 | // A coupled sink and source ensures if either side is cancelled/completed, the other will be too. 77 | return Flow.fromSinkAndSourceCoupled( 78 | Flow.create() 79 | // Add the join and leave room events 80 | .concat(Source.single(new LeaveRoom(user, room))) 81 | .prepend(Source.single(new JoinRoom(user, room))) 82 | .to(pair.first()), 83 | pair.second() 84 | ); 85 | } 86 | 87 | private Flow createFlow(User user) { 88 | // broadcast source and sink for demux/muxing multiple chat rooms in this one flow 89 | // They'll be provided later when we materialize the flow 90 | Source[] broadcastSource = new Source[1]; 91 | Sink[] mergeSink = new Sink[1]; 92 | 93 | // Create a chat flow for a user session 94 | return Flow.create().map(event -> { 95 | if (event instanceof JoinRoom) { 96 | val room = event.getRoom(); 97 | val roomFlow = getChatRoom(room, user); 98 | 99 | // Add the room to our flow 100 | broadcastSource[0] 101 | // Ensure only messages for this room get there. 102 | // Also filter out JoinRoom messages, since there's a race condition as to whether it will 103 | // actually get here or not, so we explicitly add it below. 104 | .filter(e -> e.getRoom().equals(room) && !(e instanceof JoinRoom)) 105 | // Take until we get a leave room message. 106 | .takeWhile(e -> !(e instanceof LeaveRoom)) 107 | // We ensure that a join room message is sent first 108 | // And send it through the room flow 109 | .via(roomFlow) 110 | // Re-add the leave room here, since it was filtered out before 111 | .concat(Source.single(new LeaveRoom(user, room))) 112 | // And run it with the merge sink 113 | .runWith(mergeSink[0], materializer); 114 | return event; 115 | } else if (event instanceof ChatMessage) { 116 | // Add the user 117 | return new ChatMessage(user, event.getRoom(), ((ChatMessage) event).getMessage()); 118 | } else { 119 | return event; 120 | } 121 | }).via( 122 | Flow.fromSinkAndSourceCoupledMat(BroadcastHub.of(ChatEvent.class), MergeHub.of(ChatEvent.class), (source, sink) -> { 123 | broadcastSource[0] = source; 124 | mergeSink[0] = sink; 125 | return NotUsed.getInstance(); 126 | }) 127 | ); 128 | } 129 | 130 | public EngineIOController get() { 131 | return controller; 132 | } 133 | } -------------------------------------------------------------------------------- /samples/scala/multi-room-chat/app/chat/ChatEngine.scala: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import akka.NotUsed 4 | import akka.stream._ 5 | import akka.stream.scaladsl.BroadcastHub 6 | import akka.stream.scaladsl.Flow 7 | import akka.stream.scaladsl.Keep 8 | import akka.stream.scaladsl.MergeHub 9 | import akka.stream.scaladsl.Sink 10 | import akka.stream.scaladsl.Source 11 | import play.api.libs.json.Format 12 | import play.api.libs.json.Json 13 | import play.engineio.EngineIOController 14 | import play.api.libs.functional.syntax._ 15 | import play.socketio.scaladsl.SocketIO 16 | 17 | import scala.collection.concurrent.TrieMap 18 | 19 | object ChatProtocol { 20 | 21 | /** 22 | * A chat event, either a message, a join room, or a leave room event. 23 | */ 24 | sealed trait ChatEvent { 25 | def user: Option[User] 26 | def room: String 27 | } 28 | 29 | case class ChatMessage(user: Option[User], room: String, message: String) extends ChatEvent 30 | object ChatMessage { 31 | implicit val format: Format[ChatMessage] = Json.format 32 | } 33 | 34 | case class JoinRoom(user: Option[User], room: String) extends ChatEvent 35 | object JoinRoom { 36 | implicit val format: Format[JoinRoom] = Json.format 37 | } 38 | 39 | case class LeaveRoom(user: Option[User], room: String) extends ChatEvent 40 | object LeaveRoom { 41 | implicit val format: Format[LeaveRoom] = Json.format 42 | } 43 | 44 | case class User(name: String) 45 | object User { 46 | // We're just encoding user as a simple string, not an object 47 | implicit val format: Format[User] = implicitly[Format[String]].inmap(User.apply, _.name) 48 | } 49 | 50 | import play.socketio.scaladsl.SocketIOEventCodec._ 51 | 52 | val decoder = decodeByName { 53 | case "chat message" => decodeJson[ChatMessage] 54 | case "join room" => decodeJson[JoinRoom] 55 | case "leave room" => decodeJson[LeaveRoom] 56 | } 57 | 58 | val encoder = encodeByType[ChatEvent] { 59 | case _: ChatMessage => "chat message" -> encodeJson[ChatMessage] 60 | case _: JoinRoom => "join room" -> encodeJson[JoinRoom] 61 | case _: LeaveRoom => "leave room" -> encodeJson[LeaveRoom] 62 | } 63 | } 64 | 65 | class ChatEngine(socketIO: SocketIO)(implicit mat: Materializer) { 66 | 67 | import ChatProtocol._ 68 | 69 | // All the chat rooms 70 | private val chatRooms = TrieMap.empty[String, (Sink[ChatEvent, NotUsed], Source[ChatEvent, NotUsed])] 71 | 72 | // This gets an existing chat room, or creates it if it doesn't exist 73 | private def getChatRoom(user: User, room: String) = { 74 | val (sink, source) = chatRooms.getOrElseUpdate( 75 | room, { 76 | // Each chat room is a merge hub merging messages into a broadcast hub. 77 | MergeHub.source[ChatEvent].toMat(BroadcastHub.sink[ChatEvent])(Keep.both).run() 78 | } 79 | ) 80 | 81 | Flow.fromSinkAndSourceCoupled( 82 | Flow[ChatEvent] 83 | // Add the join and leave room events 84 | .prepend(Source.single(JoinRoom(Some(user), room))) 85 | .concat(Source.single(LeaveRoom(Some(user), room))) 86 | .to(sink), 87 | source 88 | ) 89 | 90 | } 91 | 92 | // Creates a chat flow for a user session 93 | def userChatFlow(user: User): Flow[ChatEvent, ChatEvent, NotUsed] = { 94 | 95 | // broadcast source and sink for demux/muxing multiple chat rooms in this one flow 96 | // They'll be provided later when we materialize the flow 97 | var broadcastSource: Source[ChatEvent, NotUsed] = null 98 | var mergeSink: Sink[ChatEvent, NotUsed] = null 99 | 100 | Flow[ChatEvent] 101 | .map { 102 | case event @ JoinRoom(_, room) => 103 | val roomFlow = getChatRoom(user, room) 104 | 105 | // Add the room to our flow 106 | broadcastSource 107 | // Ensure only messages for this room get there 108 | // Also filter out JoinRoom messages, since there's a race condition as to whether it will 109 | // actually get here or not, so the room flow explicitly adds it. 110 | .filter(e => e.room == room && !e.isInstanceOf[JoinRoom]) 111 | // Take until we get a leave room message. 112 | .takeWhile(!_.isInstanceOf[LeaveRoom]) 113 | // And send it through the room flow 114 | .via(roomFlow) 115 | // Re-add the leave room here, since it was filtered out before and we want to see it ourselves 116 | .concat(Source.single(LeaveRoom(Some(user), room))) 117 | // And feed to the merge sink 118 | .runWith(mergeSink) 119 | 120 | event 121 | 122 | case ChatMessage(_, room, message) => 123 | ChatMessage(Some(user), room, message) 124 | 125 | case other => other 126 | 127 | } 128 | .via { 129 | Flow.fromSinkAndSourceCoupledMat(BroadcastHub.sink[ChatEvent], MergeHub.source[ChatEvent]) { (source, sink) => 130 | broadcastSource = source 131 | mergeSink = sink 132 | NotUsed 133 | } 134 | } 135 | } 136 | 137 | val controller: EngineIOController = socketIO.builder 138 | .onConnect { (request, sid) => 139 | // Extract the username from the header 140 | val username = request.getQueryString("user").getOrElse { 141 | throw new RuntimeException("No user parameter") 142 | } 143 | // And return the user, this will be the data for the session that we can read when we add a namespace 144 | User(username) 145 | } 146 | .addNamespace(decoder, encoder) { 147 | case (session, chat) if chat.split('?').head == "/chat" => userChatFlow(session.data) 148 | } 149 | .createController() 150 | } 151 | -------------------------------------------------------------------------------- /src/test/javascript/socketio.js: -------------------------------------------------------------------------------- 1 | 2 | var expect = chai.expect; 3 | 4 | var modes = [ 5 | { 6 | name: "Default", 7 | opts: {}, 8 | expectUpgrade: true 9 | }, 10 | { 11 | name: "WebSocket", 12 | opts: { 13 | transports: ["websocket"] 14 | } 15 | }, 16 | { 17 | name: "Polling", 18 | opts: { 19 | transports: ["polling"] 20 | } 21 | }, 22 | { 23 | name: "Polling Base64", 24 | opts: { 25 | transports: ["polling"], 26 | forceBase64: true 27 | } 28 | } 29 | ]; 30 | 31 | if (getQueryParameter("jsonp") === "true") { 32 | modes.push({ 33 | name: "Polling JSONP", 34 | opts: { 35 | transports: ["polling"], 36 | forceJSONP: true 37 | } 38 | }); 39 | } 40 | 41 | 42 | modes.forEach(function (mode) { 43 | 44 | describe(mode.name + " socket.io support", function() { 45 | var socket; 46 | var manager; 47 | 48 | this.timeout(10000); // 10 seconds 49 | 50 | function connect(uri, opts) { 51 | socket = manager.socket(uri, opts); 52 | return socket; 53 | } 54 | 55 | beforeEach(function(done) { 56 | socket = io(Object.assign({ 57 | forceNew: true 58 | }, mode.opts)); 59 | manager = socket.io; 60 | // If we're using a protocol that upgrades, wait to upgrade. 61 | if (mode.expectUpgrade) { 62 | manager.engine.on("upgrade", function() { 63 | done(); 64 | }); 65 | } else { 66 | done(); 67 | } 68 | }); 69 | 70 | afterEach(function() { 71 | if (manager) { 72 | manager.close(); 73 | } 74 | }); 75 | 76 | it("should allow sending and receiving messages on the root namespace", function(done) { 77 | socket.on("m", function(arg) { 78 | expect(arg).to.equal("hello"); 79 | done(); 80 | }); 81 | socket.emit("m", "hello"); 82 | }); 83 | 84 | it("should allow sending acks in both directions", function(done) { 85 | socket.emit("m", "hello", function(arg) { 86 | expect(arg).to.equal("This is an acked ack"); 87 | done(); 88 | }); 89 | socket.on("m", function(arg, ack) { 90 | ack("This is an acked ack"); 91 | }); 92 | }); 93 | 94 | it("should support binary messages", function(done) { 95 | var blob = new Blob(["im binary"], {type: "text/plain"}); 96 | socket.emit("m", blob); 97 | socket.on("m", function(arg) { 98 | var text = new TextDecoder("utf-8").decode(arg); 99 | expect(text).to.equal("im binary"); 100 | done(); 101 | }); 102 | }); 103 | 104 | it("should support binary acks", function(done) { 105 | socket.emit("m", "hello", function(arg) { 106 | var text = new TextDecoder("utf-8").decode(arg); 107 | expect(text).to.equal("this is a binary acked ack"); 108 | done(); 109 | }); 110 | socket.on("m", function(arg, ack) { 111 | var blob = new Blob(["this is a binary acked ack"], {type: "text/plain"}); 112 | ack(blob); 113 | }); 114 | }); 115 | 116 | it("should allow connecting to a namespace", function(done) { 117 | var socket = connect("/test"); 118 | socket.on("connect", function() { 119 | done(); 120 | }); 121 | }); 122 | 123 | it("should allow sending and receiving messages on a namespace", function(done) { 124 | var socket = connect("/test"); 125 | socket.emit("m", "hello"); 126 | socket.on("m", function(arg) { 127 | expect(arg).to.equal("hello"); 128 | done(); 129 | }); 130 | }); 131 | 132 | it("should allow sending acks in both directions on a namespace", function(done) { 133 | var socket = connect("/test"); 134 | socket.emit("m", "hello", function(arg) { 135 | expect(arg).to.equal("This is an acked ack"); 136 | done(); 137 | }); 138 | socket.on("m", function(arg, ack) { 139 | ack("This is an acked ack"); 140 | }); 141 | }); 142 | 143 | it("should allow disconnecting from a namespace", function(done) { 144 | var testDisconnectListener = connect("/test-disconnect-listener"); 145 | var socket = connect("/test"); 146 | testDisconnectListener.on("test disconnect", function(sid) { 147 | if (sid === manager.engine.id) { 148 | done(); 149 | } 150 | }); 151 | socket.on("connect", function() { 152 | socket.disconnect(); 153 | }); 154 | }); 155 | 156 | it("should get back an error if the namespace doesn't exist", function(done) { 157 | var socket = connect("/i-dont-exist"); 158 | socket.on("error", function(error) { 159 | expect(error).to.equal("Namespace not found: /i-dont-exist"); 160 | done(); 161 | }); 162 | }); 163 | 164 | it("should notify the client when a namespace disconnects on the server", function(done) { 165 | var socket = connect("/test"); 166 | socket.on("disconnect", function() { 167 | done(); 168 | }); 169 | socket.emit("disconnect me"); 170 | }); 171 | 172 | it("should notify the client when a namespace emits an error", function(done) { 173 | var socket = connect("/failable"); 174 | socket.on("error", function(arg) { 175 | expect(arg).to.equal("you failed"); 176 | done(); 177 | }); 178 | socket.emit("fail me"); 179 | }); 180 | 181 | it("should disconnect the namespace when a namespace emits an error", function(done) { 182 | var socket = connect("/failable"); 183 | socket.on("disconnect", function() { 184 | done(); 185 | }); 186 | socket.emit("fail me"); 187 | }); 188 | 189 | }) 190 | }); 191 | 192 | 193 | -------------------------------------------------------------------------------- /samples/scala/clustered-chat/app/chat/ChatEngine.scala: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import akka.NotUsed 4 | import akka.actor.ActorSystem 5 | import akka.cluster.pubsub.DistributedPubSub 6 | import akka.cluster.pubsub.DistributedPubSubMediator.Publish 7 | import akka.cluster.pubsub.DistributedPubSubMediator.Subscribe 8 | import akka.stream._ 9 | import akka.stream.scaladsl.BroadcastHub 10 | import akka.stream.scaladsl.Flow 11 | import akka.stream.scaladsl.MergeHub 12 | import akka.stream.scaladsl.Sink 13 | import akka.stream.scaladsl.Source 14 | import play.api.Logger 15 | import play.api.libs.json.Format 16 | import play.api.libs.json.Json 17 | import play.engineio.EngineIOController 18 | import play.api.libs.functional.syntax._ 19 | import play.socketio.scaladsl.SocketIO 20 | 21 | /** 22 | * A chat event, either a message, a join room, or a leave room event. 23 | */ 24 | sealed trait ChatEvent { 25 | def user: Option[User] 26 | def room: String 27 | } 28 | 29 | case class ChatMessage(user: Option[User], room: String, message: String) extends ChatEvent 30 | object ChatMessage { 31 | implicit val format: Format[ChatMessage] = Json.format 32 | } 33 | 34 | case class JoinRoom(user: Option[User], room: String) extends ChatEvent 35 | object JoinRoom { 36 | implicit val format: Format[JoinRoom] = Json.format 37 | } 38 | 39 | case class LeaveRoom(user: Option[User], room: String) extends ChatEvent 40 | object LeaveRoom { 41 | implicit val format: Format[LeaveRoom] = Json.format 42 | } 43 | 44 | case class User(name: String) 45 | object User { 46 | // We're just encoding user as a simple string, not an object 47 | implicit val format: Format[User] = implicitly[Format[String]].inmap(User.apply, _.name) 48 | } 49 | 50 | object ChatProtocol { 51 | import play.socketio.scaladsl.SocketIOEventCodec._ 52 | 53 | val decoder = decodeByName { 54 | case "chat message" => decodeJson[ChatMessage] 55 | case "join room" => decodeJson[JoinRoom] 56 | case "leave room" => decodeJson[LeaveRoom] 57 | } 58 | 59 | val encoder = encodeByType[ChatEvent] { 60 | case _: ChatMessage => "chat message" -> encodeJson[ChatMessage] 61 | case _: JoinRoom => "join room" -> encodeJson[JoinRoom] 62 | case _: LeaveRoom => "leave room" -> encodeJson[LeaveRoom] 63 | } 64 | } 65 | 66 | class ChatEngine(socketIO: SocketIO, system: ActorSystem)(implicit mat: Materializer) { 67 | 68 | import ChatProtocol._ 69 | 70 | val mediator = DistributedPubSub(system).mediator 71 | 72 | // This gets a chat room using Akka distributed pubsub 73 | private def getChatRoom(user: User, room: String) = { 74 | 75 | // Create a sink that sends all the messages to the chat room 76 | val sink = Sink.foreach[ChatEvent] { message => mediator ! Publish(room, message) } 77 | 78 | // Create a source that subscribes to messages from the chatroom 79 | val source = Source 80 | .actorRef[ChatEvent](16, OverflowStrategy.dropHead) 81 | .mapMaterializedValue { ref => mediator ! Subscribe(room, ref) } 82 | 83 | Flow.fromSinkAndSourceCoupled( 84 | Flow[ChatEvent] 85 | // Add the join and leave room events 86 | .prepend(Source.single(JoinRoom(Some(user), room))) 87 | .concat(Source.single(LeaveRoom(Some(user), room))) 88 | .to(sink), 89 | source 90 | ) 91 | } 92 | 93 | // Creates a chat flow for a user session 94 | def userChatFlow(user: User): Flow[ChatEvent, ChatEvent, NotUsed] = { 95 | 96 | // broadcast source and sink for demux/muxing multiple chat rooms in this one flow 97 | // They'll be provided later when we materialize the flow 98 | var broadcastSource: Source[ChatEvent, NotUsed] = null 99 | var mergeSink: Sink[ChatEvent, NotUsed] = null 100 | 101 | Flow[ChatEvent] 102 | .map { 103 | case event @ JoinRoom(_, room) => 104 | val roomFlow = getChatRoom(user, room) 105 | 106 | // Add the room to our flow 107 | broadcastSource 108 | // Ensure only messages for this room get there 109 | // Also filter out JoinRoom messages, since there's a race condition as to whether it will 110 | // actually get here or not, so the room flow explicitly adds it. 111 | .filter(e => e.room == room && !e.isInstanceOf[JoinRoom]) 112 | // Take until we get a leave room message. 113 | .takeWhile(!_.isInstanceOf[LeaveRoom]) 114 | // And send it through the room flow 115 | .via(roomFlow) 116 | // Re-add the leave room here, since it was filtered out before 117 | .concat(Source.single(LeaveRoom(Some(user), room))) 118 | // And feed to the merge sink 119 | .runWith(mergeSink) 120 | 121 | event 122 | 123 | case ChatMessage(_, room, message) => 124 | ChatMessage(Some(user), room, message) 125 | 126 | case other => other 127 | 128 | } 129 | .via { 130 | Flow.fromSinkAndSourceCoupledMat(BroadcastHub.sink[ChatEvent], MergeHub.source[ChatEvent]) { (source, sink) => 131 | broadcastSource = source 132 | mergeSink = sink 133 | NotUsed 134 | } 135 | } 136 | } 137 | 138 | val controller: EngineIOController = socketIO.builder 139 | .onConnect { (request, sid) => 140 | Logger(this.getClass).info(s"Starting $sid session") 141 | // Extract the username from the header 142 | val username = request.getQueryString("user").getOrElse { 143 | throw new RuntimeException("No user parameter") 144 | } 145 | // And return the user, this will be the data for the session that we can read when we add a namespace 146 | User(username) 147 | } 148 | .addNamespace(decoder, encoder) { 149 | case (session, chat) if chat.split('?').head == "/chat" => userChatFlow(session.data) 150 | } 151 | .createController() 152 | } 153 | -------------------------------------------------------------------------------- /samples/java/clustered-chat/app/chat/ChatEngine.java: -------------------------------------------------------------------------------- 1 | package chat; 2 | 3 | import akka.NotUsed; 4 | import akka.actor.ActorRef; 5 | import akka.actor.ActorSystem; 6 | import akka.cluster.pubsub.DistributedPubSub; 7 | import akka.cluster.pubsub.DistributedPubSubMediator.Publish; 8 | import akka.cluster.pubsub.DistributedPubSubMediator.Subscribe; 9 | import akka.stream.Materializer; 10 | import akka.stream.OverflowStrategy; 11 | import akka.stream.javadsl.*; 12 | import lombok.val; 13 | import play.Logger; 14 | import play.engineio.EngineIOController; 15 | import play.socketio.javadsl.SocketIO; 16 | import play.socketio.javadsl.SocketIOEventCodec; 17 | 18 | import javax.inject.Inject; 19 | import javax.inject.Provider; 20 | import javax.inject.Singleton; 21 | 22 | import chat.ChatEvent.*; 23 | 24 | import java.util.Optional; 25 | 26 | @Singleton 27 | public class ChatEngine implements Provider { 28 | 29 | private final EngineIOController controller; 30 | private final Materializer materializer; 31 | private final ActorRef mediator; 32 | 33 | @Inject 34 | @SuppressWarnings("unchecked") 35 | public ChatEngine(SocketIO socketIO, Materializer materializer, ActorSystem actorSystem) { 36 | this.materializer = materializer; 37 | this.mediator = DistributedPubSub.get(actorSystem).mediator(); 38 | 39 | // Here we define our codec. We're serializing our events to/from json. 40 | val codec = new SocketIOEventCodec() { 41 | { 42 | addDecoder("chat message", decodeJson(ChatMessage.class)); 43 | addDecoder("join room", decodeJson(JoinRoom.class)); 44 | addDecoder("leave room", decodeJson(LeaveRoom.class)); 45 | 46 | addEncoder("chat message", ChatMessage.class, encodeJson()); 47 | addEncoder("join room", JoinRoom.class, encodeJson()); 48 | addEncoder("leave room", LeaveRoom.class, encodeJson()); 49 | } 50 | }; 51 | 52 | controller = socketIO.createBuilder() 53 | .onConnect((request, sid) -> { 54 | Logger.info("New session created: " + sid); 55 | // Extract the username from the header 56 | val username = request.getQueryString("user"); 57 | if (username == null) { 58 | throw new RuntimeException("No user parameter"); 59 | } 60 | // And return the user, this will be the data for the session that we can read when we add a namespace 61 | return new User(username); 62 | 63 | }).addNamespace(codec, (session, chat) -> { 64 | if (chat.split("\\?")[0].equals("/chat")) { 65 | return Optional.of(createFlow(session.data())); 66 | } else { 67 | return Optional.empty(); 68 | } 69 | }) 70 | .createController(); 71 | } 72 | 73 | // This gets an existing chat room, or creates it if it doesn't exist 74 | private Flow getChatRoom(String room, User user) { 75 | 76 | // Create a sink that sends all the messages to the chat room 77 | val sink = Sink.foreach(message -> 78 | mediator.tell(new Publish(room, message), ActorRef.noSender()) 79 | ); 80 | 81 | // Create a source that subscribes to messages from the chatroom 82 | val source = Source.actorRef(16, OverflowStrategy.dropHead()) 83 | .mapMaterializedValue(ref -> { 84 | mediator.tell(new Subscribe(room, ref), ActorRef.noSender()); 85 | return NotUsed.getInstance(); 86 | }); 87 | 88 | // A coupled sink and source ensures if either side is cancelled/completed, the other will be too. 89 | return Flow.fromSinkAndSourceCoupled( 90 | Flow.create() 91 | // Add the join and leave room events 92 | .concat(Source.single(new LeaveRoom(user, room))) 93 | .prepend(Source.single(new JoinRoom(user, room))) 94 | .to(sink), 95 | source 96 | ); 97 | } 98 | 99 | private Flow createFlow(User user) { 100 | // broadcast source and sink for demux/muxing multiple chat rooms in this one flow 101 | // They'll be provided later when we materialize the flow 102 | Source[] broadcastSource = new Source[1]; 103 | Sink[] mergeSink = new Sink[1]; 104 | 105 | // Create a chat flow for a user session 106 | return Flow.create().map(event -> { 107 | if (event instanceof JoinRoom) { 108 | val room = event.getRoom(); 109 | val roomFlow = getChatRoom(room, user); 110 | 111 | // Add the room to our flow 112 | broadcastSource[0] 113 | // Ensure only messages for this room get there. 114 | // Also filter out JoinRoom messages, since there's a race condition as to whether it will 115 | // actually get here or not, so we explicitly add it below. 116 | .filter(e -> e.getRoom().equals(room) && !(e instanceof JoinRoom)) 117 | // Take until we get a leave room message. 118 | .takeWhile(e -> !(e instanceof LeaveRoom)) 119 | // We ensure that a join room message is sent first 120 | // And send it through the room flow 121 | .via(roomFlow) 122 | // Re-add the leave room here, since it was filtered out before 123 | .concat(Source.single(new LeaveRoom(user, room))) 124 | // And run it with the merge sink 125 | .runWith(mergeSink[0], materializer); 126 | return event; 127 | } else if (event instanceof ChatMessage) { 128 | // Add the user 129 | return new ChatMessage(user, event.getRoom(), ((ChatMessage) event).getMessage()); 130 | } else { 131 | return event; 132 | } 133 | }).via( 134 | Flow.fromSinkAndSourceCoupledMat(BroadcastHub.of(ChatEvent.class), MergeHub.of(ChatEvent.class), (source, sink) -> { 135 | broadcastSource[0] = source; 136 | mergeSink[0] = sink; 137 | return NotUsed.getInstance(); 138 | }) 139 | ); 140 | } 141 | 142 | public EngineIOController get() { 143 | return controller; 144 | } 145 | } -------------------------------------------------------------------------------- /src/main/scala/play/engineio/EngineIOAkkaSerializer.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.engineio 5 | 6 | import akka.actor.ExtendedActorSystem 7 | import akka.serialization.BaseSerializer 8 | import akka.serialization.SerializerWithStringManifest 9 | import akka.util.{ ByteString => AByteString } 10 | import com.google.protobuf.{ ByteString => PByteString } 11 | import play.engineio.EngineIOManagerActor._ 12 | import play.engineio.protocol._ 13 | import play.engineio.protobuf.{ engineio => p } 14 | 15 | /** 16 | * Serializer for all messages sent to/from the EngineIOManagerActor. 17 | */ 18 | class EngineIOAkkaSerializer(val system: ExtendedActorSystem) extends SerializerWithStringManifest with BaseSerializer { 19 | 20 | private val ConnectManifest = "A" 21 | private val PacketsManifest = "B" 22 | private val RetrieveManifest = "C" 23 | private val CloseManifest = "D" 24 | private val EngineIOEncodingExceptionManifest = "E" 25 | private val UnknownSessionIdManifest = "F" 26 | private val SessionClosedManifest = "G" 27 | 28 | override def manifest(obj: AnyRef) = obj match { 29 | case _: Connect => ConnectManifest 30 | case _: Packets => PacketsManifest 31 | case _: Retrieve => RetrieveManifest 32 | case _: Close => CloseManifest 33 | case _: EngineIOEncodingException => EngineIOEncodingExceptionManifest 34 | case _: UnknownSessionId => UnknownSessionIdManifest 35 | case SessionClosed => SessionClosedManifest 36 | case _ => 37 | throw new IllegalArgumentException(s"I don't know how to serialize object of type ${obj.getClass}") 38 | } 39 | 40 | override def toBinary(obj: AnyRef): Array[Byte] = { 41 | val protobufObject = obj match { 42 | 43 | case Connect(sid, transport, request, requestId) => 44 | p.Connect( 45 | sid, 46 | encodeTransport(transport), 47 | requestId, 48 | request.method, 49 | request.uri, 50 | request.version, 51 | request.headers.headers.map(header => p.HttpHeader(header._1, header._2)) 52 | ) 53 | 54 | case Packets(sid, transport, packets, requestId) => 55 | p.Packets(sid, encodeTransport(transport), packets.map(encodePacket), requestId) 56 | 57 | case Retrieve(sid, transport, requestId) => 58 | p.Retrieve(sid, encodeTransport(transport), requestId) 59 | 60 | case Close(sid, transport, requestId) => 61 | p.Close(sid, encodeTransport(transport), requestId) 62 | 63 | case EngineIOEncodingException(message) => 64 | p.EngineIOEncodingException(message) 65 | 66 | case UnknownSessionId(sid) => 67 | p.UnknownSessionId(sid) 68 | 69 | case SessionClosed => 70 | p.SessionClosed() 71 | 72 | case other => 73 | throw new RuntimeException("Don't know how to serialize " + other) 74 | } 75 | 76 | protobufObject.toByteArray 77 | } 78 | 79 | override def fromBinary(bytes: Array[Byte], manifest: String) = manifest match { 80 | case `ConnectManifest` => 81 | val connect = p.Connect.parseFrom(bytes) 82 | Connect( 83 | connect.sid, 84 | decodeTransport(connect.transport), 85 | new DeserializedRequestHeader( 86 | connect.method, 87 | connect.uri, 88 | connect.version, 89 | connect.headers.map(h => (h.name, h.value)) 90 | ), 91 | connect.requestId 92 | ) 93 | 94 | case `PacketsManifest` => 95 | val packets = p.Packets.parseFrom(bytes) 96 | Packets(packets.sid, decodeTransport(packets.transport), packets.packets.map(decodePacket), packets.requestId) 97 | 98 | case `RetrieveManifest` => 99 | val retrieve = p.Retrieve.parseFrom(bytes) 100 | Retrieve(retrieve.sid, decodeTransport(retrieve.transport), retrieve.requestId) 101 | 102 | case `CloseManifest` => 103 | val close = p.Close.parseFrom(bytes) 104 | Close(close.sid, decodeTransport(close.transport), close.requestId) 105 | 106 | case `EngineIOEncodingExceptionManifest` => 107 | val exception = p.EngineIOEncodingException.parseFrom(bytes) 108 | EngineIOEncodingException(exception.message) 109 | 110 | case `UnknownSessionIdManifest` => 111 | val exception = p.UnknownSessionId.parseFrom(bytes) 112 | UnknownSessionId(exception.sid) 113 | 114 | case `SessionClosedManifest` => 115 | SessionClosed 116 | 117 | case _ => 118 | throw new IllegalArgumentException(s"I don't know how to deserialize object with manifest [$manifest]") 119 | } 120 | 121 | private def encodeBytes(bytes: AByteString): PByteString = { 122 | // This does 2 buffer copies - not sure if there's a smarter zero buffer copy way to do it 123 | PByteString.copyFrom(bytes.toByteBuffer) 124 | } 125 | 126 | private def encodeTransport(transport: EngineIOTransport): p.Transport = transport match { 127 | case EngineIOTransport.Polling => p.Transport.POLLING 128 | case EngineIOTransport.WebSocket => p.Transport.WEBSOCKET 129 | } 130 | 131 | private def decodeTransport(transport: p.Transport): EngineIOTransport = transport match { 132 | case p.Transport.POLLING => EngineIOTransport.Polling 133 | case p.Transport.WEBSOCKET => EngineIOTransport.WebSocket 134 | case p.Transport.Unrecognized(value) => throw new IllegalArgumentException("Unrecognized transport: " + value) 135 | } 136 | 137 | private def decodeBytes(bytes: PByteString): AByteString = { 138 | AByteString.apply(bytes.asReadOnlyByteBuffer()) 139 | } 140 | 141 | private def decodePacket(packet: p.Packet): EngineIOPacket = { 142 | val packetType = EngineIOPacketType.fromBinary(packet.packetType.index.toByte) 143 | packet.payload match { 144 | case p.Packet.Payload.Text(text) => 145 | Utf8EngineIOPacket(packetType, text) 146 | case p.Packet.Payload.Binary(byteString) => 147 | BinaryEngineIOPacket(packetType, decodeBytes(byteString)) 148 | case p.Packet.Payload.Empty => 149 | throw new IllegalArgumentException("Empty payload") 150 | } 151 | } 152 | 153 | private def encodePacket(packet: EngineIOPacket): p.Packet = { 154 | p.Packet( 155 | p.PacketType.fromValue(packet.typeId.id), 156 | packet match { 157 | case Utf8EngineIOPacket(_, text) => p.Packet.Payload.Text(text) 158 | case BinaryEngineIOPacket(_, bytes) => p.Packet.Payload.Binary(encodeBytes(bytes)) 159 | } 160 | ) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/test/scala/play/socketio/scaladsl/SocketIOEventCodecSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio.scaladsl 5 | 6 | import akka.util.ByteString 7 | import org.scalatest.OptionValues 8 | import play.api.libs.json.JsString 9 | import play.api.libs.json.JsValue 10 | import play.socketio.SocketIOEvent 11 | import play.socketio.SocketIOEventAck 12 | import org.scalatest.matchers.should.Matchers 13 | import org.scalatest.wordspec.AnyWordSpec 14 | 15 | class SocketIOEventCodecSpec extends AnyWordSpec with Matchers with OptionValues { 16 | 17 | import play.socketio.scaladsl.SocketIOEventCodec._ 18 | 19 | private class CapturingAck extends SocketIOEventAck { 20 | var args: Option[Seq[Either[JsValue, ByteString]]] = None 21 | override def apply(args: Seq[Either[JsValue, ByteString]]) = this.args = Some(args) 22 | } 23 | 24 | def decodeStringArgs[T]( 25 | decoder: SocketIOEventDecoder[T], 26 | args: Seq[String], 27 | ack: Option[SocketIOEventAck] = None 28 | ): T = { 29 | decoder(SocketIOEvent.unnamed(stringsToArgs(args: _*), ack)) 30 | } 31 | 32 | def stringsToArgs(args: String*): Seq[Either[JsValue, ByteString]] = { 33 | args.map(s => Left(JsString(s))) 34 | } 35 | 36 | "The socket.io event codec DSL" should { 37 | 38 | "allow decoding single argument events" in { 39 | decodeStringArgs(decodeJson[String], Seq("one")) should ===("one") 40 | } 41 | 42 | "allow decoding single argument events with ack" in { 43 | val capture = new CapturingAck 44 | val (result, ack) = 45 | decodeStringArgs(decodeJson[String].withAckEncoder(encodeJson[String]), Seq("one"), Some(capture)) 46 | result should ===("one") 47 | ack("ack") 48 | capture.args.value should ===(stringsToArgs("ack")) 49 | } 50 | 51 | "allow decoding single argument events with multi argument acks" in { 52 | val capture = new CapturingAck 53 | val (result, ack) = decodeStringArgs( 54 | decodeJson[String].withAckEncoder(encodeJson[String] ~ encodeJson[String]), 55 | Seq("one"), 56 | Some(capture) 57 | ) 58 | result should ===("one") 59 | ack(("ack1", "ack2")) 60 | capture.args.value should ===(stringsToArgs("ack1", "ack2")) 61 | } 62 | 63 | "allow decoding two argument events" in { 64 | decodeStringArgs(decodeJson[String] ~ decodeJson[String], Seq("one", "two")) should ===(("one", "two")) 65 | } 66 | 67 | "allow decoding two argument events with ack" in { 68 | val capture = new CapturingAck 69 | val (arg1, arg2, ack) = decodeStringArgs( 70 | (decodeJson[String] ~ decodeJson[String]).withAckEncoder(encodeJson[String]), 71 | Seq("one", "two"), 72 | Some(capture) 73 | ) 74 | (arg1, arg2) should ===(("one", "two")) 75 | ack("ack") 76 | capture.args.value should ===(stringsToArgs("ack")) 77 | } 78 | 79 | "allow decoding three argument events" in { 80 | decodeStringArgs( 81 | decodeJson[String] ~ decodeJson[String] ~ decodeJson[String], 82 | Seq("one", "two", "three") 83 | ) should ===( 84 | ("one", "two", "three") 85 | ) 86 | } 87 | 88 | "allow decoding three argument events with ack" in { 89 | val capture = new CapturingAck 90 | val (arg1, arg2, arg3, ack) = decodeStringArgs( 91 | (decodeJson[String] ~ decodeJson[String] ~ decodeJson[String]).withAckEncoder(encodeJson[String]), 92 | Seq("one", "two", "three"), 93 | Some(capture) 94 | ) 95 | (arg1, arg2, arg3) should ===(("one", "two", "three")) 96 | ack("ack") 97 | capture.args.value should ===(stringsToArgs("ack")) 98 | } 99 | 100 | "allow decoding four argument events" in { 101 | decodeStringArgs( 102 | decodeJson[String] ~ decodeJson[String] ~ decodeJson[String] ~ decodeJson[String], 103 | Seq("one", "two", "three", "four") 104 | ) should ===(("one", "two", "three", "four")) 105 | } 106 | 107 | "allow encoding single argument events" in { 108 | encodeJson[String].apply("one").arguments should ===(stringsToArgs("one")) 109 | } 110 | 111 | "allow encoding single argument events with ack" in { 112 | var arg: Option[String] = None 113 | val event = encodeJson[String] 114 | .withAckDecoder(decodeJson[String]) 115 | .apply(("one", a => arg = Some(a))) 116 | 117 | event.arguments should ===(stringsToArgs("one")) 118 | event.ack.value(stringsToArgs("ack")) 119 | arg.value should ===("ack") 120 | } 121 | 122 | "allow encoding single argument events with multi argument acks" in { 123 | var arg: Option[(String, String)] = None 124 | val event = encodeJson[String] 125 | .withAckDecoder(decodeJson[String] ~ decodeJson[String]) 126 | .apply(("one", a => arg = Some(a))) 127 | 128 | event.arguments should ===(stringsToArgs("one")) 129 | event.ack.value(stringsToArgs("ack1", "ack2")) 130 | arg.value should ===(("ack1", "ack2")) 131 | } 132 | 133 | "allow encoding two argument events" in { 134 | (encodeJson[String] ~ encodeJson[String]) 135 | .apply(("one", "two")) 136 | .arguments should ===(stringsToArgs("one", "two")) 137 | } 138 | 139 | "allow encoding two argument events with ack" in { 140 | var arg: Option[String] = None 141 | val event = (encodeJson[String] ~ encodeJson[String]) 142 | .withAckDecoder(decodeJson[String]) 143 | .apply(("one", "two", a => arg = Some(a))) 144 | 145 | event.arguments should ===(stringsToArgs("one", "two")) 146 | event.ack.value(stringsToArgs("ack")) 147 | arg.value should ===("ack") 148 | } 149 | 150 | "allow encoding three argument events" in { 151 | (encodeJson[String] ~ encodeJson[String] ~ encodeJson[String]) 152 | .apply(("one", "two", "three")) 153 | .arguments should ===(stringsToArgs("one", "two", "three")) 154 | } 155 | 156 | "allow encoding three argument events with ack" in { 157 | var arg: Option[String] = None 158 | val event = (encodeJson[String] ~ encodeJson[String] ~ encodeJson[String]) 159 | .withAckDecoder(decodeJson[String]) 160 | .apply(("one", "two", "three", a => arg = Some(a))) 161 | 162 | event.arguments should ===(stringsToArgs("one", "two", "three")) 163 | event.ack.value(stringsToArgs("ack")) 164 | arg.value should ===("ack") 165 | } 166 | 167 | "allow encoding four argument events" in { 168 | (encodeJson[String] ~ encodeJson[String] ~ encodeJson[String] ~ encodeJson[String]) 169 | .apply(("one", "two", "three", "four")) 170 | .arguments should ===(stringsToArgs("one", "two", "three", "four")) 171 | } 172 | 173 | "allow combining decoders by name" in { 174 | val decoder = decodeByName { 175 | case "foo" => decodeJson[String].andThen(s => "foo: " + s) 176 | case "bar" => decodeJson[String].andThen(s => "bar: " + s) 177 | } 178 | 179 | decoder(SocketIOEvent("foo", stringsToArgs("arg"), None)) should ===("foo: arg") 180 | decoder(SocketIOEvent("bar", stringsToArgs("arg"), None)) should ===("bar: arg") 181 | } 182 | 183 | "allow combining encoders by type" in { 184 | val encoder = encodeByType[Any] { 185 | case _: String => "string" -> encodeJson[String] 186 | case _: Int => 187 | "int" -> encodeJson[String].compose { i: Int => i.toString } 188 | } 189 | 190 | val e1 = encoder("arg") 191 | e1.name should ===("string") 192 | e1.arguments should ===(stringsToArgs("arg")) 193 | val e2 = encoder(20) 194 | e2.name should ===("int") 195 | e2.arguments should ===(stringsToArgs("20")) 196 | } 197 | 198 | "allow mapping decoders" in { 199 | val decoder = (decodeJson[String] ~ decodeJson[String]).andThen { case (a, b) => s"$a:$b" } 200 | decodeStringArgs(decoder, Seq("one", "two")) should ===("one:two") 201 | } 202 | 203 | "allow composing encoders" in { 204 | case class Args(a: String, b: String) 205 | val encoder = (encodeJson[String] ~ encodeJson[String]).compose[Args] { case Args(a, b) => (a, b) } 206 | // This assignment is purely to ensure that the Scala compiler correctly inferred the type of encoder 207 | val checkTypeInference: SocketIOEventEncoder[Args] = encoder 208 | checkTypeInference(Args("one", "two")).arguments should ===(stringsToArgs("one", "two")) 209 | } 210 | } 211 | 212 | } 213 | -------------------------------------------------------------------------------- /src/main/scala/play/socketio/scaladsl/SocketIO.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio.scaladsl 5 | 6 | import javax.inject.Inject 7 | 8 | import akka.NotUsed 9 | import akka.stream.Materializer 10 | import akka.stream.scaladsl.Flow 11 | import akka.stream.scaladsl.Sink 12 | import akka.stream.scaladsl.Source 13 | import play.api.libs.json.JsString 14 | import play.api.libs.json.JsValue 15 | import play.api.mvc.RequestHeader 16 | import play.api.Logger 17 | import play.engineio._ 18 | import SocketIOEventCodec.SocketIOEventsDecoder 19 | import SocketIOEventCodec.SocketIOEventsEncoder 20 | import play.core.parsers.FormUrlEncodedParser 21 | import play.socketio._ 22 | 23 | import scala.concurrent.ExecutionContext 24 | import scala.concurrent.Future 25 | 26 | /** 27 | * The engine.io system. Allows you to create engine.io controllers for handling engine.io connections. 28 | */ 29 | final class SocketIO @Inject() (config: SocketIOConfig, engineIO: EngineIO)(implicit 30 | ec: ExecutionContext, 31 | mat: Materializer 32 | ) { 33 | 34 | private val log = Logger(classOf[SocketIO]) 35 | 36 | /** 37 | * Create a builder. 38 | * 39 | * The builder will by default: 40 | * - Accept all sessions 41 | * - Send the message of each exception the client as a JSON string 42 | * - Use a flow that ignores all incoming messages and produces no outgoing messages for the default namespace 43 | * - Provide no other namespaces 44 | */ 45 | def builder: SocketIOBuilder[Any] = { 46 | new SocketIOBuilder[Any]( 47 | (_, _) => Future.successful(NotUsed), 48 | { 49 | case e if e.getMessage != null => JsString(e.getMessage) 50 | case e => JsString(e.getClass.getName) 51 | }, 52 | _ => Flow.fromSinkAndSource(Sink.ignore, Source.maybe), 53 | PartialFunction.empty 54 | ) 55 | } 56 | 57 | /** 58 | * A builder for engine.io instances. 59 | */ 60 | class SocketIOBuilder[SessionData] private[socketio] ( 61 | connectCallback: (RequestHeader, String) => Future[SessionData], 62 | errorHandler: PartialFunction[Throwable, JsValue], 63 | defaultNamespaceCallback: SocketIOSession[SessionData] => Flow[SocketIOEvent, SocketIOEvent, _], 64 | connectToNamespaceCallback: PartialFunction[ 65 | (SocketIOSession[SessionData], String), 66 | Flow[SocketIOEvent, SocketIOEvent, _] 67 | ] 68 | ) { 69 | 70 | /** 71 | * Set the onConnect callback. 72 | * 73 | * The callback takes the request header of the incoming connection and the id of the session, and should produce a 74 | * session object, which can be anything, for example, a user principal, or other authentication and/or 75 | * authorization details. 76 | * 77 | * If you wish to reject the connection, you can throw an exception, which will later be handled by the error 78 | * handler to turn it into a message to send to the client. 79 | */ 80 | def onConnect[S <: SessionData](callback: (RequestHeader, String) => S): SocketIOBuilder[S] = { 81 | onConnectAsync((rh, sid) => Future.successful(callback(rh, sid))) 82 | } 83 | 84 | /** 85 | * Set the onConnect callback. 86 | * 87 | * The callback takes the request header of the incoming connection and the id of the ssion, and should produce a 88 | * session object, which can be anything, for example, a user principal, or other authentication and/or 89 | * authorization details. 90 | * 91 | * If you wish to reject the connection, you can throw an exception, which will later be handled by the error 92 | * handler to turn it into a message to send to the client. 93 | */ 94 | def onConnectAsync[S <: SessionData](callback: (RequestHeader, String) => Future[S]): SocketIOBuilder[S] = { 95 | new SocketIOBuilder[S]( 96 | callback, 97 | errorHandler, 98 | defaultNamespaceCallback, 99 | connectToNamespaceCallback 100 | ) 101 | } 102 | 103 | /** 104 | * Set the error handler. 105 | * 106 | * If any errors are encountered, they will be serialized to JSON this function, and then passed to the client 107 | * using a socket.io error message. 108 | * 109 | * Any errors not handled by this partial function will fallback to the existing error handler in this builder, 110 | * which by default sends the exception message as a JSON string. 111 | */ 112 | def withErrorHandler(handler: PartialFunction[Throwable, JsValue]): SocketIOBuilder[SessionData] = { 113 | new SocketIOBuilder( 114 | connectCallback, 115 | handler.orElse(errorHandler), 116 | defaultNamespaceCallback, 117 | connectToNamespaceCallback 118 | ) 119 | } 120 | 121 | /** 122 | * Set the default namespace flow. 123 | * 124 | * @param decoder the decoder to use. 125 | * @param encoder the encoder to use. 126 | * @param flow the flow. 127 | */ 128 | def defaultNamespace[In, Out]( 129 | decoder: SocketIOEventsDecoder[In], 130 | encoder: SocketIOEventsEncoder[Out], 131 | flow: Flow[In, Out, _] 132 | ): SocketIOBuilder[SessionData] = { 133 | defaultNamespace(decoder, encoder)(_ => flow) 134 | } 135 | 136 | /** 137 | * Set the default namespace flow. 138 | * 139 | * This variant allows you to customise the returned flow according to the session. 140 | * 141 | * @param decoder the decoder to use. 142 | * @param encoder the encoder to use. 143 | * @param callback a callback to create the flow given the session. 144 | */ 145 | def defaultNamespace[In, Out](decoder: SocketIOEventsDecoder[In], encoder: SocketIOEventsEncoder[Out])( 146 | callback: SocketIOSession[SessionData] => Flow[In, Out, _] 147 | ): SocketIOBuilder[SessionData] = { 148 | new SocketIOBuilder( 149 | connectCallback, 150 | errorHandler, 151 | session => createNamespace(decoder, encoder, callback(session)), 152 | connectToNamespaceCallback 153 | ) 154 | } 155 | 156 | /** 157 | * Add a namespace. 158 | * 159 | * @param name The name of the namespace. 160 | * @param decoder The decoder to use to decode messages. 161 | * @param encoder The encoder to use to encode messages. 162 | * @param flow The flow to use for the namespace. 163 | */ 164 | def addNamespace[In, Out]( 165 | name: String, 166 | decoder: SocketIOEventsDecoder[In], 167 | encoder: SocketIOEventsEncoder[Out], 168 | flow: Flow[In, Out, _] 169 | ): SocketIOBuilder[SessionData] = { 170 | addNamespace(decoder, encoder) { case (_, NamespaceWithQuery(`name`, _)) => 171 | flow 172 | } 173 | } 174 | 175 | /** 176 | * Add a namespace. 177 | * 178 | * This variant allows you to pass a callback that pattern matches on the namespace name, and uses the session 179 | * data to decide whether the user should be able to connect to this namespace or not. 180 | * 181 | * Any exceptions thrown here will result in an error being sent back to the client, serialized by the 182 | * errorHandler. Alternatively, you can simply not return a value from the partial function, which will result in 183 | * an error being sent to the client that the namespace does not exist. 184 | * 185 | * @param decoder The decoder to use to decode messages. 186 | * @param encoder The encoder to use to encode messages. 187 | * @param callback A callback to match the namespace and create a flow accordingly. 188 | */ 189 | def addNamespace[In, Out](decoder: SocketIOEventsDecoder[In], encoder: SocketIOEventsEncoder[Out])( 190 | callback: PartialFunction[(SocketIOSession[SessionData], String), Flow[In, Out, _]] 191 | ): SocketIOBuilder[SessionData] = { 192 | 193 | new SocketIOBuilder( 194 | connectCallback, 195 | errorHandler, 196 | defaultNamespaceCallback, 197 | connectToNamespaceCallback.orElse(callback.andThen { flow => createNamespace(decoder, encoder, flow) }) 198 | ) 199 | } 200 | 201 | /** 202 | * Build the engine.io controller. 203 | */ 204 | def createController(): EngineIOController = { 205 | val handler = SocketIOSessionFlow.createEngineIOSessionHandler( 206 | config, 207 | connectCallback, 208 | errorHandler, 209 | defaultNamespaceCallback, 210 | connectToNamespaceCallback 211 | ) 212 | 213 | engineIO.createController(handler) 214 | } 215 | 216 | private def createNamespace[In, Out]( 217 | decoder: SocketIOEventsDecoder[In], 218 | encoder: SocketIOEventsEncoder[Out], 219 | flow: Flow[In, Out, _] 220 | ): Flow[SocketIOEvent, SocketIOEvent, _] = { 221 | Flow[SocketIOEvent].map(decoder).via(flow).map(encoder) 222 | } 223 | } 224 | } 225 | 226 | /** 227 | * Extractor for matching namespaces that have queries. 228 | * 229 | * Can be used with a namespace partial function, for example: 230 | * 231 | * {{{ 232 | * addNamespace(decoder, encoder) { 233 | * case (session, NamespaceWithQuery("/chat", query)) => 234 | * ... 235 | * } 236 | * }}} 237 | */ 238 | object NamespaceWithQuery { 239 | def unapply(namespace: String): Option[(String, Map[String, Seq[String]])] = { 240 | val parts = namespace.split("\\?", 2) 241 | parts match { 242 | case Array(path) => 243 | Some((path, Map.empty)) 244 | case Array(path, query) => 245 | Some((path, FormUrlEncodedParser.parse(query))) 246 | case _ => None 247 | } 248 | } 249 | } 250 | 251 | /** 252 | * Provides socket.io components 253 | * 254 | * Mix this trait into your application cake to get an instance of [[SocketIO]] to build your socket.io engine with. 255 | */ 256 | trait SocketIOComponents extends EngineIOComponents { 257 | lazy val socketIOConfig: SocketIOConfig = SocketIOConfig.fromConfiguration(configuration) 258 | lazy val socketIO: SocketIO = new SocketIO(socketIOConfig, engineIO)(executionContext, materializer) 259 | } 260 | -------------------------------------------------------------------------------- /src/main/java/play/socketio/javadsl/SocketIO.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio.javadsl; 5 | 6 | import akka.NotUsed; 7 | import akka.stream.Materializer; 8 | import akka.stream.javadsl.BidiFlow; 9 | import akka.stream.javadsl.Flow; 10 | import akka.stream.javadsl.Sink; 11 | import akka.stream.scaladsl.Source; 12 | import com.fasterxml.jackson.databind.JsonNode; 13 | import com.fasterxml.jackson.databind.node.TextNode; 14 | import play.engineio.EngineIO; 15 | import play.engineio.EngineIOController; 16 | import play.engineio.EngineIOSessionHandler; 17 | import play.mvc.Http; 18 | import play.socketio.SocketIOConfig; 19 | import play.socketio.SocketIOEvent; 20 | import play.socketio.SocketIOSession; 21 | import scala.concurrent.ExecutionContext; 22 | 23 | import javax.inject.Inject; 24 | import java.util.Optional; 25 | import java.util.concurrent.CompletableFuture; 26 | import java.util.concurrent.CompletionStage; 27 | import java.util.function.BiFunction; 28 | import java.util.function.Function; 29 | 30 | /** 31 | * The engine.io system. Allows you to create engine.io controllers for handling engine.io connections. 32 | */ 33 | public final class SocketIO { 34 | 35 | private final SocketIOConfig config; 36 | private final EngineIO engineIO; 37 | private final ExecutionContext ec; 38 | private final Materializer mat; 39 | 40 | @Inject 41 | public SocketIO(SocketIOConfig config, EngineIO engineIO, ExecutionContext ec, Materializer mat) { 42 | this.config = config; 43 | this.engineIO = engineIO; 44 | this.ec = ec; 45 | this.mat = mat; 46 | } 47 | 48 | /** 49 | * Create a builder. 50 | * 51 | * If no further configuration is done, the socket.io handler produced by this build will by default: 52 | * 53 | *
                54 | *
              • Accept all sessions
              • 55 | *
              • Send the message of each exception the client as a JSON string
              • 56 | *
              • Use a flow that ignores all incoming messages and produces no outgoing messages for the default namespace
              • 57 | *
              • Provide no other namespaces
              • 58 | *
              59 | */ 60 | public SocketIOBuilder createBuilder() { 61 | return new SocketIOBuilder<>( 62 | (req, sid) -> CompletableFuture.completedFuture(NotUsed.getInstance()), 63 | t -> { 64 | if (t.getMessage() != null) { 65 | return Optional.of(TextNode.valueOf(t.getMessage())); 66 | } else { 67 | return Optional.of(TextNode.valueOf(t.getClass().getName())); 68 | } 69 | }, 70 | session -> Flow.fromSinkAndSource(Sink.ignore(), Source.maybe()), 71 | (session, namespace) -> Optional.empty() 72 | ); 73 | } 74 | 75 | /** 76 | * A builder for engine.io instances. 77 | */ 78 | public class SocketIOBuilder { 79 | 80 | private final BiFunction> connectCallback; 81 | private final Function> errorHandler; 82 | private final Function, Flow> defaultNamespaceCallback; 83 | private final BiFunction, String, Optional>> connectToNamespaceCallback; 84 | 85 | private SocketIOBuilder(BiFunction> connectCallback, 86 | Function> errorHandler, 87 | Function, Flow> defaultNamespaceCallback, 88 | BiFunction, String, Optional>> connectToNamespaceCallback) { 89 | this.connectCallback = connectCallback; 90 | this.errorHandler = errorHandler; 91 | this.defaultNamespaceCallback = defaultNamespaceCallback; 92 | this.connectToNamespaceCallback = connectToNamespaceCallback; 93 | } 94 | 95 | /** 96 | * Set the onConnect callback. 97 | *

              98 | * The callback takes the request header of the incoming connection and the id of the session, and should produce a 99 | * session object, which can be anything, for example, a user principal, or other authentication and/or 100 | * authorization details. 101 | *

              102 | * If you wish to reject the connection, you can throw an exception, which will later be handled by the error 103 | * handler to turn it into a message to send to the client. 104 | */ 105 | public SocketIOBuilder onConnect( 106 | BiFunction callback 107 | ) { 108 | return onConnectAsync((rh, sid) -> CompletableFuture.completedFuture(callback.apply(rh, sid))); 109 | } 110 | 111 | /** 112 | * Set the onConnect callback. 113 | *

              114 | * The callback takes the request header of the incoming connection and the id of the session, and should produce a 115 | * session object, which can be anything, for example, a user principal, or other authentication and/or 116 | * authorization details. 117 | *

              118 | * If you wish to reject the connection, you can throw an exception, which will later be handled by the error 119 | * handler to turn it into a message to send to the client. 120 | */ 121 | @SuppressWarnings("unchecked") 122 | public SocketIOBuilder onConnectAsync( 123 | BiFunction> callback 124 | ) { 125 | return new SocketIOBuilder( 126 | callback, 127 | errorHandler, 128 | (Function) defaultNamespaceCallback, 129 | (BiFunction) connectToNamespaceCallback 130 | ); 131 | } 132 | 133 | /** 134 | * Set the error handler. 135 | *

              136 | * If any errors are encountered, they will be serialized to JSON this function, and then passed to the client 137 | * using a socket.io error message. 138 | *

              139 | * Any errors not handled by this partial function will fallback to the existing error handler in this builder, 140 | * which by default sends the exception message as a JSON string. 141 | */ 142 | public SocketIOBuilder withErrorHandler(Function> handler) { 143 | return new SocketIOBuilder<>( 144 | connectCallback, 145 | (Throwable t) -> handler.apply(t) 146 | .map(Optional::of) 147 | .orElseGet(() -> errorHandler.apply(t)), 148 | defaultNamespaceCallback, 149 | connectToNamespaceCallback 150 | ); 151 | } 152 | 153 | /** 154 | * Set the default namespace flow. 155 | * 156 | * @param codec the codec to use. 157 | * @param flow the flow. 158 | */ 159 | public SocketIOBuilder defaultNamespace(SocketIOEventCodec codec, 160 | Flow flow) { 161 | return defaultNamespace(codec, session -> flow); 162 | } 163 | 164 | /** 165 | * Set the default namespace flow. 166 | *

              167 | * This variant allows you to customise the returned flow according to the session. 168 | * 169 | * @param codec the codec to use. 170 | * @param callback a callback to create the flow given the session. 171 | */ 172 | public SocketIOBuilder defaultNamespace(SocketIOEventCodec codec, 173 | Function, Flow> callback) { 174 | BidiFlow codecFlow = codec.createFlow(); 175 | 176 | return new SocketIOBuilder<>( 177 | connectCallback, 178 | errorHandler, 179 | callback.andThen(codecFlow::join), 180 | connectToNamespaceCallback 181 | ); 182 | } 183 | 184 | /** 185 | * Add a namespace. 186 | * 187 | * @param name The name of the namespace. 188 | * @param codec the codec to use. 189 | * @param flow The flow to use for the namespace. 190 | */ 191 | public SocketIOBuilder addNamespace(String name, 192 | SocketIOEventCodec codec, Flow flow) { 193 | return addNamespace(codec, (session, namespace) -> { 194 | // Drop the query string 195 | String[] parts = namespace.split("\\?", 2); 196 | if (parts[0].equals(name)) { 197 | return Optional.of(flow); 198 | } else { 199 | return Optional.empty(); 200 | } 201 | }); 202 | } 203 | 204 | /** 205 | * Add a namespace. 206 | *

              207 | * This variant allows you to pass a callback that pattern matches on the namespace name, and uses the session 208 | * data to decide whether the user should be able to connect to this namespace or not. 209 | *

              210 | * Any exceptions thrown here will result in an error being sent back to the client, serialized by the 211 | * errorHandler. Alternatively, you can simply not return a value from the partial function, which will result in 212 | * an error being sent to the client that the namespace does not exist. 213 | * 214 | * @param codec the codec to use. 215 | * @param callback A callback to match the namespace and create a flow accordingly. 216 | */ 217 | public SocketIOBuilder addNamespace(SocketIOEventCodec codec, 218 | BiFunction, String, Optional>> callback) { 219 | 220 | BidiFlow codecFlow = codec.createFlow(); 221 | 222 | return new SocketIOBuilder<>( 223 | connectCallback, 224 | errorHandler, 225 | defaultNamespaceCallback, 226 | (session, sid) -> connectToNamespaceCallback.apply(session, sid) 227 | .map(Optional::of) 228 | .orElseGet(() -> 229 | callback.apply(session, sid) 230 | .map(codecFlow::join) 231 | ) 232 | ); 233 | } 234 | 235 | /** 236 | * Build the engine.io controller. 237 | */ 238 | public EngineIOController createController() { 239 | EngineIOSessionHandler handler = SocketIOSessionFlowHelper.createEngineIOSessionHandler( 240 | config, 241 | connectCallback, 242 | errorHandler, 243 | defaultNamespaceCallback, 244 | connectToNamespaceCallback, 245 | ec, 246 | mat 247 | ); 248 | 249 | return engineIO.createController(handler); 250 | } 251 | 252 | } 253 | 254 | 255 | } 256 | -------------------------------------------------------------------------------- /src/main/scala/play/engineio/EngineIO.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.engineio 5 | 6 | import java.util.UUID 7 | import javax.inject.Inject 8 | import javax.inject.Provider 9 | import javax.inject.Singleton 10 | 11 | import akka.NotUsed 12 | import akka.pattern.ask 13 | import akka.actor.ActorRef 14 | import akka.actor.ActorSystem 15 | import akka.routing.FromConfig 16 | import akka.stream._ 17 | import akka.stream.scaladsl.Flow 18 | import akka.stream.scaladsl.Sink 19 | import akka.stream.scaladsl.Source 20 | import akka.util.Timeout 21 | import play.api.Configuration 22 | import play.api.Environment 23 | import play.api.Logger 24 | import play.api.http.HttpErrorHandler 25 | import play.api.inject.Module 26 | import play.api.mvc._ 27 | import play.engineio.EngineIOManagerActor._ 28 | import play.engineio.protocol._ 29 | 30 | import scala.concurrent.ExecutionContext 31 | import scala.concurrent.Future 32 | import scala.concurrent.duration._ 33 | import scala.util.Failure 34 | import scala.util.Success 35 | 36 | case class EngineIOConfig( 37 | pingInterval: FiniteDuration = 25.seconds, 38 | pingTimeout: FiniteDuration = 60.seconds, 39 | transports: Seq[EngineIOTransport] = Seq(EngineIOTransport.WebSocket, EngineIOTransport.Polling), 40 | actorName: String = "engine.io", 41 | routerName: Option[String] = None, 42 | useRole: Option[String] = None 43 | ) 44 | 45 | object EngineIOConfig { 46 | def fromConfiguration(configuration: Configuration) = { 47 | val config = configuration.get[Configuration]("play.engine-io") 48 | EngineIOConfig( 49 | pingInterval = config.get[FiniteDuration]("ping-interval"), 50 | pingTimeout = config.get[FiniteDuration]("ping-timeout"), 51 | transports = config.get[Seq[String]]("transports").map(EngineIOTransport.fromName), 52 | actorName = config.get[String]("actor-name"), 53 | routerName = config.get[Option[String]]("router-name"), 54 | useRole = config.get[Option[String]]("use-role") 55 | ) 56 | } 57 | } 58 | 59 | @Singleton 60 | class EngineIOConfigProvider @Inject() (configuration: Configuration) extends Provider[EngineIOConfig] { 61 | override lazy val get: EngineIOConfig = EngineIOConfig.fromConfiguration(configuration) 62 | } 63 | 64 | /** 65 | * An engine.io controller. 66 | * 67 | * This provides one handler, the [[endpoint()]] method. This should be routed to for all `GET` and `POST` requests for 68 | * anything on the path for engine.io (for socket.io, this defaults to `/socket.io/` unless configured otherwise on 69 | * the client. 70 | * 71 | * The `transport` parameter should be extracted from the `transport` query parameter with the request. 72 | * 73 | * For example: 74 | * 75 | * {{{ 76 | * GET /socket.io/ play.engineio.EngineIOController.endpoint(transport) 77 | * POST /socket.io/ play.engineio.EngineIOController.endpoint(transport) 78 | * }}} 79 | */ 80 | final class EngineIOController( 81 | config: EngineIOConfig, 82 | httpErrorHandler: HttpErrorHandler, 83 | controllerComponents: ControllerComponents, 84 | actorSystem: ActorSystem, 85 | engineIOManager: ActorRef 86 | )(implicit ec: ExecutionContext) 87 | extends AbstractController(controllerComponents) { 88 | 89 | private val log = Logger(classOf[EngineIOController]) 90 | private implicit val timeout = Timeout(config.pingTimeout) 91 | 92 | /** 93 | * The endpoint to route to from a router. 94 | * 95 | * @param transport The transport to use. 96 | */ 97 | def endpoint(transport: String): Handler = { 98 | EngineIOTransport.fromName(transport) match { 99 | case EngineIOTransport.Polling => pollingEndpoint 100 | case EngineIOTransport.WebSocket => webSocketEndpoint 101 | } 102 | } 103 | 104 | private def pollingEndpoint = Action.async(EngineIOPayload.parser(parse)) { implicit request => 105 | val maybeSid = request.getQueryString("sid") 106 | val requestId = request.getQueryString("t").getOrElse(request.id.toString) 107 | val transport = EngineIOTransport.Polling 108 | 109 | (maybeSid, request.body) match { 110 | // sid and payload, we're posting packets 111 | case (Some(sid), Some(payload)) => 112 | log.debug(s"Received push request for $sid") 113 | 114 | (engineIOManager ? Packets(sid, transport, payload.packets, requestId)).map { _ => Ok("ok") } 115 | 116 | // sid no payload, we're retrieving packets 117 | case (Some(sid), None) => 118 | log.debug(s"Received poll request for $sid") 119 | 120 | (engineIOManager ? Retrieve(sid, transport, requestId)).map { 121 | case Close(_, _, _) => Ok(EngineIOPacket(EngineIOPacketType.Close)) 122 | case Packets(_, _, Nil, _) => Ok(EngineIOPacket(EngineIOPacketType.Noop)) 123 | case Packets(_, _, packets, _) => Ok(EngineIOPayload(packets)) 124 | } 125 | 126 | // No sid, we're creating a new session 127 | case (None, _) => 128 | val sid = UUID.randomUUID().toString 129 | 130 | log.debug(s"Received new connection for $sid") 131 | 132 | (engineIOManager ? Connect(sid, transport, request, requestId)).mapTo[Packets].map { packets => 133 | Ok(EngineIOPayload(packets.packets)) 134 | } 135 | 136 | } 137 | } 138 | 139 | private def webSocketEndpoint = WebSocket.acceptOrResult { request => 140 | val maybeSid = request.getQueryString("sid") 141 | val requestId = request.getQueryString("t").getOrElse(request.id.toString) 142 | val transport = EngineIOTransport.WebSocket 143 | 144 | maybeSid match { 145 | 146 | case None => 147 | // No sid, first we have to create a session, then we can start the flow, sending the open packet 148 | // as the first message. 149 | val sid = UUID.randomUUID().toString 150 | (engineIOManager ? Connect(sid, transport, request, requestId)).mapTo[Packets].map { packets => 151 | if (packets.packets.headOption.exists(_.typeId == EngineIOPacketType.Open)) { 152 | Right(webSocketFlow(sid, requestId).prepend(Source.fromIterator(() => packets.packets.iterator))) 153 | } else { 154 | Right(Flow.fromSinkAndSource(Sink.ignore, Source.fromIterator(() => packets.packets.iterator))) 155 | } 156 | } 157 | 158 | case Some(sid) => 159 | Future.successful(Right(webSocketFlow(sid, requestId))) 160 | } 161 | } 162 | 163 | private def webSocketFlow(sid: String, requestId: String): Flow[EngineIOPacket, EngineIOPacket, _] = { 164 | val transport = EngineIOTransport.WebSocket 165 | 166 | log.debug(s"Received WebSocket request for $sid") 167 | 168 | val in = Flow[EngineIOPacket] 169 | .batch(4, Vector(_))(_ :+ _) 170 | .mapAsync(1) { packets => engineIOManager ? Packets(sid, transport, packets, requestId) } 171 | .to(Sink.ignore.mapMaterializedValue(_.onComplete { 172 | case Success(s) => 173 | engineIOManager ! Close(sid, transport, requestId) 174 | case Failure(t) => 175 | log.warn("Error on incoming WebSocket", t) 176 | })) 177 | 178 | val out = Source 179 | .repeat(NotUsed) 180 | .mapAsync(1) { _ => 181 | val asked = engineIOManager ? Retrieve(sid, transport, requestId) 182 | asked.onComplete { 183 | case Success(s) => 184 | case Failure(t) => 185 | log.warn("Error on outgoing WebSocket", t) 186 | } 187 | asked 188 | } 189 | .takeWhile(!_.isInstanceOf[Close]) 190 | .mapConcat { case Packets(_, _, packets: Seq[EngineIOPacket], _) => 191 | collection.immutable.Seq[EngineIOPacket](packets: _*) 192 | } 193 | 194 | Flow.fromSinkAndSourceCoupled(in, out) 195 | } 196 | 197 | } 198 | 199 | /** 200 | * The engine.io system. Allows you to create engine.io controllers for handling engine.io connections. 201 | */ 202 | @Singleton 203 | final class EngineIO @Inject() ( 204 | config: EngineIOConfig, 205 | httpErrorHandler: HttpErrorHandler, 206 | controllerComponents: ControllerComponents, 207 | actorSystem: ActorSystem 208 | )(implicit ec: ExecutionContext, mat: Materializer) { 209 | 210 | private val log = Logger(classOf[EngineIO]) 211 | 212 | /** 213 | * Build the engine.io controller. 214 | */ 215 | def createController(handler: EngineIOSessionHandler): EngineIOController = { 216 | def startManager(): ActorRef = { 217 | val sessionProps = EngineIOSessionActor.props(config, handler) 218 | val managerProps = EngineIOManagerActor.props(config, sessionProps) 219 | actorSystem.actorOf(managerProps, config.actorName) 220 | } 221 | 222 | val actorRef = config.routerName match { 223 | case Some(routerName) => 224 | // Start the manager, if we're configured to do so 225 | config.useRole match { 226 | case Some(role) => 227 | Configuration(actorSystem.settings.config).getOptional[Seq[String]]("akka.cluster.roles") match { 228 | case None => 229 | throw new IllegalArgumentException("akka.cluster.roles is not set, are you using Akka clustering?") 230 | case Some(roles) if roles.contains(role) => 231 | startManager() 232 | case _ => 233 | log.debug( 234 | "Not starting EngineIOManagerActor because we don't have the " + role + " configured on this node" 235 | ) 236 | } 237 | case None => 238 | startManager() 239 | } 240 | 241 | actorSystem.actorOf(FromConfig.props(), routerName) 242 | 243 | case None => 244 | startManager() 245 | } 246 | 247 | new EngineIOController(config, httpErrorHandler, controllerComponents, actorSystem, actorRef) 248 | } 249 | } 250 | 251 | /** 252 | * Provides engine.io components 253 | * 254 | * Mix this trait into your application cake to get an instance of [[EngineIO]] to build your engine.io engine with. 255 | */ 256 | trait EngineIOComponents { 257 | def httpErrorHandler: HttpErrorHandler 258 | def controllerComponents: ControllerComponents 259 | def actorSystem: ActorSystem 260 | def executionContext: ExecutionContext 261 | def materializer: Materializer 262 | def configuration: Configuration 263 | 264 | lazy val engineIOConfig: EngineIOConfig = EngineIOConfig.fromConfiguration(configuration) 265 | lazy val engineIO: EngineIO = 266 | new EngineIO(engineIOConfig, httpErrorHandler, controllerComponents, actorSystem)(executionContext, materializer) 267 | } 268 | 269 | /** 270 | * The engine.io module. 271 | * 272 | * Provides engine.io components to Play's runtime dependency injection implementation. 273 | */ 274 | class EngineIOModule extends Module { 275 | override def bindings(environment: Environment, configuration: Configuration) = Seq( 276 | bind[EngineIOConfig].toProvider[EngineIOConfigProvider], 277 | bind[EngineIO].toSelf 278 | ) 279 | } 280 | -------------------------------------------------------------------------------- /src/main/scala/play/socketio/protocol/SocketIOProtocol.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | package play.socketio.protocol 5 | 6 | import akka.util.ByteString 7 | import play.api.libs.json._ 8 | import play.engineio.protocol._ 9 | import play.engineio.BinaryEngineIOMessage 10 | import play.engineio.EngineIOMessage 11 | import play.engineio.TextEngineIOMessage 12 | 13 | import scala.collection.immutable 14 | 15 | /** 16 | * A socket.io packet type. 17 | */ 18 | sealed abstract class SocketIOPacketType private (val id: Int) { 19 | // Encode the packet as a char. 20 | val asChar = id.toString.head 21 | } 22 | 23 | object SocketIOPacketType { 24 | case object Connect extends SocketIOPacketType(0) 25 | case object Disconnect extends SocketIOPacketType(1) 26 | case object Event extends SocketIOPacketType(2) 27 | case object Ack extends SocketIOPacketType(3) 28 | case object Error extends SocketIOPacketType(4) 29 | case object BinaryEvent extends SocketIOPacketType(5) 30 | case object BinaryAck extends SocketIOPacketType(6) 31 | 32 | def fromChar(char: Char) = char match { 33 | case '0' => Connect 34 | case '1' => Disconnect 35 | case '2' => Event 36 | case '3' => Ack 37 | case '4' => Error 38 | case '5' => BinaryEvent 39 | case '6' => BinaryAck 40 | case _ => throw SocketIOEncodingException("", "Unknown socket.io packet type: " + char) 41 | } 42 | } 43 | 44 | /** 45 | * A socket.io packet. 46 | */ 47 | sealed trait SocketIOPacket { 48 | def packetType: SocketIOPacketType 49 | val namespace: Option[String] 50 | } 51 | 52 | case class SocketIOConnectPacket(namespace: Option[String]) extends SocketIOPacket { 53 | override def packetType = SocketIOPacketType.Connect 54 | } 55 | 56 | case class SocketIODisconnectPacket(namespace: Option[String]) extends SocketIOPacket { 57 | override def packetType = SocketIOPacketType.Disconnect 58 | } 59 | 60 | case class SocketIOEventPacket(namespace: Option[String], data: Seq[JsValue], id: Option[Long]) extends SocketIOPacket { 61 | override def packetType = SocketIOPacketType.Event 62 | } 63 | 64 | case class SocketIOAckPacket(namespace: Option[String], data: Seq[JsValue], id: Long) extends SocketIOPacket { 65 | override def packetType = SocketIOPacketType.Ack 66 | } 67 | 68 | case class SocketIOErrorPacket(namespace: Option[String], data: JsValue) extends SocketIOPacket { 69 | override def packetType = SocketIOPacketType.Error 70 | } 71 | 72 | object SocketIOErrorPacket { 73 | def encode(namespace: Option[String], data: JsValue): Utf8EngineIOPacket = { 74 | val message = new StringBuilder 75 | message += SocketIOPacketType.Error.asChar 76 | namespace.foreach { ns => 77 | message ++= ns 78 | message += ',' 79 | } 80 | message ++= Json.stringify(data) 81 | Utf8EngineIOPacket(EngineIOPacketType.Message, message.toString) 82 | } 83 | } 84 | 85 | case class SocketIOBinaryEventPacket( 86 | namespace: Option[String], 87 | data: Seq[Either[JsValue, ByteString]], 88 | id: Option[Long] 89 | ) extends SocketIOPacket { 90 | override def packetType = SocketIOPacketType.BinaryEvent 91 | } 92 | 93 | case class SocketIOBinaryAckPacket(namespace: Option[String], data: Seq[Either[JsValue, ByteString]], id: Long) 94 | extends SocketIOPacket { 95 | override def packetType = SocketIOPacketType.BinaryAck 96 | } 97 | 98 | object SocketIOPacket { 99 | 100 | /** 101 | * When encoding a binary packet, each binary data element will result in an additional packet being sent. 102 | */ 103 | def encode(packet: SocketIOPacket): List[EngineIOMessage] = { 104 | 105 | // First, write packet type. Binary packets get handled specially. 106 | val message = new StringBuilder 107 | packet match { 108 | case SocketIOBinaryEventPacket(_, data, _) => 109 | val placeholders = data.count(_.isRight) 110 | if (placeholders == 0) { 111 | message += SocketIOPacketType.Event.asChar 112 | } else { 113 | message += packet.packetType.asChar 114 | message ++= placeholders.toString 115 | message += '-' 116 | } 117 | case SocketIOBinaryAckPacket(_, data, id) => 118 | val placeholders = data.count(_.isRight) 119 | if (placeholders == 0) { 120 | message += SocketIOPacketType.Ack.asChar 121 | } else { 122 | message += packet.packetType.asChar 123 | message ++= placeholders.toString 124 | message += '-' 125 | } 126 | case _ => 127 | message += packet.packetType.asChar 128 | } 129 | 130 | // Encode namespace 131 | packet.namespace.foreach { ns => message ++= ns } 132 | 133 | def encodeData(data: JsValue, id: Option[Long]): Unit = { 134 | if (packet.namespace.isDefined) { 135 | message += ',' 136 | } 137 | id.foreach(id => message ++= id.toString) 138 | message ++= Json.stringify(data) 139 | } 140 | 141 | // Now the payload 142 | val extraPackets = packet match { 143 | case SocketIOEventPacket(_, data, id) => 144 | encodeData(JsArray(data), id) 145 | Nil 146 | case SocketIOAckPacket(_, data, id) => 147 | encodeData(JsArray(data), Some(id)) 148 | Nil 149 | case SocketIOErrorPacket(_, data) => 150 | encodeData(data, None) 151 | Nil 152 | case SocketIOBinaryEventPacket(_, data, id) => 153 | val (dataWithPlaceholders, extraPackets) = fillInPlaceholders(data) 154 | encodeData(JsArray(dataWithPlaceholders), id) 155 | extraPackets 156 | case SocketIOBinaryAckPacket(_, data, id) => 157 | val (dataWithPlaceholders, extraPackets) = fillInPlaceholders(data) 158 | encodeData(JsArray(dataWithPlaceholders), Some(id)) 159 | extraPackets 160 | case _ => 161 | // All other packets have no additional data 162 | Nil 163 | } 164 | 165 | TextEngineIOMessage(message.toString) :: 166 | extraPackets.map(BinaryEngineIOMessage) 167 | } 168 | 169 | /** 170 | * Decode an engine IO packet into a socket IO packet and a number of expected binary messages to follow. 171 | */ 172 | def decode(text: String): (SocketIOPacket, Int) = { 173 | if (text.isEmpty) { 174 | throw SocketIOEncodingException(text, "Empty socket.io packet") 175 | } 176 | 177 | val packetType = SocketIOPacketType.fromChar(text.head) 178 | val (placeholders, namespaceStart) = 179 | if (packetType == SocketIOPacketType.BinaryAck || packetType == SocketIOPacketType.BinaryEvent) { 180 | val placeholdersSeparator = text.indexOf('-', 1) 181 | if (placeholdersSeparator == -1) { 182 | throw SocketIOEncodingException(text, s"Malformed binary socket.io packet, missing placeholder separator") 183 | } 184 | val placeholders = 185 | try { 186 | text.substring(1, placeholdersSeparator).toInt 187 | } catch { 188 | case _: NumberFormatException => 189 | throw SocketIOEncodingException( 190 | text, 191 | "Malformed binary socket.io packet, num placeholders is not a number: '" + 192 | text.substring(1, placeholdersSeparator) + "'" 193 | ) 194 | } 195 | (placeholders, placeholdersSeparator + 1) 196 | } else { 197 | (0, 1) 198 | } 199 | 200 | val (namespace, dataStart) = if (text.length > namespaceStart && text(namespaceStart) == '/') { 201 | val namespaceEnd = text.indexOf(',', namespaceStart) 202 | if (namespaceEnd == -1) { 203 | throw SocketIOEncodingException(text, "Expected ',' to end namespace declaration") 204 | } 205 | (Some(text.substring(namespaceStart, namespaceEnd)), namespaceEnd + 1) 206 | } else { 207 | (None, namespaceStart) 208 | } 209 | 210 | val (index, args) = if (text.length > dataStart) { 211 | val argsStart = text.indexOf('[', dataStart) 212 | 213 | if (argsStart == -1) { 214 | throw SocketIOEncodingException( 215 | text, 216 | s"Expected JSON array open after data separator, but got '${text.substring(dataStart)}'" 217 | ) 218 | } 219 | 220 | val index = if (argsStart - dataStart >= 1) { 221 | try { 222 | Some(text.substring(dataStart, argsStart).toLong) 223 | } catch { 224 | case _: NumberFormatException => 225 | throw SocketIOEncodingException(text, "Malformed socket.io packet, index is not a number") 226 | } 227 | } else { 228 | None 229 | } 230 | 231 | val argsData = text.substring(argsStart) 232 | val args = 233 | try { 234 | Json.parse(argsData) 235 | } catch { 236 | case e: Exception => 237 | throw SocketIOEncodingException(text, "Error parsing socket.io args", e) 238 | } 239 | 240 | (index, Some(args)) 241 | } else { 242 | (None, None) 243 | } 244 | 245 | val socketIOPacket = (packetType, args) match { 246 | case (SocketIOPacketType.Connect, None) => 247 | SocketIOConnectPacket(namespace) 248 | case (SocketIOPacketType.Disconnect, None) => 249 | SocketIODisconnectPacket(namespace) 250 | case (SocketIOPacketType.Event, Some(data: JsArray)) => 251 | SocketIOEventPacket(namespace, data.value.toSeq, index) 252 | case (SocketIOPacketType.Ack, Some(data: JsArray)) if index.isDefined => 253 | SocketIOAckPacket(namespace, data.value.toSeq, index.get) 254 | case (SocketIOPacketType.Error, Some(data)) => 255 | SocketIOErrorPacket(namespace, data) 256 | case (SocketIOPacketType.BinaryEvent, Some(data: JsArray)) => 257 | SocketIOBinaryEventPacket(namespace, data.value.map(Left.apply).toSeq, index) 258 | case (SocketIOPacketType.BinaryAck, Some(data: JsArray)) if index.isDefined => 259 | SocketIOBinaryAckPacket(namespace, data.value.map(Left.apply).toSeq, index.get) 260 | case _ => 261 | throw SocketIOEncodingException(text, "Malformed socket.io packet") 262 | } 263 | 264 | (socketIOPacket, placeholders) 265 | } 266 | 267 | def replacePlaceholders( 268 | data: Seq[Either[JsValue, ByteString]], 269 | parts: IndexedSeq[ByteString] 270 | ): Seq[Either[JsValue, ByteString]] = { 271 | data.map { 272 | case Left(obj: JsObject) if (obj \ "_placeholder").toOption.contains(JsTrue) => 273 | val num = (obj \ "num").as[Int] 274 | Right(parts(num)) 275 | // Shouldn't get any rights, but anyway 276 | case other => other 277 | } 278 | } 279 | 280 | private def fillInPlaceholders(data: Seq[Either[JsValue, ByteString]]): (immutable.Seq[JsValue], List[ByteString]) = { 281 | data.foldLeft((List.empty[JsValue], List.empty[ByteString])) { 282 | case ((jsonData, extra), Left(jsValue)) => 283 | (jsonData :+ jsValue, extra) 284 | case ((jsonData, extra), Right(binaryData)) => 285 | (jsonData :+ Json.obj("_placeholder" -> true, "num" -> extra.size), extra :+ binaryData) 286 | } 287 | } 288 | } 289 | 290 | /** 291 | * Exception thrown when there was a problem encoding or decoding a socket.io packet from the underlying engine.io 292 | * packets. 293 | */ 294 | case class SocketIOEncodingException(packet: String, message: String, cause: Exception = null) 295 | extends RuntimeException( 296 | s"Error decoding socket IO packet '${packet.take(80)}${if (packet.length > 80) "..." else ""}': $message", 297 | cause 298 | ) 299 | --------------------------------------------------------------------------------