├── .gitignore ├── .gitmodules ├── .travis.yml ├── LICENSE ├── README.md ├── build.sbt ├── convert.sh ├── project ├── build.properties ├── plugins.sbt └── scripts │ └── publish-docs ├── sbt ├── src ├── main │ ├── node │ │ ├── .gitignore │ │ ├── lib │ │ │ ├── backchatio-hookup.js │ │ │ ├── filebuffer.js │ │ │ └── wireformat.js │ │ ├── package.json │ │ └── test │ │ │ ├── backchatio-hookup-test.js │ │ │ ├── filebuffer-test.js │ │ │ └── wireformat-test.js │ ├── resources │ │ └── mime.types │ ├── ruby │ │ ├── .gitignore │ │ ├── .rbenv-version │ │ ├── .rspec │ │ ├── Gemfile │ │ ├── Gemfile.lock │ │ ├── README.md │ │ ├── Rakefile │ │ ├── backchatio-hookup.gemspec │ │ ├── lib │ │ │ ├── backchatio-hookup.rb │ │ │ └── backchatio-hookup │ │ │ │ ├── client.rb │ │ │ │ ├── errors.rb │ │ │ │ ├── file_buffer.rb │ │ │ │ ├── version.rb │ │ │ │ └── wire_format.rb │ │ └── spec │ │ │ ├── client_spec.rb │ │ │ ├── file_buffer_spec.rb │ │ │ ├── rainbows.conf │ │ │ ├── spec_helper.rb │ │ │ └── wire_format_spec.rb │ ├── scala │ │ └── io │ │ │ └── backchat │ │ │ └── hookup │ │ │ ├── broadcast_channel.scala │ │ │ ├── buffer.scala │ │ │ ├── client.scala │ │ │ ├── examples │ │ │ ├── ChatClient.scala │ │ │ ├── ChatServer.scala │ │ │ ├── PrintAllEventsClient.scala │ │ │ ├── PrintAllEventsServer.scala │ │ │ ├── PrintingEchoClient.scala │ │ │ ├── PrintingEchoServer.scala │ │ │ ├── PubSubClient.scala │ │ │ └── PubSubServer.scala │ │ │ ├── http │ │ │ ├── CookieSet.scala │ │ │ ├── HeaderMap.scala │ │ │ ├── HttpMessageProxy.scala │ │ │ ├── HttpRequestProxy.scala │ │ │ ├── HttpResponseProxy.scala │ │ │ ├── MediaType.scala │ │ │ ├── Message.scala │ │ │ ├── Method.scala │ │ │ ├── ParamMap.scala │ │ │ ├── Path.scala │ │ │ ├── ProxyCredentials.scala │ │ │ ├── Request.scala │ │ │ ├── RequestBuilder.scala │ │ │ ├── RequestProxy.scala │ │ │ ├── Response.scala │ │ │ ├── Status.scala │ │ │ ├── StringUtil.scala │ │ │ └── Version.scala │ │ │ ├── messages.scala │ │ │ ├── netty_handlers.scala │ │ │ ├── operation_result.scala │ │ │ ├── package.scala │ │ │ ├── server.scala │ │ │ ├── server │ │ │ ├── DropUnhandledRequests.scala │ │ │ ├── FavIcoHandler.scala │ │ │ ├── FlashPolicyHandler.scala │ │ │ ├── LoadBalancerPingHandler.scala │ │ │ └── StaticFileHandler.scala │ │ │ ├── server_info.scala │ │ │ ├── throttles.scala │ │ │ └── wire_formats.scala │ └── site │ │ ├── .gitignore │ │ ├── .rbenv-version │ │ ├── Gemfile │ │ ├── Gemfile.lock │ │ ├── README.md │ │ ├── _config.yml │ │ ├── _includes │ │ └── .gitkeep │ │ ├── _layouts │ │ ├── .gitkeep │ │ ├── backchatio.html │ │ ├── minimalist.html │ │ └── modernist.html │ │ ├── _plugins │ │ ├── code_ref.rb │ │ ├── generate_page_toc.rb │ │ ├── redcarpet2_markdown.rb │ │ └── sass_converter.rb │ │ ├── _posts │ │ └── .gitkeep │ │ ├── config.rb │ │ ├── imgs │ │ ├── bg-watercolor.jpg │ │ └── logo.png │ │ ├── index.md │ │ ├── javascripts │ │ ├── fixed-sidebar.js │ │ └── scale.fix.js │ │ ├── server_guide.md │ │ └── stylesheets │ │ ├── backchatio.scss │ │ ├── ie.scss │ │ ├── print.scss │ │ ├── pygment_github.css │ │ ├── pygment_trac.css │ │ └── screen.scss └── test │ └── scala │ └── io │ └── backchat │ └── hookup │ ├── examples │ └── ServerConfigurationsExample.scala │ └── tests │ ├── FileBufferSpec.scala │ ├── HookupClientSpec.scala │ ├── HookupServerSpec.scala │ └── JsonProtocolWireFormatSpec.scala └── work └── .gitkeep /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | project/boot 3 | project/target 4 | project/plugins/target 5 | 6 | blacklist.txt 7 | .idea 8 | .idea_modules 9 | node_modules 10 | *.log 11 | cache 12 | .classpath 13 | .project 14 | .cache 15 | .settings 16 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/main/ruby/vendor/em-rspec"] 2 | path = src/main/ruby/vendor/em-rspec 3 | url = https://github.com/jcoglan/em-rspec.git 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | scala: 3 | - 2.10.4 4 | branches: 5 | only: 6 | - master 7 | notifications: 8 | email: 9 | - ivan@flanders.co.nz -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2011 Mojolly Ltd. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software 5 | and associated documentation files (the "Software"), to deal in the Software without restriction, 6 | including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 13 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 14 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 15 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 16 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BackChat.io Hookup [![Build Status](https://secure.travis-ci.org/backchatio/hookup.png?branch=master)](http://travis-ci.org/backchatio/hookup) 2 | 3 | Reliable messaging on top of websockets. Uses akka, netty, coda hale metrics,... Provides client for node, ruby, scala. 4 | 5 | For more information please visit the [documentation](http://backchatio.github.com/hookup). 6 | 7 | ## Patches 8 | Patches are gladly accepted from their original author. Along with any patches, please state that the patch is your original work and that you license the work to the *backchat-websocket* project under the MIT License. 9 | 10 | ## License 11 | MIT licensed. check the [LICENSE](https://github.com/backchatio/hookup/blob/master/LICENSE) file 12 | 13 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import xml.Group 2 | // import scalariform.formatter.preferences._ 3 | organization := "io.backchat.hookup" 4 | 5 | name := "hookup" 6 | 7 | version := "0.4.3-SNAPSHOT" 8 | 9 | scalaVersion := "2.10.5" 10 | 11 | crossScalaVersions := Seq("2.10.5", "2.11.7") 12 | 13 | compileOrder := CompileOrder.ScalaThenJava 14 | 15 | libraryDependencies ++= Seq( 16 | "io.netty" % "netty" % "3.10.4.Final", 17 | "com.github.nscala-time" %% "nscala-time" % "1.8.0", 18 | "org.json4s" %% "json4s-jackson" % "3.2.11" % "compile", 19 | "commons-io" % "commons-io" % "2.4", 20 | "com.typesafe.akka" %% "akka-actor" % "2.3.12" % "compile", 21 | "com.typesafe.akka" %% "akka-testkit" % "2.3.12" % "test", 22 | "org.specs2" %% "specs2-core" % "3.6.4" % "test", 23 | "org.specs2" %% "specs2-junit" % "3.6.4" % "test", 24 | "junit" % "junit" % "4.11" % "test", 25 | "joda-time" % "joda-time" % "2.8.2" 26 | ) 27 | 28 | scalacOptions ++= Seq( 29 | "-optimize", 30 | "-deprecation", 31 | "-unchecked", 32 | "-Xcheckinit", 33 | "-Yrangepos", 34 | "-encoding", "utf8") 35 | 36 | parallelExecution in Test := false 37 | 38 | testOptions := Seq(Tests.Argument("console", "junitxml")) 39 | 40 | testOptions <+= (crossTarget, resourceDirectory in Test) map { (ct, rt) => 41 | Tests.Setup { () => 42 | System.setProperty("specs2.junit.outDir", new File(ct, "specs-reports").getAbsolutePath) 43 | System.setProperty("java.util.logging.config.file", new File(rt, "logging.properties").getAbsolutePath) 44 | } 45 | } 46 | 47 | scalacOptions ++= Seq("-language:implicitConversions") 48 | 49 | publishMavenStyle := true 50 | 51 | publishTo <<= version { (v: String) => 52 | val nexus = "https://oss.sonatype.org/" 53 | if (v.trim.endsWith("SNAPSHOT")) 54 | Some("snapshots" at nexus + "content/repositories/snapshots") 55 | else 56 | Some("releases" at nexus + "service/local/staging/deploy/maven2") 57 | } 58 | 59 | publishArtifact in Test := false 60 | 61 | pomIncludeRepository := { x => false } 62 | 63 | packageOptions <<= (packageOptions, name, version, organization) map { 64 | (opts, title, version, vendor) => 65 | opts :+ Package.ManifestAttributes( 66 | "Created-By" -> "Simple Build Tool", 67 | "Built-By" -> System.getProperty("user.name"), 68 | "Build-Jdk" -> System.getProperty("java.version"), 69 | "Specification-Title" -> title, 70 | "Specification-Vendor" -> "Mojolly Ltd.", 71 | "Specification-Version" -> version, 72 | "Implementation-Title" -> title, 73 | "Implementation-Version" -> version, 74 | "Implementation-Vendor-Id" -> vendor, 75 | "Implementation-Vendor" -> "Mojolly Ltd.", 76 | "Implementation-Url" -> "https://backchat.io" 77 | ) 78 | } 79 | 80 | homepage := Some(url("https://backchatio.github.com/hookup")) 81 | 82 | startYear := Some(2012) 83 | 84 | licenses := Seq(("MIT", url("http://github.com/backchatio/hookup/raw/HEAD/LICENSE"))) 85 | 86 | pomExtra <<= (pomExtra, name, description) {(pom, name, desc) => pom ++ Group( 87 | 88 | scm:git:git://github.com/backchatio/hookup.git 89 | scm:git:git@github.com:backchatio/hookup.git 90 | https://github.com/backchatio/hookup.git 91 | 92 | 93 | 94 | casualjim 95 | Ivan Porto Carrero 96 | http://flanders.co.nz/ 97 | 98 | 99 | )} 100 | 101 | //seq(scalariformSettings: _*) 102 | // 103 | //ScalariformKeys.preferences := 104 | // (FormattingPreferences() 105 | // setPreference(IndentSpaces, 2) 106 | // setPreference(AlignParameters, false) 107 | // setPreference(AlignSingleLineCaseStatements, true) 108 | // setPreference(DoubleIndentClassDeclaration, true) 109 | // setPreference(RewriteArrowSymbols, true) 110 | // setPreference(PreserveSpaceBeforeArguments, true) 111 | // setPreference(IndentWithTabs, false)) 112 | // 113 | //(excludeFilter in ScalariformKeys.format) <<= excludeFilter(_ || "*Spec.scala") 114 | 115 | // seq(buildInfoSettings: _*) 116 | 117 | buildInfoSettings 118 | 119 | sourceGenerators in Compile <+= buildInfo 120 | 121 | buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion) 122 | 123 | // buildInfoPackage := "io.backchat.hookup" 124 | 125 | // buildInfoPackage := organization 126 | 127 | buildInfoPackage <<= organization 128 | -------------------------------------------------------------------------------- /convert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | FILE=$1 4 | cp $FILE ${FILE}.orig 5 | 6 | sed 's/import akka.util.{ Duration => AkkaDuration }/import scala.concurrent.duration.{ Duration => AkkaDuration }/' ${FILE}.orig | 7 | sed 's/akka.util.Duration/scala.concurrent.duration.Duration/' | 8 | sed 's/akka.dispatch.Future/scala.concurrent.Future/' | 9 | sed 's/akka.dispatch.{ Promise, ExecutionContext, Future }/scala.concurrent.{ Promise, ExecutionContext, Future }/' | 10 | sed 's/akka.dispatch.{/scala.concurrent.{/' | 11 | sed 's/akka.jsr166y.ForkJoinPool/scala.concurrent.forkjoin.ForkJoinPool/' | 12 | sed 's/akka.util.{ Duration, Timeout }/scala.concurrent.duration.Duration/' | 13 | sed 's/import akka.util.duration/import scala.concurrent.duration/' | 14 | sed 's/akka.util.duration/scala.concurrent.duration/g' | 15 | sed 's/org.scalaquery.ql.ForeignKeyAction/scala.slick.lifted.ForeignKeyAction/g' | 16 | sed 's/import org.scalaquery.session.Database.threadLocalSession/import scala.slick.driver.PostgresDriver.simple._; import scala.slick.session.Database.threadLocalSession/' | 17 | sed 's/org.scalaquery.session.Database.threadLocalSession/scala.slick.session.Database.threadLocalSession/' | 18 | #import org.scalaquery.session.Database.threadLocalSession 19 | #sed 's/import org.scalaquery.session.Database.threadLocalSession/import Database.threadLocalSession/' | 20 | sed 's/import org.scalaquery.ql.basic.{ BasicTable => Table }/import scala.slick.driver.PostgresDriver.simple.Table/' | 21 | sed 's/import org.scalaquery.simple.{GetResult, StaticQuery => Q}/import scala.slick.jdbc.{GetResult, StaticQuery => Q}/' | 22 | 23 | sed 's/org.scalaquery.simple/scala.slick.jdbc/' | 24 | #sed 's/org.scalaquery.session.Database/scala.slick.session.Database/' | 25 | sed 's/import org.scalaquery.session/import scala.slick.session/g' | 26 | sed 's/import org.scalaquery.ql.DDL/import scala.slick.lifted.DDL/' | 27 | sed 's/import org.scalaquery.ql.basic.BasicProfile/import scala.slick.driver.BasicProfile/g' | 28 | sed 's/org.scalaquery.ql.basic.BasicDriver/scala.slick.driver.PostgresDriver.simple.slickDriver/' | 29 | sed 's/import org.scalaquery.ql.basic._/import scala.slick.driver._/' | 30 | #sed 's/org.scalaquery.ql.basic.Basic/scala.slick.driver.Basic/' | 31 | sed 's/org.scalaquery.ql/scala.slick.lifted/' | 32 | sed 's/channelID.name/channelID/g' | sed 's/ts.name/ts/g' | 33 | sed 's/import org.scalaquery.simple/import scala.slick.jdbc/' > $FILE 34 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.5 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // resolvers += Resolver.url("sbt-plugin-releases", 2 | // new URL("http://scalasbt.artifactoryonline.com/scalasbt/sbt-plugin-releases/"))( 3 | // Resolver.ivyStylePatterns) 4 | 5 | //addSbtPlugin("com.typesafe.sbtscalariform" % "sbtscalariform" % "0.3.1") 6 | 7 | // addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.3.0") 8 | 9 | //addSbtPlugin("com.typesafe.sbt" % "sbt-pgp" % "0.8.3") 10 | 11 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.1.9") 12 | 13 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.3.2") 14 | -------------------------------------------------------------------------------- /project/scripts/publish-docs: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mkdir -p target 4 | rm -rf target/site 5 | git clone git@github.com:backchatio/hookup.git target/site 6 | cd target/site 7 | git checkout gh-pages 8 | # clean the old files, ensures no removed pages and files are left in the tree. 9 | git rm -rf * 10 | touch .nojekyll 11 | cat > .gitignore <> .rbenv-version 17 | # generate site 18 | cd ../../src/main/site 19 | bundle install && \ 20 | jekyll && \ 21 | cd ../node && \ 22 | npm install && \ 23 | npm test && \ 24 | node_modules/jsdoc-toolkit/app/run.js -d=../../../target/site/jsdoc -t=node_modules/jsdoc-toolkit/templates/codeview lib && \ 25 | cd ../../.. && \ 26 | mv target/scala-2.9.1/api target/site/scaladoc && \ 27 | cp -R target/jekyll/* target/site/ 28 | 29 | # generate yardocs 30 | # first write the yardocs 31 | 32 | 33 | # commit changes to gh-pages 34 | cd target/site 35 | git add . 36 | git commit -a -m 'new release' 37 | git push 38 | cd ../.. 39 | # avoid a surprised intellij by avoiding an unconfigured git root 40 | rm -rf target/site 41 | 42 | -------------------------------------------------------------------------------- /src/main/node/.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | -------------------------------------------------------------------------------- /src/main/node/lib/filebuffer.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore'), 2 | util = require('util'), 3 | events = require('events'), 4 | mkdirp = require('mkdirp'), 5 | fs = require('fs'), 6 | path = require('path'); 7 | 8 | var CLOSED = 0, DRAINING = 1, OPENING = 2, OPEN = 3, OVERFLOW = 4; 9 | 10 | /** 11 | * Creates a new FileBuffer. 12 | * @class 13 | * The default file buffer. 14 | * This is a file buffer that also has a memory buffer to which it writes when the file stream is 15 | * being read out or if a write to the file failed. 16 | * 17 | * This class has no maximum size or any limits attached to it yet. 18 | * So it is possible for this class to exhaust the memory and/or disk space of the machine using this buffer. 19 | * 20 | * @param {String} path The path to the file to use as buffer. 21 | * @param {Object} extra The extra configuration options. 22 | * @author Ivan Porto Carrero 23 | */ 24 | var FileBuffer = function(path, extra) { 25 | 26 | events.EventEmitter.call(this); 27 | this.path = path; 28 | var ex = extra||{}; 29 | this._memoryBuffer = ex.memoryBuffer || []; 30 | this._state = ex.initialState || CLOSED; 31 | } 32 | 33 | util.inherits(FileBuffer, events.EventEmitter); 34 | 35 | _.extend(FileBuffer.prototype, /** @lends FileBuffer.prototype */ { 36 | /** 37 | * Open this file buffer if not already opened. 38 | * This method is idempotent. 39 | */ 40 | open: function() { 41 | if (this._state < OPENING) { 42 | this._openFile(true); 43 | } 44 | return this._state < DRAINING; 45 | }, 46 | /** 47 | * Closes the buffer and releases any external resources contained by this buffer. 48 | * This method is idempotent. 49 | */ 50 | close: function() { 51 | if (this._state < DRAINING) { 52 | var self = this; 53 | this._stream.once("close", function() { 54 | self.emit("close"); 55 | self._state = CLOSED; 56 | }); 57 | this._stream.end(); 58 | } else { 59 | this.emit("close"); 60 | this._state = CLOSED; 61 | } 62 | }, 63 | /** 64 | * Write a message to the buffer. 65 | * 66 | * @param {String|Object} outMessage The outbound message 67 | * @param {Function} [callback] The callback 68 | */ 69 | write: function (outMessage, callback) { 70 | var self = this; 71 | try { 72 | switch(this._state) { 73 | case OPEN: 74 | this._flushMemoryBuffer(); 75 | process.nextTick(function() { 76 | try { 77 | self._stream.write(outMessage + "\n"); 78 | if (callback) callback(null, true); 79 | } catch (e) { 80 | if (callback) callback(e, null); 81 | } 82 | }); 83 | break; 84 | case CLOSED: 85 | this.open(); 86 | this.once("open", function() { 87 | self.write(outMessage, callback); 88 | }); 89 | break; 90 | default: 91 | this._memoryBuffer.push(outMessage); 92 | if (callback) callback(null, true); 93 | } 94 | } catch (e) { 95 | if (callback) callback(e, null); 96 | } 97 | }, 98 | /** 99 | * Drain the buffer raising data events to process each message in the buffer. 100 | * Truncates the buffer after draining. 101 | */ 102 | drain: function() { 103 | var self = this; 104 | var jnl = this._stream; 105 | jnl.once('close', function() { 106 | var rdJnl = fs.createReadStream(self.path, {encoding: 'utf8'}); 107 | rdJnl.on('data', function(data) { 108 | data.split("\n").forEach(function(line) { 109 | var cleaned = (line||"").replace(/(\n|\r)+$/, '').trim(); 110 | if (cleaned.length > 0) { 111 | self.emit('data', cleaned); 112 | } 113 | }) 114 | }); 115 | rdJnl.on('close', function() { 116 | self._openFile(false); 117 | }); 118 | }); 119 | jnl.end(); 120 | }, 121 | /** 122 | * @private 123 | * Opens the file, creating the path if it doesn't exist yet 124 | */ 125 | _openFile: function(append) { 126 | this._state = OPENING; 127 | var bufferDir = path.dirname(this.path); 128 | var self = this; 129 | mkdirp(bufferDir, function(err, data) { 130 | if (err) { 131 | self.emit("error", new Error("Can't create the path for the file buffer")); 132 | } 133 | try { 134 | self._stream = fs.createWriteStream(self.path, { flags: append ? 'a' : 'w', encoding: 'utf8'}); 135 | self._stream.once('open', function(fd) { 136 | self._state = OPEN; 137 | if (append) self.emit('open'); 138 | self._flushMemoryBuffer(); 139 | }); 140 | } catch (e) { 141 | self.emit("error", e); 142 | } 143 | }); 144 | }, 145 | /** 146 | * @private 147 | * flushes the memory buffer, to file. 148 | */ 149 | _flushMemoryBuffer: function() { 150 | var self = this; 151 | if (self._memoryBuffer.length > 0) { 152 | self._stream.write(self._memoryBuffer.join("\n") + "\n"); 153 | self._memoryBuffer = []; 154 | } 155 | } 156 | }); 157 | exports = exports.FileBuffer = FileBuffer; -------------------------------------------------------------------------------- /src/main/node/lib/wireformat.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore'), 2 | util = require('util'); 3 | 4 | /** 5 | * Creates a new wireformat 6 | * @class 7 | * A protocol that supports all the features of the websocket server. 8 | * This wireformat knows about acking and the related protocol messages. 9 | * it uses a json object to transfer, meaning everything has a property name. 10 | */ 11 | var WireFormat = function(options) { 12 | var opts = options||{}; 13 | this.format = opts.protocol || 'json'; 14 | this.name = opts.name || "simpleJson"; 15 | } 16 | 17 | _.extend(WireFormat.prototype, /** @lends WireFormat.prototype */ { 18 | /** 19 | * Parses a message from a string, and detects which type of message it is. 20 | * It detects if the message is a json string, and then works out if the message is 21 | * a protocol message or a user message and builds the appropriate data structure. 22 | * 23 | * This method is used when a message is received from a remote party. 24 | * The method is also used when draining a buffer, to read the string entries in the buffer. 25 | * 26 | * @param {String} message The string representation of the message to parse. 27 | * @returns {Object} The message object with meta data attached. 28 | */ 29 | parseMessage: function(message) { 30 | if (typeof message === 'object') return message; 31 | if (this.name === "jsonProtocol") { 32 | if (!this._canBeJson(message)) 33 | return { type: "text", content: message }; 34 | try { 35 | var prsd = JSON.parse(message); 36 | if (prsd.type === "ack" || prsd.type == "needs_ack") return prsd; 37 | if (prsd.type === "ack_request") 38 | return _.extend(prsd, { type: prsd.type, content: this.parseMessage(prsd.content)}); 39 | return _.extend({type: "json"}, { content: prsd }); 40 | } catch (e) { 41 | return { type: "text", content: message }; 42 | } 43 | } else { 44 | if (!this._canBeJson(message)) 45 | return message; 46 | try { 47 | return JSON.parse(message); 48 | } catch (e) { 49 | return message; 50 | } 51 | } 52 | }, 53 | /** 54 | * Render a message to a string for sending over the socket. 55 | * 56 | * @param {Object} message The message to serialize. 57 | * @returns {String} The string representation of the message to be sent. 58 | */ 59 | renderMessage: function(message) { 60 | return typeof message === "string" ? message : JSON.stringify(this.buildMessage(message)); 61 | }, 62 | /** 63 | * Builds a message from a string or an object and wraps it in an envelope with meta data for the protocol. 64 | * 65 | * @param {String|Object} message The message to send. 66 | * @returns {Object} The message wrapped and ready to send. 67 | */ 68 | buildMessage: function(message) { 69 | if (this.name === "jsonProtocol") { 70 | if (typeof message === 'object') { 71 | var prsd = this.parseMessage(message) 72 | if (prsd.type === "ack_request") 73 | prsd.content = this.parseMessage(prsd.content); 74 | var built = _.extend({type: "json"}, prsd); 75 | if (built.type === "json" && !built.content) return { type: "json", content: prsd}; 76 | return built; 77 | } else { 78 | return { type: "text", content: message.toString() }; 79 | } 80 | } else { 81 | if (typeof message === 'object') { 82 | return this.parseMessage(message) 83 | } else { 84 | return message.toString(); 85 | } 86 | } 87 | }, 88 | /** 89 | * Unwraps the message from the envelope, this is used before raising the data event on a hookup client. 90 | * 91 | * @param {Object|String} message The message for which to get the content. 92 | * @returns {String|Object} The content of the message if any. 93 | */ 94 | unwrapContent: function(message) { 95 | var parsed = this.parseMessage(message); 96 | if (this.name === "jsonProtocol") { 97 | if (parsed.type === "ack_request") 98 | return parsed.content.content; 99 | if (parsed.type === "json") { 100 | delete parsed['type']; 101 | return Object.keys(parsed).length === 1 && parsed.content ? parsed.content : parsed; 102 | } 103 | if (parsed.type === "ack" || parsed.type === "needs_ack") return parsed; 104 | return parsed.content; 105 | } else { 106 | return parsed; 107 | } 108 | }, 109 | _canBeJson: function(message) { 110 | return !!message.match(/^(?:\{|\[)/); 111 | } 112 | }); 113 | 114 | module.exports.WireFormat = WireFormat; 115 | 116 | -------------------------------------------------------------------------------- /src/main/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Ivan Porto Carrero (https://backchat.io)", 3 | "name": "backchatio-hookup", 4 | "description": "Provides a reliable messaging layer on top of websockets", 5 | "version": "0.1.15", 6 | "homepage": "https://backchatio.github.com/hookup", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/backchatio/hookup.git" 10 | }, 11 | "engines": { 12 | "node": "0.6.x" 13 | }, 14 | "dependencies": 15 | { "underscore": "" 16 | , "faye-websocket": "" 17 | , "dateformat": "" 18 | , "mkdirp": "" }, 19 | "devDependencies": 20 | { "vows": "0.6.x" 21 | , "sinon": "" 22 | , "rimraf": "" 23 | , "jsdoc-toolkit": ""} 24 | , "main": "./lib/backchatio-hookup.js" 25 | , "scripts": { "test": "vows --spec --isolate" } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/node/test/filebuffer-test.js: -------------------------------------------------------------------------------- 1 | var vows = require("vows"), 2 | assert = require("assert"), 3 | FileBuffer = require("../lib/filebuffer").FileBuffer, 4 | rimraf = require('rimraf').sync, 5 | events = require('events'), 6 | util = require('util'), 7 | fs = require('fs'), 8 | path = require('path'); 9 | 10 | vows.describe("FileBuffer").addBatch({ 11 | "when opening, ": { 12 | topic: { 13 | workPath: "./test-work", 14 | logPath: "./test-work/testing/and/such/buffer.log" 15 | }, 16 | "creates the path": { 17 | topic: function(options) { 18 | if (path.exists(options.workPath)) rimraf(options.workPath); 19 | var promise = new(events.EventEmitter); 20 | var buff = new FileBuffer(options.logPath); 21 | buff.on("open", function(){ 22 | buff.close(); 23 | }); 24 | buff.on("error", function(e) { 25 | promise.emit("error", e); 26 | }); 27 | buff.on("close", function() { 28 | buff.workPath = options.workPath; 29 | promise.emit("success", buff); 30 | }); 31 | buff.open(); 32 | return promise; 33 | }, 34 | "if it doesn't exist yet": function(err, res) { 35 | assert.isTrue(path.existsSync(res.path)); 36 | rimraf(res.workPath); 37 | } 38 | } 39 | }, 40 | "when not draining,": { 41 | topic: { 42 | logPath: "./test-work2/buffer.log", 43 | workPath: "./test-work2", 44 | exp1: "the first message", 45 | exp2: "the second message" 46 | }, 47 | "writes to a file": { 48 | topic: function(options) { 49 | if (path.exists(options.workPath)) rimraf(options.workPath); 50 | var promise = new(events.EventEmitter); 51 | var buff = new FileBuffer(options.logPath); 52 | buff.on('open', function() { 53 | buff.write(options.exp1, function() {}); 54 | buff.write(options.exp2, function() { 55 | buff.close(); 56 | }); 57 | buff.on("close", function() { 58 | var lines = fs.readFileSync(options.logPath, 'utf8'); 59 | rimraf(options.workPath); 60 | promise.emit("success", lines.split("\n")); 61 | }); 62 | 63 | }) 64 | buff.open(); 65 | return promise; 66 | }, 67 | "succeeds": function(err, lines) { 68 | assert.lengthOf(lines, 2); 69 | } 70 | } 71 | }, 72 | "when draining, ": { 73 | topic: { 74 | logPath: "./test-work3/buffer.log", 75 | workPath: "./test-work3", 76 | exp1: "the first message", 77 | exp2: "the second message" 78 | }, 79 | "new sends": { 80 | topic: function(options) { 81 | if (path.exists(options.workPath)) rimraf(options.workPath); 82 | options.memoryBuffer = []; 83 | options.buffer = new FileBuffer(options.logPath, { memoryBuffer: options.memoryBuffer, initialState: 1}); 84 | 85 | return options; 86 | }, 87 | "should write to the memory buffer": function(topic) { 88 | topic.buffer.write(topic.exp1); 89 | topic.buffer.write(topic.exp2); 90 | assert.lengthOf(topic.memoryBuffer, 2); 91 | } 92 | }, 93 | "a drain request": { 94 | topic: function(options) { 95 | if (path.exists(options.workPath)) rimraf(options.workPath); 96 | var promise = new (events.EventEmitter); 97 | var buff = new FileBuffer(options.logPath); 98 | var lines = []; 99 | var self = this; 100 | buff.on('data', function(data) { 101 | lines.push(data); 102 | if (lines.length === 2) { 103 | buff.close(); 104 | } 105 | }); 106 | buff.on("open", function() { 107 | buff.write(options.exp1); 108 | buff.write(options.exp2, function() { buff.drain() }); 109 | }); 110 | buff.on("close", function() { 111 | rimraf(options.workPath); 112 | self.callback(null, lines); 113 | }); 114 | buff.open(); 115 | }, 116 | "should raise data events for every line in the buffer": function (err, topic) { 117 | assert.lengthOf(topic, 2); 118 | } 119 | } 120 | } 121 | }).export(module); -------------------------------------------------------------------------------- /src/main/node/test/wireformat-test.js: -------------------------------------------------------------------------------- 1 | var vows = require("vows"), 2 | assert = require("assert"), 3 | WireFormat = require("../lib/wireformat").WireFormat; 4 | 5 | vows.describe("WireFormat").addBatch({ 6 | "A WireFormat": { 7 | topic: { 8 | text: "this is a text message", 9 | textResult: { type: "text", content: "this is a text message" }, 10 | jsonResult: { content: { data: "a json message" }, type: 'json'}, 11 | jsonData: {data: "a json message"}, 12 | json: JSON.stringify({data: "a json message"}), 13 | arrayResult: { content: [1, 2, 3, 4], type: "json"}, 14 | arrayData: [1, 2, 3, 4], 15 | arrayJson: JSON.stringify([1, 2, 3, 4]), 16 | ackResult: { id: 3, type: "ack" }, 17 | ack: JSON.stringify({ id: 3, type: "ack" }), 18 | ackRequestResult: { id: 3, type: "ack_request", content: { type: "text", content: "this is a text message" }}, 19 | ackRequestData: { id: 3, type: "ack_request", content: "this is a text message" }, 20 | ackRequest: JSON.stringify({ id: 3, type: "ack_request", content: { type: "text", content: "this is a text message" }}), 21 | needsAckResult: { type: "needs_ack", timeout: 5000, content: {type: "text", content: "this is a text message"} }, 22 | needsAck: JSON.stringify({ type: "needs_ack", timeout: 5000, content: {type: "text", content: "this is a text message"} }), 23 | wireFormat: new WireFormat({name: "jsonProtocol"}) 24 | }, 25 | "parses messages from": { 26 | "a text message": function(topic) { 27 | assert.deepEqual(topic.wireFormat.parseMessage(topic.text), topic.textResult); 28 | }, 29 | "a json message": function(topic) { 30 | assert.deepEqual(topic.wireFormat.parseMessage(topic.json), topic.jsonResult); 31 | }, 32 | "an array json message": function(topic) { 33 | assert.deepEqual(topic.wireFormat.parseMessage(topic.arrayJson), topic.arrayResult); 34 | }, 35 | "an ack message": function(topic) { 36 | assert.deepEqual(topic.wireFormat.parseMessage(topic.ack), topic.ackResult); 37 | }, 38 | "an ack_request message": function(topic) { 39 | assert.deepEqual(topic.wireFormat.parseMessage(topic.ackRequest), topic.ackRequestResult); 40 | }, 41 | "a needs_ack message": function(topic) { 42 | assert.deepEqual(topic.wireFormat.parseMessage(topic.needsAck), topic.needsAckResult); 43 | } 44 | }, 45 | 46 | "builds messages": { 47 | "a text message": function(topic) { 48 | assert.deepEqual(topic.wireFormat.buildMessage(topic.text), topic.textResult); 49 | }, 50 | "a json message": function(topic) { 51 | assert.deepEqual(topic.wireFormat.buildMessage(topic.jsonData), topic.jsonResult); 52 | }, 53 | "an array json message": function(topic) { 54 | assert.deepEqual(topic.wireFormat.buildMessage(topic.arrayData), topic.arrayResult); 55 | }, 56 | "an ack message": function(topic) { 57 | assert.deepEqual(topic.wireFormat.buildMessage(topic.ackResult), topic.ackResult); 58 | }, 59 | "an ack_request message": function(topic) { 60 | assert.deepEqual(topic.wireFormat.buildMessage(topic.ackRequestData), topic.ackRequestResult); 61 | } 62 | }, 63 | 64 | "unwraps messages": { 65 | "a text message": function(topic) { 66 | assert.deepEqual(topic.wireFormat.unwrapContent(topic.textResult), topic.text); 67 | }, 68 | "a json message": function(topic) { 69 | assert.deepEqual(topic.wireFormat.unwrapContent(topic.jsonResult), topic.jsonData); 70 | }, 71 | "an array json message": function(topic) { 72 | assert.deepEqual(topic.wireFormat.unwrapContent(topic.arrayResult), topic.arrayData); 73 | }, 74 | "an ack message": function(topic) { 75 | assert.deepEqual(topic.wireFormat.unwrapContent(topic.ackResult), topic.ackResult); 76 | }, 77 | "an ack_request message": function(topic) { 78 | assert.deepEqual(topic.wireFormat.unwrapContent(topic.ackRequestResult), topic.text); 79 | }, 80 | "a needs_ack message": function(topic) { 81 | assert.deepEqual(topic.wireFormat.unwrapContent(topic.needsAckResult), topic.needsAckResult); 82 | } 83 | } 84 | } 85 | }).export(module); -------------------------------------------------------------------------------- /src/main/ruby/.gitignore: -------------------------------------------------------------------------------- 1 | .gems 2 | .bundle 3 | -------------------------------------------------------------------------------- /src/main/ruby/.rbenv-version: -------------------------------------------------------------------------------- 1 | 1.9.3-p125 2 | -------------------------------------------------------------------------------- /src/main/ruby/.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --colour 3 | --tty 4 | -------------------------------------------------------------------------------- /src/main/ruby/Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in backchat-minutes.gemspec 4 | gemspec 5 | 6 | -------------------------------------------------------------------------------- /src/main/ruby/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | backchat-websocket (0.1.0) 5 | eventmachine 6 | faye-websocket 7 | json 8 | 9 | GEM 10 | remote: http://rubygems.org/ 11 | specs: 12 | addressable (2.2.8) 13 | bacon (1.1.0) 14 | crack (0.3.1) 15 | diff-lcs (1.1.3) 16 | em-spec (0.2.6) 17 | bacon 18 | eventmachine 19 | rspec (> 2.6.0) 20 | test-unit 21 | eventmachine (0.12.10) 22 | faye-websocket (0.4.5) 23 | eventmachine (>= 0.12.0) 24 | json (1.7.1) 25 | kgio (2.7.4) 26 | rack (1.4.1) 27 | rainbows (4.3.1) 28 | kgio (~> 2.5) 29 | rack (~> 1.1) 30 | unicorn (~> 4.1) 31 | raindrops (0.8.0) 32 | rspec (2.8.0) 33 | rspec-core (~> 2.8.0) 34 | rspec-expectations (~> 2.8.0) 35 | rspec-mocks (~> 2.8.0) 36 | rspec-core (2.8.0) 37 | rspec-expectations (2.8.0) 38 | diff-lcs (~> 1.1.2) 39 | rspec-mocks (2.8.0) 40 | test-unit (2.4.8) 41 | unicorn (4.3.1) 42 | kgio (~> 2.6) 43 | rack 44 | raindrops (~> 0.7) 45 | webmock (1.8.6) 46 | addressable (>= 2.2.7) 47 | crack (>= 0.1.7) 48 | 49 | PLATFORMS 50 | ruby 51 | 52 | DEPENDENCIES 53 | backchat-websocket! 54 | em-spec (>= 0.2.6) 55 | rack 56 | rainbows (>= 1.0.0) 57 | rspec (~> 2.8.0) 58 | webmock 59 | -------------------------------------------------------------------------------- /src/main/ruby/README.md: -------------------------------------------------------------------------------- 1 | I can't figure out how to properly automate tests for the client where I can test the fault tolerance. 2 | Someone braver than me can pick up the gauntlet and get those tested. I've tested the connection flow manually because I'm out of time for this. 3 | -------------------------------------------------------------------------------- /src/main/ruby/Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | task :default => [:test] 5 | 6 | spec = Gem::Specification.load('backchatio-hookup.gemspec.gemspec') 7 | 8 | Gem::PackageTask.new(spec) do |pkg| 9 | end 10 | 11 | require 'rspec/core/rake_task' 12 | RSpec::Core::RakeTask.new(:test) do |spec| 13 | spec.pattern = 'spec/*_spec.rb' 14 | spec.rspec_opts = '--color --format doc' 15 | end 16 | -------------------------------------------------------------------------------- /src/main/ruby/backchatio-hookup.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | $:.push File.expand_path("../lib", __FILE__) 4 | require "backchatio-hookup/version" 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "backchatio-hookup" 8 | s.version = Backchat::Hookup::VERSION 9 | s.platform = Gem::Platform::RUBY 10 | s.authors = ["This gem provides reliable websocket client."] 11 | s.email = ["ivan@backchat.io"] 12 | s.homepage = "" 13 | s.summary = %q{Gem with BackChat WebSocket client} 14 | s.description = %q{Gem to with a client for use with the backchat websocket server.} 15 | 16 | s.rubyforge_project = "backchatio-hookup" 17 | 18 | s.files = `git ls-files`.split("\n") 19 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 20 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 21 | s.require_paths = ["lib"] 22 | 23 | s.add_dependency("eventmachine") 24 | s.add_dependency("faye-websocket") 25 | s.add_dependency("json") 26 | 27 | s.add_development_dependency('webmock') 28 | s.add_development_dependency "rack" 29 | s.add_development_dependency "rspec", "~> 2.8.0" 30 | s.add_development_dependency "rainbows", ">= 1.0.0" 31 | s.add_development_dependency "em-spec", ">= 0.2.6" 32 | end 33 | -------------------------------------------------------------------------------- /src/main/ruby/lib/backchatio-hookup.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'time' 4 | require "addressable/uri" 5 | require 'json' 6 | require 'json/ext' 7 | require 'backchatio-hookup/version' 8 | require 'backchatio-hookup/errors' 9 | 10 | module Backchat 11 | module Hookup 12 | autoload :Client, "#{File.dirname(__FILE__)}/backchatio-hookup/client" 13 | autoload :WireFormat, "#{File.dirname(__FILE__)}/backchatio-hookup/wire_format" 14 | autoload :FileBuffer, "#{File.dirname(__FILE__)}/backchatio-hookup/file_buffer" 15 | end 16 | end -------------------------------------------------------------------------------- /src/main/ruby/lib/backchatio-hookup/client.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require 'thread' 3 | 4 | module Backchat 5 | module WebSocket 6 | 7 | RECONNECT_SCHEDULE = 1..300 8 | BUFFER_PATH = "./logs/buffer.log" 9 | EVENT_NAMES = { 10 | :receive => "message", 11 | :connected => "open", 12 | :disconnect => "close" 13 | } 14 | 15 | module ClientState 16 | Disconnected = 0 17 | Disconnecting = 1 18 | Reconnecting = 2 19 | Connecting = 3 20 | Connected = 4 21 | end 22 | 23 | class Client 24 | 25 | attr_reader :uri, :reconnect_schedule 26 | 27 | def on(event_name, &cb) 28 | @handlers.subscribe do |evt| 29 | callback = EM.Callback(&cb) 30 | callback.call if evt && evt.size == 1 && evt[0] == evt_name 31 | callback.call(evt[1]) if evt && evt[0] == event_name && evt.size > 1 32 | end 33 | end 34 | 35 | def remove_on(evt_id) 36 | @handlers.unsubscribe(evt_id) 37 | end 38 | 39 | def initialize(options={}) 40 | options = {:uri => options} if options.is_a?(String) 41 | raise Backchat::WebSocket::UriRequiredError, ":uri parameter is required" unless options.key?(:uri) 42 | parsed = begin 43 | u = Addressable::URI.parse(options[:uri].gsub(/^http/i, 'ws')).normalize 44 | u.path = "/" if u.path.nil? || u.path.strip.empty? 45 | u.to_s 46 | rescue 47 | raise Backchat::WebSocket::InvalidURIError, ":uri [#{options[:uri]}] must be a valid uri" 48 | end 49 | @uri, @reconnect_schedule = parsed, (options[:reconnect_schedule]||RECONNECT_SCHEDULE.clone) 50 | @max_retries = options[:max_retries] 51 | @quiet = !!options[:quiet] 52 | @handlers = options[:handlers]||{} #EM::Channel.new 53 | @wire_format = options[:wire_format] || WireFormat.new 54 | @expected_acks = {} 55 | @ack_counter = 0 56 | # this option `raise_ack_events` is only useful in tests for the acking itself. 57 | # it raises an event when an ack is received or an ack request is prepared 58 | # it serves no other purpose than that, ack_failed events are raised independently from this option. 59 | @raise_ack_events = !!options[:raise_ack_events] 60 | @state = ClientState::Disconnected 61 | if !!options[:buffered] 62 | @buffer = options[:buffer]||FileBuffer.new(options[:buffer_path]||BUFFER_PATH) 63 | @buffer.on(:data, &method(:send)) 64 | end 65 | end 66 | 67 | def send(msg) 68 | m = prepare_for_send(msg) 69 | if connected? then 70 | @ws.send(m) 71 | else 72 | buffered? ? @buffer << m : nil 73 | end 74 | end 75 | 76 | def send_acked(msg, options={}) 77 | timeout = (options||{})[:timeout]||5 78 | send(:type => :needs_ack, :content => msg, :timeout => timeout) 79 | end 80 | 81 | def connect 82 | establish_connection if @state < ClientState::Connecting 83 | end 84 | 85 | def connected? 86 | @state == ClientState::Connected 87 | end 88 | 89 | def buffered? 90 | !!@buffer 91 | end 92 | 93 | def disconnect 94 | if @state > ClientState::Reconnecting 95 | @skip_reconnect = true 96 | @ws.close 97 | end 98 | end 99 | 100 | def method_missing(name, *args, &block) 101 | if name =~ /^on_(.+)$/ 102 | on($1, &block) 103 | elsif name =~ /^remove_on_(.+)$/ 104 | remove_on($1, &block) 105 | else 106 | super 107 | end 108 | end 109 | 110 | private 111 | def emit(evt_name, args = nil) 112 | @handlers << [evt_name, args] 113 | end 114 | 115 | def reconnect 116 | if @state == ClientState::Disconnecting 117 | perform_disconnect 118 | else 119 | @notified_of_reconnect = true 120 | if @reconnect_in && @reconnect_in > 0 && @reconnect_in < @reconnect_schedule.max 121 | curr = @reconnect_in 122 | max = @reconnect_schedule.max 123 | nxt = curr < max ? curr : max 124 | if @max_retries && @max_retries > 0 125 | if @reconnects_left <= 0 126 | emit(:error, Exception.new("Exhausted the retry schedule. The server at #@uri is just not there.")) 127 | else 128 | @reconnects_left = (@reconnects_left||@max_retries) - 1 129 | end 130 | end 131 | perform_reconnect(nxt) 132 | else 133 | perform_disconnect 134 | end 135 | end 136 | end 137 | 138 | def establish_connection 139 | if @scheduled_retry 140 | @scheduled_retry.cancel 141 | @scheduled_retry = nil 142 | end 143 | unless connected? 144 | begin 145 | @ws = Faye::WebSocket::Client.new(@uri) 146 | @state = ClientState::Connecting 147 | 148 | @ws.onopen = lambda { |e| 149 | puts "connected to #{@uri}" 150 | EM.next_tick(&method(:connected)) 151 | } 152 | @ws.onmessage = lambda { |e| 153 | EM.next_tick do 154 | m = preprocess_in_message(e.data) 155 | emit(:data, m) 156 | end 157 | } 158 | @ws.onerror = lambda { |e| 159 | unless @quiet 160 | puts "Couldn't connect to #{@uri}" 161 | puts e.inspect 162 | end 163 | emit(:error, e) 164 | } 165 | @ws.onclose = lambda { |e| 166 | EM.next_tick do 167 | @state = @skip_reconnect ? ClientState::Disconnecting : ClientState::Reconnecting 168 | if @state == ClientState::Disconnecting 169 | perform_disconnect 170 | else 171 | emit(:reconnect) 172 | #EM.next_tick(&method(:reconnect)) 173 | reconnect 174 | end 175 | 176 | end 177 | } 178 | rescue Exception => e 179 | puts e 180 | emit(:error, e) 181 | end 182 | end 183 | end 184 | 185 | def perform_disconnect 186 | @state = ClientState::Disconnected 187 | emit(:close) 188 | @buffer.close if buffered? 189 | end 190 | 191 | def perform_reconnect(retry_in) 192 | unless @scheduled_retry 193 | secs = "second#{retry_in == 1 ? "" : "s"}" 194 | out = retry_in < 1 ? retry_in * 1000 : retry_in 195 | puts "connection lost, reconnecting in #{out} #{retry_in < 1 ? "millis" : secs }." 196 | @scheduled_retry = EM::Timer.new(retry_in) { establish_connection } 197 | @reconnect_in = retry_in * 2 198 | end 199 | end 200 | 201 | def connected 202 | @buffer.drain if buffered? 203 | @reconnect_in = @reconnect_schedule.min 204 | @reconnects_left = 0 205 | @notified_of_reconnect = false 206 | @skip_reconnect = false 207 | @state = ClientState::Connected 208 | emit(:open) 209 | end 210 | 211 | def preprocess_in_message(msg) 212 | message = @wire_format.parse_message(msg) 213 | send :ack => "ack", :id => msg["id"] if message.is_a?(Hash) && message["type"] == "ack_request" 214 | if message.is_a?(Hash) && message["type"] == "ack" 215 | timeout = @expected_acks[message["id"]] 216 | timeout.cancel 217 | @expected_acks.delete[message["id"]] 218 | emit("ack", message) if @raise_ack_events 219 | return nil 220 | end 221 | @wire_format.unwrap_content(message) 222 | end 223 | 224 | def prepare_for_send(message) 225 | out = @wire_format.render_message(message) 226 | if message.is_a?(Hash) && message["type"] == "needs_ack" 227 | ack_req = { 228 | :content => @wire_format.build_message(message["content"]), 229 | :type => "ack_request", 230 | :id => (@ack_counter += 1) 231 | } 232 | @expected_acks[ack_req[:id]] = EM::Timer.new(message[:timeout]) do 233 | emit(:ack_failed, message["content"]) 234 | end 235 | out = @wire_format.render_message(ack_req) 236 | emit(:ack_request, ack_req) if @raise_ack_events 237 | end 238 | puts "sending #{out.inspect}" 239 | out 240 | end 241 | 242 | 243 | end 244 | end 245 | end -------------------------------------------------------------------------------- /src/main/ruby/lib/backchatio-hookup/errors.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module Backchat 4 | module WebSocket 5 | class DefaultError < StandardError 6 | end 7 | 8 | class UriRequiredError < DefaultError 9 | end 10 | 11 | class InvalidURIError < DefaultError 12 | end 13 | 14 | class ServerDisconnectedError < DefaultError 15 | end 16 | end 17 | end -------------------------------------------------------------------------------- /src/main/ruby/lib/backchatio-hookup/file_buffer.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'fileutils' 4 | 5 | module Backchat 6 | module WebSocket 7 | 8 | module BufferState 9 | Closed = 0 10 | Draining = 1 11 | Opening = 2 12 | Open = 3 13 | Overflow = 4 14 | end 15 | 16 | class FileBuffer 17 | 18 | attr_reader :path 19 | 20 | def initialize(path, options={}) 21 | @path = path 22 | @memory_buffer = options[:memory_buffer] || [] 23 | @state = options[:initial_state] || BufferState::Closed 24 | @handlers = {} 25 | end 26 | 27 | def on(event_name, &cb) 28 | @handlers[event_name] ||= [] 29 | @handlers[event_name] << cb 30 | end 31 | 32 | def remove_on(evt_id) 33 | @handlers.unsubscribe(evt_id) 34 | end 35 | 36 | def open 37 | open_file(true) unless open? 38 | open? 39 | end 40 | 41 | def open? 42 | @state > BufferState::Draining 43 | end 44 | 45 | def close 46 | if @stream && !@stream.closed? && open? 47 | @stream.close 48 | @state = BufferState::Closed 49 | emit(:close) 50 | end 51 | end 52 | 53 | def write(message, &callback) 54 | case @state 55 | when BufferState::Open 56 | flush_memory_buffer 57 | @stream.puts(message) 58 | callback.call if callback 59 | when BufferState::Closed 60 | self.open 61 | write(message) 62 | else 63 | @memory_buffer.push message 64 | callback.call if callback 65 | end 66 | end 67 | alias :<< :write 68 | 69 | def drain 70 | @state = BufferState::Draining 71 | @stream.close unless @stream.closed? 72 | File.open(@path, "r:utf-8") do |file| 73 | while(line = file.gets) 74 | emit(:data, line.chomp.strip) 75 | end 76 | end 77 | open_file false 78 | end 79 | 80 | private 81 | def emit(evt_name, *args) 82 | (@handlers[evt_name]||[]).each do |cb| 83 | cb.call(*args) 84 | end 85 | end 86 | 87 | def open_file(append) 88 | begin 89 | @state = BufferState::Opening 90 | begin 91 | FileUtils.mkdir_p(File.dirname(@path)) unless File.exist?(File.dirname(@path)) 92 | rescue 93 | @state = BufferState::Closed 94 | emit(:error, Exception.new("Can't create the path for the file buffer.")) 95 | end 96 | @state = BufferState::Open 97 | @stream = File.open(@path, "#{append ? 'a' : 'w'}:utf-8") 98 | emit(:open) if append 99 | flush_memory_buffer 100 | rescue Exception => e 101 | @state = BufferState::Closed 102 | emit(:error, e) 103 | end 104 | end 105 | 106 | def flush_memory_buffer() 107 | emit(:data, @memory_buffer.pop) until @memory_buffer.empty? 108 | end 109 | end 110 | end 111 | end -------------------------------------------------------------------------------- /src/main/ruby/lib/backchatio-hookup/version.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module Backchat 4 | module WebSocket 5 | VERSION = "0.1.0" 6 | end 7 | end -------------------------------------------------------------------------------- /src/main/ruby/lib/backchatio-hookup/wire_format.rb: -------------------------------------------------------------------------------- 1 | 2 | module Backchat 3 | module WebSocket 4 | class WireFormat 5 | 6 | def parse_message(message) 7 | return message unless message.is_a?(String) 8 | return { "type" => "text", "content" => message } unless possibly_json?(message) 9 | begin 10 | json = JSON.parse(message) 11 | return json if json.is_a?(Hash) && (json["type"] == "ack" || json["type"] == "needs_ack") 12 | return json.update("content" => parse_message(json["content"])) if json.is_a?(Hash) && json["type"] == "ack_request" 13 | json.is_a?(Hash) && json.key?("type") ? json : { "type" => "json", "content" => json } 14 | rescue Exception => e 15 | puts e 16 | { "type" => "text", "content" => message } 17 | end 18 | end 19 | 20 | def render_message(message) 21 | JSON.generate(build_message(message)) 22 | end 23 | 24 | def build_message(message) 25 | return { "type" => "text", "content" => message } if message.is_a?(String) 26 | prsd = parse_message(message) 27 | return prsd if prsd.is_a?(Hash) && prsd["type"] == "ack" 28 | prsd["content"] = parse_message(prsd["content"]) if prsd.is_a?(Hash) && prsd["type"] == "ack_request" 29 | if prsd.is_a?(Hash) 30 | built = prsd.key?("content") ? prsd : { "type" => "json", "content" => prsd} 31 | built["type"] ||= "json" 32 | built 33 | else 34 | {"type" => "json", "content" => prsd} 35 | end 36 | end 37 | 38 | def unwrap_content(message) 39 | parsed = parse_message(message) 40 | return parsed["content"]["content"] if parsed["type"] == "ack_request" 41 | if (parsed["type"] == "json") 42 | parsed.delete('type') 43 | return parsed.size == 1 && parsed.key?("content") ? parsed["content"] : parsed 44 | end 45 | return parsed if parsed["type"] == "ack" || parsed["type"] == "needs_ack" 46 | parsed["content"] 47 | end 48 | 49 | private 50 | def possibly_json?(message) 51 | !!(message =~ /^(?:\{|\[)/) 52 | end 53 | end 54 | end 55 | end -------------------------------------------------------------------------------- /src/main/ruby/spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path("../spec_helper", __FILE__) 3 | # require "em-spec/rspec" 4 | 5 | 6 | ClientSteps = EM::RSpec.async_steps do 7 | 8 | def server(port, &callback) 9 | @server = TestServer.new 10 | @server.listen port 11 | @port = port 12 | ensure 13 | EM.add_timer(0.1, &callback) 14 | end 15 | 16 | def stop(&callback) 17 | @server.stop 18 | EM.next_tick(&callback) 19 | end 20 | 21 | def connect(url, retry_schedule = nil, &callback) 22 | done = false 23 | 24 | resume = lambda do |open| 25 | puts "resuming" 26 | unless done 27 | done = true 28 | callback.call 29 | end 30 | end 31 | 32 | @ws = Client.new(:uri => url, :reconnect_schedule => retry_schedule) 33 | 34 | @ws.on(:open) { 35 | resume.call(true) } 36 | @ws.on(:close) { resume.call(false) } 37 | @ws.connect 38 | end 39 | 40 | def restart_server(&callback) 41 | @server.stop 42 | EM.add_timer(0.5) do 43 | @ws.should_not be_connected 44 | @server = TestServer.new 45 | @server.listen 8000 46 | EM.add_timer(0.1, &callback) 47 | end 48 | end 49 | 50 | def disconnect(&callback) 51 | @ws.on_disconnected do |e| 52 | callback.call 53 | end 54 | @ws.disconnect 55 | end 56 | 57 | def wait_for(seconds, &callback) 58 | EM.add_timer(seconds) do 59 | callback.call 60 | end 61 | end 62 | 63 | def check_connected(&callback) 64 | puts "checking connected" 65 | @ws.should be_connected 66 | callback.call 67 | end 68 | 69 | def check_reconnected(&callback) 70 | @ws.should be_connected 71 | callback.call 72 | end 73 | 74 | def check_disconnected(&callback) 75 | @ws.should_not be_connected 76 | callback.call 77 | end 78 | 79 | def listen_for_message(&callback) 80 | @ws.on_receive { |e| @message = e.data } 81 | callback.call 82 | end 83 | 84 | def send_message(message, &callback) 85 | @ws.send(message) 86 | EM.add_timer(0.1, &callback) 87 | end 88 | 89 | def check_response(message, &callback) 90 | @message.should == message 91 | callback.call 92 | end 93 | 94 | end 95 | 96 | describe Backchat::Hookup::Client do 97 | # include ClientHelper 98 | # include EM::SpecHelper 99 | 100 | 101 | # default_timeout 1 102 | 103 | before do 104 | Thread.new { EM.run } 105 | sleep(0.1) until EM.reactor_running? 106 | end 107 | 108 | context "initializing" do 109 | 110 | before(:all) do 111 | @uri = "ws://localhost:2948/" 112 | @defaults_client = Client.new(:uri => @uri) 113 | @retries = 1..5 114 | @journaled = true 115 | @client = Client.new(:uri => @uri, :reconnect_schedule => @retries, :buffered => @journaled) 116 | end 117 | 118 | context "should raise when the uri param is" do 119 | it "missing" do 120 | (lambda do 121 | Client.new 122 | end).should raise_error(Backchat::Hookup::UriRequiredError) 123 | end 124 | 125 | it "an invalid uri" do 126 | (lambda do 127 | Client.new :uri => "http:" 128 | end).should raise_error(Backchat::Hookup::InvalidURIError) 129 | end 130 | end 131 | 132 | it "should set use the default retry schedule" do 133 | @defaults_client.reconnect_schedule.should == Backchat::Hookup::RECONNECT_SCHEDULE 134 | end 135 | 136 | it "should set journaling as default to false" do 137 | @defaults_client.should_not be_buffered 138 | end 139 | 140 | it "should use the uri from the options" do 141 | @defaults_client.uri.should == @uri 142 | end 143 | 144 | it "should use the retry schedule from the options" do 145 | @client.reconnect_schedule.should == @retries 146 | end 147 | 148 | it "should use the journaling value from the options" do 149 | @client.should be_buffered 150 | end 151 | end 152 | 153 | context "sending json to the server" do 154 | 155 | 156 | 157 | # it "connects to the server" do 158 | # em do 159 | # server(8001) 160 | # ws = Client.new("ws://127.0.0.1:8001/") 161 | # op = false 162 | # ws.on(:open) do 163 | # op = true 164 | # ws.disconnect 165 | # end 166 | # ws.on(:close) do 167 | # begin 168 | # op.should be_true 169 | # stop_server 170 | # ensure 171 | # done 172 | # end 173 | # end 174 | # ws.connect 175 | # end 176 | # end 177 | 178 | # it "disconnects from the server" do 179 | # em do 180 | # server(8002) 181 | # ws = Client.new("ws://127.0.0.1:8002/") 182 | # ws.on(:open) do 183 | # ws.disconnect 184 | # end 185 | # ws.on(:close) do 186 | # begin 187 | # 1.should == 1 188 | # stop_server 189 | # ensure 190 | # done 191 | # end 192 | # end 193 | # ws.connect 194 | # end 195 | # end 196 | 197 | # it "sends messages to the server" do 198 | # msg = "I expect this to be echoed" 199 | # em do 200 | # server(8003) 201 | # ws = Client.new("ws://127.0.0.1:8003/") 202 | # ws.on(:open) do 203 | # ws.send msg 204 | # end 205 | # ws.on(:data) do |data| 206 | # begin 207 | # data.should == msg 208 | # ensure 209 | # ws.disconnect 210 | # end 211 | # end 212 | # ws.on(:close) do 213 | # begin 214 | # stop_server 215 | # ensure 216 | # done 217 | # end 218 | # end 219 | # ws.connect 220 | # end 221 | # end 222 | 223 | # include ClientSteps 224 | 225 | # before { server 8000; connect("ws://0.0.0.0:8000/") } 226 | # after { stop } 227 | 228 | # it "connects to the server" do 229 | # check_connected 230 | # disconnect 231 | # end 232 | 233 | # it "disconnects from the server" do 234 | # disconnect 235 | # check_disconnected 236 | # end 237 | 238 | # it "sends messages to the server" do 239 | # listen_for_message 240 | # send_message "I expect this to be echoed" 241 | # check_response "I expect this to be echoed" 242 | # end 243 | 244 | # it "converts objects to json before sending" do 245 | # listen_for_message 246 | # send_message ["subscribe", "me"] 247 | # check_response ["subscribe", "me"].to_json 248 | # end 249 | 250 | end 251 | 252 | # context "fault-tolerance" do 253 | 254 | # include ServerClientSteps 255 | 256 | # before { server 8000 } 257 | # after { sync ; stop } 258 | 259 | # it "recovers if the server comes back within the schedule" do 260 | # connect("ws://0.0.0.0:8000/", [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) 261 | # restart_server 262 | # wait_for(5) 263 | # check_connected 264 | # end 265 | 266 | # # it "raises a Backchat::Minutes::ServerDisconnectedError if the server doesn't come back" do 267 | # # connect("ws://0.0.0.0:8000/", [1, 1, 1, 1, 1, 1, 1]) 268 | # # stop 269 | # # check_disconnected 270 | # # EM.add_timer(3) { check_connected } 271 | # # end 272 | # # end 273 | 274 | # end 275 | 276 | end -------------------------------------------------------------------------------- /src/main/ruby/spec/file_buffer_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path("../spec_helper", __FILE__) 3 | require 'fileutils' 4 | 5 | describe Backchat::Hookup::FileBuffer do 6 | 7 | context "when opening" do 8 | work_path = "./rb-test-work" 9 | log_path = "./rb-test-work/testing/and/such/buffer.log" 10 | 11 | before(:each) do 12 | FileUtils.rm_rf(work_path) if File.exist?(work_path) 13 | end 14 | 15 | it "creates the path if missing" do 16 | buff = FileBuffer.new(work_path) 17 | buff.on(:open) do 18 | File.exist?(work_path).should be_true 19 | end 20 | buff.open 21 | FileUtils.rm_rf(work_path) 22 | end 23 | end 24 | 25 | context 'when not draining' do 26 | work_path = "./rb-test-work2" 27 | log_path = "#{work_path}/buffer.log" 28 | exp1 = "the first message" 29 | exp2 = "the second message" 30 | 31 | before(:each) do 32 | FileUtils.rm_rf(work_path) if File.exist?(work_path) 33 | end 34 | 35 | after(:each) do 36 | FileUtils.rm_rf(work_path) 37 | end 38 | 39 | it "writes to a file" do 40 | buff = FileBuffer.new(log_path) 41 | lines = [] 42 | buff.open 43 | buff.write(exp1) 44 | buff.write(exp2) 45 | buff.close 46 | File.open(log_path).readlines.size.should == 2 47 | end 48 | end 49 | 50 | context "when draining" do 51 | work_path = "./rb-test-work3" 52 | log_path = "#{work_path}/buffer.log" 53 | exp1 = "the first message" 54 | exp2 = "the second message" 55 | 56 | before(:each) do 57 | FileUtils.rm_rf(work_path) if File.exist?(work_path) 58 | end 59 | 60 | after(:each) do 61 | FileUtils.rm_rf(work_path) 62 | end 63 | 64 | it "writes new sends to the memory buffer" do 65 | mem = [] 66 | buff = FileBuffer.new(log_path, :memory_buffer => mem, :initial_state => 1) 67 | buff.write(exp1) 68 | buff.write(exp2) 69 | buff.close 70 | mem.size.should == 2 71 | end 72 | 73 | it "raises data events for every line in the buffer" do 74 | lines = [] 75 | buff = FileBuffer.new(log_path) 76 | buff.on(:data) do |args| 77 | lines << args 78 | end 79 | buff.open 80 | buff.write(exp1) 81 | buff.write(exp2) do 82 | buff.drain 83 | end 84 | buff.close 85 | lines.size.should == 2 86 | end 87 | end 88 | end -------------------------------------------------------------------------------- /src/main/ruby/spec/rainbows.conf: -------------------------------------------------------------------------------- 1 | Rainbows! do 2 | use :EventMachine 3 | end -------------------------------------------------------------------------------- /src/main/ruby/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require 'bundler/setup' 3 | require 'rainbows' 4 | require 'faye/websocket' 5 | require 'json' 6 | Unicorn::Configurator::DEFAULTS[:logger] = Logger.new(StringIO.new) 7 | 8 | $:.unshift File.expand_path('../../lib', __FILE__) 9 | require File.expand_path('../../vendor/em-rspec/lib/em-rspec', __FILE__) 10 | 11 | class TestServer 12 | def call(env) 13 | socket = Faye::Hookup.new(env, ["echo"]) 14 | socket.onmessage = lambda do |event| 15 | puts "SERVER: #{event.data}" 16 | socket.send(event.data) 17 | end 18 | socket.rack_response 19 | end 20 | 21 | def listen(port) 22 | rackup = Unicorn::Configurator::RACKUP 23 | rackup[:port] = port 24 | rackup[:set_listener] = true 25 | options = rackup[:options] 26 | options[:config_file] = File.expand_path('../rainbows.conf', __FILE__) 27 | @server = Rainbows::HttpServer.new(self, options) 28 | @server.start 29 | end 30 | 31 | def restart 32 | begin 33 | @server.start 34 | rescue Exception => e 35 | puts e 36 | end 37 | end 38 | 39 | def stop 40 | begin 41 | @server.stop if @server 42 | rescue 43 | end 44 | end 45 | end 46 | 47 | require 'backchatio-hookup' 48 | include Backchat::Hookup -------------------------------------------------------------------------------- /src/main/ruby/spec/wire_format_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path("../spec_helper", __FILE__) 3 | 4 | describe Backchat::Hookup::WireFormat do 5 | 6 | text = "this is a text message" 7 | text_result = { "type" => "text", "content" => "this is a text message" } 8 | json_result = { "type" => 'json', "content" => { "data" => "a json message" }} 9 | json_data = {"data" => "a json message"} 10 | json = JSON.generate({"data" => "a json message"}) 11 | array_result = { "content" => [1, 2, 3, 4], "type" => "json" } 12 | array_data = [1,2,3,4] 13 | array_json = [1,2,3,4].to_json 14 | ack_result = { "id" => 3, "type" => "ack" } 15 | ack = JSON.generate(ack_result) 16 | ack_request_result = { "id" => 3, "type" => "ack_request", "content" => { "type" => "text", "content" => "this is a text message" }} 17 | ack_request_data = { "id" => 3, "type" => "ack_request", "content" => "this is a text message" } 18 | ack_request = JSON.generate({ "id" => 3, "type" => "ack_request", "content" => { "type" => "text", "content" => "this is a text message" }}) 19 | needs_ack_result = { "type" => "needs_ack", "timeout" => 5000 } 20 | needs_ack = JSON.generate({ "type" => "needs_ack", "timeout" => 5000 }) 21 | 22 | wire_format = WireFormat.new 23 | 24 | context 'parses a message from' do 25 | it "a text message" do 26 | wire_format.parse_message(text).should == text_result 27 | end 28 | it "a json message" do 29 | wire_format.parse_message(json).should == json_result 30 | end 31 | it "a json array" do 32 | wire_format.parse_message(array_json).should == array_result 33 | end 34 | it "an ack message" do 35 | wire_format.parse_message(ack).should == ack_result 36 | end 37 | it "an ack_request message" do 38 | wire_format.parse_message(ack_request).should == ack_request_result 39 | end 40 | it "a needs_ack message" do 41 | wire_format.parse_message(needs_ack).should == needs_ack_result 42 | end 43 | end 44 | 45 | context "builds messages" do 46 | it "a text message" do 47 | wire_format.build_message(text).should == text_result 48 | end 49 | it "a json message" do 50 | wire_format.build_message(json_data).should == json_result 51 | end 52 | it "a json array" do 53 | wire_format.build_message(array_data).should == array_result 54 | end 55 | it "an ack message" do 56 | wire_format.build_message(ack_result).should == ack_result 57 | end 58 | it "an ack_request message" do 59 | wire_format.build_message(ack_request_data).should == ack_request_result 60 | end 61 | end 62 | 63 | context "unwraps messages" do 64 | it "a text message" do 65 | wire_format.unwrap_content(text_result).should == text 66 | end 67 | it "a json message" do 68 | wire_format.unwrap_content(json_result).should == json_data 69 | end 70 | it "a json array" do 71 | wire_format.unwrap_content(array_result).should == array_data 72 | end 73 | it "an ack message" do 74 | wire_format.unwrap_content(ack_result).should == ack_result 75 | end 76 | it "an ack_request message" do 77 | wire_format.unwrap_content(ack_request_result).should == text 78 | end 79 | it "a needs_ack message" do 80 | wire_format.unwrap_content(needs_ack_result).should == needs_ack_result 81 | end 82 | end 83 | end -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/broadcast_channel.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | 3 | import scala.concurrent.Future 4 | 5 | /** 6 | * A broadcast channel represents a connection 7 | */ 8 | trait BroadcastChannel extends BroadcastChannelLike { 9 | /** 10 | * @return The connection id 11 | */ 12 | def id: Int 13 | } 14 | 15 | /** 16 | * Broadcast channel like is either a connection or a proxy for a connection. 17 | * It contains the methods for sending and disconnecting from a socket 18 | */ 19 | trait BroadcastChannelLike { 20 | 21 | /** 22 | * Send a message over the current connection 23 | * @param message A [[io.backchat.hookup.OutboundMessage]] message 24 | * @return An [[scala.concurrent.Future]] of [[io.backchat.hookup.OperationResult]] 25 | */ 26 | def send(message: OutboundMessage): Future[OperationResult] 27 | 28 | /** 29 | * Disconnect from the socket, perform closing handshake if necessary 30 | * @return An [[scala.concurrent.Future]] of [[io.backchat.hookup.OperationResult]] 31 | */ 32 | def disconnect(): Future[OperationResult] 33 | } 34 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/buffer.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | 3 | import java.io._ 4 | import java.util.concurrent.ConcurrentLinkedQueue 5 | import collection.mutable 6 | import scala.concurrent.{ Promise, ExecutionContext, Future } 7 | import org.json4s._ 8 | import collection.JavaConverters._ 9 | import java.util.Queue 10 | import collection.mutable.{ Queue ⇒ ScalaQueue } 11 | 12 | /** 13 | * Companion object for the [[io.backchat.hookup.FileBuffer]] 14 | */ 15 | object FileBuffer { 16 | private object State extends Enumeration { 17 | val Closed, Draining, Open = Value 18 | } 19 | } 20 | 21 | /** 22 | * Interface trait to which fallback mechanisms must adhere to 23 | * It implements [[java.io.Closeable]] so you can use it as a resource 24 | */ 25 | trait BackupBuffer extends Closeable { 26 | 27 | /** 28 | * open the buffer 29 | */ 30 | def open() 31 | 32 | /** 33 | * close the buffer, closing all external resources used by this buffer 34 | */ 35 | def close() 36 | 37 | /** 38 | * Write a line to the buffer 39 | * @param line A [[io.backchat.hookup.OutboundMessage]] 40 | */ 41 | def write(line: OutboundMessage)(implicit wireFormat: WireFormat) 42 | def drain(readLine: (OutboundMessage ⇒ Future[OperationResult]))(implicit executionContext: ExecutionContext, wireFormat: WireFormat): Future[OperationResult] 43 | } 44 | 45 | class MemoryBuffer(memoryBuffer: Queue[String] = new ConcurrentLinkedQueue[String]()) extends BackupBuffer { 46 | 47 | /** 48 | * open the buffer 49 | */ 50 | def open() {} 51 | 52 | /** 53 | * close the buffer, closing all external resources used by this buffer 54 | */ 55 | def close() {} 56 | 57 | def write(line: OutboundMessage)(implicit wireFormat: WireFormat) { 58 | memoryBuffer.offer(wireFormat.render(line)) 59 | } 60 | 61 | def drain(readLine: (OutboundMessage) => Future[OperationResult])(implicit executionContext: ExecutionContext, wireFormat: WireFormat) = { 62 | var futures = mutable.ListBuffer[Future[OperationResult]]() 63 | while (!memoryBuffer.isEmpty) { // and then the memory buffer 64 | val msg = memoryBuffer.poll() 65 | if (msg.nonBlank) 66 | futures += readLine(wireFormat.parseOutMessage(msg)) 67 | } 68 | if (futures.isEmpty) Promise.successful(Success).future else Future.sequence(futures.toList).map(ResultList(_)) 69 | } 70 | } 71 | // 72 | //abstract class BufferFactory(val id: String) { 73 | // def create(wireFormat: WireFormat): BackupBuffer 74 | //} 75 | // 76 | //class FileBufferFactory private[hookup] (file: File, writeToFile: Boolean, memoryBuffer: Queue[String]) extends BufferFactory("file_buffer") { 77 | // def this(file: File) = this(file, true, new ConcurrentLinkedQueue[String]()) 78 | // def create(wireFormat: WireFormat) = new FileBuffer(file, writeToFile, memoryBuffer)(wireFormat) 79 | //} 80 | 81 | /** 82 | * The default file buffer. 83 | * This is a file buffer that also has a memory buffer to which it writes when the file stream is 84 | * being read out or if a write to the file failed. 85 | * 86 | * This class has no maximum size or any limits attached to it yet. 87 | * So it is possible for this class to exhaust the memory and/or disk space of the machine using this buffer. 88 | * 89 | * @param file 90 | */ 91 | class FileBuffer private[hookup] (file: File, writeToFile: Boolean, memoryBuffer: Queue[String]) extends BackupBuffer { 92 | 93 | def this(file: File) = this(file, true, new ConcurrentLinkedQueue[String]()) 94 | 95 | import FileBuffer._ 96 | 97 | @volatile private[this] var output: PrintWriter = _ 98 | @volatile private[this] var state = State.Closed 99 | 100 | /** 101 | * Open this file buffer if not already opened. 102 | * This method is idempotent. 103 | */ 104 | def open() { if (state == State.Closed) openFile(true) } 105 | 106 | @inline private[this] def openFile(append: Boolean) { 107 | val dir = file.getAbsoluteFile.getParentFile 108 | if (!dir.exists()) dir.mkdirs() 109 | output = new PrintWriter(new BufferedOutputStream(new FileOutputStream(file, append)), true) 110 | state = if (writeToFile) State.Open else State.Draining 111 | } 112 | 113 | /** 114 | * Write a message to the buffer. 115 | * When the buffer is opened it will write a new line to the file 116 | * When the buffer is closed it will open the buffer and then write the new line. 117 | * When the buffer is being drained it will buffer to memory 118 | * When an exception is thrown it will first buffer the message to memory and then rethrow the exception 119 | * @param message A [[io.backchat.hookup.OutboundMessage]] 120 | */ 121 | def write(message: OutboundMessage)(implicit wireFormat: WireFormat): Unit = synchronized { 122 | val msg = wireFormat.render(message) 123 | try { 124 | state match { 125 | case State.Open ⇒ { 126 | output.println(msg) 127 | } 128 | case State.Closed ⇒ openFile(true); output.println(msg) 129 | case State.Draining ⇒ 130 | memoryBuffer.offer(msg) 131 | } 132 | } catch { 133 | case e: Throwable => 134 | memoryBuffer.offer(msg) 135 | throw e 136 | } 137 | 138 | } 139 | 140 | private[this] def serializeAndSave(message: OutboundMessage)(save: String ⇒ Unit)(implicit wireFormat: WireFormat) = { 141 | save(wireFormat.render(message)) 142 | } 143 | 144 | /** 145 | * Drain the buffer using the `readLine` function to process each message in the buffer. 146 | * This method works with [[scala.concurrent.Future]] objects and needs an [[scala.concurrent.ExecutionContext]] in scope 147 | * 148 | * @param readLine A function that takes a [[io.backchat.hookup.OutboundMessage]] and produces a [[scala.concurrent.Future]] of [[io.backchat.hookup.OperationResult]] 149 | * @param executionContext An [[scala.concurrent.ExecutionContext]] 150 | * @return A [[scala.concurrent.Future]] of [[io.backchat.hookup.OperationResult]] 151 | */ 152 | def drain(readLine: (OutboundMessage ⇒ Future[OperationResult]))(implicit executionContext: ExecutionContext, wireFormat: WireFormat): Future[OperationResult] = synchronized { 153 | var futures = mutable.ListBuffer[Future[OperationResult]]() 154 | state = State.Draining 155 | close() 156 | var input: BufferedReader = null 157 | var append = true 158 | try { 159 | if (file != null) { 160 | input = new BufferedReader(new FileReader(file)) 161 | var line = input.readLine() 162 | while (line != null) { // first drain the file buffer 163 | if (line.nonBlank) { 164 | futures += readLine(wireFormat.parseOutMessage(line)) 165 | } 166 | line = input.readLine() 167 | } 168 | } 169 | while (!memoryBuffer.isEmpty) { // and then the memory buffer 170 | val msg = memoryBuffer.poll() 171 | if (msg.nonBlank) 172 | futures += readLine(wireFormat.parseOutMessage(msg)) 173 | } 174 | val res = if (futures.isEmpty) Promise.successful(Success).future else Future.sequence(futures.toList).map(ResultList(_)) 175 | append = false 176 | res 177 | } catch { 178 | case e: Throwable ⇒ 179 | e.printStackTrace() 180 | Promise.failed(e).future 181 | } finally { 182 | if (input != null) { 183 | input.close() 184 | } 185 | openFile(append) 186 | } 187 | } 188 | 189 | /** 190 | * Closes the buffer and releases any external resources contained by this buffer. 191 | * This method is idempotent. 192 | */ 193 | def close() { 194 | if (state != State.Closed) { 195 | if (output != null) output.close() 196 | output = null 197 | state = State.Closed 198 | } 199 | } 200 | 201 | } 202 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/examples/ChatClient.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package examples 3 | 4 | import java.net.URI 5 | import org.json4s.{ DefaultFormats, Formats } 6 | import akka.actor.ActorSystem 7 | import scala.concurrent.duration._ 8 | import java.util.concurrent.atomic.AtomicInteger 9 | import java.io.File 10 | 11 | object DefaultConversions { 12 | implicit def stringToTextMessage(s: String) = TextMessage(s) 13 | } 14 | 15 | 16 | object ChatClient { 17 | 18 | val messageCounter = new AtomicInteger(0) 19 | 20 | import DefaultConversions._ 21 | //implicit def stringToTextMessage(s: String) = TextMessage(s) 22 | 23 | val system = ActorSystem("ChatClient") 24 | 25 | def makeClient(args: Array[String]) = { 26 | if (args.isEmpty) { 27 | sys.error("Specify a name as the argument") 28 | } 29 | // val system = ActorSystem("ChatClient") 30 | 31 | val client = new HookupClient { 32 | val uri = URI.create("ws://localhost:8127/") 33 | 34 | val settings: HookupClientConfig = HookupClientConfig( 35 | uri = uri, 36 | throttle = IndefiniteThrottle(5 seconds, 30 minutes), 37 | buffer = Some(new FileBuffer(new File("./work/buffer.log")))) 38 | 39 | def receive = { 40 | case TextMessage(text) ⇒ 41 | println(text) 42 | } 43 | 44 | connect() onSuccess { 45 | case _ ⇒ 46 | println("connected to: %s" format uri.toASCIIString) 47 | system.scheduler.schedule(2 seconds, 5 second) { 48 | send(args(0) + ": message " + messageCounter.incrementAndGet().toString) 49 | } 50 | } 51 | } 52 | client 53 | } 54 | 55 | def main(args: Array[String]) { makeClient(args) } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/examples/ChatServer.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package examples 3 | 4 | import org.json4s._ 5 | import org.json4s.jackson.JsonMethods._ 6 | 7 | object ChatServer { 8 | 9 | import DefaultConversions._ 10 | 11 | def makeServer() = { 12 | val server = HookupServer(ServerInfo("ChatServer", port = 8127)){ 13 | new HookupServerClient { 14 | def receive = { 15 | case Disconnected(_) ⇒ 16 | println("%s has left" format id) 17 | this >< "%s has left".format(id) 18 | case Connected ⇒ 19 | println("%s has joined" format id) 20 | broadcast("%s has joined" format id) 21 | case TextMessage(text) ⇒ 22 | println("broadcasting: " + text + " from " + id) 23 | this >< text 24 | case m: JsonMessage ⇒ 25 | println("JsonMessage(" + pretty(render(m.content)) + ")") 26 | } 27 | } 28 | } 29 | server.start 30 | server 31 | } 32 | 33 | def main(args: Array[String]) { makeServer } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/examples/PrintAllEventsClient.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package examples 3 | 4 | import org.json4s._ 5 | import java.util.concurrent.atomic.AtomicInteger 6 | import java.net.URI 7 | import scala.concurrent.duration._ 8 | import akka.actor.{ Cancellable, ActorSystem } 9 | import JsonDSL._ 10 | import java.io.File 11 | import org.json4s.jackson.JsonMethods._ 12 | 13 | object PrintAllEventsClient { 14 | import DefaultConversions._ 15 | 16 | val messageCounter = new AtomicInteger(0) 17 | val bufferedCounter = new AtomicInteger(0) 18 | 19 | def main(args: Array[String]) { 20 | 21 | val system = ActorSystem("PrintAllEventsClient") // the actor system is only for the scheduler in the example 22 | var timeout: Cancellable = null 23 | 24 | new HookupClient { 25 | val uri = URI.create("ws://localhost:8126/") 26 | 27 | val settings: HookupClientConfig = HookupClientConfig( 28 | uri, 29 | throttle = IndefiniteThrottle(5 seconds, 30 minutes), 30 | buffer = Some(new FileBuffer(new File("./work/buffer.log")))) 31 | 32 | def receive = { 33 | case Connected ⇒ println("Connected to the server") 34 | case Reconnecting ⇒ println("Reconnecting") 35 | case Disconnected(_) ⇒ println("disconnected from the server") 36 | case m @ Error(exOpt) ⇒ 37 | System.err.println("Received an error: " + m) 38 | exOpt foreach { _.printStackTrace(System.err) } 39 | case m: TextMessage ⇒ 40 | println("RECV: " + m) 41 | case m: JsonMessage ⇒ 42 | println("RECV: JsonMessage(" + pretty(render(m.content)) + ")") 43 | } 44 | 45 | connect() onSuccess { 46 | case _ ⇒ 47 | // At this point we're fully connected and the handshake has completed 48 | println("connected to: %s" format uri.toASCIIString) 49 | timeout = system.scheduler.schedule(0 seconds, 1 second) { 50 | if (isConnected) { // if we are still connected when this executes then just send a message to the socket 51 | val newCount = messageCounter.incrementAndGet() 52 | if (newCount <= 10) { 53 | if (newCount % 2 != 0) send("message " + newCount.toString) // send a text message 54 | else send((("data" -> ("message" -> newCount))): JValue) // send a json message 55 | } else { // if we reach 10 messages disconnect from the server 56 | println("Disconnecting after 10 messages") 57 | if (timeout != null) timeout.cancel() 58 | disconnect() onComplete { 59 | case _ ⇒ 60 | println("All resources have been closed") 61 | sys.exit() 62 | } 63 | } 64 | } else { 65 | // we've lost connection from the server so buffer messages and 66 | // employ a backoff strategy until the server comes back 67 | val newCount = bufferedCounter.incrementAndGet() 68 | if (newCount % 2 != 0) send("buffered message " + newCount.toString) // send a text message 69 | else send((("data" -> (("message" -> newCount) ~ ("extra" -> "buffered")))): JValue) // send a json message 70 | } 71 | } 72 | 73 | } 74 | } 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/examples/PrintAllEventsServer.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package examples 3 | 4 | import org.json4s._ 5 | import org.json4s.jackson.JsonMethods._ 6 | 7 | object PrintAllEventsServer { 8 | import DefaultConversions._ 9 | 10 | def main(args: Array[String]) { 11 | 12 | val server = HookupServer(8126) { 13 | /// code_ref: all_events 14 | new HookupServerClient { 15 | def receive = { 16 | case Connected ⇒ 17 | println("client connected") 18 | case Disconnected(_) ⇒ 19 | println("client disconnected") 20 | case m @ Error(exOpt) ⇒ 21 | System.err.println("Received an error: " + m) 22 | exOpt foreach { _.printStackTrace(System.err) } 23 | case m: TextMessage ⇒ 24 | println(m) 25 | send(m) 26 | case m: JsonMessage ⇒ 27 | println("JsonMessage(" + pretty(render(m.content)) + ")") 28 | send(m) 29 | } 30 | } 31 | /// end_code_ref 32 | } 33 | 34 | server onStart { 35 | println("Server is starting") 36 | } 37 | server onStop { 38 | println("Server is stopping") 39 | } 40 | server.start 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/examples/PrintingEchoClient.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package examples 3 | 4 | import java.net.URI 5 | import org.json4s.{ DefaultFormats, Formats } 6 | import akka.actor.ActorSystem 7 | import scala.concurrent.duration._ 8 | import java.util.concurrent.atomic.AtomicInteger 9 | import java.io.File 10 | 11 | object PrintingEchoClient { 12 | 13 | val messageCounter = new AtomicInteger(0) 14 | 15 | def main(args: Array[String]) { 16 | 17 | val system = ActorSystem("PrintingEchoClient") 18 | 19 | /// code_ref: default_client 20 | new HookupClient { 21 | val uri = URI.create("ws://localhost:8125/") 22 | 23 | val settings: HookupClientConfig = HookupClientConfig(uri = uri) 24 | 25 | def receive = { 26 | case TextMessage(text) ⇒ 27 | println("RECV: " + text) 28 | } 29 | 30 | connect() onSuccess { 31 | case _ ⇒ 32 | println("connected to: %s" format uri.toASCIIString) 33 | system.scheduler.schedule(0 seconds, 1 second) { 34 | send("message " + messageCounter.incrementAndGet().toString) 35 | } 36 | } 37 | } 38 | /// end_code_ref 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/examples/PrintingEchoServer.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package examples 3 | 4 | import org.json4s._ 5 | 6 | object PrintingEchoServer { 7 | import DefaultConversions._ 8 | 9 | def main(args: Array[String]) { 10 | /// code_ref: default_server 11 | val server = HookupServer(8125) { 12 | new HookupServerClient { 13 | def receive = { 14 | case TextMessage(text) ⇒ 15 | println(text) 16 | send(text) 17 | } 18 | } 19 | } 20 | 21 | server onStop { 22 | println("Server is stopped") 23 | } 24 | server onStart { 25 | println("Server is started") 26 | } 27 | server.start 28 | /// end_code_ref 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/examples/PubSubClient.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package examples 3 | 4 | import java.net.URI 5 | import scala.concurrent.duration._ 6 | import org.json4s._ 7 | import JsonDSL._ 8 | import org.json4s.jackson.JsonMethods._ 9 | import java.util.concurrent.atomic.AtomicInteger 10 | import akka.actor.ActorSystem 11 | 12 | object PubSubClient { 13 | 14 | private val system = ActorSystem("pubsubclient") 15 | 16 | class PubSubClient(name: String) extends HookupClient { 17 | val uri = URI.create("ws://localhost:8128/") 18 | val messageCounter = new AtomicInteger(0) 19 | 20 | val settings: HookupClientConfig = HookupClientConfig( 21 | uri = uri, 22 | throttle = IndefiniteThrottle(5 seconds, 30 minutes), 23 | buffer = None) 24 | 25 | def receive = { 26 | case Connected => 27 | send(List("subscribe", "topic.a"): JValue) 28 | case TextMessage(text) ⇒ 29 | println(text) 30 | case JsonMessage(JArray(JString("publish") :: data :: Nil)) => 31 | println("received pubsub message") 32 | println(pretty(render(data))) 33 | } 34 | 35 | connect() onSuccess { 36 | case _ ⇒ 37 | println("connected to: %s" format uri.toASCIIString) 38 | system.scheduler.schedule(2 seconds, 5 second) { 39 | send(List("publish", "topic.a", name + ": message " + messageCounter.incrementAndGet().toString): JValue) 40 | } 41 | } 42 | } 43 | 44 | def main(args: Array[String]) { 45 | new PubSubClient(args(0)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/examples/PubSubServer.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package examples 3 | 4 | 5 | import org.json4s._ 6 | import java.util.concurrent.{ConcurrentSkipListSet, ConcurrentHashMap} 7 | import collection.JavaConverters._ 8 | 9 | object PubSubServer { 10 | 11 | private val subscriptions = new ConcurrentHashMap[String, Set[HookupServerClient]]().asScala.withDefaultValue(Set.empty) 12 | 13 | 14 | def publish(topic: String, data: JValue) { 15 | subscriptions(topic) foreach { 16 | _ ! JArray(JString("publish") :: data :: Nil) 17 | } 18 | } 19 | 20 | def subscribe(topic: String, client: HookupServerClient) { 21 | subscriptions(topic) += client 22 | } 23 | 24 | def unsubscribe(topic: String, client: HookupServerClient) { 25 | subscriptions(topic) -= client 26 | } 27 | 28 | 29 | def main(args: Array[String]) { 30 | val server = HookupServer(ServerInfo("PubSubServer", port = 8128)) { 31 | new HookupServerClient { 32 | def receive = { 33 | case Disconnected(_) ⇒ 34 | subscriptions.keysIterator foreach { subscriptions(_) -= this } 35 | case Connected ⇒ 36 | println("client connected") 37 | case TextMessage(_) ⇒ 38 | this send "only json messages are allowed" 39 | case JsonMessage(JArray(JString(c) :: JString(topic) :: Nil)) if c.equalsIgnoreCase("subscribe") => 40 | subscribe(topic, this) 41 | case JsonMessage(JArray(JString(c) :: JString(topic) :: Nil)) if c.equalsIgnoreCase("unsubscribe") => 42 | unsubscribe(topic, this) 43 | case JsonMessage(JArray(JString(c) :: JString(topic) :: data :: Nil)) if c.equalsIgnoreCase("publish") => 44 | publish(topic, data) 45 | } 46 | } 47 | } 48 | server.start 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/CookieSet.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.http 2 | 3 | import org.jboss.netty.handler.codec.http.{HttpHeaders, HttpRequest} 4 | import org.jboss.netty.handler.codec.http.cookie.{Cookie, ServerCookieDecoder, ServerCookieEncoder} 5 | import scala.collection.mutable 6 | import scala.collection.JavaConverters._ 7 | 8 | 9 | /** 10 | * Adapt cookies of a Message to a mutable Set. Requests use the Cookie header and 11 | * Responses use the Set-Cookie header. If a cookie is added to the CookieSet, a 12 | * header is automatically added to the Message. If a cookie is removed from the 13 | * CookieSet, a header is automatically removed from the message. 14 | * 15 | * Note: This is a Set, not a Map, because we assume the caller should choose the 16 | * cookie based on name, domain, path, and possibly other attributes. 17 | */ 18 | class CookieSet(message: Message) extends 19 | mutable.SetLike[Cookie, mutable.Set[Cookie]] { 20 | 21 | def seq = Set.empty ++ iterator 22 | 23 | private[this] var _isValid = true 24 | 25 | private[this] val cookieHeaderName = 26 | if (message.isRequest) 27 | HttpHeaders.Names.COOKIE 28 | else 29 | HttpHeaders.Names.SET_COOKIE 30 | 31 | private[this] val cookies: mutable.Set[CookieWrapper] = { 32 | val decoder = new ServerCookieDecoder 33 | val res = Option(message.headers.get(cookieHeaderName)) map { cookieHeader => 34 | try { 35 | (decoder.decode(cookieHeader).asScala map { c => new CookieWrapper(c) }).toSet 36 | } catch { 37 | case e: IllegalArgumentException => 38 | _isValid = false 39 | Set.empty[CookieWrapper] 40 | } 41 | } 42 | mutable.Set[CookieWrapper]() ++ res.getOrElse(mutable.Set.empty) 43 | } 44 | 45 | /** Check if there was a parse error. Invalid cookies are ignored. */ 46 | def isValid = _isValid 47 | 48 | def +=(cookie: Cookie) = { 49 | cookies += new CookieWrapper(cookie) 50 | rewriteCookieHeaders() 51 | this 52 | } 53 | 54 | def -=(cookie: Cookie) = { 55 | cookies -= new CookieWrapper(cookie) 56 | rewriteCookieHeaders() 57 | this 58 | } 59 | 60 | def contains(cookie: Cookie) = 61 | cookies.contains(new CookieWrapper(cookie)) 62 | 63 | def iterator = cookies map { _.cookie } iterator 64 | 65 | def empty = mutable.Set[Cookie]() 66 | 67 | protected def rewriteCookieHeaders() { 68 | // Clear all cookies - there may be more than one with this name. 69 | message.headers.remove(cookieHeaderName) 70 | 71 | // Add cookies back again 72 | cookies foreach { cookie => 73 | val cookieEncoder = new ServerCookieEncoder() 74 | message.headers.add(cookieHeaderName, cookieEncoder.encode(cookie.cookie)) 75 | } 76 | } 77 | 78 | // Wrap Cookie to handle broken equals() 79 | protected[http] class CookieWrapper(val cookie: Cookie) { 80 | override def equals(obj: Any): Boolean = { 81 | obj match { 82 | case other: CookieWrapper => 83 | cookie.name == other.cookie.name && 84 | cookie.path == other.cookie.path && 85 | cookie.domain == other.cookie.domain 86 | case _ => 87 | throw new IllegalArgumentException // shouldn't happen 88 | } 89 | } 90 | 91 | override def hashCode() = cookie.hashCode 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/HeaderMap.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package http 3 | 4 | import org.jboss.netty.handler.codec.http.HttpMessage 5 | import scala.collection.JavaConversions._ 6 | import collection.{Map, mutable} 7 | 8 | 9 | /** 10 | * Adapt headers of an HttpMessage to a mutable Map. Header names 11 | * are case-insensitive. For example, get("accept") is the same as 12 | * get("Accept"). 13 | */ 14 | class HeaderMap(httpMessage: HttpMessage) 15 | extends mutable.MapLike[String, String, mutable.Map[String, String]] { 16 | 17 | 18 | def seq: Map[String, String] = Map.empty ++ iterator 19 | 20 | def get(key: String): Option[String] = 21 | Option(httpMessage.headers.get(key)) 22 | 23 | def getAll(key: String): Iterable[String] = 24 | httpMessage.headers.getAll(key) 25 | 26 | def iterator: Iterator[(String, String)] = 27 | httpMessage.headers.entries().toIterator map { entry => 28 | (entry.getKey, entry.getValue) 29 | } 30 | 31 | override def keys: Iterable[String] = 32 | httpMessage.headers().names() 33 | 34 | override def contains(key: String): Boolean = 35 | httpMessage.headers.contains(key) 36 | 37 | /** Add a header but don't replace existing header(s). */ 38 | def add(k: String, v: String) = { 39 | httpMessage.headers.add(k, v) 40 | this 41 | } 42 | 43 | /** Add a header and do replace existing header(s). */ 44 | def += (kv: (String, String)) = { 45 | httpMessage.headers.set(kv._1, kv._2) 46 | this 47 | } 48 | 49 | /** Remove header(s). */ 50 | def -= (key: String) = { 51 | httpMessage.headers.remove(key) 52 | this 53 | } 54 | 55 | def empty = mutable.Map[String, String]() 56 | } 57 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/HttpMessageProxy.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.http 2 | 3 | import java.util.{Set => JSet, Map => JMap, List => JList} 4 | import java.lang.{ Iterable => JIterable } 5 | import org.jboss.netty.handler.codec.http.{HttpVersion, HttpMessage} 6 | import org.jboss.netty.buffer.ChannelBuffer 7 | 8 | /** Proxy for HttpMessage. Used by Request and Response. */ 9 | trait HttpMessageProxy extends HttpMessage { 10 | def httpMessage: HttpMessage 11 | def getHttpMessage(): HttpMessage = httpMessage 12 | 13 | def getHeader(name: String): String = httpMessage.headers.get(name) 14 | def getHeaders(name: String): JList[String] = httpMessage.headers.getAll(name) 15 | def getHeaders(): JList[JMap.Entry[String, String]] = httpMessage.headers().entries() 16 | def containsHeader(name: String): Boolean = httpMessage.headers.contains(name) 17 | def getHeaderNames(): JSet[String] = httpMessage.headers.names() 18 | def addHeader(name: String, value: Object) { httpMessage.headers.add(name, value) } 19 | def setHeader(name: String, value: Object) { httpMessage.headers.set(name, value) } 20 | def setHeader(name: String, values: JIterable[_]) { httpMessage.headers.set(name, values) } 21 | def removeHeader(name: String) { httpMessage.headers.remove(name) } 22 | def clearHeaders() { httpMessage.headers.clear() } 23 | 24 | def getProtocolVersion(): HttpVersion = httpMessage.getProtocolVersion() 25 | def setProtocolVersion(version: HttpVersion) { httpMessage.setProtocolVersion(version) } 26 | 27 | def getContent(): ChannelBuffer = httpMessage.getContent() 28 | def setContent(content: ChannelBuffer) { httpMessage.setContent(content) } 29 | def isChunked: Boolean = httpMessage.isChunked() 30 | def setChunked(chunked: Boolean) { httpMessage.setChunked(chunked) } 31 | 32 | // @deprecated("deprecated in netty", "0.2.2") 33 | // def getContentLength(): Long = httpMessage.getContentLength() 34 | // @deprecated("deprecated in netty", "0.2.2") 35 | // def getContentLength(defaultValue: Long): Long = httpMessage.getContentLength(defaultValue) 36 | // @deprecated("deprecated in netty", "0.2.2") 37 | // def isKeepAlive: Boolean = httpMessage.isKeepAlive() 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/HttpRequestProxy.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.http 2 | 3 | import org.jboss.netty.handler.codec.http.{HttpMessage, HttpRequest, HttpMethod} 4 | 5 | 6 | /** Proxy for HttpRequest. Used by Request. */ 7 | trait HttpRequestProxy extends HttpRequest with HttpMessageProxy { 8 | def httpRequest: HttpRequest 9 | def getHttpRequest(): HttpRequest = httpRequest 10 | def httpMessage: HttpMessage = httpRequest 11 | 12 | def getMethod(): HttpMethod = httpRequest.getMethod 13 | def setMethod(method: HttpMethod) { httpRequest.setMethod(method) } 14 | def getUri(): String = httpRequest.getUri() 15 | def setUri(uri: String) { httpRequest.setUri(uri) } 16 | } 17 | 18 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/HttpResponseProxy.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.http 2 | 3 | import org.jboss.netty.handler.codec.http.{HttpMessage, HttpResponse, HttpResponseStatus} 4 | 5 | 6 | /** Proxy for HttpResponse. Used by Response. */ 7 | trait HttpResponseProxy extends HttpResponse with HttpMessageProxy { 8 | def httpResponse: HttpResponse 9 | def getHttpResponse(): HttpResponse = httpResponse 10 | def httpMessage: HttpMessage = httpResponse 11 | 12 | def getStatus(): HttpResponseStatus = httpResponse.getStatus() 13 | def setStatus(status: HttpResponseStatus) { httpResponse.setStatus(status) } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/MediaType.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.http 2 | 3 | object MediaType { 4 | // Common media types 5 | val Atom = "application/atom+xml" 6 | val Html = "text/html" 7 | val Javascript = "application/javascript" 8 | val Json = "application/json" 9 | val OctetStream = "application/octet-stream" 10 | val Rss = "application/rss+xml" 11 | val WwwForm = "application/x-www-form-urlencoded" 12 | val Xml = "application/xml" 13 | } 14 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/Method.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.http 2 | 3 | import org.jboss.netty.handler.codec.http.HttpMethod 4 | 5 | /** Scala aliases for HttpMethod. Java users should use Netty's HttpMethod. */ 6 | object Method { 7 | val Get = HttpMethod.GET 8 | val Post = HttpMethod.POST 9 | val Put = HttpMethod.PUT 10 | val Head = HttpMethod.HEAD 11 | val Patch = HttpMethod.PATCH 12 | val Delete = HttpMethod.DELETE 13 | val Trace = HttpMethod.TRACE 14 | val Connect = HttpMethod.CONNECT 15 | } 16 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/ParamMap.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.http 2 | 3 | import java.nio.charset.Charset 4 | import java.util.{List => JList, Map => JMap} 5 | import org.jboss.netty.handler.codec.http.{QueryStringDecoder, QueryStringEncoder} 6 | import scala.collection.MapLike 7 | import scala.collection.JavaConversions._ 8 | 9 | 10 | /** 11 | * Adapt params of a Request to a mutable Map. Handle parameters in the 12 | * URL and form encoded body. Multipart forms are not supported (not 13 | * needed, could be abusive). 14 | */ 15 | class ParamMap(val request: Request) 16 | extends MapLike[String, String, Map[String, String]] { 17 | 18 | def seq = Map.empty ++ iterator 19 | 20 | private[this] var _isValid = true 21 | 22 | private[this] val getParams: JMap[String, JList[String]] = 23 | parseParams(request.uri) 24 | 25 | private[this] val postParams: JMap[String, JList[String]] = 26 | if (request.method == Method.Post && 27 | request.mediaType == Some(MediaType.WwwForm) && 28 | request.length > 0) 29 | parseParams("?" + request.contentString) 30 | else 31 | // Most requests won't have body - don't bother creating an object for 32 | // the lifetime of the request. 33 | null 34 | 35 | // Convert IllegalArgumentException to ParamMapException so it can be handled 36 | // appropriately (e.g., 400 Bad Request). 37 | private[this] def parseParams(s: String): JMap[String, JList[String]] = 38 | try 39 | new QueryStringDecoder(s).getParameters 40 | catch { 41 | case e: IllegalArgumentException => 42 | _isValid = false 43 | null 44 | } 45 | 46 | /** 47 | * Check if there was a parse error. On a parse error, the parameters 48 | * are treated as empty (versus throwing a parse exception). 49 | */ 50 | def isValid = _isValid 51 | 52 | /** Get value */ 53 | def get(name: String): Option[String] = 54 | jget(postParams, name) match { 55 | case None => jget(getParams, name) 56 | case value => value 57 | } 58 | 59 | /* Equivalent to get(name).getOrElse(default). */ 60 | def getOrElse(name: String, default: String): String = 61 | get(name).getOrElse(default) 62 | 63 | /** Get Short value. Uses forgiving StringUtil.toSomeShort to parse. */ 64 | def getShort(name: String): Option[Short] = 65 | get(name) map { StringUtil.toSomeShort(_) } 66 | 67 | /** Get Short value or default. Equivalent to getShort(name).getOrElse(default). */ 68 | def getShortOrElse(name: String, default: Short): Short = 69 | getShort(name) getOrElse default 70 | 71 | /** Get Int value. Uses forgiving StringUtil.toSomeInt to parse. */ 72 | def getInt(name: String): Option[Int] = 73 | get(name) map { StringUtil.toSomeInt(_) } 74 | 75 | /** Get Int value or default. Equivalent to getInt(name).getOrElse(default). */ 76 | def getIntOrElse(name: String, default: Int): Int = 77 | getInt(name) getOrElse default 78 | 79 | /** Get Long value. Uses forgiving StringUtil.toLong to parse. */ 80 | def getLong(name: String): Option[Long] = 81 | get(name) map { StringUtil.toSomeLong(_) } 82 | 83 | /** Get Long value or default. Equivalent to getLong(name).getOrElse(default). */ 84 | def getLongOrElse(name: String, default: Long): Long = 85 | getLong(name) getOrElse default 86 | 87 | /** Get Boolean value. True is "1" or "true", false is all other values. */ 88 | def getBoolean(name: String): Option[Boolean] = 89 | get(name) map { _.toLowerCase } map { v => v == "1" || v == "t" || v == "true" } 90 | 91 | /** Get Boolean value or default. Equivalent to getBoolean(name).getOrElse(default). */ 92 | def getBooleanOrElse(name: String, default: Boolean): Boolean = 93 | getBoolean(name) getOrElse default 94 | 95 | def getAll(name: String): Iterable[String] = 96 | jgetAll(postParams, name) ++ jgetAll(getParams, name) 97 | 98 | def iterator: Iterator[(String, String)] = 99 | jiterator(postParams) ++ jiterator(getParams) 100 | 101 | def +[B >: String](kv: (String, B)): Map[String, B] = 102 | Map.empty ++ iterator + kv 103 | 104 | def -(name: String): Map[String, String] = 105 | Map.empty ++ iterator - name 106 | 107 | def empty = Map.empty[String, String] 108 | 109 | override def toString = { 110 | val encoder = new QueryStringEncoder("", Charset.forName("utf-8")) 111 | iterator foreach { case (k, v) => 112 | encoder.addParam(k, v) 113 | } 114 | encoder.toString 115 | } 116 | 117 | // Get value from JMap, which might be null 118 | private def jget(params: JMap[String, JList[String]], name: String): Option[String] = { 119 | if (params != null) { 120 | Option(params.get(name)) flatMap { _.headOption } 121 | } else { 122 | None 123 | } 124 | } 125 | 126 | // Get values from JMap, which might be null 127 | private def jgetAll(params: JMap[String, JList[String]], name: String): Iterable[String] = { 128 | if (params != null) { 129 | Option(params.get(name)) map { _.toList } getOrElse Nil 130 | } else { 131 | Nil 132 | } 133 | } 134 | 135 | // Get iterable for JMap, which might be null 136 | private def jiterator(params: JMap[String, JList[String]]): Iterator[(String, String)] = { 137 | if (params != null) { 138 | params.entrySet flatMap { entry => 139 | entry.getValue.toList map { value => 140 | (entry.getKey, value) 141 | } 142 | } toIterator 143 | } else { 144 | Iterator.empty 145 | } 146 | } 147 | } 148 | 149 | 150 | object ParamMap { 151 | /** Create ParamMap from parameter list. Convenience method for testing. */ 152 | def apply(params: Tuple2[String, String]*): ParamMap = 153 | new ParamMap(Request(params:_*)) 154 | } 155 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/Path.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.http 2 | 3 | import org.jboss.netty.handler.codec.http.HttpMethod 4 | import annotation.switch 5 | 6 | /** Base class for path extractors. */ 7 | abstract class Path { 8 | def /(child: String) = new /(this, child) 9 | def :?(params: ParamMap) = new :?(this, params) 10 | def toList: List[String] 11 | def parent: Path 12 | def lastOption: Option[String] 13 | def startsWith(other: Path): Boolean 14 | } 15 | 16 | 17 | object Path { 18 | def apply(str: String): Path = 19 | if (str == "" || str == "/") 20 | Root 21 | else if (!str.startsWith("/")) 22 | Path("/" + str) 23 | else { 24 | val slash = str.lastIndexOf('/') 25 | val prefix = Path(str.substring(0, slash)) 26 | if (slash == str.length - 1) 27 | prefix 28 | else 29 | prefix / str.substring(slash + 1) 30 | } 31 | 32 | def apply(first: String, rest: String*): Path = 33 | rest.foldLeft(Root / first)( _ / _) 34 | 35 | def apply(list: List[String]): Path = list.foldLeft(Root: Path)(_ / _) 36 | 37 | def unapplySeq(path: Path) = Some(path.toList) 38 | } 39 | 40 | 41 | case class :?(val path: Path, params: ParamMap) { 42 | override def toString = params.toString 43 | } 44 | 45 | 46 | /** File extension extractor */ 47 | object ~ { 48 | /** 49 | * File extension extractor for Path: 50 | * Path("example.json") match { 51 | * case Root / "example" ~ "json" => ... 52 | */ 53 | def unapply(path: Path): Option[(Path, String)] = { 54 | path match { 55 | case Root => None 56 | case parent / last => 57 | unapply(last) map { 58 | case (base, ext) => (parent / base, ext) 59 | } 60 | } 61 | } 62 | 63 | /** 64 | * File extension matcher for String: 65 | * "example.json" match { 66 | * case => "example" ~ "json" => ... 67 | */ 68 | def unapply(fileName: String): Option[(String, String)] = { 69 | fileName.lastIndexOf('.') match { 70 | case -1 => Some((fileName, "")) 71 | case index => Some((fileName.substring(0, index), fileName.substring(index + 1))) 72 | } 73 | } 74 | } 75 | 76 | /** HttpMethod extractor */ 77 | object -> { 78 | /** 79 | * HttpMethod extractor: 80 | * (request.method, Path(request.path)) match { 81 | * case Methd.Get -> Root / "test.json" => ... 82 | */ 83 | def unapply(x: (HttpMethod, Path)) = Some(x) 84 | } 85 | 86 | 87 | case class /(parent: Path, child: String) extends Path { 88 | lazy val toList: List[String] = parent.toList ++ List(child) 89 | def lastOption: Option[String] = Some(child) 90 | lazy val asString = parent.toString + "/" + child 91 | override def toString = asString 92 | def startsWith(other: Path) = { 93 | val components = other.toList 94 | (toList take components.length) == components 95 | } 96 | } 97 | 98 | 99 | /** 100 | * Root extractor: 101 | * Path("/") match { 102 | * case Root => ... 103 | * } 104 | */ 105 | case object Root extends Path { 106 | def toList: List[String] = Nil 107 | def parent = this 108 | def lastOption: Option[String] = None 109 | override def toString = "" 110 | def startsWith(other: Path) = other == Root 111 | } 112 | 113 | 114 | /** 115 | * Path separator extractor: 116 | * Path("/1/2/3/test.json") match { 117 | * case Root / "1" / "2" / "3" / "test.json" => ... 118 | */ 119 | object /: { 120 | def unapply(path: Path): Option[(String, Path)] = { 121 | path.toList match { 122 | case Nil => None 123 | case head :: tail => Some((head, Path(tail))) 124 | } 125 | } 126 | } 127 | 128 | 129 | // Base class for Integer and Long extractors. 130 | protected class Numeric[A <: AnyVal](cast: String => A) { 131 | def unapply(str: String): Option[A] = { 132 | if (!str.isEmpty && str.forall(Character.isDigit _)) 133 | try { 134 | Some(cast(str)) 135 | } catch { 136 | case _: NumberFormatException => 137 | None 138 | } 139 | else 140 | None 141 | } 142 | } 143 | 144 | /** 145 | * Integer extractor: 146 | * Path("/user/123") match { 147 | * case Root / "user" / Int(userId) => ... 148 | */ 149 | object Integer extends Numeric(_.toInt) 150 | 151 | /** 152 | * Long extractor: 153 | * Path("/user/123") match { 154 | * case Root / "user" / Long(userId) => ... 155 | */ 156 | object Long extends Numeric(_.toLong) 157 | 158 | 159 | 160 | /** 161 | * Multiple param extractor: 162 | * object A extends ParamMatcher("a") 163 | * object B extends ParamMatcher("b") 164 | * (Path(request.path) :? request.params) match { 165 | * case Root / "user" :? A(a) :& B(b) => ... 166 | */ 167 | object :& { 168 | def unapply(params: ParamMap) = Some((params, params)) 169 | } 170 | 171 | 172 | /** 173 | * Param extractor: 174 | * object ScreenName extends ParamMatcher("screen_name") 175 | * (Path(request.path) :? request.params) match { 176 | * case Root / "user" :? ScreenName(screenName) => ... 177 | */ 178 | abstract class ParamMatcher(name: String) { 179 | def unapply(params: ParamMap) = params.get(name) 180 | } 181 | 182 | 183 | /** 184 | * Int param extractor: 185 | * object Page extends IntParamMatcher("page") 186 | * (Path(request.path) :? request.params) match { 187 | * case Root / "blog" :? Page(page) => ... 188 | */ 189 | abstract class IntParamMatcher(name: String) { 190 | def unapply(params: ParamMap): Option[Int] = 191 | params.get(name) flatMap { value => 192 | try { 193 | Some(value.toInt) 194 | } catch { 195 | case ex: NumberFormatException => 196 | None 197 | } 198 | } 199 | } 200 | 201 | 202 | /** 203 | * Long param extractor: 204 | * object UserId extends LongParamMatcher("user_id") 205 | * (Path(request.path) :? request.params) match { 206 | * case Root / "user" :? UserId(userId) => ... 207 | */ 208 | abstract class LongParamMatcher(name: String) { 209 | def unapply(params: ParamMap): Option[Long] = 210 | params.get(name) flatMap { value => 211 | try { 212 | Some(value.toLong) 213 | } catch { 214 | case ex: NumberFormatException => 215 | None 216 | } 217 | } 218 | } 219 | 220 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/ProxyCredentials.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package http 3 | 4 | import scala.collection.JavaConversions._ 5 | import org.jboss.netty.handler.codec.base64.Base64 6 | import org.jboss.netty.buffer.ChannelBuffers 7 | 8 | object ProxyCredentials { 9 | def apply(credentials: java.util.Map[String, String]): Option[ProxyCredentials] = 10 | apply(credentials.toMap) 11 | 12 | def apply(credentials: Map[String, String]): Option[ProxyCredentials] = { 13 | for { 14 | user <- credentials.get("http_proxy_user") 15 | pass <- credentials.get("http_proxy_pass") 16 | } yield { 17 | ProxyCredentials(user, pass) 18 | } 19 | } 20 | } 21 | 22 | case class ProxyCredentials(username: String, password: String) { 23 | lazy val basicAuthorization = { 24 | val bytes = "%s:%s".format(username, password).getBytes 25 | "Basic " + Base64.decode(ChannelBuffers.wrappedBuffer(bytes)).toString(Utf8) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/Request.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package http 3 | 4 | import java.net.{InetAddress, InetSocketAddress} 5 | import java.util.{AbstractMap, List => JList, Map => JMap, Set => JSet} 6 | import org.jboss.netty.channel.Channel 7 | import org.jboss.netty.handler.codec.http._ 8 | import scala.collection.JavaConversions._ 9 | import beans.BeanProperty 10 | 11 | 12 | /** 13 | * Rich HttpRequest. 14 | * 15 | * Use RequestProxy to created an even richer subclass. 16 | */ 17 | abstract class Request extends Message with HttpRequestProxy { 18 | 19 | def isRequest = true 20 | 21 | lazy val params = new ParamMap(this) 22 | 23 | def method: HttpMethod = getMethod 24 | def method_=(method: HttpMethod) = setMethod(method) 25 | def uri: String = getUri() 26 | def uri_=(uri: String) { setUri(uri) } 27 | 28 | /** Path from URI. */ 29 | @BeanProperty 30 | def path: String = { 31 | val u = getUri 32 | u.indexOf('?') match { 33 | case -1 => u 34 | case n => u.substring(0, n) 35 | } 36 | } 37 | 38 | /** File extension. Empty string if none. */ 39 | @BeanProperty 40 | def fileExtension: String = { 41 | val p = path 42 | p.lastIndexOf('.') match { 43 | case -1 => "" 44 | case n => p.substring(n + 1).toLowerCase 45 | } 46 | } 47 | 48 | /** Remote InetSocketAddress */ 49 | @BeanProperty 50 | def remoteSocketAddress: InetSocketAddress 51 | 52 | /** Remote host - a dotted quad */ 53 | @BeanProperty 54 | def remoteHost: String = 55 | remoteAddress.getHostAddress 56 | 57 | /** Remote InetAddress */ 58 | @BeanProperty 59 | def remoteAddress: InetAddress = 60 | remoteSocketAddress.getAddress 61 | 62 | /** Remote port */ 63 | @BeanProperty 64 | def remotePort: Int = 65 | remoteSocketAddress.getPort 66 | 67 | // The get*Param methods below are for Java compatibility. Note Scala default 68 | // arguments aren't compatible with Java, so we need two versions of each. 69 | 70 | /** Get parameter value. Returns value or null. */ 71 | def getParam(name: String): String = 72 | params.get(name).orNull 73 | 74 | /** Get parameter value. Returns value or default. */ 75 | def getParam(name: String, default: String): String = 76 | params.get(name).getOrElse(default) 77 | 78 | /** Get Short param. Returns value or 0. */ 79 | def getShortParam(name: String): Short = 80 | params.getShortOrElse(name, 0) 81 | 82 | /** Get Short param. Returns value or default. */ 83 | def getShortParam(name: String, default: Short): Short = 84 | params.getShortOrElse(name, default) 85 | 86 | /** Get Int param. Returns value or 0. */ 87 | def getIntParam(name: String): Int = 88 | params.getIntOrElse(name, 0) 89 | 90 | /** Get Int param. Returns value or default. */ 91 | def getIntParam(name: String, default: Int): Int = 92 | params.getIntOrElse(name, default) 93 | 94 | /** Get Long param. Returns value or 0. */ 95 | def getLongParam(name: String): Long = 96 | params.getLongOrElse(name, 0L) 97 | 98 | /** Get Long param. Returns value or default. */ 99 | def getLongParam(name: String, default: Long=0L): Long = 100 | params.getLongOrElse(name, default) 101 | 102 | /** Get Boolean param. Returns value or false. */ 103 | def getBooleanParam(name: String): Boolean = 104 | params.getBooleanOrElse(name, false) 105 | 106 | /** Get Boolean param. Returns value or default. */ 107 | def getBooleanParam(name: String, default: Boolean): Boolean = 108 | params.getBooleanOrElse(name, default) 109 | 110 | /** Get all values of parameter. Returns list of values. */ 111 | def getParams(name: String): JList[String] = 112 | params.getAll(name).toList 113 | 114 | /** Get all parameters. */ 115 | def getParams(): JList[JMap.Entry[String, String]] = 116 | (params.toList.map { case (k, v) => new AbstractMap.SimpleImmutableEntry(k, v) }) 117 | 118 | /** Check if parameter exists. */ 119 | def containsParam(name: String): Boolean = 120 | params.contains(name) 121 | 122 | /** Get parameters names. */ 123 | def getParamNames(): JSet[String] = 124 | params.keySet 125 | 126 | /** Response associated with request */ 127 | lazy val response: Response = Response(this) 128 | 129 | /** Get response associated with request. */ 130 | def getResponse(): Response = response 131 | 132 | override def toString = 133 | "Request(\"" + method + " " + uri + "\", from " + remoteSocketAddress + ")" 134 | } 135 | 136 | 137 | object Request { 138 | 139 | /** Create Request from parameters. Convenience method for testing. */ 140 | def apply(params: Tuple2[String, String]*): MockRequest = 141 | apply("/", params:_*) 142 | 143 | /** Create Request from URI and parameters. Convenience method for testing. */ 144 | def apply(uri: String, params: Tuple2[String, String]*): MockRequest = { 145 | val encoder = new QueryStringEncoder(uri) 146 | params.foreach { case (key, value) => 147 | encoder.addParam(key, value) 148 | } 149 | apply(Method.Get, encoder.toString) 150 | } 151 | 152 | /** Create Request from URI string. Convenience method for testing. */ 153 | def apply(uri: String): MockRequest = 154 | apply(Method.Get, uri) 155 | 156 | /** Create Request from method and URI string. Convenience method for testing. */ 157 | def apply(method: HttpMethod, uri: String): MockRequest = 158 | apply(Version.Http11, method, uri) 159 | 160 | /** Create Request from version, method, and URI string. Convenience method for testing. */ 161 | def apply(version: HttpVersion, method: HttpMethod, uri: String): MockRequest = 162 | apply(new DefaultHttpRequest(version, method, uri)) 163 | 164 | /** Create Request from HttpRequest. */ 165 | def apply(httpRequestArg: HttpRequest): MockRequest = { 166 | new MockRequest { 167 | override lazy val httpRequest = httpRequestArg 168 | override val httpMessage = httpRequestArg 169 | override lazy val remoteSocketAddress = new InetSocketAddress("127.0.0.1", 12345) 170 | } 171 | } 172 | 173 | /** Create Request from HttpRequest and Channel. Used by Codec. */ 174 | def apply(httpRequestArg: HttpRequest, channel: Channel): Request = 175 | new Request { 176 | val httpRequest = httpRequestArg 177 | override val httpMessage = httpRequestArg 178 | lazy val remoteSocketAddress = channel.getRemoteAddress.asInstanceOf[InetSocketAddress] 179 | 180 | override def headers(): HttpHeaders = httpRequest.headers() 181 | } 182 | 183 | // for testing 184 | protected class MockRequest extends Request { 185 | lazy val httpRequest: HttpRequest = new DefaultHttpRequest(Version.Http11, Method.Get, "/") 186 | override val httpMessage: HttpMessage = httpRequest 187 | lazy val remoteSocketAddress = new InetSocketAddress("127.0.0.1", 12345) 188 | override def headers(): HttpHeaders = httpRequest.headers() 189 | 190 | // Create a MockRequest with a specific IP 191 | def withIp(ip: String) = 192 | new MockRequest { 193 | override lazy val httpRequest = MockRequest.this 194 | override final val httpMessage = MockRequest.this 195 | override lazy val remoteSocketAddress = new InetSocketAddress(ip, 12345) 196 | } 197 | 198 | // Create an internal MockRequest 199 | def internal = withIp("10.0.0.1") 200 | 201 | // Create an external MockRequest 202 | def external = withIp("8.8.8.8") 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/RequestProxy.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package http 3 | 4 | 5 | /** 6 | * Proxy for Request. This can be used to create a richer request class 7 | * that wraps Request. 8 | */ 9 | abstract class RequestProxy extends Request { 10 | def request: Request 11 | def getRequest(): Request = request 12 | 13 | override def httpRequest = request 14 | override def httpMessage = request 15 | 16 | override lazy val params = request.params 17 | def remoteSocketAddress = request.remoteSocketAddress 18 | } 19 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/Response.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.http 2 | 3 | import org.jboss.netty.handler.codec.http._ 4 | 5 | 6 | /** 7 | * Rich HttpResponse 8 | */ 9 | abstract class Response extends Message with HttpResponseProxy { 10 | 11 | def isRequest = false 12 | 13 | def status: HttpResponseStatus = getStatus 14 | def status_=(value: HttpResponseStatus) { setStatus(value) } 15 | def statusCode: Int = getStatus.getCode 16 | def statusCode_=(value: Int) { setStatus(HttpResponseStatus.valueOf(value)) } 17 | 18 | def getStatusCode(): Int = statusCode 19 | def setStatusCode(value: Int) { statusCode = value } 20 | 21 | override def toString = 22 | "Response(\"" + version + " " + status + ")" 23 | } 24 | 25 | 26 | class MockResponse extends Response { 27 | val httpResponse = new DefaultHttpResponse(Version.Http11, Status.Ok) 28 | 29 | override def headers(): HttpHeaders = httpResponse.headers() 30 | } 31 | 32 | 33 | object Response { 34 | 35 | /** Create Response. Convenience method for testing. */ 36 | def apply(): Response = 37 | apply(Version.Http11, Status.Ok) 38 | 39 | /** Create Response from version and status. Convenience method for testing. */ 40 | def apply(version: HttpVersion, status: HttpResponseStatus): Response = 41 | apply(new DefaultHttpResponse(version, status)) 42 | 43 | /** Create Response from HttpResponse. */ 44 | def apply(httpResponseArg: HttpResponse): Response = 45 | new Response { 46 | final val httpResponse = httpResponseArg 47 | 48 | override def headers(): HttpHeaders = httpResponse.headers() 49 | } 50 | 51 | /** Create Response from HttpRequest. */ 52 | def apply(httpRequest: HttpRequest): Response = 53 | new Response { 54 | final val httpResponse = 55 | new DefaultHttpResponse(httpRequest.getProtocolVersion, Status.Ok) 56 | 57 | override def headers(): HttpHeaders = httpResponse.headers() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/Status.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.http 2 | 3 | import org.jboss.netty.handler.codec.http.HttpResponseStatus 4 | 5 | /** Scala aliases for HttpResponseStatus. Java users should use Netty's HttpResponseStatus. */ 6 | object Status { 7 | val Continue = HttpResponseStatus.CONTINUE 8 | val Processing = HttpResponseStatus.PROCESSING 9 | val Ok = HttpResponseStatus.OK 10 | val Created = HttpResponseStatus.CREATED 11 | val Accepted = HttpResponseStatus.ACCEPTED 12 | val NonAuthoritativeInformation = HttpResponseStatus.NON_AUTHORITATIVE_INFORMATION 13 | val NoContent = HttpResponseStatus.NO_CONTENT 14 | val ResetContent = HttpResponseStatus.RESET_CONTENT 15 | val PartialContent = HttpResponseStatus.PARTIAL_CONTENT 16 | val MultiStatus = HttpResponseStatus.MULTI_STATUS 17 | val MultipleChoices = HttpResponseStatus.MULTIPLE_CHOICES 18 | val MovedPermanently = HttpResponseStatus.MOVED_PERMANENTLY 19 | val Found = HttpResponseStatus.FOUND 20 | val SeeOther = HttpResponseStatus.SEE_OTHER 21 | val NotModified = HttpResponseStatus.NOT_MODIFIED 22 | val UseProxy = HttpResponseStatus.USE_PROXY 23 | val TemporaryRedirect = HttpResponseStatus.TEMPORARY_REDIRECT 24 | val BadRequest = HttpResponseStatus.BAD_REQUEST 25 | val Unauthorized = HttpResponseStatus.UNAUTHORIZED 26 | val PaymentRequired = HttpResponseStatus.PAYMENT_REQUIRED 27 | val Forbidden = HttpResponseStatus.FORBIDDEN 28 | val NotFound = HttpResponseStatus.NOT_FOUND 29 | val MethodNotAllowed = HttpResponseStatus.METHOD_NOT_ALLOWED 30 | val NotAcceptable = HttpResponseStatus.NOT_ACCEPTABLE 31 | val ProxyAuthenticationRequired = HttpResponseStatus.PROXY_AUTHENTICATION_REQUIRED 32 | val RequestTimeout = HttpResponseStatus.REQUEST_TIMEOUT 33 | val Conflict = HttpResponseStatus.CONFLICT 34 | val Gone = HttpResponseStatus.GONE 35 | val LengthRequired = HttpResponseStatus.LENGTH_REQUIRED 36 | val PreconditionFailed = HttpResponseStatus.PRECONDITION_FAILED 37 | val RequestEntityTooLarge = HttpResponseStatus.REQUEST_ENTITY_TOO_LARGE 38 | val RequestUriTooLong = HttpResponseStatus.REQUEST_URI_TOO_LONG 39 | val UnsupportedMediaType = HttpResponseStatus.UNSUPPORTED_MEDIA_TYPE 40 | val RequestedRangeNotSatisfiable = HttpResponseStatus.REQUESTED_RANGE_NOT_SATISFIABLE 41 | val ExpectationFailed = HttpResponseStatus.EXPECTATION_FAILED 42 | val UnprocessableEntity = HttpResponseStatus.UNPROCESSABLE_ENTITY 43 | val Locked = HttpResponseStatus.LOCKED 44 | val FailedDependency = HttpResponseStatus.FAILED_DEPENDENCY 45 | val UnorderedCollection = HttpResponseStatus.UNORDERED_COLLECTION 46 | val UpgradeRequired = HttpResponseStatus.UPGRADE_REQUIRED 47 | val InternalServerError = HttpResponseStatus.INTERNAL_SERVER_ERROR 48 | val NotImplemented = HttpResponseStatus.NOT_IMPLEMENTED 49 | val BadGateway = HttpResponseStatus.BAD_GATEWAY 50 | val ServiceUnavailable = HttpResponseStatus.SERVICE_UNAVAILABLE 51 | val GatewayTimeout = HttpResponseStatus.GATEWAY_TIMEOUT 52 | val HttpVersionNotSupported = HttpResponseStatus.HTTP_VERSION_NOT_SUPPORTED 53 | val VariantAlsoNegotiates = HttpResponseStatus.VARIANT_ALSO_NEGOTIATES 54 | val InsufficientStorage = HttpResponseStatus.INSUFFICIENT_STORAGE 55 | val NotExtended = HttpResponseStatus.NOT_EXTENDED 56 | val SwitchingProtocols = HttpResponseStatus.SWITCHING_PROTOCOLS 57 | } 58 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/StringUtil.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.http 2 | 3 | object StringUtil { 4 | 5 | private val SomeIntRegex = """\A\s*(-?\d+).*\Z""".r 6 | 7 | /** 8 | * Convert s to a Int liberally: initial whitespace and zeros are 9 | * skipped, non-digits after the number are ignored, and the default is 0. 10 | */ 11 | def toSomeShort(s: String): Short = { 12 | SomeIntRegex.findFirstMatchIn(s) match { 13 | case Some(sMatch) => 14 | try { 15 | sMatch.group(1).toShort 16 | } catch { 17 | case e: NumberFormatException => 0 18 | } 19 | case None => 20 | 0 21 | } 22 | } 23 | 24 | /** 25 | * Convert s to an Int liberally: initial whitespace and zeros are 26 | * skipped, non-digits after the number are ignored, and the default is 0. 27 | */ 28 | def toSomeInt(s: String): Int = { 29 | SomeIntRegex.findFirstMatchIn(s) match { 30 | case Some(sMatch) => 31 | try { 32 | sMatch.group(1).toInt 33 | } catch { 34 | case e: NumberFormatException => 0 35 | } 36 | case None => 37 | 0 38 | } 39 | } 40 | 41 | /** 42 | * Convert s to a Long liberally: initial whitespace and zeros are 43 | * skipped, non-digits after the number are ignored, and the default is 0L. 44 | */ 45 | def toSomeLong(s: String): Long = { 46 | SomeIntRegex.findFirstMatchIn(s) match { 47 | case Some(sMatch) => 48 | try { 49 | sMatch.group(1).toLong 50 | } catch { 51 | case e: NumberFormatException => 0L 52 | } 53 | case None => 54 | 0L 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/http/Version.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.http 2 | 3 | import org.jboss.netty.handler.codec.http.HttpVersion 4 | 5 | /** Scala aliases for HttpVersion. Java users should use Netty's HttpVersion */ 6 | object Version { 7 | val Http10 = HttpVersion.HTTP_1_0 8 | val Http11 = HttpVersion.HTTP_1_1 9 | } 10 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/messages.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | 3 | import scala.concurrent.duration._ 4 | import org.json4s._ 5 | import scala.concurrent.duration.Duration 6 | 7 | /** 8 | * A marker trait for inbound messages 9 | */ 10 | sealed trait InboundMessage 11 | 12 | /** 13 | * A marker trait for outbound messages 14 | */ 15 | sealed trait OutboundMessage 16 | 17 | /** 18 | * Adds acking support to a message 19 | * This can only be included in a websocket out message 20 | */ 21 | trait Ackable { self: OutboundMessage ⇒ 22 | 23 | /** 24 | * Request that this message will be acked upon receipt by the server. 25 | * 26 | * @param within An [[scala.concurrent.duration.Duration]] representing the timeout for the ack 27 | * @return A [[io.backchat.hookup.OutboundMessage]] with this message wrapped in a [[io.backchat.hookup.NeedsAck]] envelope 28 | */ 29 | def needsAck(within: Duration = 1 second): OutboundMessage = NeedsAck(this, within) 30 | } 31 | 32 | /** 33 | * A base trait for creating messages of different content types 34 | * @tparam T The type of content this protocol message represents 35 | */ 36 | trait ProtocolMessage[T] extends InboundMessage with OutboundMessage with Ackable { 37 | def content: T 38 | } 39 | 40 | /** 41 | * A callback event signaling that the connection has been fully established. 42 | * This means that any handshakes have been completed successfully too. 43 | * 44 | * When you receive this callback message you can be sure there is someone on the other end. 45 | */ 46 | case object Connected extends InboundMessage 47 | 48 | /** 49 | * A callback event signaling that the connection to the server has been broken and the client 50 | * is trying to reconnect. Every reconnect attempt fires this message. 51 | * 52 | * Typically you don't need to do anything when this happens, if you use a backoff like 53 | * [[io.backchat.hookup.IndefiniteThrottle]] then the client does the reconnection bit automatically, it's only then 54 | * that you can expect these events. 55 | */ 56 | case object Reconnecting extends InboundMessage 57 | 58 | /** 59 | * A message representing a json object sent to/received from a remote party. 60 | * 61 | * @param content A [[org.json4s.JValue]] object 62 | */ 63 | case class JsonMessage(content: JValue) extends ProtocolMessage[JValue] 64 | 65 | /** 66 | * A message representing a text object sent to/received from a remote party. 67 | * 68 | * @param content A [[scala.Predef.String]] representing the content of the message 69 | */ 70 | case class TextMessage(content: String) extends ProtocolMessage[String] 71 | 72 | /** 73 | * A message representing an array of bytes sent to/received from a remote party. 74 | * 75 | * @param content An Array of Bytes representing the content of the message 76 | */ 77 | case class BinaryMessage(content: Array[Byte]) extends ProtocolMessage[Array[Byte]] 78 | 79 | /** 80 | * A message envelope to request acking for an outbound message 81 | * 82 | * @param message The [[io.backchat.hookup.Ackable]] message to be acknowledged 83 | * @param timeout An [[scala.concurrent.duration.Duration]] specifying the timeout for the operation 84 | */ 85 | private[hookup] case class NeedsAck(message: Ackable, timeout: Duration = 1 second) extends OutboundMessage 86 | 87 | /** 88 | * An Inbound message for an ack operation, this is an implementation detail and not visible to the library user 89 | * 90 | * @param message The [[io.backchat.hookup.Ackable]] message to be acknowledged 91 | * @param id The id of the ack operation 92 | */ 93 | private[hookup] case class AckRequest(message: Ackable, id: Long) extends InboundMessage 94 | 95 | /** 96 | * A callback event signaling failure of an ack request. 97 | * This is not handled automatically and you have to decide what you want to do with the message, 98 | * you could send it again, send it somewhere else, drop it ... 99 | * 100 | * @param message An [[io.backchat.hookup.OutboundMessage]] outbound message 101 | */ 102 | case class AckFailed(message: OutboundMessage) extends InboundMessage 103 | 104 | private[hookup] case class Ack(id: Long) extends InboundMessage with OutboundMessage 105 | private[hookup] case class SelectedWireFormat(wireFormat: WireFormat) extends InboundMessage 106 | 107 | /** 108 | * A callback event signaling that an error has occurred. if the error was an exception thrown 109 | * then the cause object will be filled in. 110 | * 111 | * @param cause A [[scala.Option]] of [[java.lang.Throwable]] 112 | */ 113 | case class Error(cause: Option[Throwable]) extends InboundMessage 114 | 115 | /** 116 | * A callback event signaling that the connection has ended, if the cause was an exception thrown 117 | * then the cause object will be filled in. 118 | * 119 | * @param cause A [[scala.Option]] of [[java.lang.Throwable]] 120 | */ 121 | case class Disconnected(cause: Option[Throwable]) extends InboundMessage 122 | 123 | private[hookup] case object Disconnect extends OutboundMessage 124 | 125 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/netty_handlers.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | 3 | class ScalaUpstreamHandler extends { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/operation_result.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | 3 | import beans.BeanProperty 4 | import collection.JavaConverters._ 5 | 6 | /** 7 | * The interface to represent a result from an operation 8 | * These results can indicate Success, Cancellation or a sequence of operation results 9 | */ 10 | sealed trait OperationResult { 11 | /** 12 | * Flag for the java api to indicate success 13 | * @return A boolean indicating success 14 | */ 15 | @BeanProperty 16 | def isSuccess: Boolean = false 17 | 18 | /** 19 | * Flag for the java api to indicate cancellation 20 | * @return A boolean indication cancellation 21 | */ 22 | @BeanProperty 23 | def isCancelled: Boolean = false 24 | 25 | /** 26 | * A list of child operation results. 27 | * @return 28 | */ 29 | @BeanProperty 30 | def children: java.util.List[OperationResult] = List[OperationResult]().asJava 31 | } 32 | 33 | /** 34 | * An object indicating a success result 35 | */ 36 | case object Success extends OperationResult { 37 | 38 | /** 39 | * Flag for the java api to indicate success 40 | * @return A boolean indicating success, always returns true here 41 | */ 42 | @BeanProperty 43 | override def isSuccess: Boolean = true 44 | } 45 | 46 | /** 47 | * An object indicating a failure result 48 | */ 49 | case object Cancelled extends OperationResult { 50 | 51 | /** 52 | * Flag for the java api to indicate cancellation 53 | * @return A boolean indication cancellation, always returns true here 54 | */ 55 | @BeanProperty 56 | override def isCancelled: Boolean = true 57 | } 58 | 59 | /** 60 | * A list of operation results, contains some aggregate helper methods in addition to a populated list of children 61 | * @param results a [[scala.List]] of [[io.backchat.hookup.OperationResult]] objects 62 | */ 63 | case class ResultList(results: List[OperationResult]) extends OperationResult { 64 | 65 | /** 66 | * Flag for the java api to indicate success, returns true when the list is empty or all the 67 | * elements in the list are [[io.backchat.hookup.Success]] objects. 68 | * @return A boolean indicating success 69 | */ 70 | @BeanProperty 71 | override def isSuccess = results.forall(_.isSuccess) 72 | 73 | /** 74 | * Flag for the java api to indicate cancellation, returns true when the list is not empty and at least 75 | * one of the elements in the list is a [[io.backchat.hookup.Cancelled]] object. 76 | * @return A boolean indicating cancellation 77 | */ 78 | @BeanProperty 79 | override def isCancelled = results.nonEmpty && results.exists(_.isCancelled) 80 | 81 | /** 82 | * A list of child operation results. 83 | * @return a [[java.util.List]] of [[io.backchat.hookup.OperationResult]] objects 84 | */ 85 | @BeanProperty 86 | override def children: java.util.List[OperationResult] = results.asJava 87 | 88 | /** 89 | * A list of child cancellation results. 90 | * @return a [[java.util.List]] of [[io.backchat.hookup.Cancelled]] objects 91 | */ 92 | @BeanProperty 93 | def cancellations: java.util.List[OperationResult] = (results filter (_.isCancelled)).asJava 94 | 95 | /** 96 | * A list of child success results. 97 | * @return a [[java.util.List]] of [[io.backchat.hookup.Success]] objects 98 | */ 99 | @BeanProperty 100 | def sucesses: java.util.List[OperationResult] = (results filter (_.isSuccess)).asJava 101 | } 102 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/package.scala: -------------------------------------------------------------------------------- 1 | package io.backchat 2 | 3 | import scala.concurrent.{ ExecutionContext, Promise, Future } 4 | import org.jboss.netty.channel.{ Channel, ChannelFutureListener, ChannelFuture } 5 | import org.json4s._ 6 | import scala.concurrent.duration.Duration 7 | import java.util.concurrent.TimeUnit 8 | import org.jboss.netty.logging.{Slf4JLoggerFactory, InternalLoggerFactory} 9 | import beans.BeanProperty 10 | import org.joda.time.DateTimeZone 11 | import org.joda.time.format.DateTimeFormat 12 | import org.jboss.netty.util.CharsetUtil 13 | 14 | /** 15 | * The package object for the library. 16 | * This contains some implicit conversions to smooth the api over and allow for a single import 17 | * at the top of a file to get the api working. 18 | */ 19 | package object hookup { 20 | 21 | 22 | 23 | val HttpDateGMT = "GMT" 24 | val HttpDateTimeZone = DateTimeZone.forID(HttpDateGMT) 25 | val HttpDateFormat = DateTimeFormat.forPattern("EEE, dd MMM yyyy HH:mm:ss zzz").withZone(HttpDateTimeZone) 26 | val HttpCacheSeconds = 60 27 | 28 | private[hookup] val Utf8 = CharsetUtil.UTF_8 29 | 30 | /// code_ref: default_protocols 31 | val SimpleJson = new SimpleJsonWireFormat()(DefaultFormats) 32 | val JsonProtocol = new JsonProtocolWireFormat()(DefaultFormats) 33 | /** 34 | * The default protocols hookup understands 35 | */ 36 | @BeanProperty 37 | val DefaultProtocols = Seq(SimpleJson, JsonProtocol) 38 | /// end_code_ref 39 | 40 | /** 41 | * The default protocol for the hookup server to use. By default this a simple json protocol that doesn't support 42 | * any advanced features but just sends json messages back and forth 43 | */ 44 | @BeanProperty 45 | var DefaultProtocol = "simpleJson" 46 | 47 | private[hookup] implicit def string2richerString(s: String) = new { 48 | def blankOption = if (isBlank) None else Some(s) 49 | def isBlank = s == null || s.trim.isEmpty 50 | def nonBlank = !isBlank 51 | } 52 | 53 | private[hookup] implicit def option2richerOption[T](opt: Option[T]) = new { 54 | def `|`(other: ⇒ T): T = opt getOrElse other 55 | } 56 | 57 | private[hookup] implicit def richerDuration(duration: Duration) = new { 58 | def doubled = Duration(duration.toMillis * 2, TimeUnit.MILLISECONDS) 59 | 60 | def max(upperBound: Duration) = if (duration > upperBound) upperBound else duration 61 | } 62 | 63 | /** 64 | * An implicit conversion from a predicate function that takes a [[io.backchat.hookup.BroadcastChannel]] to 65 | * a [[io.backchat.hookup.HookupServer.BroadcastFilter]] 66 | * 67 | * @param fn The predicate function to convert to a filter 68 | * @return A [[io.backchat.hookup.HookupServer.BroadcastFilter]] 69 | */ 70 | implicit def fn2BroadcastFilter(fn: BroadcastChannel ⇒ Boolean): HookupServer.BroadcastFilter = { 71 | new HookupServer.BroadcastFilter { 72 | def apply(v1: BroadcastChannel) = fn(v1) 73 | } 74 | } 75 | 76 | /** 77 | * An implicit conversion from a [[org.jboss.netty.channel.ChannelFuture]] to an [[scala.concurrent.Future]] 78 | * @param fut The [[org.jboss.netty.channel.ChannelFuture]] 79 | * @return A [[scala.concurrent.Future]] 80 | */ 81 | implicit def channelFutureToAkkaFuture(fut: ChannelFuture) = new { 82 | 83 | def toAkkaFuture(implicit context: ExecutionContext): Promise[OperationResult] = { 84 | val res = Promise[OperationResult]() 85 | fut.addListener(new ChannelFutureListener { 86 | def operationComplete(future: ChannelFuture) { 87 | if (future.isSuccess) { 88 | res.success(Success) 89 | } else if (fut.isCancelled) { 90 | res.success(Cancelled) 91 | } else { 92 | res.failure(future.getCause) 93 | } 94 | } 95 | }) 96 | res 97 | } 98 | } 99 | 100 | /** 101 | * Implicit conversion from a json4s jvalue to a [[io.backchat.hookup.JsonMessage]] 102 | * 103 | * @param content The string content of the message 104 | * @return A [[io.backchat.hookup.JsonMessage]] 105 | */ 106 | implicit def jvalue2JsonMessage(content: JValue): OutboundMessage with Ackable = JsonMessage(content) 107 | 108 | /** 109 | * Type forwarder for a websocket server client 110 | */ 111 | type HookupServerClient = HookupServer.HookupServerClient 112 | 113 | private[hookup] implicit def nettyChannel2BroadcastChannel(ch: Channel)(implicit executionContext: ExecutionContext): BroadcastChannel = 114 | new { val id: Int = ch.getId } with BroadcastChannel { 115 | def send(msg: OutboundMessage) = ch.write(msg).toAkkaFuture.future 116 | def disconnect() = ch.close().toAkkaFuture.future 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/server/DropUnhandledRequests.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup.server 2 | 3 | import org.jboss.netty.channel.{ChannelFutureListener, MessageEvent, ChannelHandlerContext, SimpleChannelUpstreamHandler} 4 | import org.jboss.netty.handler.codec.http.{DefaultHttpResponse, HttpResponse, HttpResponseStatus, HttpRequest} 5 | import io.backchat.hookup.http.Status 6 | import org.jboss.netty.handler.codec.http.HttpVersion._ 7 | import org.jboss.netty.handler.codec.http.HttpHeaders.Names._ 8 | import org.jboss.netty.buffer.ChannelBuffers 9 | import org.jboss.netty.util.CharsetUtil 10 | 11 | class DropUnhandledRequests extends SimpleChannelUpstreamHandler { 12 | 13 | override def messageReceived(ctx: ChannelHandlerContext, e: MessageEvent) = e.getMessage match { 14 | case p: HttpRequest ⇒ sendError(ctx, Status.NotFound) 15 | case _ ⇒ ctx.sendUpstream(e) 16 | } 17 | 18 | protected def sendError(ctx: ChannelHandlerContext, status: HttpResponseStatus) { 19 | val response: HttpResponse = new DefaultHttpResponse(HTTP_1_1, status) 20 | response.headers.set(CONTENT_TYPE, "text/plain; charset=UTF-8") 21 | response.setContent(ChannelBuffers.copiedBuffer("Failure: "+status.toString+"\r\n", CharsetUtil.UTF_8)) 22 | ctx.getChannel.write(response).addListener(ChannelFutureListener.CLOSE) 23 | } 24 | } -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/server/FavIcoHandler.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package server 3 | 4 | import java.io.File 5 | import org.jboss.netty.handler.codec.http.{DefaultHttpResponse, HttpResponse, HttpRequest} 6 | import org.jboss.netty.handler.codec.http.HttpHeaders.Names._ 7 | import org.jboss.netty.buffer.ChannelBuffers 8 | import org.jboss.netty.channel.{ChannelFutureListener, MessageEvent, ChannelHandlerContext, SimpleChannelUpstreamHandler} 9 | import http.{Version, Status} 10 | 11 | /** 12 | * An unfinished implementation of a favico handler. 13 | * currently always responds with 404. 14 | * 15 | * @param favico the file that is the favico. 16 | */ 17 | class Favico(favico: Option[File] = None) extends SimpleChannelUpstreamHandler { 18 | override def messageReceived(ctx: ChannelHandlerContext, e: MessageEvent) { 19 | e.getMessage match { 20 | case r: HttpRequest if r.getUri.toLowerCase.startsWith("/favicon.ico") => 21 | if (favico.isDefined && favico.forall(_.exists())) { 22 | StaticFileHandler.serveFile(ctx, r, favico.get) 23 | } else { 24 | val status = Status.NotFound 25 | val response: HttpResponse = new DefaultHttpResponse(Version.Http11, status) 26 | response.headers.set(CONTENT_TYPE, "text/plain; charset=UTF-8") 27 | response.setContent(ChannelBuffers.copiedBuffer("Failure: "+status.toString+"\r\n", Utf8)) 28 | ctx.getChannel.write(response).addListener(ChannelFutureListener.CLOSE) 29 | } 30 | 31 | case _ => ctx.sendUpstream(e) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/server/FlashPolicyHandler.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package server 3 | 4 | import org.jboss.netty.handler.codec.frame.FrameDecoder 5 | import org.jboss.netty.channel.{ChannelPipeline, ChannelFutureListener, Channel, ChannelHandlerContext} 6 | import org.jboss.netty.buffer.{ChannelBuffers, ChannelBuffer} 7 | 8 | object FlashPolicyHandler { 9 | val PolicyXml = 10 | val AllowAllPolicy = ChannelBuffers.copiedBuffer(PolicyXml.toString(), Utf8) 11 | } 12 | 13 | /** 14 | * A flash policy handler for netty. This needs to be included in the pipeline before anything else has touched 15 | * the message. 16 | * 17 | * @see [[https://github.com/cgbystrom/netty-tools/blob/master/src/main/java/se/cgbystrom/netty/FlashPolicyHandler.java]] 18 | * @param policyResponse The response xml to send for a request 19 | */ 20 | class FlashPolicyHandler(policyResponse: ChannelBuffer = FlashPolicyHandler.AllowAllPolicy) extends FrameDecoder { 21 | 22 | def decode(ctx: ChannelHandlerContext, channel: Channel, buffer: ChannelBuffer) = { 23 | if (buffer.readableBytes > 1) { 24 | 25 | val magic1 = buffer.getUnsignedByte(buffer.readerIndex()); 26 | val magic2 = buffer.getUnsignedByte(buffer.readerIndex() + 1); 27 | val isFlashPolicyRequest = (magic1 == '<' && magic2 == 'p'); 28 | 29 | if (isFlashPolicyRequest) { 30 | // Discard everything 31 | buffer.skipBytes(buffer.readableBytes()) 32 | 33 | // Make sure we don't have any downstream handlers interfering with our injected write of policy request. 34 | removeAllPipelineHandlers(channel.getPipeline) 35 | channel.write(policyResponse).addListener(ChannelFutureListener.CLOSE) 36 | null 37 | } else { 38 | 39 | // Remove ourselves, important since the byte length check at top can hinder frame decoding 40 | // down the pipeline 41 | ctx.getPipeline.remove(this) 42 | buffer.readBytes(buffer.readableBytes()) 43 | } 44 | } else null 45 | } 46 | 47 | private def removeAllPipelineHandlers(pipe: ChannelPipeline) { 48 | while (pipe.getFirst != null) { 49 | pipe.removeFirst(); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/server/LoadBalancerPingHandler.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package server 3 | 4 | import org.jboss.netty.handler.codec.http.{DefaultHttpResponse, HttpRequest} 5 | import org.jboss.netty.handler.codec.http.HttpVersion._ 6 | import org.jboss.netty.handler.codec.http.HttpResponseStatus._ 7 | import org.jboss.netty.buffer.ChannelBuffers 8 | import org.jboss.netty.handler.codec.http.HttpHeaders.Names 9 | import org.joda.time.DateTime 10 | import org.jboss.netty.channel.{ChannelFutureListener, MessageEvent, ChannelHandlerContext, SimpleChannelUpstreamHandler} 11 | 12 | /** 13 | * A http request handler that responses to `ping` requests with the word `pong` for the specified path 14 | * 15 | * @param path The path for the ping endpoint 16 | */ 17 | class LoadBalancerPing(path: String) extends SimpleChannelUpstreamHandler { 18 | override def messageReceived(ctx: ChannelHandlerContext, e: MessageEvent) { 19 | e.getMessage match { 20 | case r: HttpRequest if r.getUri.toLowerCase.startsWith(path) => 21 | val res = new DefaultHttpResponse(HTTP_1_1, OK) 22 | val content = ChannelBuffers.copiedBuffer("pong", Utf8) 23 | res.headers.set(Names.CONTENT_TYPE, "text/plain; charset=utf-8") 24 | res.headers.set(Names.EXPIRES, new DateTime().toString(HttpDateFormat)) 25 | res.headers.set(Names.CACHE_CONTROL, "no-cache, must-revalidate") 26 | res.headers.set(Names.PRAGMA, "no-cache") 27 | 28 | res.setContent(content) 29 | ctx.getChannel.write(res).addListener(ChannelFutureListener.CLOSE) 30 | case _ => ctx.sendUpstream(e) 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/server/StaticFileHandler.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package server 3 | 4 | import org.jboss.netty.handler.codec.http._ 5 | import org.jboss.netty.handler.ssl.SslHandler 6 | import org.jboss.netty.handler.stream.ChunkedFile 7 | import org.jboss.netty.channel._ 8 | import org.jboss.netty.handler.codec.frame.TooLongFrameException 9 | import org.jboss.netty.buffer.ChannelBuffers 10 | import org.jboss.netty.util.CharsetUtil 11 | import java.io.{UnsupportedEncodingException, FileNotFoundException, RandomAccessFile, File} 12 | import java.net.URLDecoder 13 | import javax.activation.MimetypesFileTypeMap 14 | import org.joda.time.DateTime 15 | import http.{Status, Version} 16 | 17 | object StaticFileHandler { 18 | // Many people would put a cache in but to me that is just a horrifying thought. 19 | // There is zero-copy so you don't incur a cost and you're not storing those 20 | // 2GB files in memory either. Either you store the bytes in the JVM which bloats them a bit 21 | // or you keep them on a store that is made to store files: the disk. 22 | def serveFile(ctx: ChannelHandlerContext, request: HttpRequest, file: File, contentType: Option[String] = None) = { 23 | try { 24 | val raf = new RandomAccessFile(file, "r") 25 | val length = raf.length() 26 | val resp = new DefaultHttpResponse(Version.Http11, Status.Ok) 27 | setDateHeader(resp) 28 | setCacheHeaders(resp, file, contentType) 29 | val ch = ctx.getChannel 30 | ch.write(resp) 31 | val future = if (ch.getPipeline.get(classOf[SslHandler]) != null) { 32 | ch.write(new ChunkedFile(raf, 0, length, 8192)) 33 | } else { 34 | // no ssl, zero-copy is a go 35 | val region = new DefaultFileRegion(raf.getChannel, 0, length) 36 | val fut = ch.write(region) 37 | fut.addListener(new ChannelFutureProgressListener { 38 | def operationProgressed(future: ChannelFuture, amount: Long, current: Long, total: Long) { 39 | printf("%s: %d / %d (+%d)%n", file.getPath, current, total, amount) 40 | } 41 | 42 | def operationComplete(future: ChannelFuture) { 43 | region.releaseExternalResources() 44 | } 45 | }) 46 | fut 47 | } 48 | 49 | if (!HttpHeaders.isKeepAlive(request)) future.addListener(ChannelFutureListener.CLOSE) 50 | } catch { 51 | case _: FileNotFoundException => sendError(ctx, HttpResponseStatus.NOT_FOUND) 52 | } 53 | } 54 | 55 | private val mimes = new MimetypesFileTypeMap(getClass.getResourceAsStream("/mime.types")) 56 | 57 | private def setDateHeader(response: HttpResponse) { 58 | response.headers.set(HttpHeaders.Names.DATE, (new DateTime).toString(HttpDateFormat)) 59 | } 60 | 61 | private def setCacheHeaders(response: HttpResponse, fileToCache: File, contentType: Option[String]) { 62 | response.headers.set(HttpHeaders.Names.CONTENT_TYPE, contentType getOrElse mimes.getContentType(fileToCache)) 63 | response.headers.set(HttpHeaders.Names.EXPIRES, (new DateTime).toString(HttpDateFormat)) 64 | response.headers.set(HttpHeaders.Names.CACHE_CONTROL, "private, max-age=" + HttpCacheSeconds) 65 | response.headers.set(HttpHeaders.Names.LAST_MODIFIED, new DateTime(fileToCache.lastModified()).toString(HttpDateFormat)) 66 | HttpHeaders.setContentLength(response, fileToCache.length()) 67 | } 68 | 69 | def sendError(ctx: ChannelHandlerContext, status: HttpResponseStatus) { 70 | val response = new DefaultHttpResponse(Version.Http11, status) 71 | response.headers.set(HttpHeaders.Names.CONTENT_TYPE, "text/plain; charset=UTF-8") 72 | response.setContent(ChannelBuffers.copiedBuffer("Failure: " + status.toString + "\r\n", Utf8 )) 73 | 74 | // Close the connection as soon as the error message is sent. 75 | ctx.getChannel.write(response).addListener(ChannelFutureListener.CLOSE) 76 | } 77 | 78 | def sendNotModified(ctx: ChannelHandlerContext) { 79 | val response = new DefaultHttpResponse(Version.Http11, Status.NotModified) 80 | setDateHeader(response) 81 | 82 | // Close the connection as soon as the error message is sent. 83 | ctx.getChannel.write(response).addListener(ChannelFutureListener.CLOSE) 84 | } 85 | } 86 | 87 | class StaticFileHandler(publicDirectory: String) extends SimpleChannelUpstreamHandler { 88 | 89 | private def isModified(request: HttpRequest, file: File) = { 90 | val ifModifiedSince = request.headers.get(HttpHeaders.Names.IF_MODIFIED_SINCE) 91 | if (ifModifiedSince != null && ifModifiedSince.trim.nonEmpty) { 92 | val date = HttpDateFormat.parseDateTime(ifModifiedSince) 93 | val ifModifiedDateSeconds = date.getMillis / 1000 94 | val fileLastModifiedSeconds = file.lastModified() / 1000 95 | ifModifiedDateSeconds == fileLastModifiedSeconds 96 | } else false 97 | } 98 | 99 | @throws(classOf[Exception]) 100 | override def messageReceived(ctx: ChannelHandlerContext, e: MessageEvent) { 101 | e.getMessage match { 102 | case request: HttpRequest if request.getMethod != HttpMethod.GET => 103 | StaticFileHandler.sendError(ctx, Status.MethodNotAllowed) 104 | case request: HttpRequest => { 105 | val path = sanitizeUri(request.getUri) 106 | if (path == null) { 107 | StaticFileHandler.sendError(ctx, Status.Forbidden) 108 | } else { 109 | val file = new File(path) 110 | if (file.isHidden || !file.exists()) StaticFileHandler.sendError(ctx, Status.NotFound) 111 | else if (!file.isFile) StaticFileHandler.sendError(ctx, Status.Forbidden) 112 | else { 113 | if (isModified(request, file)) { 114 | StaticFileHandler.sendNotModified(ctx) 115 | } else { 116 | StaticFileHandler.serveFile(ctx, request, file) 117 | } 118 | } 119 | } 120 | } 121 | } 122 | } 123 | 124 | @throws(classOf[Exception]) 125 | override def exceptionCaught(ctx: ChannelHandlerContext, e: ExceptionEvent) { 126 | e.getCause match { 127 | case ex: TooLongFrameException => StaticFileHandler.sendError(ctx, Status.BadRequest) 128 | case ex => 129 | ex.printStackTrace() 130 | if (e.getChannel.isConnected) StaticFileHandler.sendError(ctx, Status.InternalServerError) 131 | } 132 | } 133 | 134 | private def sanitizeUri(uri: String) = { 135 | // Decode the path. 136 | val decoded = try { 137 | URLDecoder.decode(uri, CharsetUtil.UTF_8.displayName()) 138 | } catch { 139 | case _: UnsupportedEncodingException => 140 | URLDecoder.decode(uri, CharsetUtil.ISO_8859_1.displayName()) 141 | } 142 | 143 | // Convert file separators. 144 | val converted = decoded.replace('/', File.separatorChar) 145 | 146 | val uf = new File(sys.props("user.dir") + File.separatorChar + publicDirectory).getAbsolutePath 147 | val pth = uf + File.separatorChar + converted 148 | val f = new File(pth) 149 | val absPath = f.getAbsolutePath 150 | if (!(absPath startsWith uf)) null 151 | else absPath 152 | } 153 | 154 | 155 | } -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/throttles.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | 3 | import scala.concurrent.duration.Duration 4 | import scala.concurrent.duration._ 5 | 6 | /** 7 | * The interface for an immutable backoff schedule 8 | */ 9 | trait Throttle { 10 | /** 11 | * The delay to wait until the next operation can occur 12 | * 13 | * @return A [[scala.concurrent.duration.Duration]] 14 | */ 15 | def delay: Duration 16 | 17 | /** 18 | * The maximum value the delay can have 19 | * 20 | * @return A [[scala.concurrent.duration.Duration]] 21 | */ 22 | def maxWait: Duration 23 | 24 | /** 25 | * Calculate the next delay and return a new throttle 26 | * @return A new [[io.backchat.hookup.Throttle]] 27 | */ 28 | def next(): Throttle 29 | } 30 | 31 | /** 32 | * A Null object representing no backing off at all 33 | */ 34 | case object NoThrottle extends Throttle { 35 | 36 | /** 37 | * The delay is always 0 38 | */ 39 | val delay = 0.millis 40 | 41 | /** 42 | * The max wait is always 0 43 | */ 44 | val maxWait = 0.millis 45 | 46 | /** 47 | * Always returns a [[io.backchat.hookup.NoThrottle]] 48 | * @return A [[io.backchat.hookup.NoThrottle]] 49 | */ 50 | def next(): Throttle = NoThrottle 51 | } 52 | 53 | /** 54 | * Represents a back off strategy that will retry forever when the maximum wait has been reached 55 | * From then on it will continue to use the max wait as delay. 56 | * 57 | * @param delay An [[scala.concurrent.duration.Duration]] indicating how long to wait for the next operation can occur 58 | * @param maxWait An [[scala.concurrent.duration.Duration]] indicating the maximum value a `delay` can have 59 | */ 60 | case class IndefiniteThrottle(delay: Duration, maxWait: Duration) extends Throttle { 61 | 62 | def next(): Throttle = { 63 | copy(delay = delay.doubled min maxWait) 64 | } 65 | } 66 | 67 | /** 68 | * Represents a back off strategy that will retry for `maxTimes` when the maximum wait has been reached 69 | * When it can't connect within the `maxTimes` a `maxValue` can occur it will return a [[io.backchat.hookup.NoThrottle]] strategy 70 | * 71 | * @param delay An [[scala.concurrent.duration.Duration]] indicating how long to wait for the next operation can occur 72 | * @param maxWait An [[scala.concurrent.duration.Duration]] indicating the maximum value a `delay` can have 73 | * @param maxTimes A [[scala.Int]] representing the maximum amount of time a maxWait can be repeated 74 | */ 75 | case class MaxTimesThrottle(delay: Duration, maxWait: Duration, maxTimes: Int = 1) extends Throttle { 76 | 77 | def next(): Throttle = { 78 | if (maxTimes > 0) 79 | copy(delay = delay.doubled min maxWait, maxTimes = maxTimes - 1) 80 | else NoThrottle 81 | } 82 | } -------------------------------------------------------------------------------- /src/main/scala/io/backchat/hookup/wire_formats.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | 3 | import org.json4s._ 4 | import jackson.JsonMethods._ 5 | import scala.concurrent.duration._ 6 | 7 | /** 8 | * The interface trait for a wire format. 9 | * Creating a new wire format means implementing these 3 methods. 10 | */ 11 | trait WireFormat { 12 | 13 | /** 14 | * The name of this wire format 15 | * @return The name 16 | */ 17 | def name: String 18 | 19 | /** 20 | * A flag to indicate whether this wireformat supports acking or not 21 | * @return True if this wire format supports acking, otherwise false 22 | */ 23 | def supportsAck: Boolean 24 | 25 | /** 26 | * Parse an inbound message from a string. This is used when a message is received over a connection. 27 | * 28 | * @param message The serialized message to parse 29 | * @return the resulting [[io.backchat.hookup.InboundMessage]] 30 | */ 31 | def parseInMessage(message: String): InboundMessage 32 | 33 | /** 34 | * Parse an outbound message from a string. This is used when the buffer is being drained. 35 | * 36 | * @param message The serialized message to parse 37 | * @return the resulting [[io.backchat.hookup.OutboundMessage]] 38 | */ 39 | def parseOutMessage(message: String): OutboundMessage 40 | 41 | /** 42 | * Render an outbound message to string. This is used when a message is sent to the remote party. 43 | * 44 | * @param message The message to serialize 45 | * @return The string representation of the message 46 | */ 47 | def render(message: OutboundMessage): String 48 | 49 | } 50 | 51 | /** 52 | * A protocol format that is just plain and simple json. This protocol doesn't support acking. 53 | * It looks at the first character in the message and if it thinks it's JSON it will try to parse it as JSON 54 | * otherwise it creates a text message 55 | * 56 | * @param formats the [[org.json4s.Formats]] for json4s 57 | */ 58 | class SimpleJsonWireFormat(implicit formats: Formats) extends WireFormat { 59 | 60 | val name = "simpleJson" 61 | val supportsAck = false 62 | 63 | private[this] def parseMessage(message: String) = { 64 | if (message.trim.startsWith("{") || message.trim.startsWith("[")) 65 | parseOpt(message) map (JsonMessage(_)) getOrElse TextMessage(message) 66 | else TextMessage(message) 67 | } 68 | 69 | def parseOutMessage(message: String): OutboundMessage = parseMessage(message) 70 | 71 | def parseInMessage(message: String): InboundMessage = parseMessage(message) 72 | 73 | def render(message: OutboundMessage) = message match { 74 | case TextMessage(text) => text 75 | case JsonMessage(json) => compact(json) 76 | case _ => "" 77 | } 78 | } 79 | 80 | /** 81 | * @see [[io.backchat.hookup.JsonProtocolWireFormat]] 82 | */ 83 | object JsonProtocolWireFormat { 84 | 85 | private object ParseToWebSocketInMessage { 86 | 87 | def apply(message: String)(implicit format: Formats) = inferMessageTypeFromContent(message) 88 | 89 | private def inferMessageTypeFromContent(content: String)(implicit format: Formats): InboundMessage = { 90 | val possiblyJson = content.trim.startsWith("{") || content.trim.startsWith("[") 91 | if (!possiblyJson) TextMessage(content) 92 | else parseOpt(content) map inferJsonMessageFromContent getOrElse TextMessage(content) 93 | } 94 | 95 | private def inferJsonMessageFromContent(content: JValue)(implicit format: Formats) = { 96 | val contentType = (content \ "type").extractOpt[String].map(_.toLowerCase) getOrElse "none" 97 | (contentType) match { 98 | case "ack_request" ⇒ AckRequest(inferContentMessage((content \ "content")), (content \ "id").extract[Long]) 99 | case "ack" ⇒ Ack((content \ "id").extract[Long]) 100 | case "text" ⇒ TextMessage((content \ "content").extract[String]) 101 | case "json" ⇒ JsonMessage((content \ "content")) 102 | case _ ⇒ JsonMessage(content) 103 | } 104 | } 105 | 106 | private def inferContentMessage(content: JValue)(implicit format: Formats): Ackable = { 107 | content match { 108 | case JString(text) ⇒ TextMessage(text) 109 | case _ => 110 | val contentType = (content \ "type").extractOrElse("none") 111 | (contentType) match { 112 | case "text" ⇒ TextMessage((content \ "content").extract[String]) 113 | case "json" ⇒ JsonMessage((content \ "content")) 114 | case "none" ⇒ content match { 115 | case JString(text) => 116 | val possiblyJson = text.trim.startsWith("{") || text.trim.startsWith("[") 117 | if (!possiblyJson) TextMessage(text) 118 | else parseOpt(text) map inferContentMessage getOrElse TextMessage(text) 119 | case jv => JsonMessage(content) 120 | } 121 | } 122 | } 123 | } 124 | } 125 | 126 | private object ParseToWebSocketOutMessage { 127 | def apply(message: String)(implicit format: Formats): OutboundMessage = inferMessageTypeFromContent(message) 128 | 129 | private def inferMessageTypeFromContent(content: String)(implicit format: Formats): OutboundMessage = { 130 | val possiblyJson = content.trim.startsWith("{") || content.trim.startsWith("[") 131 | if (!possiblyJson) TextMessage(content) 132 | else parseOpt(content) map inferJsonMessageFromContent getOrElse TextMessage(content) 133 | } 134 | 135 | private def inferJsonMessageFromContent(content: JValue)(implicit format: Formats): OutboundMessage = { 136 | val contentType = (content \ "type").extractOpt[String].map(_.toLowerCase) getOrElse "none" 137 | (contentType) match { 138 | case "ack" => Ack((content \ "id").extract[Long]) 139 | case "needs_ack" ⇒ NeedsAck(inferContentMessage(content \ "content"), (content \ "timeout").extract[Long].millis) 140 | case "text" ⇒ TextMessage((content \ "content").extract[String]) 141 | case "json" ⇒ JsonMessage((content \ "content")) 142 | case _ ⇒ JsonMessage(content) 143 | } 144 | } 145 | 146 | private def inferContentMessage(content: JValue)(implicit format: Formats): Ackable = content match { 147 | case JString(text) ⇒ TextMessage(text) 148 | case _ => 149 | val contentType = (content \ "type").extractOrElse("none") 150 | (contentType) match { 151 | case "text" ⇒ TextMessage((content \ "content").extract[String]) 152 | case "json" ⇒ JsonMessage((content \ "content")) 153 | case "none" ⇒ content match { 154 | case JString(text) => 155 | val possiblyJson = text.trim.startsWith("{") || text.trim.startsWith("[") 156 | if (!possiblyJson) TextMessage(text) 157 | else parseOpt(text) map inferContentMessage getOrElse TextMessage(text) 158 | case jv => JsonMessage(content) 159 | } 160 | } 161 | } 162 | } 163 | 164 | private object RenderOutMessage { 165 | 166 | import JsonDSL._ 167 | 168 | def apply(message: OutboundMessage): String = { 169 | message match { 170 | case Ack(id) ⇒ compact(render(("type" -> "ack") ~ ("id" -> id))) 171 | case m: TextMessage ⇒ compact(render(contentFrom(m))) 172 | case m: JsonMessage ⇒ compact(render(contentFrom(m))) 173 | case NeedsAck(msg, timeout) ⇒ 174 | compact(render(("type" -> "needs_ack") ~ ("timeout" -> timeout.toMillis) ~ ("content" -> contentFrom(msg)))) 175 | case x ⇒ sys.error(x.getClass.getName + " is an unsupported message type") 176 | } 177 | } 178 | 179 | private[this] def contentFrom(message: Ackable): JValue = message match { 180 | case TextMessage(text) ⇒ ("type" -> "text") ~ ("content" -> text) 181 | case JsonMessage(json) ⇒ ("type" -> "json") ~ ("content" -> json) 182 | } 183 | } 184 | 185 | } 186 | 187 | /** 188 | * A protocol that supports all the features of the websocket server. 189 | * This wireformat knows about acking and the related protocol messages. 190 | * it uses a json object to transfer meaning everything has a property name. 191 | * 192 | * @param formats the [[org.json4s.Formats]] for json4s 193 | */ 194 | class JsonProtocolWireFormat(implicit formats: Formats) extends WireFormat { 195 | val name = "jsonProtocol" 196 | val supportsAck = true 197 | import JsonProtocolWireFormat._ 198 | def parseInMessage(message: String): InboundMessage = ParseToWebSocketInMessage(message) 199 | def parseOutMessage(message: String): OutboundMessage = ParseToWebSocketOutMessage(message) 200 | def render(message: OutboundMessage) = RenderOutMessage(message) 201 | } -------------------------------------------------------------------------------- /src/main/site/.gitignore: -------------------------------------------------------------------------------- 1 | .sass-cache/ 2 | _site 3 | .idea* 4 | project 5 | src 6 | target 7 | work 8 | _cache 9 | -------------------------------------------------------------------------------- /src/main/site/.rbenv-version: -------------------------------------------------------------------------------- 1 | 1.9.3-p194 2 | -------------------------------------------------------------------------------- /src/main/site/Gemfile: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | 3 | gem "jekyll" 4 | gem "redcarpet" 5 | gem "compass" 6 | gem "pygments.rb" 7 | -------------------------------------------------------------------------------- /src/main/site/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | albino (1.3.3) 5 | posix-spawn (>= 0.3.6) 6 | blankslate (2.1.2.4) 7 | chunky_png (1.2.5) 8 | classifier (1.3.3) 9 | fast-stemmer (>= 1.0.0) 10 | compass (0.12.1) 11 | chunky_png (~> 1.2) 12 | fssm (>= 0.2.7) 13 | sass (~> 3.1) 14 | directory_watcher (1.4.1) 15 | fast-stemmer (1.0.1) 16 | ffi (1.0.11) 17 | fssm (0.2.9) 18 | jekyll (0.11.2) 19 | albino (~> 1.3) 20 | classifier (~> 1.3) 21 | directory_watcher (~> 1.1) 22 | kramdown (~> 0.13) 23 | liquid (~> 2.3) 24 | maruku (~> 0.5) 25 | kramdown (0.13.6) 26 | liquid (2.3.0) 27 | maruku (0.6.0) 28 | syntax (>= 1.0.0) 29 | posix-spawn (0.3.6) 30 | pygments.rb (0.2.12) 31 | rubypython (~> 0.5.3) 32 | redcarpet (2.1.1) 33 | rubypython (0.5.3) 34 | blankslate (>= 2.1.2.3) 35 | ffi (~> 1.0.7) 36 | sass (3.1.18) 37 | syntax (1.0.0) 38 | 39 | PLATFORMS 40 | ruby 41 | 42 | DEPENDENCIES 43 | compass 44 | jekyll 45 | pygments.rb 46 | redcarpet 47 | -------------------------------------------------------------------------------- /src/main/site/README.md: -------------------------------------------------------------------------------- 1 | # BackChat.io Github Pages Theme 2 | 3 | [Demo the Theme](http://backchatio.github.com/github-pages-doc-theme/) 4 | 5 | This is the raw HTML and styles that are used for the *BackChat.io* [GitHub Pages](http://pages.github.com/) theme. It is based upon the *minimal* theme. 6 | 7 | Syntax highlighting is provided on GitHub Pages by [Pygments](http://pygments.org). 8 | 9 | # License 10 | 11 | This work is licensed under a [Creative Commons Attribution-ShareAlike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/). 12 | -------------------------------------------------------------------------------- /src/main/site/_config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | auto : false 3 | pygments : false 4 | markdown : redcarpet2 5 | redcarpet: 6 | extensions: ["no_intra_emphasis", "fenced_code_blocks", "autolink", "strikethrough", "superscript", "with_toc_data"] 7 | charset : UTF-8 8 | exclude : ["Gemfile", "Gemfile.lock", "Rakefile"] 9 | title : "BackChat.io Hookup" 10 | sub_title : "Reliable messaging over websockets with akka." 11 | rich_title : "Hookup by BackChat.io" 12 | rich_byline : "Reliable messaging over websockets" 13 | sponsor: 14 | byline : "Maintained by" 15 | logo : "//assets.backchat.io/images/logo.png" 16 | name : "BackChat.io" 17 | url : "https://backchat.io" 18 | footer : "This project is maintained by BackChat.io, the real-time data filtering API for the cloud." 19 | 20 | ga_code : "UA-240612-7" 21 | github: 22 | username : "backchatio" 23 | project : "hookup" 24 | destination : "../../../target/jekyll" -------------------------------------------------------------------------------- /src/main/site/_includes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/backchatio/hookup/2913794eb45d90d65713c9fd631b427abcca2d05/src/main/site/_includes/.gitkeep -------------------------------------------------------------------------------- /src/main/site/_layouts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/backchatio/hookup/2913794eb45d90d65713c9fd631b427abcca2d05/src/main/site/_layouts/.gitkeep -------------------------------------------------------------------------------- /src/main/site/_layouts/backchatio.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ site.title }} 7 | 8 | 9 | 10 | 11 | 12 | 15 | 25 | 26 | 27 |
28 | 35 |
36 |

{{ site.rich_title }}

37 |

{{ site.rich_byline }}

38 |
39 |
40 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/main/site/_layouts/minimalist.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | BackChat.io Hookup 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 |
17 |
18 |

BackChat.io Hookup

19 |

A reliable netty websocket server built on top of akka

20 |

View the Project on GitHub backchatio/hookup

21 |

View the Scala API Docs scala api docs

22 |

View the Node.js API Docs node.js api docs

23 | 28 |
29 |
30 | {{ content }} 31 |
32 | 36 | 37 |
38 | 39 | 40 | 44 | 50 | 51 | -------------------------------------------------------------------------------- /src/main/site/_layouts/modernist.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Backchat-websocket by mojolly 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 |
19 |
20 |

BackChat.io Hookup

21 |

A reliable netty websocket server built on top of akka

22 |

View the Project on GitHub backchatio/hookup

23 | 27 |
28 |
29 | {{ content }} 30 |
31 |
32 | 36 | 37 | 41 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/main/site/_plugins/code_ref.rb: -------------------------------------------------------------------------------- 1 | require 'pygments' 2 | 3 | module Jekyll 4 | class CodeRefTag < Liquid::Tag 5 | def initialize(tag_name, args, tokens) 6 | all = args.strip.reverse.split(' ') 7 | item = all.first.reverse 8 | file = all[1..-1].join(" ").reverse 9 | raise "You need to specify a name for the section to fetch" if all.size == 1 10 | super 11 | 12 | @file = file 13 | @item = item 14 | 15 | end 16 | 17 | def add_code_tags(code, lang) 18 | # Add nested tags to code blocks 19 | code = code.sub(/
/,'
')
20 |       code = code.sub(/<\/pre>/,"
") 21 | end 22 | 23 | def strip_margin(text, spaces) 24 | lines = text.strip.split("\n") 25 | lines[0] << "\n" << lines[1..-1].map { |l| l[spaces..-1] }.join("\n") 26 | end 27 | 28 | def render(context) 29 | return "Code ref file '#{@file}' does not exist." unless File.exist?(@file) 30 | 31 | indented = (File.read(@file).match(/(?:\/\/\/|###)\s*code_ref\s*\:\s*#{@item}(.*?)(?:\/{3}|###)\s*end_code_ref/mi)||[])[1] 32 | spaces = indented[1..-1].match(/(\s*)[^ ]/)[1].size 33 | code = spaces == 0 ? indented : strip_margin(indented, spaces) 34 | 35 | return "No code matched the key #{@item} in #{@file}" unless code 36 | 37 | lexer = Pygments::Lexer.find_by_extname(File.extname(@file)).aliases[0] 38 | highlighted = Pygments.highlight(code, :lexer => lexer, :options => { :style => "default", :encoding => 'utf-8'}) 39 | add_code_tags(highlighted, lexer) 40 | end 41 | end 42 | 43 | end 44 | 45 | Liquid::Template.register_tag('code_ref', Jekyll::CodeRefTag) -------------------------------------------------------------------------------- /src/main/site/_plugins/generate_page_toc.rb: -------------------------------------------------------------------------------- 1 | module Jekyll 2 | class PageTocTag < Liquid::Tag 3 | def initialize(tag_name, args, tokens) 4 | @toc = Redcarpet::Markdown.new(Redcarpet::Render::HTML_TOC).render(tokens.join("\n")) 5 | end 6 | def render(context) 7 | @toc 8 | end 9 | end 10 | 11 | end 12 | 13 | Liquid::Template.register_tag('page_toc', Jekyll::PageTocTag) -------------------------------------------------------------------------------- /src/main/site/_plugins/redcarpet2_markdown.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | require 'digest/md5' 3 | require 'redcarpet' 4 | require 'pygments' 5 | 6 | class Redcarpet2Markdown < Redcarpet::Render::HTML 7 | def block_code(code, lang) 8 | lang = lang || "text" 9 | colorized = Pygments.highlight(code, :lexer => lang, :options => { :style => "default", :encoding => 'utf-8'}) 10 | add_code_tags(colorized, lang) 11 | end 12 | 13 | def add_code_tags(code, lang) 14 | code.sub(/
/, "
").
15 |          sub(/<\/pre>/, "
") 16 | end 17 | end 18 | 19 | 20 | class Jekyll::MarkdownConverter 21 | def extensions 22 | Hash[ *@config['redcarpet']['extensions'].map {|e| [e.to_sym, true] }.flatten ] 23 | end 24 | 25 | def markdown 26 | @markdown ||= Redcarpet::Markdown.new(Redcarpet2Markdown.new(extensions), extensions) 27 | end 28 | 29 | def convert(content) 30 | return super unless @config['markdown'] == 'redcarpet2' 31 | markdown.render(content) 32 | end 33 | end -------------------------------------------------------------------------------- /src/main/site/_plugins/sass_converter.rb: -------------------------------------------------------------------------------- 1 | module Jekyll 2 | # Sass plugin to convert .scss to .css 3 | # 4 | # Note: This is configured to use the new css like syntax available in sass. 5 | require 'sass' 6 | require 'compass' 7 | class SassConverter < Converter 8 | safe true 9 | priority :low 10 | 11 | def matches(ext) 12 | ext =~ /scss/i 13 | end 14 | 15 | def output_ext(ext) 16 | ".css" 17 | end 18 | 19 | def convert(content) 20 | begin 21 | Compass.add_project_configuration 22 | Compass.configuration.project_path ||= Dir.pwd 23 | 24 | load_paths = [".", "./scss", "./css"] 25 | load_paths += Compass.configuration.sass_load_paths 26 | 27 | engine = Sass::Engine.new(content, :syntax => :scss, :load_paths => load_paths, :style => :compact) 28 | engine.render 29 | rescue StandardError => e 30 | puts "!!! SASS Error: " + e.message 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /src/main/site/_posts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/backchatio/hookup/2913794eb45d90d65713c9fd631b427abcca2d05/src/main/site/_posts/.gitkeep -------------------------------------------------------------------------------- /src/main/site/config.rb: -------------------------------------------------------------------------------- 1 | # Require any additional compass plugins here. 2 | 3 | # Set this to the root of your project when deployed: 4 | http_path = "/" 5 | css_dir = "stylesheets" 6 | sass_dir = "stylesheets" 7 | images_dir = "imgs" 8 | javascripts_dir = "javascripts" 9 | 10 | # You can select your preferred output style here (can be overridden via the command line): 11 | # output_style = :expanded or :nested or :compact or :compressed 12 | 13 | # To enable relative paths to assets via compass helper functions. Uncomment: 14 | # relative_assets = true 15 | 16 | # To disable debugging comments that display the original location of your selectors. Uncomment: 17 | # line_comments = false 18 | 19 | 20 | # If you prefer the indented syntax, you might want to regenerate this 21 | # project again passing --syntax sass, or you can uncomment this: 22 | # preferred_syntax = :sass 23 | # and then run: 24 | # sass-convert -R --from scss --to sass sass scss && rm -rf sass && mv scss sass 25 | -------------------------------------------------------------------------------- /src/main/site/imgs/bg-watercolor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/backchatio/hookup/2913794eb45d90d65713c9fd631b427abcca2d05/src/main/site/imgs/bg-watercolor.jpg -------------------------------------------------------------------------------- /src/main/site/imgs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/backchatio/hookup/2913794eb45d90d65713c9fd631b427abcca2d05/src/main/site/imgs/logo.png -------------------------------------------------------------------------------- /src/main/site/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: backchatio 3 | title: BackChat.io hookup 4 | --- 5 | 6 | # Reliable messaging on top of websockets. 7 | 8 | A scala based client and server for websockets based on netty and akka futures. 9 | It draws its inspiration from finagle, faye-websocket, zeromq, akka, ... 10 | 11 | The aim of this project is to provide a websocket client in multiple languages an to be used in non-browser applications. 12 | This client should be reliable by making a best effort not to lose any messages and gracefully recover from disconnections. 13 | 14 | The server should serve regular websocket applications but can be configured for more reliability too. 15 | 16 | ## Features 17 | To reach said goals this library implements: 18 | 19 | ### Protocol features: 20 | 21 | These features are baked into the default `JsonProtocolWireFormat` or in the WebSocket spec. 22 | 23 | #### Message Acking: 24 | You can decide if you want to ack a message on a per message basis. 25 | 26 | ```scala 27 | client ! "the message".needsAck(within = 5 seconds) 28 | ``` 29 | 30 | #### PingPong 31 | This is baked into the websocket protocol, the library ensures it really happens 32 | 33 | ### Client only features: 34 | 35 | There are a number of extras baked into the client, of course they can be enabled and disabled based on config. 36 | 37 | #### Reconnection 38 | 39 | The client reconnects to the server on a backoff schedule indefinitely or for a maximum amount of times 40 | 41 | #### Message buffering 42 | 43 | During phases of disconnection it will buffer the messages to a file so that upon reconnection the messages will all be sent to the server. 44 | 45 | ## Usage 46 | 47 | This library is available on maven central. 48 | 49 | ```scala 50 | libraryDependencies += "io.backchat.hookup" %% "hookup" % "0.2.2" 51 | ``` 52 | 53 | #### Create a websocket server 54 | 55 | ```scala 56 | import io.backchat.hookup._ 57 | 58 | (HookupServer(8125) { 59 | new HookupServerClient { 60 | def receive = { 61 | case TextMessage(text) => 62 | println(text) 63 | send(text) 64 | } 65 | } 66 | }).start 67 | ``` 68 | 69 | #### Create a websocket client 70 | 71 | ```scala 72 | import io.backchat.hookup._ 73 | 74 | new DefaultHookupClient(HookupClientConfig(new URI("ws://localhost:8080/thesocket"))) { 75 | 76 | def receive = { 77 | case Disconnected(_) ⇒ 78 | println("The websocket to " + uri.toASCIIString + " disconnected.") 79 | case TextMessage(message) ⇒ { 80 | println("RECV: " + message) 81 | send("ECHO: " + message) 82 | } 83 | } 84 | 85 | connect() onSuccess { 86 | case Success ⇒ 87 | println("The websocket is connected to:"+this.uri.toASCIIString+".") 88 | system.scheduler.schedule(0 seconds, 1 second) { 89 | send("message " + messageCounter.incrementAndGet().toString) 90 | } 91 | case _ ⇒ 92 | } 93 | } 94 | ``` 95 | 96 | There are [code examples](https://github.com/backchatio/hookup/tree/master/src/main/scala/io/backchat/hookup /examples) that show all the events being raised and a chat server/client. 97 | 98 | * Echo ([server](https://github.com/backchatio/hookup/blob/master/src/main/scala/io/backchat/hookup/examples/PrintingEchoServer.scala) | [client](https://github.com/backchatio/hookup/blob/master/src/main/scala/io/backchat/hookup/examples/PrintingEchoClient.scala)) 99 | * All Events ([server](https://github.com/backchatio/hookup/blob/master/src/main/scala/io/backchat/hookup/examples/PrintAllEventsServer.scala) | [client](https://github.com/backchatio/hookup/blob/master/src/main/scala/io/backchat/hookup/examples/PrintAllEventsClient.scala)) 100 | * Chat ([server](https://github.com/backchatio/hookup/blob/master/src/main/scala/io/backchat/hookup/examples/ChatServer.scala) | [client](https://github.com/backchatio/hookup/blob/master/src/main/scala/io/backchat/hookup/examples/ChatClient.scala)) 101 | * PubSub ([server](https://github.com/backchatio/hookup/blob/master/src/main/scala/io/backchat/hookup/examples/PubSubServer.scala) | [client](https://github.com/backchatio/hookup/blob/master/src/main/scala/io/backchat/hookup/examples/PubSubClient.scala)) 102 | 103 | ## Patches 104 | Patches are gladly accepted from their original author. Along with any patches, please state that the patch is your original work and that you license the work to the *backchat-websocket* project under the MIT License. 105 | 106 | ## License 107 | MIT licensed. check the [LICENSE](https://github.com/backchatio/hookup/blob/master/LICENSE) file -------------------------------------------------------------------------------- /src/main/site/javascripts/fixed-sidebar.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var sidebar = document.getElementById("sidebar"); 3 | 4 | function getScrollTop(){ 5 | if(typeof pageYOffset!= 'undefined'){ 6 | return pageYOffset;//most browsers 7 | } 8 | else{ 9 | var B= document.body; //IE 'quirks' 10 | var D= document.documentElement; //IE with doctype 11 | D= (D.clientHeight)? D: B; 12 | return D.scrollTop; 13 | } 14 | } 15 | 16 | function setSidebarPosition() { 17 | if(getScrollTop() > 350) { 18 | sidebar.style.top = "50px"; 19 | sidebar.style.position = "fixed"; 20 | } else { 21 | sidebar.style.top = ""; 22 | sidebar.style.position = "absolute"; 23 | } 24 | } 25 | 26 | window.onscroll = setSidebarPosition; 27 | })(); -------------------------------------------------------------------------------- /src/main/site/javascripts/scale.fix.js: -------------------------------------------------------------------------------- 1 | var metas = document.getElementsByTagName('meta'); 2 | var i; 3 | if (navigator.userAgent.match(/iPhone/i)) { 4 | for (i=0; i 6 | * 7 | * */ 8 | -------------------------------------------------------------------------------- /src/main/site/stylesheets/print.scss: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | /* Welcome to Compass. Use this file to define print styles. 4 | * Import this file using the following HTML or equivalent: 5 | * */ 6 | -------------------------------------------------------------------------------- /src/main/site/stylesheets/pygment_github.css: -------------------------------------------------------------------------------- 1 | .highlight { background: #ffffff; } 2 | .highlight .c { color: #999988; font-style: italic } /* Comment */ 3 | .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ 4 | .highlight .k { font-weight: bold } /* Keyword */ 5 | .highlight .o { font-weight: bold } /* Operator */ 6 | .highlight .cm { color: #999988; font-style: italic } /* Comment.Multiline */ 7 | .highlight .cp { color: #999999; font-weight: bold } /* Comment.Preproc */ 8 | .highlight .c1 { color: #999988; font-style: italic } /* Comment.Single */ 9 | .highlight .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */ 10 | .highlight .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ 11 | .highlight .gd .x { color: #000000; background-color: #ffaaaa } /* Generic.Deleted.Specific */ 12 | .highlight .ge { font-style: italic } /* Generic.Emph */ 13 | .highlight .gr { color: #aa0000 } /* Generic.Error */ 14 | .highlight .gh { color: #999999 } /* Generic.Heading */ 15 | .highlight .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ 16 | .highlight .gi .x { color: #000000; background-color: #aaffaa } /* Generic.Inserted.Specific */ 17 | .highlight .go { color: #888888 } /* Generic.Output */ 18 | .highlight .gp { color: #555555 } /* Generic.Prompt */ 19 | .highlight .gs { font-weight: bold } /* Generic.Strong */ 20 | .highlight .gu { color: #aaaaaa } /* Generic.Subheading */ 21 | .highlight .gt { color: #aa0000 } /* Generic.Traceback */ 22 | .highlight .kc { font-weight: bold } /* Keyword.Constant */ 23 | .highlight .kd { font-weight: bold } /* Keyword.Declaration */ 24 | .highlight .kp { font-weight: bold } /* Keyword.Pseudo */ 25 | .highlight .kr { font-weight: bold } /* Keyword.Reserved */ 26 | .highlight .kt { color: #445588; font-weight: bold } /* Keyword.Type */ 27 | .highlight .m { color: #009999 } /* Literal.Number */ 28 | .highlight .s { color: #d14 } /* Literal.String */ 29 | .highlight .na { color: #008080 } /* Name.Attribute */ 30 | .highlight .nb { color: #0086B3 } /* Name.Builtin */ 31 | .highlight .nc { color: #445588; font-weight: bold } /* Name.Class */ 32 | .highlight .no { color: #008080 } /* Name.Constant */ 33 | .highlight .ni { color: #800080 } /* Name.Entity */ 34 | .highlight .ne { color: #990000; font-weight: bold } /* Name.Exception */ 35 | .highlight .nf { color: #990000; font-weight: bold } /* Name.Function */ 36 | .highlight .nn { color: #555555 } /* Name.Namespace */ 37 | .highlight .nt { color: #000080 } /* Name.Tag */ 38 | .highlight .nv { color: #008080 } /* Name.Variable */ 39 | .highlight .ow { font-weight: bold } /* Operator.Word */ 40 | .highlight .w { color: #bbbbbb } /* Text.Whitespace */ 41 | .highlight .mf { color: #009999 } /* Literal.Number.Float */ 42 | .highlight .mh { color: #009999 } /* Literal.Number.Hex */ 43 | .highlight .mi { color: #009999 } /* Literal.Number.Integer */ 44 | .highlight .mo { color: #009999 } /* Literal.Number.Oct */ 45 | .highlight .sb { color: #d14 } /* Literal.String.Backtick */ 46 | .highlight .sc { color: #d14 } /* Literal.String.Char */ 47 | .highlight .sd { color: #d14 } /* Literal.String.Doc */ 48 | .highlight .s2 { color: #d14 } /* Literal.String.Double */ 49 | .highlight .se { color: #d14 } /* Literal.String.Escape */ 50 | .highlight .sh { color: #d14 } /* Literal.String.Heredoc */ 51 | .highlight .si { color: #d14 } /* Literal.String.Interpol */ 52 | .highlight .sx { color: #d14 } /* Literal.String.Other */ 53 | .highlight .sr { color: #009926 } /* Literal.String.Regex */ 54 | .highlight .s1 { color: #d14 } /* Literal.String.Single */ 55 | .highlight .ss { color: #990073 } /* Literal.String.Symbol */ 56 | .highlight .bp { color: #999999 } /* Name.Builtin.Pseudo */ 57 | .highlight .vc { color: #008080 } /* Name.Variable.Class */ 58 | .highlight .vg { color: #008080 } /* Name.Variable.Global */ 59 | .highlight .vi { color: #008080 } /* Name.Variable.Instance */ 60 | .highlight .il { color: #009999 } /* Literal.Number.Integer.Long */ -------------------------------------------------------------------------------- /src/main/site/stylesheets/pygment_trac.css: -------------------------------------------------------------------------------- 1 | .highlight { background: #ffffff; } 2 | .highlight .c { color: #999988; font-style: italic } /* Comment */ 3 | .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ 4 | .highlight .k { font-weight: bold } /* Keyword */ 5 | .highlight .o { font-weight: bold } /* Operator */ 6 | .highlight .cm { color: #999988; font-style: italic } /* Comment.Multiline */ 7 | .highlight .cp { color: #999999; font-weight: bold } /* Comment.Preproc */ 8 | .highlight .c1 { color: #999988; font-style: italic } /* Comment.Single */ 9 | .highlight .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */ 10 | .highlight .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ 11 | .highlight .gd .x { color: #000000; background-color: #ffaaaa } /* Generic.Deleted.Specific */ 12 | .highlight .ge { font-style: italic } /* Generic.Emph */ 13 | .highlight .gr { color: #aa0000 } /* Generic.Error */ 14 | .highlight .gh { color: #999999 } /* Generic.Heading */ 15 | .highlight .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ 16 | .highlight .gi .x { color: #000000; background-color: #aaffaa } /* Generic.Inserted.Specific */ 17 | .highlight .go { color: #888888 } /* Generic.Output */ 18 | .highlight .gp { color: #555555 } /* Generic.Prompt */ 19 | .highlight .gs { font-weight: bold } /* Generic.Strong */ 20 | .highlight .gu { color: #800080; font-weight: bold; } /* Generic.Subheading */ 21 | .highlight .gt { color: #aa0000 } /* Generic.Traceback */ 22 | .highlight .kc { font-weight: bold } /* Keyword.Constant */ 23 | .highlight .kd { font-weight: bold } /* Keyword.Declaration */ 24 | .highlight .kn { font-weight: bold } /* Keyword.Namespace */ 25 | .highlight .kp { font-weight: bold } /* Keyword.Pseudo */ 26 | .highlight .kr { font-weight: bold } /* Keyword.Reserved */ 27 | .highlight .kt { color: #445588; font-weight: bold } /* Keyword.Type */ 28 | .highlight .m { color: #009999 } /* Literal.Number */ 29 | .highlight .s { color: #d14 } /* Literal.String */ 30 | .highlight .na { color: #008080 } /* Name.Attribute */ 31 | .highlight .nb { color: #0086B3 } /* Name.Builtin */ 32 | .highlight .nc { color: #445588; font-weight: bold } /* Name.Class */ 33 | .highlight .no { color: #008080 } /* Name.Constant */ 34 | .highlight .ni { color: #800080 } /* Name.Entity */ 35 | .highlight .ne { color: #990000; font-weight: bold } /* Name.Exception */ 36 | .highlight .nf { color: #990000; font-weight: bold } /* Name.Function */ 37 | .highlight .nn { color: #555555 } /* Name.Namespace */ 38 | .highlight .nt { color: #000080 } /* Name.Tag */ 39 | .highlight .nv { color: #008080 } /* Name.Variable */ 40 | .highlight .ow { font-weight: bold } /* Operator.Word */ 41 | .highlight .w { color: #bbbbbb } /* Text.Whitespace */ 42 | .highlight .mf { color: #009999 } /* Literal.Number.Float */ 43 | .highlight .mh { color: #009999 } /* Literal.Number.Hex */ 44 | .highlight .mi { color: #009999 } /* Literal.Number.Integer */ 45 | .highlight .mo { color: #009999 } /* Literal.Number.Oct */ 46 | .highlight .sb { color: #d14 } /* Literal.String.Backtick */ 47 | .highlight .sc { color: #d14 } /* Literal.String.Char */ 48 | .highlight .sd { color: #d14 } /* Literal.String.Doc */ 49 | .highlight .s2 { color: #d14 } /* Literal.String.Double */ 50 | .highlight .se { color: #d14 } /* Literal.String.Escape */ 51 | .highlight .sh { color: #d14 } /* Literal.String.Heredoc */ 52 | .highlight .si { color: #d14 } /* Literal.String.Interpol */ 53 | .highlight .sx { color: #d14 } /* Literal.String.Other */ 54 | .highlight .sr { color: #009926 } /* Literal.String.Regex */ 55 | .highlight .s1 { color: #d14 } /* Literal.String.Single */ 56 | .highlight .ss { color: #990073 } /* Literal.String.Symbol */ 57 | .highlight .bp { color: #999999 } /* Name.Builtin.Pseudo */ 58 | .highlight .vc { color: #008080 } /* Name.Variable.Class */ 59 | .highlight .vg { color: #008080 } /* Name.Variable.Global */ 60 | .highlight .vi { color: #008080 } /* Name.Variable.Instance */ 61 | .highlight .il { color: #009999 } /* Literal.Number.Integer.Long */ 62 | 63 | .type-csharp .highlight .k { color: #0000FF } 64 | .type-csharp .highlight .kt { color: #0000FF } 65 | .type-csharp .highlight .nf { color: #000000; font-weight: normal } 66 | .type-csharp .highlight .nc { color: #2B91AF } 67 | .type-csharp .highlight .nn { color: #000000 } 68 | .type-csharp .highlight .s { color: #A31515 } 69 | .type-csharp .highlight .sc { color: #A31515 } 70 | -------------------------------------------------------------------------------- /src/main/site/stylesheets/screen.scss: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | /* Welcome to Compass. 4 | * In this file you should write your main styles. (or centralize your imports) 5 | * Import this file using the following HTML or equivalent: 6 | * */ 7 | 8 | @import "compass/reset"; 9 | -------------------------------------------------------------------------------- /src/test/scala/io/backchat/hookup/examples/ServerConfigurationsExample.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package examples 3 | 4 | import org.specs2.Specification 5 | import org.specs2.time.NoTimeConversions 6 | import scala.concurrent.duration._ 7 | import akka.testkit._ 8 | import org.json4s.{Formats, DefaultFormats} 9 | import akka.util.Timeout 10 | import java.net.{InetSocketAddress, SocketAddress, ServerSocket, Socket} 11 | import java.io.{BufferedReader, PrintWriter, InputStreamReader} 12 | import java.util.concurrent.{TimeUnit, CountDownLatch, TimeoutException} 13 | import scala.concurrent.{Future, Await} 14 | 15 | 16 | class NoopWireformat(val name: String, val supportsAck: Boolean = false) extends WireFormat { 17 | 18 | def parseInMessage(message: String) = null 19 | 20 | def parseOutMessage(message: String) = null 21 | 22 | def render(message: OutboundMessage) = null 23 | } 24 | class ServerConfigurationsExample extends Specification { def is = 25 | "A Server with a ping configuration" ! serverWithPing ^ 26 | "A Server with a content compression configuration" ! serverWithContentCompression ^ 27 | "A Server with a max frame configuration" ! serverWithMaxFrame ^ 28 | "A Server with a ssl configuration" ! serverWithSslSupport ^ 29 | "A Server with a subprotocols configuration" ! serverWithSubprotocols ^ 30 | "A Server with a flash policy configuration" ! serverWithFlashPolicy ^ end 31 | 32 | import scala.concurrent.ExecutionContext.Implicits.global 33 | 34 | def serverWithPing = { 35 | /// code_ref: server_with_ping 36 | implicit val jsonFormats: Formats = DefaultFormats 37 | implicit val wireFormat: WireFormat = new JsonProtocolWireFormat 38 | 39 | HookupServer(Ping(Timeout(2 minutes))) { 40 | new HookupServerClient { 41 | def receive = { case _ =>} 42 | } 43 | } 44 | /// end_code_ref 45 | success 46 | } 47 | 48 | def serverWithContentCompression = { 49 | /// code_ref: server_with_compression 50 | HookupServer(ContentCompression(2)) { 51 | new HookupServerClient { 52 | def receive = { case _ =>} 53 | } 54 | } 55 | /// end_code_ref 56 | success 57 | } 58 | 59 | def serverWithMaxFrame = { 60 | /// code_ref: server_with_max_frame 61 | HookupServer(MaxFrameSize(512*1024)) { 62 | new HookupServerClient { 63 | def receive = { case _ =>} 64 | } 65 | } 66 | /// end_code_ref 67 | success 68 | } 69 | 70 | def serverWithSslSupport = { 71 | try { 72 | /// code_ref: server_with_ssl 73 | val sslSupport = 74 | SslSupport( 75 | keystorePath = "./ssl/keystore.jks", 76 | keystorePassword = "changeme", 77 | algorithm = "SunX509") 78 | 79 | HookupServer(sslSupport) { 80 | new HookupServerClient { 81 | def receive = { case _ =>} 82 | } 83 | } 84 | /// end_code_ref 85 | } catch { 86 | case _: Throwable => 87 | } 88 | success 89 | } 90 | 91 | 92 | 93 | def serverWithSubprotocols = { 94 | /// code_ref: server_with_subprotocols 95 | // these wire formats aren't actually implemented it's just to show the idea 96 | HookupServer(SubProtocols(new NoopWireformat("irc"), new NoopWireformat("xmpp"))) { 97 | new HookupServerClient { 98 | def receive = { case _ =>} 99 | } 100 | } 101 | /// end_code_ref 102 | success 103 | } 104 | 105 | def serverWithFlashPolicy = { 106 | val latch = new CountDownLatch(1) 107 | val port = { 108 | val s = new ServerSocket(0); 109 | try { s.getLocalPort } finally { s.close() } 110 | } 111 | import HookupClient.executionContext 112 | /// code_ref: server_with_flash_policy 113 | val server = HookupServer(port, FlashPolicy("*.example.com", Seq(80, 443, 8080, 8843, port))) { 114 | new HookupServerClient { 115 | def receive = { case _ =>} 116 | } 117 | } 118 | /// end_code_ref 119 | server onStart { 120 | latch.countDown 121 | } 122 | server.start 123 | latch.await(2, TimeUnit.SECONDS) must beTrue and { 124 | val socket = new Socket 125 | socket.connect(new InetSocketAddress("localhost", port), 2000) 126 | val out = new PrintWriter(socket.getOutputStream) 127 | 128 | val in = new BufferedReader(new InputStreamReader(socket.getInputStream)) 129 | out.println("%c" format 0) 130 | out.flush() 131 | val recv = Future { 132 | val sb = new Array[Char](159) 133 | var line = in.read(sb) 134 | val resp = new String(sb) 135 | resp 136 | } 137 | 138 | val res = Await.result(recv, 3 seconds) 139 | in.close() 140 | out.close() 141 | socket.close() 142 | server.stop 143 | res must contain("*.example.com") and (res must contain(port.toString)) 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/test/scala/io/backchat/hookup/tests/FileBufferSpec.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package tests 3 | 4 | import org.specs2.specification.AfterAll 5 | import org.specs2.time.NoTimeConversions 6 | import org.specs2.Specification 7 | import java.io.File 8 | import org.apache.commons.io.{FilenameUtils, FileUtils} 9 | import org.json4s._ 10 | import scala.io.Source 11 | import collection.JavaConverters._ 12 | import org.specs2.specification.core.{Fragments} 13 | import java.util.concurrent.{Executors, ConcurrentLinkedQueue} 14 | import scala.concurrent.{Await, Future, ExecutionContext} 15 | import scala.concurrent.duration._ 16 | import akka.actor.ActorSystem 17 | import collection.mutable.{ArrayBuffer, Buffer, SynchronizedBuffer, ListBuffer} 18 | import java.util.concurrent.atomic.AtomicInteger 19 | 20 | class FileBufferSpec extends Specification with AfterAll { def is = 21 | "A FileBuffer should" ^ 22 | "create the path to the file if it doesn't exist" ! createsPath ^ 23 | "write to a file while the buffer is open" ! writesToFile ^ 24 | "write to memory buffer while draining" ! writesToMemory ^ 25 | "drain the buffers" ! drainsBuffers ^ 26 | "not fail under concurrent load" ! handlesConcurrentLoads ^ 27 | end 28 | 29 | implicit val wireFormat: WireFormat = new JsonProtocolWireFormat()(DefaultFormats) 30 | implicit val executionContext = ExecutionContext.fromExecutorService(Executors.newCachedThreadPool()) 31 | 32 | 33 | override def afterAll(): Unit = executionContext.shutdown() 34 | 35 | def createsPath = { 36 | val logPath = new File("./test-work/testing/and/such/buffer.log") 37 | val workPath = new File("./test-work") 38 | if (workPath.exists()) FileUtils.deleteDirectory(workPath) 39 | val buff = new FileBuffer(logPath) 40 | buff.open() 41 | val res = logPath.getParentFile.exists must beTrue 42 | FileUtils.deleteDirectory(workPath) 43 | buff.close() 44 | res 45 | } 46 | 47 | def writesToFile = { 48 | FileUtils.deleteQuietly(new File("./test-work2")) 49 | val logPath = new File("./test-work2/buffer.log") 50 | val buff = new FileBuffer(logPath) 51 | val exp1: OutboundMessage = TextMessage("the first message") 52 | val exp2: OutboundMessage = TextMessage("the second message") 53 | buff.open() 54 | buff.write(exp1) 55 | buff.write(exp2) 56 | buff.close() 57 | val lines = Source.fromFile(logPath).getLines().toList map wireFormat.parseOutMessage 58 | FileUtils.deleteQuietly(new File("./test-work2")) 59 | lines must contain(eachOf(exp1, exp2)) 60 | } 61 | 62 | def writesToMemory = { 63 | val logPath = new File("./test-work3/buffer.log") 64 | val exp1: OutboundMessage = TextMessage("the first message") 65 | val exp2: OutboundMessage = TextMessage("the second message") 66 | val queue = new ConcurrentLinkedQueue[String]() 67 | val buff = new FileBuffer(logPath, false, queue) 68 | buff.open() 69 | buff.write(exp1) 70 | buff.write(exp2) 71 | val lst = queue.asScala.toList 72 | buff.close() 73 | FileUtils.deleteDirectory(new File("./test-work3")) 74 | lst must contain(eachOf(wireFormat.render(exp1), wireFormat.render(exp2))) 75 | } 76 | 77 | def drainsBuffers = { 78 | val logPath = new File("./test-work4/buffer.log") 79 | val buff = new FileBuffer(logPath) 80 | val exp1: OutboundMessage = TextMessage("the first message") 81 | val exp2: OutboundMessage = TextMessage("the second message") 82 | buff.open() 83 | buff.write(exp1) 84 | buff.write(exp2) 85 | val lines = new ListBuffer[OutboundMessage] 86 | Await.ready(buff drain { out => 87 | Future { 88 | lines += out 89 | Success 90 | } 91 | }, 5 seconds) 92 | buff.close() 93 | FileUtils.deleteQuietly(new File("./test-work4")) 94 | lines must contain(eachOf(exp1, exp2)) 95 | } 96 | 97 | def handlesConcurrentLoads = { 98 | val system = ActorSystem("filebufferconc") 99 | val logPath = new File("./test-work5/buffer.log") 100 | val buff = new FileBuffer(logPath) 101 | val lines = new ArrayBuffer[OutboundMessage] with SynchronizedBuffer[OutboundMessage] 102 | buff.open() 103 | val reader = system.scheduler.schedule(50 millis, 50 millis) { 104 | Await.ready(buff drain { out => 105 | Future { 106 | lines += out 107 | Success 108 | } 109 | }, 5 seconds) 110 | } 111 | (1 to 20000) foreach { s => 112 | buff.write(TextMessage("message %s" format s)) 113 | } 114 | reader.cancel() 115 | Await.ready(buff drain { out => 116 | Future { 117 | lines += out 118 | Success 119 | } 120 | }, 5 seconds) 121 | buff.close() 122 | FileUtils.deleteDirectory(new File("./test-work5")) 123 | system.shutdown() 124 | lines must haveSize(20000) 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /src/test/scala/io/backchat/hookup/tests/HookupClientSpec.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package tests 3 | 4 | import org.specs2.Specification 5 | import org.specs2.specification.{Around, AfterAll} 6 | import org.specs2.time.NoTimeConversions 7 | import org.json4s._ 8 | import org.specs2.execute.Result 9 | import org.specs2.execute.AsResult 10 | import java.net.{ServerSocket, URI} 11 | import akka.testkit._ 12 | import akka.actor.ActorSystem 13 | import scala.concurrent.duration._ 14 | import org.specs2.specification.core.Fragments 15 | import scala.concurrent.{ExecutionContext, Await} 16 | import scala.concurrent.forkjoin.ForkJoinPool 17 | import java.lang.Thread.UncaughtExceptionHandler 18 | import java.util.concurrent.{TimeUnit, TimeoutException} 19 | 20 | object HookupClientSpecification { 21 | 22 | def newExecutionContext() = ExecutionContext.fromExecutorService(new ForkJoinPool( 23 | Runtime.getRuntime.availableProcessors(), 24 | ForkJoinPool.defaultForkJoinWorkerThreadFactory, 25 | new UncaughtExceptionHandler { 26 | def uncaughtException(t: Thread, e: Throwable) { 27 | e.printStackTrace() 28 | } 29 | }, 30 | true)) 31 | 32 | def newServer(port: Int, defaultProtocol: String = "jsonProtocol"): HookupServer = { 33 | val executor = newExecutionContext() 34 | val serv = HookupServer( 35 | ServerInfo( 36 | name = "Test Echo Server", 37 | defaultProtocol = defaultProtocol, 38 | listenOn = "127.0.0.1", 39 | port = port, 40 | executionContext = executor)) { 41 | new HookupServerClient { 42 | def receive = { 43 | case TextMessage(text) ⇒ send(text) 44 | case JsonMessage(json) => send(json) 45 | } 46 | } 47 | } 48 | serv.onStop { 49 | executor.shutdown() 50 | executor.awaitTermination(5, TimeUnit.SECONDS) 51 | } 52 | serv 53 | } 54 | } 55 | 56 | trait HookupClientSpecification { 57 | 58 | 59 | val serverAddress = { 60 | val s = new ServerSocket(0); 61 | try { s.getLocalPort } finally { s.close() } 62 | } 63 | def server: Server 64 | 65 | type Handler = PartialFunction[(HookupClient, InboundMessage), Any] 66 | 67 | val uri = new URI("ws://127.0.0.1:"+serverAddress.toString+"/") 68 | val clientExecutor = HookupClientSpecification.newExecutionContext() 69 | val defaultClientConfig = HookupClientConfig( 70 | uri, 71 | defaultProtocol = new JsonProtocolWireFormat()(DefaultFormats), 72 | executionContext = clientExecutor) 73 | def withWebSocket[T <% Result](handler: Handler, config: HookupClientConfig = defaultClientConfig)(t: HookupClient => T) = { 74 | val client = new HookupClient { 75 | 76 | val settings = config 77 | def receive = { 78 | case m => handler.lift((this, m)) 79 | } 80 | } 81 | Await.ready(client.connect(), 5 seconds) 82 | try { t(client) } finally { 83 | try { 84 | Await.ready(client.disconnect(), 2 seconds) 85 | clientExecutor.shutdownNow() 86 | } catch { case e: Throwable => e.printStackTrace() } 87 | } 88 | } 89 | 90 | } 91 | 92 | class HookupClientSpec extends Specification with AfterAll { def is = 93 | "A WebSocketClient should" ^ 94 | "when configured with jsonProtocol" ^ 95 | "connect to a server" ! specify("jsonProtocol").connectsToServer ^ 96 | "exchange json messages with the server" ! specify("jsonProtocol").exchangesJsonMessages ^ bt ^ 97 | "when configured with simpleJsonProtocol" ^ 98 | "connect to a server" ! specify("simpleJson").connectsToServerSimpleJson ^ 99 | "exchange json messages with the server" ! specify("simpleJson").exchangesJsonMessagesSimpleJson ^ bt ^ 100 | "when client requests simpleJson and server is jsonProtocol" ^ 101 | "connect to a server" ! specify("jsonProtocol").connectsToServerSimpleJson ^ 102 | "exchange json messages with the server" ! specify("jsonProtocol").connectsToServerSimpleJson ^ bt ^ 103 | "when client requests jsonProtocol and server is simpleJson" ^ 104 | "connect to a server" ! specify("simpleJson").connectsToServer ^ 105 | "exchange json messages with the server" ! specify("simpleJson").exchangesJsonMessages ^ 106 | end 107 | 108 | implicit val system: ActorSystem = ActorSystem("HookupClientSpec") 109 | 110 | def stopActorSystem() = { 111 | system.shutdown() 112 | system.awaitTermination(5 seconds) 113 | } 114 | 115 | def afterAll() { stopActorSystem() } 116 | 117 | def specify(proto: String) = new ClientSpecContext(proto) 118 | 119 | class ClientSpecContext(defaultProtocol: String) extends HookupClientSpecification with Around { 120 | 121 | val server = HookupClientSpecification.newServer(serverAddress, defaultProtocol) 122 | 123 | def around[T: AsResult](t: =>T) = { 124 | server.start 125 | val r = AsResult(t) 126 | server.stop 127 | r 128 | } 129 | 130 | def connectsToServer = this { 131 | val latch = TestLatch() 132 | withWebSocket({ 133 | case (_, Connected) => latch.open() 134 | }) { _ => Await.result(latch, 5 seconds) must not(throwA[TimeoutException]) } 135 | } 136 | 137 | def exchangesJsonMessages = this { 138 | val latch = TestLatch() 139 | withWebSocket({ 140 | case (client, Connected) => client send JObject(JField("hello", JString("world")) :: Nil) 141 | case (client, JsonMessage(JObject(JField("hello", JString("world")) :: Nil))) => latch.open 142 | }) { _ => Await.result(latch, 5 seconds) must not(throwA[TimeoutException]) } 143 | } 144 | 145 | def connectsToServerSimpleJson = this { 146 | val latch = TestLatch() 147 | withWebSocket({ 148 | case (_, Connected) => latch.open() 149 | }, HookupClientConfig(uri)) { _ => Await.result(latch, 5 seconds) must not(throwA[TimeoutException]) } 150 | } 151 | 152 | def exchangesJsonMessagesSimpleJson = this { 153 | val latch = TestLatch() 154 | withWebSocket({ 155 | case (client, Connected) => client send JObject(JField("hello", JString("world")) :: Nil) 156 | case (client, JsonMessage(JObject(JField("hello", JString("world")) :: Nil))) => latch.open 157 | }, HookupClientConfig(uri)) { _ => Await.result(latch, 5 seconds) must not(throwA[TimeoutException]) } 158 | } 159 | 160 | def pendingSpec = pending 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/test/scala/io/backchat/hookup/tests/JsonProtocolWireFormatSpec.scala: -------------------------------------------------------------------------------- 1 | package io.backchat.hookup 2 | package tests 3 | 4 | import org.specs2.Specification 5 | import org.json4s._ 6 | import JsonDSL._ 7 | import scala.concurrent.duration._ 8 | import org.specs2.time.NoTimeConversions 9 | 10 | class JsonProtocolWireFormatSpec extends Specification { def is = 11 | "A JsonProtocolWireFormat should" ^ 12 | testWireFormat("text", textMessage, text, text) ^ 13 | testWireFormat("json", jsonMessage, json, json) ^ 14 | testWireFormat("json array", jsonArrayMessage, jsonArray, jsonArray) ^ 15 | testWireFormat("ack", ackMessage, ack, ack) ^ 16 | "parse an ack request message" ! { wf.parseInMessage(ackRequestMessage) must_== ackRequest } ^ 17 | "build a needs ack message" ! { wf.parseOutMessage(needsAckMessage) must_== needsAck } ^ 18 | "render a needs ack message" ! { wf.render(needsAck) must_== needsAckMessage } 19 | end 20 | 21 | val wf = new JsonProtocolWireFormat()(DefaultFormats) 22 | 23 | val textMessage = """{"type":"text","content":"this is a text message"}""" 24 | val text = TextMessage("this is a text message") 25 | 26 | val jsonMessage = """{"type":"json","content":{"data":"a json message"}}""" 27 | val json = JsonMessage(("data" -> "a json message")) 28 | 29 | val jsonArrayMessage = """{"type":"json","content":["data","a json message"]}""" 30 | val jsonArray = JsonMessage(List("data", "a json message")) 31 | 32 | val ackMessage = """{"type":"ack","id":3}""" 33 | val ack = Ack(3L) 34 | 35 | val ackRequestMessage = """{"type":"ack_request","id":3,"content":"this is a text message"}""" 36 | val ackRequest = AckRequest(text, 3) 37 | 38 | val needsAckMessage = """{"type":"needs_ack","timeout":5000,"content":{"type":"text","content":"this is a text message"}}""" 39 | val needsAck: OutboundMessage = NeedsAck(text, 5.seconds) 40 | 41 | def testWireFormat(name: String, serialized: String, in: InboundMessage, out: OutboundMessage) = 42 | "parse a %s message".format(name) ! { wf.parseInMessage(serialized) must_== in } ^ 43 | "build a %s message".format(name) ! { wf.parseOutMessage(serialized) must_== out } ^ 44 | "render a %s message".format(name) ! { wf.render(out) must_== serialized } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /work/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/backchatio/hookup/2913794eb45d90d65713c9fd631b427abcca2d05/work/.gitkeep --------------------------------------------------------------------------------